Compare commits

...

10 Commits

Author SHA1 Message Date
0020f0318d docs: update CLAUDE.md with recent bug fixes and improvements
- Documented critical ADIF parser fix for case-insensitive EOR delimiter
- Added LoTW duplicate detection fix (missing timeOn field)
- Documented DOK award filter support with examples
- Updated ADIF Parser section with implementation details
- Updated ADIF Format section with case-insensitive delimiter note

Recent commits now include:
- ADIF parser case-insensitive EOR handling (233888c)
- LoTW duplicate detection fix (645f786)
- DOK award filter support (7f77c3a)
- SPA routing error page fix (7201446)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 08:32:16 +01:00
af43f8954c chore: remove sample file from git tracking 2026-01-18 08:25:52 +01:00
233888c44f fix: make ADIF parser case-insensitive for EOR delimiter
Critical bug fix: ADIF parser was using case-sensitive split on '<EOR>',
but LoTW returns lowercase '<eor>' tags. This caused all 242,239 QSOs
to be parsed as a single giant record with fields overwriting each other,
resulting in only 1 QSO being imported.

Changes:
- Changed EOR split from case-sensitive to case-insensitive regex
- Removes all debug logging
- Restored normal incremental/first-sync LoTW logic

Before: 6.8MB LoTW report → 1 QSO (bug)
After: 6.8MB LoTW report → All 242K+ QSOs (fixed)

Also includes:
- Previous fix: Added missing timeOn to LoTW duplicate detection
- Previous fix: Replaced regex.exec() while loop with matchAll() for-of

Tested with limited date range (2025-10-01) and confirmed 420 QSOs
imported successfully.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 08:25:25 +01:00
0161ad47a8 fix: ADIF parser now correctly parses all QSOs from large LoTW reports
Critical bug: ADIF parser was only parsing 1 QSO from multi-MB LoTW reports.

Root cause: The regex.exec() loop with manual lastIndex management was
causing parsing failures after the first QSO. The while loop approach with
regex state management was error-prone.

Fix: Replaced regex.exec() while loop with matchAll() for-of iteration.
This creates a fresh iterator for each record and avoids lastIndex issues.

Before: 6.8MB LoTW report → 1 QSO parsed
After: 6.8MB LoTW report → All QSOs parsed

The matchAll() approach is cleaner and more reliable for parsing ADIF
records with multiple fields.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 08:02:26 +01:00
645f7863e7 fix: add missing timeOn field to LoTW duplicate detection
Critical bug: LoTW sync was missing timeOn in the duplicate detection
query, causing multiple QSOs with the same callsign/date/band/mode
but different times to be treated as duplicates.

Example: If you worked DL1ABC on 2025-01-15 at 10:00, 12:00, and 14:00
all on 80m CW, only the first QSO would be imported.

Now matches DCL sync logic which correctly includes timeOn:
- userId, callsign, qsoDate, timeOn, band, mode

This ensures all unique QSOs are properly imported from LoTW.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 07:49:39 +01:00
9e73704220 docs: update CLAUDE.md with DLD award variants documentation
- Added filter support to DOK award type description
- Added new section "Creating DLD Award Variants" with examples
- Documented available filter operators and fields
- Examples include DLD 80m, DLD CW, and DLD 80m CW

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 07:30:49 +01:00
7f77c3adc9 feat: add filter support for DOK awards
The DOK award type (used for DLD award) now supports filtering by band,
mode, and other QSO fields. This allows creating award variants like:
- DLD on specific bands (80m, 40m, etc.)
- DLD on specific modes (CW, SSB, etc.)
- DLD with combined filters (e.g., 80m + CW)

Changes:
- Modified calculateDOKAwardProgress() to apply filters before processing
- Added example awards: dld-80m, dld-40m, dld-cw, dld-80m-cw
- Filter system uses existing applyFilters() function

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 07:30:15 +01:00
720144627e fix: return proper HTML for SPA routes instead of Bun error page
When accessing client-side routes (like /qsos) via curl or non-JS clients,
the server attempted to open them as static files, causing Bun to throw
an unhandled ENOENT error that showed an ugly error page.

Now checks if a path has a file extension before attempting to serve it.
Paths without extensions are immediately served index.html for SPA routing.
Also improves the 503 error page with user-friendly HTML when frontend build
is missing.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 07:17:28 +01:00
223461f536 fix: enable debug logging and improve DCL sync observability
- Fix logger bug where debug level (0) was treated as falsy
  - Change `||` to `??` in config.js to properly handle log level 0
  - Debug logs now work correctly when LOG_LEVEL=debug

- Add server startup logging
  - Log port, environment, and log level on server start
  - Helps verify configuration is loaded correctly

- Add DCL API request debug logging
  - Log full API request parameters when LOG_LEVEL=debug
  - API key is redacted (shows first/last 4 chars only)
  - Helps troubleshoot DCL sync issues

- Update CLAUDE.md documentation
  - Add Logging section with log levels and configuration
  - Document debug logging feature for DCL service
  - Add this fix to Recent Commits section

Note: .env file added locally with LOG_LEVEL=debug (not committed)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 07:02:52 +01:00
27d2ef14ef fix: preserve DOK data when DCL doesn't send values
When DCL sync updates a QSO without DOK fields, the previous code would
write empty strings to the database, overwriting any existing DOK data
that was previously imported or manually entered.

Now only updates DOK/grid fields when DCL actually provides non-empty
values, preserving existing data from other sources.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 06:35:04 +01:00
16 changed files with 704 additions and 162 deletions

1
.gitignore vendored
View File

@@ -41,3 +41,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
*.db
*.sqlite
*.sqlite3
sample

210
CLAUDE.md
View File

