Compare commits

..

12 Commits

Author SHA1 Message Date
8550b91255 feat: add DXCC SAT award for satellite-only QSOs
Added new award "DXCC SAT" that only counts satellite QSOs (QSOs with
satName field set). This adds a new "satellite_only" key to award
definitions that filters to only include satellite communications.

Award definition:
- ID: dxcc-sat
- Name: DXCC SAT
- Target: 100 DXCC entities
- Only satellite QSOs count

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:25:13 +01:00
a93d4ff85b refactor: remove DLD 80m CW award variant
Removed dld-80m-cw.json award definition. Only the main DLD award
remains, which covers all bands and modes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:22:58 +01:00
f3ee1be651 refactor: remove DLD variant awards (80m, 40m, CW)
Removed the following DLD award variants:
- dld-80m.json
- dld-40m.json
- dld-cw.json

Kept dld-80m-cw.json as it represents a more specific combination.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:22:09 +01:00
6c9aa1efe7 feat: add allowed_bands filter to award definitions
Adds a new "allowed_bands" key to award definitions that restricts which
bands count toward an award. If absent, all bands are allowed (default
behavior).

Applied to DXCC award to only count HF bands (160m-10m), excluding
VHF/UHF bands like 6m, 2m, and 70cm.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:19:32 +01:00
14c7319c9e refactor: remove DXCC CW award and rename DXCC Mixed Mode to DXCC
- Removed dxcc-cw.json award definition
- Renamed "DXCC Mixed Mode" to "DXCC" in dxcc.json
- Changed award ID from "dxcc-mixed" to "dxcc"
- Removed dxcc-cw.json from awards service file list

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:14:33 +01:00
5792a98dca feat: sort band columns by wavelength instead of alphabetically
Band columns are now sorted by wavelength (longest to shortest):
160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm, SAT.

Unknown bands are sorted to the end.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:10:24 +01:00
aa25d21c6b fix: count unique entities in column sums instead of QSO counts
Column sums now correctly count unique entities (e.g., unique DXCC
countries per band) instead of counting individual entity entries or
QSOs. This matches the award progress semantics.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:07:38 +01:00
e14da11a93 fix: correct column sum calculation for satellite QSOs
The SAT column sum was always showing 0 because it was filtering by
e.band === 'SAT', but entities still have their original band in the
data. Now it correctly identifies satellite QSOs by checking if any
QSOs have satName.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:04:51 +01:00
dc34fc20b1 feat: group satellite QSOs under SAT column in award detail
Satellite QSOs are now grouped under a "SAT" column instead of their
frequency band. The backend now includes satName in QSO data, and the
frontend detects satellite QSOs and groups them appropriately.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:03:03 +01:00
c75e55d130 feat: show unique entity progress in award summary
Summary cards now display unique entity counts (e.g., unique DXCC countries)
instead of per-band/mode slot counts. This shows actual award progress:
Total entities worked, confirmed, and needed to reach the award target.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 07:58:21 +01:00
89edd07722 feat: make award summary respect mode filter and remove mode from table headers
Summary cards (Total, Confirmed, Worked, Needed) now update based on the
selected mode filter. Also removed redundant mode display from table column
headers since the mode is already visible in the filter dropdown.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 07:52:13 +01:00
dd3beef9af 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>
2026-01-22 07:34:55 +01:00
10 changed files with 647 additions and 235 deletions

View File

@@ -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

View File

@@ -1,19 +0,0 @@
{
"id": "dld-40m",
"name": "DLD 40m",
"description": "Confirm 100 unique DOKs on 40m",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 40m band. Only DCL-confirmed QSOs with valid DOK information on 40m count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "40m" }
]
}
}
}

View File

@@ -1,20 +0,0 @@
{
"id": "dld-80m-cw",
"name": "DLD 80m CW",
"description": "Confirm 100 unique DOKs on 80m using CW",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band using CW mode. Only DCL-confirmed QSOs with valid DOK information on 80m CW count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" },
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}

View File

@@ -1,19 +0,0 @@
{
"id": "dld-80m",
"name": "DLD 80m",
"description": "Confirm 100 unique DOKs on 80m",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band. Only DCL-confirmed QSOs with valid DOK information on 80m count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" }
]
}
}
}

View File

