diff --git a/docs/AWARD-SYSTEM-SPECIFICATION.md b/docs/AWARD-SYSTEM-SPECIFICATION.md new file mode 100644 index 0000000..ba955ad --- /dev/null +++ b/docs/AWARD-SYSTEM-SPECIFICATION.md @@ -0,0 +1,1051 @@ +# Award System Specification + +## Overview + +This specification describes a JSON-driven award calculation system for amateur radio QSO (contact) tracking applications. The system calculates progress toward awards based on QSO data stored in a database. + +The award definitions are externalized as JSON files, allowing awards to be added, modified, or extended without changing application code. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ AWARD SYSTEM │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Award Definitions│ �───> │ Rule Processor │ ──> │ QSO Database │ │ +│ │ (JSON files) │ │ (Engine Core) │ │ │ │ +│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ +│ │ │ │ +│ │ │ │ +│ v v │ +│ ┌──────────────────┐ ┌──────────────┐ │ +│ │ Award Metadata │ │ QSO Records │ │ +│ │ - id, name │ │ - callsign │ │ +│ │ - description │ │ - qsoDate │ │ +│ │ - caption │ │ - band │ │ +│ │ - category │ │ - mode │ │ +│ │ - rules │ │ - entityId │ │ +│ └──────────────────┘ │ - state │ │ +│ │ - grid │ │ +│ │ - darcDok │ │ +│ │ - satName │ │ +│ │ - lotw... │ │ +│ │ - dclQsl... │ │ +│ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Award Definition JSON Schema + +### Root Object Structure + +Every award definition is a JSON object with the following structure: + +```json +{ + "id": "string (required, unique identifier)", + "name": "string (required, display name)", + "description": "string (required, short description)", + "caption": "string (required, detailed explanation)", + "category": "string (required, grouping category)", + "rules": { + "type": "string (required, rule type)", + "...": "rule-specific properties" + } +} +``` + +### Root Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `id` | string | Yes | Unique identifier for the award (e.g., "dxcc-mixed", "dld-80m") | +| `name` | string | Yes | Display name shown in UI (e.g., "DXCC Mixed Mode", "DLD 80m") | +| `description` | string | Yes | Short description for list views | +| `caption` | string | Yes | Detailed explanation of award requirements | +| `category` | string | Yes | Grouping category for UI organization (e.g., "dxcc", "darc", "vucc") | +| `rules` | object | Yes | Award calculation rules (see Rule Types below) | + +--- + +## Rule Types + +The system supports **5 rule types**. Each type defines a different calculation method. + +### 1. Entity Rule Type (`"type": "entity"`) + +Counts unique entities (DXCC countries, states, grid squares, callsigns) from QSOs. + +#### Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `type` | string | Yes | Must be `"entity"` | +| `entityType` | string | Yes | What to count: `"dxcc"`, `"state"`, `"grid"`, `"callsign"` | +| `target` | number | Yes | Number required to complete award | +| `displayField` | string | No | Field to display as entity name (defaults to entity value) | +| `filters` | object | No | Filter criteria (see Filters section) | + +#### Entity Type Mappings + +| `entityType` Value | Source Field | Notes | +|-------------------|--------------|-------| +| `"dxcc"` | `entityId` | Numeric DXCC entity ID | +| `"state"` | `state` | US state or other regional code | +| `"grid"` | `grid` | First 4 characters only (e.g., "FN31") | +| `"callsign"` | `callsign` | Station callsign | + +#### Calculation Logic + +``` +worked = count of unique entities where QSO exists +confirmed = count of unique entities where lotwQslRstatus === 'Y' +``` + +#### Example: DXCC Mixed Mode + +```json +{ + "id": "dxcc-mixed", + "name": "DXCC Mixed Mode", + "description": "Confirm 100 DXCC entities on any band/mode", + "caption": "Contact and confirm 100 different DXCC entities. Any band and mode combination counts.", + "category": "dxcc", + "rules": { + "type": "entity", + "entityType": "dxcc", + "target": 100, + "displayField": "entity" + } +} +``` + +#### Example: VUCC Satellite (with filter) + +```json +{ + "id": "vucc-satellite", + "name": "VUCC Satellite", + "description": "Confirm 100 unique grid squares via satellite", + "caption": "Contact and confirm 100 unique 4-character grid squares via satellite.", + "category": "vucc", + "rules": { + "type": "entity", + "entityType": "grid", + "target": 100, + "displayField": "grid", + "filters": { + "operator": "AND", + "filters": [ + { "field": "satellite", "operator": "eq", "value": true } + ] + } + } +} +``` + +--- + +### 2. DOK Rule Type (`"type": "dok"`) + +Counts unique DOK (DARC Ortsverband Kennung) combinations for German amateur radio awards. + +#### Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `type` | string | Yes | Must be `"dok"` | +| `target` | number | Yes | Number required to complete award | +| `confirmationType` | string | Yes | Must be `"dcl"` (DARC Community Logbook) | +| `displayField` | string | No | Field to display (typically `"darcDok"`) | +| `filters` | object | No | Filter criteria (band, mode, etc.) | + +#### Calculation Logic + +``` +For each QSO with darcDok: + combination = darcDok + "/" + band + "/" + mode + Track unique combinations + +worked = count of unique (DOK, band, mode) combinations +confirmed = count of combinations where dclQslRstatus === 'Y' +``` + +**Key Behavior**: Each unique DOK on each unique band/mode counts separately. + +#### Example: DLD (Deutschland Diplom) + +```json +{ + "id": "dld", + "name": "DLD", + "description": "Deutschland Diplom - Confirm 100 unique DOKs on different bands/modes", + "caption": "Contact and confirm stations with 100 unique DOKs on different band/mode combinations.", + "category": "darc", + "rules": { + "type": "dok", + "target": 100, + "confirmationType": "dcl", + "displayField": "darcDok" + } +} +``` + +#### Example: DLD 80m (with filter) + +```json +{ + "id": "dld-80m", + "name": "DLD 80m", + "description": "Confirm 100 unique DOKs on 80m", + "caption": "Contact and confirm 100 unique DOKs on the 80m band.", + "category": "darc", + "rules": { + "type": "dok", + "target": 100, + "confirmationType": "dcl", + "displayField": "darcDok", + "filters": { + "operator": "AND", + "filters": [ + { "field": "band", "operator": "eq", "value": "80m" } + ] + } + } +} +``` + +--- + +### 3. Points Rule Type (`"type": "points"`) + +Awards points for contacting specific stations, with three counting modes. + +#### Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `type` | string | Yes | Must be `"points"` | +| `target` | number | Yes | Points required to complete award | +| `countMode` | string | No | `"perStation"`, `"perBandMode"`, or `"perQso"` (default: `"perStation"`) | +| `stations` | array | Yes | Array of station definitions | + +#### Station Array Format + +```json +"stations": [ + { "callsign": "STATION1", "points": 10 }, + { "callsign": "STATION2", "points": 5 } +] +``` + +#### Count Modes + +| Mode | Description | +|------|-------------| +| `"perStation"` | Each unique station earns points once (if confirmed) | +| `"perBandMode"` | Each unique (callsign, band, mode) combination earns points | +| `"perQso"` | Every confirmed QSO earns points | + +#### Calculation Logic + +``` +For each QSO with callsign matching a station: + points = station.points for that callsign + + if countMode === "perStation": + Count unique callsigns (if confirmed) + else if countMode === "perBandMode": + Count unique (callsign, band, mode) combinations (if confirmed) + else if countMode === "perQso": + Count every confirmed QSO + +totalPoints = sum(points for confirmed entries) +``` + +#### Example: Wavelog Award (perBandMode) + +```json +{ + "id": "wavelog-award", + "name": "Wavelog Award", + "description": "Contact special stations on multiple bands and modes to earn points", + "caption": "Contact special stations to earn points. Points awarded for each unique band/mode combination.", + "category": "special", + "rules": { + "type": "points", + "target": 50, + "countMode": "perBandMode", + "stations": [ + { "callsign": "DF2ET", "points": 10 }, + { "callsign": "DJ7NT", "points": 10 }, + { "callsign": "HB9HIL", "points": 10 }, + { "callsign": "DB4SCW", "points": 5 } + ] + } +} +``` + +--- + +### 4. Filtered Rule Type (`"type": "filtered"`) + +Creates a filtered variant of another award by applying additional criteria. + +#### Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `type` | string | Yes | Must be `"filtered"` | +| `baseRule` | object | Yes | The base entity rule to filter | +| `filters` | object | Yes | Additional filters to apply | + +#### Base Rule Format + +```json +"baseRule": { + "type": "entity", + "entityType": "dxcc|state|grid|callsign", + "target": number, + "displayField": "string" +} +``` + +#### Example: DXCC CW + +```json +{ + "id": "dxcc-cw", + "name": "DXCC CW", + "description": "Confirm 100 DXCC entities using CW mode", + "caption": "Contact and confirm 100 different DXCC entities using CW mode only.", + "category": "dxcc", + "rules": { + "type": "filtered", + "baseRule": { + "type": "entity", + "entityType": "dxcc", + "target": 100, + "displayField": "entity" + }, + "filters": { + "operator": "AND", + "filters": [ + { "field": "mode", "operator": "eq", "value": "CW" } + ] + } + } +} +``` + +**Note**: At runtime, this is internally converted to an `entity` rule with the filters applied. + +--- + +### 5. Counter Rule Type (`"type": "counter"`) + +Counts QSOs or unique callsigns matching filter criteria. + +#### Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `type` | string | Yes | Must be `"counter"` | +| `target` | number | Yes | Number required to complete award | +| `countBy` | string | Yes | `"qso"` (QSOs) or `"callsign"` (unique callsigns) | +| `displayField` | string | No | Field to display | +| `filters` | object | No | Filter criteria | + +#### Example: RS-44 Satellite + +```json +{ + "id": "sat-rs44", + "name": "RS-44 Satellite", + "description": "Work 44 QSOs on satellite RS-44", + "caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts.", + "category": "custom", + "rules": { + "type": "counter", + "target": 44, + "countBy": "qso", + "displayField": "callsign", + "filters": { + "operator": "AND", + "filters": [ + { "field": "satName", "operator": "eq", "value": "RS-44" } + ] + } + } +} +``` + +**Note**: At runtime, counter rules are internally converted to `entity` rules with `entityType: "callsign"`. + +--- + +## Filters + +Filters allow awards to apply criteria based on QSO fields. + +### Filter Structure + +```json +"filters": { + "operator": "AND|OR", + "filters": [ + { "field": "fieldName", "operator": "operator", "value": "value" }, + ... + ] +} +``` + +### Filter Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `"eq"` | Equals | `{ "field": "band", "operator": "eq", "value": "20m" }` | +| `"ne"` | Not equals | `{ "field": "mode", "operator": "ne", "value": "FM" }` | +| `"in"` | In array | `{ "field": "band", "operator": "in", "value": ["20m", "40m"] }` | +| `"nin"` | Not in array | `{ "field": "mode", "operator": "nin", "value": ["FM", "AM"] }` | +| `"contains"` | Contains substring | `{ "field": "callsign", "operator": "contains", "value": "DL" }` | + +### Available Filter Fields + +Any QSO database field can be filtered: + +| Field | Type | Example | +|-------|------|---------| +| `band` | string | `"80m"`, `"20m"`, `"2m"` | +| `mode` | string | `"CW"`, `"SSB"`, `"FT8"` | +| `callsign` | string | `"DJ7NT"` | +| `entity` | string | `"Germany"` | +| `entityId` | number | `230` | +| `state` | string | `"CA"`, `"NY"` | +| `grid` | string | `"FN31pr"` | +| `satName` | string | `"AO-73"`, `"RS-44"` | +| `satellite` | boolean | `true` (has satellite name) | + +### Special Field: `satellite` + +The `satellite` field is a computed field: +- Returns `true` if QSO has a non-empty `satName` +- Returns `false` otherwise + +### Compound Filters + +Multiple filters can be combined with `AND` or `OR`: + +```json +"filters": { + "operator": "AND", + "filters": [ + { "field": "band", "operator": "eq", "value": "80m" }, + { "field": "mode", "operator": "eq", "value": "CW" } + ] +} +``` + +This creates: DLD 80m CW (only QSOs on 80m band AND CW mode count). + +--- + +## QSO Database Schema + +The award system relies on QSO records with specific fields. + +### Required QSO Fields + +| Field | Type | Description | +|-------|------|-------------| +| `userId` | number | User/owner identifier | +| `id` | number | Unique QSO identifier | +| `callsign` | string | Station callsign contacted | +| `qsoDate` | string | QSO date (YYYY-MM-DD) | +| `timeOn` | string | QSO time (HH:MM) | +| `band` | string | Amateur band (e.g., "20m", "80m") | +| `mode` | string | Mode (e.g., "CW", "SSB", "FT8") | + +### DXCC Fields + +| Field | Type | Description | +|-------|------|-------------| +| `entityId` | number | DXCC entity ID (numeric) | +| `entity` | string | DXCC entity name (e.g., "Germany") | + +### US State Fields + +| Field | Type | Description | +|-------|------|-------------| +| `state` | string | US state code (e.g., "CA", "NY") | + +### Grid Square Fields + +| Field | Type | Description | +|-------|------|-------------| +| `grid` | string | Maidenhead grid square (e.g., "FN31pr") | + +### DOK Fields (German Awards) + +| Field | Type | Description | +|-------|------|-------------| +| `darcDok` | string | DARC Ortsverband Kennung (e.g., "F03", "P30") | + +### Satellite Fields + +| Field | Type | Description | +|-------|------|-------------| +| `satName` | string | Satellite name (e.g., "AO-73", "RS-44") | + +### LoTW Confirmation Fields + +| Field | Type | Description | +|-------|------|-------------| +| `lotwQslRstatus` | string | LoTW QSL received status (`"Y"` = confirmed) | +| `lotwQslRdate` | string | LoTW QSL received date (YYYY-MM-DD) | + +### DCL Confirmation Fields + +| Field | Type | Description | +|-------|------|-------------| +| `dclQslRstatus` | string | DCL QSL received status (`"Y"` = confirmed) | +| `dclQslRdate` | string | DCL QSL received date (YYYY-MM-DD) | + +--- + +## Progress Calculation Result + +The award calculation returns a standardized result object: + +### Base Result Structure + +```typescript +{ + worked: number, // Count of worked entities + confirmed: number, // Count of confirmed entities + target: number, // Award target + percentage: number, // Progress percentage (0-100) + workedEntities: string[], // Array of worked entity identifiers + confirmedEntities: string[] // Array of confirmed entity identifiers +} +``` + +### Detail Result Structure (with `includeDetails: true`) + +```typescript +{ + // ... base fields ... + + award: { + id: string, + name: string, + description: string, + caption: string, + target: number + }, + + entities: [ + { + qsoId: number, // Reference QSO ID + entity: string, // Entity identifier + entityId: number, // DXCC entity ID (if applicable) + entityName: string, // Display name + worked: boolean, + confirmed: boolean, + qsoDate: string, + band: string, + mode: string, + callsign: string, + lotwQslRdate?: string, // Present if confirmed + dclQslRdate?: string, // Present if DCL confirmed + satName?: string, // For satellite awards + points?: number // For points-based awards + } + ], + + total: number, // Total entities + confirmed: number // Confirmed count +} +``` + +--- + +## Implementation Guide + +### Core Algorithm + +``` +1. Load award definition from JSON file +2. Query QSOs for user from database +3. Apply filters (if defined in rules) +4. Based on rule type: + - entity: Count unique entity values + - dok: Count unique (DOK, band, mode) combinations + - points: Sum points by countMode + - filtered: Apply filters to base entity rule + - counter: Count QSOs or callsigns +5. Return progress with worked/confirmed counts +``` + +### Rule Type Resolution + +``` +1. If rules.type === "filtered": + Convert to entity rule with baseRule + filters + +2. If rules.type === "counter": + Convert to entity rule with entityType = "callsign" + +3. If rules.type === "dok": + Use dedicated DOK calculation + +4. If rules.type === "points": + Use dedicated points calculation + +5. If rules.type === "entity": + Use standard entity calculation +``` + +### Filter Application + +``` +function applyFilters(qsos, filters) { + if filters.operator === "AND": + return qsos where ALL filter conditions match + else if filters.operator === "OR": + return qsos where ANY filter condition matches +} + +function matchesFilter(qso, filter) { + value = qso[filter.field] + + switch filter.operator: + case "eq": return value === filter.value + case "ne": return value !== filter.value + case "in": return filter.value.includes(value) + case "nin": return !filter.value.includes(value) + case "contains": return value.includes(filter.value) +} +``` + +### Entity Value Extraction + +``` +function getEntityValue(qso, entityType) { + switch entityType: + case "dxcc": return qso.entityId + case "state": return qso.state + case "grid": return qso.grid.substring(0, 4) // First 4 chars + case "callsign": return qso.callsign +} +``` + +--- + +## Confirmation Systems + +### LoTW (Logbook of The World) + +- **Confirmation field**: `lotwQslRstatus === 'Y'` +- **Date field**: `lotwQslRdate` +- Used for: DXCC, WAS, VUCC, most entity-based awards + +### DCL (DARC Community Logbook) + +- **Confirmation field**: `dclQslRstatus === 'Y'` +- **Date field**: `dclQslRdate` +- Required for: DLD award +- Provides: DOK fields (`darcDok`) + +--- + +## Award Variants + +Using filters, you can create award variants from a base award: + +| Base Award | Filter | Variant | +|------------|--------|---------| +| DLD | `band = "80m"` | DLD 80m | +| DLD | `mode = "CW"` | DLD CW | +| DLD | `band = "80m"` AND `mode = "CW"` | DLD 80m CW | +| DXCC | `mode = "CW"` | DXCC CW | +| DXCC | `band = "20m"` | DXCC 20m | +| DXCC | `satellite = true` | DXCC Satellite | + +--- + +## Example Awards + +### 1. DXCC Mixed Mode + +```json +{ + "id": "dxcc-mixed", + "name": "DXCC Mixed Mode", + "description": "Confirm 100 DXCC entities on any band/mode", + "caption": "Contact and confirm 100 different DXCC entities. Any band and mode combination counts.", + "category": "dxcc", + "rules": { + "type": "entity", + "entityType": "dxcc", + "target": 100, + "displayField": "entity" + } +} +``` + +### 2. WAS (Worked All States) + +```json +{ + "id": "was-mixed", + "name": "WAS Mixed Mode", + "description": "Confirm all 50 US states", + "caption": "Contact and confirm all 50 US states.", + "category": "was", + "rules": { + "type": "entity", + "entityType": "state", + "target": 50, + "displayField": "state", + "filters": { + "operator": "AND", + "filters": [ + { "field": "entityId", "operator": "eq", "value": 291 } + ] + } + } +} +``` + +### 3. VUCC Satellite + +```json +{ + "id": "vucc-satellite", + "name": "VUCC Satellite", + "description": "Confirm 100 unique grid squares via satellite", + "caption": "Contact and confirm 100 unique 4-character grid squares via satellite.", + "category": "vucc", + "rules": { + "type": "entity", + "entityType": "grid", + "target": 100, + "displayField": "grid", + "filters": { + "operator": "AND", + "filters": [ + { "field": "satellite", "operator": "eq", "value": true } + ] + } + } +} +``` + +### 4. 73 on 73 (Satellite) + +```json +{ + "id": "73-on-73", + "name": "73 on 73", + "description": "Confirm 73 unique QSO partners on satellite AO-73", + "caption": "Contact and confirm 73 different stations via the AO-73 satellite.", + "category": "satellite", + "rules": { + "type": "entity", + "entityType": "callsign", + "target": 73, + "displayField": "callsign", + "filters": { + "operator": "AND", + "filters": [ + { "field": "satName", "operator": "eq", "value": "AO-73" } + ] + } + } +} +``` + +### 5. DLD 80m CW (Combined Filters) + +```json +{ + "id": "dld-80m-cw", + "name": "DLD 80m CW", + "description": "Confirm 100 unique DOKs on 80m using CW", + "caption": "Contact and confirm 100 unique DOKs on 80m band using CW mode.", + "category": "darc", + "rules": { + "type": "dok", + "target": 100, + "confirmationType": "dcl", + "displayField": "darcDok", + "filters": { + "operator": "AND", + "filters": [ + { "field": "band", "operator": "eq", "value": "80m" }, + { "field": "mode", "operator": "eq", "value": "CW" } + ] + } + } +} +``` + +--- + +## Pseudocode Implementation + +### Main Calculation Function + +``` +function calculateAwardProgress(userId, award, includeDetails = false): + rules = normalizeRules(award.rules) + + // Get user's QSOs + qsos = database.getQSOs(userId) + + // Apply filters + if rules.filters: + qsos = applyFilters(qsos, rules.filters) + + // Calculate based on rule type + switch rules.type: + case "entity": + return calculateEntityProgress(qsos, rules, includeDetails) + case "dok": + return calculateDOKProgress(qsos, rules, includeDetails) + case "points": + return calculatePointsProgress(qsos, rules, includeDetails) +``` + +### Entity Progress Calculation + +``` +function calculateEntityProgress(qsos, rules, includeDetails): + workedEntities = new Set() + confirmedEntities = new Set() + entityDetails = [] + + for qso in qsos: + entity = getEntityValue(qso, rules.entityType) + if entity is null: continue + + workedEntities.add(entity) + + if qso.lotwQslRstatus === 'Y': + confirmedEntities.add(entity) + + if includeDetails and entity not in entityDetails: + entityDetails.push({ + qsoId: qso.id, + entity: entity, + entityName: getDisplayName(qso, rules.displayField), + worked: true, + confirmed: true, + qsoDate: qso.qsoDate, + band: qso.band, + mode: qso.mode, + callsign: qso.callsign + }) + + result = { + worked: workedEntities.size, + confirmed: confirmedEntities.size, + target: rules.target, + percentage: (confirmedEntities.size / rules.target) * 100 + } + + if includeDetails: + result.entities = entityDetails + result.total = entityDetails.length + + return result +``` + +### DOK Progress Calculation + +``` +function calculateDOKProgress(qsos, rules, includeDetails): + combinations = new Map() // Key: "DOK/band/mode" + + for qso in qsos: + dok = qso.darcDok + if dok is null: continue + + band = qso.band || "Unknown" + mode = qso.mode || "Unknown" + key = `${dok}/${band}/${mode}` + + if not combinations.has(key): + combinations.set(key, { + qsoId: qso.id, + entity: dok, + entityName: dok, + band: band, + mode: mode, + callsign: qso.callsign, + worked: false, + confirmed: false + }) + + detail = combinations.get(key) + detail.worked = true + + if qso.dclQslRstatus === 'Y': + detail.confirmed = true + detail.dclQslRdate = qso.dclQslRdate + + workedDOKs = new Set() + confirmedDOKs = new Set() + + for [key, detail] in combinations: + workedDOKs.add(detail.entity) + if detail.confirmed: + confirmedDOKs.add(detail.entity) + + result = { + worked: workedDOKs.size, + confirmed: confirmedDOKs.size, + target: rules.target, + percentage: (confirmedDOKs.size / rules.target) * 100 + } + + if includeDetails: + result.entities = Array.from(combinations.values()) + result.total = result.entities.length + + return result +``` + +### Points Progress Calculation + +``` +function calculatePointsProgress(qsos, rules, includeDetails): + stationPoints = new Map() + for station in rules.stations: + stationPoints.set(station.callsign.toUpperCase(), station.points) + + workedStations = new Set() + totalPoints = 0 + stationDetails = [] + + if rules.countMode === "perBandMode": + combinationMap = new Map() + + for qso in qsos: + callsign = qso.callsign.toUpperCase() + points = stationPoints.get(callsign) + if not points: continue + + band = qso.band || "Unknown" + mode = qso.mode || "Unknown" + key = `${callsign}/${band}/${mode}` + + if not combinationMap.has(key): + combinationMap.set(key, { + qsoId: qso.id, + callsign: callsign, + band: band, + mode: mode, + points: points, + worked: true, + confirmed: false + }) + + if qso.lotwQslRstatus === 'Y': + detail = combinationMap.get(key) + if not detail.confirmed: + detail.confirmed = true + + details = Array.from(combinationMap.values()) + totalPoints = details.filter(d => d.confirmed).reduce(sum, d.points) + + result = { + worked: workedStations.size, + totalPoints: totalPoints, + target: rules.target, + percentage: (totalPoints / rules.target) * 100 + } + + if includeDetails: + result.entities = details.map(formatEntity) + result.total = details.length + + return result +``` + +--- + +## Language Implementation Notes + +### Data Structure Recommendations + +1. **Use Sets for uniqueness**: When counting unique entities, use hash sets for O(1) lookups +2. **Use Maps for combinations**: When tracking (DOK, band, mode) combinations, use composite keys +3. **Normalize early**: Convert filtered/counter rules to entity rules at load time + +### Performance Considerations + +1. **Cache award definitions**: Load once at startup, not per calculation +2. **Database queries**: Apply filters at database level when possible (SQL WHERE clauses) +3. **Pagination**: For large QSO datasets, process in batches +4. **Index fields**: Ensure database indexes on userId, entityId, band, mode, callsign + +### Confirmation Field Handling + +```typescript +// Helper function for unified confirmation checking +function isConfirmed(qso): boolean { + return qso.lotwQslRstatus === 'Y' || qso.dclQslRstatus === 'Y' +} +``` + +--- + +## Extension Points + +### Adding New Rule Types + +To add a new rule type: + +1. Define the rule schema (properties, required fields) +2. Implement calculation function +3. Add case to main calculation switch +4. Add example award definition + +### Adding New Entity Types + +To add a new entity type: + +1. Add to `entityType` enum in entity rule +2. Add case to `getEntityValue()` function +3. Ensure QSO database has the field +4. Add display field mapping if needed + +### Adding New Filter Operators + +To add a new filter operator: + +1. Add operator name to filter schema +2. Implement comparison logic in `matchesFilter()` +3. Add documentation with examples + +--- + +## Summary + +The award system is a flexible, JSON-driven framework for calculating amateur radio award progress. Key design principles: + +1. **Externalized definitions**: Awards defined as JSON, not code +2. **Rule-based calculation**: Different rule types for different award patterns +3. **Filter-based variants**: Create awards variants without new rule types +4. **Standardized output**: Consistent progress format across all award types +5. **Confirmation system aware**: Supports LoTW and DCL separately + +This specification provides all information needed to implement the system in any programming language.