Files
award/docs/DOCUMENTATION.md
Joerg d959235cdd Add structured logging, navigation bar, and code cleanup
## Backend
- Add Pino logging framework with timestamps and structured output
- Replace all console.error statements (49+) with proper logging levels
- Fix Drizzle ORM bug: replace invalid .get() calls with .limit(1)
- Remove unused auth routes file (already in index.js)
- Make internal functions private (remove unnecessary exports)
- Simplify code by removing excessive debug logging

## Frontend
- Add navigation bar to layout with:
  - User's callsign display
  - Navigation links (Dashboard, QSOs, Settings)
  - Logout button with red color distinction
- Navigation only shows when user is logged in
- Dark themed design matching footer

## Documentation
- Update README.md with new project structure
- Update docs/DOCUMENTATION.md with logging and nav bar info
- Add logger.js to configuration section

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 08:11:57 +01:00

20 KiB

Ham Radio Award Portal - Documentation

Table of Contents

  1. Architecture
  2. Components
  3. Code Structure
  4. Awards System

Architecture

Overview

The Ham Radio Award Portal is a full-stack web application designed to help amateur radio operators track their award progress by syncing QSOs (contacts) from ARRL's Logbook of the World (LoTW).

Technology Stack

Backend:

  • Runtime: Bun - Fast JavaScript runtime
  • Framework: Elysia.js - Lightweight, high-performance web framework
  • Database: SQLite - Embedded database for data persistence
  • ORM: Drizzle ORM - Type-safe database queries
  • Authentication: JWT tokens via @elysiajs/jwt
  • Password Hashing: bcrypt
  • Logging: Pino - Structured logging with timestamps and log levels

Frontend:

  • Framework: SvelteKit - Modern reactive framework
  • Bundler: Vite - Fast development server and build tool
  • Language: JavaScript with TypeScript type definitions
  • State Management: Svelte stores
  • Styling: CSS modules

System Architecture

┌─────────────────┐      HTTP/REST      ┌─────────────────┐
│                 │ ◄──────────────────► │                 │
│   SvelteKit     │                      │   ElysiaJS      │
│   Frontend      │                      │   Backend       │
│                 │                      │   Server        │
└─────────────────┘                      └────────┬────────┘
                                                  │
                                                  ▼
                                         ┌─────────────────┐
                                         │   SQLite DB     │
                                         │   (Drizzle ORM) │
                                         └─────────────────┘
                                                  ▲
                                                  │
                                         ┌────────┴────────┐
                                         │   ARRL LoTW     │
                                         │   External API  │
                                         └─────────────────┘

Key Design Decisions

  1. SQLite over PostgreSQL/MySQL: Simplified deployment, embedded database, excellent for single-user or small-scale deployments
  2. Bun over Node.js: Faster startup, better performance, native TypeScript support
  3. ElysiaJS over Express: Better TypeScript support, faster performance, modern API design
  4. SvelteKit over React: Smaller bundle sizes, better performance, simpler reactivity model

Components

Backend Components

1. Server (src/backend/index.js)

Main entry point that configures and starts the ElysiaJS server.

Key Features:

  • CORS configuration for cross-origin requests
  • JWT authentication plugin
  • Route handlers for API endpoints
  • Error handling middleware

Routes:

  • GET /api/health - Health check endpoint
  • POST /api/auth/register - User registration
  • POST /api/auth/login - User login
  • GET /api/auth/me - Get current user
  • PUT /api/auth/lotw-credentials - Update LoTW credentials
  • POST /api/lotw/sync - Sync QSOs from LoTW
  • GET /api/qsos - Get QSOs with filtering
  • GET /api/qsos/stats - Get QSO statistics

2. Database Schema (src/backend/db/schema/index.js)

Defines the database structure using Drizzle ORM schema builder.

Tables:

  • users: User accounts, authentication credentials, LoTW credentials
  • qsos: Amateur radio contacts in ADIF format with LoTW confirmation data
  • awards: Award definitions with JSON rule configurations
  • award_progress: Cached award progress for each user

