From 9bfb44925bd6601427eb0da25c09c7462ced46cf Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Thu, 6 Aug 2026 01:36:17 +0200 Subject: [PATCH] fix(cluster): refresh spot statuses by merge, not by clearing the cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setSpotStatus({}) blanked EVERY spot's status, so logging one contact wiped all the other NEW/NEW-BAND badges and they popped back a moment later. Instead, re-fetch the shown spots and OVERWRITE each status in place (merge) — the just- worked spot updates while the rest stay put, no flicker. Same visibility gate and 2 s debounce; backend cost is one status batch (one logbook scan) as before. --- frontend/src/App.tsx | 59 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d4991b3..7f5385e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1500,34 +1500,67 @@ export default function App() { // still need resolving without re-subscribing the cluster:spot listener. const spotStatusRef = useRef(spotStatus); useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]); - // After a QSO is logged, the call/entity/POTA/prefix/slot just worked is no - // longer "new" — but each spot's status is cached and never expires, so the - // cluster kept showing the old NEW badge until a restart. Dropping the cache - // re-evaluates every visible spot against the fresh logbook (the poll refetches - // the now-unknown statuses). Kept CHEAP for old machines / big logbooks: - // • only when the cluster is actually on screen — otherwise it costs nothing; - // • a log made while it's hidden just marks it dirty and refetches ONCE the - // next time you open the cluster; - // • debounced, so a fast run coalesces instead of re-scanning per QSO. + // Mirror of spots so the log-triggered refresh reads the current list without + // a stale closure. + const spotsRef = useRef(spots); + useEffect(() => { spotsRef.current = spots; }, [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 + // value lands, instead of blanking the whole grid and letting the badges pop + // back a moment later. The backend cost is one status batch regardless of spot + // 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 }[] = []; + const seen = new Set(); + for (const s of cur) { + const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); + if (seen.has(k)) continue; + 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 ?? '' }); + } + if (!queries.length) return; + try { + const res = await ClusterSpotStatuses(queries 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, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx, + }; + } + return next; + }); + } catch { /* leave the old statuses in place on failure */ } + }, []); + // After a QSO is logged, refresh the shown spots so a call/entity/POTA/prefix/ + // slot just worked drops its NEW badge — WITHOUT blanking the others (see the + // merge above). Kept cheap: only while the cluster is on screen (a log while + // hidden marks it dirty and refreshes ONCE on next open), and debounced so a + // fast run coalesces instead of re-scanning per QSO. const clusterVisibleRef = useRef(false); const spotsDirtyRef = useRef(false); useEffect(() => { const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster' || activeTab === 'cluster'; if (vis && !clusterVisibleRef.current && spotsDirtyRef.current) { spotsDirtyRef.current = false; - setSpotStatus({}); + void refreshSpotStatuses(); } clusterVisibleRef.current = vis; - }, [mainPaneLeft, mainPaneRight, activeTab]); + }, [mainPaneLeft, mainPaneRight, activeTab, refreshSpotStatuses]); useEffect(() => { let t: number | undefined; const off = EventsOn('qso:logged', () => { if (!clusterVisibleRef.current) { spotsDirtyRef.current = true; return; } if (t) window.clearTimeout(t); - t = window.setTimeout(() => setSpotStatus({}), 2000); + t = window.setTimeout(() => void refreshSpotStatuses(), 2000); }); return () => { off(); if (t) window.clearTimeout(t); }; - }, []); + }, [refreshSpotStatuses]); // 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.