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.
418 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|