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 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 10:14:41 +01:00
parent ac79645477
commit 436e9c2278
14 changed files with 266 additions and 78 deletions

View File

@@ -2,6 +2,7 @@
"id": "dxcc-cw", "id": "dxcc-cw",
"name": "DXCC CW", "name": "DXCC CW",
"description": "Confirm 100 DXCC entities using CW mode", "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", "category": "dxcc",
"rules": { "rules": {
"type": "filtered", "type": "filtered",

View File

@@ -2,6 +2,7 @@
"id": "dxcc-mixed", "id": "dxcc-mixed",
"name": "DXCC Mixed Mode", "name": "DXCC Mixed Mode",
"description": "Confirm 100 DXCC entities on any band/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", "category": "dxcc",
"rules": { "rules": {
"type": "entity", "type": "entity",

View File

@@ -2,6 +2,7 @@
"id": "sat-rs44", "id": "sat-rs44",
"name": "RS-44 Satellite", "name": "RS-44 Satellite",
"description": "Work 44 QSOs on satellite RS-44", "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", "category": "custom",
"rules": { "rules": {
"type": "counter", "type": "counter",

View File

@@ -1,11 +1,13 @@
{ {
"id": "special-stations", "id": "wavelog-award",
"name": "Special Stations Award", "name": "Wavelog Award",
"description": "Contact special stations to earn points - reach 50 points to complete", "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", "category": "special",
"rules": { "rules": {
"type": "points", "type": "points",
"target": 50, "target": 50,
"countMode": "perBandMode",
"stations": [ "stations": [
{ "callsign": "DF2ET", "points": 10 }, { "callsign": "DF2ET", "points": 10 },
{ "callsign": "DJ7NT", "points": 10 }, { "callsign": "DJ7NT", "points": 10 },

View File

@@ -2,6 +2,7 @@
"id": "vucc-satellite", "id": "vucc-satellite",
"name": "VUCC Satellite", "name": "VUCC Satellite",
"description": "Confirm 100 unique grid squares via 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", "category": "vucc",
"rules": { "rules": {
"type": "entity", "type": "entity",

View File

@@ -2,6 +2,7 @@
"id": "was-mixed", "id": "was-mixed",
"name": "WAS Mixed Mode", "name": "WAS Mixed Mode",
"description": "Confirm all 50 US states", "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", "category": "was",
"rules": { "rules": {
"type": "entity", "type": "entity",

View File

@@ -26,6 +26,7 @@ function loadAwardDefinitions() {
'was.json', 'was.json',
'vucc-sat.json', 'vucc-sat.json',
'sat-rs44.json', 'sat-rs44.json',
'special-stations.json',
]; ];
for (const file of files) { for (const file of files) {
@@ -55,6 +56,7 @@ export async function getAllAwards() {
id: def.id, id: def.id,
name: def.name, name: def.name,
description: def.description, description: def.description,
caption: def.caption,
category: def.category, category: def.category,
rules: def.rules, rules: def.rules,
})); }));
@@ -168,9 +170,13 @@ export async function calculateAwardProgress(userId, award) {
/** /**
* Calculate progress for point-based awards * 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) { async function calculatePointsAwardProgress(userId, rules) {
const { stations, target } = rules; const { stations, target, countMode = 'perStation' } = rules;
// Create a map of callsign -> points for quick lookup // Create a map of callsign -> points for quick lookup
const stationPoints = new Map(); const stationPoints = new Map();
@@ -180,6 +186,7 @@ async function calculatePointsAwardProgress(userId, rules) {
logger.debug('Point-based award stations', { logger.debug('Point-based award stations', {
totalStations: stations.length, totalStations: stations.length,
countMode,
maxPoints: stations.reduce((sum, s) => sum + s.points, 0), maxPoints: stations.reduce((sum, s) => sum + s.points, 0),
}); });
@@ -189,22 +196,69 @@ async function calculatePointsAwardProgress(userId, rules) {
.from(qsos) .from(qsos)
.where(eq(qsos.userId, userId)); .where(eq(qsos.userId, userId));
// Calculate points from confirmed QSOs with special stations const workedStations = new Set(); // Unique callsigns worked
const workedStations = new Set(); // Callsigns worked (any QSO) let totalPoints = 0;
const confirmedStations = new Map(); // Callsign -> points from confirmed QSOs const stationDetails = [];
const stationDetails = []; // Array of station details
if (countMode === 'perBandMode') {
// Count unique (callsign, band, mode) combinations
const workedCombinations = new Set();
const confirmedCombinations = new Map();
for (const qso of allQSOs) { for (const qso of allQSOs) {
const callsign = qso.callsign?.toUpperCase(); const callsign = qso.callsign?.toUpperCase();
if (!callsign) continue; if (!callsign) continue;
const points = stationPoints.get(callsign); const points = stationPoints.get(callsign);
if (!points) continue; // Not a special station 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); workedStations.add(callsign);
if (!workedCombinations.has(combinationKey)) {
workedCombinations.add(combinationKey);
stationDetails.push({ stationDetails.push({
callsign,
band,
mode,
points,
worked: true,
confirmed: false,
qsoDate: qso.qsoDate,
});
}
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;
}
}
}
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, callsign,
points, points,
worked: true, worked: true,
@@ -215,46 +269,71 @@ async function calculatePointsAwardProgress(userId, rules) {
}); });
} }
// Track confirmed stations (only confirmed QSOs count for points) if (qso.lotwQslRstatus === 'Y') {
if (qso.lotwQslRstatus === 'Y' && !confirmedStations.has(callsign)) { const detail = workedStationsMap.get(callsign);
confirmedStations.set(callsign, points); if (detail && !detail.confirmed) {
// Update the station detail to confirmed
const detail = stationDetails.find((s) => s.callsign === callsign);
if (detail) {
detail.confirmed = true; detail.confirmed = true;
detail.lotwQslRdate = qso.lotwQslRdate; detail.lotwQslRdate = qso.lotwQslRdate;
} }
} }
} }
// Calculate total points (sum of confirmed station points) totalPoints = Array.from(workedStationsMap.values())
const totalPoints = Array.from(confirmedStations.values()).reduce((sum, points) => sum + points, 0); .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', { logger.debug('Point-based award progress', {
workedStations: workedStations.size, workedStations: workedStations.size,
confirmedStations: confirmedStations.size,
totalPoints, totalPoints,
target, target,
}); });
return { return {
worked: workedStations.size, worked: workedStations.size,
confirmed: confirmedStations.size, confirmed: stationDetails.filter((s) => s.confirmed).length,
totalPoints, totalPoints,
target: target || 0, target: target || 0,
percentage: target ? Math.min(100, Math.round((totalPoints / target) * 100)) : 0, percentage: target ? Math.min(100, Math.round((totalPoints / target) * 100)) : 0,
workedEntities: Array.from(workedStations), workedEntities: Array.from(workedStations),
confirmedEntities: Array.from(confirmedStations.keys()), confirmedEntities: stationDetails.filter((s) => s.confirmed).map((s) => s.callsign),
stationDetails, stationDetails,
}; };
} }
/** /**
* Get entity breakdown for point-based awards * 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) { async function getPointsAwardEntityBreakdown(userId, award) {
const { stations, target } = rules; const { rules } = award;
const { stations, target, countMode = 'perStation' } = rules;
// Create a map of callsign -> points for quick lookup // Create a map of callsign -> points for quick lookup
const stationPoints = new Map(); const stationPoints = new Map();
@@ -268,24 +347,67 @@ async function getPointsAwardEntityBreakdown(userId, rules) {
.from(qsos) .from(qsos)
.where(eq(qsos.userId, userId)); .where(eq(qsos.userId, userId));
// Build list of special stations with QSO info let entities = [];
const stationMap = new Map(); // callsign -> station data let totalPoints = 0;
if (countMode === 'perBandMode') {
// Show each (callsign, band, mode) combination
const combinationMap = new Map();
for (const qso of allQSOs) { for (const qso of allQSOs) {
const callsign = qso.callsign?.toUpperCase(); const callsign = qso.callsign?.toUpperCase();
if (!callsign) continue; if (!callsign) continue;
const points = stationPoints.get(callsign); const points = stationPoints.get(callsign);
if (!points) continue; // Not a special station if (!points) continue;
// Only add if not already in map const band = qso.band || 'Unknown';
if (!stationMap.has(callsign)) { const mode = qso.mode || 'Unknown';
stationMap.set(callsign, { const combinationKey = `${callsign}/${band}/${mode}`;
entity: callsign,
if (!combinationMap.has(combinationKey)) {
combinationMap.set(combinationKey, {
entity: combinationKey,
entityId: null, entityId: null,
entityName: callsign, entityName: `${callsign} (${band}/${mode})`,
points, points,
worked: true, // Has QSO 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', confirmed: qso.lotwQslRstatus === 'Y',
qsoDate: qso.qsoDate, qsoDate: qso.qsoDate,
band: qso.band, band: qso.band,
@@ -294,7 +416,6 @@ async function getPointsAwardEntityBreakdown(userId, rules) {
lotwQslRdate: qso.lotwQslRdate, lotwQslRdate: qso.lotwQslRdate,
}); });
} else { } else {
// Update to confirmed if this QSO is confirmed
const data = stationMap.get(callsign); const data = stationMap.get(callsign);
if (!data.confirmed && qso.lotwQslRstatus === 'Y') { if (!data.confirmed && qso.lotwQslRstatus === 'Y') {
data.confirmed = true; data.confirmed = true;
@@ -303,19 +424,48 @@ async function getPointsAwardEntityBreakdown(userId, rules) {
} }
} }
const stationList = Array.from(stationMap.values()); entities = Array.from(stationMap.values());
const confirmedStations = stationList.filter((s) => s.confirmed); totalPoints = entities.filter((e) => e.confirmed).reduce((sum, e) => sum + e.points, 0);
const totalPoints = confirmedStations.reduce((sum, s) => sum + s.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} on ${qso.qsoDate}`,
points,
worked: true,
confirmed: qso.lotwQslRstatus === 'Y',
qsoDate: qso.qsoDate,
band: qso.band,
mode: qso.mode,
callsign: qso.callsign,
lotwQslRdate: qso.lotwQslRdate,
});
if (qso.lotwQslRstatus === 'Y') {
totalPoints += points;
}
}
}
return { return {
award: { award: {
id: 'special-stations', id: award.id,
name: 'Special Stations Award', name: award.name,
description: 'Contact special stations to earn points', description: award.description,
caption: award.caption,
target: award.rules?.target || 0,
}, },
entities: stationList, entities,
total: stationList.length, total: entities.length,
confirmed: confirmedStations.length, confirmed: entities.filter((e) => e.confirmed).length,
totalPoints, totalPoints,
}; };
} }
@@ -407,6 +557,7 @@ export async function getAwardProgressDetails(userId, awardId) {
id: award.id, id: award.id,
name: award.name, name: award.name,
description: award.description, description: award.description,
caption: award.caption,
category: award.category, category: award.category,
}, },
...progress, ...progress,
@@ -431,7 +582,7 @@ export async function getAwardEntityBreakdown(userId, awardId) {
// Handle point-based awards // Handle point-based awards
if (rules.type === 'points') { if (rules.type === 'points') {
return getPointsAwardEntityBreakdown(userId, rules); return getPointsAwardEntityBreakdown(userId, award);
} }
// Get all QSOs for user // Get all QSOs for user
@@ -495,6 +646,8 @@ export async function getAwardEntityBreakdown(userId, awardId) {
id: award.id, id: award.id,
name: award.name, name: award.name,
description: award.description, description: award.description,
caption: award.caption,
target: award.rules?.target || 0,
}, },
entities: Array.from(entityMap.values()), entities: Array.from(entityMap.values()),
total: entityMap.size, total: entityMap.size,

View File

@@ -113,6 +113,9 @@
<div class="award-header"> <div class="award-header">
<h1>{award.name}</h1> <h1>{award.name}</h1>
<p class="description">{award.description}</p> <p class="description">{award.description}</p>
{#if award.caption}
<div class="caption">{award.caption}</div>
{/if}
<a href="/awards" class="back-link">← Back to Awards</a> <a href="/awards" class="back-link">← Back to Awards</a>
</div> </div>
@@ -137,9 +140,12 @@
</div> </div>
<div class="summary"> <div class="summary">
{#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)}
<div class="summary-card"> <div class="summary-card">
<span class="summary-label">Total Stations:</span> <span class="summary-label">Total Combinations:</span>
<span class="summary-value">{entities.length}</span> <span class="summary-value">{entities.length}</span>
</div> </div>
<div class="summary-card confirmed"> <div class="summary-card confirmed">
@@ -148,11 +154,15 @@
</div> </div>
<div class="summary-card" style="background-color: #fff3cd; border-color: #ffc107;"> <div class="summary-card" style="background-color: #fff3cd; border-color: #ffc107;">
<span class="summary-label">Points:</span> <span class="summary-label">Points:</span>
<span class="summary-value">{entities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}</span> <span class="summary-value">{earnedPoints}</span>
</div>
<div class="summary-card unworked">
<span class="summary-label">Needed:</span>
<span class="summary-value">{neededPoints}</span>
</div> </div>
<div class="summary-card" style="background-color: #e3f2fd; border-color: #2196f3;"> <div class="summary-card" style="background-color: #e3f2fd; border-color: #2196f3;">
<span class="summary-label">Target:</span> <span class="summary-label">Target:</span>
<span class="summary-value">{entities.reduce((sum, e) => sum + e.points, 0)}</span> <span class="summary-value">{targetPoints}</span>
</div> </div>
{:else} {:else}
<div class="summary-card"> <div class="summary-card">
@@ -253,6 +263,17 @@
margin-bottom: 1rem; 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 { .back-link {
display: inline-block; display: inline-block;
color: #4a90e2; color: #4a90e2;

View File

@@ -2,6 +2,7 @@
"id": "dxcc-cw", "id": "dxcc-cw",
"name": "DXCC CW", "name": "DXCC CW",
"description": "Confirm 100 DXCC entities using CW mode", "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", "category": "dxcc",
"rules": { "rules": {
"type": "filtered", "type": "filtered",

View File

@@ -2,6 +2,7 @@
"id": "dxcc-mixed", "id": "dxcc-mixed",
"name": "DXCC Mixed Mode", "name": "DXCC Mixed Mode",
"description": "Confirm 100 DXCC entities on any band/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", "category": "dxcc",
"rules": { "rules": {
"type": "entity", "type": "entity",

View File

@@ -2,6 +2,7 @@
"id": "sat-rs44", "id": "sat-rs44",
"name": "RS-44 Satellite", "name": "RS-44 Satellite",
"description": "Work 44 QSOs on satellite RS-44", "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", "category": "custom",
"rules": { "rules": {
"type": "counter", "type": "counter",

View File

@@ -1,11 +1,13 @@
{ {
"id": "special-stations", "id": "wavelog-award",
"name": "Special Stations Award", "name": "Wavelog Award",
"description": "Contact special stations to earn points - reach 50 points to complete", "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", "category": "special",
"rules": { "rules": {
"type": "points", "type": "points",
"target": 50, "target": 50,
"countMode": "perBandMode",
"stations": [ "stations": [
{ "callsign": "DF2ET", "points": 10 }, { "callsign": "DF2ET", "points": 10 },
{ "callsign": "DJ7NT", "points": 10 }, { "callsign": "DJ7NT", "points": 10 },

View File

@@ -2,6 +2,7 @@
"id": "vucc-satellite", "id": "vucc-satellite",
"name": "VUCC Satellite", "name": "VUCC Satellite",
"description": "Confirm 100 unique grid squares via 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", "category": "vucc",
"rules": { "rules": {
"type": "entity", "type": "entity",

View File

@@ -2,6 +2,7 @@
"id": "was-mixed", "id": "was-mixed",
"name": "WAS Mixed Mode", "name": "WAS Mixed Mode",
"description": "Confirm all 50 US states", "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", "category": "was",
"rules": { "rules": {
"type": "entity", "type": "entity",