From a1886a94c8648e17ef2468c80912d7ddc41de28a Mon Sep 17 00:00:00 2001 From: Joerg Date: Fri, 16 Jan 2026 09:33:53 +0100 Subject: [PATCH] Add point-based award system with station-specific values Implement a new award type where stations have different point values. Each confirmed QSO with a special station adds its points to the total. New Award: Special Stations Award - 10-point stations: DF2ET, DJ7NT, HB9HIL, LA8AJA - 5-point stations: DB4SCW, DG2RON, DG0TM, DO8MKR - Target: 50 points to complete Backend Changes - Add support for 'points' award type - Add calculatePointsAwardProgress() for point calculation - Add getPointsAwardEntityBreakdown() for station details - Track worked, confirmed, and totalPoints separately Frontend Changes - Awards page: Show stations and points for point awards - Details page: Show point summary cards - Details page: Show points badge next to station callsign - Gold/amber gradient badge for point values Co-Authored-By: Claude Sonnet 4.5 --- award-definitions/special-stations.json | 20 ++ src/backend/services/awards.service.js | 173 ++++++++++++++++++ src/frontend/src/routes/awards/+page.svelte | 18 +- .../src/routes/awards/[id]/+page.svelte | 64 +++++-- .../award-definitions/special-stations.json | 20 ++ 5 files changed, 274 insertions(+), 21 deletions(-) create mode 100644 award-definitions/special-stations.json create mode 100644 src/frontend/static/award-definitions/special-stations.json diff --git a/award-definitions/special-stations.json b/award-definitions/special-stations.json new file mode 100644 index 0000000..c57de3a --- /dev/null +++ b/award-definitions/special-stations.json @@ -0,0 +1,20 @@ +{ + "id": "special-stations", + "name": "Special Stations Award", + "description": "Contact special stations to earn points - reach 50 points to complete", + "category": "special", + "rules": { + "type": "points", + "target": 50, + "stations": [ + { "callsign": "DF2ET", "points": 10 }, + { "callsign": "DJ7NT", "points": 10 }, + { "callsign": "HB9HIL", "points": 10 }, + { "callsign": "LA8AJA", "points": 10 }, + { "callsign": "DB4SCW", "points": 5 }, + { "callsign": "DG2RON", "points": 5 }, + { "callsign": "DG0TM", "points": 5 }, + { "callsign": "DO8MKR", "points": 5 } + ] + } +} diff --git a/src/backend/services/awards.service.js b/src/backend/services/awards.service.js index 0ffd08a..b947ae4 100644 --- a/src/backend/services/awards.service.js +++ b/src/backend/services/awards.service.js @@ -87,6 +87,15 @@ function normalizeAwardRules(rules) { }; } + // Handle "points" type awards (station-specific point values) + // Keep as-is but validate stations array exists + if (rules.type === 'points') { + if (!rules.stations || !Array.isArray(rules.stations)) { + logger.warn('Point-based award missing stations array'); + } + return rules; // Return as-is for special handling + } + return rules; } @@ -109,6 +118,11 @@ export async function calculateAwardProgress(userId, award) { hasFilters: !!rules.filters, }); + // Handle point-based awards + if (rules.type === 'points') { + return calculatePointsAwardProgress(userId, rules); + } + // Get all QSOs for user const allQSOs = await db .select() @@ -152,6 +166,160 @@ export async function calculateAwardProgress(userId, award) { }; } +/** + * Calculate progress for point-based awards + */ +async function calculatePointsAwardProgress(userId, rules) { + const { stations, target } = rules; + + // Create a map of callsign -> points for quick lookup + const stationPoints = new Map(); + for (const station of stations) { + stationPoints.set(station.callsign.toUpperCase(), station.points); + } + + logger.debug('Point-based award stations', { + totalStations: stations.length, + maxPoints: stations.reduce((sum, s) => sum + s.points, 0), + }); + + // Get all QSOs for user + const allQSOs = await db + .select() + .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 + + for (const qso of allQSOs) { + const callsign = qso.callsign?.toUpperCase(); + if (!callsign) continue; + + const points = stationPoints.get(callsign); + if (!points) continue; // Not a special station + + // 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); + + // Update the station detail to confirmed + const detail = stationDetails.find((s) => s.callsign === callsign); + 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); + + logger.debug('Point-based award progress', { + workedStations: workedStations.size, + confirmedStations: confirmedStations.size, + totalPoints, + target, + }); + + return { + worked: workedStations.size, + confirmed: confirmedStations.size, + totalPoints, + target: target || 0, + percentage: target ? Math.min(100, Math.round((totalPoints / target) * 100)) : 0, + workedEntities: Array.from(workedStations), + confirmedEntities: Array.from(confirmedStations.keys()), + stationDetails, + }; +} + +/** + * Get entity breakdown for point-based awards + */ +async function getPointsAwardEntityBreakdown(userId, rules) { + const { stations, target } = rules; + + // Create a map of callsign -> points for quick lookup + const stationPoints = new Map(); + for (const station of stations) { + stationPoints.set(station.callsign.toUpperCase(), station.points); + } + + // Get all QSOs for user + const allQSOs = await db + .select() + .from(qsos) + .where(eq(qsos.userId, userId)); + + // Build list of special stations with QSO info + const stationMap = new Map(); // callsign -> station data + + for (const qso of allQSOs) { + const callsign = qso.callsign?.toUpperCase(); + if (!callsign) continue; + + const points = stationPoints.get(callsign); + if (!points) continue; // Not a special station + + // Only add if not already in map + if (!stationMap.has(callsign)) { + stationMap.set(callsign, { + entity: callsign, + entityId: null, + entityName: callsign, + points, + worked: true, // Has QSO + confirmed: qso.lotwQslRstatus === 'Y', + qsoDate: qso.qsoDate, + band: qso.band, + mode: qso.mode, + 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; + } + } + } + + const stations = Array.from(stationMap.values()); + const confirmedStations = stations.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', + }, + entities: stations, + total: stations.length, + confirmed: confirmedStations.length, + totalPoints, + }; +} + /** * Get entity value from QSO based on entity type */ @@ -261,6 +429,11 @@ export async function getAwardEntityBreakdown(userId, awardId) { // Normalize rules to handle different formats rules = normalizeAwardRules(rules); + // Handle point-based awards + if (rules.type === 'points') { + return getPointsAwardEntityBreakdown(userId, rules); + } + // Get all QSOs for user const allQSOs = await db .select() diff --git a/src/frontend/src/routes/awards/+page.svelte b/src/frontend/src/routes/awards/+page.svelte index ab9630a..1f7d17c 100644 --- a/src/frontend/src/routes/awards/+page.svelte +++ b/src/frontend/src/routes/awards/+page.svelte @@ -120,11 +120,19 @@ style="width: {award.progress.percentage}%" > -
- Worked: {award.progress.worked} - Confirmed: {award.progress.confirmed} -
-
{award.progress.percentage}% complete
+ {#if award.progress.totalPoints !== undefined} +
+ Stations: {award.progress.confirmed} + Points: {award.progress.totalPoints} +
+
{award.progress.percentage}% complete ({award.progress.totalPoints}/{award.progress.target} points)
+ {:else} +
+ Worked: {award.progress.worked} + Confirmed: {award.progress.confirmed} +
+
{award.progress.percentage}% complete
+ {/if} {/if} diff --git a/src/frontend/src/routes/awards/[id]/+page.svelte b/src/frontend/src/routes/awards/[id]/+page.svelte index 273408d..e2ef5c6 100644 --- a/src/frontend/src/routes/awards/[id]/+page.svelte +++ b/src/frontend/src/routes/awards/[id]/+page.svelte @@ -137,22 +137,41 @@
-
- Total: - {entities.length} -
-
- Confirmed: - {entities.filter((e) => e.confirmed).length} -
-
- Worked: - {entities.filter((e) => e.worked).length} -
-
- Needed: - {entities.filter((e) => !e.worked).length} -
+ {#if award && award.rules?.type === 'points'} +
+ Total Stations: + {entities.length} +
+
+ Confirmed: + {entities.filter((e) => e.confirmed).length} +
+
+ Points: + {entities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)} +
+
+ Target: + {entities.reduce((sum, e) => sum + e.points, 0)} +
+ {:else} +
+ Total: + {entities.length} +
+
+ Confirmed: + {entities.filter((e) => e.confirmed).length} +
+
+ Worked: + {entities.filter((e) => e.worked).length} +
+
+ Needed: + {entities.filter((e) => !e.worked).length} +
+ {/if}
@@ -164,6 +183,9 @@
{entity.entityName || entity.entity || 'Unknown'} + {#if entity.points} + {entity.points} pts + {/if} {#if entity.entityId && entity.entityId !== entity.entityName} ({entity.entityId}) {/if} @@ -368,6 +390,16 @@ margin-left: 0.5rem; } + .points-badge { + font-size: 0.85rem; + font-weight: 600; + color: #fff; + background: linear-gradient(135deg, #ffc107 0%, #ff9800 100%); + padding: 0.2rem 0.5rem; + border-radius: 10px; + margin-left: 0.5rem; + } + .entity-details { display: flex; flex-wrap: wrap; diff --git a/src/frontend/static/award-definitions/special-stations.json b/src/frontend/static/award-definitions/special-stations.json new file mode 100644 index 0000000..c57de3a --- /dev/null +++ b/src/frontend/static/award-definitions/special-stations.json @@ -0,0 +1,20 @@ +{ + "id": "special-stations", + "name": "Special Stations Award", + "description": "Contact special stations to earn points - reach 50 points to complete", + "category": "special", + "rules": { + "type": "points", + "target": 50, + "stations": [ + { "callsign": "DF2ET", "points": 10 }, + { "callsign": "DJ7NT", "points": 10 }, + { "callsign": "HB9HIL", "points": 10 }, + { "callsign": "LA8AJA", "points": 10 }, + { "callsign": "DB4SCW", "points": 5 }, + { "callsign": "DG2RON", "points": 5 }, + { "callsign": "DG0TM", "points": 5 }, + { "callsign": "DO8MKR", "points": 5 } + ] + } +}