feat: add award detail view with QSO count per slot and mode filter

- Award detail page now shows QSO counts per (entity, band, mode) slot
- Click count to open modal with all QSOs for that slot
- Click QSO in list to view full details
- Add mode filter: "Mixed Mode" aggregates by band, specific modes show (band, mode) columns
- Backend groups by slot and collects all confirmed QSOs in qsos array
- Frontend displays clickable count links (removed blue bubbles)

Backend changes:
- calculateDOKAwardProgress(): groups by (DOK, band, mode), collects qsos array
- calculatePointsAwardProgress(): updated for all count modes with qsos array
- getAwardEntityBreakdown(): groups by (entity, band, mode) slots

Frontend changes:
- Add mode filter dropdown with "Mixed Mode" default
- Update grouping logic to handle mixed mode vs specific mode
- Replace count badges with simple clickable links
- Add QSO list modal showing all QSOs per slot
- Add Mode column to QSO list (useful in mixed mode)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-22 07:34:55 +01:00
parent 695000e35c
commit dd3beef9af
3 changed files with 507 additions and 111 deletions

View File

@@ -199,7 +199,7 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
}
// Track unique (DOK, band, mode) combinations
const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object
const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object with qsos array
for (const qso of filteredQSOs) {
const dok = qso.darcDok;
@@ -212,29 +212,35 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
// Initialize combination if not exists
if (!dokCombinations.has(combinationKey)) {
dokCombinations.set(combinationKey, {
qsoId: qso.id,
entity: dok,
entityId: null,
entityName: dok,
band,
mode,
callsign: qso.callsign,
worked: false,
confirmed: false,
qsoDate: qso.qsoDate,
dclQslRdate: null,
qsos: [], // Array of confirmed QSOs for this slot
});
}
const detail = dokCombinations.get(combinationKey);
detail.worked = true;
// Check for DCL confirmation
// Check for DCL confirmation and add to qsos array
if (qso.dclQslRstatus === 'Y') {
if (!detail.confirmed) {
detail.confirmed = true;
detail.dclQslRdate = qso.dclQslRdate;
}
// Add this confirmed QSO to the qsos array
detail.qsos.push({
qsoId: qso.id,
callsign: qso.callsign,
mode: qso.mode,
qsoDate: qso.qsoDate,
timeOn: qso.timeOn,
band: qso.band,
confirmed: true,
});
}
}
@@ -339,15 +345,13 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (!combinationMap.has(combinationKey)) {
combinationMap.set(combinationKey, {
qsoId: qso.id,
callsign,
band,
mode,
points,
worked: true,
confirmed: false,
qsoDate: qso.qsoDate,
lotwQslRdate: null,
qsos: [], // Array of confirmed QSOs for this slot
});
}
@@ -355,8 +359,17 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
const detail = combinationMap.get(combinationKey);
if (!detail.confirmed) {
detail.confirmed = true;
detail.lotwQslRdate = qso.lotwQslRdate;
}
// Add this confirmed QSO to the qsos array
detail.qsos.push({
qsoId: qso.id,
callsign: qso.callsign,
mode: qso.mode,
qsoDate: qso.qsoDate,
timeOn: qso.timeOn,
band: qso.band,
confirmed: true,
});
}
}
@@ -378,15 +391,11 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (!stationMap.has(callsign)) {
stationMap.set(callsign, {
qsoId: qso.id,
callsign,
points,
worked: true,
confirmed: false,
qsoDate: qso.qsoDate,
band: qso.band,
mode: qso.mode,
lotwQslRdate: null,
qsos: [], // Array of confirmed QSOs for this station
});
}
@@ -394,8 +403,17 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
const detail = stationMap.get(callsign);
if (!detail.confirmed) {
detail.confirmed = true;
detail.lotwQslRdate = qso.lotwQslRdate;
}
// Add this confirmed QSO to the qsos array
detail.qsos.push({
qsoId: qso.id,
callsign: qso.callsign,
mode: qso.mode,
qsoDate: qso.qsoDate,
timeOn: qso.timeOn,
band: qso.band,
confirmed: true,
});
}
}
@@ -415,6 +433,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (qso.lotwQslRstatus === 'Y') {
totalPoints += points;
// For perQso mode, each QSO is its own slot with a qsos array containing just itself
stationDetails.push({
qsoId: qso.id,
callsign,
@@ -424,7 +443,15 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
qsoDate: qso.qsoDate,
band: qso.band,
mode: qso.mode,
lotwQslRdate: qso.lotwQslRdate,
qsos: [{
qsoId: qso.id,
callsign: qso.callsign,
mode: qso.mode,
qsoDate: qso.qsoDate,
timeOn: qso.timeOn,
band: qso.band,
confirmed: true,
}],
});
}
}
@@ -465,6 +492,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
mode: detail.mode,
callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate,
qsos: detail.qsos || [], // All confirmed QSOs for this slot
};
} else if (countMode === 'perStation') {
return {
@@ -480,6 +508,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
mode: detail.mode,
callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate,
qsos: detail.qsos || [], // All confirmed QSOs for this station
};
} else {
return {
@@ -495,6 +524,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
mode: detail.mode,
callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate,
qsos: detail.qsos || [], // All confirmed QSOs for this slot (just this one QSO)
};
}
});
@@ -675,48 +705,62 @@ export async function getAwardEntityBreakdown(userId, awardId) {
// Apply filters
const filteredQSOs = applyFilters(allQSOs, rules.filters);
// Group by entity
const entityMap = new Map();
// Group by (entity, band, mode) slot for entity awards
// This allows showing multiple QSOs per entity on different bands/modes
const slotMap = new Map(); // Key: "entity/band/mode" -> slot object
for (const qso of filteredQSOs) {
const entity = getEntityValue(qso, rules.entityType);
if (!entity) continue;
if (!entityMap.has(entity)) {
// Determine what to display as the entity name
let displayName = String(entity);
if (rules.displayField) {
let rawValue = qso[rules.displayField];
if (rules.displayField === 'grid' && rawValue && rawValue.length > 4) {
rawValue = rawValue.substring(0, 4);
}
displayName = String(rawValue || entity);
} else {
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
}
const band = qso.band || 'Unknown';
const mode = qso.mode || 'Unknown';
const slotKey = `${entity}/${band}/${mode}`;
entityMap.set(entity, {
qsoId: qso.id,
// Determine what to display as the entity name (only on first create)
let displayName = String(entity);
if (rules.displayField) {
let rawValue = qso[rules.displayField];
if (rules.displayField === 'grid' && rawValue && rawValue.length > 4) {
rawValue = rawValue.substring(0, 4);
}
displayName = String(rawValue || entity);
} else {
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
}
if (!slotMap.has(slotKey)) {
slotMap.set(slotKey, {
entity,
entityId: qso.entityId,
entityName: displayName,
band,
mode,
worked: false,
confirmed: false,
qsoDate: qso.qsoDate,
band: qso.band,
mode: qso.mode,
callsign: qso.callsign,
satName: qso.satName,
qsos: [], // Array of confirmed QSOs for this slot
});
}
const entityData = entityMap.get(entity);
entityData.worked = true;
const slotData = slotMap.get(slotKey);
slotData.worked = true;
// Check for LoTW confirmation and add to qsos array
if (qso.lotwQslRstatus === 'Y') {
entityData.confirmed = true;
entityData.lotwQslRdate = qso.lotwQslRdate;
if (!slotData.confirmed) {
slotData.confirmed = true;
}
// Add this confirmed QSO to the qsos array
slotData.qsos.push({
qsoId: qso.id,
callsign: qso.callsign,
mode: qso.mode,
qsoDate: qso.qsoDate,
timeOn: qso.timeOn,
band: qso.band,
confirmed: true,
});
}
}
@@ -728,8 +772,8 @@ export async function getAwardEntityBreakdown(userId, awardId) {
caption: award.caption,
target: rules.target || 0,
},
entities: Array.from(entityMap.values()),
total: entityMap.size,
confirmed: Array.from(entityMap.values()).filter((e) => e.confirmed).length,
entities: Array.from(slotMap.values()),
total: slotMap.size,
confirmed: Array.from(slotMap.values()).filter((e) => e.confirmed).length,
};
}