@@ -1,19 +0,0 @@
{
"id": "dld-cw",
"name": "DLD CW",
"description": "Confirm 100 unique DOKs using CW mode",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) using CW (Morse code). Each unique DOK on CW counts separately. Only DCL-confirmed QSOs with valid DOK information count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}

View File

@@ -1,27 +0,0 @@
{
"id": "dxcc-cw",
"name": "DXCC CW",
"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",
"rules": {
"target": 100,
"type": "filtered",
"baseRule": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity"
},
"filters": {
"operator": "AND",
"filters": [
{
"field": "mode",
"operator": "eq",
"value": "CW"
}
]
}
}
}

View File

@@ -0,0 +1,14 @@
{
"id": "dxcc-sat",
"name": "DXCC SAT",
"description": "Confirm 100 DXCC entities via satellite",
"caption": "Contact and confirm 100 different DXCC entities using satellite communications. Only satellite QSOs count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "dxcc",
"rules": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity",
"satellite_only": true
}
}

View File

@@ -1,13 +1,14 @@
{
"id": "dxcc-mixed",
"name": "DXCC Mixed 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.",
"id": "dxcc",
"name": "DXCC",
"description": "Confirm 100 DXCC entities on HF bands",
"caption": "Contact and confirm 100 different DXCC entities on HF bands (160m-10m). Only HF band QSOs count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "dxcc",
"rules": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity"
"displayField": "entity",
"allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]
}
}

View File

@@ -22,16 +22,12 @@ function loadAwardDefinitions() {
try {
const files = [
'dxcc.json',
'dxcc-cw.json',
'dxcc-sat.json',
'was.json',
'vucc-sat.json',
'sat-rs44.json',
'special-stations.json',
'dld.json',
'dld-80m.json',
'dld-40m.json',
'dld-cw.json',
'dld-80m-cw.json',
'73-on-73.json',
];
@@ -140,11 +136,27 @@ export async function calculateAwardProgress(userId, award, options = {}) {
logger.debug('QSOs after filters', { count: filteredQSOs.length });
}
// Apply allowed_bands filter if present
let finalQSOs = filteredQSOs;
if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) {
finalQSOs = filteredQSOs.filter(qso => {
const band = qso.band;
return rules.allowed_bands.includes(band);
});
logger.debug('QSOs after allowed_bands filter', { count: finalQSOs.length });
}
// Apply satellite_only filter if present
if (rules.satellite_only) {
finalQSOs = finalQSOs.filter(qso => qso.satName);
logger.debug('QSOs after satellite_only filter', { count: finalQSOs.length });
}
// Calculate worked and confirmed entities
const workedEntities = new Set();
const confirmedEntities = new Set();
for (const qso of filteredQSOs) {
for (const qso of finalQSOs) {
const entity = getEntityValue(qso, rules.entityType);
if (entity) {
@@ -199,7 +211,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 +224,36 @@ 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,
satName: qso.satName,
confirmed: true,
});
}
}
@@ -339,15 +358,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 +372,18 @@ 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,
satName: qso.satName,
confirmed: true,
});
}
}
@@ -378,15 +405,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 +417,18 @@ 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,
satName: qso.satName,
confirmed: true,
});
}
}
@@ -415,6 +448,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 +458,16 @@ 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,
satName: qso.satName,
confirmed: true,
}],
});
}
}
@@ -465,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 slot
};
} else if (countMode === 'perStation') {
return {
@@ -480,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 station
};
} else {
return {
@@ -495,6 +540,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 +721,77 @@ export async function getAwardEntityBreakdown(userId, awardId) {
// Apply filters
const filteredQSOs = applyFilters(allQSOs, rules.filters);
// Group by entity
const entityMap = new Map();
// Apply allowed_bands filter if present
let finalQSOs = filteredQSOs;
if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) {
finalQSOs = filteredQSOs.filter(qso => {
const band = qso.band;
return rules.allowed_bands.includes(band);
});
}
for (const qso of filteredQSOs) {
// Apply satellite_only filter if present
if (rules.satellite_only) {
finalQSOs = finalQSOs.filter(qso => qso.satName);
}
// 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 finalQSOs) {
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,
satName: qso.satName,
confirmed: true,
});
}
}
@@ -728,8 +803,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,
};
}

View File