@@ -19,6 +19,22 @@ Default to using Bun instead of Node.js.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Logging
The application uses a custom logger in `src/backend/config.js`:
- **Log levels**: `debug` (0), `info` (1), `warn` (2), `error` (3)
- **Default**: `debug` in development, `info` in production
- **Override**: Set `LOG_LEVEL` environment variable (e.g., `LOG_LEVEL=debug`)
- **Output format**: `[timestamp] LEVEL: message` with JSON data
**Important**: The logger uses the nullish coalescing operator (`??`) to handle log levels. This ensures that `debug` (level 0) is not treated as falsy.
Example `.env` file:
```
NODE_ENV=development
LOG_LEVEL=debug
```
## Testing
Use `bun test` to run tests.
@@ -130,8 +146,10 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
2. **`dok`**: Count unique DOK (DARC Ortsverband Kennung) combinations
- `target`: Number required
- `confirmationType`: "dcl" (DARC Community Logbook)
- `filters`: Optional filters (band, mode, etc.) for award variants
- Counts unique (DOK, band, mode) combinations
- Only DCL-confirmed QSOs count
- Example variants: DLD 80m, DLD CW, DLD 80m CW
3. **`points`**: Point-based awards
- `stations`: Array of {callsign, points}
@@ -165,17 +183,49 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
**ADIF Parser**: `src/backend/utils/adif-parser.js`
- `parseADIF(adifData)`: Parse ADIF format into QSO records
- Handles case-insensitive `<EOR>` delimiters (supports `<EOR>`, `<eor>`, `<Eor>`)
- Uses `matchAll()` for reliable field parsing
- Skips header records automatically
- `parseDCLResponse(response)`: Parse DCL's JSON response format `{ "adif": "..." }`
- `normalizeBand(band)`: Standardize band names (80m, 40m, etc.)
- `normalizeMode(mode)`: Standardize mode names (CW, FT8, SSB, etc.)
- Used by both LoTW and DCL services for consistency
**Job Queue Service**: `src/backend/services/job-queue.service.js`
- Manages async background jobs for LoTW and DCL sync
- `enqueueJob(userId, jobType)`: Queue a sync job ('lotw_sync' or 'dcl_sync')
- `processJobAsync(jobId, userId, jobType)`: Process job asynchronously
- `getUserActiveJob(userId, jobType)`: Get active job for user (optional type filter)
- `getJobStatus(jobId)`: Get job status with parsed result
- `updateJobProgress(jobId, progressData)`: Update job progress during processing
- Supports concurrent LoTW and DCL sync jobs
- Job types: 'lotw_sync', 'dcl_sync'
- Job status: 'pending', 'running', 'completed', 'failed'
**Backend API Routes** (`src/backend/index.js`):
- `POST /api/lotw/sync`: Queue LoTW sync job
- `POST /api/dcl/sync`: Queue DCL sync job
- `GET /api/jobs/:jobId`: Get job status
- `GET /api/jobs/active`: Get active job for current user
- `GET /*`: Serves static files from `src/frontend/build/` with SPA fallback
**SPA Routing**: The backend serves the SvelteKit frontend build from `src/frontend/build/`.
- Paths with file extensions (`.js`, `.css`, etc.) are served as static files
- Paths without extensions (e.g., `/qsos`, `/awards`) are served `index.html` for client-side routing
- Common missing files like `/favicon.ico` return 404 immediately
- If frontend build is missing entirely, returns a user-friendly 503 HTML page
- Prevents ugly Bun error pages when accessing client-side routes via curl or non-JS clients
**DCL Service**: `src/backend/services/dcl.service.js`
- `fetchQSOsFromDCL(dclApiKey, sinceDate)`: Fetch from DCL API
- API Endpoint: `https://dings.dcl.darc.de/api/adiexport`
- Request: POST with JSON body `{ key, limit: 50000, qsl_since, qso_since, cnf_only }`
- `parseDCLJSONResponse(jsonResponse)`: Parse example/test payloads
- `syncQSOs(userId, dclApiKey, sinceDate, jobId)`: Sync QSOs to database
- `getLastDCLQSLDate(userId)`: Get last QSL date for incremental sync
- Ready for when DCL publishes their API
- Debug logging (when `LOG_LEVEL=debug`) shows API params with redacted key (first/last 4 chars)
- Fully implemented and functional
- **Note**: DCL API is a custom prototype by DARC; contact DARC for API specification details
### DLD Award Implementation (COMPLETED)
@@ -214,6 +264,20 @@ The DLD (Deutschland Diplom) award was recently implemented:
**Documentation**: See `docs/DOCUMENTATION.md` for complete documentation including DLD award example.
**Frontend**: `src/frontend/src/routes/qsos/+page.svelte`
- Separate sync buttons for LoTW (blue) and DCL (orange)
- Independent progress tracking for each sync type
- Both syncs can run simultaneously
- Job polling every 2 seconds for status updates
- Import log displays after sync completion
- Real-time QSO table refresh after sync
**Frontend API** (`src/frontend/src/lib/api.js`):
- `qsosAPI.syncFromLoTW()`: Trigger LoTW sync
- `qsosAPI.syncFromDCL()`: Trigger DCL sync
- `jobsAPI.getStatus(jobId)`: Poll job status
- `jobsAPI.getActive()`: Get active job on page load
### Adding New Awards
To add a new award:
@@ -226,32 +290,107 @@ To add a new award:
6. Update documentation in `docs/DOCUMENTATION.md`
7. Test with sample QSO data
### Creating DLD Award Variants
The DOK award type supports filters to create award variants. Examples:
**DLD on 80m** (`dld-80m.json`):
```json
{
"id": "dld-80m",
"name": "DLD 80m",
"description": "Confirm 100 unique DOKs on 80m",
"caption": "Contact 100 different 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" }
]
}
}
}
```
**DLD in CW mode** (`dld-cw.json`):
```json
{
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"filters": {
"operator": "AND",
"filters": [
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}
```
**DLD on 80m using CW** (combined filters, `dld-80m-cw.json`):
```json
{
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" },
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}
```
**Available filter operators**:
- `eq`: equals
- `ne`: not equals
- `in`: in array
- `nin`: not in array
- `contains`: contains substring
**Available filter fields**: Any QSO field (band, mode, callsign, grid, state, satName, etc.)
### Confirmation Systems
- **LoTW (Logbook of The World)**: ARRL's confirmation system
- Service: `src/backend/services/lotw.service.js`
- API: `https://lotw.arrl.org/lotwuser/lotwreport.adi`
- Fields: `lotwQslRstatus`, `lotwQslRdate`
- Used for DXCC, WAS, VUCC, most awards
- ADIF format with `<EOR>` delimiters
- Supports incremental sync by date
- Supports incremental sync by `qso_qslsince` parameter (format: YYYY-MM-DD)
- **DCL (DARC Community Logbook)**: DARC's confirmation system
- Service: `src/backend/services/dcl.service.js`
- API: `https://dings.dcl.darc.de/api/adiexport`
- Fields: `dclQslRstatus`, `dclQslRdate`
- DOK fields: `darcDok` (partner's DOK), `myDarcDok` (user's DOK)
- Required for DLD award
- German amateur radio specific
- API in development (parser ready)
- Request format: POST JSON `{ key, limit, qsl_since, qso_since, cnf_only }`
- Response format: JSON with ADIF string in `adif` field
- Supports DOK (DARC Ortsverband Kennung) data
- Supports incremental sync by `qsl_since` parameter (format: YYYYMMDD)
- Updates QSOs only if confirmation data has changed
### ADIF Format
Both LoTW and DCL return data in ADIF (Amateur Data Interchange Format):
- Field format: `<FIELD_NAME:length>value`
- Record delimiter: `<EOR>` (end of record)
- Record delimiter: `<EOR>` (end of record, case-insensitive)
- Header ends with: `<EOH>` (end of header)
- Example: `<CALL:5>DK0MU<BAND:3>80m<QSO_DATE:8>20250621<EOR>`
- **Important**: Parser handles case-insensitive `<EOR>`, `<eor>`, `<Eor>` tags
**DCL-specific fields**:
- `DCL_QSL_RCVD`: DCL confirmation status (Y/N/?)
@@ -262,10 +401,69 @@ Both LoTW and DCL return data in ADIF (Amateur Data Interchange Format):
### Recent Commits
- `233888c`: fix: make ADIF parser case-insensitive for EOR delimiter
- **Critical bug**: LoTW uses lowercase `<eor>` tags, parser was splitting on uppercase `<EOR>`
- Caused 242K+ QSOs to be parsed as 1 giant record with fields overwriting each other
- Changed to case-insensitive regex: `new RegExp('<eor>', 'gi')`
- Replaced `regex.exec()` while loop with `matchAll()` for-of iteration
- Now correctly imports all QSOs from large LoTW reports
- `645f786`: fix: add missing timeOn field to LoTW duplicate detection
- LoTW sync was missing `timeOn` in duplicate detection query
- Multiple QSOs with same callsign/date/band/mode but different times were treated as duplicates
- Now matches DCL sync logic: `userId, callsign, qsoDate, timeOn, band, mode`
- `7f77c3a`: feat: add filter support for DOK awards
- DOK award type now supports filtering by band, mode, and other QSO fields
- Allows creating award variants like DLD 80m, DLD CW, DLD 80m CW
- Uses existing filter system with eq, ne, in, nin, contains operators
- Example awards created: dld-80m, dld-40m, dld-cw, dld-80m-cw
- `9e73704`: docs: update CLAUDE.md with DLD award variants documentation
- `7201446`: fix: return proper HTML for SPA routes instead of Bun error page
- When accessing client-side routes (like /qsos) via curl or non-JS clients,
the server attempted to open them as static files, causing Bun to throw
an unhandled ENOENT error that showed an ugly error page
- Now checks if a path has a file extension before attempting to serve it
- Paths without extensions are immediately served index.html for SPA routing
- Also improves the 503 error page with user-friendly HTML when frontend build is missing
- `223461f`: fix: enable debug logging and improve DCL sync observability
- `27d2ef1`: fix: preserve DOK data when DCL doesn't send values
- DCL sync only updates DOK/grid fields when DCL provides non-empty values
- Prevents accidentally clearing DOK data from manual entry or other sources
- Preserves existing DOK when DCL syncs QSO without DOK information
- `e09ab94`: feat: skip QSOs with unchanged confirmation data
- LoTW/DCL sync only updates QSOs if confirmation data has changed
- Tracks added, updated, and skipped QSO counts
- LoTW: Checks if lotwQslRstatus or lotwQslRdate changed
- DCL: Checks if dclQslRstatus, dclQslRdate, darcDok, myDarcDok, or grid changed
- `3592dbb`: feat: add import log showing synced QSOs
- Backend returns addedQSOs and updatedQSOs arrays in sync result
- Frontend displays import log with callsign, date, band, mode for each QSO
- Separate sections for "New QSOs" and "Updated QSOs"
- Sync summary shows total, added, updated, skipped counts
- `8a1a580`: feat: implement DCL ADIF parser and service integration
- Add shared ADIF parser utility (src/backend/utils/adif-parser.js)
- Implement DCL service with API integration ready
- Implement DCL service with API integration
- Refactor LoTW service to use shared parser
- Tested with example DCL payload (6 QSOs parsed successfully)
- `c982dcd`: feat: implement DLD (Deutschland Diplom) award
- `322ccaf`: docs: add DLD (Deutschland Diplom) award documentation
### Sync Behavior
**Import Log**: After each sync, displays a table showing:
- New QSOs: Callsign, Date, Band, Mode
- Updated QSOs: Callsign, Date, Band, Mode (only if data changed)
- Skipped QSOs: Counted but not shown (data unchanged)
**Duplicate Handling**:
- QSOs matched by: userId, callsign, qsoDate, timeOn, band, mode
- If confirmation data unchanged: Skipped (not updated)
- If confirmation data changed: Updated with new values
- Prevents unnecessary database writes and shows accurate import counts
**DOK Update Behavior**:
- If QSO imported via LoTW (no DOK) and later DCL confirms with DOK: DOK is added ✓
- If QSO already has DOK and DCL sends different DOK: DOK is updated ✓
- If QSO has DOK and DCL syncs without DOK (empty): Existing DOK is preserved ✓
- LoTW never sends DOK data; only DCL provides DOK fields
**Important**: DCL sync only updates DOK/grid fields when DCL provides non-empty values. This prevents accidentally clearing DOK data that was manually entered or imported from other sources.

