From e3175ef6a3fb35c721d03b4d6d804e9a55682949 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 29 Aug 2026 01:06:01 +0200 Subject: [PATCH] fix(watchlist): never draw a spot before its verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new spot rendered as Needed the instant it arrived and was withdrawn half a second later when the worked answer landed — the list twitched on every burst. Unsettled spots are now invisible everywhere (card lines, counters, the Active/Needed-only filters) until their verdict is in: nobody needs a spot 150 ms early, they need it settled. The answer map is merged rather than replaced, so a settled line can never fall back to unknown, and the query debounce drops to 150 ms since there is nothing on screen waiting for it. --- frontend/src/components/WatchlistTab.tsx | 27 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/WatchlistTab.tsx b/frontend/src/components/WatchlistTab.tsx index 801ba09..a941bed 100644 --- a/frontend/src/components/WatchlistTab.tsx +++ b/frontend/src/components/WatchlistTab.tsx @@ -138,16 +138,25 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P if (queries.length === 0) { setWorked({}); return; } try { const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? []; - const next: Record = {}; - keys.forEach((k, i) => { next[k] = !!res[i]; }); - setWorked(next); + // MERGED, not replaced: replacing made every already-answered key + // momentarily unknown on each refresh, which re-hid settled lines. + setWorked((prev) => { + const next = { ...prev }; + keys.forEach((k, i) => { next[k] = !!res[i]; }); + return next; + }); } catch { /* the badges just stay conservative */ } - }, 400) as unknown as number; + }, 150) as unknown as number; return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); }; }, [spotsFor, entries]); - const workedFor = (e: WLEntry, s: ClusterSpot): boolean => - worked[`${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`] ?? false; + const wkey = (e: WLEntry, s: ClusterSpot) => + `${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`; + const workedFor = (e: WLEntry, s: ClusterSpot): boolean => worked[wkey(e, s)] ?? false; + // A spot whose verdict has not come back yet is NOT drawn. Showing it as + // Needed and withdrawing it half a second later made the list twitch on + // every burst — and nobody needs a spot 400 ms early, they need it settled. + const settled = (e: WLEntry, s: ClusterSpot): boolean => wkey(e, s) in worked; const add = async () => { const c = addCall.trim().toUpperCase(); @@ -173,7 +182,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P if (family === 'normal' && e.isContest) return false; if (family === 'contest' && !e.isContest) return false; if (search && !e.callsign.includes(search.trim().toUpperCase())) return false; - const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches); + const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2)); if (activeOnly && list.length === 0) return false; if (neededOnly && !list.some((s2) => !workedFor(e, s2))) return false; return true; @@ -182,7 +191,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P const counters = useMemo(() => { let active = 0, needed = 0; for (const e of entries) { - const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches); + const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2)); if (list.length > 0) active++; if (list.some((s2) => !workedFor(e, s2))) needed++; } @@ -281,7 +290,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P {entries.length === 0 ? t('wl.empty') : t('wl.noneMatch')} ) : shown.map((e) => { - const all = (spotsFor.get(e.callsign) ?? []).filter(modeMatches); + const all = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2)); const needed = all.filter((s) => !workedFor(e, s)).length; // Needed-only hides the worked LINES as well as the all-worked cards: // a filter that says needed and still lists five green Worked rows is