fix(cluster): refresh spot statuses by merge, not by clearing the cache

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.
This commit is contained in:
2026-08-06 01:36:17 +02:00
parent fb1af9b6a2
commit 9bfb44925b
+46 -13
View File
@@ -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<string>();
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.