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 <noreply@anthropic.com>
This commit is contained in:
20
award-definitions/special-stations.json
Normal file
20
award-definitions/special-stations.json
Normal file
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
return rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +118,11 @@ export async function calculateAwardProgress(userId, award) {
|
|||||||
hasFilters: !!rules.filters,
|
hasFilters: !!rules.filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle point-based awards
|
||||||
|
if (rules.type === 'points') {
|
||||||
|
return calculatePointsAwardProgress(userId, rules);
|
||||||
|
}
|
||||||
|
|
||||||
// Get all QSOs for user
|
// Get all QSOs for user
|
||||||
const allQSOs = await db
|
const allQSOs = await db
|
||||||
.select()
|
.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
|
* 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
|
// Normalize rules to handle different formats
|
||||||
rules = normalizeAwardRules(rules);
|
rules = normalizeAwardRules(rules);
|
||||||
|
|
||||||
|
// Handle point-based awards
|
||||||
|
if (rules.type === 'points') {
|
||||||
|
return getPointsAwardEntityBreakdown(userId, rules);
|
||||||
|
}
|
||||||
|
|
||||||
// Get all QSOs for user
|
// Get all QSOs for user
|
||||||
const allQSOs = await db
|
const allQSOs = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -120,11 +120,19 @@
|
|||||||
style="width: {award.progress.percentage}%"
|
style="width: {award.progress.percentage}%"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-text">
|
{#if award.progress.totalPoints !== undefined}
|
||||||
<span class="worked">Worked: {award.progress.worked}</span>
|
<div class="progress-text">
|
||||||
<span class="confirmed">Confirmed: {award.progress.confirmed}</span>
|
<span class="worked">Stations: {award.progress.confirmed}</span>
|
||||||
</div>
|
<span class="confirmed">Points: {award.progress.totalPoints}</span>
|
||||||
<div class="percentage">{award.progress.percentage}% complete</div>
|
</div>
|
||||||
|
<div class="percentage">{award.progress.percentage}% complete ({award.progress.totalPoints}/{award.progress.target} points)</div>
|
||||||
|
{:else}
|
||||||
|
<div class="progress-text">
|
||||||
|
<span class="worked">Worked: {award.progress.worked}</span>
|
||||||
|
<span class="confirmed">Confirmed: {award.progress.confirmed}</span>
|
||||||
|
</div>
|
||||||
|
<div class="percentage">{award.progress.percentage}% complete</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -137,22 +137,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<div class="summary-card">
|
{#if award && award.rules?.type === 'points'}
|
||||||
<span class="summary-label">Total:</span>
|
<div class="summary-card">
|
||||||
<span class="summary-value">{entities.length}</span>
|
<span class="summary-label">Total Stations:</span>
|
||||||
</div>
|
<span class="summary-value">{entities.length}</span>
|
||||||
<div class="summary-card confirmed">
|
</div>
|
||||||
<span class="summary-label">Confirmed:</span>
|
<div class="summary-card confirmed">
|
||||||
<span class="summary-value">{entities.filter((e) => e.confirmed).length}</span>
|
<span class="summary-label">Confirmed:</span>
|
||||||
</div>
|
<span class="summary-value">{entities.filter((e) => e.confirmed).length}</span>
|
||||||
<div class="summary-card worked">
|
</div>
|
||||||
<span class="summary-label">Worked:</span>
|
<div class="summary-card" style="background-color: #fff3cd; border-color: #ffc107;">
|
||||||
<span class="summary-value">{entities.filter((e) => e.worked).length}</span>
|
<span class="summary-label">Points:</span>
|
||||||
</div>
|
<span class="summary-value">{entities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}</span>
|
||||||
<div class="summary-card unworked">
|
</div>
|
||||||
<span class="summary-label">Needed:</span>
|
<div class="summary-card" style="background-color: #e3f2fd; border-color: #2196f3;">
|
||||||
<span class="summary-value">{entities.filter((e) => !e.worked).length}</span>
|
<span class="summary-label">Target:</span>
|
||||||
</div>
|
<span class="summary-value">{entities.reduce((sum, e) => sum + e.points, 0)}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="summary-card">
|
||||||
|
<span class="summary-label">Total:</span>
|
||||||
|
<span class="summary-value">{entities.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card confirmed">
|
||||||
|
<span class="summary-label">Confirmed:</span>
|
||||||
|
<span class="summary-value">{entities.filter((e) => e.confirmed).length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card worked">
|
||||||
|
<span class="summary-label">Worked:</span>
|
||||||
|
<span class="summary-value">{entities.filter((e) => e.worked).length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card unworked">
|
||||||
|
<span class="summary-label">Needed:</span>
|
||||||
|
<span class="summary-value">{entities.filter((e) => !e.worked).length}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="entities-list">
|
<div class="entities-list">
|
||||||
@@ -164,6 +183,9 @@
|
|||||||
<div class="entity-info">
|
<div class="entity-info">
|
||||||
<div class="entity-name">
|
<div class="entity-name">
|
||||||
{entity.entityName || entity.entity || 'Unknown'}
|
{entity.entityName || entity.entity || 'Unknown'}
|
||||||
|
{#if entity.points}
|
||||||
|
<span class="points-badge">{entity.points} pts</span>
|
||||||
|
{/if}
|
||||||
{#if entity.entityId && entity.entityId !== entity.entityName}
|
{#if entity.entityId && entity.entityId !== entity.entityName}
|
||||||
<span class="entity-id">({entity.entityId})</span>
|
<span class="entity-id">({entity.entityId})</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -368,6 +390,16 @@
|
|||||||
margin-left: 0.5rem;
|
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 {
|
.entity-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
20
src/frontend/static/award-definitions/special-stations.json
Normal file
20
src/frontend/static/award-definitions/special-stations.json
Normal file
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user