From cd361115feefc92318c74ce91088abed8ac17f31 Mon Sep 17 00:00:00 2001 From: Joerg Date: Fri, 23 Jan 2026 07:06:12 +0100 Subject: [PATCH] feat: add configurable mode groups to award detail view Add per-award configurable mode groups for filtering multiple modes together in the award detail view. Mode groups are displayed with visual separators in the mode filter dropdown. Backend changes: - Add modeGroups to getAllAwards() return mapping - Add getAwardById() function to fetch single award definition - Add GET /api/awards/:awardId endpoint Frontend changes: - Fetch award definition separately to get modeGroups - Update availableModes to include mode groups with separator - Update filteredEntities logic to handle mode groups - Update groupDataForTable() and applyFilter() for mode groups - Disable separator option in dropdown Award definitions: - DXCC: Add Digi-Modes, Classic Digi-Modes, Mixed-Mode w/o WSJT-Modes, Phone-Modes groups - DLD: Add same mode groups (adjusted for available modes) Co-Authored-By: Claude --- award-definitions/dld.json | 6 ++ award-definitions/dxcc.json | 6 ++ src/backend/index.js | 37 +++++++ src/backend/services/awards.service.js | 25 +++++ .../src/routes/awards/[id]/+page.svelte | 101 ++++++++++++++++-- 5 files changed, 164 insertions(+), 11 deletions(-) diff --git a/award-definitions/dld.json b/award-definitions/dld.json index 91770f6..2a6ec6f 100644 --- a/award-definitions/dld.json +++ b/award-definitions/dld.json @@ -4,6 +4,12 @@ "description": "Deutschland Diplom - Confirm 100 unique DOKs on different bands/modes", "caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on different band/mode combinations. Each unique DOK on a unique band/mode counts as one point. Only DCL-confirmed QSOs with valid DOK information count toward this award.", "category": "darc", + "modeGroups": { + "Digi-Modes": ["FT8", "FT4", "MFSK", "PSK31", "RTTY"], + "Classic Digi-Modes": ["PSK31", "RTTY"], + "Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"], + "Phone-Modes": ["AM", "SSB", "FM"] + }, "rules": { "type": "dok", "target": 100, diff --git a/award-definitions/dxcc.json b/award-definitions/dxcc.json index 89e5f32..8c33765 100644 --- a/award-definitions/dxcc.json +++ b/award-definitions/dxcc.json @@ -4,6 +4,12 @@ "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", + "modeGroups": { + "Digi-Modes": ["FT8", "FT4", "MFSK", "PSK31", "RTTY", "JT65", "JT9"], + "Classic Digi-Modes": ["PSK31", "RTTY", "JT65", "JT9"], + "Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"], + "Phone-Modes": ["AM", "SSB", "FM"] + }, "rules": { "type": "entity", "entityType": "dxcc", diff --git a/src/backend/index.js b/src/backend/index.js index b0804e8..88c25a1 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -40,6 +40,7 @@ import { } from './services/job-queue.service.js'; import { getAllAwards, + getAwardById, getAwardProgressDetails, getAwardEntityBreakdown, } from './services/awards.service.js'; @@ -913,6 +914,42 @@ const app = new Elysia() } }) + /** + * GET /api/awards/:awardId + * Get a single award by ID (requires authentication) + */ + .get('/api/awards/:awardId', async ({ user, params, set }) => { + if (!user) { + set.status = 401; + return { success: false, error: 'Unauthorized' }; + } + + try { + const { awardId } = params; + const award = getAwardById(awardId); + + if (!award) { + set.status = 404; + return { + success: false, + error: 'Award not found', + }; + } + + return { + success: true, + award, + }; + } catch (error) { + logger.error('Error fetching award', { error: error.message }); + set.status = 500; + return { + success: false, + error: 'Failed to fetch award', + }; + } + }) + /** * GET /api/awards/:awardId/progress * Get award progress for user (requires authentication) diff --git a/src/backend/services/awards.service.js b/src/backend/services/awards.service.js index baa36fe..b7bea4a 100644 --- a/src/backend/services/awards.service.js +++ b/src/backend/services/awards.service.js @@ -66,9 +66,34 @@ export async function getAllAwards() { caption: def.caption, category: def.category, rules: def.rules, + modeGroups: def.modeGroups || null, })); } +/** + * Get a single award by ID + * @param {string} awardId - Award ID + * @returns {Object|null} Award definition or null if not found + */ +export function getAwardById(awardId) { + const definitions = loadAwardDefinitions(); + const award = definitions.find((def) => def.id === awardId); + + if (!award) { + return null; + } + + return { + id: award.id, + name: award.name, + description: award.description, + caption: award.caption, + category: award.category, + rules: award.rules, + modeGroups: award.modeGroups || null, + }; +} + /** * Calculate award progress for a user * @param {number} userId - User ID diff --git a/src/frontend/src/routes/awards/[id]/+page.svelte b/src/frontend/src/routes/awards/[id]/+page.svelte index 00a1c01..47023f0 100644 --- a/src/frontend/src/routes/awards/[id]/+page.svelte +++ b/src/frontend/src/routes/awards/[id]/+page.svelte @@ -22,15 +22,52 @@ let selectedSlotInfo = null; // { entityName, band, mode } // Get available modes from entities - $: availableModes = ['Mixed Mode', ...new Set(entities.map(e => e.mode).filter(Boolean).sort())]; + // Structure: Mixed Mode, Mode Groups (if any), Separator, Individual Modes + $: availableModes = (() => { + const modes = ['Mixed Mode']; + + // Add mode groups if defined in award + if (award?.modeGroups) { + const groupNames = Object.keys(award.modeGroups).sort(); + modes.push(...groupNames); + } + + // Add separator if there are mode groups + if (award?.modeGroups && Object.keys(award.modeGroups).length > 0) { + modes.push('---'); + } + + // Add individual modes + const individualModes = [...new Set(entities.map(e => e.mode).filter(Boolean))].sort(); + modes.push(...individualModes); + + return modes; + })(); // 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); + $: filteredEntities = (() => { + // Mixed Mode - show all + if (selectedMode === 'Mixed Mode') { + return entities; + } + + // Separator - shouldn't be selected, but handle gracefully + if (selectedMode === '---') { + return entities; + } + + // Check if selectedMode is a mode group + if (award?.modeGroups?.[selectedMode]) { + const groupModes = award.modeGroups[selectedMode]; + return entities.filter(e => groupModes.includes(e.mode)); + } + + // Single mode filter + return entities.filter(e => e.mode === selectedMode); + })(); // Calculate unique entity progress (for DXCC, DLD, etc.) $: uniqueEntityProgress = (() => { @@ -66,6 +103,25 @@ const awardId = $page.params.id; + // Fetch award definition (including modeGroups) + const awardResponse = await fetch(`/api/awards/${awardId}`, { + headers: { + 'Authorization': `Bearer ${$auth.token}`, + }, + }); + + if (!awardResponse.ok) { + throw new Error('Failed to load award definition'); + } + + const awardData = await awardResponse.json(); + + if (!awardData.success) { + throw new Error(awardData.error || 'Failed to load award definition'); + } + + award = awardData.award; + // Fetch award entities const response = await fetch(`/api/awards/${awardId}/entities`, { headers: { @@ -83,7 +139,6 @@ throw new Error(data.error || 'Failed to load award details'); } - award = data.award; entities = data.entities || []; // Group data for table display @@ -101,11 +156,23 @@ const columnSet = new Set(); const isMixedMode = selectedMode === 'Mixed Mode'; + const isModeGroup = award?.modeGroups?.[selectedMode]; + const groupModes = isModeGroup ? award.modeGroups[selectedMode] : null; entities.forEach((entity) => { // Skip if mode filter is set and entity doesn't match - if (!isMixedMode && entity.mode !== selectedMode) { - return; + if (!isMixedMode) { + if (isModeGroup) { + // Mode group: check if entity's mode is in the group + if (!groupModes.includes(entity.mode)) { + return; + } + } else if (selectedMode !== '---') { + // Single mode filter + if (entity.mode !== selectedMode) { + return; + } + } } const entityName = entity.entityName || entity.entity || 'Unknown'; @@ -200,11 +267,23 @@ const columnSet = new Set(); const isMixedMode = selectedMode === 'Mixed Mode'; + const isModeGroup = award?.modeGroups?.[selectedMode]; + const groupModes = isModeGroup ? award.modeGroups[selectedMode] : null; filteredEntities.forEach((entity) => { // Skip if mode filter is set and entity doesn't match - if (!isMixedMode && entity.mode !== selectedMode) { - return; + if (!isMixedMode) { + if (isModeGroup) { + // Mode group: check if entity's mode is in the group + if (!groupModes.includes(entity.mode)) { + return; + } + } else if (selectedMode !== '---') { + // Single mode filter + if (entity.mode !== selectedMode) { + return; + } + } } const entityName = entity.entityName || entity.entity || 'Unknown'; @@ -518,10 +597,10 @@ - {#if selectedMode !== 'Mixed Mode'} + {#if selectedMode !== 'Mixed Mode' && selectedMode !== '---'} {/if}