Compare commits
6 Commits
720144627e
...
af43f8954c
| Author | SHA1 | Date | |
|---|---|---|---|
|
af43f8954c
|
|||
|
233888c44f
|
|||
|
0161ad47a8
|
|||
|
645f7863e7
|
|||
|
9e73704220
|
|||
|
7f77c3adc9
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,3 +41,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
sample
|
||||||
|
|||||||
94
CLAUDE.md
94
CLAUDE.md
@@ -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
|
2. **`dok`**: Count unique DOK (DARC Ortsverband Kennung) combinations
|
||||||
- `target`: Number required
|
- `target`: Number required
|
||||||
- `confirmationType`: "dcl" (DARC Community Logbook)
|
- `confirmationType`: "dcl" (DARC Community Logbook)
|
||||||
|
- `filters`: Optional filters (band, mode, etc.) for award variants
|
||||||
- Counts unique (DOK, band, mode) combinations
|
- Counts unique (DOK, band, mode) combinations
|
||||||
- Only DCL-confirmed QSOs count
|
- Only DCL-confirmed QSOs count
|
||||||
|
- Example variants: DLD 80m, DLD CW, DLD 80m CW
|
||||||
|
|
||||||
3. **`points`**: Point-based awards
|
3. **`points`**: Point-based awards
|
||||||
- `stations`: Array of {callsign, points}
|
- `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
|
- `POST /api/dcl/sync`: Queue DCL sync job
|
||||||
- `GET /api/jobs/:jobId`: Get job status
|
- `GET /api/jobs/:jobId`: Get job status
|
||||||
- `GET /api/jobs/active`: Get active job for current user
|
- `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`
|
**DCL Service**: `src/backend/services/dcl.service.js`
|
||||||
- `fetchQSOsFromDCL(dclApiKey, sinceDate)`: Fetch from DCL API
|
- `fetchQSOsFromDCL(dclApiKey, sinceDate)`: Fetch from DCL API
|
||||||
@@ -277,6 +287,77 @@ To add a new award:
|
|||||||
6. Update documentation in `docs/DOCUMENTATION.md`
|
6. Update documentation in `docs/DOCUMENTATION.md`
|
||||||
7. Test with sample QSO data
|
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
|
### Confirmation Systems
|
||||||
|
|
||||||
- **LoTW (Logbook of The World)**: ARRL's confirmation system
|
- **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
|
### Recent Commits
|
||||||
|
|
||||||
- **Uncommitted**: fix: logger debug level not working
|
- `7201446`: fix: return proper HTML for SPA routes instead of Bun error page
|
||||||
- Fixed bug where debug logs weren't showing due to falsy value handling
|
- When accessing client-side routes (like /qsos) via curl or non-JS clients,
|
||||||
- Changed `||` to `??` in logger config to properly handle log level 0 (debug)
|
the server attempted to open them as static files, causing Bun to throw
|
||||||
- Added `.env` file with `LOG_LEVEL=debug` for development
|
an unhandled ENOENT error that showed an ugly error page
|
||||||
- Debug logs now show DCL API request parameters with redacted API key
|
- 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
|
- `27d2ef1`: fix: preserve DOK data when DCL doesn't send values
|
||||||
- DCL sync only updates DOK/grid fields when DCL provides non-empty 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
|
- Prevents accidentally clearing DOK data from manual entry or other sources
|
||||||
|
|||||||
19
award-definitions/dld-40m.json
Normal file
19
award-definitions/dld-40m.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
award-definitions/dld-80m-cw.json
Normal file
20
award-definitions/dld-80m-cw.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
award-definitions/dld-80m.json
Normal file
19
award-definitions/dld-80m.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
award-definitions/dld-cw.json
Normal file
19
award-definitions/dld-cw.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ function loadAwardDefinitions() {
|
|||||||
'sat-rs44.json',
|
'sat-rs44.json',
|
||||||
'special-stations.json',
|
'special-stations.json',
|
||||||
'dld.json',
|
'dld.json',
|
||||||
|
'dld-80m.json',
|
||||||
|
'dld-40m.json',
|
||||||
|
'dld-cw.json',
|
||||||
|
'dld-80m-cw.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -173,9 +177,9 @@ export async function calculateAwardProgress(userId, award, options = {}) {
|
|||||||
async function calculateDOKAwardProgress(userId, award, options = {}) {
|
async function calculateDOKAwardProgress(userId, award, options = {}) {
|
||||||
const { includeDetails = false } = options;
|
const { includeDetails = false } = options;
|
||||||
const { rules } = award;
|
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
|
// Get all QSOs for user
|
||||||
const allQSOs = await db
|
const allQSOs = await db
|
||||||
@@ -185,10 +189,17 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
|
|||||||
|
|
||||||
logger.debug('Total QSOs for user', { count: allQSOs.length });
|
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
|
// Track unique (DOK, band, mode) combinations
|
||||||
const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object
|
const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object
|
||||||
|
|
||||||
for (const qso of allQSOs) {
|
for (const qso of filteredQSOs) {
|
||||||
const dok = qso.darcDok;
|
const dok = qso.darcDok;
|
||||||
if (!dok) continue; // Skip QSOs without DOK
|
if (!dok) continue; // Skip QSOs without DOK
|
||||||
|
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
|
|||||||
eq(qsos.userId, userId),
|
eq(qsos.userId, userId),
|
||||||
eq(qsos.callsign, dbQSO.callsign),
|
eq(qsos.callsign, dbQSO.callsign),
|
||||||
eq(qsos.qsoDate, dbQSO.qsoDate),
|
eq(qsos.qsoDate, dbQSO.qsoDate),
|
||||||
|
eq(qsos.timeOn, dbQSO.timeOn),
|
||||||
eq(qsos.band, dbQSO.band),
|
eq(qsos.band, dbQSO.band),
|
||||||
eq(qsos.mode, dbQSO.mode)
|
eq(qsos.mode, dbQSO.mode)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,8 +13,10 @@
|
|||||||
*/
|
*/
|
||||||
export function parseADIF(adifData) {
|
export function parseADIF(adifData) {
|
||||||
const qsos = [];
|
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) {
|
for (const record of records) {
|
||||||
if (!record.trim()) continue;
|
if (!record.trim()) continue;
|
||||||
@@ -26,10 +28,11 @@ export function parseADIF(adifData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const qso = {};
|
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 [fullMatch, fieldName, lengthStr] = match;
|
||||||
const length = parseInt(lengthStr, 10);
|
const length = parseInt(lengthStr, 10);
|
||||||
const valueStart = match.index + fullMatch.length;
|
const valueStart = match.index + fullMatch.length;
|
||||||
@@ -38,9 +41,6 @@ export function parseADIF(adifData) {
|
|||||||
const value = record.substring(valueStart, valueStart + length);
|
const value = record.substring(valueStart, valueStart + length);
|
||||||
|
|
||||||
qso[fieldName.toLowerCase()] = value.trim();
|
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
|
// Only add if we have at least a callsign
|
||||||
|
|||||||
Reference in New Issue
Block a user