// 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'; import { useWatchlistSpots, matchesEntry, newBadge, type WLEntry } from '@/lib/watchlistSpots'; interface Props { spots: ClusterSpot[]; spotStatus: Record; onSpotSelect?: (s: ClusterSpot) => void; onSpotClick?: (s: ClusterSpot) => void; } 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; }; 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). // Which entries are on the air, and which of their slots are still needed. // One definition, shared with the docked watch-list widget — the answer // involves a debounced query per visible slot, and two copies of it would be // two bursts of the same question and two ideas of what "needed" means. const { spotsFor, workedFor, settled, onAir, worked } = useWatchlistSpots(entries, spots); 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 = onAir; 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)]; const b = newBadge(st?.status); return b ? { label: t(b.key), color: b.colour } : 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')}
)}
); })}
); }