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:
115
award-data/wae-country-list.json
Normal file
115
award-data/wae-country-list.json
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"dxccBased": [
|
||||||
|
{ "entityId": 230, "country": "Germany", "prefix": "DL", "deleted": false },
|
||||||
|
{ "entityId": 227, "country": "France", "prefix": "F", "deleted": false },
|
||||||
|
{ "entityId": 248, "country": "Italy", "prefix": "I", "deleted": false },
|
||||||
|
{ "entityId": 223, "country": "England", "prefix": "G", "deleted": false },
|
||||||
|
{ "entityId": 279, "country": "Scotland", "prefix": "GM", "deleted": false },
|
||||||
|
{ "entityId": 265, "country": "Northern Ireland", "prefix": "GI", "deleted": false },
|
||||||
|
{ "entityId": 294, "country": "Wales", "prefix": "GW", "deleted": false },
|
||||||
|
{ "entityId": 114, "country": "Isle of Man", "prefix": "GD", "deleted": false },
|
||||||
|
{ "entityId": 122, "country": "Jersey", "prefix": "GJ", "deleted": false },
|
||||||
|
{ "entityId": 106, "country": "Guernsey", "prefix": "GU", "deleted": false },
|
||||||
|
{ "entityId": 236, "country": "Greece", "prefix": "SV", "deleted": false },
|
||||||
|
{ "entityId": 209, "country": "Belgium", "prefix": "ON", "deleted": false },
|
||||||
|
{ "entityId": 263, "country": "Netherlands", "prefix": "PA", "deleted": false },
|
||||||
|
{ "entityId": 287, "country": "Switzerland", "prefix": "HB", "deleted": false },
|
||||||
|
{ "entityId": 281, "country": "Spain", "prefix": "EA", "deleted": false },
|
||||||
|
{ "entityId": 272, "country": "Portugal", "prefix": "CT", "deleted": false },
|
||||||
|
{ "entityId": 206, "country": "Austria", "prefix": "OE", "deleted": false },
|
||||||
|
{ "entityId": 503, "country": "Czech Republic", "prefix": "OK", "deleted": false },
|
||||||
|
{ "entityId": 504, "country": "Slovakia", "prefix": "OM", "deleted": false },
|
||||||
|
{ "entityId": 239, "country": "Hungary", "prefix": "HA", "deleted": false },
|
||||||
|
{ "entityId": 269, "country": "Poland", "prefix": "SP", "deleted": false },
|
||||||
|
{ "entityId": 284, "country": "Sweden", "prefix": "SM", "deleted": false },
|
||||||
|
{ "entityId": 266, "country": "Norway", "prefix": "LA", "deleted": false },
|
||||||
|
{ "entityId": 221, "country": "Denmark", "prefix": "OZ", "deleted": false },
|
||||||
|
{ "entityId": 224, "country": "Finland", "prefix": "OH", "deleted": false },
|
||||||
|
{ "entityId": 52, "country": "Estonia", "prefix": "ES", "deleted": false },
|
||||||
|
{ "entityId": 145, "country": "Latvia", "prefix": "YL", "deleted": false },
|
||||||
|
{ "entityId": 146, "country": "Lithuania", "prefix": "LY", "deleted": false },
|
||||||
|
{ "entityId": 27, "country": "Belarus", "prefix": "EU", "deleted": false },
|
||||||
|
{ "entityId": 288, "country": "Ukraine", "prefix": "UR", "deleted": false },
|
||||||
|
{ "entityId": 179, "country": "Moldova", "prefix": "ER", "deleted": false },
|
||||||
|
{ "entityId": 275, "country": "Romania", "prefix": "YO", "deleted": false },
|
||||||
|
{ "entityId": 212, "country": "Bulgaria", "prefix": "LZ", "deleted": false },
|
||||||
|
{ "entityId": 296, "country": "Serbia", "prefix": "YT", "deleted": false },
|
||||||
|
{ "entityId": 497, "country": "Croatia", "prefix": "9A", "deleted": false },
|
||||||
|
{ "entityId": 499, "country": "Slovenia", "prefix": "S5", "deleted": false },
|
||||||
|
{ "entityId": 501, "country": "Bosnia and Herzegovina", "prefix": "E7", "deleted": false },
|
||||||
|
{ "entityId": 502, "country": "North Macedonia", "prefix": "Z3", "deleted": false },
|
||||||
|
{ "entityId": 7, "country": "Albania", "prefix": "ZA", "deleted": false },
|
||||||
|
{ "entityId": 514, "country": "Montenegro", "prefix": "4O", "deleted": false },
|
||||||
|
{ "entityId": 54, "country": "Russia (European)", "prefix": "UA", "deleted": false },
|
||||||
|
{ "entityId": 126, "country": "Kaliningrad", "prefix": "UA2", "deleted": false },
|
||||||
|
{ "entityId": 390, "country": "Turkey", "prefix": "TA", "deleted": false },
|
||||||
|
{ "entityId": 215, "country": "Cyprus", "prefix": "5B", "deleted": false },
|
||||||
|
{ "entityId": 257, "country": "Malta", "prefix": "9H", "deleted": false },
|
||||||
|
{ "entityId": 242, "country": "Iceland", "prefix": "TF", "deleted": false },
|
||||||
|
{ "entityId": 245, "country": "Ireland", "prefix": "EI", "deleted": false },
|
||||||
|
{ "entityId": 254, "country": "Luxembourg", "prefix": "LX", "deleted": false },
|
||||||
|
{ "entityId": 260, "country": "Monaco", "prefix": "3A", "deleted": false },
|
||||||
|
{ "entityId": 203, "country": "Andorra", "prefix": "C3", "deleted": false },
|
||||||
|
{ "entityId": 278, "country": "San Marino", "prefix": "T7", "deleted": false },
|
||||||
|
{ "entityId": 295, "country": "Vatican City", "prefix": "HV", "deleted": false },
|
||||||
|
{ "entityId": 251, "country": "Liechtenstein", "prefix": "HB0", "deleted": false }
|
||||||
|
],
|
||||||
|
"waeSpecific": [
|
||||||
|
{
|
||||||
|
"country": "Shetland Islands",
|
||||||
|
"prefix": "GM/S",
|
||||||
|
"callsigns": ["GM/S*", "GS/S*", "2M/S*"],
|
||||||
|
"parentDxcc": 279
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country": "European Turkey",
|
||||||
|
"prefix": "TA1",
|
||||||
|
"callsigns": ["TA1*"],
|
||||||
|
"parentDxcc": 390
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country": "Sardinia",
|
||||||
|
"prefix": "IS0",
|
||||||
|
"callsigns": ["IS0*"],
|
||||||
|
"parentDxcc": 248
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country": "Sicily",
|
||||||
|
"prefix": "IT9",
|
||||||
|
"callsigns": ["IT9*"],
|
||||||
|
"parentDxcc": 248
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country": "Corsica",
|
||||||
|
"prefix": "TK",
|
||||||
|
"callsigns": ["TK*"],
|
||||||
|
"parentDxcc": 227
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country": "Crete",
|
||||||
|
"prefix": "SV9",
|
||||||
|
"callsigns": ["SV9*", "J49*"],
|
||||||
|
"parentDxcc": 236
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country": "ITU Headquarters Geneva",
|
||||||
|
"prefix": "4U1I",
|
||||||
|
"callsigns": ["4U1I"],
|
||||||
|
"parentDxcc": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"country": "UN Vienna",
|
||||||
|
"prefix": "4U1V",
|
||||||
|
"callsigns": ["4U1V"],
|
||||||
|
"parentDxcc": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deletedCountries": [
|
||||||
|
{
|
||||||
|
"country": "German Democratic Republic",
|
||||||
|
"prefix": "Y2",
|
||||||
|
"deleted": "1990-10-03",
|
||||||
|
"formerEntityId": 229
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
33
award-definitions/wae.json
Normal file
33
award-definitions/wae.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"id": "wae",
|
||||||
|
"name": "WAE",
|
||||||
|
"description": "Worked All Europe - Contact and confirm European countries from the WAE Country List",
|
||||||
|
"caption": "Worked All Europe Award. Bandpoints: 1 point per band (2 points for 80m/160m), maximum 5 bands per country. Available in multiple mode variants.",
|
||||||
|
"category": "darc",
|
||||||
|
"modeGroups": {
|
||||||
|
"CW": ["CW"],
|
||||||
|
"SSB": ["SSB", "AM", "FM"],
|
||||||
|
"RTTY": ["RTTY"],
|
||||||
|
"FT8": ["FT8"],
|
||||||
|
"Digi-Modes": ["FT4", "FT8", "JT65", "JT9", "MFSK", "PSK31", "RTTY"],
|
||||||
|
"Classic Digi-Modes": ["PSK31", "RTTY"],
|
||||||
|
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
|
||||||
|
"Phone-Modes": ["AM", "SSB", "FM"]
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"type": "wae",
|
||||||
|
"targetCountries": 40,
|
||||||
|
"targetBandpoints": 100,
|
||||||
|
"doublePointBands": ["160m", "80m"],
|
||||||
|
"maxBandsPerCountry": 5,
|
||||||
|
"excludeDeletedForTop": true,
|
||||||
|
"waeCountryList": "wae-country-list.json"
|
||||||
|
},
|
||||||
|
"achievements": [
|
||||||
|
{ "name": "WAE III", "thresholdCountries": 40, "thresholdBandpoints": 100 },
|
||||||
|
{ "name": "WAE II", "thresholdCountries": 50, "thresholdBandpoints": 150 },
|
||||||
|
{ "name": "WAE I", "thresholdCountries": 60, "thresholdBandpoints": 200 },
|
||||||
|
{ "name": "WAE TOP", "thresholdCountries": 70, "thresholdBandpoints": 300, "excludeDeleted": true },
|
||||||
|
{ "name": "WAE Trophy", "thresholdCountries": 999, "thresholdBandpoints": 365, "requireAllCountries": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@ function loadAwardDefinitions() {
|
|||||||
*/
|
*/
|
||||||
export function clearAwardCache() {
|
export function clearAwardCache() {
|
||||||
cachedAwardDefinitions = null;
|
cachedAwardDefinitions = null;
|
||||||
|
cachedWAECountryList = null;
|
||||||
logger.info('Award cache cleared');
|
logger.info('Award cache cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +243,11 @@ export async function calculateAwardProgress(userId, award, options = {}) {
|
|||||||
return calculatePointsAwardProgress(userId, award, { includeDetails });
|
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
|
// Get all QSOs for user
|
||||||
const allQSOs = await db
|
const allQSOs = await db
|
||||||
.select()
|
.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
|
* Get award progress with QSO details
|
||||||
*/
|
*/
|
||||||
@@ -851,6 +1354,11 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
|||||||
return await calculatePointsAwardProgress(userId, award, { includeDetails: true });
|
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
|
// Get all QSOs for user
|
||||||
const allQSOs = await db
|
const allQSOs = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -60,7 +60,8 @@
|
|||||||
'dok': 'DOK',
|
'dok': 'DOK',
|
||||||
'points': 'Points',
|
'points': 'Points',
|
||||||
'filtered': 'Filtered',
|
'filtered': 'Filtered',
|
||||||
'counter': 'Counter'
|
'counter': 'Counter',
|
||||||
|
'wae': 'WAE'
|
||||||
};
|
};
|
||||||
return names[ruleType] || ruleType;
|
return names[ruleType] || ruleType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,9 @@
|
|||||||
case 'counter':
|
case 'counter':
|
||||||
validateCounterRule(errors, warnings);
|
validateCounterRule(errors, warnings);
|
||||||
break;
|
break;
|
||||||
|
case 'wae':
|
||||||
|
validateWAERule(errors, warnings);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode groups validation
|
// 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) {
|
function validateModeGroups(errors, warnings) {
|
||||||
if (!formData.modeGroups) return;
|
if (!formData.modeGroups) return;
|
||||||
|
|
||||||
@@ -774,6 +800,7 @@
|
|||||||
<option value="points">Points (sum points from stations)</option>
|
<option value="points">Points (sum points from stations)</option>
|
||||||
<option value="filtered">Filtered (base rule with filters)</option>
|
<option value="filtered">Filtered (base rule with filters)</option>
|
||||||
<option value="counter">Counter (count QSOs or callsigns)</option>
|
<option value="counter">Counter (count QSOs or callsigns)</option>
|
||||||
|
<option value="wae">WAE (Worked All Europe)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -995,6 +1022,93 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/if}
|
||||||
|
|
||||||
<!-- Filters section (common to all rule types) -->
|
<!-- Filters section (common to all rule types) -->
|
||||||
|
|||||||
@@ -42,6 +42,21 @@
|
|||||||
stationsStore.set(formData.rules.stations.map(s => ({...s})));
|
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
|
// 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_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'];
|
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(() => {
|
onMount(() => {
|
||||||
// Check if we're editing an existing award
|
// Check if we're editing an existing award
|
||||||
const pathParts = window.location.pathname.split('/');
|
const pathParts = window.location.pathname.split('/');
|
||||||
if (pathParts.includes('[id]') || pathParts.length > 5) {
|
const lastPart = pathParts[pathParts.length - 1];
|
||||||
// Extract award ID from path
|
|
||||||
const idPart = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
|
// If the last part is not 'create' and looks like an award ID, load the award
|
||||||
if (idPart && idPart !== 'create') {
|
if (lastPart && lastPart !== 'create' && lastPart !== 'awards') {
|
||||||
loadAward(idPart);
|
loadAward(lastPart);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,6 +142,9 @@
|
|||||||
case 'counter':
|
case 'counter':
|
||||||
validateCounterRule(errors, warnings);
|
validateCounterRule(errors, warnings);
|
||||||
break;
|
break;
|
||||||
|
case 'wae':
|
||||||
|
validateWAERule(errors, warnings);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode groups validation
|
// 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) {
|
function validateModeGroups(errors, warnings) {
|
||||||
if (!formData.modeGroups) return;
|
if (!formData.modeGroups) return;
|
||||||
|
|
||||||
@@ -799,6 +839,7 @@
|
|||||||
<option value="points">Points (sum points from stations)</option>
|
<option value="points">Points (sum points from stations)</option>
|
||||||
<option value="filtered">Filtered (base rule with filters)</option>
|
<option value="filtered">Filtered (base rule with filters)</option>
|
||||||
<option value="counter">Counter (count QSOs or callsigns)</option>
|
<option value="counter">Counter (count QSOs or callsigns)</option>
|
||||||
|
<option value="wae">WAE (Worked All Europe)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1021,6 +1062,93 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/if}
|
||||||
|
|
||||||
<!-- Filters section (common to all rule types) -->
|
<!-- Filters section (common to all rule types) -->
|
||||||
|
|||||||
@@ -539,6 +539,11 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle WAE awards with dual thresholds
|
||||||
|
if (award.rules?.type === 'wae') {
|
||||||
|
return calculateWAEAchievementProgress(award, entities);
|
||||||
|
}
|
||||||
|
|
||||||
// Get current count (confirmed entities or points)
|
// Get current count (confirmed entities or points)
|
||||||
let currentCount;
|
let currentCount;
|
||||||
if (entities.length > 0 && entities[0].points !== undefined) {
|
if (entities.length > 0 && entities[0].points !== undefined) {
|
||||||
@@ -598,6 +603,85 @@
|
|||||||
earnedCount: earned.length,
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -619,7 +703,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary">
|
<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 earnedPoints = filteredEntities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
|
||||||
{@const targetPoints = award.rules?.target}
|
{@const targetPoints = award.rules?.target}
|
||||||
{@const neededPoints = targetPoints !== undefined ? Math.max(0, targetPoints - earnedPoints) : null}
|
{@const neededPoints = targetPoints !== undefined ? Math.max(0, targetPoints - earnedPoints) : null}
|
||||||
@@ -685,7 +796,11 @@
|
|||||||
<div class="achievement-badge earned">
|
<div class="achievement-badge earned">
|
||||||
<span class="achievement-icon">★</span>
|
<span class="achievement-icon">★</span>
|
||||||
<span class="achievement-name">{achievement.name}</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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -696,9 +811,15 @@
|
|||||||
<div class="next-achievement">
|
<div class="next-achievement">
|
||||||
<div class="next-achievement-header">
|
<div class="next-achievement-header">
|
||||||
<span class="next-achievement-title">Next: {achievementProgress.nextLevel.name}</span>
|
<span class="next-achievement-title">Next: {achievementProgress.nextLevel.name}</span>
|
||||||
<span class="next-achievement-count">
|
{#if achievementProgress.nextLevel.thresholdCountries !== undefined}
|
||||||
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.threshold}
|
<span class="next-achievement-count">
|
||||||
</span>
|
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.thresholdCountries} countries
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="next-achievement-count">
|
||||||
|
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.threshold}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="achievement-progress-bar">
|
<div class="achievement-progress-bar">
|
||||||
<div
|
<div
|
||||||
@@ -707,7 +828,13 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="next-achievement-footer">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -1159,6 +1286,20 @@
|
|||||||
background-color: rgba(33, 150, 243, 0.15);
|
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 {
|
.summary-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user