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}