diff --git a/CLAUDE.md b/CLAUDE.md index 396d23f..6733ae0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -754,3 +754,53 @@ AND (dclQslRstatus IS NULL OR dclQslRstatus != 'Y') - Tracks updated QSOs (restores previous state) - Only allows canceling failed jobs or stale running jobs (>1 hour) - Server-side validation prevents unauthorized cancellations + +### Award Detail View (January 2025) + +**Overview**: The award detail page (`src/frontend/src/routes/awards/[id]/+page.svelte`) displays award progress in a pivot table format with entities as rows and band/mode combinations as columns. + +**Key Features**: +- **QSO Count per Slot**: Each table cell shows the count of confirmed QSOs for that (entity, band, mode) combination +- **Drill-Down**: Click a count to open a modal showing all QSOs for that slot +- **QSO Detail**: Click any QSO in the list to view full QSO details +- **Mode Filter**: Filter by specific mode or view "Mixed Mode" (aggregates all modes by band) + +**Backend Changes** (`src/backend/services/awards.service.js`): +- `calculateDOKAwardProgress()`: Groups by (DOK, band, mode) slots, collects all confirmed QSOs in `qsos` array +- `calculatePointsAwardProgress()`: Updated for all count modes (perBandMode, perStation, perQso) with `qsos` array +- `getAwardEntityBreakdown()`: Groups by (entity, band, mode) slots for entity awards + +**Response Structure**: +```javascript +{ + entity: "F03", + band: "80m", + mode: "CW", + worked: true, + confirmed: true, + qsos: [ + { qsoId: 123, callsign: "DK0MU", mode: "CW", qsoDate: "20250115", timeOn: "123456", confirmed: true }, + { qsoId: 456, callsign: "DL1ABC", mode: "CW", qsoDate: "20250120", timeOn: "234500", confirmed: true } + ] +} +``` + +**Mode Filter**: +- **Mixed Mode (default)**: Shows bands as columns, aggregates all modes + - Example: Columns are "80m", "40m", "20m" + - Clicking a count shows all QSOs for that band across all modes +- **Specific Mode**: Shows (band, mode) combinations as columns + - Example: Columns are "80m CW", "80m SSB", "40m CW" + - Filters to only show QSOs with that mode + +**Frontend Components**: +- **Mode Filter Dropdown**: Located between summary cards and table + - Dynamically populated with available modes from the data + - Clear button appears when specific mode is selected +- **Count Badges**: Blue clickable links showing QSO count (removed bubbles, kept links) +- **QSO List Modal**: Shows all QSOs for selected slot with columns: Callsign, Date, Time, Mode +- **QSO Detail Modal**: Full QSO information (existing feature) + +**Files Modified**: +- `src/backend/services/awards.service.js` - Backend grouping and QSO collection +- `src/frontend/src/routes/awards/[id]/+page.svelte` - Frontend display and interaction diff --git a/src/backend/services/awards.service.js b/src/backend/services/awards.service.js index 0a8c83b..7179cbf 100644 --- a/src/backend/services/awards.service.js +++ b/src/backend/services/awards.service.js @@ -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, }; } diff --git a/src/frontend/src/routes/awards/[id]/+page.svelte b/src/frontend/src/routes/awards/[id]/+page.svelte index 9408263..5ee2ad7 100644 --- a/src/frontend/src/routes/awards/[id]/+page.svelte +++ b/src/frontend/src/routes/awards/[id]/+page.svelte @@ -8,13 +8,22 @@ let loading = true; let error = null; let groupedData = []; - let bands = []; + let columns = []; // Array of {band, mode?} - mode is undefined for mixed mode + let selectedMode = 'Mixed Mode'; // Mode filter, default is all modes aggregated // QSO detail modal state let selectedQSO = null; let showQSODetailModal = false; let loadingQSO = false; + // QSO list modal state + let showQSOListModal = false; + let selectedSlotQSOs = []; + let selectedSlotInfo = null; // { entityName, band, mode } + + // Get available modes from entities + $: availableModes = ['Mixed Mode', ...new Set(entities.map(e => e.mode).filter(Boolean).sort())]; + onMount(async () => { await loadAwardData(); }); @@ -56,17 +65,24 @@ } function groupDataForTable() { - // Group by entity name, then create band columns + // Group by entity name, then create columns based on mode filter const entityMap = new Map(); - const bandsSet = new Set(); + const columnSet = new Set(); + + const isMixedMode = selectedMode === 'Mixed Mode'; entities.forEach((entity) => { + // Skip if mode filter is set and entity doesn't match + if (!isMixedMode && entity.mode !== selectedMode) { + return; + } + const entityName = entity.entityName || entity.entity || 'Unknown'; if (!entityMap.has(entityName)) { entityMap.set(entityName, { entityName, - bands: new Map(), + slots: new Map(), worked: entity.worked, confirmed: entity.confirmed, }); @@ -74,27 +90,61 @@ const entityData = entityMap.get(entityName); - if (entity.band) { - bandsSet.add(entity.band); + const band = entity.band || 'Unknown'; - if (!entityData.bands.has(entity.band)) { - entityData.bands.set(entity.band, []); + if (isMixedMode) { + // Mixed Mode: aggregate by band only, collect all QSOs across modes + columnSet.add(band); + + if (!entityData.slots.has(band)) { + entityData.slots.set(band, { + band, + mode: null, // No specific mode in mixed mode + qsos: [], // Will be aggregated + confirmed: false, + }); } - // Add QSO info to this band - entityData.bands.get(entity.band).push({ - qsoId: entity.qsoId, - callsign: entity.callsign, - mode: entity.mode, - band: entity.band, + const slot = entityData.slots.get(band); + // Add QSOs from this entity to the aggregated slot + if (entity.qsos && entity.qsos.length > 0) { + slot.qsos.push(...entity.qsos); + if (entity.confirmed) slot.confirmed = true; + } + } else { + // Specific Mode: group by (band, mode) + const mode = entity.mode || 'Unknown'; + const columnKey = `${band}/${mode}`; + columnSet.add(columnKey); + + entityData.slots.set(columnKey, { + band, + mode, + qsos: entity.qsos || [], confirmed: entity.confirmed, - qsoDate: entity.qsoDate, }); } }); - // Convert bands Set to sorted array - bands = Array.from(bandsSet).sort(); + // Convert columnSet to sorted array of column objects + columns = Array.from(columnSet) + .map(key => { + if (isMixedMode) { + return { band: key, mode: null }; // key is just the band name + } else { + const [band, mode] = key.split('/'); + return { band, mode }; + } + }) + .sort((a, b) => { + // Sort by band first, then mode (if present) + const bandCompare = (a.band || '').localeCompare(b.band || ''); + if (bandCompare !== 0) return bandCompare; + if (a.mode !== undefined && b.mode !== undefined) { + return (a.mode || '').localeCompare(b.mode || ''); + } + return 0; + }); // Convert Map to array groupedData = Array.from(entityMap.values()); @@ -108,15 +158,22 @@ const filteredEntities = getFilteredEntities(); const entityMap = new Map(); - const bandsSet = new Set(); + const columnSet = new Set(); + + const isMixedMode = selectedMode === 'Mixed Mode'; filteredEntities.forEach((entity) => { + // Skip if mode filter is set and entity doesn't match + if (!isMixedMode && entity.mode !== selectedMode) { + return; + } + const entityName = entity.entityName || entity.entity || 'Unknown'; if (!entityMap.has(entityName)) { entityMap.set(entityName, { entityName, - bands: new Map(), + slots: new Map(), worked: entity.worked, confirmed: entity.confirmed, }); @@ -124,25 +181,59 @@ const entityData = entityMap.get(entityName); - if (entity.band) { - bandsSet.add(entity.band); + const band = entity.band || 'Unknown'; - if (!entityData.bands.has(entity.band)) { - entityData.bands.set(entity.band, []); + if (isMixedMode) { + // Mixed Mode: aggregate by band only + columnSet.add(band); + + if (!entityData.slots.has(band)) { + entityData.slots.set(band, { + band, + mode: null, + qsos: [], + confirmed: false, + }); } - entityData.bands.get(entity.band).push({ - qsoId: entity.qsoId, - callsign: entity.callsign, - mode: entity.mode, - band: entity.band, + const slot = entityData.slots.get(band); + if (entity.qsos && entity.qsos.length > 0) { + slot.qsos.push(...entity.qsos); + if (entity.confirmed) slot.confirmed = true; + } + } else { + // Specific Mode: group by (band, mode) + const mode = entity.mode || 'Unknown'; + const columnKey = `${band}/${mode}`; + columnSet.add(columnKey); + + entityData.slots.set(columnKey, { + band, + mode, + qsos: entity.qsos || [], confirmed: entity.confirmed, - qsoDate: entity.qsoDate, }); } }); - bands = Array.from(bandsSet).sort(); + columns = Array.from(columnSet) + .map(key => { + if (isMixedMode) { + return { band: key, mode: null }; + } else { + const [band, mode] = key.split('/'); + return { band, mode }; + } + }) + .sort((a, b) => { + const bandCompare = (a.band || '').localeCompare(b.band || ''); + if (bandCompare !== 0) return bandCompare; + if (a.mode !== undefined && b.mode !== undefined) { + return (a.mode || '').localeCompare(b.mode || ''); + } + return 0; + }); + groupedData = Array.from(entityMap.values()); } @@ -159,27 +250,41 @@ return filtered; } - // Re-apply sort when entities or sort changes - $: if (entities.length > 0) { + // Re-apply sort when entities or mode changes + $: if (entities.length > 0 || selectedMode) { applyFilter(); } - // Calculate band sums - $: bandSums = (() => { + // Calculate column sums + $: columnSums = (() => { const sums = new Map(); const hasPoints = entities.length > 0 && entities[0].points !== undefined; + const isMixedMode = selectedMode === 'Mixed Mode'; - bands.forEach(band => { + columns.forEach(({ band, mode }) => { + const key = isMixedMode ? band : `${band}/${mode}`; if (hasPoints) { - // Sum points for confirmed QSOs in this band - const sum = entities - .filter(e => e.band === band && e.confirmed) - .reduce((total, e) => total + (e.points || 0), 0); - sums.set(band, sum); + // Sum points for confirmed QSOs in this column + if (isMixedMode) { + const sum = entities + .filter(e => e.band === band && e.confirmed) + .reduce((total, e) => total + (e.points || 0), 0); + sums.set(key, sum); + } else { + const sum = entities + .filter(e => e.band === band && e.mode === mode && e.confirmed) + .reduce((total, e) => total + (e.points || 0), 0); + sums.set(key, sum); + } } else { - // Count confirmed QSOs in this band - const count = entities.filter(e => e.band === band && e.confirmed).length; - sums.set(band, count); + // Count confirmed QSOs in this column + if (isMixedMode) { + const count = entities.filter(e => e.band === band && e.confirmed).length; + sums.set(key, count); + } else { + const count = entities.filter(e => e.band === band && e.mode === mode && e.confirmed).length; + sums.set(key, count); + } } }); @@ -229,6 +334,23 @@ showQSODetailModal = false; } + // QSO List Modal Functions + function openQSOListModal(slotData, entityName, band, mode) { + selectedSlotInfo = { + entityName, + band, + mode, + }; + selectedSlotQSOs = slotData.qsos || []; + showQSOListModal = true; + } + + function closeQSOListModal() { + selectedSlotInfo = null; + selectedSlotQSOs = []; + showQSOListModal = false; + } + function formatDate(dateStr) { if (!dateStr) return '-'; // ADIF format: YYYYMMDD @@ -318,6 +440,18 @@ {/if} +