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.
This commit is contained in:
2026-08-18 07:03:09 +02:00
parent fba7e79a1c
commit f833ff6d04
+25 -2
View File
@@ -1700,6 +1700,10 @@ export default function App() {
// a stale closure. // a stale closure.
const spotsRef = useRef(spots); const spotsRef = useRef(spots);
useEffect(() => { spotsRef.current = spots; }, [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<DecodeRow[]>([]);
// Bound the status cache. Keyed per call|band|mode, it otherwise kept an entry // 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 // 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 // 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); const keys = Object.keys(prev);
if (keys.length <= SPOTS_CAP * 2) return 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))); 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 = {}; const pruned: typeof prev = {};
for (const k of keys) if (live.has(k)) pruned[k] = prev[k]; for (const k of keys) if (live.has(k)) pruned[k] = prev[k];
return pruned; return pruned;
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spots]); }, [spots]);
// Re-fetch the status of every SHOWN spot and OVERWRITE the cache (merge, never // 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 // 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. // count (it scans the logbook once), so this is as cheap as the poll already is.
const refreshSpotStatuses = useCallback(async () => { const refreshSpotStatuses = useCallback(async () => {
const cur = spotsRef.current; const cur = spotsRef.current;
if (!cur.length) return;
const queries: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = []; const queries: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const s of cur) { for (const s of cur) {
@@ -1732,6 +1741,19 @@ export default function App() {
seen.add(k); 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 ?? '' }); 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; if (!queries.length) return;
try { try {
const res = await ClusterSpotStatuses(queries as any); const res = await ClusterSpotStatuses(queries as any);
@@ -1764,7 +1786,7 @@ export default function App() {
const spotsDirtyRef = useRef(false); const spotsDirtyRef = useRef(false);
useEffect(() => { useEffect(() => {
const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster' const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster'
|| activeTab === 'cluster' || activeTab === 'bandmap' || showBandMap; || activeTab === 'cluster' || activeTab === 'bandmap' || activeTab === 'decodes' || showBandMap;
if (vis && !spotsVisibleRef.current && spotsDirtyRef.current) { if (vis && !spotsVisibleRef.current && spotsDirtyRef.current) {
spotsDirtyRef.current = false; spotsDirtyRef.current = false;
void refreshSpotStatuses(); void refreshSpotStatuses();
@@ -2045,6 +2067,7 @@ export default function App() {
// The LIVE transmit state, replaced on every Status — what is going out now // The LIVE transmit state, replaced on every Status — what is going out now
// and to whom, which the period history cannot answer between overs. // and to whom, which the period history cannot answer between overs.
const [txState, setTxState] = useState<TxMsgRow | null>(null); const [txState, setTxState] = useState<TxMsgRow | null>(null);
useEffect(() => { decodesRef.current = decodes; }, [decodes]);
// Staged like the cluster's, so a period arriving as one burst of fifty // 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. // packets costs one status lookup and one render, not fifty of each.
const pendingDecodesRef = useRef<DecodeRow[]>([]); const pendingDecodesRef = useRef<DecodeRow[]>([]);