Compare commits

..

6 Commits

Author SHA1 Message Date
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
9 changed files with 190 additions and 16 deletions

1
.gitignore vendored
View File

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

View File

@@ -146,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}
@@ -202,6 +204,14 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
- `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
@@ -277,6 +287,77 @@ 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
@@ -316,11 +397,14 @@ Both LoTW and DCL return data in ADIF (Amateur Data Interchange Format):
### Recent Commits
- **Uncommitted**: fix: logger debug level not working
- Fixed bug where debug logs weren't showing due to falsy value handling
- Changed `||` to `??` in logger config to properly handle log level 0 (debug)
- Added `.env` file with `LOG_LEVEL=debug` for development
- Debug logs now show DCL API request parameters with redacted API key
- `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

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

@@ -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

@@ -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