3. Services

Auth Service (src/backend/services/auth.service.js)

  • User registration and login
  • Password hashing with bcrypt
  • JWT token generation and validation
  • User profile management

LoTW Service (src/backend/services/lotw.service.js)

  • Synchronization with ARRL's Logbook of the World
  • ADIF format parsing
  • Long-polling for report generation
  • QSO deduplication
  • Band/mode normalization
  • Error handling and retry logic

4. Configuration (src/backend/config/)

  • database.js: Database connection and client initialization
  • jwt.js: JWT secret configuration
  • logger.js: Pino logger configuration with structured logging and timestamps

Frontend Components

1. Pages (SvelteKit Routes)

  • /: Dashboard with welcome message
  • /auth/login: User login form
  • /auth/register: User registration form
  • /qsos: QSO logbook with filtering and LoTW sync
  • /settings: LoTW credentials management

2. Layout (+layout.svelte)

Global layout component providing:

  • Navigation bar: Shows user's callsign, navigation links (Dashboard, QSOs, Settings), and logout button
  • Only visible when user is logged in
  • Responsive design with dark theme matching footer

3. Libraries

API Client (src/frontend/src/lib/api.js)

  • Centralized API communication
  • Automatic JWT token injection
  • Response/error handling
  • Typed API methods

Stores (src/frontend/src/lib/stores.js)

  • Authentication state management
  • Persistent login with localStorage
  • Reactive user data

Types (src/frontend/src/lib/types/)

  • TypeScript type definitions
  • Award system types
  • API response types

Code Structure

Directory Layout

award/
├── award-definitions/          # Award rule definitions (JSON)
│   ├── dxcc.json              # DXCC award configurations
│   ├── dxcc-cw.json           # DXCC CW-specific award
│   ├── was.json               # WAS award configurations
│   ├── vucc-sat.json          # VUCC Satellite award
│   └── sat-rs44.json          # Satellite RS-44 award
│
├── drizzle/                   # Database migrations
│   └── 0000_init.sql         # Initial schema
│
├── docs/                      # Documentation
│   └── DOCUMENTATION.md      # This file
│
├── src/
│   ├── backend/              # Backend server code
│   │   ├── config/
│   │   │   ├── database.js    # Database connection
│   │   │   ├── jwt.js         # JWT configuration
│   │   │   └── logger.js      # Pino logging configuration
│   │   ├── db/
│   │   │   └── schema/
│   │   │       └── index.js   # Drizzle schema definitions
│   │   ├── services/
│   │   │   ├── auth.service.js
│   │   │   ├── job-queue.service.js
│   │   │   └── lotw.service.js
│   │   └── index.js           # Main server entry point
│   │
│   ├── frontend/             # Frontend application
│   │   ├── src/
│   │   │   ├── lib/
│   │   │   │   ├── api.js     # API client
│   │   │   │   ├── stores.js  # Svelte stores
│   │   │   │   └── types/     # TypeScript definitions
│   │   │   └── routes/        # SvelteKit pages
│   │   │       ├── +page.svelte
│   │   │       ├── auth/
│   │   │       ├── qsos/
│   │   │       └── settings/
│   │   ├── static/            # Static assets
│   │   └── vite.config.js
│   │
│   └── shared/               # Shared TypeScript types
│       └── index.ts
│
├── award.db                  # SQLite database file
├── drizzle.config.ts         # Drizzle configuration
├── package.json              # Dependencies and scripts
├── CLAUDE.md                 # Claude AI instructions
└── README.md                 # Project overview

File Organization Principles

  1. Separation of Concerns: Clear separation between backend and frontend
  2. Shared Types: Common types in src/shared/ to ensure consistency
  3. Service Layer: Business logic isolated in service files
  4. Configuration: Config files co-located in dedicated directories
  5. Database-First: Schema drives the application structure

Database Schema Details

Users Table

{
  id: integer (primary key, auto-increment)
  email: text (unique, not null)
  passwordHash: text (not null)
  callsign: text (not null)
  lotwUsername: text (nullable)
  lotwPassword: text (nullable, encrypted)
  createdAt: timestamp
  updatedAt: timestamp
}

