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()
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
'dok': 'DOK',
|
||||
'points': 'Points',
|
||||
'filtered': 'Filtered',
|
||||
'counter': 'Counter'
|
||||
'counter': 'Counter',
|
||||
'wae': 'WAE'
|
||||
};
|
||||
return names[ruleType] || ruleType;
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@
|
||||
case 'counter':
|
||||
validateCounterRule(errors, warnings);
|
||||
break;
|
||||
case 'wae':
|
||||
validateWAERule(errors, warnings);
|
||||
break;
|
||||
}
|
||||
|
||||
// Mode groups validation
|
||||
@@ -236,6 +239,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
function validateWAERule(errors, warnings) {
|
||||
if (!formData.rules.targetCountries || formData.rules.targetCountries <= 0) {
|
||||
errors.push('WAE rule requires targetCountries (positive number)');
|
||||
}
|
||||
|
||||
if (!formData.rules.targetBandpoints || formData.rules.targetBandpoints <= 0) {
|
||||
errors.push('WAE rule requires targetBandpoints (positive number)');
|
||||
}
|
||||
|
||||
if (!formData.rules.maxBandsPerCountry || formData.rules.maxBandsPerCountry <= 0) {
|
||||
warnings.push('WAE rule should have maxBandsPerCountry (default: 5)');
|
||||
}
|
||||
|
||||
// Check that double-point bands are valid
|
||||
if (formData.rules.doublePointBands && Array.isArray(formData.rules.doublePointBands)) {
|
||||
const validDoublePointBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
|
||||
const invalid = formData.rules.doublePointBands.filter(b => !validDoublePointBands.includes(b));
|
||||
if (invalid.length > 0) {
|
||||
warnings.push(`Unusual double-point bands: ${invalid.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateModeGroups(errors, warnings) {
|
||||
if (!formData.modeGroups) return;
|
||||
|
||||
@@ -774,6 +800,7 @@
|
||||
<option value="points">Points (sum points from stations)</option>
|
||||
<option value="filtered">Filtered (base rule with filters)</option>
|
||||
<option value="counter">Counter (count QSOs or callsigns)</option>
|
||||
<option value="wae">WAE (Worked All Europe)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -995,6 +1022,93 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if formData.rules.type === 'wae'}
|
||||
<div class="rule-config">
|
||||
<h3>WAE Rule Configuration</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Target Countries *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={formData.rules.targetCountries}
|
||||
placeholder="40"
|
||||
/>
|
||||
<small>Number of unique WAE countries required (e.g., 40 for WAE III)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Target Bandpoints *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={formData.rules.targetBandpoints}
|
||||
placeholder="100"
|
||||
/>
|
||||
<small>Total bandpoints required (1 per band, 2 for 80m/160m)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Double-Point Bands</label>
|
||||
<div class="bands-selector">
|
||||
{#each ['160m', '80m'] as band}
|
||||
<label class="band-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.rules.doublePointBands?.includes(band)}
|
||||
on:change={(e) => {
|
||||
if (!formData.rules.doublePointBands) {
|
||||
formData = {
|
||||
...formData,
|
||||
rules: {
|
||||
...formData.rules,
|
||||
doublePointBands: []
|
||||
}
|
||||
};
|
||||
}
|
||||
if (e.target.checked) {
|
||||
formData.rules.doublePointBands = [...formData.rules.doublePointBands, band];
|
||||
} else {
|
||||
formData.rules.doublePointBands = formData.rules.doublePointBands.filter(b => b !== band);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{band}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<small>Bands that count double points (typically 160m and 80m)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Maximum Bands Per Country *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
bind:value={formData.rules.maxBandsPerCountry}
|
||||
placeholder="5"
|
||||
/>
|
||||
<small>Only top N bands count per country (default: 5)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={formData.rules.excludeDeletedForTop}
|
||||
/>
|
||||
Exclude deleted countries for WAE TOP/Trophy
|
||||
</label>
|
||||
<small>WAE TOP and Trophy exclude deleted countries from the count</small>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>WAE Award Info:</strong> The WAE award tracks dual metrics - unique countries
|
||||
AND bandpoints. Each confirmed country counts 1 bandpoint per band (2 points for 80m/160m),
|
||||
with a maximum of 5 bands per country.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters section (common to all rule types) -->
|
||||
|
||||
@@ -42,6 +42,21 @@
|
||||
stationsStore.set(formData.rules.stations.map(s => ({...s})));
|
||||
}
|
||||
|
||||
// Initialize WAE-specific fields when rule type changes to 'wae' (only for new awards, not editing)
|
||||
$: if (formData.rules?.type === 'wae' && formData.rules.targetCountries === undefined && !isEdit) {
|
||||
formData = {
|
||||
...formData,
|
||||
rules: {
|
||||
...formData.rules,
|
||||
targetCountries: 40,
|
||||
targetBandpoints: 100,
|
||||
doublePointBands: ['160m', '80m'],
|
||||
maxBandsPerCountry: 5,
|
||||
excludeDeletedForTop: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Available bands and modes
|
||||
const ALL_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'];
|
||||
const ALL_MODES = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'];
|
||||
@@ -57,12 +72,11 @@
|
||||
onMount(() => {
|
||||
// Check if we're editing an existing award
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
if (pathParts.includes('[id]') || pathParts.length > 5) {
|
||||
// Extract award ID from path
|
||||
const idPart = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
|
||||
if (idPart && idPart !== 'create') {
|
||||
loadAward(idPart);
|
||||
}
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
|
||||
// If the last part is not 'create' and looks like an award ID, load the award
|
||||
if (lastPart && lastPart !== 'create' && lastPart !== 'awards') {
|
||||
loadAward(lastPart);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -128,6 +142,9 @@
|
||||
case 'counter':
|
||||
validateCounterRule(errors, warnings);
|
||||
break;
|
||||
case 'wae':
|
||||
validateWAERule(errors, warnings);
|
||||
break;
|
||||
}
|
||||
|
||||
// Mode groups validation
|
||||
@@ -243,6 +260,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
function validateWAERule(errors, warnings) {
|
||||
if (!formData.rules.targetCountries || formData.rules.targetCountries <= 0) {
|
||||
errors.push('WAE rule requires targetCountries (positive number)');
|
||||
}
|
||||
|
||||
if (!formData.rules.targetBandpoints || formData.rules.targetBandpoints <= 0) {
|
||||
errors.push('WAE rule requires targetBandpoints (positive number)');
|
||||
}
|
||||
|
||||
if (!formData.rules.maxBandsPerCountry || formData.rules.maxBandsPerCountry <= 0) {
|
||||
warnings.push('WAE rule should have maxBandsPerCountry (default: 5)');
|
||||
}
|
||||
|
||||
// Check that double-point bands are valid
|
||||
if (formData.rules.doublePointBands && Array.isArray(formData.rules.doublePointBands)) {
|
||||
const validDoublePointBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
|
||||
const invalid = formData.rules.doublePointBands.filter(b => !validDoublePointBands.includes(b));
|
||||
if (invalid.length > 0) {
|
||||
warnings.push(`Unusual double-point bands: ${invalid.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateModeGroups(errors, warnings) {
|
||||
if (!formData.modeGroups) return;
|
||||
|
||||
@@ -799,6 +839,7 @@
|
||||
<option value="points">Points (sum points from stations)</option>
|
||||
<option value="filtered">Filtered (base rule with filters)</option>
|
||||
<option value="counter">Counter (count QSOs or callsigns)</option>
|
||||
<option value="wae">WAE (Worked All Europe)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1021,6 +1062,93 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if formData.rules.type === 'wae'}
|
||||
<div class="rule-config">
|
||||
<h3>WAE Rule Configuration</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Target Countries *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={formData.rules.targetCountries}
|
||||
placeholder="40"
|
||||
/>
|
||||
<small>Number of unique WAE countries required (e.g., 40 for WAE III)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Target Bandpoints *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={formData.rules.targetBandpoints}
|
||||
placeholder="100"
|
||||
/>
|
||||
<small>Total bandpoints required (1 per band, 2 for 80m/160m)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Double-Point Bands</label>
|
||||
<div class="bands-selector">
|
||||
{#each ['160m', '80m'] as band}
|
||||
<label class="band-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.rules.doublePointBands?.includes(band)}
|
||||
on:change={(e) => {
|
||||
if (!formData.rules.doublePointBands) {
|
||||
formData = {
|
||||
...formData,
|
||||
rules: {
|
||||
...formData.rules,
|
||||
doublePointBands: []
|
||||
}
|
||||
};
|
||||
}
|
||||
if (e.target.checked) {
|
||||
formData.rules.doublePointBands = [...formData.rules.doublePointBands, band];
|
||||
} else {
|
||||
formData.rules.doublePointBands = formData.rules.doublePointBands.filter(b => b !== band);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{band}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<small>Bands that count double points (typically 160m and 80m)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Maximum Bands Per Country *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
bind:value={formData.rules.maxBandsPerCountry}
|
||||
placeholder="5"
|
||||
/>
|
||||
<small>Only top N bands count per country (default: 5)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={formData.rules.excludeDeletedForTop}
|
||||
/>
|
||||
Exclude deleted countries for WAE TOP/Trophy
|
||||
</label>
|
||||
<small>WAE TOP and Trophy exclude deleted countries from the count</small>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>WAE Award Info:</strong> The WAE award tracks dual metrics - unique countries
|
||||
AND bandpoints. Each confirmed country counts 1 bandpoint per band (2 points for 80m/160m),
|
||||
with a maximum of 5 bands per country.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters section (common to all rule types) -->
|
||||
|
||||
@@ -539,6 +539,11 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle WAE awards with dual thresholds
|
||||
if (award.rules?.type === 'wae') {
|
||||
return calculateWAEAchievementProgress(award, entities);
|
||||
}
|
||||
|
||||
// Get current count (confirmed entities or points)
|
||||
let currentCount;
|
||||
if (entities.length > 0 && entities[0].points !== undefined) {
|
||||
@@ -598,6 +603,85 @@
|
||||
earnedCount: earned.length,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateWAEAchievementProgress(award, entities) {
|
||||
// Count unique confirmed countries and total bandpoints
|
||||
const uniqueCountries = new Set();
|
||||
let totalBandpoints = 0;
|
||||
|
||||
entities.forEach(e => {
|
||||
if (e.confirmed && !e.isDeleted) {
|
||||
uniqueCountries.add(e.entityName || e.entity || 'Unknown');
|
||||
}
|
||||
totalBandpoints += e.bandpoints || 0;
|
||||
});
|
||||
|
||||
const confirmedCountries = uniqueCountries.size;
|
||||
|
||||
// Sort achievements by thresholdCountries
|
||||
const sorted = [...award.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 both thresholds are met
|
||||
const countriesMet = confirmedCountries >= achievement.thresholdCountries;
|
||||
const bandpointsMet = totalBandpoints >= achievement.thresholdBandpoints;
|
||||
|
||||
// Special handling for "requireAllCountries" (WAE Trophy)
|
||||
let allCountriesMet = false;
|
||||
if (achievement.requireAllCountries) {
|
||||
// This would require knowing total WAE countries - for now use a large number
|
||||
allCountriesMet = confirmedCountries >= 75; // Approximate WAE country count
|
||||
}
|
||||
|
||||
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,
|
||||
progressNeeded,
|
||||
progressBandpointsCurrent,
|
||||
progressBandpointsNeeded,
|
||||
totalAchievements: sorted.length,
|
||||
earnedCount: earned.length,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
@@ -619,7 +703,34 @@
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
{#if entities.length > 0 && entities[0].points !== undefined}
|
||||
{#if award?.rules?.type === 'wae'}
|
||||
{@const targetCountries = award.rules?.targetCountries}
|
||||
{@const targetBandpoints = award.rules?.targetBandpoints}
|
||||
{@const confirmedCountries = uniqueEntityProgress.confirmed}
|
||||
{@const workedCountries = uniqueEntityProgress.worked}
|
||||
{@const neededCountries = targetCountries ? Math.max(0, targetCountries - confirmedCountries) : null}
|
||||
{@const totalBandpoints = filteredEntities.reduce((sum, e) => sum + (e.bandpoints || 0), 0)}
|
||||
{@const neededBandpoints = targetBandpoints ? Math.max(0, targetBandpoints - totalBandpoints) : null}
|
||||
|
||||
<div class="summary-card wae-countries">
|
||||
<span class="summary-label">Countries:</span>
|
||||
<span class="summary-value">{confirmedCountries} / {targetCountries}</span>
|
||||
</div>
|
||||
<div class="summary-card wae-bandpoints">
|
||||
<span class="summary-label">Bandpoints:</span>
|
||||
<span class="summary-value">{totalBandpoints} / {targetBandpoints}</span>
|
||||
</div>
|
||||
{#if neededCountries !== null}
|
||||
<div class="summary-card unworked">
|
||||
<span class="summary-label">Need:</span>
|
||||
<span class="summary-value">{neededCountries} countries</span>
|
||||
</div>
|
||||
<div class="summary-card unworked">
|
||||
<span class="summary-label">Need:</span>
|
||||
<span class="summary-value">{neededBandpoints} bandpoints</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if entities.length > 0 && entities[0].points !== undefined}
|
||||
{@const earnedPoints = filteredEntities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
|
||||
{@const targetPoints = award.rules?.target}
|
||||
{@const neededPoints = targetPoints !== undefined ? Math.max(0, targetPoints - earnedPoints) : null}
|
||||
@@ -685,7 +796,11 @@
|
||||
<div class="achievement-badge earned">
|
||||
<span class="achievement-icon">★</span>
|
||||
<span class="achievement-name">{achievement.name}</span>
|
||||
<span class="achievement-threshold">{achievement.threshold}</span>
|
||||
{#if achievement.thresholdCountries !== undefined}
|
||||
<span class="achievement-threshold">{achievement.thresholdCountries} countries / {achievement.thresholdBandpoints} bandpoints</span>
|
||||
{:else}
|
||||
<span class="achievement-threshold">{achievement.threshold}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -696,9 +811,15 @@
|
||||
<div class="next-achievement">
|
||||
<div class="next-achievement-header">
|
||||
<span class="next-achievement-title">Next: {achievementProgress.nextLevel.name}</span>
|
||||
<span class="next-achievement-count">
|
||||
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.threshold}
|
||||
</span>
|
||||
{#if achievementProgress.nextLevel.thresholdCountries !== undefined}
|
||||
<span class="next-achievement-count">
|
||||
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.thresholdCountries} countries
|
||||
</span>
|
||||
{:else}
|
||||
<span class="next-achievement-count">
|
||||
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.threshold}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="achievement-progress-bar">
|
||||
<div
|
||||
@@ -707,7 +828,13 @@
|
||||
></div>
|
||||
</div>
|
||||
<div class="next-achievement-footer">
|
||||
<span class="needed-text">{achievementProgress.progressNeeded} more to {achievementProgress.nextLevel.name}</span>
|
||||
{#if achievementProgress.progressBandpointsNeeded !== undefined}
|
||||
<span class="needed-text">
|
||||
{achievementProgress.progressNeeded} countries and {achievementProgress.progressBandpointsNeeded} bandpoints more to {achievementProgress.nextLevel.name}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="needed-text">{achievementProgress.progressNeeded} more to {achievementProgress.nextLevel.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -1159,6 +1286,20 @@
|
||||
background-color: rgba(33, 150, 243, 0.15);
|
||||
}
|
||||
|
||||
.summary-card.wae-countries {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.summary-card.wae-bandpoints {
|
||||
border-color: #9c27b0;
|
||||
background-color: rgba(156, 39, 176, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .summary-card.wae-bandpoints {
|
||||
background-color: rgba(156, 39, 176, 0.2);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
|
||||
Reference in New Issue
Block a user