Files
award/docs/AWARD-SYSTEM-SPECIFICATION.md
Joerg b9b6afedb8 docs: add modeGroups feature to award system specification
Document the modeGroups property for award definitions, which allows
creating convenient multi-mode filters in the award detail view.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 07:27:29 +01:00

32 KiB
Raw Blame History

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│ <20>───> │  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:

{
  "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)
modeGroups object No Mode group definitions for award detail view filtering (see Mode Groups 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

{
  "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)

{
  "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)

{
  "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)

{
  "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

"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)

{
  "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

"baseRule": {
  "type": "entity",
  "entityType": "dxcc|state|grid|callsign",
  "target": number,
  "displayField": "string"
}

Example: DXCC CW

{
  "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

{
  "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

"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:

"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).


Mode Groups

Mode groups allow awards to define convenient multi-mode filters for the award detail view. This feature enables users to filter by multiple modes at once (e.g., all digital modes combined).

Structure

{
  "modeGroups": {
    "Group Display Name": ["mode1", "mode2", "mode3"],
    "Another Group": ["modeA", "modeB"]
  }
}

Properties

Property Type Required Description
Key string Yes Display name shown in mode filter dropdown
Value array Yes Array of mode strings to include in the group

Behavior

  • Location: Defined at award root level (not in rules)
  • Optional: Awards without modeGroups work as before (backward compatible)
  • UI Integration: Creates entries in mode filter dropdown with visual separator
  • Filtering: Selecting a mode group filter shows QSOs matching ANY mode in the group

Example: DXCC with Mode Groups

{
  "id": "dxcc",
  "name": "DXCC",
  "description": "Confirm 100 DXCC entities on HF bands",
  "caption": "Contact and confirm 100 different DXCC entities on HF bands (160m-10m).",
  "category": "dxcc",
  "modeGroups": {
    "Digi-Modes": ["FT8", "FT4", "MFSK", "PSK31", "RTTY", "JT65", "JT9"],
    "Classic Digi-Modes": ["PSK31", "RTTY", "JT65", "JT9"],
    "Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
    "Phone-Modes": ["AM", "SSB", "FM"]
  },
  "rules": {
    "type": "entity",
    "entityType": "dxcc",
    "target": 100,
    "allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]
  }
}

UI Display

In the award detail view, the mode filter dropdown shows:

  • Mixed Mode (default - aggregates all modes by band)
  • ───── (visual separator)
  • Digi-Modes
  • Classic Digi-Modes
  • Mixed-Mode w/o WSJT-Modes
  • Phone-Modes
  • ───── (visual separator)
  • CW (individual modes)
  • FT8
  • SSB
  • etc.

Common Mode Group Patterns

Group Name Modes Use Case
Digi-Modes FT8, FT4, MFSK, PSK31, RTTY, JT65, JT9 All digital modes
Classic Digi-Modes PSK31, RTTY, JT65, JT9 Pre-WSJT digital modes
Phone-Modes AM, SSB, FM All voice modes
Mixed-Mode w/o WSJT PSK31, RTTY, AM, SSB, FM, CW Traditional mixed mode award

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

{
  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)

{
  // ... 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

{
  "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)

{
  "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

{
  "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)

{
  "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)

{
  "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

// 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.