A DXpeditions tab holding the two feeds the DX world announces itself on: NG3K's ADXO (structured — dates, entity, calls, bands, modes, QSL route) and DX-World's headlines. What a logger can say that a news reader cannot is whether the operation is worth chasing, so every announcement is put through the SAME verdict the cluster paints on a spot — one badge, strongest wins, dimmed when the need is only a missing QSL — with an 'only what I need' filter. One click watches every callsign of an operation; the news headlines are mined for callsigns so they can be watched the same way. Parsers ported from DXHunter's and pinned with table tests against real feed text: the 'as PJ2/W2APF' mining, the DXCC-prefix-first normalisation without which no spot ever matches, both ADXO date forms, and the '160-6m' span whose unit is written once (DXHunter read that as 6m alone and lost the low end). Watchlist membership now announces itself app-wide, on the same card as a new version: the bindings emit watchlist:changed, so a call added from the cluster or this tab is confirmed where the operator is actually looking — the panel's own inline message never was.
423 lines
23 KiB
TypeScript
423 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('');
|
||
// 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;
|
||
}
|
||
};
|
||
|
||
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.5 text-xs text-muted-foreground">
|
||
<Eye className="size-4 text-primary shrink-0" />
|
||
<span className="flex items-center gap-1.5">{t('wl.cTotal')}
|
||
<b className="px-2 py-0.5 rounded bg-muted text-foreground">{counters.total}</b></span>
|
||
<span className="opacity-40">|</span>
|
||
<span className="flex items-center gap-1.5">{t('wl.cActive')}
|
||
<b className="px-2 py-0.5 rounded text-info border border-info/30 bg-info/10">{counters.active}</b></span>
|
||
<span className="opacity-40">|</span>
|
||
<span className="flex items-center gap-1.5">{t('wl.cNeeded')}
|
||
<b className={cn('px-2 py-0.5 rounded border', counters.needed > 0
|
||
? 'text-warning border-warning/40 bg-warning/10'
|
||
: 'text-muted-foreground border-border bg-muted/40')}>{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/70 p-3 transition-colors hover:bg-accent/20',
|
||
needed > 0 ? 'border-warning/40' : 'border-border/70',
|
||
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" style={{ color: '#f472b6' }}>{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/25 hover:bg-muted/70 transition-colors text-left',
|
||
!done && 'border-l-[3px] border-warning')}>
|
||
{done && <span className='font-bold shrink-0 text-success'>✓</span>}
|
||
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
|
||
<span className="font-mono font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
|
||
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
|
||
<span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
|
||
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
|
||
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{(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>
|
||
);
|
||
}
|