diff --git a/award-data/wae-country-list.json b/award-data/wae-country-list.json new file mode 100644 index 0000000..f157063 --- /dev/null +++ b/award-data/wae-country-list.json @@ -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 + } + ] +} diff --git a/award-definitions/wae.json b/award-definitions/wae.json new file mode 100644 index 0000000..1797171 --- /dev/null +++ b/award-definitions/wae.json @@ -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 } + ] +} diff --git a/src/backend/services/awards.service.js b/src/backend/services/awards.service.js index 5f84a71..3c10756 100644 --- a/src/backend/services/awards.service.js +++ b/src/backend/services/awards.service.js @@ -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() diff --git a/src/frontend/src/routes/admin/awards/+page.svelte b/src/frontend/src/routes/admin/awards/+page.svelte index b8d01fa..c2f44a7 100644 --- a/src/frontend/src/routes/admin/awards/+page.svelte +++ b/src/frontend/src/routes/admin/awards/+page.svelte @@ -60,7 +60,8 @@ 'dok': 'DOK', 'points': 'Points', 'filtered': 'Filtered', - 'counter': 'Counter' + 'counter': 'Counter', + 'wae': 'WAE' }; return names[ruleType] || ruleType; } diff --git a/src/frontend/src/routes/admin/awards/[id]/+page.svelte b/src/frontend/src/routes/admin/awards/[id]/+page.svelte index ec4f7a7..2425c52 100644 --- a/src/frontend/src/routes/admin/awards/[id]/+page.svelte +++ b/src/frontend/src/routes/admin/awards/[id]/+page.svelte @@ -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 @@ + @@ -995,6 +1022,93 @@ /> + {:else if formData.rules.type === 'wae'} +