- Add entityName field to backend entity breakdown - For DXCC: Show country name (e.g., 'Germany') with ID in parentheses - For WAS: Show state name - For VUCC: Show grid square - For RS-44: Show callsign - Update sorting to use entity names for better UX - Add subtle styling for entity IDs (gray, smaller font) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
313 lines
7.5 KiB
JavaScript
313 lines
7.5 KiB
JavaScript
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,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Normalize award rules to a consistent format
|
|
*/
|
|
function normalizeAwardRules(rules) {
|
|
// Handle "filtered" type awards (like DXCC CW)
|
|
if (rules.type === 'filtered' && rules.baseRule) {
|
|
return {
|
|
type: 'entity',
|
|
entityType: rules.baseRule.entityType,
|
|
target: rules.baseRule.target,
|
|
filters: rules.filters,
|
|
};
|
|
}
|
|
|
|
// Handle "counter" type awards (like RS-44)
|
|
// These count unique callsigns instead of entities
|
|
if (rules.type === 'counter') {
|
|
return {
|
|
type: 'entity',
|
|
entityType: rules.countBy === 'qso' ? 'callsign' : 'callsign',
|
|
target: rules.target,
|
|
filters: rules.filters,
|
|
};
|
|
}
|
|
|
|
return rules;
|
|
}
|
|
|
|
/**
|
|
* Calculate award progress for a user
|
|
* @param {number} userId - User ID
|
|
* @param {Object} award - Award definition
|
|
*/
|
|
export async function calculateAwardProgress(userId, award) {
|
|
let { rules } = award;
|
|
|
|
// Normalize rules to handle different formats
|
|
rules = normalizeAwardRules(rules);
|
|
|
|
logger.debug('Calculating award progress', {
|
|
userId,
|
|
awardId: award.id,
|
|
awardType: rules.type,
|
|
entityType: rules.entityType,
|
|
hasFilters: !!rules.filters,
|
|
});
|
|
|
|
// Get all QSOs for user
|
|
const allQSOs = await db
|
|
.select()
|
|
.from(qsos)
|
|
.where(eq(qsos.userId, userId));
|
|
|
|
logger.debug('Total QSOs for user', { count: allQSOs.length });
|
|
|
|
// Apply filters if defined
|
|
let filteredQSOs = allQSOs;
|
|
if (rules.filters) {
|
|
filteredQSOs = applyFilters(allQSOs, rules.filters);
|
|
logger.debug('QSOs after filters', { count: filteredQSOs.length });
|
|
}
|
|
|
|
// 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) {
|
|
let value;
|
|
|
|
// Special handling for satellite field
|
|
if (filter.field === 'satellite') {
|
|
// Check if it's a satellite QSO (has satName)
|
|
value = qso.satName && qso.satName.length > 0;
|
|
} else {
|
|
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.toLowerCase().includes(filter.value.toLowerCase());
|
|
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');
|
|
}
|
|
|
|
let { rules } = award;
|
|
|
|
// Normalize rules to handle different formats
|
|
rules = normalizeAwardRules(rules);
|
|
|
|
// 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,
|
|
entityName: qso.entity || qso.state || qso.grid || qso.callsign || String(entity),
|
|
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,
|
|
};
|
|
}
|