QSOs Table

{
  id: integer (primary key, auto-increment)
  userId: integer (foreign key  users.id)
  callsign: text
  qsoDate: text (ADIF format: YYYYMMDD)
  timeOn: text (HHMMSS)
  band: text (160m, 80m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm)
  mode: text (CW, SSB, FM, AM, FT8, FT4, PSK31, etc.)
  freq: integer (Hz)
  freqRx: integer (Hz, satellite only)
  entity: text (DXCC entity name)
  entityId: integer (DXCC entity number)
  grid: text (Maidenhead grid square)
  gridSource: text (LOTW, USER, CALC)
  continent: text (NA, SA, EU, AF, AS, OC, AN)
  cqZone: integer
  ituZone: integer
  state: text (US state, CA province, etc.)
  county: text
  satName: text (satellite name)
  satMode: text (satellite mode)
  lotwQslRdate: text (LoTW confirmation date)
  lotwQslRstatus: text ('Y', 'N', '?')
  lotwSyncedAt: timestamp
  createdAt: timestamp
}

Awards Table

{
  id: text (primary key)
  name: text
  description: text
  definition: text (JSON)
  isActive: boolean
  createdAt: timestamp
}

Award Progress Table

{
  id: integer (primary key, auto-increment)
  userId: integer (foreign key  users.id)
  awardId: text (foreign key  awards.id)
  workedCount: integer
  confirmedCount: integer
  totalRequired: integer
  workedEntities: text (JSON array)
  confirmedEntities: text (JSON array)
  lastCalculatedAt: timestamp
  lastQsoSyncAt: timestamp
  updatedAt: timestamp
}

Awards System

Overview

The awards system is designed to be flexible and extensible. Awards are defined as JSON configuration files that specify rules for calculating progress based on QSOs.

Award Definition Structure

Each award is defined with the following structure:

{
  "id": "unique-identifier",
  "name": "Award Display Name",
  "description": "Award description",
  "category": "award-category",
  "rules": {
    "type": "entity",
    "entityType": "dxcc|state|grid|...",
    "target": 100,
    "filters": {
      "operator": "AND|OR",
      "filters": [...]
    }
  }
}

Entity Types

Entity Type Description Example Field
dxcc DXCC entities (countries) entityId
state US States, Canadian provinces state
grid Maidenhead grid squares grid

Filter Operators

Operator Description
eq Equal to
ne Not equal to
in In list
nin Not in list
contains Contains substring
AND All filters must match
OR At least one filter must match

Award Categories

  • dxcc: DXCC (Countries/Districts) awards
  • was: Worked All States awards
  • vucc: VHF/UHF Century Club awards
  • satellite: Satellite-specific awards

Award Examples

1. DXCC Mixed Mode

File: award-definitions/dxcc.json

{
  "id": "dxcc-mixed",
  "name": "DXCC Mixed Mode",
  "description": "Confirm 100 DXCC entities on any band/mode",
  "category": "dxcc",
  "rules": {
    "type": "entity",
    "entityType": "dxcc",
    "target": 100
  }
}

Explanation:

  • Counts unique DXCC entities (entityId field)
  • No filtering - all QSOs count toward progress
  • Target: 100 confirmed entities
  • Both worked (any QSO) and confirmed (LoTW QSL) are tracked

Progress Calculation:

worked = COUNT(DISTINCT entityId WHERE lotwQslRstatus IS NOT NULL)
confirmed = COUNT(DISTINCT entityId WHERE lotwQslRstatus = 'Y')

2. DXCC CW (CW-specific)

File: award-definitions/dxcc-cw.json

{
  "id": "dxcc-cw",
  "name": "DXCC CW",
  "description": "Confirm 100 DXCC entities using CW mode only",
  "category": "dxcc",
  "rules": {
    "type": "entity",
    "entityType": "dxcc",
    "target": 100,
    "filters": {
      "operator": "AND",
      "filters": [
        {
          "field": "mode",
          "operator": "eq",
          "value": "CW"
        }
      ]
    }
  }
}

