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:
2026-01-23 18:07:52 +01:00
parent e4e7f3c208
commit aa55158347
7 changed files with 1053 additions and 13 deletions

View File

@@ -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()

View File

@@ -60,7 +60,8 @@
'dok': 'DOK',
'points': 'Points',
'filtered': 'Filtered',
'counter': 'Counter'
'counter': 'Counter',
'wae': 'WAE'
};
return names[ruleType] || ruleType;
}

View File

@@ -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) -->

View File

@@ -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) -->

View File

@@ -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;