fix: resolve stations editor reactivity issue in award admin

Use Svelte stores with value/on:input instead of bind:value for
stations array to properly track nested property changes. Add invisible
reactivity triggers to force Svelte to track station callsign/points properties.

Also update award sorting to handle numeric prefixes in numerical order
(44 before 73) and update RS-44 satellite award category.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-23 09:27:08 +01:00
parent bd89ea0855
commit 36453c8922
4 changed files with 122 additions and 29 deletions

View File

@@ -1,9 +1,9 @@
{
"id": "sat-rs44",
"name": "RS-44 Satellite",
"name": "44 on RS-44",
"description": "Work 44 QSOs on satellite RS-44",
"caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts toward the total.",
"category": "custom",
"category": "satellite",
"rules": {
"type": "counter",
"target": 44,
@@ -19,5 +19,6 @@
}
]
}
}
},
"modeGroups": {}
}

View File

@@ -30,8 +30,7 @@ function loadAwardDefinitions() {
try {
// Auto-discover all JSON files in the award-definitions directory
const files = readdirSync(AWARD_DEFINITIONS_DIR)
.filter(f => f.endsWith('.json'))
.sort();
.filter(f => f.endsWith('.json'));
for (const file of files) {
try {
@@ -47,6 +46,32 @@ function loadAwardDefinitions() {
logger.error('Error loading award definitions', { error: error.message });
}
// Sort by award name with numeric prefixes in numerical order
definitions.sort((a, b) => {
const nameA = a.name || '';
const nameB = b.name || '';
// Extract leading numbers if present
const matchA = nameA.match(/^(\d+)/);
const matchB = nameB.match(/^(\d+)/);
// If both start with numbers, compare numerically first
if (matchA && matchB) {
const numA = parseInt(matchA[1], 10);
const numB = parseInt(matchB[1], 10);
if (numA !== numB) {
return numA - numB;
}
// If numbers are equal, fall through to alphabetical
}
// If one starts with a number, it comes first
else if (matchA) return -1;
else if (matchB) return 1;
// Otherwise, alphabetical comparison (case-insensitive)
return nameA.toLowerCase().localeCompare(nameB.toLowerCase());
});
// Cache the definitions for future calls
cachedAwardDefinitions = definitions;

View File

@@ -1,10 +1,14 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { writable, derived } from 'svelte/store';
import { awardsAdminAPI } from '$lib/api.js';
import FilterBuilder from '../components/FilterBuilder.svelte';
import TestAwardModal from '../components/TestAwardModal.svelte';
// Create a store for stations to ensure reactivity
const stationsStore = writable([]);
let loading = true;
let saving = false;
let error = null;
@@ -27,6 +31,11 @@
}
};
// Update stations store when formData changes
$: if (formData.rules?.stations) {
stationsStore.set(formData.rules.stations.map(s => ({...s})));
}
// Available bands and modes
const ALL_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'];
const ALL_MODES = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'];
@@ -166,20 +175,21 @@
errors.push('Target must be a positive number');
}
if (!formData.rules.stations || formData.rules.stations.length === 0) {
const stations = $stationsStore;
if (!stations || stations.length === 0) {
errors.push('Points rule requires at least one station');
}
// Check for duplicate callsigns
if (formData.rules.stations) {
const callsigns = formData.rules.stations.map(s => s.callsign?.toUpperCase());
if (stations) {
const callsigns = stations.map(s => s.callsign?.toUpperCase());
const duplicates = callsigns.filter((c, i) => callsigns.indexOf(c) !== i);
if (duplicates.length > 0) {
errors.push(`Duplicate station callsigns: ${[...new Set(duplicates)].join(', ')}`);
}
// Check for zero or negative points
formData.rules.stations.forEach((station, i) => {
stations.forEach((station, i) => {
if (!station.callsign || !station.callsign.trim()) {
errors.push(`Station ${i + 1} is missing callsign`);
}
@@ -361,6 +371,10 @@
}
async function handleSave() {
// Sync stations from store to formData before saving
const stations = $stationsStore;
formData = { ...formData, rules: { ...formData.rules, stations } };
if (!performSafetyValidation()) {
return;
}
@@ -709,23 +723,43 @@
<div class="form-group">
<label>Stations *</label>
<div class="stations-editor">
{#if formData.rules.stations && formData.rules.stations.length > 0}
{#each formData.rules.stations as station, i}
{#if $stationsStore && $stationsStore.length > 0}
{#each $stationsStore as station, i (i)}
<div class="station-row">
<!-- Invisible reactivity trigger - forces Svelte to track station properties -->
<span style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;">
{station.callsign}|{station.points}
</span>
<input
type="text"
bind:value={station.callsign}
value={station.callsign}
on:input={(e) => {
stationsStore.update(s => {
const newS = [...s];
newS[i] = { ...newS[i], callsign: e.target.value };
return newS;
});
}}
placeholder="Callsign"
/>
<input
type="number"
min="1"
bind:value={station.points}
value={station.points}
on:input={(e) => {
stationsStore.update(s => {
const newS = [...s];
newS[i] = { ...newS[i], points: parseInt(e.target.value) || 0 };
return newS;
});
}}
placeholder="Points"
/>
<button
class="btn-remove"
on:click={() => formData.rules.stations.splice(i, 1)}
on:click={() => {
stationsStore.update(s => s.filter((_, idx) => idx !== i));
}}
>
Remove
</button>
@@ -737,8 +771,7 @@
<button
class="btn btn-secondary"
on:click={() => {
if (!formData.rules.stations) formData.rules.stations = [];
formData.rules.stations.push({ callsign: '', points: 1 });
stationsStore.update(s => [...s, { callsign: '', points: 1 }]);
}}
>
Add Station

View File

@@ -1,10 +1,14 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
import { awardsAdminAPI } from '$lib/api.js';
import FilterBuilder from '../components/FilterBuilder.svelte';
import TestAwardModal from '../components/TestAwardModal.svelte';
// Create a store for stations to ensure reactivity
const stationsStore = writable([]);
let loading = false;
let saving = false;
let error = null;
@@ -28,6 +32,11 @@
}
};
// Update stations store when formData changes
$: if (formData.rules?.stations) {
stationsStore.set(formData.rules.stations.map(s => ({...s})));
}
// Available bands and modes
const ALL_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'];
const ALL_MODES = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'];
@@ -173,20 +182,21 @@
errors.push('Target must be a positive number');
}
if (!formData.rules.stations || formData.rules.stations.length === 0) {
const stations = $stationsStore;
if (!stations || stations.length === 0) {
errors.push('Points rule requires at least one station');
}
// Check for duplicate callsigns
if (formData.rules.stations) {
const callsigns = formData.rules.stations.map(s => s.callsign?.toUpperCase());
if (stations) {
const callsigns = stations.map(s => s.callsign?.toUpperCase());
const duplicates = callsigns.filter((c, i) => callsigns.indexOf(c) !== i);
if (duplicates.length > 0) {
errors.push(`Duplicate station callsigns: ${[...new Set(duplicates)].join(', ')}`);
}
// Check for zero or negative points
formData.rules.stations.forEach((station, i) => {
stations.forEach((station, i) => {
if (!station.callsign || !station.callsign.trim()) {
errors.push(`Station ${i + 1} is missing callsign`);
}
@@ -368,6 +378,10 @@
}
async function handleSave() {
// Sync stations from store to formData before saving
const stations = $stationsStore;
formData = { ...formData, rules: { ...formData.rules, stations } };
if (!performSafetyValidation()) {
return;
}
@@ -734,23 +748,44 @@
<div class="form-group">
<label>Stations *</label>
<div class="stations-editor">
{#if formData.rules.stations && formData.rules.stations.length > 0}
{#each formData.rules.stations as station, i}
{#if $stationsStore && $stationsStore.length > 0}
{#each $stationsStore as station, i (i)}
<div class="station-row">
<!-- Invisible reactivity trigger - forces Svelte to track station properties -->
<span style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;">
{station.callsign}|{station.points}
</span>
<input
type="text"
bind:value={station.callsign}
value={station.callsign}
on:input={(e) => {
stationsStore.update(s => {
const newS = [...s];
newS[i] = { ...newS[i], callsign: e.target.value };
return newS;
});
}}
placeholder="Callsign"
/>
<input
type="number"
min="1"
bind:value={station.points}
value={station.points}
on:input={(e) => {
stationsStore.update(s => {
const newS = [...s];
newS[i] = { ...newS[i], points: parseInt(e.target.value) || 0 };
return newS;
});
}}
placeholder="Points"
/>
<button
class="btn-remove"
on:click={() => formData.rules.stations.splice(i, 1)}
on:click={() => {
stationsStore.update(s => s.filter((_, idx) => idx !== i));
}}
>
Remove
</button>
@@ -762,8 +797,7 @@
<button
class="btn btn-secondary"
on:click={() => {
if (!formData.rules.stations) formData.rules.stations = [];
formData.rules.stations.push({ callsign: '', points: 1 });
stationsStore.update(s => [...s, { callsign: '', points: 1 }]);
}}
>
Add Station