Explanation:

  • Counts unique DXCC entities
  • Only QSOs with mode = 'CW' are counted
  • Target: 100 confirmed entities
  • Example: A CW QSO with Germany (entityId=230) counts, but SSB does not

Progress Calculation:

worked = COUNT(DISTINCT entityId
         WHERE mode = 'CW' AND lotwQslRstatus IS NOT NULL)
confirmed = COUNT(DISTINCT entityId
           WHERE mode = 'CW' AND lotwQslRstatus = 'Y')

3. WAS Mixed Mode

File: award-definitions/was.json

{
  "id": "was-mixed",
  "name": "WAS Mixed Mode",
  "description": "Confirm all 50 US states",
  "category": "was",
  "rules": {
    "type": "entity",
    "entityType": "state",
    "target": 50,
    "filters": {
      "operator": "AND",
      "filters": [
        {
          "field": "entity",
          "operator": "eq",
          "value": "United States"
        }
      ]
    }
  }
}

Explanation:

  • Counts unique US states
  • Only QSOs where entity = 'United States'
  • Target: 50 confirmed states
  • Both worked and confirmed are tracked separately

Progress Calculation:

worked = COUNT(DISTINCT state
         WHERE entity = 'United States' AND lotwQslRstatus IS NOT NULL)
confirmed = COUNT(DISTINCT state
           WHERE entity = 'United States' AND lotwQslRstatus = 'Y')

Example States:

  • "CA" - California
  • "TX" - Texas
  • "NY" - New York
  • (All 50 US states)

4. VUCC Satellite

File: award-definitions/vucc-sat.json

{
  "id": "vucc-satellite",
  "name": "VUCC Satellite",
  "description": "Confirm 100 unique grid squares via satellite",
  "category": "vucc",
  "rules": {
    "type": "entity",
    "entityType": "grid",
    "target": 100,
    "filters": {
      "operator": "AND",
      "filters": [
        {
          "field": "satellite",
          "operator": "eq",
          "value": true
        }
      ]
    }
  }
}

Explanation:

  • Counts unique grid squares (4-character: FN31, CM97, etc.)
  • Only satellite QSOs (where satName is not null)
  • Target: 100 confirmed grids
  • Grid squares are counted independently of band/mode

Progress Calculation:

worked = COUNT(DISTINCT SUBSTRING(grid, 1, 4)
         WHERE satName IS NOT NULL AND lotwQslRstatus IS NOT NULL)
confirmed = COUNT(DISTINCT SUBSTRING(grid, 1, 4)
           WHERE satName IS NOT NULL AND lotwQslRstatus = 'Y')

Example Grids:

  • "FN31pr" → counts as "FN31"
  • "CM97" → counts as "CM97"

5. Satellite RS-44 (Specific Satellite)

File: award-definitions/sat-rs44.json

{
  "id": "sat-rs44",
  "name": "RS-44 Satellite Award",
  "description": "Make 100 QSOs via RS-44 satellite",
  "category": "satellite",
  "rules": {
    "type": "entity",
    "entityType": "callsign",
    "target": 100,
    "filters": {
      "operator": "AND",
      "filters": [
        {
          "field": "satName",
          "operator": "eq",
          "value": "RS-44"
        }
      ]
    }
  }
}

Explanation:

  • Counts unique callsigns worked via RS-44 satellite
  • Only QSOs where satName = 'RS-44'
  • Target: 100 unique callsigns
  • Each station (callsign) counts once regardless of multiple QSOs

Progress Calculation:

worked = COUNT(DISTINCT callsign
         WHERE satName = 'RS-44' AND lotwQslRstatus IS NOT NULL)
confirmed = COUNT(DISTINCT callsign
           WHERE satName = 'RS-44' AND lotwQslRstatus = 'Y')

Advanced Filter Examples

Multiple Conditions (AND)

Count only 20m CW QSOs:

