From f833ff6d042c47fcb585afc3a27e146806daec2e Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 18 Aug 2026 07:03:09 +0200 Subject: [PATCH] fix(decodes): a station just worked drops its NEW badge EY35S went on showing NEW SLOT for the rest of the half hour it stayed in the list, five minutes after the QSO was in the log. A decode's verdict is resolved once, when it arrives, and then read from the shared status cache for ever. The cache IS refreshed after a QSO is logged - but refreshSpotStatuses only ever re-queried the cluster spots, so no decoded callsign was in the batch. And the decodes tab was not in the "is anything showing a status?" test, so logging a QSO while looking at this very panel only marked the cache dirty and waited for the cluster or the band map to be opened. Both fixed: decoded stations join the refresh batch, deduplicated by call+band+mode so half an hour of a busy band is a few hundred queries the backend answers with one pass of the log, and the tab counts as visible. Second, worse bug found on the way. The cache is pruned back to the live spots once it outgrows twice the spot cap - keeping only keys present in the cluster list. Decodes share that cache and are exactly what pushes it past the cap, so on a busy band the panel would have wiped every one of its own badges the moment it filled up. Decoded stations now count as live. --- frontend/src/App.tsx | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f031997..6a2f0c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1700,6 +1700,10 @@ export default function App() { // a stale closure. const spotsRef = useRef(spots); useEffect(() => { spotsRef.current = spots; }, [spots]); + // The decoded stations, for the same reason: the status refresh and the cache + // prune below both need them, and neither may re-subscribe every time a decode + // lands. Filled by an effect next to the `decodes` state further down. + const decodesRef = useRef([]); // Bound the status cache. Keyed per call|band|mode, it otherwise kept an entry // for every station ever seen — under an RBN firehose (thousands of unique // calls/hour) that grew without limit to gigabytes. Prune it back to the live @@ -1711,10 +1715,16 @@ export default function App() { const keys = Object.keys(prev); if (keys.length <= SPOTS_CAP * 2) return prev; const live = new Set(spots.map((x) => spotStatusKey(x.dx_call, x.band ?? '', x.comment ?? '', x.freq_hz))); + // Decoded stations count as live too. They share this cache, and pruning + // to the cluster spots alone would evict every one of them — on a busy + // band the decodes are what push the cache past the cap in the first + // place, so the panel would blank its own badges the moment it filled up. + for (const d of decodesRef.current) live.add(`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`); const pruned: typeof prev = {}; for (const k of keys) if (live.has(k)) pruned[k] = prev[k]; return pruned; }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [spots]); // Re-fetch the status of every SHOWN spot and OVERWRITE the cache (merge, never // clear). Overwriting keeps the other NEW badges on screen until their fresh @@ -1723,7 +1733,6 @@ export default function App() { // count (it scans the logbook once), so this is as cheap as the poll already is. const refreshSpotStatuses = useCallback(async () => { const cur = spotsRef.current; - if (!cur.length) return; const queries: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = []; const seen = new Set(); for (const s of cur) { @@ -1732,6 +1741,19 @@ export default function App() { seen.add(k); queries.push({ call: s.dx_call, band: s.band ?? '', mode: inferSpotMode(s.comment ?? '', s.freq_hz), pota_ref: (s as any).pota_ref ?? '', spotter: s.spotter ?? '' }); } + // The decoded stations as well. Their verdict is resolved once when the + // decode arrives and then cached for ever, so a station worked five minutes + // ago went on wearing its NEW SLOT badge for the rest of the half hour it + // stays in the list — reported on an EY35S already in the log. Deduplicated + // by call+band+mode, so half an hour of a busy band is a few hundred + // queries, and the backend answers a whole batch with one pass of the log. + for (const d of decodesRef.current) { + const mode = (d.mode ?? '').toUpperCase(); + const k = `${d.call}|${d.band ?? ''}|${mode}`; + if (seen.has(k)) continue; + seen.add(k); + queries.push({ call: d.call, band: d.band ?? '', mode, pota_ref: '', spotter: '' }); + } if (!queries.length) return; try { const res = await ClusterSpotStatuses(queries as any); @@ -1764,7 +1786,7 @@ export default function App() { const spotsDirtyRef = useRef(false); useEffect(() => { const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster' - || activeTab === 'cluster' || activeTab === 'bandmap' || showBandMap; + || activeTab === 'cluster' || activeTab === 'bandmap' || activeTab === 'decodes' || showBandMap; if (vis && !spotsVisibleRef.current && spotsDirtyRef.current) { spotsDirtyRef.current = false; void refreshSpotStatuses(); @@ -2045,6 +2067,7 @@ export default function App() { // The LIVE transmit state, replaced on every Status — what is going out now // and to whom, which the period history cannot answer between overs. const [txState, setTxState] = useState(null); + useEffect(() => { decodesRef.current = decodes; }, [decodes]); // Staged like the cluster's, so a period arriving as one burst of fifty // packets costs one status lookup and one render, not fifty of each. const pendingDecodesRef = useRef([]);