Add awards system with progress tracking

Implement awards display with progress calculation based on QSO data.

## Backend
- Add awards service with progress calculation logic
- Support for DXCC, WAS, VUCC, and satellite awards
- Filter QSOs by band, mode, and other criteria
- Calculate worked/confirmed entities per award
- API endpoints:
  - GET /api/awards - List all awards
  - GET /api/awards/:id/progress - Get award progress
  - GET /api/awards/:id/entities - Get detailed entity breakdown

## Frontend
- Create awards listing page with progress cards
- Add Awards link to navigation bar
- Display award progress bars with worked/confirmed counts
- Link to individual award detail pages (to be implemented)
- Copy award definitions to static folder

## Award Definitions
- DXCC Mixed Mode (100 entities)
- DXCC CW (100 entities, CW only)
- WAS Mixed Mode (50 states)
- VUCC Satellite (100 grids)
- RS-44 Satellite Award (100 QSOs)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 08:47:30 +01:00
parent 31488e556c
commit 884bdb436d
10 changed files with 755 additions and 1 deletions

View File

@@ -20,6 +20,11 @@ import {
getUserActiveJob,
getUserJobs,
} from './services/job-queue.service.js';
import {
getAllAwards,
getAwardProgressDetails,
getAwardEntityBreakdown,
} from './services/awards.service.js';
/**
* Main backend application
@@ -478,6 +483,89 @@ const app = new Elysia()
}
})
/**
* GET /api/awards
* Get all available awards (requires authentication)
*/
.get('/api/awards', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const awards = await getAllAwards();
return {
success: true,
awards,
};
} catch (error) {
logger.error('Error fetching awards', { error: error.message });
set.status = 500;
return {
success: false,
error: 'Failed to fetch awards',
};
}
})
/**
* GET /api/awards/:awardId/progress
* Get award progress for user (requires authentication)
*/
.get('/api/awards/:awardId/progress', async ({ user, params, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const { awardId } = params;
const progress = await getAwardProgressDetails(user.id, awardId);
return {
success: true,
...progress,
};
} catch (error) {
logger.error('Error calculating award progress', { error: error.message });
set.status = 500;
return {
success: false,
error: error.message || 'Failed to calculate award progress',
};
}
})
/**
* GET /api/awards/:awardId/entities
* Get detailed entity breakdown for an award (requires authentication)
*/
.get('/api/awards/:awardId/entities', async ({ user, params, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const { awardId } = params;
const breakdown = await getAwardEntityBreakdown(user.id, awardId);
return {
success: true,
...breakdown,
};
} catch (error) {
logger.error('Error fetching award entities', { error: error.message });
set.status = 500;
return {
success: false,
error: error.message || 'Failed to fetch award entities',
};
}
})
// Health check endpoint
.get('/api/health', () => ({
status: 'ok',

View File

@@ -0,0 +1,258 @@
import { db } from '../config/database.js';
import { qsos } from '../db/schema/index.js';
import { eq, and, or, desc, sql } from 'drizzle-orm';
import logger from '../config/logger.js';
import { readFileSync } from 'fs';
import { join } from 'path';
/**
* Awards Service
* Calculates award progress based on QSO data
*/
// Load award definitions from files
const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions');
/**
* Load all award definitions
*/
function loadAwardDefinitions() {
const definitions = [];
try {
const files = [
'dxcc.json',
'dxcc-cw.json',
'was.json',
'vucc-sat.json',
'sat-rs44.json',
];
for (const file of files) {
try {
const filePath = join(AWARD_DEFINITIONS_DIR, file);
const content = readFileSync(filePath, 'utf-8');
const definition = JSON.parse(content);
definitions.push(definition);
} catch (error) {
logger.warn('Failed to load award definition', { file, error: error.message });
}
}
} catch (error) {
logger.error('Error loading award definitions', { error: error.message });
}
return definitions;
}
/**
* Get all available awards
*/
export async function getAllAwards() {
const definitions = loadAwardDefinitions();
return definitions.map((def) => ({
id: def.id,
name: def.name,
description: def.description,
category: def.category,
rules: def.rules,
}));
}
/**
* Calculate award progress for a user
* @param {number} userId - User ID
* @param {Object} award - Award definition
*/
export async function calculateAwardProgress(userId, award) {
const { rules } = award;
// Get all QSOs for user
const allQSOs = await db
.select()
.from(qsos)
.where(eq(qsos.userId, userId));
// Apply filters if defined
let filteredQSOs = allQSOs;
if (rules.filters) {
filteredQSOs = applyFilters(allQSOs, rules.filters);
}
// Calculate worked and confirmed entities
const workedEntities = new Set();
const confirmedEntities = new Set();
for (const qso of filteredQSOs) {
const entity = getEntityValue(qso, rules.entityType);
if (entity) {
// Worked: QSO exists (any LoTW status)
workedEntities.add(entity);
// Confirmed: LoTW QSL received
if (qso.lotwQslRstatus === 'Y') {
confirmedEntities.add(entity);
}
}
}
return {
worked: workedEntities.size,
confirmed: confirmedEntities.size,
target: rules.target || 0,
percentage: rules.target ? Math.round((confirmedEntities.size / rules.target) * 100) : 0,
workedEntities: Array.from(workedEntities),
confirmedEntities: Array.from(confirmedEntities),
};
}
/**
* Get entity value from QSO based on entity type
*/
function getEntityValue(qso, entityType) {
switch (entityType) {
case 'dxcc':
return qso.entityId;
case 'state':
return qso.state;
case 'grid':
// For VUCC, use first 4 characters of grid
return qso.grid ? qso.grid.substring(0, 4) : null;
case 'callsign':
return qso.callsign;
default:
return null;
}
}
/**
* Apply filters to QSOs based on award rules
*/
function applyFilters(qsos, filters) {
if (!filters || !filters.filters) {
return qsos;
}
return qsos.filter((qso) => {
if (filters.operator === 'AND') {
return filters.filters.every((filter) => matchesFilter(qso, filter));
} else if (filters.operator === 'OR') {
return filters.filters.some((filter) => matchesFilter(qso, filter));
}
return true;
});
}
/**
* Check if a QSO matches a filter
*/
function matchesFilter(qso, filter) {
const value = qso[filter.field];
switch (filter.operator) {
case 'eq':
return value === filter.value;
case 'ne':
return value !== filter.value;
case 'in':
return Array.isArray(filter.value) && filter.value.includes(value);
case 'nin':
return Array.isArray(filter.value) && !filter.value.includes(value);
case 'contains':
return value && typeof value === 'string' && value.includes(filter.value);
default:
return true;
}
}
/**
* Get award progress with QSO details
*/
export async function getAwardProgressDetails(userId, awardId) {
// Get award definition
const definitions = loadAwardDefinitions();
const award = definitions.find((def) => def.id === awardId);
if (!award) {
throw new Error('Award not found');
}
// Calculate progress
const progress = await calculateAwardProgress(userId, award);
return {
award: {
id: award.id,
name: award.name,
description: award.description,
category: award.category,
},
...progress,
};
}
/**
* Get detailed entity breakdown for an award
*/
export async function getAwardEntityBreakdown(userId, awardId) {
const definitions = loadAwardDefinitions();
const award = definitions.find((def) => def.id === awardId);
if (!award) {
throw new Error('Award not found');
}
const { rules } = award;
// Get all QSOs for user
const allQSOs = await db
.select()
.from(qsos)
.where(eq(qsos.userId, userId));
// Apply filters
const filteredQSOs = applyFilters(allQSOs, rules.filters);
// Group by entity
const entityMap = new Map();
for (const qso of filteredQSOs) {
const entity = getEntityValue(qso, rules.entityType);
if (!entity) continue;
if (!entityMap.has(entity)) {
entityMap.set(entity, {
entity,
entityId: qso.entityId,
worked: false,
confirmed: false,
qsoDate: qso.qsoDate,
band: qso.band,
mode: qso.mode,
callsign: qso.callsign,
});
}
const entityData = entityMap.get(entity);
entityData.worked = true;
if (qso.lotwQslRstatus === 'Y') {
entityData.confirmed = true;
entityData.lotwQslRdate = qso.lotwQslRdate;
}
}
return {
award: {
id: award.id,
name: award.name,
description: award.description,
},
entities: Array.from(entityMap.values()),
total: entityMap.size,
confirmed: Array.from(entityMap.values()).filter((e) => e.confirmed).length,
};
}