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

@@ -754,3 +754,53 @@ AND (dclQslRstatus IS NULL OR dclQslRstatus != 'Y')
- Tracks updated QSOs (restores previous state) - Tracks updated QSOs (restores previous state)
- Only allows canceling failed jobs or stale running jobs (>1 hour) - Only allows canceling failed jobs or stale running jobs (>1 hour)
- Server-side validation prevents unauthorized cancellations - 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

View File

@@ -199,7 +199,7 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
} }
// Track unique (DOK, band, mode) combinations // 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) { for (const qso of filteredQSOs) {
const dok = qso.darcDok; const dok = qso.darcDok;
@@ -212,29 +212,35 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
// Initialize combination if not exists // Initialize combination if not exists
if (!dokCombinations.has(combinationKey)) { if (!dokCombinations.has(combinationKey)) {
dokCombinations.set(combinationKey, { dokCombinations.set(combinationKey, {
qsoId: qso.id,
entity: dok, entity: dok,
entityId: null, entityId: null,
entityName: dok, entityName: dok,
band, band,
mode, mode,
callsign: qso.callsign,
worked: false, worked: false,
confirmed: false, confirmed: false,
qsoDate: qso.qsoDate, qsos: [], // Array of confirmed QSOs for this slot
dclQslRdate: null,
}); });
} }
const detail = dokCombinations.get(combinationKey); const detail = dokCombinations.get(combinationKey);
detail.worked = true; detail.worked = true;
// Check for DCL confirmation // Check for DCL confirmation and add to qsos array
if (qso.dclQslRstatus === 'Y') { if (qso.dclQslRstatus === 'Y') {
if (!detail.confirmed) { if (!detail.confirmed) {
detail.confirmed = true; 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)) { if (!combinationMap.has(combinationKey)) {
combinationMap.set(combinationKey, { combinationMap.set(combinationKey, {
qsoId: qso.id,
callsign, callsign,
band, band,
mode, mode,
points, points,
worked: true, worked: true,
confirmed: false, confirmed: false,
qsoDate: qso.qsoDate, qsos: [], // Array of confirmed QSOs for this slot
lotwQslRdate: null,
}); });
} }
@@ -355,8 +359,17 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
const detail = combinationMap.get(combinationKey); const detail = combinationMap.get(combinationKey);
if (!detail.confirmed) { if (!detail.confirmed) {
detail.confirmed = true; 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)) { if (!stationMap.has(callsign)) {
stationMap.set(callsign, { stationMap.set(callsign, {
qsoId: qso.id,
callsign, callsign,
points, points,
worked: true, worked: true,
confirmed: false, confirmed: false,
qsoDate: qso.qsoDate, qsos: [], // Array of confirmed QSOs for this station
band: qso.band,
mode: qso.mode,
lotwQslRdate: null,
}); });
} }
@@ -394,8 +403,17 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
const detail = stationMap.get(callsign); const detail = stationMap.get(callsign);
if (!detail.confirmed) { if (!detail.confirmed) {
detail.confirmed = true; 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') { if (qso.lotwQslRstatus === 'Y') {
totalPoints += points; totalPoints += points;
// For perQso mode, each QSO is its own slot with a qsos array containing just itself
stationDetails.push({ stationDetails.push({
qsoId: qso.id, qsoId: qso.id,
callsign, callsign,
@@ -424,7 +443,15 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
qsoDate: qso.qsoDate, qsoDate: qso.qsoDate,
band: qso.band, band: qso.band,
mode: qso.mode, 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, mode: detail.mode,
callsign: detail.callsign, callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate, lotwQslRdate: detail.lotwQslRdate,
qsos: detail.qsos || [], // All confirmed QSOs for this slot
}; };
} else if (countMode === 'perStation') { } else if (countMode === 'perStation') {
return { return {
@@ -480,6 +508,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
mode: detail.mode, mode: detail.mode,
callsign: detail.callsign, callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate, lotwQslRdate: detail.lotwQslRdate,
qsos: detail.qsos || [], // All confirmed QSOs for this station
}; };
} else { } else {
return { return {
@@ -495,6 +524,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
mode: detail.mode, mode: detail.mode,
callsign: detail.callsign, callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate, 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 // Apply filters
const filteredQSOs = applyFilters(allQSOs, rules.filters); const filteredQSOs = applyFilters(allQSOs, rules.filters);
// Group by entity // Group by (entity, band, mode) slot for entity awards
const entityMap = new Map(); // 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) { for (const qso of filteredQSOs) {
const entity = getEntityValue(qso, rules.entityType); const entity = getEntityValue(qso, rules.entityType);
if (!entity) continue; if (!entity) continue;
if (!entityMap.has(entity)) { const band = qso.band || 'Unknown';
// Determine what to display as the entity name const mode = qso.mode || 'Unknown';
let displayName = String(entity); const slotKey = `${entity}/${band}/${mode}`;
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);
}
entityMap.set(entity, { // Determine what to display as the entity name (only on first create)
qsoId: qso.id, 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, entity,
entityId: qso.entityId, entityId: qso.entityId,
entityName: displayName, entityName: displayName,
band,
mode,
worked: false, worked: false,
confirmed: false, confirmed: false,
qsoDate: qso.qsoDate, qsos: [], // Array of confirmed QSOs for this slot
band: qso.band,
mode: qso.mode,
callsign: qso.callsign,
satName: qso.satName,
}); });
} }
const entityData = entityMap.get(entity); const slotData = slotMap.get(slotKey);
entityData.worked = true; slotData.worked = true;
// Check for LoTW confirmation and add to qsos array
if (qso.lotwQslRstatus === 'Y') { if (qso.lotwQslRstatus === 'Y') {
entityData.confirmed = true; if (!slotData.confirmed) {
entityData.lotwQslRdate = qso.lotwQslRdate; 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, caption: award.caption,
target: rules.target || 0, target: rules.target || 0,
}, },
entities: Array.from(entityMap.values()), entities: Array.from(slotMap.values()),
total: entityMap.size, total: slotMap.size,
confirmed: Array.from(entityMap.values()).filter((e) => e.confirmed).length, confirmed: Array.from(slotMap.values()).filter((e) => e.confirmed).length,
}; };
} }

