feat: never drop cluster spots + theme-aware spot badges + FR settings

Backend: replace the bounded cluster-event channel (which dropped spots
under RBN bursts once enrichment/UI fell behind) with an unbounded
clusterQueue (slice + sync.Cond). The socket-read goroutine still never
blocks, but a burst now grows the backlog and drains instead of losing
spots. Head compaction keeps a sustained burst from growing the backing
array without bound.

ClusterGrid: drop all hard-coded light-theme hex colours (Time, Country,
Spotter, Comment, POTA, band/mode fills) for semantic CSS-var tokens so
they read correctly on every theme. Replace the heavy full-cell band/mode
fills with small rounded pills (cellChip). Only NEW MODE pills the mode
cell — NEW SLOT (band and mode each worked, just not together) no longer
highlights the mode cell, which wrongly implied the mode was new.

App: stage incoming spots ~50ms and resolve their slot status before
inserting, so a row paints with its badge already on instead of flashing
plain text then flipping to a pill. Self-spot toast still fires per spot.

i18n: translate the General settings that were still English — telemetry
and live-status toggles, the Main view pane pickers and their options.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-18 18:20:06 +02:00
co-authored by Claude Opus 4.8
parent 04eaa91bd8
commit 3ed9f29d9a
5 changed files with 240 additions and 66 deletions
+78 -3
View File
@@ -1064,6 +1064,15 @@ export default function App() {
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// different slots don't share the same colour.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus);
useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]);
// Incoming spots are staged here for a few ms, their status resolved, then
// committed to `spots` together — so a row paints with its NEW BAND/MODE badge
// already on, instead of flashing plain text then flipping to the pill.
const pendingSpotsRef = useRef<ClusterSpot[]>([]);
const pendingSpotTimer = useRef<number | undefined>(undefined);
// === Modals ===
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
@@ -1740,13 +1749,76 @@ export default function App() {
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
});
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
// Commit the staged spots: resolve the status for any slot we don't know yet
// FIRST, then insert the rows — so they appear with the right badge already
// painted instead of flashing plain text then flipping to a pill.
const flushPendingSpots = async () => {
pendingSpotTimer.current = undefined;
const batch = pendingSpotsRef.current;
pendingSpotsRef.current = [];
if (batch.length === 0) return;
// Resolve unknown statuses before the rows go in.
try {
const known = spotStatusRef.current;
const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
const seen = new Set<string>();
for (const s of batch) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k) || known[k]) continue;
seen.add(k);
unknown.push({
call: s.dx_call, band: s.band ?? '',
mode: inferSpotMode(s.comment ?? '', s.freq_hz),
pota_ref: (s as any).pota_ref ?? '',
});
}
if (unknown.length > 0) {
const res = await ClusterSpotStatuses(unknown as any);
setSpotStatus((prev) => {
const next = { ...prev };
for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = {
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
new_county: !!(r as any).new_county,
new_pota: !!(r as any).new_pota,
};
}
return next;
});
}
} catch {}
// Now insert the staged rows. Same de-dupe as before: a station re-spotted
// (RBN skimmers on a CQ, several nodes relaying) shows as ONE live row that
// the freshest spot REPLACES and floats to the top. Historical (SH/DX
// replay) rows are left alone.
setSpots((arr) => {
const next = [sp, ...arr];
const key = (x: ClusterSpot) => `${(x.dx_call ?? '').toUpperCase()}|${(x.band ?? '').toLowerCase()}`;
const hist = (x: ClusterSpot) => !!(x as any).historical;
let next = arr;
for (const sp of batch) {
const k = key(sp);
const filtered = hist(sp) ? next : next.filter((x) => hist(x) || key(x) !== k);
next = [sp, ...filtered];
}
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
});
};
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
// Stage the spot; a short timer resolves its status then commits it.
pendingSpotsRef.current.push(sp);
// 50 ms is enough to coalesce an RBN burst into one status lookup (the
// worked-index is in memory, so resolving is near-instant) while staying
// imperceptible.
if (pendingSpotTimer.current === undefined) {
pendingSpotTimer.current = window.setTimeout(flushPendingSpots, 50);
}
// Self-spot: someone spotted OUR callsign — show it in the shared header
// toast (same place as the other notifications), not a separate banner.
// Fired immediately (not staged) so the notification never lags the spot.
const mine = myCallRef.current;
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
const by = cleanSpotter(sp.spotter ?? '') || '?';
@@ -1754,7 +1826,10 @@ export default function App() {
showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`);
}
});
return () => { unsubState?.(); unsubSpot?.(); };
return () => {
unsubState?.(); unsubSpot?.();
if (pendingSpotTimer.current !== undefined) window.clearTimeout(pendingSpotTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);