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>
This commit is contained in:
@@ -1607,6 +1607,7 @@ const app = new Elysia()
|
||||
category: t.String(),
|
||||
rules: t.Any(),
|
||||
modeGroups: t.Optional(t.Any()),
|
||||
achievements: t.Optional(t.Any()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user