From b09e2b3ea297ba26247bfc2a6a119303d7b8a17f Mon Sep 17 00:00:00 2001 From: Joerg Date: Fri, 23 Jan 2026 12:42:32 +0100 Subject: [PATCH] feat: add achievements system to awards with mode filter support Implement achievements milestone feature for awards with configurable levels (Silver, Gold, Platinum, etc.) that track progress beyond the base award target. Achievements display earned badges and progress bar toward next level. Backend: - Add calculateAchievementProgress() helper in awards.service.js - Include achievements field in getAllAwards() and getAwardById() - Add achievements validation in awards-admin.service.js - Update PUT endpoint validation schema to include achievements field Frontend: - Add achievements section to award detail page with gold badges - Add reactive achievement progress calculation that respects mode filter - Add achievements tab to admin create/edit pages with full CRUD Award Definitions: - Add achievements to DXCC (100/200/300/500) - Add achievements to DLD (50/100/200/300) - Add achievements to WAS (30/40/50) Co-Authored-By: Claude --- award-definitions/dld.json | 37 ++- award-definitions/dxcc.json | 72 +++++- award-definitions/was.json | 3 + src/backend/index.js | 1 + src/backend/services/awards-admin.service.js | 23 ++ src/backend/services/awards.service.js | 77 +++++- .../src/routes/admin/awards/[id]/+page.svelte | 205 ++++++++++++++- .../routes/admin/awards/create/+page.svelte | 205 ++++++++++++++- .../src/routes/awards/[id]/+page.svelte | 241 ++++++++++++++++++ 9 files changed, 844 insertions(+), 20 deletions(-) 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} +