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