feat: add WAE (Worked All Europe) award implementation
Implement DARC's WAE award with dual metrics tracking (countries + bandpoints). Features: - 54 European countries with correct DXCC entityIds from ARRL - 8 WAE-specific entities (Shetland, Sicily, Sardinia, Crete, etc.) - Bandpoints calculation: 1 pt/band (2 pts for 160m/80m), max 5 bands/country - 5 award levels: WAE III (40/100), WAE II (50/150), WAE I (60/200), WAE TOP (70/300), WAE Trophy (all/365) - Mode groups: CW, SSB, RTTY, FT8, Digi-Modes, Mixed-Mode - Admin UI support for creating/editing WAE awards - Award detail page with dual metrics display Files: - award-data/wae-country-list.json: WAE country definitions - award-definitions/wae.json: Award configuration - src/backend/services/awards.service.js: WAE calculation functions - src/frontend: Admin and award detail views Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,7 @@ function loadAwardDefinitions() {
|
||||
*/
|
||||
export function clearAwardCache() {
|
||||
cachedAwardDefinitions = null;
|
||||
cachedWAECountryList = null;
|
||||
logger.info('Award cache cleared');
|
||||
}
|
||||
|
||||
@@ -242,6 +243,11 @@ export async function calculateAwardProgress(userId, award, options = {}) {
|
||||
return calculatePointsAwardProgress(userId, award, { includeDetails });
|
||||
}
|
||||
|
||||
// Handle WAE-based awards (Worked All Europe)
|
||||
if (rules.type === 'wae') {
|
||||
return calculateWAEAwardProgress(userId, award, { includeDetails });
|
||||
}
|
||||
|
||||
// Get all QSOs for user
|
||||
const allQSOs = await db
|
||||
.select()
|
||||
@@ -768,6 +774,503 @@ function matchesFilter(qso, filter) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WAE (Worked All Europe) Award Functions
|
||||
// ============================================================================
|
||||
|
||||
// In-memory cache for WAE country list
|
||||
let cachedWAECountryList = null;
|
||||
|
||||
/**
|
||||
* Load WAE country list from JSON file
|
||||
*/
|
||||
function loadWAECountryList() {
|
||||
if (cachedWAECountryList) {
|
||||
return cachedWAECountryList;
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = join(process.cwd(), 'award-data', 'wae-country-list.json');
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Build lookup maps for efficient matching
|
||||
const dxccMap = new Map();
|
||||
const waeSpecificMap = new Map();
|
||||
const deletedPrefixes = new Set();
|
||||
|
||||
// Index DXCC-based countries
|
||||
if (data.dxccBased) {
|
||||
for (const entry of data.dxccBased) {
|
||||
dxccMap.set(entry.entityId, {
|
||||
country: entry.country,
|
||||
prefix: entry.prefix,
|
||||
deleted: entry.deleted || false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Index WAE-specific countries with callsign patterns
|
||||
if (data.waeSpecific) {
|
||||
for (const entry of data.waeSpecific) {
|
||||
waeSpecificMap.set(entry.prefix, {
|
||||
country: entry.country,
|
||||
prefix: entry.prefix,
|
||||
callsigns: entry.callsigns || [],
|
||||
parentDxcc: entry.parentDxcc,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Index deleted countries
|
||||
if (data.deletedCountries) {
|
||||
for (const entry of data.deletedCountries) {
|
||||
deletedPrefixes.add(entry.prefix);
|
||||
}
|
||||
}
|
||||
|
||||
cachedWAECountryList = {
|
||||
dxccMap,
|
||||
waeSpecificMap,
|
||||
deletedPrefixes,
|
||||
rawData: data,
|
||||
};
|
||||
|
||||
logger.debug('WAE country list loaded', {
|
||||
dxccCount: dxccMap.size,
|
||||
waeSpecificCount: waeSpecificMap.size,
|
||||
deletedCount: deletedPrefixes.size,
|
||||
});
|
||||
|
||||
return cachedWAECountryList;
|
||||
} catch (error) {
|
||||
logger.error('Failed to load WAE country list', { error: error.message });
|
||||
return { dxccMap: new Map(), waeSpecificMap: new Map(), deletedPrefixes: new Set(), rawData: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a callsign to WAE country
|
||||
* Only matches if the country is explicitly in the WAE country list
|
||||
* @param {string} callsign - The callsign to match
|
||||
* @param {number} entityId - The DXCC entityId from QSO
|
||||
* @returns {Object|null} WAE country info or null if not a WAE country
|
||||
*/
|
||||
function matchWAECountry(callsign, entityId) {
|
||||
const waeList = loadWAECountryList();
|
||||
|
||||
if (!callsign) return null;
|
||||
|
||||
const normalizedCallsign = callsign.toUpperCase().trim();
|
||||
|
||||
// First check WAE-specific patterns (these override DXCC)
|
||||
for (const [prefix, info] of waeList.waeSpecificMap) {
|
||||
for (const pattern of info.callsigns) {
|
||||
if (pattern.includes('*')) {
|
||||
// Wildcard pattern matching
|
||||
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
||||
if (regex.test(normalizedCallsign)) {
|
||||
return {
|
||||
country: info.country,
|
||||
prefix: info.prefix,
|
||||
isDeleted: false,
|
||||
isWAESpecific: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if (normalizedCallsign === pattern) {
|
||||
return {
|
||||
country: info.country,
|
||||
prefix: info.prefix,
|
||||
isDeleted: false,
|
||||
isWAESpecific: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only match DXCC entities that are EXPLICITLY in the WAE country list
|
||||
// Do NOT fall back to matching any DXCC entity - WAE has its own list
|
||||
if (entityId && waeList.dxccMap.has(entityId)) {
|
||||
const dxccInfo = waeList.dxccMap.get(entityId);
|
||||
return {
|
||||
country: dxccInfo.country,
|
||||
prefix: dxccInfo.prefix,
|
||||
isDeleted: dxccInfo.deleted,
|
||||
isWAESpecific: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Not a WAE country (includes all non-European entities like US, JA, etc.)
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bandpoint value for a band
|
||||
* @param {string} band - The band name
|
||||
* @param {Array} doublePointBands - Bands that count double
|
||||
* @returns {number} Point value for this band
|
||||
*/
|
||||
function getBandpointValue(band, doublePointBands = []) {
|
||||
if (doublePointBands && doublePointBands.includes(band)) {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort bands by point value (descending) for max bands per country calculation
|
||||
* @param {Array} bands - Array of band names
|
||||
* @param {Array} doublePointBands - Bands that count double
|
||||
* @returns {Array} Bands sorted by point value
|
||||
*/
|
||||
function sortBandsByPointValue(bands, doublePointBands = []) {
|
||||
return bands.sort((a, b) => {
|
||||
const pointsA = getBandpointValue(a, doublePointBands);
|
||||
const pointsB = getBandpointValue(b, doublePointBands);
|
||||
if (pointsA !== pointsB) {
|
||||
return pointsB - pointsA; // Higher points first
|
||||
}
|
||||
// Tie-breaker: prefer lower frequency (longer wavelength) bands
|
||||
const bandOrder = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm'];
|
||||
const indexA = bandOrder.indexOf(a);
|
||||
const indexB = bandOrder.indexOf(b);
|
||||
if (indexA !== -1 && indexB !== -1) {
|
||||
return indexA - indexB;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress for WAE awards
|
||||
* WAE tracks dual metrics: unique countries AND bandpoints
|
||||
* @param {number} userId - User ID
|
||||
* @param {Object} award - Award definition
|
||||
* @param {Object} options - Options
|
||||
* @param {boolean} options.includeDetails - Include detailed entity breakdown
|
||||
*/
|
||||
async function calculateWAEAwardProgress(userId, award, options = {}) {
|
||||
const { includeDetails = false } = options;
|
||||
const { rules } = award;
|
||||
const {
|
||||
targetCountries = 40,
|
||||
targetBandpoints = 100,
|
||||
doublePointBands = ['160m', '80m'],
|
||||
maxBandsPerCountry = 5,
|
||||
excludeDeletedForTop = true,
|
||||
} = rules;
|
||||
|
||||
logger.debug('Calculating WAE award progress', {
|
||||
userId,
|
||||
awardId: award.id,
|
||||
targetCountries,
|
||||
targetBandpoints,
|
||||
doublePointBands,
|
||||
maxBandsPerCountry,
|
||||
excludeDeletedForTop,
|
||||
});
|
||||
|
||||
// Get all QSOs for user
|
||||
const allQSOs = await db
|
||||
.select()
|
||||
.from(qsos)
|
||||
.where(eq(qsos.userId, userId));
|
||||
|
||||
logger.debug('Total QSOs for WAE calculation', { count: allQSOs.length });
|
||||
|
||||
// Track per-country data
|
||||
// Map: country -> { confirmed: boolean, bands: Set, bandpoints: number, qsos: [] }
|
||||
const countryData = new Map();
|
||||
|
||||
// Track all unique countries worked and confirmed
|
||||
const workedCountries = new Set();
|
||||
const confirmedCountries = new Set();
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
const waeCountry = matchWAECountry(qso.callsign, qso.entityId);
|
||||
|
||||
if (!waeCountry) {
|
||||
// Not a WAE country, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
const country = waeCountry.country;
|
||||
|
||||
// Track worked countries
|
||||
workedCountries.add(country);
|
||||
|
||||
// Check for LoTW confirmation
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
confirmedCountries.add(country);
|
||||
|
||||
// Initialize country data if not exists
|
||||
if (!countryData.has(country)) {
|
||||
countryData.set(country, {
|
||||
country,
|
||||
prefix: waeCountry.prefix,
|
||||
isDeleted: waeCountry.isDeleted,
|
||||
isWAESpecific: waeCountry.isWAESpecific,
|
||||
confirmed: true,
|
||||
bands: new Set(),
|
||||
bandpoints: 0,
|
||||
qsos: [],
|
||||
});
|
||||
}
|
||||
|
||||
const data = countryData.get(country);
|
||||
const band = qso.band || 'Unknown';
|
||||
|
||||
// Only count this band if we haven't seen it before for this country
|
||||
if (!data.bands.has(band)) {
|
||||
data.bands.add(band);
|
||||
|
||||
// Calculate bandpoints for this country
|
||||
// Get all confirmed bands for this country, sort by point value, take top N
|
||||
const allBands = Array.from(data.bands);
|
||||
const sortedBands = sortBandsByPointValue(allBands, doublePointBands);
|
||||
const bandsToCount = sortedBands.slice(0, maxBandsPerCountry);
|
||||
|
||||
// Recalculate total bandpoints
|
||||
let newBandpoints = 0;
|
||||
for (const b of bandsToCount) {
|
||||
newBandpoints += getBandpointValue(b, doublePointBands);
|
||||
}
|
||||
|
||||
data.bandpoints = newBandpoints;
|
||||
}
|
||||
|
||||
// Add QSO to the qsos array for drill-down
|
||||
data.qsos.push({
|
||||
qsoId: qso.id,
|
||||
callsign: qso.callsign,
|
||||
mode: qso.mode,
|
||||
qsoDate: qso.qsoDate,
|
||||
timeOn: qso.timeOn,
|
||||
band: qso.band,
|
||||
satName: qso.satName,
|
||||
confirmed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total bandpoints across all countries
|
||||
let totalBandpoints = 0;
|
||||
for (const data of countryData.values()) {
|
||||
totalBandpoints += data.bandpoints;
|
||||
}
|
||||
|
||||
// For WAE TOP/Trophy, we may need to exclude deleted countries
|
||||
let displayConfirmedCount = confirmedCountries.size;
|
||||
let displayWorkedCount = workedCountries.size;
|
||||
let displayTotalBandpoints = totalBandpoints;
|
||||
|
||||
// Check if this is for WAE TOP or Trophy (which exclude deleted countries)
|
||||
if (excludeDeletedForTop) {
|
||||
let confirmedWithoutDeleted = 0;
|
||||
for (const country of confirmedCountries) {
|
||||
const data = countryData.get(country);
|
||||
if (data && !data.isDeleted) {
|
||||
confirmedWithoutDeleted++;
|
||||
}
|
||||
}
|
||||
// Store both counts
|
||||
displayConfirmedCount = confirmedWithoutDeleted;
|
||||
}
|
||||
|
||||
logger.debug('WAE award progress calculated', {
|
||||
workedCountries: displayWorkedCount,
|
||||
confirmedCountries: displayConfirmedCount,
|
||||
totalBandpoints: displayTotalBandpoints,
|
||||
targetCountries,
|
||||
targetBandpoints,
|
||||
});
|
||||
|
||||
// Build result
|
||||
const result = {
|
||||
worked: displayWorkedCount,
|
||||
confirmed: displayConfirmedCount,
|
||||
bandpoints: displayTotalBandpoints,
|
||||
workedBandpoints: displayTotalBandpoints, // For consistency with worked/confirmed naming
|
||||
targetCountries,
|
||||
targetBandpoints,
|
||||
percentage: targetCountries ? Math.round((displayConfirmedCount / targetCountries) * 100) : 0,
|
||||
bandpointsPercentage: targetBandpoints ? Math.min(100, Math.round((displayTotalBandpoints / targetBandpoints) * 100)) : 0,
|
||||
workedEntities: Array.from(workedCountries),
|
||||
confirmedEntities: Array.from(confirmedCountries),
|
||||
};
|
||||
|
||||
// Add details if requested
|
||||
if (includeDetails) {
|
||||
result.award = {
|
||||
id: award.id,
|
||||
name: award.name,
|
||||
description: award.description,
|
||||
caption: award.caption,
|
||||
targetCountries,
|
||||
targetBandpoints,
|
||||
};
|
||||
|
||||
// Build entities array for detail view
|
||||
// For WAE, we need to expand countries into (country, band, mode) slots
|
||||
// to match the frontend's expected (entity, band, mode) structure
|
||||
const expandedEntities = [];
|
||||
|
||||
for (const [countryName, data] of countryData) {
|
||||
const { bands, bandpoints, qsos, prefix, isDeleted, isWAESpecific } = data;
|
||||
|
||||
// For each band, create a slot entry
|
||||
for (const band of bands) {
|
||||
// Get all modes used on this band for this country
|
||||
const modesInBand = new Set();
|
||||
for (const qso of qsos) {
|
||||
if (qso.band === band) {
|
||||
modesInBand.add(qso.mode || 'Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
// Create a slot for each mode on this band
|
||||
for (const mode of modesInBand) {
|
||||
// Get QSOs for this specific (country, band, mode) combination
|
||||
const slotQSOs = qsos.filter(q => q.band === band && q.mode === mode);
|
||||
|
||||
expandedEntities.push({
|
||||
entity: countryName,
|
||||
entityId: null,
|
||||
entityName: countryName,
|
||||
prefix,
|
||||
isDeleted,
|
||||
isWAESpecific,
|
||||
band,
|
||||
mode,
|
||||
confirmed: true,
|
||||
bandpoints: getBandpointValue(band, award.rules.doublePointBands),
|
||||
worked: true,
|
||||
qsos: slotQSOs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no bands (shouldn't happen for confirmed countries, but handle edge case)
|
||||
if (bands.length === 0 && confirmedCountries.has(countryName)) {
|
||||
expandedEntities.push({
|
||||
entity: countryName,
|
||||
entityId: null,
|
||||
entityName: countryName,
|
||||
prefix,
|
||||
isDeleted,
|
||||
isWAESpecific,
|
||||
band: 'Unknown',
|
||||
mode: 'Unknown',
|
||||
confirmed: true,
|
||||
bandpoints: 0,
|
||||
worked: true,
|
||||
qsos: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.entities = expandedEntities;
|
||||
result.total = expandedEntities.length;
|
||||
result.confirmed = expandedEntities.filter((e) => e.confirmed).length;
|
||||
}
|
||||
|
||||
// Add achievement progress if award has achievements defined
|
||||
if (award.achievements && award.achievements.length > 0) {
|
||||
result.achievements = calculateWAEAchievementProgress(
|
||||
displayConfirmedCount,
|
||||
displayTotalBandpoints,
|
||||
award.achievements,
|
||||
excludeDeletedForTop
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate achievement progress for WAE awards (dual thresholds)
|
||||
* @param {number} confirmedCountries - Number of confirmed countries
|
||||
* @param {number} totalBandpoints - Total bandpoints earned
|
||||
* @param {Array} achievements - Array of achievement definitions
|
||||
* @param {boolean} excludeDeletedForTop - Whether deleted countries are excluded
|
||||
* @returns {Object} Achievement progress info
|
||||
*/
|
||||
function calculateWAEAchievementProgress(confirmedCountries, totalBandpoints, achievements, excludeDeletedForTop) {
|
||||
if (!achievements || achievements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort achievements by thresholdCountries
|
||||
const sorted = [...achievements].sort((a, b) => a.thresholdCountries - b.thresholdCountries);
|
||||
|
||||
// Find earned achievements, current level, and next level
|
||||
const earned = [];
|
||||
let currentLevel = null;
|
||||
let nextLevel = null;
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const achievement = sorted[i];
|
||||
|
||||
// Check if achievement criteria are met
|
||||
// For achievements with excludeDeleted flag, we need both thresholds met
|
||||
// Otherwise, just check country and bandpoint thresholds
|
||||
const countriesMet = confirmedCountries >= achievement.thresholdCountries;
|
||||
const bandpointsMet = totalBandpoints >= achievement.thresholdBandpoints;
|
||||
|
||||
// Special handling for "requireAllCountries" (WAE Trophy)
|
||||
let allCountriesMet = false;
|
||||
if (achievement.requireAllCountries) {
|
||||
const waeList = loadWAECountryList();
|
||||
const totalCountries = waeList.dxccMap.size + waeList.waeSpecificMap.size;
|
||||
allCountriesMet = confirmedCountries >= totalCountries;
|
||||
}
|
||||
|
||||
const criteriaMet = achievement.requireAllCountries
|
||||
? (allCountriesMet && bandpointsMet)
|
||||
: (countriesMet && bandpointsMet);
|
||||
|
||||
if (criteriaMet) {
|
||||
earned.push(achievement);
|
||||
currentLevel = achievement;
|
||||
} else {
|
||||
nextLevel = achievement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress toward next level
|
||||
let progressPercent = 100;
|
||||
let progressCurrent = confirmedCountries;
|
||||
let progressNeeded = 0;
|
||||
let progressBandpointsCurrent = totalBandpoints;
|
||||
let progressBandpointsNeeded = 0;
|
||||
|
||||
if (nextLevel) {
|
||||
const prevThreshold = currentLevel ? currentLevel.thresholdCountries : 0;
|
||||
const range = nextLevel.thresholdCountries - prevThreshold;
|
||||
const progressInLevel = confirmedCountries - prevThreshold;
|
||||
progressPercent = Math.round((progressInLevel / range) * 100);
|
||||
progressNeeded = nextLevel.thresholdCountries - confirmedCountries;
|
||||
progressBandpointsNeeded = nextLevel.thresholdBandpoints - totalBandpoints;
|
||||
}
|
||||
|
||||
return {
|
||||
earned,
|
||||
currentLevel,
|
||||
nextLevel,
|
||||
progressPercent,
|
||||
progressCurrent: confirmedCountries,
|
||||
progressNeeded,
|
||||
progressBandpointsCurrent: totalBandpoints,
|
||||
progressBandpointsNeeded,
|
||||
totalAchievements: sorted.length,
|
||||
earnedCount: earned.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get award progress with QSO details
|
||||
*/
|
||||
@@ -851,6 +1354,11 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
||||
return await calculatePointsAwardProgress(userId, award, { includeDetails: true });
|
||||
}
|
||||
|
||||
// Handle WAE-based awards - use the dedicated function
|
||||
if (rules.type === 'wae') {
|
||||
return await calculateWAEAwardProgress(userId, award, { includeDetails: true });
|
||||
}
|
||||
|
||||
// Get all QSOs for user
|
||||
const allQSOs = await db
|
||||
.select()
|
||||
|
||||
Reference in New Issue
Block a user