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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
<label for="mode-select">Filter by mode:</label>
|
||||
<select id="mode-select" bind:value={selectedMode}>
|
||||
{#each availableModes as mode}
|
||||
<option value={mode}>{mode}</option>
|
||||
<option value={mode} disabled={mode === '---'}>{mode === '---' ? '─────' : mode}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if selectedMode !== 'Mixed Mode'}
|
||||
{#if selectedMode !== 'Mixed Mode' && selectedMode !== '---'}
|
||||
<button class="clear-filter-btn" on:click={() => selectedMode = 'Mixed Mode'}>Clear</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user