Compare commits
2 Commits
239963ed89
...
a5f0e3b96f
| Author | SHA1 | Date | |
|---|---|---|---|
|
a5f0e3b96f
|
|||
|
b09e2b3ea2
|
@@ -4,16 +4,37 @@
|
|||||||
"description": "Deutschland Diplom - Confirm 100 unique DOKs on different bands/modes",
|
"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.",
|
"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",
|
"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": {
|
"rules": {
|
||||||
"type": "dok",
|
"type": "dok",
|
||||||
"target": 100,
|
"target": 100,
|
||||||
"confirmationType": "dcl",
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,17 +4,71 @@
|
|||||||
"description": "Confirm 100 DXCC entities on HF bands",
|
"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.",
|
"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",
|
"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": {
|
"rules": {
|
||||||
"type": "entity",
|
"type": "entity",
|
||||||
"entityType": "dxcc",
|
"entityType": "dxcc",
|
||||||
"target": 100,
|
"target": 100,
|
||||||
"displayField": "entity",
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
"description": "Confirm all 50 US states",
|
"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.",
|
"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",
|
"category": "was",
|
||||||
|
"achievements": [
|
||||||
|
{ "name": "WAS Award", "threshold": 50 }
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "entity",
|
"type": "entity",
|
||||||
"entityType": "state",
|
"entityType": "state",
|
||||||
@@ -14,8 +17,8 @@
|
|||||||
"filters": [
|
"filters": [
|
||||||
{
|
{
|
||||||
"field": "entityId",
|
"field": "entityId",
|
||||||
"operator": "eq",
|
"operator": "in",
|
||||||
"value": 291
|
"value": [291, 6, 110]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1607,6 +1607,7 @@ const app = new Elysia()
|
|||||||
category: t.String(),
|
category: t.String(),
|
||||||
rules: t.Any(),
|
rules: t.Any(),
|
||||||
modeGroups: t.Optional(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');
|
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
|
// Validate modeGroups if present
|
||||||
if (definition.modeGroups) {
|
if (definition.modeGroups) {
|
||||||
if (typeof definition.modeGroups !== 'object') {
|
if (typeof definition.modeGroups !== 'object') {
|
||||||
|
|||||||
@@ -87,6 +87,61 @@ export function clearAwardCache() {
|
|||||||
logger.info('Award cache cleared');
|
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
|
* Get all available awards
|
||||||
*/
|
*/
|
||||||
@@ -101,6 +156,7 @@ export async function getAllAwards() {
|
|||||||
category: def.category,
|
category: def.category,
|
||||||
rules: def.rules,
|
rules: def.rules,
|
||||||
modeGroups: def.modeGroups || null,
|
modeGroups: def.modeGroups || null,
|
||||||
|
achievements: def.achievements || null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +181,7 @@ export function getAwardById(awardId) {
|
|||||||
category: award.category,
|
category: award.category,
|
||||||
rules: award.rules,
|
rules: award.rules,
|
||||||
modeGroups: award.modeGroups || null,
|
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,
|
worked: workedEntities.size,
|
||||||
confirmed: confirmedEntities.size,
|
confirmed: confirmedEntities.size,
|
||||||
target: rules.target || 0,
|
target: rules.target || 0,
|
||||||
@@ -242,6 +299,13 @@ export async function calculateAwardProgress(userId, award, options = {}) {
|
|||||||
workedEntities: Array.from(workedEntities),
|
workedEntities: Array.from(workedEntities),
|
||||||
confirmedEntities: Array.from(confirmedEntities),
|
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;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +692,12 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
result.stationDetails = stationDetails;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
let showTestModal = false;
|
let showTestModal = false;
|
||||||
let activeTab = 'basic'; // basic, modeGroups, rules
|
let activeTab = 'basic'; // basic, modeGroups, achievements, rules
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
let formData = {
|
let formData = {
|
||||||
@@ -26,11 +26,16 @@
|
|||||||
caption: '',
|
caption: '',
|
||||||
category: '',
|
category: '',
|
||||||
modeGroups: {},
|
modeGroups: {},
|
||||||
|
achievements: [],
|
||||||
rules: {
|
rules: {
|
||||||
type: 'entity',
|
type: 'entity',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Achievements editor state
|
||||||
|
let newAchievementName = '';
|
||||||
|
let newAchievementThreshold = 100;
|
||||||
|
|
||||||
// Update stations store when formData changes
|
// Update stations store when formData changes
|
||||||
$: if (formData.rules?.stations) {
|
$: if (formData.rules?.stations) {
|
||||||
stationsStore.set(formData.rules.stations.map(s => ({...s})));
|
stationsStore.set(formData.rules.stations.map(s => ({...s})));
|
||||||
@@ -66,6 +71,7 @@
|
|||||||
caption: data.award.caption || '',
|
caption: data.award.caption || '',
|
||||||
category: data.award.category || '',
|
category: data.award.category || '',
|
||||||
modeGroups: data.award.modeGroups || {},
|
modeGroups: data.award.modeGroups || {},
|
||||||
|
achievements: data.award.achievements || [],
|
||||||
rules: data.award.rules || { type: 'entity' },
|
rules: data.award.rules || { type: 'entity' },
|
||||||
};
|
};
|
||||||
awardId = id;
|
awardId = id;
|
||||||
@@ -120,6 +126,9 @@
|
|||||||
// Mode groups validation
|
// Mode groups validation
|
||||||
validateModeGroups(errors, warnings);
|
validateModeGroups(errors, warnings);
|
||||||
|
|
||||||
|
// Achievements validation
|
||||||
|
validateAchievements(errors, warnings);
|
||||||
|
|
||||||
// Cross-field validation
|
// Cross-field validation
|
||||||
performCrossFieldValidation(errors, warnings);
|
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) {
|
function performCrossFieldValidation(errors, warnings) {
|
||||||
// Check if filters contradict satellite_only
|
// Check if filters contradict satellite_only
|
||||||
if (formData.rules.satellite_only && formData.rules.filters) {
|
if (formData.rules.satellite_only && formData.rules.filters) {
|
||||||
@@ -434,6 +483,48 @@
|
|||||||
performSafetyValidation();
|
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() {
|
function testAward() {
|
||||||
showTestModal = true;
|
showTestModal = true;
|
||||||
}
|
}
|
||||||
@@ -492,6 +583,12 @@
|
|||||||
>
|
>
|
||||||
Mode Groups
|
Mode Groups
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab {activeTab === 'achievements' ? 'active' : ''}"
|
||||||
|
on:click={() => activeTab = 'achievements'}
|
||||||
|
>
|
||||||
|
Achievements
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="tab {activeTab === 'rules' ? 'active' : ''}"
|
class="tab {activeTab === 'rules' ? 'active' : ''}"
|
||||||
on:click={() => activeTab = 'rules'}
|
on:click={() => activeTab = 'rules'}
|
||||||
@@ -611,6 +708,60 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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'}
|
{#if activeTab === 'rules'}
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Award Rules</h2>
|
<h2>Award Rules</h2>
|
||||||
@@ -1088,6 +1239,58 @@
|
|||||||
color: var(--text-primary);
|
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 {
|
.mode-selector, .bands-selector {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
let showTestModal = false;
|
let showTestModal = false;
|
||||||
let activeTab = 'basic'; // basic, modeGroups, rules
|
let activeTab = 'basic'; // basic, modeGroups, achievements, rules
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
let formData = {
|
let formData = {
|
||||||
@@ -27,11 +27,16 @@
|
|||||||
caption: '',
|
caption: '',
|
||||||
category: '',
|
category: '',
|
||||||
modeGroups: {},
|
modeGroups: {},
|
||||||
|
achievements: [],
|
||||||
rules: {
|
rules: {
|
||||||
type: 'entity',
|
type: 'entity',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Achievements editor state
|
||||||
|
let newAchievementName = '';
|
||||||
|
let newAchievementThreshold = 100;
|
||||||
|
|
||||||
// Update stations store when formData changes
|
// Update stations store when formData changes
|
||||||
$: if (formData.rules?.stations) {
|
$: if (formData.rules?.stations) {
|
||||||
stationsStore.set(formData.rules.stations.map(s => ({...s})));
|
stationsStore.set(formData.rules.stations.map(s => ({...s})));
|
||||||
@@ -72,6 +77,7 @@
|
|||||||
caption: data.award.caption || '',
|
caption: data.award.caption || '',
|
||||||
category: data.award.category || '',
|
category: data.award.category || '',
|
||||||
modeGroups: data.award.modeGroups || {},
|
modeGroups: data.award.modeGroups || {},
|
||||||
|
achievements: data.award.achievements || [],
|
||||||
rules: data.award.rules || { type: 'entity' },
|
rules: data.award.rules || { type: 'entity' },
|
||||||
};
|
};
|
||||||
awardId = id;
|
awardId = id;
|
||||||
@@ -127,6 +133,9 @@
|
|||||||
// Mode groups validation
|
// Mode groups validation
|
||||||
validateModeGroups(errors, warnings);
|
validateModeGroups(errors, warnings);
|
||||||
|
|
||||||
|
// Achievements validation
|
||||||
|
validateAchievements(errors, warnings);
|
||||||
|
|
||||||
// Cross-field validation
|
// Cross-field validation
|
||||||
performCrossFieldValidation(errors, warnings);
|
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) {
|
function performCrossFieldValidation(errors, warnings) {
|
||||||
// Check if filters contradict satellite_only
|
// Check if filters contradict satellite_only
|
||||||
if (formData.rules.satellite_only && formData.rules.filters) {
|
if (formData.rules.satellite_only && formData.rules.filters) {
|
||||||
@@ -445,6 +494,48 @@
|
|||||||
performSafetyValidation();
|
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() {
|
function testAward() {
|
||||||
showTestModal = true;
|
showTestModal = true;
|
||||||
}
|
}
|
||||||
@@ -514,6 +605,12 @@
|
|||||||
>
|
>
|
||||||
Mode Groups
|
Mode Groups
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab {activeTab === 'achievements' ? 'active' : ''}"
|
||||||
|
on:click={() => activeTab = 'achievements'}
|
||||||
|
>
|
||||||
|
Achievements
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="tab {activeTab === 'rules' ? 'active' : ''}"
|
class="tab {activeTab === 'rules' ? 'active' : ''}"
|
||||||
on:click={() => activeTab = 'rules'}
|
on:click={() => activeTab = 'rules'}
|
||||||
@@ -636,6 +733,60 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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'}
|
{#if activeTab === 'rules'}
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Award Rules</h2>
|
<h2>Award Rules</h2>
|
||||||
@@ -1114,6 +1265,58 @@
|
|||||||
color: var(--text-primary);
|
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 {
|
.mode-selector, .bands-selector {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
|
|||||||
@@ -21,6 +21,12 @@
|
|||||||
let selectedSlotQSOs = [];
|
let selectedSlotQSOs = [];
|
||||||
let selectedSlotInfo = null; // { entityName, band, mode }
|
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
|
// Get available modes from entities
|
||||||
// Structure: Mixed Mode, Mode Groups (if any), Separator, Individual Modes
|
// Structure: Mixed Mode, Mode Groups (if any), Separator, Individual Modes
|
||||||
$: availableModes = (() => {
|
$: availableModes = (() => {
|
||||||
@@ -527,6 +533,71 @@
|
|||||||
if (status === '?') return { label: 'Unknown', class: 'unknown' };
|
if (status === '?') return { label: 'Unknown', class: 'unknown' };
|
||||||
return { label: 'No Data', class: 'no-data' };
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -602,6 +673,52 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<div class="mode-filter">
|
||||||
<label for="mode-select">Filter by mode:</label>
|
<label for="mode-select">Filter by mode:</label>
|
||||||
<select id="mode-select" bind:value={selectedMode}>
|
<select id="mode-select" bind:value={selectedMode}>
|
||||||
@@ -1432,6 +1549,130 @@
|
|||||||
background-color: var(--border-color-light);
|
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 */
|
||||||
.qso-count-link {
|
.qso-count-link {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
Reference in New Issue
Block a user