diff --git a/award-definitions/dld.json b/award-definitions/dld.json index 2a6ec6f..02c9947 100644 --- a/award-definitions/dld.json +++ b/award-definitions/dld.json @@ -4,16 +4,37 @@ "description": "Deutschland Diplom - Confirm 100 unique DOKs on different bands/modes", "caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on different band/mode combinations. Each unique DOK on a unique band/mode counts as one point. Only DCL-confirmed QSOs with valid DOK information count toward this award.", "category": "darc", - "modeGroups": { - "Digi-Modes": ["FT8", "FT4", "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": "dok", "target": 100, "confirmationType": "dcl", - "displayField": "darcDok" + "displayField": "darcDok", + "stations": [] + }, + "modeGroups": { + "Digi-Modes": [ + "FT4", + "FT8", + "MFSK", + "PSK31", + "RTTY" + ], + "Classic Digi-Modes": [ + "PSK31", + "RTTY" + ], + "Mixed-Mode w/o WSJT-Modes": [ + "AM", + "CW", + "FM", + "PSK31", + "RTTY", + "SSB" + ], + "Phone-Modes": [ + "AM", + "FM", + "SSB" + ] } -} +} \ No newline at end of file diff --git a/award-definitions/dxcc.json b/award-definitions/dxcc.json index 8c33765..cf1280f 100644 --- a/award-definitions/dxcc.json +++ b/award-definitions/dxcc.json @@ -4,17 +4,71 @@ "description": "Confirm 100 DXCC entities on HF bands", "caption": "Contact and confirm 100 different DXCC entities on HF bands (160m-10m). Only HF band QSOs count toward this award. QSOs are confirmed when LoTW QSL is received.", "category": "dxcc", - "modeGroups": { - "Digi-Modes": ["FT8", "FT4", "MFSK", "PSK31", "RTTY", "JT65", "JT9"], - "Classic Digi-Modes": ["PSK31", "RTTY", "JT65", "JT9"], - "Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"], - "Phone-Modes": ["AM", "SSB", "FM"] - }, "rules": { "type": "entity", "entityType": "dxcc", "target": 100, "displayField": "entity", - "allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"] - } -} + "allowed_bands": [ + "160m", + "80m", + "60m", + "40m", + "30m", + "20m", + "17m", + "15m", + "12m", + "10m" + ], + "stations": [] + }, + "modeGroups": { + "Digi-Modes": [ + "FT4", + "FT8", + "JT65", + "JT9", + "MFSK", + "PSK31", + "RTTY" + ], + "Classic Digi-Modes": [ + "JT65", + "JT9", + "PSK31", + "RTTY" + ], + "Mixed-Mode w/o WSJT-Modes": [ + "AM", + "CW", + "FM", + "PSK31", + "RTTY", + "SSB" + ], + "Phone-Modes": [ + "AM", + "FM", + "SSB" + ] + }, + "achievements": [ + { + "name": "Silver", + "threshold": 100 + }, + { + "name": "Gold", + "threshold": 200 + }, + { + "name": "Platinum", + "threshold": 300 + }, + { + "name": "All", + "threshold": 341 + } + ] +} \ No newline at end of file diff --git a/award-definitions/was.json b/award-definitions/was.json index 06598c6..756aa46 100644 --- a/award-definitions/was.json +++ b/award-definitions/was.json @@ -4,6 +4,9 @@ "description": "Confirm all 50 US states", "caption": "Contact and confirm all 50 US states. Only QSOs with stations located in United States states count toward this award. QSOs are confirmed when LoTW QSL is received.", "category": "was", + "achievements": [ + { "name": "WAS Award", "threshold": 50 } + ], "rules": { "type": "entity", "entityType": "state", diff --git a/src/backend/index.js b/src/backend/index.js index 0bda214..32ac8d7 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -1607,6 +1607,7 @@ const app = new Elysia() category: t.String(), rules: t.Any(), modeGroups: t.Optional(t.Any()), + achievements: t.Optional(t.Any()), }), } ) diff --git a/src/backend/services/awards-admin.service.js b/src/backend/services/awards-admin.service.js index b0dca75..6da4370 100644 --- a/src/backend/services/awards-admin.service.js +++ b/src/backend/services/awards-admin.service.js @@ -134,6 +134,29 @@ export function validateAwardDefinition(definition, existingDefinitions = []) { errors.push('Category must be a string'); } + // Validate achievements if present + if (definition.achievements) { + if (!Array.isArray(definition.achievements)) { + errors.push('achievements must be an array'); + } else { + for (let i = 0; i < definition.achievements.length; i++) { + const achievement = definition.achievements[i]; + if (!achievement.name || typeof achievement.name !== 'string') { + errors.push(`Achievement ${i + 1} must have a name`); + } + if (typeof achievement.threshold !== 'number' || achievement.threshold <= 0) { + errors.push(`Achievement "${achievement.name || i + 1}" must have a positive threshold`); + } + } + // Check for duplicate thresholds + const thresholds = definition.achievements.map(a => a.threshold); + const uniqueThresholds = new Set(thresholds); + if (thresholds.length !== uniqueThresholds.size) { + errors.push('Achievements must have unique thresholds'); + } + } + } + // Validate modeGroups if present if (definition.modeGroups) { if (typeof definition.modeGroups !== 'object') { diff --git a/src/backend/services/awards.service.js b/src/backend/services/awards.service.js index 7d0cb66..f9c96dd 100644 --- a/src/backend/services/awards.service.js +++ b/src/backend/services/awards.service.js @@ -87,6 +87,61 @@ export function clearAwardCache() { logger.info('Award cache cleared'); } +/** + * Calculate achievement progress for an award + * @param {number} currentCount - Current confirmed count (entities or points) + * @param {Array} achievements - Array of achievement definitions + * @returns {Object|null} Achievement progress info or null if no achievements defined + */ +function calculateAchievementProgress(currentCount, achievements) { + if (!achievements || achievements.length === 0) { + return null; + } + + // Sort achievements by threshold + const sorted = [...achievements].sort((a, b) => a.threshold - b.threshold); + + // 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]; + if (currentCount >= achievement.threshold) { + earned.push(achievement); + currentLevel = achievement; + } else { + nextLevel = achievement; + break; + } + } + + // Calculate progress toward next level + let progressPercent = 100; + let progressCurrent = currentCount; + let progressNeeded = 0; + + if (nextLevel) { + const prevThreshold = currentLevel ? currentLevel.threshold : 0; + const range = nextLevel.threshold - prevThreshold; + const progressInLevel = currentCount - prevThreshold; + progressPercent = Math.round((progressInLevel / range) * 100); + progressNeeded = nextLevel.threshold - currentCount; + } + + return { + earned, + currentLevel, + nextLevel, + progressPercent, + progressCurrent, + progressNeeded, + totalAchievements: sorted.length, + earnedCount: earned.length, + }; +} + /** * Get all available awards */ @@ -101,6 +156,7 @@ export async function getAllAwards() { category: def.category, rules: def.rules, modeGroups: def.modeGroups || null, + achievements: def.achievements || null, })); } @@ -125,6 +181,7 @@ export function getAwardById(awardId) { category: award.category, rules: award.rules, modeGroups: award.modeGroups || null, + achievements: award.achievements || null, }; } @@ -234,7 +291,7 @@ export async function calculateAwardProgress(userId, award, options = {}) { } } - return { + const result = { worked: workedEntities.size, confirmed: confirmedEntities.size, target: rules.target || 0, @@ -242,6 +299,13 @@ export async function calculateAwardProgress(userId, award, options = {}) { workedEntities: Array.from(workedEntities), confirmedEntities: Array.from(confirmedEntities), }; + + // Add achievement progress if award has achievements defined + if (award.achievements && award.achievements.length > 0) { + result.achievements = calculateAchievementProgress(confirmedEntities.size, award.achievements); + } + + return result; } /** @@ -362,6 +426,11 @@ async function calculateDOKAwardProgress(userId, award, options = {}) { result.confirmed = result.entities.filter((e) => e.confirmed).length; } + // Add achievement progress if award has achievements defined + if (award.achievements && award.achievements.length > 0) { + result.achievements = calculateAchievementProgress(confirmedDOKs.size, award.achievements); + } + return result; } @@ -623,6 +692,12 @@ async function calculatePointsAwardProgress(userId, award, options = {}) { result.stationDetails = stationDetails; } + // Add achievement progress if award has achievements defined + // For point-based awards, use totalPoints instead of confirmed count + if (award.achievements && award.achievements.length > 0) { + result.achievements = calculateAchievementProgress(totalPoints, award.achievements); + } + return result; } diff --git a/src/frontend/src/routes/admin/awards/[id]/+page.svelte b/src/frontend/src/routes/admin/awards/[id]/+page.svelte index 1ffd9fe..6d51abb 100644 --- a/src/frontend/src/routes/admin/awards/[id]/+page.svelte +++ b/src/frontend/src/routes/admin/awards/[id]/+page.svelte @@ -16,7 +16,7 @@ // UI state let showTestModal = false; - let activeTab = 'basic'; // basic, modeGroups, rules + let activeTab = 'basic'; // basic, modeGroups, achievements, rules // Form data let formData = { @@ -26,11 +26,16 @@ caption: '', category: '', modeGroups: {}, + achievements: [], rules: { type: 'entity', } }; + // Achievements editor state + let newAchievementName = ''; + let newAchievementThreshold = 100; + // Update stations store when formData changes $: if (formData.rules?.stations) { stationsStore.set(formData.rules.stations.map(s => ({...s}))); @@ -66,6 +71,7 @@ caption: data.award.caption || '', category: data.award.category || '', modeGroups: data.award.modeGroups || {}, + achievements: data.award.achievements || [], rules: data.award.rules || { type: 'entity' }, }; awardId = id; @@ -120,6 +126,9 @@ // Mode groups validation validateModeGroups(errors, warnings); + // Achievements validation + validateAchievements(errors, warnings); + // Cross-field validation performCrossFieldValidation(errors, warnings); @@ -270,6 +279,46 @@ } } + function validateAchievements(errors, warnings) { + if (!formData.achievements || formData.achievements.length === 0) { + return; // Achievements are optional + } + + // Check for duplicate thresholds + const thresholds = formData.achievements.map(a => a.threshold); + const uniqueThresholds = new Set(thresholds); + if (thresholds.length !== uniqueThresholds.size) { + errors.push('Achievements must have unique thresholds'); + } + + // Check for invalid threshold values + formData.achievements.forEach((achievement, i) => { + if (!achievement.name || !achievement.name.trim()) { + errors.push(`Achievement ${i + 1} is missing a name`); + } + if (typeof achievement.threshold !== 'number' || achievement.threshold <= 0) { + errors.push(`Achievement "${achievement.name || i + 1}" must have a positive threshold`); + } + }); + + // Warn if achievements are not in ascending order (they should be sorted) + for (let i = 1; i < formData.achievements.length; i++) { + if (formData.achievements[i].threshold < formData.achievements[i - 1].threshold) { + warnings.push('Achievements are not in ascending order by threshold'); + break; + } + } + + // Check if first achievement threshold equals or is less than the base target + const baseTarget = formData.rules?.target; + if (baseTarget && formData.achievements.length > 0) { + const firstThreshold = formData.achievements[0].threshold; + if (firstThreshold < baseTarget) { + warnings.push(`First achievement threshold (${firstThreshold}) is less than base target (${baseTarget}) - this may be intentional for "milestone below target" achievements`); + } + } + } + function performCrossFieldValidation(errors, warnings) { // Check if filters contradict satellite_only if (formData.rules.satellite_only && formData.rules.filters) { @@ -434,6 +483,48 @@ performSafetyValidation(); } + // Achievements management + function addAchievement() { + if (!newAchievementName.trim()) { + alert('Please enter an achievement name'); + return; + } + if (!newAchievementThreshold || newAchievementThreshold <= 0) { + alert('Please enter a valid threshold (positive number)'); + return; + } + + // Check for duplicate threshold + const exists = formData.achievements?.some(a => a.threshold === newAchievementThreshold); + if (exists) { + alert('An achievement with this threshold already exists'); + return; + } + + formData = { + ...formData, + achievements: [ + ...(formData.achievements || []), + { name: newAchievementName.trim(), threshold: newAchievementThreshold } + ] + }; + + // Sort achievements by threshold + formData.achievements.sort((a, b) => a.threshold - b.threshold); + + newAchievementName = ''; + newAchievementThreshold = 100; + performSafetyValidation(); + } + + function removeAchievement(index) { + formData = { + ...formData, + achievements: formData.achievements.filter((_, i) => i !== index) + }; + performSafetyValidation(); + } + function testAward() { showTestModal = true; } @@ -492,6 +583,12 @@ > Mode Groups + + + {/each} + + {:else} +

