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