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",
|
"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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user