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 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 07:06:12 +01:00
parent 69b33720b3
commit cd361115fe
5 changed files with 164 additions and 11 deletions

View File

@@ -4,6 +4,12 @@
"description": "Deutschland Diplom - Confirm 100 unique DOKs on different bands/modes", "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.", "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", "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": { "rules": {
"type": "dok", "type": "dok",
"target": 100, "target": 100,

View File

@@ -4,6 +4,12 @@
"description": "Confirm 100 DXCC entities on HF bands", "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.", "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", "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": { "rules": {
"type": "entity", "type": "entity",
"entityType": "dxcc", "entityType": "dxcc",

View File

@@ -40,6 +40,7 @@ import {
} from './services/job-queue.service.js'; } from './services/job-queue.service.js';
import { import {
getAllAwards, getAllAwards,
getAwardById,
getAwardProgressDetails, getAwardProgressDetails,
getAwardEntityBreakdown, getAwardEntityBreakdown,
} from './services/awards.service.js'; } 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 /api/awards/:awardId/progress
* Get award progress for user (requires authentication) * Get award progress for user (requires authentication)

View File

@@ -66,9 +66,34 @@ export async function getAllAwards() {
caption: def.caption, caption: def.caption,
category: def.category, category: def.category,
rules: def.rules, 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 * Calculate award progress for a user
* @param {number} userId - User ID * @param {number} userId - User ID

View File

@@ -22,15 +22,52 @@
let selectedSlotInfo = null; // { entityName, band, mode } let selectedSlotInfo = null; // { entityName, band, mode }
// Get available modes from entities // 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 // 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']; 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 // Filter entities by selected mode for summary calculations
$: filteredEntities = selectedMode === 'Mixed Mode' $: filteredEntities = (() => {
? entities // Mixed Mode - show all
: entities.filter(e => e.mode === selectedMode); 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.) // Calculate unique entity progress (for DXCC, DLD, etc.)
$: uniqueEntityProgress = (() => { $: uniqueEntityProgress = (() => {
@@ -66,6 +103,25 @@
const awardId = $page.params.id; 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 // Fetch award entities
const response = await fetch(`/api/awards/${awardId}/entities`, { const response = await fetch(`/api/awards/${awardId}/entities`, {
headers: { headers: {
@@ -83,7 +139,6 @@
throw new Error(data.error || 'Failed to load award details'); throw new Error(data.error || 'Failed to load award details');
} }
award = data.award;
entities = data.entities || []; entities = data.entities || [];
// Group data for table display // Group data for table display
@@ -101,11 +156,23 @@
const columnSet = new Set(); const columnSet = new Set();
const isMixedMode = selectedMode === 'Mixed Mode'; const isMixedMode = selectedMode === 'Mixed Mode';
const isModeGroup = award?.modeGroups?.[selectedMode];
const groupModes = isModeGroup ? award.modeGroups[selectedMode] : null;
entities.forEach((entity) => { entities.forEach((entity) => {
// Skip if mode filter is set and entity doesn't match // Skip if mode filter is set and entity doesn't match
if (!isMixedMode && entity.mode !== selectedMode) { if (!isMixedMode) {
return; 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'; const entityName = entity.entityName || entity.entity || 'Unknown';
@@ -200,11 +267,23 @@
const columnSet = new Set(); const columnSet = new Set();
const isMixedMode = selectedMode === 'Mixed Mode'; const isMixedMode = selectedMode === 'Mixed Mode';
const isModeGroup = award?.modeGroups?.[selectedMode];
const groupModes = isModeGroup ? award.modeGroups[selectedMode] : null;
filteredEntities.forEach((entity) => { filteredEntities.forEach((entity) => {
// Skip if mode filter is set and entity doesn't match // Skip if mode filter is set and entity doesn't match
if (!isMixedMode && entity.mode !== selectedMode) { if (!isMixedMode) {
return; 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'; const entityName = entity.entityName || entity.entity || 'Unknown';
@@ -518,10 +597,10 @@
<label for="mode-select">Filter by mode:</label> <label for="mode-select">Filter by mode:</label>
<select id="mode-select" bind:value={selectedMode}> <select id="mode-select" bind:value={selectedMode}>
{#each availableModes as mode} {#each availableModes as mode}
<option value={mode}>{mode}</option> <option value={mode} disabled={mode === '---'}>{mode === '---' ? '─────' : mode}</option>
{/each} {/each}
</select> </select>
{#if selectedMode !== 'Mixed Mode'} {#if selectedMode !== 'Mixed Mode' && selectedMode !== '---'}
<button class="clear-filter-btn" on:click={() => selectedMode = 'Mixed Mode'}>Clear</button> <button class="clear-filter-btn" on:click={() => selectedMode = 'Mixed Mode'}>Clear</button>
{/if} {/if}
</div> </div>