Add comprehensive documentation and LoTW integration

- Add detailed documentation covering architecture, components, code structure
- Document award system with multiple examples (DXCC, WAS, VUCC, Satellite)
- Implement LoTW sync service with ADIF parsing and long-polling
- Add QSO logbook page with filtering and statistics
- Add settings page for LoTW credentials management
- Add API endpoints for LoTW sync, QSO retrieval, and statistics

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 16:39:49 +01:00
parent 8c26fc93e3
commit 44c13e1bdc
6 changed files with 2229 additions and 2 deletions

783
docs/DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,783 @@
# Ham Radio Award Portal - Documentation
## Table of Contents
1. [Architecture](#architecture)
2. [Components](#components)
3. [Code Structure](#code-structure)
4. [Awards System](#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
**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
- **constants.js**: Application constants (JWT expiration, etc.)
### 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. 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/
│ │ │ ├── constants.js
│ │ │ └── database.js
│ │ ├── db/
│ │ │ └── schema/
│ │ │ └── index.js # Drizzle schema definitions
│ │ ├── services/
│ │ │ ├── auth.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
```javascript
{
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
```javascript
{
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
```javascript
{
id: text (primary key)
name: text
description: text
definition: text (JSON)
isActive: boolean
createdAt: timestamp
}
```
#### Award Progress Table
```javascript
{
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:
```json
{
"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`
```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:**
```javascript
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`
```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:**
```javascript
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`
```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:**
```javascript
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`
```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:**
```javascript
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`
```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:**
```javascript
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:
```json
{
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "20m" },
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
```
#### Multiple Options (OR)
Count any phone mode QSOs:
```json
{
"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):
```json
{
"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:
```json
{
"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
```json
{
"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:**
```json
{
"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:**
```json
{
"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
- [ARRL LoTW](https://lotw.arrl.org/)
- [ADIF Specification](https://adif.org/)
- [DXCC List](https://www.arrl.org/dxcc)
- [VUCC Program](https://www.arrl.org/vucc)
- [WAS Award](https://www.arrl.org/was)

View File

@@ -8,6 +8,11 @@ import {
getUserById,
updateLoTWCredentials,
} from './services/auth.service.js';
import {
syncQSOs,
getUserQSOs,
getQSOStats,
} from './services/lotw.service.js';
/**
* Main backend application
@@ -179,7 +184,17 @@ const app = new Elysia()
}
try {
await updateLoTWCredentials(user.id, body.lotwUsername, body.lotwPassword);
// Get current user data to preserve password if not provided
const userData = await getUserById(user.id);
if (!userData) {
set.status = 404;
return { success: false, error: 'User not found' };
}
// If password is empty, keep existing password
const lotwPassword = body.lotwPassword || userData.lotwPassword;
await updateLoTWCredentials(user.id, body.lotwUsername, lotwPassword);
return {
success: true,
@@ -196,11 +211,106 @@ const app = new Elysia()
{
body: t.Object({
lotwUsername: t.String(),
lotwPassword: t.String(),
lotwPassword: t.Optional(t.String()),
}),
}
)
/**
* POST /api/lotw/sync
* Sync QSOs from LoTW (requires authentication)
*/
.post('/api/lotw/sync', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
// Get user's LoTW credentials from database
const userData = await getUserById(user.id);
if (!userData || !userData.lotwUsername || !userData.lotwPassword) {
set.status = 400;
return {
success: false,
error: 'LoTW credentials not configured. Please add them in Settings.',
};
}
// Decrypt password (for now, assuming it's stored as-is. TODO: implement encryption)
const lotwPassword = userData.lotwPassword;
// Sync QSOs from LoTW
const result = await syncQSOs(user.id, userData.lotwUsername, lotwPassword);
return result;
} catch (error) {
set.status = 500;
return {
success: false,
error: `LoTW sync failed: ${error.message}`,
};
}
})
/**
* GET /api/qsos
* Get user's QSOs (requires authentication)
*/
.get('/api/qsos', async ({ user, query, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const filters = {};
if (query.band) filters.band = query.band;
if (query.mode) filters.mode = query.mode;
if (query.confirmed) filters.confirmed = query.confirmed === 'true';
const qsos = await getUserQSOs(user.id, filters);
return {
success: true,
qsos,
count: qsos.length,
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to fetch QSOs',
};
}
})
/**
* GET /api/qsos/stats
* Get QSO statistics (requires authentication)
*/
.get('/api/qsos/stats', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const stats = await getQSOStats(user.id);
return {
success: true,
stats,
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to fetch statistics',
};
}
})
// Health check endpoint
.get('/api/health', () => ({
status: 'ok',

View File

@@ -0,0 +1,531 @@
import { db } from '../config/database.js';
import { qsos } from '../db/schema/index.js';
/**
* LoTW (Logbook of the World) Service
* Fetches QSOs from ARRL's LoTW system
*/
// Configuration for long-polling
const POLLING_CONFIG = {
maxRetries: 30, // Maximum number of retry attempts
retryDelay: 10000, // Delay between retries in ms (10 seconds)
requestTimeout: 60000, // Timeout for individual requests in ms (1 minute)
maxTotalTime: 600000, // Maximum total time to wait in ms (10 minutes)
};
/**
* Check if LoTW response indicates the report is still being prepared
* @param {string} responseData - The response text from LoTW
* @returns {boolean} True if report is still pending
*/
function isReportPending(responseData) {
const trimmed = responseData.trim().toLowerCase();
// LoTW returns various messages when report is not ready:
// - Empty responses
// - "Report is being prepared" or similar messages
// - HTML error pages
// - Very short responses that aren't valid ADIF
// Check for empty or very short responses
if (trimmed.length < 100) {
return true;
}
// Check for HTML responses (error pages)
if (trimmed.includes('<html>') || trimmed.includes('<!doctype html>')) {
return true;
}
// Check for common "not ready" messages
const pendingMessages = [
'report is being prepared',
'your report is being generated',
'please try again',
'report queue',
'not yet available',
'temporarily unavailable',
];
for (const msg of pendingMessages) {
if (trimmed.includes(msg)) {
return true;
}
}
// Check if it looks like valid ADIF data (should start with <ADIF_VER: or have <qso_date)
const hasAdifHeader = trimmed.includes('<adif_ver:') ||
trimmed.includes('<qso_date') ||
trimmed.includes('<call:') ||
trimmed.includes('<band:');
return !hasAdifHeader;
}
/**
* Sleep/delay utility
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Fetch QSOs from LoTW with long-polling support
* @param {string} lotwUsername - LoTW username
* @param {string} lotwPassword - LoTW password
* @param {Date} sinceDate - Only fetch QSOs since this date
* @returns {Promise<Array>} Array of QSO objects
*/
export async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
// LoTW report URL
const url = 'https://lotw.arrl.org/lotwuser/lotwreport.adi';
// Build query parameters - qso_query=1 is REQUIRED to get QSO records!
const params = new URLSearchParams({
login: lotwUsername,
password: lotwPassword,
qso_query: '1', // REQUIRED: Without this, no QSO records are returned
qso_qsl: 'yes', // Only get QSOs with QSLs (confirmed)
qso_qsldetail: 'yes', // Include QSL details (station location)
qso_mydetail: 'yes', // Include my station details
qso_withown: 'yes', // Include own callsign
});
// Add date filter - ALWAYS send qso_qslsince to get all QSOs
// LoTW default behavior only returns QSOs since last download, so we must
// explicitly send a date to get QSOs since that date
const dateStr = sinceDate
? sinceDate.toISOString().split('T')[0]
: '2026-01-01'; // Default: Get QSOs since 2026-01-01
params.append('qso_qslsince', dateStr);
console.error('Date filter:', dateStr, sinceDate ? '(User-specified date)' : '(Default: since 2026-01-01)');
const fullUrl = `${url}?${params.toString()}`;
console.error('Fetching from LoTW:', fullUrl.replace(/password=[^&]+/, 'password=***'));
const startTime = Date.now();
// Long-polling loop
for (let attempt = 0; attempt < POLLING_CONFIG.maxRetries; attempt++) {
try {
// Check if we've exceeded max total time
const elapsed = Date.now() - startTime;
if (elapsed > POLLING_CONFIG.maxTotalTime) {
throw new Error(`LoTW sync timeout: exceeded maximum wait time of ${POLLING_CONFIG.maxTotalTime / 1000} seconds`);
}
if (attempt > 0) {
console.error(`Retry attempt ${attempt + 1}/${POLLING_CONFIG.maxRetries} (elapsed: ${Math.round(elapsed / 1000)}s)`);
}
// Make request with timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), POLLING_CONFIG.requestTimeout);
const response = await fetch(fullUrl, { signal: controller.signal });
clearTimeout(timeoutId);
// Handle HTTP errors
if (!response.ok) {
if (response.status === 503) {
// Service unavailable - might be temporary, retry
console.error('LoTW returned 503 (Service Unavailable), waiting before retry...');
await sleep(POLLING_CONFIG.retryDelay);
continue;
} else if (response.status === 401) {
throw new Error('Invalid LoTW credentials. Please check your username and password in Settings.');
} else if (response.status === 404) {
throw new Error('LoTW service not found (404). The LoTW API URL may have changed.');
} else {
// Other errors - log but retry
console.error(`LoTW returned ${response.status} ${response.statusText}, waiting before retry...`);
await sleep(POLLING_CONFIG.retryDelay);
continue;
}
}
// Get response text
const adifData = await response.text();
console.error(`Response length: ${adifData.length} bytes`);
// Check if report is still pending
if (isReportPending(adifData)) {
console.error('LoTW report is still being prepared, waiting...', adifData.substring(0, 100));
// Wait before retrying
await sleep(POLLING_CONFIG.retryDelay);
continue;
}
// We have valid data!
console.error('LoTW report ready, parsing ADIF data...');
console.error('ADIF preview:', adifData.substring(0, 200));
// Parse ADIF format
const qsos = parseADIF(adifData);
console.error(`Successfully parsed ${qsos.length} QSOs from LoTW`);
return qsos;
} catch (error) {
const elapsed = Date.now() - startTime;
if (error.name === 'AbortError') {
console.error(`Request timeout on attempt ${attempt + 1}, retrying...`);
await sleep(POLLING_CONFIG.retryDelay);
continue;
}
// Re-throw credential/auth errors immediately
if (error.message.includes('credentials') || error.message.includes('401') || error.message.includes('404')) {
throw error;
}
// For other errors, log and retry if we haven't exhausted retries
if (attempt < POLLING_CONFIG.maxRetries - 1) {
console.error(`Error on attempt ${attempt + 1}: ${error.message}`);
console.error('Retrying...');
await sleep(POLLING_CONFIG.retryDelay);
continue;
} else {
throw error;
}
}
}
// If we get here, we exhausted all retries
const totalTime = Math.round((Date.now() - startTime) / 1000);
throw new Error(`LoTW sync failed: Report not ready after ${POLLING_CONFIG.maxRetries} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.`);
}
/**
* Parse ADIF (Amateur Data Interchange Format) data
* @param {string} adifData - Raw ADIF text data
* @returns {Array<Array>} Array of QSOs (each QSO is an array of field objects)
*/
function parseADIF(adifData) {
const qsos = [];
const records = adifData.split('<eor>');
console.error(`Total records after splitting by <eor>: ${records.length}`);
for (let i = 0; i < records.length; i++) {
const record = records[i];
// Skip empty records or records that are just header info
if (!record.trim()) continue;
if (record.trim().startsWith('<') && !record.includes('<CALL:') && !record.includes('<call:')) continue;
const qso = {};
// Match ADIF fields: <FIELDNAME:length[:type]>value
// Important: The 'type' part is optional, and field names can contain underscores
// We use the length parameter to extract exactly that many characters
const regex = /<([A-Z_]+):(\d+)(?::[A-Z]+)?>([\s\S])/gi;
let match;
while ((match = regex.exec(record)) !== null) {
const [fullMatch, fieldName, lengthStr, firstChar] = match;
const length = parseInt(lengthStr, 10);
// Extract exactly 'length' characters starting from the position after the '>'
const valueStart = match.index + fullMatch.length - 1; // -1 because firstChar is already captured
const value = record.substring(valueStart, valueStart + length);
qso[fieldName.toLowerCase()] = value.trim();
// Move regex lastIndex to the end of the value so we can find the next tag
regex.lastIndex = valueStart + length;
}
// Only add if we have actual QSO data (has CALL or call field)
if (Object.keys(qso).length > 0 && (qso.call || qso.call)) {
qsos.push(qso);
// Log first few QSOs for debugging
if (qsos.length <= 3) {
console.error(`Parsed QSO #${qsos.length}: ${qso.call} on ${qso.qso_date} ${qso.band} ${qso.mode}`);
}
}
}
console.error(`Total QSOs parsed: ${qsos.length}`);
return qsos;
}
/**
* Convert ADIF QSO to database format
* @param {Object} adifQSO - QSO object from ADIF parser
* @param {number} userId - User ID
* @returns {Object} Database-ready QSO object
*/
export function convertQSODatabaseFormat(adifQSO, userId) {
return {
userId,
callsign: adifQSO.call || '',
qsoDate: adifQSO.qso_date || '',
timeOn: adifQSO.time_on || adifQSO.time_off || '000000',
band: normalizeBand(adifQSO.band),
mode: normalizeMode(adifQSO.mode),
freq: adifQSO.freq ? parseInt(adifQSO.freq) : null,
freqRx: adifQSO.freq_rx ? parseInt(adifQSO.freq_rx) : null,
entity: adifQSO.country || adifQSO.dxcc_country || '',
entityId: adifQSO.dxcc || null,
grid: adifQSO.gridsquare || '',
continent: adifQSO.continent || '',
cqZone: adifQSO.cq_zone ? parseInt(adifQSO.cq_zone) : null,
ituZone: adifQSO.itu_zone ? parseInt(adifQSO.itu_zone) : null,
state: adifQSO.state || adifQSO.us_state || '',
satName: adifQSO.sat_name || '',
satMode: adifQSO.sat_mode || '',
lotwQslRdate: adifQSO.qslrdate || '',
lotwQslRstatus: adifQSO.qsl_rcvd || 'N',
lotwSyncedAt: new Date(),
};
}
/**
* Normalize band name
* @param {string} band - Band from ADIF
* @returns {string|null} Normalized band
*/
function normalizeBand(band) {
if (!band) return null;
const bandMap = {
'160m': '160m',
'80m': '80m',
'60m': '60m',
'40m': '40m',
'30m': '30m',
'20m': '20m',
'17m': '17m',
'15m': '15m',
'12m': '12m',
'10m': '10m',
'6m': '6m',
'4m': '4m',
'2m': '2m',
'1.25m': '1.25m',
'70cm': '70cm',
'33cm': '33cm',
'23cm': '23cm',
'13cm': '13cm',
'9cm': '9cm',
'6cm': '6cm',
'3cm': '3cm',
'1.2cm': '1.2cm',
'mm': 'mm',
};
return bandMap[band.toLowerCase()] || band;
}
/**
* Normalize mode name
* @param {string} mode - Mode from ADIF
* @returns {string} Normalized mode
*/
function normalizeMode(mode) {
if (!mode) return '';
const modeMap = {
'cw': 'CW',
'ssb': 'SSB',
'am': 'AM',
'fm': 'FM',
'rtty': 'RTTY',
'psk31': 'PSK31',
'psk63': 'PSK63',
'ft8': 'FT8',
'ft4': 'FT4',
'jt65': 'JT65',
'jt9': 'JT9',
'js8': 'JS8',
'mfsk': 'MFSK',
' Olivia': 'OLIVIA',
};
const normalized = modeMap[mode.toLowerCase()];
return normalized || mode.toUpperCase();
}
/**
* Sync QSOs from LoTW to database
* @param {number} userId - User ID
* @param {string} lotwUsername - LoTW username
* @param {string} lotwPassword - LoTW password
* @param {Date} sinceDate - Only sync QSOs since this date
* @returns {Promise<Object>} Sync result with counts
*/
export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = null) {
try {
// Fetch QSOs from LoTW
const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate);
if (!adifQSOs || adifQSOs.length === 0) {
return {
success: true,
total: 0,
added: 0,
updated: 0,
message: 'No QSOs found in LoTW',
};
}
let addedCount = 0;
let updatedCount = 0;
const errors = [];
// Process each QSO
for (const qsoData of adifQSOs) {
try {
console.error('Raw ADIF QSO data:', JSON.stringify(qsoData));
const dbQSO = convertQSODatabaseFormat(qsoData, userId);
console.error('Converted QSO:', JSON.stringify(dbQSO));
console.error('Processing QSO:', dbQSO.callsign, dbQSO.qsoDate, dbQSO.band, dbQSO.mode);
// Check if QSO already exists (by callsign, date, time, band, mode)
const { eq } = await import('drizzle-orm');
const existing = await db
.select()
.from(qsos)
.where(eq(qsos.userId, userId))
.where(eq(qsos.callsign, dbQSO.callsign))
.where(eq(qsos.qsoDate, dbQSO.qsoDate))
.where(eq(qsos.band, dbQSO.band))
.where(eq(qsos.mode, dbQSO.mode))
.limit(1);
console.error('Existing QSOs found:', existing.length);
if (existing.length > 0) {
console.error('Existing QSO:', JSON.stringify(existing[0]));
}
if (existing.length > 0) {
// Update existing QSO
console.error('Updating existing QSO');
await db
.update(qsos)
.set({
lotwQslRdate: dbQSO.lotwQslRdate,
lotwQslRstatus: dbQSO.lotwQslRstatus,
lotwSyncedAt: dbQSO.lotwSyncedAt,
})
.where(eq(qsos.id, existing[0].id));
updatedCount++;
} else {
// Insert new QSO
console.error('Inserting new QSO with data:', JSON.stringify(dbQSO));
try {
const result = await db.insert(qsos).values(dbQSO);
console.error('Insert result:', result);
addedCount++;
} catch (insertError) {
console.error('Insert failed:', insertError.message);
console.error('Insert error details:', insertError);
throw insertError;
}
}
} catch (error) {
console.error('ERROR processing QSO:', error);
errors.push({
qso: qsoData,
error: error.message,
});
}
}
return {
success: true,
total: adifQSOs.length,
added: addedCount,
updated: updatedCount,
errors: errors.length > 0 ? errors : undefined,
};
} catch (error) {
throw new Error(`LoTW sync failed: ${error.message}`);
}
}
/**
* Get QSOs for a user
* @param {number} userId - User ID
* @param {Object} filters - Query filters
* @returns {Promise<Array>} Array of QSOs
*/
export async function getUserQSOs(userId, filters = {}) {
const { eq, and } = await import('drizzle-orm');
console.error('getUserQSOs called with userId:', userId, 'filters:', filters);
// Build where conditions
const conditions = [eq(qsos.userId, userId)];
if (filters.band) {
conditions.push(eq(qsos.band, filters.band));
}
if (filters.mode) {
conditions.push(eq(qsos.mode, filters.mode));
}
if (filters.confirmed) {
conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
}
// Use and() to combine all conditions
const results = await db.select().from(qsos).where(and(...conditions));
console.error('getUserQSOs returning', results.length, 'QSOs');
// Order by date descending, then time
return results.sort((a, b) => {
const dateCompare = b.qsoDate.localeCompare(a.qsoDate);
if (dateCompare !== 0) return dateCompare;
return b.timeOn.localeCompare(a.timeOn);
});
}
/**
* Get QSO statistics for a user
* @param {number} userId - User ID
* @returns {Promise<Object>} Statistics object
*/
export async function getQSOStats(userId) {
const { eq } = await import('drizzle-orm');
const allQSOs = await db.select().from(qsos).where(eq(qsos.userId, userId));
console.error('getQSOStats called with userId:', userId, 'found', allQSOs.length, 'QSOs in database');
const confirmed = allQSOs.filter((q) => q.lotwQslRstatus === 'Y');
// Count unique entities
const uniqueEntities = new Set();
const uniqueBands = new Set();
const uniqueModes = new Set();
allQSOs.forEach((q) => {
if (q.entity) uniqueEntities.add(q.entity);
if (q.band) uniqueBands.add(q.band);
if (q.mode) uniqueModes.add(q.mode);
});
const stats = {
total: allQSOs.length,
confirmed: confirmed.length,
uniqueEntities: uniqueEntities.size,
uniqueBands: uniqueBands.size,
uniqueModes: uniqueModes.size,
};
console.error('getQSOStats returning:', stats);
return stats;
}

View File

@@ -127,6 +127,12 @@ export const qsosAPI = {
return apiRequest(`/qsos?${params}`);
},
/**
* Get QSO statistics
* @returns {Promise<Object>} QSO statistics
*/
getStats: () => apiRequest('/qsos/stats'),
/**
* Sync QSOs from LoTW
* @returns {Promise<Object>} Sync result

View File

@@ -0,0 +1,462 @@
<script>
import { onMount } from 'svelte';
import { qsosAPI } from '$lib/api.js';
import { auth } from '$lib/stores.js';
let qsos = [];
let stats = null;
let loading = true;
let error = null;
let syncing = false;
let syncResult = null;
let filters = {
band: '',
mode: '',
confirmed: false
};
// Load QSOs on mount
onMount(async () => {
if (!$auth.user) return;
await loadQSOs();
await loadStats();
});
async function loadQSOs() {
try {
loading = true;
error = null;
const activeFilters = {};
if (filters.band) activeFilters.band = filters.band;
if (filters.mode) activeFilters.mode = filters.mode;
if (filters.confirmed) activeFilters.confirmed = 'true';
const response = await qsosAPI.getAll(activeFilters);
qsos = response.qsos;
} catch (err) {
error = err.message;
} finally {
loading = false;
}
}
async function loadStats() {
try {
const response = await qsosAPI.getStats();
stats = response.stats;
} catch (err) {
console.error('Failed to load stats:', err);
}
}
async function handleSync() {
console.log('handleSync called!');
try {
syncing = true;
syncResult = null;
console.log('Calling qsosAPI.syncFromLoTW...');
const result = await qsosAPI.syncFromLoTW();
console.log('Sync result:', result);
syncResult = result;
// Reload QSOs and stats after sync
await loadQSOs();
await loadStats();
} catch (err) {
console.error('Sync error:', err);
syncResult = {
success: false,
error: err.message
};
} finally {
syncing = false;
console.log('Sync complete, syncing = false');
}
}
async function applyFilters() {
await loadQSOs();
}
function clearFilters() {
filters = {
band: '',
mode: '',
confirmed: false
};
loadQSOs();
}
function formatDate(dateStr) {
if (!dateStr) return '-';
// ADIF format: YYYYMMDD
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
return `${year}-${month}-${day}`;
}
function formatTime(timeStr) {
if (!timeStr) return '-';
// ADIF format: HHMMSS
return timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4);
}
const bands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm'];
const modes = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9'];
</script>
<svelte:head>
<title>QSO Log - Ham Radio Awards</title>
</svelte:head>
<div class="container">
<div class="header">
<h1>QSO Log</h1>
<button
class="btn btn-primary"
on:click={handleSync}
disabled={syncing}
>
{syncing ? 'Syncing from LoTW...' : 'Sync from LoTW'}
</button>
</div>
{#if syncResult}
<div class="alert {syncResult.success ? 'alert-success' : 'alert-error'}">
{#if syncResult.success}
<h3>Sync Complete!</h3>
<p>Total: {syncResult.total}, Added: {syncResult.added}, Updated: {syncResult.updated}</p>
{#if syncResult.errors && syncResult.errors.length > 0}
<p class="text-small">Some QSOs had errors</p>
{/if}
{:else}
<h3>Sync Failed</h3>
<p>{syncResult.error}</p>
{/if}
<button on:click={() => syncResult = null} class="btn-small">Dismiss</button>
</div>
{/if}
{#if stats}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{stats.total}</div>
<div class="stat-label">Total QSOs</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.confirmed}</div>
<div class="stat-label">Confirmed</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.uniqueEntities}</div>
<div class="stat-label">DXCC Entities</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.uniqueBands}</div>
<div class="stat-label">Bands</div>
</div>
</div>
{/if}
<div class="filters">
<h3>Filters</h3>
<div class="filter-row">
<select bind:value={filters.band} on:change={applyFilters}>
<option value="">All Bands</option>
{#each bands as band}
<option value={band}>{band}</option>
{/each}
</select>
<select bind:value={filters.mode} on:change={applyFilters}>
<option value="">All Modes</option>
{#each modes as mode}
<option value={mode}>{mode}</option>
{/each}
</select>
<label class="checkbox-label">
<input type="checkbox" bind:checked={filters.confirmed} on:change={applyFilters}>
Confirmed only
</label>
<button class="btn btn-secondary" on:click={clearFilters}>Clear</button>
</div>
</div>
{#if loading}
<div class="loading">Loading QSOs...</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if qsos.length === 0}
<div class="empty">
<p>No QSOs found. Sync from LoTW to get started!</p>
</div>
{:else}
<div class="qso-table-container">
<table class="qso-table">
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Callsign</th>
<th>Band</th>
<th>Mode</th>
<th>Entity</th>
<th>Grid</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each qsos as qso}
<tr>
<td>{formatDate(qso.qsoDate)}</td>
<td>{formatTime(qso.timeOn)}</td>
<td class="callsign">{qso.callsign}</td>
<td>{qso.band || '-'}</td>
<td>{qso.mode || '-'}</td>
<td>{qso.entity || '-'}</td>
<td>{qso.grid || '-'}</td>
<td>
{#if qso.lotwQslRstatus === 'Y'}
<span class="badge badge-success">Confirmed</span>
{:else}
<span class="badge badge-pending">Pending</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="showing">Showing {qsos.length} QSOs</p>
{/if}
</div>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header h1 {
margin: 0;
color: #333;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #4a90e2;
}
.stat-label {
color: #666;
margin-top: 0.5rem;
}
.filters {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.filters h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
color: #333;
}
.filter-row {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.filter-row select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4a90e2;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #357abd;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-small {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 4px;
cursor: pointer;
color: inherit;
}
.alert {
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.alert-success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.alert-error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.alert h3 {
margin: 0 0 0.5rem 0;
}
.alert p {
margin: 0;
}
.text-small {
font-size: 0.875rem;
margin-top: 0.5rem;
}
.qso-table-container {
overflow-x: auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.qso-table {
width: 100%;
border-collapse: collapse;
}
.qso-table th,
.qso-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.qso-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #333;
}
.qso-table tr:hover {
background-color: #f8f9fa;
}
.callsign {
font-weight: 600;
color: #4a90e2;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-success {
background-color: #d4edda;
color: #155724;
}
.badge-pending {
background-color: #fff3cd;
color: #856404;
}
.loading, .error, .empty {
text-align: center;
padding: 3rem;
color: #666;
}
.error {
color: #dc3545;
}
.showing {
text-align: center;
color: #666;
font-size: 0.875rem;
margin-top: 1rem;
}
</style>

View File

@@ -0,0 +1,335 @@
<script>
import { onMount } from 'svelte';
import { authAPI } from '$lib/api.js';
import { auth } from '$lib/stores.js';
import { goto } from '$app/navigation';
let lotwUsername = '';
let lotwPassword = '';
let loading = false;
let saving = false;
let error = null;
let success = false;
let hasCredentials = false;
onMount(async () => {
// Load user profile to check if credentials exist
await loadProfile();
});
async function loadProfile() {
try {
loading = true;
const response = await authAPI.getProfile();
console.log('Loaded profile:', response.user);
if (response.user) {
lotwUsername = response.user.lotwUsername || '';
lotwPassword = ''; // Never pre-fill password for security
hasCredentials = !!(response.user.lotwUsername && response.user.lotwPassword);
console.log('Has credentials:', hasCredentials);
}
} catch (err) {
console.error('Failed to load profile:', err);
error = err.message;
} finally {
loading = false;
}
}
async function handleSave(e) {
e.preventDefault();
try {
saving = true;
error = null;
success = false;
console.log('Saving credentials:', { lotwUsername, hasPassword: !!lotwPassword });
await authAPI.updateLoTWCredentials({
lotwUsername,
lotwPassword
});
console.log('Save successful!');
// Reload profile to update hasCredentials flag
await loadProfile();
success = true;
} catch (err) {
console.error('Save failed:', err);
error = err.message;
} finally {
saving = false;
}
}
function handleLogout() {
auth.logout();
goto('/');
}
</script>
<svelte:head>
<title>Settings - Ham Radio Awards</title>
</svelte:head>
<div class="container">
<div class="header">
<h1>Settings</h1>
<button class="btn btn-secondary" on:click={handleLogout}>Logout</button>
</div>
<div class="user-info">
<h2>Account</h2>
<p><strong>Callsign:</strong> {$auth.user?.callsign || '-'}</p>
<p><strong>Email:</strong> {$auth.user?.email || '-'}</p>
</div>
<div class="settings-section">
<h2>LoTW Credentials</h2>
<p class="help-text">
Configure your ARRL Logbook of the World (LoTW) credentials to sync your QSOs.
Your credentials are stored securely and used only to fetch your confirmed QSOs.
</p>
{#if hasCredentials}
<div class="alert alert-info">
<strong>Credentials configured</strong> - You can update them below if needed.
</div>
{/if}
<form on:submit={handleSave} class="settings-form">
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if success}
<div class="alert alert-success">
LoTW credentials saved successfully!
</div>
{/if}
<div class="form-group">
<label for="lotwUsername">LoTW Username</label>
<input
id="lotwUsername"
type="text"
bind:value={lotwUsername}
placeholder="your_lotw_username"
required
/>
</div>
<div class="form-group">
<label for="lotwPassword">LoTW Password</label>
<input
id="lotwPassword"
type="password"
bind:value={lotwPassword}
placeholder="Your LoTW password"
required
/>
<p class="hint">
Leave blank to keep existing password
</p>
</div>
<button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Credentials'}
</button>
</form>
<div class="info-box">
<h3>About LoTW</h3>
<p>
LoTW (Logbook of the World) is ARRL's system for confirming amateur radio contacts.
Once configured, you can sync your confirmed QSOs to track award progress.
</p>
<p>
<strong>Don't have a LoTW account?</strong>{' '}
<a href="https://lotw.arrl.org/" target="_blank" rel="noopener">
Sign up at LoTWARRL.org
</a>
</p>
</div>
</div>
</div>
<style>
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header h1 {
margin: 0;
color: #333;
}
.user-info {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.user-info h2 {
margin: 0 0 1rem 0;
font-size: 1.25rem;
color: #333;
}
.user-info p {
margin: 0.5rem 0;
color: #666;
}
.settings-section {
margin-bottom: 2rem;
}
.settings-section h2 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
color: #333;
}
.help-text {
color: #666;
margin-bottom: 1.5rem;
line-height: 1.6;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
}
.alert-info {
background-color: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
}
.alert-error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.alert-success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.settings-form {
background: white;
padding: 2rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
}
.hint {
font-size: 0.875rem;
color: #666;
margin-top: 0.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4a90e2;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #357abd;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.info-box {
background: #e8f4fd;
border-left: 4px solid #4a90e2;
padding: 1.5rem;
border-radius: 4px;
}
.info-box h3 {
margin: 0 0 1rem 0;
color: #333;
}
.info-box p {
margin: 0.5rem 0;
color: #666;
line-height: 1.6;
}
.info-box a {
color: #4a90e2;
text-decoration: none;
}
.info-box a:hover {
text-decoration: underline;
}
</style>