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:
+46
-13
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user