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:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,72 +196,144 @@ 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
|
|
||||||
|
|
||||||
for (const qso of allQSOs) {
|
if (countMode === 'perBandMode') {
|
||||||
const callsign = qso.callsign?.toUpperCase();
|
// Count unique (callsign, band, mode) combinations
|
||||||
if (!callsign) continue;
|
const workedCombinations = new Set();
|
||||||
|
const confirmedCombinations = new Map();
|
||||||
|
|
||||||
const points = stationPoints.get(callsign);
|
for (const qso of allQSOs) {
|
||||||
if (!points) continue; // Not a special station
|
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);
|
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 (!workedCombinations.has(combinationKey)) {
|
||||||
if (qso.lotwQslRstatus === 'Y' && !confirmedStations.has(callsign)) {
|
workedCombinations.add(combinationKey);
|
||||||
confirmedStations.set(callsign, points);
|
stationDetails.push({
|
||||||
|
callsign,
|
||||||
|
band,
|
||||||
|
mode,
|
||||||
|
points,
|
||||||
|
worked: true,
|
||||||
|
confirmed: false,
|
||||||
|
qsoDate: qso.qsoDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update the station detail to confirmed
|
if (qso.lotwQslRstatus === 'Y' && !confirmedCombinations.has(combinationKey)) {
|
||||||
const detail = stationDetails.find((s) => s.callsign === callsign);
|
confirmedCombinations.set(combinationKey, points);
|
||||||
if (detail) {
|
const detail = stationDetails.find((c) =>
|
||||||
detail.confirmed = true;
|
c.callsign === callsign && c.band === band && c.mode === mode
|
||||||
detail.lotwQslRdate = qso.lotwQslRdate;
|
);
|
||||||
|
if (detail) {
|
||||||
|
detail.confirmed = true;
|
||||||
|
detail.lotwQslRdate = qso.lotwQslRdate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total points (sum of confirmed station points)
|
totalPoints = Array.from(confirmedCombinations.values()).reduce((sum, p) => sum + p, 0);
|
||||||
const totalPoints = Array.from(confirmedStations.values()).reduce((sum, points) => sum + points, 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', {
|
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,100 @@ 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;
|
||||||
|
|
||||||
for (const qso of allQSOs) {
|
if (countMode === 'perBandMode') {
|
||||||
const callsign = qso.callsign?.toUpperCase();
|
// Show each (callsign, band, mode) combination
|
||||||
if (!callsign) continue;
|
const combinationMap = new Map();
|
||||||
|
|
||||||
const points = stationPoints.get(callsign);
|
for (const qso of allQSOs) {
|
||||||
if (!points) continue; // Not a special station
|
const callsign = qso.callsign?.toUpperCase();
|
||||||
|
if (!callsign) continue;
|
||||||
|
|
||||||
// Only add if not already in map
|
const points = stationPoints.get(callsign);
|
||||||
if (!stationMap.has(callsign)) {
|
if (!points) continue;
|
||||||
stationMap.set(callsign, {
|
|
||||||
entity: callsign,
|
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,
|
entityId: null,
|
||||||
entityName: callsign,
|
entityName: `${callsign} on ${qso.qsoDate}`,
|
||||||
points,
|
points,
|
||||||
worked: true, // Has QSO
|
worked: true,
|
||||||
confirmed: qso.lotwQslRstatus === 'Y',
|
confirmed: qso.lotwQslRstatus === 'Y',
|
||||||
qsoDate: qso.qsoDate,
|
qsoDate: qso.qsoDate,
|
||||||
band: qso.band,
|
band: qso.band,
|
||||||
@@ -293,29 +448,24 @@ async function getPointsAwardEntityBreakdown(userId, rules) {
|
|||||||
callsign: qso.callsign,
|
callsign: qso.callsign,
|
||||||
lotwQslRdate: qso.lotwQslRdate,
|
lotwQslRdate: qso.lotwQslRdate,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// Update to confirmed if this QSO is confirmed
|
if (qso.lotwQslRstatus === 'Y') {
|
||||||
const data = stationMap.get(callsign);
|
totalPoints += points;
|
||||||
if (!data.confirmed && qso.lotwQslRstatus === 'Y') {
|
|
||||||
data.confirmed = true;
|
|
||||||
data.lotwQslRdate = qso.lotwQslRdate;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stationList = Array.from(stationMap.values());
|
|
||||||
const confirmedStations = stationList.filter((s) => s.confirmed);
|
|
||||||
const totalPoints = confirmedStations.reduce((sum, s) => sum + s.points, 0);
|
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user