Files
OpsLog/frontend/src/components/WatchlistTab.tsx
T
rouggy e93d2d36a8 feat(watchlist): the ClubLog expedition enrichment — phase 2 lands
clublog.org/watch.php, per entry: the DXpedition flag, OQRS, LiveStream, the
log's QSO total and the last-24h rate — the fields the schema has carried since
phase 1. Refreshed on DXHunter's own cadence (hourly for expeditions,
six-hourly for the rest), two at a time with a breath between requests: the
application API key is shared by every install, so a hundred-entry list must
read as a trickle at ClubLog's end. Nothing to configure — OpsLog's own key,
already used for cty and Most Wanted, serves.

A starred pattern is asked about by its base (ClubLog has no log for VK9*).
The card gains the 24h rate beside the QSO total and the Live link to the
expedition's stream.
2026-08-29 01:49:13 +02:00

418 lines
23 KiB
TypeScript

// 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, 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<string, SpotStatusEntry>;
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<WLEntry[]>([]);
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<number | undefined>(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<Record<string, boolean>>({});
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<string, ClusterSpot[]>();
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<string>();
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<number | undefined>(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('');
flash(t(addContest ? 'wl.addedContest' : 'wl.added', { call: c }), false);
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); flash(t('wl.removed', { call }), false); 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;
}
};
const chip = (color: string, text: string, extra?: string) => (
<span className={cn('px-1.5 py-0.5 rounded text-[10px] font-bold border', extra)}
style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, background: `color-mix(in srgb, ${color} 12%, transparent)` }}>
{text}
</span>
);
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.
<div className="flex flex-col min-h-0 flex-1 gap-2 p-2 w-full max-w-5xl mx-auto">
{/* Header: the counters alone, centred — they are the tab's headline.
Everything one INTERACTS with lives on the second row. */}
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Eye className="size-4 text-primary shrink-0" />
<span>
{t('wl.cTotal')} <b className="text-foreground">{counters.total}</b>
<span className="mx-1.5 opacity-50">|</span>
{t('wl.cActive')} <b className="text-info">{counters.active}</b>
<span className="mx-1.5 opacity-50">|</span>
{t('wl.cNeeded')} <b className="text-warning">{counters.needed}</b>
</span>
</div>
{/* toolbar */}
<div className="flex items-start justify-between gap-x-3 gap-y-1.5 flex-wrap">
<div className="flex items-center gap-2 shrink-0">
<Input className="h-8 w-40 font-mono placeholder:font-sans placeholder:normal-case" placeholder={t('wl.addPh')} value={addCall}
onChange={(e) => setAddCall(e.target.value.toUpperCase())}
onKeyDown={(e) => { if (e.key === 'Enter') void add(); }} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('wl.contestHint')}>
<Checkbox checked={addContest} onCheckedChange={(c) => setAddContest(!!c)} />
<Trophy className="size-3.5 text-warning" /> {t('wl.addAsContest')}
</label>
<Button size="sm" className="h-8" onClick={() => void add()} disabled={!addCall.trim()}>
<Plus className="size-3.5" /> {t('wl.add')}
</Button>
<Input className="h-8 w-28 font-mono placeholder:font-sans placeholder:normal-case" placeholder={t('wl.patternPh')}
title={t('wl.patternHint')}
value={pattern}
onChange={(e) => setPattern(e.target.value.toUpperCase())}
onBlur={() => { void SetWatchlistContestPattern(pattern.trim()); }} />
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input className="h-8 w-28 pl-7 text-sm" placeholder={t('wl.searchPh')} value={search}
onChange={(e) => setSearch(e.target.value)} />
</div>
{/* families kept together, told apart — per review of the DXHunter port,
no global contest mode: each entry carries its own rule. */}
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
{([['all', t('wl.famAll')], ['normal', t('wl.famNormal')], ['contest', t('wl.famContest')]] as const).map(([k, label]) => (
<button key={k} type="button" onClick={() => setFamily(k)}
className={cn('px-2 py-1 border-l border-border first:border-l-0',
family === k ? 'bg-accent font-medium' : 'text-muted-foreground hover:bg-accent/50')}>
{label}
</button>
))}
</div>
<select value={modeFilter} onChange={(e) => setModeFilter(e.target.value)}
title={t('wl.modeFilter')}
className="h-7 px-1.5 rounded-md border border-border bg-background text-xs text-foreground">
{['ALL', 'CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'DIGI'].map((m) => (
<option key={m} value={m}>{m === 'ALL' ? t('wl.allModes') : m}</option>
))}
</select>
<button type="button" onClick={() => setActiveOnly((v) => !v)}
className={cn('px-2 py-1 rounded-md border text-xs', activeOnly ? 'bg-accent border-border font-medium' : 'border-border text-muted-foreground hover:bg-accent/50')}>
{t('wl.activeOnly')}
</button>
<button type="button" onClick={() => setNeededOnly((v) => !v)}
className={cn('px-2 py-1 rounded-md border text-xs', neededOnly ? 'bg-accent border-border font-medium' : 'border-border text-muted-foreground hover:bg-accent/50')}>
{t('wl.neededOnly')}
</button>
</div>
</div>
{error && <div className="text-xs text-danger px-1">{error}</div>}
{notice && <div className="text-xs text-success px-1">{notice}</div>}
{/* cards */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-2 pr-1">
{shown.length === 0 ? (
<div className="text-sm text-muted-foreground text-center pt-16 max-w-md mx-auto leading-relaxed">
{entries.length === 0 ? t('wl.empty') : t('wl.noneMatch')}
</div>
) : 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 (
<div key={e.callsign}
className={cn('rounded-lg border bg-card p-3',
needed > 0 ? 'border-warning/50' : 'border-border',
e.isContest && 'border-l-4 border-l-warning')}>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-lg font-bold font-mono text-primary">{e.callsign}</span>
{isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
{e.isContest && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border"
title={t('wl.contestHint')}>
<Trophy className="size-3" /> {t('wl.contest')}
</span>
)}
{e.isExpedition && chip('var(--chart-5)', t('wl.expedition'))}
{e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}</span>}
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
{e.clubLogLiveStream && (
<a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
className="px-1.5 py-0.5 rounded text-[10px] font-bold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
)}
{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' && (
<span className="text-[11px] text-muted-foreground">· {e.lastSeenStr}</span>
)}
{e.spotCount > 0 && (
<span className="text-[11px] text-muted-foreground/70">· {t('wl.totalSpots', { n: e.spotCount })}</span>
)}
<div className="flex-1" />
<button type="button" title={t('wl.toggleContest')}
onClick={() => void WatchlistSetContest(e.callsign, !e.isContest).then(refresh)}
className={cn('p-1 rounded hover:bg-muted', e.isContest ? 'text-warning' : 'text-muted-foreground/40')}>
<Trophy className="size-3.5" />
</button>
<button type="button" title={e.notify ? t('wl.notifyOff') : t('wl.notifyOn')}
onClick={() => void WatchlistSetNotify(e.callsign, !e.notify).then(refresh)}
className={cn('p-1 rounded hover:bg-muted', e.notify ? 'text-warning' : 'text-muted-foreground/40')}>
{e.notify ? <Bell className="size-3.5" /> : <BellOff className="size-3.5" />}
</button>
<button type="button" title={t('wl.remove')}
onClick={() => void remove(e.callsign)}
className="p-1 rounded hover:bg-muted text-muted-foreground/40 hover:text-danger">
<Trash2 className="size-3.5" />
</button>
</div>
{list.length > 0 ? (
<div className="mt-2 space-y-1 max-h-52 overflow-y-auto">
{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 (
<button key={i} type="button"
onClick={() => onSpotSelect?.(s)}
onDoubleClick={() => onSpotClick?.(s)}
title={t('wl.spotTip')}
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/40 hover:bg-muted text-left',
!done && 'border-l-2 border-warning')}>
{done && <span className='font-bold shrink-0 text-success'></span>}
<span className="font-mono font-bold text-info shrink-0">{s.dx_call}</span>
<span className="text-muted-foreground truncate flex-1 min-w-0 max-w-56">{(s as any).country ?? ''}</span>
<span className="px-1.5 rounded bg-muted shrink-0">{s.band}</span>
{mode && <span className="px-1.5 rounded shrink-0" style={{ color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' }}>{mode}</span>}
<span className="font-mono text-muted-foreground shrink-0">{(s.freq_hz / 1e6).toFixed(4)}</span>
{badge && chip(badge.color, badge.label)}
<div className="flex-1" />
{done
? chip('var(--success)', e.isContest ? t('wl.todayOk') : t('wl.worked'))
: chip('var(--warning)', e.isContest ? t('wl.workToday') : t('wl.needed'))}
<span className="text-muted-foreground/70 shrink-0">{s.time_utc ?? ''}</span>
</button>
);
})}
</div>
) : (
<div className="mt-2 text-[11px] text-muted-foreground text-center py-1.5 bg-muted/30 rounded">
{t('wl.noSpots')}
</div>
)}
</div>
);
})}
</div>
</div>
);
}