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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user