View File

@@ -8,13 +8,22 @@
let loading = true; let loading = true;
let error = null; let error = null;
let groupedData = []; 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 // QSO detail modal state
let selectedQSO = null; let selectedQSO = null;
let showQSODetailModal = false; let showQSODetailModal = false;
let loadingQSO = 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 () => { onMount(async () => {
await loadAwardData(); await loadAwardData();
}); });
@@ -56,17 +65,24 @@
} }
function groupDataForTable() { 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 entityMap = new Map();
const bandsSet = new Set(); const columnSet = new Set();
const isMixedMode = selectedMode === 'Mixed Mode';
entities.forEach((entity) => { 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'; const entityName = entity.entityName || entity.entity || 'Unknown';
if (!entityMap.has(entityName)) { if (!entityMap.has(entityName)) {
entityMap.set(entityName, { entityMap.set(entityName, {
entityName, entityName,
bands: new Map(), slots: new Map(),
worked: entity.worked, worked: entity.worked,
confirmed: entity.confirmed, confirmed: entity.confirmed,
}); });
@@ -74,27 +90,61 @@
const entityData = entityMap.get(entityName); const entityData = entityMap.get(entityName);
if (entity.band) { const band = entity.band || 'Unknown';
bandsSet.add(entity.band);
if (!entityData.bands.has(entity.band)) { if (isMixedMode) {
entityData.bands.set(entity.band, []); // 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 const slot = entityData.slots.get(band);
entityData.bands.get(entity.band).push({ // Add QSOs from this entity to the aggregated slot
qsoId: entity.qsoId, if (entity.qsos && entity.qsos.length > 0) {
callsign: entity.callsign, slot.qsos.push(...entity.qsos);
mode: entity.mode, if (entity.confirmed) slot.confirmed = true;
band: entity.band, }
} 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, confirmed: entity.confirmed,
qsoDate: entity.qsoDate,
}); });
} }
}); });
// Convert bands Set to sorted array // Convert columnSet to sorted array of column objects
bands = Array.from(bandsSet).sort(); 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 // Convert Map to array
groupedData = Array.from(entityMap.values()); groupedData = Array.from(entityMap.values());
@@ -108,15 +158,22 @@
const filteredEntities = getFilteredEntities(); const filteredEntities = getFilteredEntities();
const entityMap = new Map(); const entityMap = new Map();
const bandsSet = new Set(); const columnSet = new Set();
const isMixedMode = selectedMode === 'Mixed Mode';
filteredEntities.forEach((entity) => { 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'; const entityName = entity.entityName || entity.entity || 'Unknown';
if (!entityMap.has(entityName)) { if (!entityMap.has(entityName)) {
entityMap.set(entityName, { entityMap.set(entityName, {
entityName, entityName,
bands: new Map(), slots: new Map(),
worked: entity.worked, worked: entity.worked,
confirmed: entity.confirmed, confirmed: entity.confirmed,
}); });
@@ -124,25 +181,59 @@
const entityData = entityMap.get(entityName); const entityData = entityMap.get(entityName);
if (entity.band) { const band = entity.band || 'Unknown';
bandsSet.add(entity.band);
if (!entityData.bands.has(entity.band)) { if (isMixedMode) {
entityData.bands.set(entity.band, []); // 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({ const slot = entityData.slots.get(band);
qsoId: entity.qsoId, if (entity.qsos && entity.qsos.length > 0) {
callsign: entity.callsign, slot.qsos.push(...entity.qsos);
mode: entity.mode, if (entity.confirmed) slot.confirmed = true;
band: entity.band, }
} 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, 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()); groupedData = Array.from(entityMap.values());
} }
@@ -159,27 +250,41 @@
return filtered; return filtered;
} }
// Re-apply sort when entities or sort changes // Re-apply sort when entities or mode changes
$: if (entities.length > 0) { $: if (entities.length > 0 || selectedMode) {
applyFilter(); applyFilter();
} }
// Calculate band sums // Calculate column sums
$: bandSums = (() => { $: columnSums = (() => {
const sums = new Map(); const sums = new Map();
const hasPoints = entities.length > 0 && entities[0].points !== undefined; 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) { if (hasPoints) {
// Sum points for confirmed QSOs in this band // Sum points for confirmed QSOs in this column
const sum = entities if (isMixedMode) {
.filter(e => e.band === band && e.confirmed) const sum = entities
.reduce((total, e) => total + (e.points || 0), 0); .filter(e => e.band === band && e.confirmed)
sums.set(band, sum); .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 { } else {
// Count confirmed QSOs in this band // Count confirmed QSOs in this column
const count = entities.filter(e => e.band === band && e.confirmed).length; if (isMixedMode) {
sums.set(band, count); 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; 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) { function formatDate(dateStr) {
if (!dateStr) return '-'; if (!dateStr) return '-';
// ADIF format: YYYYMMDD // ADIF format: YYYYMMDD
@@ -318,6 +440,18 @@
{/if} {/if}
</div> </div>
<div class="mode-filter">
<label for="mode-select">Filter by mode:</label>
<select id="mode-select" bind:value={selectedMode}>
{#each availableModes as mode}
<option value={mode}>{mode}</option>
{/each}
</select>
{#if selectedMode !== 'Mixed Mode'}
<button class="clear-filter-btn" on:click={() => selectedMode = 'Mixed Mode'}>Clear</button>
{/if}
</div>
<div class="table-container"> <div class="table-container">
{#if groupedData.length === 0} {#if groupedData.length === 0}
<div class="empty">No entities match the current filter.</div> <div class="empty">No entities match the current filter.</div>
@@ -326,8 +460,8 @@
<thead> <thead>
<tr> <tr>
<th class="entity-column">Entity</th> <th class="entity-column">Entity</th>
{#each bands as band} {#each columns as { band, mode }}
<th class="band-column">{band}</th> <th class="band-column">{band}{#if mode} {mode}{/if}</th>
{/each} {/each}
</tr> </tr>
</thead> </thead>
@@ -337,26 +471,23 @@
<td class="entity-cell"> <td class="entity-cell">
<div class="entity-name">{row.entityName}</div> <div class="entity-name">{row.entityName}</div>
</td> </td>
{#each bands as band} {#each columns as { band, mode }}
{@const qsos = row.bands.get(band) || []} {@const columnKey = mode ? `${band}/${mode}` : band}
{@const slotData = row.slots.get(columnKey)}
<td class="band-cell"> <td class="band-cell">
{#if qsos.length > 0} {#if slotData && slotData.qsos && slotData.qsos.length > 0}
<div class="qso-list"> <span
{#each qsos as qso} class="qso-count-link"
<div on:click={() => openQSOListModal(slotData, row.entityName, band, mode)}
class="qso-entry {qso.confirmed ? 'qso-confirmed' : 'qso-worked'}" on:keydown={(e) => e.key === 'Enter' && openQSOListModal(slotData, row.entityName, band, mode)}
on:click={() => openQSODetailModal(qso)} role="button"
on:keydown={(e) => e.key === 'Enter' && openQSODetailModal(qso)} tabindex="0"
role="button" title="{slotData.qsos.length} QSO{slotData.qsos.length === 1 ? '' : 's'}"
tabindex="0" >
> {slotData.qsos.length}
<span class="callsign">{qso.callsign}</span> </span>
<span class="mode">{qso.mode}</span>
</div>
{/each}
</div>
{:else} {:else}
<div class="no-qso">-</div> <span class="no-qso">-</span>
{/if} {/if}
</td> </td>
{/each} {/each}
@@ -368,8 +499,9 @@
<td class="sum-label"> <td class="sum-label">
<strong>Sum</strong> <strong>Sum</strong>
</td> </td>
{#each bands as band} {#each columns as { band, mode }}
{@const sum = bandSums.get(band) ?? 0} {@const columnKey = mode ? `${band}/${mode}` : band}
{@const sum = columnSums.get(columnKey) ?? 0}
<td class="sum-cell"> <td class="sum-cell">
<strong>{sum}</strong> <strong>{sum}</strong>
</td> </td>
@@ -564,6 +696,51 @@
</div> </div>
{/if} {/if}
<!-- QSO List Modal -->
{#if showQSOListModal && selectedSlotInfo}
<div class="modal-backdrop" on:click={closeQSOListModal} on:keydown={(e) => e.key === 'Escape' && closeQSOListModal()} role="dialog" aria-modal="true">
<div class="modal-content qso-list-modal" on:click|stopPropagation>
<div class="modal-header">
<h2>QSOs for {selectedSlotInfo.entityName} ({selectedSlotInfo.band}{#if selectedSlotInfo.mode} {selectedSlotInfo.mode}{/if})</h2>
<button class="modal-close" on:click={closeQSOListModal} aria-label="Close modal">×</button>
</div>
<div class="modal-body">
{#if selectedSlotQSOs.length === 0}
<div class="empty">No QSOs found for this slot.</div>
{:else}
<table class="qso-list-table">
<thead>
<tr>
<th>Callsign</th>
<th>Date</th>
<th>Time</th>
<th>Mode</th>
</tr>
</thead>
<tbody>
{#each selectedSlotQSOs as qso}
<tr
class="qso-list-row"
on:click={() => { openQSODetailModal(qso); closeQSOListModal(); }}
on:keydown={(e) => e.key === 'Enter' && (openQSODetailModal(qso), closeQSOListModal())}
role="button"
tabindex="0"
>
<td class="callsign-cell">{qso.callsign}</td>
<td>{formatDate(qso.qsoDate)}</td>
<td>{formatTime(qso.timeOn)}</td>
<td>{qso.mode || '-'}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>
{/if}
<style> <style>
.container { .container {
max-width: 1200px; max-width: 1200px;
@@ -1037,4 +1214,129 @@
.modal-content::-webkit-scrollbar-thumb:hover { .modal-content::-webkit-scrollbar-thumb:hover {
background: #555; background: #555;
} }
/* Mode Filter */
.mode-filter {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.mode-filter label {
font-weight: 600;
color: #333;
margin: 0;
}
.mode-filter select {
padding: 0.5rem 2rem 0.5rem 1rem;
border: 1px solid #ccc;
border-radius: 4px;
background-color: white;
font-size: 0.95rem;
color: #333;
cursor: pointer;
min-width: 150px;
}
.mode-filter select:hover {
border-color: #4a90e2;
}
.mode-filter select:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}
.clear-filter-btn {
padding: 0.5rem 1rem;
background-color: #e0e0e0;
color: #333;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.clear-filter-btn:hover {
background-color: #d0d0d0;
}
/* QSO Count Link */
.qso-count-link {
cursor: pointer;
color: #4a90e2;
font-weight: 500;
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.qso-count-link:hover {
background-color: #f0f7ff;
text-decoration: underline;
}
.qso-count-link:focus {
outline: 2px solid #4a90e2;
outline-offset: -2px;
}
.no-qso {
color: #999;
}
/* QSO List Modal */
.qso-list-modal {
max-width: 500px;
}
.qso-list-table {
width: 100%;
border-collapse: collapse;
}
.qso-list-table th,
.qso-list-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.qso-list-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #333;
font-size: 0.85rem;
text-transform: uppercase;
}
.qso-list-row {
cursor: pointer;
transition: background-color 0.2s;
}
.qso-list-row:hover {
background-color: #f0f7ff;
}
.qso-list-row:focus {
outline: 2px solid #4a90e2;
outline-offset: -2px;
}
.callsign-cell {
font-family: monospace;
font-weight: 600;
color: #333;
}
</style> </style>