No achievements defined yet.

+ {/if} + +
+

Add Achievement

+
+ + +
+
+ + + The number of confirmed entities or points required to earn this achievement +
+ +
+ +
+ Achievement Progress: Users will see earned achievements as gold badges. A progress bar shows progress toward the next achievement level. + Achievements are sorted by threshold (lowest first). The first achievement is typically at or above the base target. +
+ + {/if} + {#if activeTab === 'rules'}

Award Rules

@@ -1088,6 +1239,58 @@ color: var(--text-primary); } + /* Achievements styles */ + .achievements-list { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 2rem; + } + + .achievements-list h3 { + margin-bottom: 0.5rem; + color: var(--text-primary); + } + + .achievement-item { + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 1rem; + background: var(--bg-secondary); + } + + .achievement-info { + display: flex; + align-items: center; + gap: 1rem; + } + + .achievement-info strong { + color: var(--text-primary); + } + + .achievement-threshold { + background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%); + color: #5d4037; + padding: 0.25rem 0.75rem; + border-radius: var(--border-radius-pill); + font-size: 0.85rem; + font-weight: 600; + } + + .add-achievement { + border-top: 1px solid var(--border-color); + padding-top: 2rem; + } + + .add-achievement h3 { + margin-bottom: 1rem; + color: var(--text-primary); + } + .mode-selector, .bands-selector { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); diff --git a/src/frontend/src/routes/admin/awards/create/+page.svelte b/src/frontend/src/routes/admin/awards/create/+page.svelte index 612cfcd..2843c5e 100644 --- a/src/frontend/src/routes/admin/awards/create/+page.svelte +++ b/src/frontend/src/routes/admin/awards/create/+page.svelte @@ -17,7 +17,7 @@ // UI state let showTestModal = false; - let activeTab = 'basic'; // basic, modeGroups, rules + let activeTab = 'basic'; // basic, modeGroups, achievements, rules // Form data let formData = { @@ -27,11 +27,16 @@ caption: '', category: '', modeGroups: {}, + achievements: [], rules: { type: 'entity', } }; + // Achievements editor state + let newAchievementName = ''; + let newAchievementThreshold = 100; + // Update stations store when formData changes $: if (formData.rules?.stations) { stationsStore.set(formData.rules.stations.map(s => ({...s}))); @@ -72,6 +77,7 @@ caption: data.award.caption || '', category: data.award.category || '', modeGroups: data.award.modeGroups || {}, + achievements: data.award.achievements || [], rules: data.award.rules || { type: 'entity' }, }; awardId = id; @@ -127,6 +133,9 @@ // Mode groups validation validateModeGroups(errors, warnings); + // Achievements validation + validateAchievements(errors, warnings); + // Cross-field validation performCrossFieldValidation(errors, warnings); @@ -277,6 +286,46 @@ } } + function validateAchievements(errors, warnings) { + if (!formData.achievements || formData.achievements.length === 0) { + return; // Achievements are optional + } + + // Check for duplicate thresholds + const thresholds = formData.achievements.map(a => a.threshold); + const uniqueThresholds = new Set(thresholds); + if (thresholds.length !== uniqueThresholds.size) { + errors.push('Achievements must have unique thresholds'); + } + + // Check for invalid threshold values + formData.achievements.forEach((achievement, i) => { + if (!achievement.name || !achievement.name.trim()) { + errors.push(`Achievement ${i + 1} is missing a name`); + } + if (typeof achievement.threshold !== 'number' || achievement.threshold <= 0) { + errors.push(`Achievement "${achievement.name || i + 1}" must have a positive threshold`); + } + }); + + // Warn if achievements are not in ascending order (they should be sorted) + for (let i = 1; i < formData.achievements.length; i++) { + if (formData.achievements[i].threshold < formData.achievements[i - 1].threshold) { + warnings.push('Achievements are not in ascending order by threshold'); + break; + } + } + + // Check if first achievement threshold equals or is less than the base target + const baseTarget = formData.rules?.target; + if (baseTarget && formData.achievements.length > 0) { + const firstThreshold = formData.achievements[0].threshold; + if (firstThreshold < baseTarget) { + warnings.push(`First achievement threshold (${firstThreshold}) is less than base target (${baseTarget}) - this may be intentional for "milestone below target" achievements`); + } + } + } + function performCrossFieldValidation(errors, warnings) { // Check if filters contradict satellite_only if (formData.rules.satellite_only && formData.rules.filters) { @@ -445,6 +494,48 @@ performSafetyValidation(); } + // Achievements management + function addAchievement() { + if (!newAchievementName.trim()) { + alert('Please enter an achievement name'); + return; + } + if (!newAchievementThreshold || newAchievementThreshold <= 0) { + alert('Please enter a valid threshold (positive number)'); + return; + } + + // Check for duplicate threshold + const exists = formData.achievements?.some(a => a.threshold === newAchievementThreshold); + if (exists) { + alert('An achievement with this threshold already exists'); + return; + } + + formData = { + ...formData, + achievements: [ + ...(formData.achievements || []), + { name: newAchievementName.trim(), threshold: newAchievementThreshold } + ] + }; + + // Sort achievements by threshold + formData.achievements.sort((a, b) => a.threshold - b.threshold); + + newAchievementName = ''; + newAchievementThreshold = 100; + performSafetyValidation(); + } + + function removeAchievement(index) { + formData = { + ...formData, + achievements: formData.achievements.filter((_, i) => i !== index) + }; + performSafetyValidation(); + } + function testAward() { showTestModal = true; } @@ -514,6 +605,12 @@ > Mode Groups +
{/if} + {#if activeTab === 'achievements'} +
+

Achievements

+

Define achievement levels (milestones) for this award. These are optional and represent additional goals beyond the base target.

+ + {#if formData.achievements && formData.achievements.length > 0} +
+

Defined Achievements

+ {#each formData.achievements as achievement, i (i)} +
+
+ {achievement.name} + {achievement.threshold} pts/entities +
+ +
+ {/each} +
+ {:else} +

No achievements defined yet.

+ {/if} + +
+

Add Achievement

+
+ + +
+
+ + + The number of confirmed entities or points required to earn this achievement +
+ +
+ +
+ Achievement Progress: Users will see earned achievements as gold badges. A progress bar shows progress toward the next achievement level. + Achievements are sorted by threshold (lowest first). The first achievement is typically at or above the base target. +
+
+ {/if} + {#if activeTab === 'rules'}

Award Rules

@@ -1114,6 +1265,58 @@ color: var(--text-primary); } + /* Achievements styles */ + .achievements-list { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 2rem; + } + + .achievements-list h3 { + margin-bottom: 0.5rem; + color: var(--text-primary); + } + + .achievement-item { + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 1rem; + background: var(--bg-secondary); + } + + .achievement-info { + display: flex; + align-items: center; + gap: 1rem; + } + + .achievement-info strong { + color: var(--text-primary); + } + + .achievement-threshold { + background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%); + color: #5d4037; + padding: 0.25rem 0.75rem; + border-radius: var(--border-radius-pill); + font-size: 0.85rem; + font-weight: 600; + } + + .add-achievement { + border-top: 1px solid var(--border-color); + padding-top: 2rem; + } + + .add-achievement h3 { + margin-bottom: 1rem; + color: var(--text-primary); + } + .mode-selector, .bands-selector { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); diff --git a/src/frontend/src/routes/awards/[id]/+page.svelte b/src/frontend/src/routes/awards/[id]/+page.svelte index c1136a6..fddcf1b 100644 --- a/src/frontend/src/routes/awards/[id]/+page.svelte +++ b/src/frontend/src/routes/awards/[id]/+page.svelte @@ -21,6 +21,12 @@ let selectedSlotQSOs = []; let selectedSlotInfo = null; // { entityName, band, mode } + // Achievement progress (reactive to mode changes via filteredEntities) + let achievementProgress = null; + $: if (award && filteredEntities) { + achievementProgress = calculateAchievementProgress(award, filteredEntities); + } + // Get available modes from entities // Structure: Mixed Mode, Mode Groups (if any), Separator, Individual Modes $: availableModes = (() => { @@ -527,6 +533,71 @@ if (status === '?') return { label: 'Unknown', class: 'unknown' }; return { label: 'No Data', class: 'no-data' }; } + + function calculateAchievementProgress(award, entities) { + if (!award.achievements || award.achievements.length === 0) { + return null; + } + + // Get current count (confirmed entities or points) + let currentCount; + if (entities.length > 0 && entities[0].points !== undefined) { + // Point-based award + currentCount = entities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0); + } else { + // Entity-based award - count unique confirmed entities + const uniqueEntities = new Set(); + entities.forEach(e => { + if (e.confirmed) { + uniqueEntities.add(e.entityName || e.entity || 'Unknown'); + } + }); + currentCount = uniqueEntities.size; + } + + // Sort achievements by threshold + const sorted = [...award.achievements].sort((a, b) => a.threshold - b.threshold); + + // 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]; + if (currentCount >= achievement.threshold) { + earned.push(achievement); + currentLevel = achievement; + } else { + nextLevel = achievement; + break; + } + } + + // Calculate progress toward next level + let progressPercent = 100; + let progressCurrent = currentCount; + let progressNeeded = 0; + + if (nextLevel) { + const prevThreshold = currentLevel ? currentLevel.threshold : 0; + const range = nextLevel.threshold - prevThreshold; + const progressInLevel = currentCount - prevThreshold; + progressPercent = Math.round((progressInLevel / range) * 100); + progressNeeded = nextLevel.threshold - currentCount; + } + + return { + earned, + currentLevel, + nextLevel, + progressPercent, + progressCurrent, + progressNeeded, + totalAchievements: sorted.length, + earnedCount: earned.length, + }; + }
@@ -602,6 +673,52 @@ {/if}
+ + {#if award?.achievements && award.achievements.length > 0 && achievementProgress} +
+

Achievements

+ + + {#if achievementProgress.earned.length > 0} +
+ {#each achievementProgress.earned as achievement} +
+ + {achievement.name} + {achievement.threshold} +
+ {/each} +
+ {/if} + + + {#if achievementProgress.nextLevel} +
+
+ Next: {achievementProgress.nextLevel.name} + + {achievementProgress.progressCurrent} / {achievementProgress.nextLevel.threshold} + +
+
+
+
+ +
+ {:else} +
+ + All achievements complete! +
+ {/if} +
+ {/if} +