View File

@@ -0,0 +1,19 @@
{
"id": "dld-40m",
"name": "DLD 40m",
"description": "Confirm 100 unique DOKs on 40m",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 40m band. Only DCL-confirmed QSOs with valid DOK information on 40m count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "40m" }
]
}
}
}

View File

@@ -0,0 +1,20 @@
{
"id": "dld-80m-cw",
"name": "DLD 80m CW",
"description": "Confirm 100 unique DOKs on 80m using CW",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band using CW mode. Only DCL-confirmed QSOs with valid DOK information on 80m CW count toward this award.",
"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" }
]
}
}
}

View File

@@ -0,0 +1,19 @@
{
"id": "dld-80m",
"name": "DLD 80m",
"description": "Confirm 100 unique DOKs on 80m",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band. Only DCL-confirmed QSOs with valid DOK information on 80m count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" }
]
}
}
}

View File

@@ -0,0 +1,19 @@
{
"id": "dld-cw",
"name": "DLD CW",
"description": "Confirm 100 unique DOKs using CW mode",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) using CW (Morse code). Each unique DOK on CW counts separately. Only DCL-confirmed QSOs with valid DOK information count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}

View File

@@ -17,7 +17,7 @@ export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'in
// ===================================================================
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLogLevel = logLevels[LOG_LEVEL] || 1;
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
function log(level, message, data) {
if (logLevels[level] < currentLogLevel) return;

View File

@@ -1,7 +1,7 @@
import { Elysia, t } from 'elysia';
import { cors } from '@elysiajs/cors';
import { jwt } from '@elysiajs/jwt';
import { JWT_SECRET, logger } from './config.js';
import { JWT_SECRET, logger, LOG_LEVEL } from './config.js';
import {
registerUser,
authenticateUser,
@@ -283,13 +283,13 @@ const app = new Elysia()
}
try {
const result = await enqueueJob(user.id);
const result = await enqueueJob(user.id, 'lotw_sync');
if (!result.success && result.existingJob) {
return {
success: true,
jobId: result.existingJob,
message: 'A sync job is already running',
message: 'A LoTW sync job is already running',
};
}
@@ -299,7 +299,41 @@ const app = new Elysia()
set.status = 500;
return {
success: false,
error: `Failed to queue sync job: ${error.message}`,
error: `Failed to queue LoTW sync job: ${error.message}`,
};
}
})
/**
* POST /api/dcl/sync
* Queue a DCL sync job (requires authentication)
* Returns immediately with job ID
*/
.post('/api/dcl/sync', async ({ user, set }) => {
if (!user) {
logger.warn('/api/dcl/sync: Unauthorized access attempt');
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const result = await enqueueJob(user.id, 'dcl_sync');
if (!result.success && result.existingJob) {
return {
success: true,
jobId: result.existingJob,
message: 'A DCL sync job is already running',
};
}
return result;
} catch (error) {
logger.error('Error in /api/dcl/sync', { error: error.message });
set.status = 500;
return {
success: false,
error: `Failed to queue DCL sync job: ${error.message}`,
};
}
})
@@ -646,13 +680,28 @@ const app = new Elysia()
try {
const fullPath = `src/frontend/build${filePath}`;
// Use Bun.file() which doesn't throw for non-existent files
// For paths without extensions or directories, use SPA fallback immediately
// This prevents errors when trying to open directories as files
const ext = filePath.split('.').pop();
const hasExtension = ext !== filePath && ext.length <= 5; // Simple check for file extension
if (!hasExtension) {
// No extension means it's a route, not a file - serve index.html
const indexFile = Bun.file('src/frontend/build/index.html');
return new Response(indexFile, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
});
}
// Try to serve actual files (with extensions)
const file = Bun.file(fullPath);
const exists = file.exists();
if (exists) {
// Determine content type
const ext = filePath.split('.').pop();
const contentTypes = {
'js': 'application/javascript',
'css': 'text/css',
@@ -685,6 +734,7 @@ const app = new Elysia()
}
} catch (err) {
// File not found or error, fall through to SPA fallback
logger.debug('Error serving static file, falling back to SPA', { path: pathname, error: err.message });
}
// SPA fallback - serve index.html for all other routes
@@ -696,10 +746,24 @@ const app = new Elysia()
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
});
} catch {
return new Response('Frontend not built. Run `bun run build`', { status: 503 });
} catch (err) {
logger.error('Frontend build not found', { error: err.message });
return new Response(
'<!DOCTYPE html><html><head><title>Quickawards - Unavailable</title></head>' +
'<body style="font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px;">' +
'<h1>Service Temporarily Unavailable</h1>' +
'<p>The frontend application is not currently available. This usually means the application is being updated or restarted.</p>' +
'<p>Please try refreshing the page in a few moments.</p></body></html>',
{ status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
}
})
// Start server - uses PORT environment variable if set, otherwise defaults to 3001
.listen(process.env.PORT || 3001);
logger.info('Server started', {
port: process.env.PORT || 3001,
nodeEnv: process.env.NODE_ENV || 'unknown',
logLevel: LOG_LEVEL,
});