@@ -8,13 +8,53 @@
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())];
// Band order by wavelength (longest to shortest), SAT at the end
const bandOrder = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', 'SAT', '23cm', '13cm', '9cm', '6cm', '3cm'];
// Filter entities by selected mode for summary calculations
$: filteredEntities = selectedMode === 'Mixed Mode'
? entities
: entities.filter(e => e.mode === selectedMode);
// Calculate unique entity progress (for DXCC, DLD, etc.)
$: uniqueEntityProgress = (() => {
const uniqueEntities = new Map();
filteredEntities.forEach(e => {
const entityName = e.entityName || e.entity || 'Unknown';
if (!uniqueEntities.has(entityName)) {
uniqueEntities.set(entityName, { worked: false, confirmed: false });
}
const status = uniqueEntities.get(entityName);
if (e.worked) status.worked = true;
if (e.confirmed) status.confirmed = true;
});
return {
total: uniqueEntities.size,
worked: Array.from(uniqueEntities.values()).filter(s => s.worked).length,
confirmed: Array.from(uniqueEntities.values()).filter(s => s.confirmed).length
};
})();
onMount(async () => {
await loadAwardData();
});
@@ -56,17 +96,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 +121,69 @@
const entityData = entityMap.get(entityName);
if (entity.band) {
bandsSet.add(entity.band);
// Check if this is a satellite QSO - use "SAT" instead of band
const isSatellite = entity.qsos && entity.qsos.some(qso => qso.satName);
const band = isSatellite ? 'SAT' : (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 order (by wavelength), then by mode
const aBandIndex = bandOrder.indexOf(a.band);
const bBandIndex = bandOrder.indexOf(b.band);
const aIndex = aBandIndex === -1 ? 999 : aBandIndex;
const bIndex = bBandIndex === -1 ? 999 : bBandIndex;
if (aIndex !== bIndex) return aIndex - bIndex;
// Same band, sort by mode if present
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 +197,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 +220,68 @@
const entityData = entityMap.get(entityName);
if (entity.band) {
bandsSet.add(entity.band);
// Check if this is a satellite QSO - use "SAT" instead of band
const isSatellite = entity.qsos && entity.qsos.some(qso => qso.satName);
const band = isSatellite ? 'SAT' : (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) => {
// Sort by band order (by wavelength), then by mode
const aBandIndex = bandOrder.indexOf(a.band);
const bBandIndex = bandOrder.indexOf(b.band);
const aIndex = aBandIndex === -1 ? 999 : aBandIndex;
const bIndex = bBandIndex === -1 ? 999 : bBandIndex;
if (aIndex !== bIndex) return aIndex - bIndex;
// Same band, sort by mode if present
if (a.mode !== undefined && b.mode !== undefined) {
return (a.mode || '').localeCompare(b.mode || '');
}
return 0;
});
groupedData = Array.from(entityMap.values());
}
@@ -159,27 +298,69 @@
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 - counts unique entities per column (not QSO counts)
$: 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 => {
// For SAT column, check if entity has satellite QSOs
if (band === 'SAT') {
return e.qsos && e.qsos.some(qso => qso.satName) && e.confirmed;
}
return e.band === band && e.confirmed;
})
.reduce((total, e) => total + (e.points || 0), 0);
sums.set(key, sum);
} else {
const sum = entities
.filter(e => {
// For SAT column, check if entity has satellite QSOs
if (band === 'SAT') {
return e.qsos && e.qsos.some(qso => qso.satName) && e.mode === mode && e.confirmed;
}
return 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 unique entities in this column (not QSO counts)
if (isMixedMode) {
const matchedEntities = entities.filter(e => {
// For SAT column, check if entity has satellite QSOs
if (band === 'SAT') {
return e.qsos && e.qsos.some(qso => qso.satName) && e.confirmed;
}
return e.band === band && e.confirmed;
});
// Count unique entity names
const uniqueEntities = new Set(matchedEntities.map(e => e.entityName || e.entity || 'Unknown'));
sums.set(key, uniqueEntities.size);
} else {
const matchedEntities = entities.filter(e => {
// For SAT column, check if entity has satellite QSOs
if (band === 'SAT') {
return e.qsos && e.qsos.some(qso => qso.satName) && e.mode === mode && e.confirmed;
}
return e.band === band && e.mode === mode && e.confirmed;
});
// Count unique entity names
const uniqueEntities = new Set(matchedEntities.map(e => e.entityName || e.entity || 'Unknown'));
sums.set(key, uniqueEntities.size);
}
}
});
@@ -229,6 +410,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
@@ -272,16 +470,16 @@
<div class="summary">
{#if entities.length > 0 && entities[0].points !== undefined}
{@const earnedPoints = entities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
{@const earnedPoints = filteredEntities.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">
<span class="summary-label">Total Combinations:</span>
<span class="summary-value">{entities.length}</span>
<span class="summary-value">{filteredEntities.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>
<span class="summary-value">{filteredEntities.filter((e) => e.confirmed).length}</span>
</div>
<div class="summary-card" style="background-color: #fff3cd; border-color: #ffc107;">
<span class="summary-label">Points:</span>
@@ -296,20 +494,18 @@
<span class="summary-value">{targetPoints}</span>
</div>
{:else}
{@const workedCount = entities.filter((e) => e.worked).length}
{@const confirmedCount = entities.filter((e) => e.confirmed).length}
{@const neededCount = award.target ? Math.max(0, award.target - workedCount) : entities.filter((e) => !e.worked).length}
{@const neededCount = award.target ? Math.max(0, award.target - uniqueEntityProgress.worked) : uniqueEntityProgress.total - uniqueEntityProgress.worked}
<div class="summary-card">
<span class="summary-label">Total:</span>
<span class="summary-value">{entities.length}</span>
<span class="summary-value">{uniqueEntityProgress.total}</span>
</div>
<div class="summary-card confirmed">
<span class="summary-label">Confirmed:</span>
<span class="summary-value">{confirmedCount}</span>
<span class="summary-value">{uniqueEntityProgress.confirmed}</span>
</div>
<div class="summary-card worked">
<span class="summary-label">Worked:</span>
<span class="summary-value">{workedCount}</span>
<span class="summary-value">{uniqueEntityProgress.worked}</span>
</div>
<div class="summary-card unworked">
<span class="summary-label">Needed:</span>
@@ -318,6 +514,18 @@
{/if}
</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">
{#if groupedData.length === 0}
<div class="empty">No entities match the current filter.</div>
@@ -326,7 +534,7 @@
<thead>
<tr>
<th class="entity-column">Entity</th>
{#each bands as band}
{#each columns as { band }}
<th class="band-column">{band}</th>
{/each}
</tr>
@@ -337,26 +545,23 @@
<td class="entity-cell">
<div class="entity-name">{row.entityName}</div>
</td>
{#each bands as band}
{@const qsos = row.bands.get(band) || []}
{#each columns as { band, mode }}
{@const columnKey = mode ? `${band}/${mode}` : band}
{@const slotData = row.slots.get(columnKey)}
<td class="band-cell">
{#if qsos.length > 0}
<div class="qso-list">
{#each qsos as qso}
<div
class="qso-entry {qso.confirmed ? 'qso-confirmed' : 'qso-worked'}"
on:click={() => openQSODetailModal(qso)}
on:keydown={(e) => e.key === 'Enter' && openQSODetailModal(qso)}
role="button"
tabindex="0"
>
<span class="callsign">{qso.callsign}</span>
<span class="mode">{qso.mode}</span>
</div>
{/each}
</div>
{#if slotData && slotData.qsos && slotData.qsos.length > 0}
<span
class="qso-count-link"
on:click={() => openQSOListModal(slotData, row.entityName, band, mode)}
on:keydown={(e) => e.key === 'Enter' && openQSOListModal(slotData, row.entityName, band, mode)}
role="button"
tabindex="0"
title="{slotData.qsos.length} QSO{slotData.qsos.length === 1 ? '' : 's'}"
>
{slotData.qsos.length}
</span>
{:else}
<div class="no-qso">-</div>
<span class="no-qso">-</span>
{/if}
</td>
{/each}
@@ -368,8 +573,9 @@
<td class="sum-label">
<strong>Sum</strong>
</td>
{#each bands as band}
{@const sum = bandSums.get(band) ?? 0}
{#each columns as { band, mode }}
{@const columnKey = mode ? `${band}/${mode}` : band}
{@const sum = columnSums.get(columnKey) ?? 0}
<td class="sum-cell">
<strong>{sum}</strong>
</td>
@@ -564,6 +770,51 @@
</div>
{/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>
.container {
max-width: 1200px;
@@ -1037,4 +1288,129 @@
.modal-content::-webkit-scrollbar-thumb:hover {
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>