{
  "filters": {
    "operator": "AND",
    "filters": [
      { "field": "band", "operator": "eq", "value": "20m" },
      { "field": "mode", "operator": "eq", "value": "CW" }
    ]
  }
}

Multiple Options (OR)

Count any phone mode QSOs:

{
  "filters": {
    "operator": "OR",
    "filters": [
      { "field": "mode", "operator": "eq", "value": "SSB" },
      { "field": "mode", "operator": "eq", "value": "FM" },
      { "field": "mode", "operator": "eq", "value": "AM" }
    ]
  }
}

Band Ranges

Count HF QSOs (all HF bands):

{
  "filters": {
    "operator": "OR",
    "filters": [
      { "field": "band", "operator": "eq", "value": "160m" },
      { "field": "band", "operator": "eq", "value": "80m" },
      { "field": "band", "operator": "eq", "value": "60m" },
      { "field": "band", "operator": "eq", "value": "40m" },
      { "field": "band", "operator": "eq", "value": "30m" },
      { "field": "band", "operator": "eq", "value": "20m" },
      { "field": "band", "operator": "eq", "value": "17m" },
      { "field": "band", "operator": "eq", "value": "15m" },
      { "field": "band", "operator": "eq", "value": "12m" },
      { "field": "band", "operator": "eq", "value": "10m" }
    ]
  }
}

Grid Square by Continent

Count VUCC grids in Europe only:

{
  "filters": {
    "operator": "AND",
    "filters": [
      { "field": "continent", "operator": "eq", "value": "EU" },
      {
        "filters": [
          { "field": "band", "operator": "eq", "value": "2m" },
          { "field": "band", "operator": "eq", "value": "70cm" }
        ],
        "operator": "OR"
      }
    ]
  }
}

Creating Custom Awards

To create a new award:

  1. Create a new JSON file in award-definitions/
  2. Define the award structure following the schema above
  3. Add to database (via API or database migration)

Example: IOTA Award

{
  "id": "iota-mixed",
  "name": "IOTA Mixed Mode",
  "description": "Confirm 100 Islands on the Air",
  "category": "iota",
  "rules": {
    "type": "entity",
    "entityType": "iota",
    "target": 100,
    "filters": {
      "operator": "AND",
      "filters": [
        { "field": "iotaNumber", "operator": "ne", "value": null }
      ]
    }
  }
}

Note: This would require adding an iotaNumber field to the QSO table.


Award Progress API

Get User Award Progress

GET /api/awards/progress

Response:

{
  "userId": 1,
  "awards": [
    {
      "id": "dxcc-mixed",
      "name": "DXCC Mixed Mode",
      "worked": 87,
      "confirmed": 73,
      "totalRequired": 100,
      "percentage": 73
    }
  ]
}

Get Detailed Award Breakdown

GET /api/awards/progress/:awardId

Response:

{
  "awardId": "dxcc-mixed",
  "workedEntities": [
    { "entity": "Germany", "entityId": 230, "worked": true, "confirmed": true },
    { "entity": "Japan", "entityId": 339, "worked": true, "confirmed": false }
  ],
  "confirmedEntities": [
    { "entity": "Germany", "entityId": 230 }
  ]
}

Future Enhancements

The award system is designed for extensibility. Planned enhancements:

  1. Additional Entity Types:

    • IOTA (Islands on the Air)
    • CQ Zones
    • ITU Zones
    • Counties
  2. Advanced Filters:

    • Date ranges
    • Regular expressions
    • Custom field queries
  3. Award Endorsements:

    • Band-specific endorsements
    • Mode-specific endorsements
    • Combined endorsements
  4. Award Tiers:

    • Bronze, Silver, Gold levels
    • Progressive achievements
  5. User-Defined Awards:

    • Allow users to create custom awards
    • Share award definitions with community

Contributing

When adding new awards or modifying the award system:

  1. Follow the JSON schema for award definitions
  2. Add tests for new filter types
  3. Update this documentation with examples
  4. Ensure database schema supports required fields
  5. Test progress calculations thoroughly

Resources