From 436e9c22783567f95a2b7bd8acce177283d42e3a Mon Sep 17 00:00:00 2001 From: Joerg Date: Fri, 16 Jan 2026 10:14:41 +0100 Subject: [PATCH] Add award rule captions and configurable point counting modes - Add caption field to all award definitions with detailed rule explanations - Rename Special Stations Award to Wavelog Award - Add configurable countMode for point-based awards: - perBandMode: count unique (callsign, band, mode) combinations - perStation: count each station once - perQso: count every QSO - Update backend to respect countMode in progress calculation - Add target field to award API responses - Fix "Needed" calculation for point-based awards - Display caption on award detail pages with styled info box Co-Authored-By: Claude Sonnet 4.5 --- award-definitions/dxcc-cw.json | 1 + award-definitions/dxcc.json | 1 + award-definitions/sat-rs44.json | 1 + award-definitions/special-stations.json | 8 +- award-definitions/vucc-sat.json | 1 + award-definitions/was.json | 1 + src/backend/services/awards.service.js | 289 +++++++++++++----- .../src/routes/awards/[id]/+page.svelte | 29 +- .../static/award-definitions/dxcc-cw.json | 1 + .../static/award-definitions/dxcc.json | 1 + .../static/award-definitions/sat-rs44.json | 1 + .../award-definitions/special-stations.json | 8 +- .../static/award-definitions/vucc-sat.json | 1 + .../static/award-definitions/was.json | 1 + 14 files changed, 266 insertions(+), 78 deletions(-) diff --git a/award-definitions/dxcc-cw.json b/award-definitions/dxcc-cw.json index 68e15c8..92f1475 100644 --- a/award-definitions/dxcc-cw.json +++ b/award-definitions/dxcc-cw.json @@ -2,6 +2,7 @@ "id": "dxcc-cw", "name": "DXCC CW", "description": "Confirm 100 DXCC entities using CW mode", + "caption": "Contact and confirm 100 different DXCC entities using CW mode only. Only QSOs made with CW mode count toward this award. QSOs are confirmed when LoTW QSL is received.", "category": "dxcc", "rules": { "type": "filtered", diff --git a/award-definitions/dxcc.json b/award-definitions/dxcc.json index 65320dd..c96205d 100644 --- a/award-definitions/dxcc.json +++ b/award-definitions/dxcc.json @@ -2,6 +2,7 @@ "id": "dxcc-mixed", "name": "DXCC Mixed Mode", "description": "Confirm 100 DXCC entities on any band/mode", + "caption": "Contact and confirm 100 different DXCC entities. Any band and mode combination counts. QSOs are confirmed when LoTW QSL is received.", "category": "dxcc", "rules": { "type": "entity", diff --git a/award-definitions/sat-rs44.json b/award-definitions/sat-rs44.json index ed13870..bed903e 100644 --- a/award-definitions/sat-rs44.json +++ b/award-definitions/sat-rs44.json @@ -2,6 +2,7 @@ "id": "sat-rs44", "name": "RS-44 Satellite", "description": "Work 44 QSOs on satellite RS-44", + "caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts toward the total.", "category": "custom", "rules": { "type": "counter", diff --git a/award-definitions/special-stations.json b/award-definitions/special-stations.json index c57de3a..99b97c7 100644 --- a/award-definitions/special-stations.json +++ b/award-definitions/special-stations.json @@ -1,11 +1,13 @@ { - "id": "special-stations", - "name": "Special Stations Award", - "description": "Contact special stations to earn points - reach 50 points to complete", + "id": "wavelog-award", + "name": "Wavelog Award", + "description": "Contact special stations on multiple bands and modes to earn points - reach 50 points to complete", + "caption": "Contact special stations to earn points. Points are awarded for each unique band/mode combination confirmed. 10-point stations: DF2ET, DJ7NT, HB9HIL, LA8AJA. 5-point stations: DB4SCW, DG2RON, DG0TM, DO8MKR. Example: Working DF2ET on 20m/SSB and 20m/CW earns 20 points. Same band/mode combinations are only counted once. Only LoTW-confirmed QSOs count.", "category": "special", "rules": { "type": "points", "target": 50, + "countMode": "perBandMode", "stations": [ { "callsign": "DF2ET", "points": 10 }, { "callsign": "DJ7NT", "points": 10 }, diff --git a/award-definitions/vucc-sat.json b/award-definitions/vucc-sat.json index 27d3b39..bc1da0f 100644 --- a/award-definitions/vucc-sat.json +++ b/award-definitions/vucc-sat.json @@ -2,6 +2,7 @@ "id": "vucc-satellite", "name": "VUCC Satellite", "description": "Confirm 100 unique grid squares via satellite", + "caption": "Contact and confirm 100 unique 4-character grid squares via satellite. Only satellite QSOs count. Grid squares are counted as the first 4 characters (e.g., FN31). QSOs are confirmed when LoTW QSL is received.", "category": "vucc", "rules": { "type": "entity", diff --git a/award-definitions/was.json b/award-definitions/was.json index b4cf3dc..7f01bda 100644 --- a/award-definitions/was.json +++ b/award-definitions/was.json @@ -2,6 +2,7 @@ "id": "was-mixed", "name": "WAS Mixed Mode", "description": "Confirm all 50 US states", + "caption": "Contact and confirm all 50 US states. Only QSOs with stations located in United States states count toward this award. QSOs are confirmed when LoTW QSL is received.", "category": "was", "rules": { "type": "entity", diff --git a/src/backend/services/awards.service.js b/src/backend/services/awards.service.js index 074e289..c869cc5 100644 --- a/src/backend/services/awards.service.js +++ b/src/backend/services/awards.service.js @@ -26,6 +26,7 @@ function loadAwardDefinitions() { 'was.json', 'vucc-sat.json', 'sat-rs44.json', + 'special-stations.json', ]; for (const file of files) { @@ -55,6 +56,7 @@ export async function getAllAwards() { id: def.id, name: def.name, description: def.description, + caption: def.caption, category: def.category, rules: def.rules, })); @@ -168,9 +170,13 @@ export async function calculateAwardProgress(userId, award) { /** * Calculate progress for point-based awards + * countMode determines how points are counted: + * - "perBandMode": each unique (callsign, band, mode) combination earns points + * - "perStation": each unique station earns points once + * - "perQso": every confirmed QSO earns points */ async function calculatePointsAwardProgress(userId, rules) { - const { stations, target } = rules; + const { stations, target, countMode = 'perStation' } = rules; // Create a map of callsign -> points for quick lookup const stationPoints = new Map(); @@ -180,6 +186,7 @@ async function calculatePointsAwardProgress(userId, rules) { logger.debug('Point-based award stations', { totalStations: stations.length, + countMode, maxPoints: stations.reduce((sum, s) => sum + s.points, 0), }); @@ -189,72 +196,144 @@ async function calculatePointsAwardProgress(userId, rules) { .from(qsos) .where(eq(qsos.userId, userId)); - // Calculate points from confirmed QSOs with special stations - const workedStations = new Set(); // Callsigns worked (any QSO) - const confirmedStations = new Map(); // Callsign -> points from confirmed QSOs - const stationDetails = []; // Array of station details + const workedStations = new Set(); // Unique callsigns worked + let totalPoints = 0; + const stationDetails = []; - for (const qso of allQSOs) { - const callsign = qso.callsign?.toUpperCase(); - if (!callsign) continue; + if (countMode === 'perBandMode') { + // Count unique (callsign, band, mode) combinations + const workedCombinations = new Set(); + const confirmedCombinations = new Map(); - const points = stationPoints.get(callsign); - if (!points) continue; // Not a special station + for (const qso of allQSOs) { + const callsign = qso.callsign?.toUpperCase(); + if (!callsign) continue; + + const points = stationPoints.get(callsign); + if (!points) continue; + + const band = qso.band || 'Unknown'; + const mode = qso.mode || 'Unknown'; + const combinationKey = `${callsign}/${band}/${mode}`; - // Track worked stations - if (!workedStations.has(callsign)) { workedStations.add(callsign); - stationDetails.push({ - callsign, - points, - worked: true, - confirmed: false, - qsoDate: qso.qsoDate, - band: qso.band, - mode: qso.mode, - }); - } - // Track confirmed stations (only confirmed QSOs count for points) - if (qso.lotwQslRstatus === 'Y' && !confirmedStations.has(callsign)) { - confirmedStations.set(callsign, points); + if (!workedCombinations.has(combinationKey)) { + workedCombinations.add(combinationKey); + stationDetails.push({ + callsign, + band, + mode, + points, + worked: true, + confirmed: false, + qsoDate: qso.qsoDate, + }); + } - // Update the station detail to confirmed - const detail = stationDetails.find((s) => s.callsign === callsign); - if (detail) { - detail.confirmed = true; - detail.lotwQslRdate = qso.lotwQslRdate; + if (qso.lotwQslRstatus === 'Y' && !confirmedCombinations.has(combinationKey)) { + confirmedCombinations.set(combinationKey, points); + const detail = stationDetails.find((c) => + c.callsign === callsign && c.band === band && c.mode === mode + ); + if (detail) { + detail.confirmed = true; + detail.lotwQslRdate = qso.lotwQslRdate; + } } } - } - // Calculate total points (sum of confirmed station points) - const totalPoints = Array.from(confirmedStations.values()).reduce((sum, points) => sum + points, 0); + totalPoints = Array.from(confirmedCombinations.values()).reduce((sum, p) => sum + p, 0); + } else if (countMode === 'perStation') { + // Count unique stations only + const workedStationsMap = new Map(); + + for (const qso of allQSOs) { + const callsign = qso.callsign?.toUpperCase(); + if (!callsign) continue; + + const points = stationPoints.get(callsign); + if (!points) continue; + + workedStations.add(callsign); + + if (!workedStationsMap.has(callsign)) { + workedStationsMap.set(callsign, { + callsign, + points, + worked: true, + confirmed: false, + qsoDate: qso.qsoDate, + band: qso.band, + mode: qso.mode, + }); + } + + if (qso.lotwQslRstatus === 'Y') { + const detail = workedStationsMap.get(callsign); + if (detail && !detail.confirmed) { + detail.confirmed = true; + detail.lotwQslRdate = qso.lotwQslRdate; + } + } + } + + totalPoints = Array.from(workedStationsMap.values()) + .filter((s) => s.confirmed) + .reduce((sum, s) => sum + s.points, 0); + + stationDetails.push(...workedStationsMap.values()); + } else if (countMode === 'perQso') { + // Count every confirmed QSO + const qsoCount = { worked: 0, confirmed: 0, points: 0 }; + + for (const qso of allQSOs) { + const callsign = qso.callsign?.toUpperCase(); + if (!callsign) continue; + + const points = stationPoints.get(callsign); + if (!points) continue; + + workedStations.add(callsign); + qsoCount.worked++; + + if (qso.lotwQslRstatus === 'Y') { + qsoCount.confirmed++; + qsoCount.points += points; + } + } + + totalPoints = qsoCount.points; + } logger.debug('Point-based award progress', { workedStations: workedStations.size, - confirmedStations: confirmedStations.size, totalPoints, target, }); return { worked: workedStations.size, - confirmed: confirmedStations.size, + confirmed: stationDetails.filter((s) => s.confirmed).length, totalPoints, target: target || 0, percentage: target ? Math.min(100, Math.round((totalPoints / target) * 100)) : 0, workedEntities: Array.from(workedStations), - confirmedEntities: Array.from(confirmedStations.keys()), + confirmedEntities: stationDetails.filter((s) => s.confirmed).map((s) => s.callsign), stationDetails, }; } /** * Get entity breakdown for point-based awards + * countMode determines what entities are shown: + * - "perBandMode": shows each (callsign, band, mode) combination + * - "perStation": shows each unique station + * - "perQso": shows every QSO (not recommended for large datasets) */ -async function getPointsAwardEntityBreakdown(userId, rules) { - const { stations, target } = rules; +async function getPointsAwardEntityBreakdown(userId, award) { + const { rules } = award; + const { stations, target, countMode = 'perStation' } = rules; // Create a map of callsign -> points for quick lookup const stationPoints = new Map(); @@ -268,24 +347,100 @@ async function getPointsAwardEntityBreakdown(userId, rules) { .from(qsos) .where(eq(qsos.userId, userId)); - // Build list of special stations with QSO info - const stationMap = new Map(); // callsign -> station data + let entities = []; + let totalPoints = 0; - for (const qso of allQSOs) { - const callsign = qso.callsign?.toUpperCase(); - if (!callsign) continue; + if (countMode === 'perBandMode') { + // Show each (callsign, band, mode) combination + const combinationMap = new Map(); - const points = stationPoints.get(callsign); - if (!points) continue; // Not a special station + for (const qso of allQSOs) { + const callsign = qso.callsign?.toUpperCase(); + if (!callsign) continue; - // Only add if not already in map - if (!stationMap.has(callsign)) { - stationMap.set(callsign, { - entity: callsign, + const points = stationPoints.get(callsign); + if (!points) continue; + + const band = qso.band || 'Unknown'; + const mode = qso.mode || 'Unknown'; + const combinationKey = `${callsign}/${band}/${mode}`; + + if (!combinationMap.has(combinationKey)) { + combinationMap.set(combinationKey, { + entity: combinationKey, + entityId: null, + entityName: `${callsign} (${band}/${mode})`, + points, + worked: true, + confirmed: qso.lotwQslRstatus === 'Y', + qsoDate: qso.qsoDate, + band: qso.band, + mode: qso.mode, + callsign: qso.callsign, + lotwQslRdate: qso.lotwQslRdate, + }); + } else { + const data = combinationMap.get(combinationKey); + if (!data.confirmed && qso.lotwQslRstatus === 'Y') { + data.confirmed = true; + data.lotwQslRdate = qso.lotwQslRdate; + } + } + } + + entities = Array.from(combinationMap.values()); + totalPoints = entities.filter((e) => e.confirmed).reduce((sum, e) => sum + e.points, 0); + } else if (countMode === 'perStation') { + // Show each unique station + const stationMap = new Map(); + + for (const qso of allQSOs) { + const callsign = qso.callsign?.toUpperCase(); + if (!callsign) continue; + + const points = stationPoints.get(callsign); + if (!points) continue; + + if (!stationMap.has(callsign)) { + stationMap.set(callsign, { + entity: callsign, + entityId: null, + entityName: callsign, + points, + worked: true, + confirmed: qso.lotwQslRstatus === 'Y', + qsoDate: qso.qsoDate, + band: qso.band, + mode: qso.mode, + callsign: qso.callsign, + lotwQslRdate: qso.lotwQslRdate, + }); + } else { + const data = stationMap.get(callsign); + if (!data.confirmed && qso.lotwQslRstatus === 'Y') { + data.confirmed = true; + data.lotwQslRdate = qso.lotwQslRdate; + } + } + } + + entities = Array.from(stationMap.values()); + totalPoints = entities.filter((e) => e.confirmed).reduce((sum, e) => sum + e.points, 0); + } else if (countMode === 'perQso') { + // Show every QSO (use with caution) + for (const qso of allQSOs) { + const callsign = qso.callsign?.toUpperCase(); + if (!callsign) continue; + + const points = stationPoints.get(callsign); + if (!points) continue; + + entities.push({ + entity: `${callsign}-${qso.qsoDate}`, entityId: null, - entityName: callsign, + entityName: `${callsign} on ${qso.qsoDate}`, points, - worked: true, // Has QSO + worked: true, confirmed: qso.lotwQslRstatus === 'Y', qsoDate: qso.qsoDate, band: qso.band, @@ -293,29 +448,24 @@ async function getPointsAwardEntityBreakdown(userId, rules) { callsign: qso.callsign, lotwQslRdate: qso.lotwQslRdate, }); - } else { - // Update to confirmed if this QSO is confirmed - const data = stationMap.get(callsign); - if (!data.confirmed && qso.lotwQslRstatus === 'Y') { - data.confirmed = true; - data.lotwQslRdate = qso.lotwQslRdate; + + if (qso.lotwQslRstatus === 'Y') { + totalPoints += points; } } } - const stationList = Array.from(stationMap.values()); - const confirmedStations = stationList.filter((s) => s.confirmed); - const totalPoints = confirmedStations.reduce((sum, s) => sum + s.points, 0); - return { award: { - id: 'special-stations', - name: 'Special Stations Award', - description: 'Contact special stations to earn points', + id: award.id, + name: award.name, + description: award.description, + caption: award.caption, + target: award.rules?.target || 0, }, - entities: stationList, - total: stationList.length, - confirmed: confirmedStations.length, + entities, + total: entities.length, + confirmed: entities.filter((e) => e.confirmed).length, totalPoints, }; } @@ -407,6 +557,7 @@ export async function getAwardProgressDetails(userId, awardId) { id: award.id, name: award.name, description: award.description, + caption: award.caption, category: award.category, }, ...progress, @@ -431,7 +582,7 @@ export async function getAwardEntityBreakdown(userId, awardId) { // Handle point-based awards if (rules.type === 'points') { - return getPointsAwardEntityBreakdown(userId, rules); + return getPointsAwardEntityBreakdown(userId, award); } // Get all QSOs for user @@ -495,6 +646,8 @@ export async function getAwardEntityBreakdown(userId, awardId) { id: award.id, name: award.name, description: award.description, + caption: award.caption, + target: award.rules?.target || 0, }, entities: Array.from(entityMap.values()), total: entityMap.size, diff --git a/src/frontend/src/routes/awards/[id]/+page.svelte b/src/frontend/src/routes/awards/[id]/+page.svelte index e2ef5c6..3b90293 100644 --- a/src/frontend/src/routes/awards/[id]/+page.svelte +++ b/src/frontend/src/routes/awards/[id]/+page.svelte @@ -113,6 +113,9 @@

{award.name}

{award.description}

+ {#if award.caption} +
{award.caption}
+ {/if} ← Back to Awards
@@ -137,9 +140,12 @@
- {#if award && award.rules?.type === 'points'} + {#if award && award.target && award.target > 0} + {@const earnedPoints = entities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)} + {@const targetPoints = award.target} + {@const neededPoints = Math.max(0, targetPoints - earnedPoints)}
- Total Stations: + Total Combinations: {entities.length}
@@ -148,11 +154,15 @@
Points: - {entities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)} + {earnedPoints} +
+
+ Needed: + {neededPoints}
Target: - {entities.reduce((sum, e) => sum + e.points, 0)} + {targetPoints}
{:else}
@@ -253,6 +263,17 @@ margin-bottom: 1rem; } + .caption { + background-color: #f0f7ff; + border-left: 4px solid #4a90e2; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 4px; + font-size: 0.95rem; + line-height: 1.6; + color: #333; + } + .back-link { display: inline-block; color: #4a90e2; diff --git a/src/frontend/static/award-definitions/dxcc-cw.json b/src/frontend/static/award-definitions/dxcc-cw.json index 68e15c8..92f1475 100644 --- a/src/frontend/static/award-definitions/dxcc-cw.json +++ b/src/frontend/static/award-definitions/dxcc-cw.json @@ -2,6 +2,7 @@ "id": "dxcc-cw", "name": "DXCC CW", "description": "Confirm 100 DXCC entities using CW mode", + "caption": "Contact and confirm 100 different DXCC entities using CW mode only. Only QSOs made with CW mode count toward this award. QSOs are confirmed when LoTW QSL is received.", "category": "dxcc", "rules": { "type": "filtered", diff --git a/src/frontend/static/award-definitions/dxcc.json b/src/frontend/static/award-definitions/dxcc.json index 65320dd..c96205d 100644 --- a/src/frontend/static/award-definitions/dxcc.json +++ b/src/frontend/static/award-definitions/dxcc.json @@ -2,6 +2,7 @@ "id": "dxcc-mixed", "name": "DXCC Mixed Mode", "description": "Confirm 100 DXCC entities on any band/mode", + "caption": "Contact and confirm 100 different DXCC entities. Any band and mode combination counts. QSOs are confirmed when LoTW QSL is received.", "category": "dxcc", "rules": { "type": "entity", diff --git a/src/frontend/static/award-definitions/sat-rs44.json b/src/frontend/static/award-definitions/sat-rs44.json index ed13870..bed903e 100644 --- a/src/frontend/static/award-definitions/sat-rs44.json +++ b/src/frontend/static/award-definitions/sat-rs44.json @@ -2,6 +2,7 @@ "id": "sat-rs44", "name": "RS-44 Satellite", "description": "Work 44 QSOs on satellite RS-44", + "caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts toward the total.", "category": "custom", "rules": { "type": "counter", diff --git a/src/frontend/static/award-definitions/special-stations.json b/src/frontend/static/award-definitions/special-stations.json index c57de3a..99b97c7 100644 --- a/src/frontend/static/award-definitions/special-stations.json +++ b/src/frontend/static/award-definitions/special-stations.json @@ -1,11 +1,13 @@ { - "id": "special-stations", - "name": "Special Stations Award", - "description": "Contact special stations to earn points - reach 50 points to complete", + "id": "wavelog-award", + "name": "Wavelog Award", + "description": "Contact special stations on multiple bands and modes to earn points - reach 50 points to complete", + "caption": "Contact special stations to earn points. Points are awarded for each unique band/mode combination confirmed. 10-point stations: DF2ET, DJ7NT, HB9HIL, LA8AJA. 5-point stations: DB4SCW, DG2RON, DG0TM, DO8MKR. Example: Working DF2ET on 20m/SSB and 20m/CW earns 20 points. Same band/mode combinations are only counted once. Only LoTW-confirmed QSOs count.", "category": "special", "rules": { "type": "points", "target": 50, + "countMode": "perBandMode", "stations": [ { "callsign": "DF2ET", "points": 10 }, { "callsign": "DJ7NT", "points": 10 }, diff --git a/src/frontend/static/award-definitions/vucc-sat.json b/src/frontend/static/award-definitions/vucc-sat.json index 27d3b39..bc1da0f 100644 --- a/src/frontend/static/award-definitions/vucc-sat.json +++ b/src/frontend/static/award-definitions/vucc-sat.json @@ -2,6 +2,7 @@ "id": "vucc-satellite", "name": "VUCC Satellite", "description": "Confirm 100 unique grid squares via satellite", + "caption": "Contact and confirm 100 unique 4-character grid squares via satellite. Only satellite QSOs count. Grid squares are counted as the first 4 characters (e.g., FN31). QSOs are confirmed when LoTW QSL is received.", "category": "vucc", "rules": { "type": "entity", diff --git a/src/frontend/static/award-definitions/was.json b/src/frontend/static/award-definitions/was.json index b4cf3dc..7f01bda 100644 --- a/src/frontend/static/award-definitions/was.json +++ b/src/frontend/static/award-definitions/was.json @@ -2,6 +2,7 @@ "id": "was-mixed", "name": "WAS Mixed Mode", "description": "Confirm all 50 US states", + "caption": "Contact and confirm all 50 US states. Only QSOs with stations located in United States states count toward this award. QSOs are confirmed when LoTW QSL is received.", "category": "was", "rules": { "type": "entity",