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([]);