A new spot rendered as Needed the instant it arrived and was withdrawn half a second later when the worked answer landed — the list twitched on every burst. Unsettled spots are now invisible everywhere (card lines, counters, the Active/Needed-only filters) until their verdict is in: nobody needs a spot 150 ms early, they need it settled. The answer map is merged rather than replaced, so a settled line can never fall back to unknown, and the query debounce drops to 150 ms since there is nothing on screen waiting for it.
384 lines
21 KiB
TypeScript
384 lines
21 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,
|
|
} 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;
|
|
|
|
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<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('');
|
|
const [removeArm, setRemoveArm] = useState('');
|
|
// 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;
|
|
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) => (
|
|
<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">
|
|
{/* toolbar */}
|
|
<div className="flex items-center gap-x-2 gap-y-1.5 flex-wrap">
|
|
<Eye className="size-4 text-primary shrink-0" />
|
|
<span className="text-[11px] text-muted-foreground shrink-0">
|
|
{t('wl.cTotal')} <b className="text-foreground">{counters.total}</b>
|
|
<span className="mx-1 opacity-50">·</span>
|
|
{t('wl.cActive')} <b className="text-info">{counters.active}</b>
|
|
<span className="mx-1 opacity-50">·</span>
|
|
{t('wl.cNeeded')} <b className="text-warning">{counters.needed}</b>
|
|
</span>
|
|
<Input className="h-8 w-44 font-mono uppercase" placeholder={t('wl.addPh')} value={addCall}
|
|
onChange={(e) => setAddCall(e.target.value)}
|
|
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 uppercase" placeholder={t('wl.patternPh')}
|
|
title={t('wl.patternHint')}
|
|
value={pattern}
|
|
onChange={(e) => setPattern(e.target.value.toUpperCase())}
|
|
onBlur={() => { void SetWatchlistContestPattern(pattern.trim()); }} />
|
|
<div className="flex-1" />
|
|
<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-32 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>
|
|
{error && <div className="text-xs text-danger px-1">{error}</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</span>}
|
|
{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' && (
|
|
<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={cn('p-1 rounded hover:bg-muted', removeArm === e.callsign ? 'text-danger' : 'text-muted-foreground/40')}>
|
|
<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>
|
|
);
|
|
}
|