// 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, Check, AlertTriangle } 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, OpenExternalURL, } 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; // Exact unless the entry carries a trailing * — the same rule the backend's // Match applies to the live stream, mirrored so the tab and the alerts can // never disagree about what an entry covers. function matchesEntry(call: string, pattern: string): boolean { const c = call.toUpperCase(); if (pattern.endsWith('*')) { const p = pattern.slice(0, -1); return p !== '' && c.startsWith(p); } return c === 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(''); // A quiet confirmation under the toolbar; both messages clear themselves — // a stale "added" from ten minutes ago reads as a fresh one. const [notice, setNotice] = useState(''); const noticeTimer = useRef(undefined); const flash = (msg: string, isError: boolean) => { if (noticeTimer.current) window.clearTimeout(noticeTimer.current); setError(isError ? msg : ''); setNotice(isError ? '' : msg); noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number; }; // 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; try { await WatchlistAdd(c, addContest); setAddCall(''); // The confirmation is the app-level notice (App.tsx, on watchlist:changed) // — saying it twice, once per place a call can be added from, was noise. await refresh(); } catch (e: any) { flash(String(e?.message ?? e), true); } }; // One click, like the bell and the trophy beside it: removing a watchlist // entry is cheap to undo (type it again), so it does not earn a confirmation // the other two buttons do not have. const remove = async (call: string) => { try { await WatchlistRemove(call); await refresh(); } catch (e: any) { flash(String(e?.message ?? e), true); } }; 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; } }; // Three decimals, and no trailing zeros beyond them: 7.056 rather than // 7.0560, 14.0745 rather than 14.074500. DXHunter's own rule, and the one an // operator reads a cluster line with. const fmtMHz = (hz: number) => { const [int, dec] = (hz / 1e6).toFixed(6).split('.'); return int + '.' + dec.slice(0, 3) + dec.slice(3).replace(/0+$/, ''); }; 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.
{/* Header: the counters alone, centred — they are the tab's headline. Everything one INTERACTS with lives on the second row. */}
{t('wl.cTotal')} {counters.total} | {t('wl.cActive')} {counters.active} | {t('wl.cNeeded')} 0 ? 'text-warning border-warning/40 bg-warning/10' : 'text-muted-foreground border-border bg-muted/40')}>{counters.needed}
{/* toolbar */}
setAddCall(e.target.value.toUpperCase())} 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}
} {notice &&
{notice}
} {/* 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/40' : 'border-border/70', e.isContest && 'border-l-4 border-l-warning')}>
{/* Proportional, not monospaced: DXHunter sets this one in the interface font and the difference is the first thing an operator notices with the two windows side by side. There is nothing to align here — it is a heading, not a column. */} {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.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}} {e.clubLogHasOQRS && chip('var(--success)', 'OQRS')} {e.clubLogLiveStream && ( { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }} className="px-1.5 py-0.5 rounded text-[11px] font-semibold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live )} {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')}
)}
); })}
); }