feat(watchlist): the DXHunter watchlist as an OpsLog tab
The concept, transplanted: a list of callsigns or prefixes being hunted, matched against the live spot stream, one card per entry with the spots underneath and the two questions that matter answered on every line — is this slot still needed, and what is it worth (the cluster's own NEW badges, read from the same status index). The file is DXHunter's own watchlist.json, field for field, ClubLog block included though phase 2 will fill it — a file that round-trips unchanged is the whole of 'same format', and a test pins it with a real DXHunter entry. Global (dataDir), not per profile. CONTEST is per entry, not the global mode DXHunter has: a contest entry is judged against the current UTC day — the boundary lives in the query's date bound, so midnight needs no timer and resets nothing. Normal entries read the same in-memory worked index the alerts use. Prefix matching is why RI0SP catches RI0SP/MM, pinned by test. Notify goes through the existing alert:fired event — the frontend already toasts and sounds it — throttled to one alert per entry per two minutes, because a DXpedition lights every skimmer on the planet. Tab wired like NET Control: opt-in from Tools, persisted, closable. Single click fills the callsign, double click works the spot — the cluster's own gesture, kept.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
// 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,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
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('');
|
||||
const [neededOnly, setNeededOnly] = useState(false);
|
||||
const [activeOnly, setActiveOnly] = useState(false);
|
||||
const [family, setFamily] = useState<'all' | 'normal' | 'contest'>('all');
|
||||
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)); }
|
||||
}, []);
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
|
||||
// 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)) ?? [];
|
||||
const next: Record<string, boolean> = {};
|
||||
keys.forEach((k, i) => { next[k] = !!res[i]; });
|
||||
setWorked(next);
|
||||
} catch { /* the badges just stay conservative */ }
|
||||
}, 400) as unknown as number;
|
||||
return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
|
||||
}, [spotsFor, entries]);
|
||||
|
||||
const workedFor = (e: WLEntry, s: ClusterSpot): boolean =>
|
||||
worked[`${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`] ?? false;
|
||||
|
||||
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;
|
||||
if (activeOnly && (spotsFor.get(e.callsign) ?? []).length === 0) return false;
|
||||
if (neededOnly && !(spotsFor.get(e.callsign) ?? []).some((s) => !workedFor(e, s))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 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 (
|
||||
<div className="flex flex-col min-h-0 flex-1 gap-2 p-2">
|
||||
{/* toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Eye className="size-4 text-primary shrink-0" />
|
||||
<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.contest')}
|
||||
</label>
|
||||
<Button size="sm" className="h-8" onClick={() => void add()} disabled={!addCall.trim()}>
|
||||
<Plus className="size-3.5" /> {t('wl.add')}
|
||||
</Button>
|
||||
<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-40 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>
|
||||
<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 list = spotsFor.get(e.callsign) ?? [];
|
||||
const needed = list.filter((s) => !workedFor(e, s)).length;
|
||||
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')}>
|
||||
<span className={cn('font-bold shrink-0', done ? 'text-success' : 'text-warning')}>{done ? '✓' : '!'}</span>
|
||||
<span className="font-mono font-bold text-info shrink-0">{s.dx_call}</span>
|
||||
<span className="text-muted-foreground truncate max-w-32">{(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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user