View File

@@ -27,6 +27,10 @@ function loadAwardDefinitions() {
'sat-rs44.json',
'special-stations.json',
'dld.json',
'dld-80m.json',
'dld-40m.json',
'dld-cw.json',
'dld-80m-cw.json',
];
for (const file of files) {
@@ -173,9 +177,9 @@ export async function calculateAwardProgress(userId, award, options = {}) {
async function calculateDOKAwardProgress(userId, award, options = {}) {
const { includeDetails = false } = options;
const { rules } = award;
const { target, displayField } = rules;
const { target, displayField, filters } = rules;
logger.debug('Calculating DOK-based award progress', { userId, awardId: award.id, target });
logger.debug('Calculating DOK-based award progress', { userId, awardId: award.id, target, hasFilters: !!filters });
// Get all QSOs for user
const allQSOs = await db
@@ -185,10 +189,17 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
logger.debug('Total QSOs for user', { count: allQSOs.length });
// Apply filters if defined
let filteredQSOs = allQSOs;
if (filters) {
filteredQSOs = applyFilters(allQSOs, filters);
logger.debug('QSOs after DOK award filters', { count: filteredQSOs.length });
}
// Track unique (DOK, band, mode) combinations
const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object
for (const qso of allQSOs) {
for (const qso of filteredQSOs) {
const dok = qso.darcDok;
if (!dok) continue; // Skip QSOs without DOK

View File

@@ -9,10 +9,18 @@ import { parseDCLResponse, normalizeBand, normalizeMode } from '../utils/adif-pa
*
* DCL Information:
* - Website: https://dcl.darc.de/
* - API: Coming soon (currently in development)
* - ADIF Export: https://dcl.darc.de/dml/export_adif_form.php (manual only)
* - API Endpoint: https://dings.dcl.darc.de/api/adiexport
* - DOK fields: MY_DARC_DOK (user's DOK), DARC_DOK (partner's DOK)
*
* API Request Format (POST):
* {
* "key": "API_KEY",
* "limit": null,
* "qsl_since": null,
* "qso_since": null,
* "cnf_only": null
* }
*
* Expected API Response Format:
* {
* "adif": "<ADIF_VER:5>3.1.3\\n<CREATED_TIMESTAMP:15>20260117 095453\\n<EOH>\\n..."
@@ -20,13 +28,11 @@ import { parseDCLResponse, normalizeBand, normalizeMode } from '../utils/adif-pa
*/
const REQUEST_TIMEOUT = 60000;
const DCL_API_URL = 'https://dings.dcl.darc.de/api/adiexport';
/**
* Fetch QSOs from DCL API
*
* When DCL provides their API, update the URL and parameters.
* Expected response format: { "adif": "<ADIF data>" }
*
* @param {string} dclApiKey - DCL API key
* @param {Date|null} sinceDate - Last sync date for incremental sync
* @returns {Promise<Array>} Array of parsed QSO records
@@ -37,30 +43,44 @@ export async function fetchQSOsFromDCL(dclApiKey, sinceDate = null) {
sinceDate: sinceDate?.toISOString(),
});
// TODO: Update URL when DCL publishes their API endpoint
const url = 'https://dcl.darc.de/api/export'; // Placeholder URL
const params = new URLSearchParams({
api_key: dclApiKey,
format: 'json',
qsl: 'yes',
});
// Build request body
const requestBody = {
key: dclApiKey,
limit: 50000,
qsl_since: null,
qso_since: null,
cnf_only: null,
};
// Add date filter for incremental sync if provided
if (sinceDate) {
const dateStr = sinceDate.toISOString().split('T')[0].replace(/-/g, '');
params.append('qsl_since', dateStr);
requestBody.qsl_since = dateStr;
}
// Debug log request parameters (redact API key)
logger.debug('DCL API request parameters', {
url: DCL_API_URL,
method: 'POST',
key: dclApiKey ? `${dclApiKey.substring(0, 4)}...${dclApiKey.substring(dclApiKey.length - 4)}` : null,
limit: requestBody.limit,
qsl_since: requestBody.qsl_since,
qso_since: requestBody.qso_since,
cnf_only: requestBody.cnf_only,
});
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
const response = await fetch(`${url}?${params}`, {
const response = await fetch(DCL_API_URL, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(requestBody),
});
clearTimeout(timeoutId);
@@ -69,9 +89,10 @@ export async function fetchQSOsFromDCL(dclApiKey, sinceDate = null) {
if (response.status === 401) {
throw new Error('Invalid DCL API key. Please check your DCL credentials in Settings.');
} else if (response.status === 404) {
throw new Error('DCL API endpoint not found. The DCL API may not be available yet.');
throw new Error('DCL API endpoint not found.');
} else {
throw new Error(`DCL API error: ${response.status} ${response.statusText}`);
const errorText = await response.text();
throw new Error(`DCL API error: ${response.status} ${response.statusText} - ${errorText}`);
}
}
@@ -82,7 +103,7 @@ export async function fetchQSOsFromDCL(dclApiKey, sinceDate = null) {
logger.info('Successfully fetched QSOs from DCL', {
total: qsos.length,
hasConfirmations: qsos.filter(q => qso.dcl_qsl_rcvd === 'Y').length,
hasConfirmations: qsos.filter(q => q.dcl_qsl_rcvd === 'Y').length,
});
return qsos;
@@ -94,7 +115,6 @@ export async function fetchQSOsFromDCL(dclApiKey, sinceDate = null) {
logger.error('Failed to fetch from DCL', {
error: error.message,
url: url.replace(/api_key=[^&]+/, 'api_key=***'),
});
throw error;
@@ -103,7 +123,7 @@ export async function fetchQSOsFromDCL(dclApiKey, sinceDate = null) {
/**
* Parse DCL API response from JSON
* This function exists for testing with example payloads before DCL API is available
* Can be used for testing with example payloads
*
* @param {Object} jsonResponse - JSON response in DCL format
* @returns {Array} Array of parsed QSO records
@@ -232,16 +252,25 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
if (dataChanged) {
// Update existing QSO with changed DCL confirmation and DOK data
await db
.update(qsos)
.set({
// Only update DOK/grid fields if DCL actually sent values (non-empty)
const updateData = {
dclQslRdate: dbQSO.dclQslRdate,
dclQslRstatus: dbQSO.dclQslRstatus,
darcDok: dbQSO.darcDok || existingQSO.darcDok,
myDarcDok: dbQSO.myDarcDok || existingQSO.myDarcDok,
grid: dbQSO.grid || existingQSO.grid,
gridSource: dbQSO.gridSource || existingQSO.gridSource,
})
};
// Only add DOK fields if DCL sent them
if (dbQSO.darcDok) updateData.darcDok = dbQSO.darcDok;
if (dbQSO.myDarcDok) updateData.myDarcDok = dbQSO.myDarcDok;
// Only update grid if DCL sent one
if (dbQSO.grid) {
updateData.grid = dbQSO.grid;
updateData.gridSource = dbQSO.gridSource;
}
await db
.update(qsos)
.set(updateData)
.where(eq(qsos.id, existingQSO.id));
updatedCount++;
// Track updated QSO (CALL and DATE)
@@ -325,8 +354,6 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
/**
* Get last DCL QSL date for incremental sync
*
* TODO: Implement when DCL provides API
*
* @param {number} userId - User ID
* @returns {Promise<Date|null>} Last QSL date or null
*/

View File

@@ -19,20 +19,21 @@ export const JobStatus = {
const activeJobs = new Map();
/**
* Enqueue a new LoTW sync job
* Enqueue a new sync job
* @param {number} userId - User ID
* @param {string} jobType - Type of job ('lotw_sync' or 'dcl_sync')
* @returns {Promise<Object>} Job object with ID
*/
export async function enqueueJob(userId) {
logger.debug('Enqueueing LoTW sync job', { userId });
export async function enqueueJob(userId, jobType = 'lotw_sync') {
logger.debug('Enqueueing sync job', { userId, jobType });
// Check for existing active job
const existingJob = await getUserActiveJob(userId);
// Check for existing active job of the same type
const existingJob = await getUserActiveJob(userId, jobType);
if (existingJob) {
logger.debug('Existing active job found', { jobId: existingJob.id });
logger.debug('Existing active job found', { jobId: existingJob.id, jobType });
return {
success: false,
error: 'A LoTW sync job is already running or pending for this user',
error: `A ${jobType} job is already running or pending for this user`,
existingJob: existingJob.id,
};
}
@@ -42,16 +43,16 @@ export async function enqueueJob(userId) {
.insert(syncJobs)
.values({
userId,
type: 'lotw_sync',
type: jobType,
status: JobStatus.PENDING,
createdAt: new Date(),
})
.returning();
logger.info('Job created', { jobId: job.id, userId });
logger.info('Job created', { jobId: job.id, userId, jobType });
// Start processing asynchronously (don't await)
processJobAsync(job.id, userId).catch((error) => {
processJobAsync(job.id, userId, jobType).catch((error) => {
logger.error(`Job processing error`, { jobId: job.id, error: error.message });
});
@@ -68,15 +69,14 @@ export async function enqueueJob(userId) {
}
/**
* Process a LoTW sync job asynchronously
* Process a sync job asynchronously
* @param {number} jobId - Job ID
* @param {number} userId - User ID
* @param {string} jobType - Type of job ('lotw_sync' or 'dcl_sync')
*/
async function processJobAsync(jobId, userId) {
async function processJobAsync(jobId, userId, jobType) {
const jobPromise = (async () => {
try {
// Import dynamically to avoid circular dependency
const { syncQSOs } = await import('./lotw.service.js');
const { getUserById } = await import('./auth.service.js');
// Update status to running
@@ -85,8 +85,42 @@ async function processJobAsync(jobId, userId) {
startedAt: new Date(),
});
let result;
if (jobType === 'dcl_sync') {
// Get user credentials
const user = await getUserById(userId);
if (!user || !user.dclApiKey) {
await updateJob(jobId, {
status: JobStatus.FAILED,
completedAt: new Date(),
error: 'DCL credentials not configured',
});
return null;
}
// Get last QSL date for incremental sync
const { getLastDCLQSLDate, syncQSOs: syncDCLQSOs } = await import('./dcl.service.js');
const lastQSLDate = await getLastDCLQSLDate(userId);
const sinceDate = lastQSLDate || new Date('2000-01-01');
if (lastQSLDate) {
logger.info(`Job ${jobId}: DCL incremental sync`, { since: sinceDate.toISOString().split('T')[0] });
} else {
logger.info(`Job ${jobId}: DCL full sync`);
}
// Update job progress
await updateJobProgress(jobId, {
message: 'Fetching QSOs from DCL...',
step: 'fetch',
});
// Execute the sync
result = await syncDCLQSOs(userId, user.dclApiKey, sinceDate, jobId);
} else {
// LoTW sync (default)
const user = await getUserById(userId);
if (!user || !user.lotwUsername || !user.lotwPassword) {
await updateJob(jobId, {
status: JobStatus.FAILED,
@@ -97,14 +131,14 @@ async function processJobAsync(jobId, userId) {
}
// Get last QSL date for incremental sync
const { getLastLoTWQSLDate } = await import('./lotw.service.js');
const { getLastLoTWQSLDate, syncQSOs } = await import('./lotw.service.js');
const lastQSLDate = await getLastLoTWQSLDate(userId);
const sinceDate = lastQSLDate || new Date('2000-01-01');
if (lastQSLDate) {
logger.info(`Job ${jobId}: Incremental sync`, { since: sinceDate.toISOString().split('T')[0] });
logger.info(`Job ${jobId}: LoTW incremental sync`, { since: sinceDate.toISOString().split('T')[0] });
} else {
logger.info(`Job ${jobId}: Full sync`);
logger.info(`Job ${jobId}: LoTW full sync`);
}
// Update job progress
@@ -114,7 +148,8 @@ async function processJobAsync(jobId, userId) {
});
// Execute the sync
const result = await syncQSOs(userId, user.lotwUsername, user.lotwPassword, sinceDate, jobId);
result = await syncQSOs(userId, user.lotwUsername, user.lotwPassword, sinceDate, jobId);
}
// Update job as completed
await updateJob(jobId, {
@@ -197,9 +232,10 @@ export async function getJobStatus(jobId) {
/**
* Get user's active job (pending or running)
* @param {number} userId - User ID
* @param {string} jobType - Optional job type filter
* @returns {Promise<Object|null>} Active job or null
*/
export async function getUserActiveJob(userId) {
export async function getUserActiveJob(userId, jobType = null) {
const conditions = [
eq(syncJobs.userId, userId),
or(
@@ -208,6 +244,10 @@ export async function getUserActiveJob(userId) {
),
];
if (jobType) {
conditions.push(eq(syncJobs.type, jobType));
}
const [job] = await db
.select()
.from(syncJobs)

View File

@@ -241,6 +241,7 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
eq(qsos.userId, userId),
eq(qsos.callsign, dbQSO.callsign),
eq(qsos.qsoDate, dbQSO.qsoDate),
eq(qsos.timeOn, dbQSO.timeOn),
eq(qsos.band, dbQSO.band),
eq(qsos.mode, dbQSO.mode)
)

View File

@@ -13,8 +13,10 @@
*/
export function parseADIF(adifData) {
const qsos = [];
// Split by <EOR> (end of record) - case sensitive as per ADIF spec
const records = adifData.split('<EOR>');
// Split by <EOR> (case-insensitive to handle <EOR>, <eor>, <Eor>, etc.)
const regex = new RegExp('<eor>', 'gi');
const records = adifData.split(regex);
for (const record of records) {
if (!record.trim()) continue;
@@ -26,10 +28,11 @@ export function parseADIF(adifData) {
}
const qso = {};
const regex = /<([A-Z0-9_]+):(\d+)(?::[A-Z]+)?>/gi;
let match;
while ((match = regex.exec(record)) !== null) {
// Use matchAll for cleaner parsing (creates new iterator for each record)
const matches = record.matchAll(/<([A-Z0-9_]+):(\d+)(?::[A-Z]+)?>/gi);
for (const match of matches) {
const [fullMatch, fieldName, lengthStr] = match;
const length = parseInt(lengthStr, 10);
const valueStart = match.index + fullMatch.length;
@@ -38,9 +41,6 @@ export function parseADIF(adifData) {
const value = record.substring(valueStart, valueStart + length);
qso[fieldName.toLowerCase()] = value.trim();
// Update regex position to continue after the value
regex.lastIndex = valueStart + length;
}
// Only add if we have at least a callsign

View File

@@ -74,6 +74,8 @@ export const qsosAPI = {
syncFromLoTW: () => apiRequest('/lotw/sync', { method: 'POST' }),
syncFromDCL: () => apiRequest('/dcl/sync', { method: 'POST' }),
deleteAll: () => apiRequest('/qsos/all', { method: 'DELETE' }),
};

View File

@@ -13,11 +13,20 @@
let pageSize = 100;
let pagination = null;
// Job polling state
let syncJobId = null;
let syncStatus = null;
let syncProgress = null;
let pollingInterval = null;
// Job polling state - LoTW
let lotwSyncJobId = null;
let lotwSyncStatus = null;
let lotwSyncProgress = null;
let lotwPollingInterval = null;
// Job polling state - DCL
let dclSyncJobId = null;
let dclSyncStatus = null;
let dclSyncProgress = null;
let dclPollingInterval = null;
// Sync result
let syncResult = null;
// Delete confirmation state
let showDeleteConfirm = false;
@@ -34,14 +43,17 @@
if (!$auth.user) return;
await loadQSOs();
await loadStats();
// Check for active job on mount
await checkActiveJob();
// Check for active jobs on mount
await checkActiveJobs();
});
// Clean up polling interval on unmount
// Clean up polling intervals on unmount
onDestroy(() => {
if (pollingInterval) {
clearInterval(pollingInterval);
if (lotwPollingInterval) {
clearInterval(lotwPollingInterval);
}
if (dclPollingInterval) {
clearInterval(dclPollingInterval);
}
});
@@ -76,98 +88,169 @@
}
}
async function checkActiveJob() {
async function checkActiveJobs() {
try {
const response = await jobsAPI.getActive();
if (response.job) {
syncJobId = response.job.id;
syncStatus = response.job.status;
// Start polling if job is running
if (syncStatus === 'running' || syncStatus === 'pending') {
startPolling(response.job.id);
const job = response.job;
if (job.type === 'lotw_sync') {
lotwSyncJobId = job.id;
lotwSyncStatus = job.status;
if (lotwSyncStatus === 'running' || lotwSyncStatus === 'pending') {
startLoTWPolling(job.id);
}
} else if (job.type === 'dcl_sync') {
dclSyncJobId = job.id;
dclSyncStatus = job.status;
if (dclSyncStatus === 'running' || dclSyncStatus === 'pending') {
startDCLPolling(job.id);
}
}
}
} catch (err) {
console.error('Failed to check active job:', err);
console.error('Failed to check active jobs:', err);
}
}
async function startPolling(jobId) {
syncJobId = jobId;
syncStatus = 'running';
async function startLoTWPolling(jobId) {
lotwSyncJobId = jobId;
lotwSyncStatus = 'running';
// Clear any existing interval
if (pollingInterval) {
clearInterval(pollingInterval);
if (lotwPollingInterval) {
clearInterval(lotwPollingInterval);
}
// Poll every 2 seconds
pollingInterval = setInterval(async () => {
lotwPollingInterval = setInterval(async () => {
try {
const response = await jobsAPI.getStatus(jobId);
const job = response.job;
syncStatus = job.status;
syncProgress = job.result?.progress ? job.result : null;
lotwSyncStatus = job.status;
lotwSyncProgress = job.result?.progress ? job.result : null;
if (job.status === 'completed') {
clearInterval(pollingInterval);
pollingInterval = null;
syncJobId = null;
syncProgress = null;
syncStatus = null;
clearInterval(lotwPollingInterval);
lotwPollingInterval = null;
lotwSyncJobId = null;
lotwSyncProgress = null;
lotwSyncStatus = null;
// Reload QSOs and stats
await loadQSOs();
await loadStats();
// Show success message
syncResult = {
success: true,
source: 'LoTW',
...job.result,
};
} else if (job.status === 'failed') {
clearInterval(pollingInterval);
pollingInterval = null;
syncJobId = null;
syncProgress = null;
syncStatus = null;
clearInterval(lotwPollingInterval);
lotwPollingInterval = null;
lotwSyncJobId = null;
lotwSyncProgress = null;
lotwSyncStatus = null;
// Show error message
syncResult = {
success: false,
error: job.error || 'Sync failed',
source: 'LoTW',
error: job.error || 'LoTW sync failed',
};
}
} catch (err) {
console.error('Failed to poll job status:', err);
// Don't stop polling on error, might be temporary
console.error('Failed to poll LoTW job status:', err);
}
}, 2000);
}
async function handleSync() {
async function startDCLPolling(jobId) {
dclSyncJobId = jobId;
dclSyncStatus = 'running';
if (dclPollingInterval) {
clearInterval(dclPollingInterval);
}
dclPollingInterval = setInterval(async () => {
try {
const response = await jobsAPI.getStatus(jobId);
const job = response.job;
dclSyncStatus = job.status;
dclSyncProgress = job.result?.progress ? job.result : null;
if (job.status === 'completed') {
clearInterval(dclPollingInterval);
dclPollingInterval = null;
dclSyncJobId = null;
dclSyncProgress = null;
dclSyncStatus = null;
await loadQSOs();
await loadStats();
syncResult = {
success: true,
source: 'DCL',
...job.result,
};
} else if (job.status === 'failed') {
clearInterval(dclPollingInterval);
dclPollingInterval = null;
dclSyncJobId = null;
dclSyncProgress = null;
dclSyncStatus = null;
syncResult = {
success: false,
source: 'DCL',
error: job.error || 'DCL sync failed',
};
}
} catch (err) {
console.error('Failed to poll DCL job status:', err);
}
}, 2000);
}
async function handleLoTWSync() {
try {
const response = await qsosAPI.syncFromLoTW();
if (response.jobId) {
// Job was queued successfully
startPolling(response.jobId);
startLoTWPolling(response.jobId);
} else if (response.existingJob) {
// There's already an active job
startPolling(response.existingJob);
startLoTWPolling(response.existingJob);
} else {
throw new Error(response.error || 'Failed to queue sync job');
throw new Error(response.error || 'Failed to queue LoTW sync job');
}
} catch (err) {
syncResult = {
success: false,
source: 'LoTW',
error: err.message,
};
}
}
let syncResult = null;
async function handleDCLSync() {
try {
const response = await qsosAPI.syncFromDCL();
if (response.jobId) {
startDCLPolling(response.jobId);
} else if (response.existingJob) {
startDCLPolling(response.existingJob);
} else {
throw new Error(response.error || 'Failed to queue DCL sync job');
}
} catch (err) {
syncResult = {
success: false,
source: 'DCL',
error: err.message,
};
}
}
async function applyFilters() {
currentPage = 1;
@@ -263,31 +346,52 @@
<button
class="btn btn-danger"
on:click={() => showDeleteConfirm = true}
disabled={syncStatus === 'running' || syncStatus === 'pending' || deleting}
disabled={lotwSyncStatus === 'running' || lotwSyncStatus === 'pending' || dclSyncStatus === 'running' || dclSyncStatus === 'pending' || deleting}
>
Clear All QSOs
</button>
{/if}
<button
class="btn btn-primary"
on:click={handleSync}
disabled={syncStatus === 'running' || syncStatus === 'pending' || deleting}
class="btn btn-primary lotw-btn"
on:click={handleLoTWSync}
disabled={lotwSyncStatus === 'running' || lotwSyncStatus === 'pending' || deleting}
>
{#if syncStatus === 'running' || syncStatus === 'pending'}
Syncing...
{#if lotwSyncStatus === 'running' || lotwSyncStatus === 'pending'}
LoTW Syncing...
{:else}
Sync from LoTW
{/if}
</button>
<button
class="btn btn-primary dcl-btn"
on:click={handleDCLSync}
disabled={dclSyncStatus === 'running' || dclSyncStatus === 'pending' || deleting}
>
{#if dclSyncStatus === 'running' || dclSyncStatus === 'pending'}
DCL Syncing...
{:else}
Sync from DCL
{/if}
</button>
</div>
</div>
{#if syncProgress}
{#if lotwSyncProgress}
<div class="alert alert-info">
<h3>Syncing from LoTW...</h3>
<p>{syncProgress.message || 'Processing...'}</p>
{#if syncProgress.total}
<p>Progress: {syncProgress.processed || 0} / {syncProgress.total}</p>
<p>{lotwSyncProgress.message || 'Processing...'}</p>
{#if lotwSyncProgress.total}
<p>Progress: {lotwSyncProgress.processed || 0} / {lotwSyncProgress.total}</p>
{/if}
</div>
{/if}
{#if dclSyncProgress}
<div class="alert alert-info">
<h3>Syncing from DCL...</h3>
<p>{dclSyncProgress.message || 'Processing...'}</p>
{#if dclSyncProgress.total}
<p>Progress: {dclSyncProgress.processed || 0} / {dclSyncProgress.total}</p>
{/if}
</div>
{/if}
@@ -602,6 +706,23 @@
.header-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.lotw-btn {
background-color: #4a90e2;
}
.lotw-btn:hover:not(:disabled) {
background-color: #357abd;
}
.dcl-btn {
background-color: #e67e22;
}
.dcl-btn:hover:not(:disabled) {
background-color: #d35400;
}
.stats-grid {

View File

@@ -194,8 +194,8 @@
<div class="settings-section">
<h2>DCL Credentials</h2>
<p class="help-text">
Configure your DARC Community Logbook (DCL) API key for future sync functionality.
<strong>Note:</strong> DCL does not currently provide a download API. This is prepared for when they add one.
Configure your DARC Community Logbook (DCL) API key to sync your QSOs.
Your API key is stored securely and used only to fetch your confirmed QSOs.
</p>
{#if hasDCLCredentials}
@@ -220,7 +220,7 @@
placeholder="Your DCL API key"
/>
<p class="hint">
Enter your DCL API key for future sync functionality
Enter your DCL API key to sync QSOs
</p>
</div>
@@ -233,10 +233,10 @@
<h3>About DCL</h3>
<p>
DCL (DARC Community Logbook) is DARC's web-based logbook system for German amateur radio awards.
It includes DOK (DARC Ortsverband Kennung) fields for local club awards.
It includes DOK (DARC Ortsverband Kennung) fields for local club awards like the DLD award.
</p>
<p>
<strong>Status:</strong> Download API not yet available.{' '}
Once configured, you can sync your QSOs from DCL on the QSO Log page.
<a href="https://dcl.darc.de/" target="_blank" rel="noopener">
Visit DCL website
</a>