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

1132 lines
32 KiB
Markdown
Raw Permalink 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:
```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) |
| `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
```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).
---
## 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
```json
{
"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
```json
{
"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
```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.