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:
@@ -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',
|
||||
|
||||
258
src/backend/services/awards.service.js
Normal file
258
src/backend/services/awards.service.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user