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 + activeTab = 'achievements'} + > + Achievements + activeTab = 'rules'} @@ -611,6 +708,60 @@ {/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 + + removeAchievement(i)}>Remove + + {/each} + + {:else} + No achievements defined yet. + {/if} + + + Add Achievement + + Achievement Name + + + + Threshold (entities/points required) * + + The number of confirmed entities or points required to earn this achievement + + Add 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 + activeTab = 'achievements'} + > + Achievements + activeTab = 'rules'} @@ -636,6 +733,60 @@ {/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 + + removeAchievement(i)}>Remove + + {/each} + + {:else} + No achievements defined yet. + {/if} + + + Add Achievement + + Achievement Name + + + + Threshold (entities/points required) * + + The number of confirmed entities or points required to earn this achievement + + Add 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} + Filter by mode: @@ -1432,6 +1549,130 @@ background-color: var(--border-color-light); } + /* Achievements Section */ + .achievements-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + padding: 1.5rem; + margin-bottom: 2rem; + box-shadow: var(--shadow-sm); + } + + .achievements-section h2 { + font-size: 1.25rem; + color: var(--text-primary); + margin: 0 0 1rem 0; + border-bottom: 2px solid var(--border-color); + padding-bottom: 0.5rem; + } + + /* Earned Achievements */ + .earned-achievements { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.5rem; + } + + .achievement-badge { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: var(--border-radius-pill); + font-weight: 500; + border: 2px solid; + } + + .achievement-badge.earned { + background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%); + border-color: #ff8f00; + color: #5d4037; + } + + .achievement-icon { + font-size: 1.1rem; + } + + .achievement-name { + font-size: 0.95rem; + font-weight: 600; + } + + .achievement-threshold { + font-size: 0.85rem; + opacity: 0.8; + } + + /* Next Achievement */ + .next-achievement { + background: var(--bg-secondary); + border-radius: var(--border-radius); + padding: 1rem; + } + + .next-achievement-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + } + + .next-achievement-title { + font-weight: 600; + color: var(--text-primary); + } + + .next-achievement-count { + font-size: 0.9rem; + color: var(--color-primary); + font-weight: 500; + } + + .achievement-progress-bar { + width: 100%; + height: 12px; + background: var(--border-color); + border-radius: 6px; + overflow: hidden; + margin-bottom: 0.5rem; + } + + .achievement-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--color-primary) 0%, #6a1b9a 100%); + transition: width 0.3s ease; + border-radius: 6px; + } + + .next-achievement-footer { + text-align: center; + } + + .needed-text { + font-size: 0.85rem; + color: var(--text-secondary); + } + + /* All Complete */ + .all-achievements-complete { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 1.5rem; + background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%); + border-radius: var(--border-radius); + color: #5d4037; + font-weight: 600; + font-size: 1.1rem; + } + + .complete-icon { + font-size: 1.5rem; + } + /* QSO Count Link */ .qso-count-link { cursor: pointer;
Define achievement levels (milestones) for this award. These are optional and represent additional goals beyond the base target.
No achievements defined yet.