// Watchlist — the DXHunter concept as an OpsLog tab. // // One card per watched callsign (or prefix), the live spots that match it // underneath, and the two questions an operator actually asks answered on every // line: is this slot still NEEDED, and what would it be worth (the same NEW // DXCC / band / mode / slot badges the cluster shows, resolved from the same // index). A CONTEST entry is judged against the current UTC day — at midnight // everything reads "work today" again; the boundary lives in the backend query, // so there is nothing to reset. // // The design is DXHunter's — the layout, the badges, the wording — repainted in // the app's theme tokens rather than its hard-coded slate/pink. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { WatchlistEntries, WatchlistAdd, WatchlistRemove, WatchlistSetNotify, WatchlistSetContest, WatchlistWorkedSlots, GetWatchlistContestPattern, SetWatchlistContestPattern, } from '../../wailsjs/go/main/App'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid'; import { inferSpotMode, spotStatusKey } from '@/lib/spot'; interface WLEntry { callsign: string; lastSeenStr: string; addedAt: string; spotCount: number; isContest: boolean; notify: boolean; isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number; clubLogHasOQRS: boolean; clubLogLiveStream: boolean; } interface Props { spots: ClusterSpot[]; spotStatus: Record; onSpotSelect?: (s: ClusterSpot) => void; onSpotClick?: (s: ClusterSpot) => void; } // A spot is ON AIR for the badge while its last sighting is this fresh. const ON_AIR_MS = 10 * 60 * 1000; function matchesEntry(call: string, pattern: string): boolean { const c = call.toUpperCase(); return c === pattern || c.startsWith(pattern); } export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) { const { t } = useI18n(); const [entries, setEntries] = useState([]); const [addCall, setAddCall] = useState(''); const [addContest, setAddContest] = useState(false); const [search, setSearch] = useState(''); // The filters survive leaving the tab: the component unmounts on every tab // switch, and filters that reset each time are filters nobody trusts. const [neededOnly, setNeededOnlyRaw] = useState(() => localStorage.getItem('opslog.wlNeededOnly') === '1'); const [activeOnly, setActiveOnlyRaw] = useState(() => localStorage.getItem('opslog.wlActiveOnly') === '1'); const [family, setFamilyRaw] = useState<'all' | 'normal' | 'contest'>(() => { const v = localStorage.getItem('opslog.wlFamily'); return v === 'normal' || v === 'contest' ? v : 'all'; }); const setNeededOnly = (f: (v: boolean) => boolean) => setNeededOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlNeededOnly', nv ? '1' : '0'); } catch {} return nv; }); const setActiveOnly = (f: (v: boolean) => boolean) => setActiveOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlActiveOnly', nv ? '1' : '0'); } catch {} return nv; }); const setFamily = (v: 'all' | 'normal' | 'contest') => { setFamilyRaw(v); try { localStorage.setItem('opslog.wlFamily', v); } catch {} }; // Mode filter — DXHunter's "All Modes" select. DIGI matches the digital class // (FT8, FT4, a generic DATA spot…), the named modes match exactly. const [modeFilter, setModeFilterRaw] = useState(() => localStorage.getItem('opslog.wlMode') || 'ALL'); const setModeFilter = (v: string) => { setModeFilterRaw(v); try { localStorage.setItem('opslog.wlMode', v); } catch {} }; const modeMatches = (s2: ClusterSpot): boolean => { if (modeFilter === 'ALL') return true; const m = (inferSpotMode(s2.comment ?? '', s2.freq_hz) || '').toUpperCase(); if (modeFilter === 'DIGI') return !['', 'CW', 'SSB', 'USB', 'LSB', 'FM', 'AM'].includes(m); if (modeFilter === 'SSB') return m === 'SSB' || m === 'USB' || m === 'LSB'; return m === modeFilter; }; const [error, setError] = useState(''); const [removeArm, setRemoveArm] = useState(''); // worked answer per "call|band|modeclass|contest" key. const [worked, setWorked] = useState>({}); const refresh = useCallback(async () => { try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); } catch (e: any) { setError(String(e?.message ?? e)); } }, []); // Refreshed on mount, when the backend auto-adds (event), and on a slow tick // so last-seen and the spot counters stay honest while the tab sits open. useEffect(() => { void refresh(); const off = EventsOn('watchlist:changed', () => { void refresh(); }); const id = window.setInterval(() => { void refresh(); }, 30_000); return () => { off(); window.clearInterval(id); }; }, [refresh]); // The auto-contest pattern (DXHunter's contest_prefix): spots whose call // contains it are added as contest entries by the backend. const [pattern, setPattern] = useState(''); useEffect(() => { GetWatchlistContestPattern().then((p: string) => setPattern(p ?? '')).catch(() => {}); }, []); // Live spots per entry — prefix-matched, newest first, deduped per band+mode // (one line per slot; the freshest spot represents it). const spotsFor = useMemo(() => { const map = new Map(); for (const e of entries) map.set(e.callsign, []); for (const s of spots) { for (const e of entries) { if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; } } } for (const [k, list] of map) { const seen = new Set(); map.set(k, list.filter((s) => { const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`; if (seen.has(key)) return false; seen.add(key); return true; })); } return map; }, [spots, entries]); // The worked answers, refreshed when the visible slots change. Debounced: a // spot burst must cost one round trip, not one per spot. const queryTimer = useRef(undefined); useEffect(() => { if (queryTimer.current) window.clearTimeout(queryTimer.current); queryTimer.current = window.setTimeout(async () => { const queries: { call: string; band: string; mode: string; contest: boolean }[] = []; const keys: string[] = []; for (const e of entries) { for (const s of spotsFor.get(e.callsign) ?? []) { const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || ''; queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest }); keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`); } } if (queries.length === 0) { setWorked({}); return; } try { const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? []; // 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 */ } }, 150) as unknown as number; return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); }; }, [spotsFor, entries]); 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(); if (!c) return; setError(''); try { await WatchlistAdd(c, addContest); setAddCall(''); await refresh(); } catch (e: any) { setError(String(e?.message ?? e)); } }; const remove = async (call: string) => { if (removeArm !== call) { setRemoveArm(call); window.setTimeout(() => setRemoveArm(''), 2500); return; } try { await WatchlistRemove(call); await refresh(); } catch (e: any) { setError(String(e?.message ?? e)); } }; const isOnAir = (e: WLEntry): boolean => (spotsFor.get(e.callsign) ?? []).some((s) => { const ts = Date.parse(String((s as any).received_at ?? '')); return ts > 0 && Date.now() - ts < ON_AIR_MS; }); const shown = entries.filter((e) => { 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).filter((s2) => settled(e, s2)); if (activeOnly && list.length === 0) return false; if (neededOnly && !list.some((s2) => !workedFor(e, s2))) return false; return true; }); const counters = useMemo(() => { let active = 0, needed = 0; for (const e of entries) { 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++; } return { total: entries.length, active, needed }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [entries, spotsFor, worked, modeFilter]); // The cluster's own badge for a spot, read from the shared status index. const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => { const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]; switch (st?.status) { case 'new': return { label: t('wl.newDxcc'), color: 'var(--danger)' }; case 'new-band-mode': return { label: t('clg2.newBandMode'), color: 'var(--danger)' }; case 'new-band': return { label: t('clg2.newBand'), color: 'var(--warning)' }; case 'new-mode': return { label: t('clg2.newMode'), color: 'var(--caution)' }; case 'new-slot': return { label: t('clg2.newSlot'), color: '#5AC8FA' }; default: return null; } }; const chip = (color: string, text: string, extra?: string) => ( {text} ); return ( // Capped and centred: a card is a reading surface, and callsign-to-badge // lines stretched across a 34-inch window are not readable, they are long.
{/* toolbar */}
{t('wl.cTotal')} {counters.total} · {t('wl.cActive')} {counters.active} · {t('wl.cNeeded')} {counters.needed} setAddCall(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void add(); }} /> setPattern(e.target.value.toUpperCase())} onBlur={() => { void SetWatchlistContestPattern(pattern.trim()); }} />
setSearch(e.target.value)} />
{/* families kept together, told apart — per review of the DXHunter port, no global contest mode: each entry carries its own rule. */}
{([['all', t('wl.famAll')], ['normal', t('wl.famNormal')], ['contest', t('wl.famContest')]] as const).map(([k, label]) => ( ))}
{error &&
{error}
} {/* cards */}
{shown.length === 0 ? (
{entries.length === 0 ? t('wl.empty') : t('wl.noneMatch')}
) : shown.map((e) => { 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 // answering a different question than the one asked. const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all; return (
0 ? 'border-warning/50' : 'border-border', e.isContest && 'border-l-4 border-l-warning')}>
{e.callsign} {isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')} {e.isContest && ( {t('wl.contest')} )} {e.isExpedition && chip('var(--chart-5)', t('wl.expedition'))} {e.clubLogTotalQSOs > 0 && {e.clubLogTotalQSOs.toLocaleString()} QSOs} {e.clubLogHasOQRS && chip('var(--success)', 'OQRS')} {list.length > 0 && (needed > 0 ? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed })) : chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))} {e.lastSeenStr && e.lastSeenStr !== 'Never' && ( · {e.lastSeenStr} )} {e.spotCount > 0 && ( · {t('wl.totalSpots', { n: e.spotCount })} )}
{list.length > 0 ? (
{list.slice(0, 12).map((s, i) => { const done = workedFor(e, s); const badge = dxccBadge(s); const mode = inferSpotMode(s.comment ?? '', s.freq_hz); return ( ); })}
) : (
{t('wl.noSpots')}
)}
); })}
); }