The operator preferred the original's aesthetics. Adopted: the signature pink callsign (a fixed identity colour, like the cluster palette), counters as coloured pills, a quieter card surface with a hover, a stronger needed bar on spot rows, and the lightning bolt back on the DXpedition badge. Everything else stays on theme tokens.
421 lines
23 KiB
TypeScript
421 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.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>}
|
|
<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>
|
|
);
|
|
}
|