Compare commits

...

2 Commits

Author SHA1 Message Date
a5f0e3b96f fix: include Alaska and Hawaii DXCC entities in WAS award
WAS award was only counting states in DXCC entity 291 (United States),
which excluded Alaska (DXCC 6) and Hawaii (DXCC 110). Updated filter to
use "in" operator with all three relevant DXCC entity IDs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 12:47:09 +01:00
b09e2b3ea2 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 <noreply@anthropic.com>
2026-01-23 12:42:32 +01:00
9 changed files with 846 additions and 22 deletions

View File

@@ -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"
]
}
}

View File

@@ -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
}
]
}

View File

@@ -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",
@@ -14,8 +17,8 @@
"filters": [
{
"field": "entityId",
"operator": "eq",
"value": 291
"operator": "in",
"value": [291, 6, 110]
}
]
}

View File

@@ -1607,6 +1607,7 @@ const app = new Elysia()
category: t.String(),
rules: t.Any(),
modeGroups: t.Optional(t.Any()),
achievements: t.Optional(t.Any()),
}),
}
)

View File

@@ -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') {

View File

@@ -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;
}

View File

@@ -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
</button>
<button
class="tab {activeTab === 'achievements' ? 'active' : ''}"
on:click={() => activeTab = 'achievements'}
>
Achievements
</button>
<button
class="tab {activeTab === 'rules' ? 'active' : ''}"
on:click={() => activeTab = 'rules'}
@@ -611,6 +708,60 @@
</div>
{/if}
{#if activeTab === 'achievements'}
<div class="form-section">
<h2>Achievements</h2>
<p class="help-text">Define achievement levels (milestones) for this award. These are optional and represent additional goals beyond the base target.</p>
{#if formData.achievements && formData.achievements.length > 0}
<div class="achievements-list">
<h3>Defined Achievements</h3>
{#each formData.achievements as achievement, i (i)}
<div class="achievement-item">
<div class="achievement-info">
<strong>{achievement.name}</strong>
<span class="achievement-threshold">{achievement.threshold} pts/entities</span>
</div>
<button class="btn-remove" on:click={() => removeAchievement(i)}>Remove</button>
</div>
{/each}
</div>
{:else}
<p class="empty-state">No achievements defined yet.</p>
{/if}
<div class="add-achievement">
<h3>Add Achievement</h3>
<div class="form-group">
<label for="achievement-name">Achievement Name</label>
<input
id="achievement-name"
type="text"
bind:value={newAchievementName}
placeholder="e.g., Silver, Gold, Platinum"
/>
</div>
<div class="form-group">
<label for="achievement-threshold">Threshold (entities/points required) *</label>
<input
id="achievement-threshold"
type="number"
min="1"
bind:value={newAchievementThreshold}
placeholder="e.g., 100, 200, 500"
/>
<small>The number of confirmed entities or points required to earn this achievement</small>
</div>
<button class="btn btn-secondary" on:click={addAchievement}>Add Achievement</button>
</div>
<div class="info-box">
<strong>Achievement Progress:</strong> 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.
</div>
</div>
{/if}
{#if activeTab === 'rules'}
<div class="form-section">
<h2>Award Rules</h2>
@@ -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));

View File

@@ -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
</button>
<button
class="tab {activeTab === 'achievements' ? 'active' : ''}"
on:click={() => activeTab = 'achievements'}
>
Achievements
</button>
<button
class="tab {activeTab === 'rules' ? 'active' : ''}"
on:click={() => activeTab = 'rules'}
@@ -636,6 +733,60 @@
</div>
{/if}
{#if activeTab === 'achievements'}
<div class="form-section">
<h2>Achievements</h2>
<p class="help-text">Define achievement levels (milestones) for this award. These are optional and represent additional goals beyond the base target.</p>
{#if formData.achievements && formData.achievements.length > 0}
<div class="achievements-list">
<h3>Defined Achievements</h3>
{#each formData.achievements as achievement, i (i)}
<div class="achievement-item">
<div class="achievement-info">
<strong>{achievement.name}</strong>
<span class="achievement-threshold">{achievement.threshold} pts/entities</span>
</div>
<button class="btn-remove" on:click={() => removeAchievement(i)}>Remove</button>
</div>
{/each}
</div>
{:else}
<p class="empty-state">No achievements defined yet.</p>
{/if}
<div class="add-achievement">
<h3>Add Achievement</h3>
<div class="form-group">
<label for="achievement-name">Achievement Name</label>
<input
id="achievement-name"
type="text"
bind:value={newAchievementName}
placeholder="e.g., Silver, Gold, Platinum"
/>
</div>
<div class="form-group">
<label for="achievement-threshold">Threshold (entities/points required) *</label>
<input
id="achievement-threshold"
type="number"
min="1"
bind:value={newAchievementThreshold}
placeholder="e.g., 100, 200, 500"
/>
<small>The number of confirmed entities or points required to earn this achievement</small>
</div>
<button class="btn btn-secondary" on:click={addAchievement}>Add Achievement</button>
</div>
<div class="info-box">
<strong>Achievement Progress:</strong> 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.
</div>
</div>
{/if}
{#if activeTab === 'rules'}
<div class="form-section">
<h2>Award Rules</h2>
@@ -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));

View File

@@ -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,
};
}
</script>
<div class="container">
@@ -602,6 +673,52 @@
{/if}
</div>
<!-- Achievements Section -->
{#if award?.achievements && award.achievements.length > 0 && achievementProgress}
<div class="achievements-section">
<h2>Achievements</h2>
<!-- Earned Achievements -->
{#if achievementProgress.earned.length > 0}
<div class="earned-achievements">
{#each achievementProgress.earned as achievement}
<div class="achievement-badge earned">
<span class="achievement-icon"></span>
<span class="achievement-name">{achievement.name}</span>
<span class="achievement-threshold">{achievement.threshold}</span>
</div>
{/each}
</div>
{/if}
<!-- Next Achievement Progress -->
{#if achievementProgress.nextLevel}
<div class="next-achievement">
<div class="next-achievement-header">
<span class="next-achievement-title">Next: {achievementProgress.nextLevel.name}</span>
<span class="next-achievement-count">
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.threshold}
</span>
</div>
<div class="achievement-progress-bar">
<div
class="achievement-progress-fill"
style="width: {achievementProgress.progressPercent}%"
></div>
</div>
<div class="next-achievement-footer">
<span class="needed-text">{achievementProgress.progressNeeded} more to {achievementProgress.nextLevel.name}</span>
</div>
</div>
{:else}
<div class="all-achievements-complete">
<span class="complete-icon"></span>
<span>All achievements complete!</span>
</div>
{/if}
</div>
{/if}
<div class="mode-filter">
<label for="mode-select">Filter by mode:</label>
<select id="mode-select" bind:value={selectedMode}>
@@ -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;