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:
+28
-1
@@ -69,6 +69,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { APP_VERSION, APP_AUTHOR } from '@/version';
|
||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||
import { WatchlistTab } from '@/components/WatchlistTab';
|
||||
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
||||
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
||||
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
||||
@@ -1684,6 +1685,9 @@ export default function App() {
|
||||
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
|
||||
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
|
||||
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
|
||||
// Watchlist tab — opt-in from Tools, persisted, closable like NET Control.
|
||||
const [watchlistEnabled, setWatchlistEnabled] = useState(() => localStorage.getItem('opslog.watchlistEnabled') === '1');
|
||||
useEffect(() => { localStorage.setItem('opslog.watchlistEnabled', watchlistEnabled ? '1' : '0'); }, [watchlistEnabled]);
|
||||
// Contest tab is hidden until enabled from Tools → Contest mode.
|
||||
const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1');
|
||||
useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]);
|
||||
@@ -5004,6 +5008,7 @@ export default function App() {
|
||||
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
||||
{ type: 'item', label: (watchlistEnabled ? '✓ ' : '') + t('tools.watchlist'), action: 'tools.watchlist' },
|
||||
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
|
||||
{ type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
|
||||
{ type: 'separator' },
|
||||
@@ -5027,7 +5032,7 @@ export default function App() {
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, selectedIds, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||
], [total, selectedId, selectedIds, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, watchlistEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -5055,6 +5060,7 @@ export default function App() {
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
||||
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
||||
case 'tools.watchlist': setWatchlistEnabled((v) => { const nv = !v; if (nv) setActiveTab('watchlist'); return nv; }); break;
|
||||
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
|
||||
case 'tools.alerts': setAlertsOpen(true); break;
|
||||
case 'tools.duplicates': setShowDuplicates(true); break;
|
||||
@@ -7601,6 +7607,21 @@ export default function App() {
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{watchlistEnabled && (
|
||||
<TabsTrigger value="watchlist" className="gap-1.5">
|
||||
{t('tab.watchlist')}
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Close Watchlist"
|
||||
title="Close"
|
||||
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => { e.stopPropagation(); setWatchlistEnabled(false); setActiveTab((t) => (t === 'watchlist' ? 'recent' : t)); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{catState.backend === 'flex' && <TabsTrigger value="flex">Flex Console</TabsTrigger>}
|
||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom Console</TabsTrigger>}
|
||||
{catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>}
|
||||
@@ -8312,6 +8333,12 @@ export default function App() {
|
||||
{/* Band Map: several bands shown side-by-side (panadapter-style
|
||||
strips). Pick bands with the chips; each strip is clickable to
|
||||
tune the rig. */}
|
||||
{watchlistEnabled && (
|
||||
<TabsContent value="watchlist" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WatchlistTab spots={spots} spotStatus={spotStatus}
|
||||
onSpotSelect={handleSpotSelect} onSpotClick={handleSpotClick} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{netEnabled && (
|
||||
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,20 @@ const en: Dict = {
|
||||
'cwd.tipOnCw': 'CW decoder — on (decoding) · click to disable',
|
||||
'cwd.tipOnIdle': 'CW decoder — on, idle until CW mode · click to disable',
|
||||
'cwd.tipOff': 'CW decoder · click to enable (decodes RX audio in CW mode)',
|
||||
'tools.watchlist': 'Watchlist…', 'tab.watchlist': 'Watchlist',
|
||||
'wl.addPh': 'Callsign or prefix', 'wl.add': 'Add', 'wl.contest': 'Contest',
|
||||
'wl.contestHint': 'Contest station: worked/needed is judged against the current UTC day — at 00:00 UTC every slot can be worked again.',
|
||||
'wl.searchPh': 'Search…', 'wl.famAll': 'All', 'wl.famNormal': 'DX', 'wl.famContest': 'Contest',
|
||||
'wl.activeOnly': 'Active only', 'wl.neededOnly': 'Needed only',
|
||||
'wl.empty': 'Add the callsigns or prefixes you are hunting — a prefix catches the portable forms too (RI0SP matches RI0SP/MM). Spots from the cluster appear under each entry with what they are worth.',
|
||||
'wl.noneMatch': 'Nothing matches the current filters.',
|
||||
'wl.onAir': 'ON AIR', 'wl.expedition': 'DXpedition',
|
||||
'wl.nNeeded': '{n} needed', 'wl.nToday': '{n} today', 'wl.allWorked': 'All worked', 'wl.workedToday': 'Worked today',
|
||||
'wl.totalSpots': '{n} spots', 'wl.toggleContest': 'Contest entry: judged per UTC day',
|
||||
'wl.notifyOn': 'Alert me when this station is spotted', 'wl.notifyOff': 'Stop alerting for this station',
|
||||
'wl.remove': 'Remove (click twice)', 'wl.noSpots': 'No live spots',
|
||||
'wl.newDxcc': 'NEW DXCC', 'wl.worked': 'Worked', 'wl.needed': 'Needed!', 'wl.todayOk': 'Today ✓', 'wl.workToday': 'Work today!',
|
||||
'wl.spotTip': 'Click: fill the callsign · double-click: tune and work',
|
||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
||||
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
||||
'menu.help': 'Help', 'prof.switchTitle': 'Switch station profile', 'prof.manage': 'Manage profiles…', 'logview.title': "Diagnostic log", 'logview.empty': "(log is empty)", 'logview.searchPh': "Filter lines — e.g. cluster, acom, cat", 'logview.matches': "{n} of {total} lines", 'logview.autoScroll': "Follow the end", 'logview.updated': "refreshed {time}", 'logview.copy': "Copy", 'logview.toEnd': "Jump to end", 'help.about': 'About OpsLog', 'help.donate': '♥ Support OpsLog (donate)', 'tools.duplicates': 'Find duplicates…',
|
||||
@@ -548,6 +562,20 @@ const fr: Dict = {
|
||||
'cwd.tipOnCw': 'Décodeur CW — actif (décode) · clic pour désactiver',
|
||||
'cwd.tipOnIdle': 'Décodeur CW — actif, en veille hors mode CW · clic pour désactiver',
|
||||
'cwd.tipOff': 'Décodeur CW · clic pour activer (décode l’audio RX en mode CW)',
|
||||
'tools.watchlist': 'Watchlist…', 'tab.watchlist': 'Watchlist',
|
||||
'wl.addPh': 'Indicatif ou préfixe', 'wl.add': 'Ajouter', 'wl.contest': 'Contest',
|
||||
'wl.contestHint': "Station contest : contacté/manquant est jugé sur la journée UTC courante — à 00:00 UTC chaque créneau redevient à faire.",
|
||||
'wl.searchPh': 'Chercher…', 'wl.famAll': 'Tous', 'wl.famNormal': 'DX', 'wl.famContest': 'Contest',
|
||||
'wl.activeOnly': 'Actifs seulement', 'wl.neededOnly': 'Manquants seulement',
|
||||
'wl.empty': "Ajoutez les indicatifs ou préfixes que vous chassez — un préfixe attrape aussi les formes portables (RI0SP matche RI0SP/MM). Les spots du cluster apparaissent sous chaque entrée avec ce qu'ils valent.",
|
||||
'wl.noneMatch': 'Rien ne correspond aux filtres actuels.',
|
||||
'wl.onAir': 'ON AIR', 'wl.expedition': 'DXpédition',
|
||||
'wl.nNeeded': '{n} manquants', 'wl.nToday': "{n} aujourd'hui", 'wl.allWorked': 'Tout contacté', 'wl.workedToday': "Contacté aujourd'hui",
|
||||
'wl.totalSpots': '{n} spots', 'wl.toggleContest': 'Entrée contest : jugée par jour UTC',
|
||||
'wl.notifyOn': "M'alerter quand cette station est spottée", 'wl.notifyOff': "Ne plus alerter pour cette station",
|
||||
'wl.remove': 'Supprimer (cliquer deux fois)', 'wl.noSpots': 'Aucun spot en cours',
|
||||
'wl.newDxcc': 'NOUV DXCC', 'wl.worked': 'Contacté', 'wl.needed': 'Manquant !', 'wl.todayOk': "Auj. ✓", 'wl.workToday': "À faire auj. !",
|
||||
'wl.spotTip': "Clic : remplir l'indicatif · double-clic : régler la radio et travailler",
|
||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
||||
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
||||
'menu.help': 'Aide', 'prof.switchTitle': 'Changer de profil de station', 'prof.manage': 'Gérer les profils…', 'logview.title': "Journal de diagnostic", 'logview.empty': "(journal vide)", 'logview.searchPh': "Filtrer les lignes — ex. cluster, acom, cat", 'logview.matches': "{n} lignes sur {total}", 'logview.autoScroll': "Suivre la fin", 'logview.updated': "actualisé {time}", 'logview.copy': "Copier", 'logview.toEnd': "Aller à la fin", 'help.about': 'À propos d\'OpsLog', 'help.donate': '♥ Soutenir OpsLog (don)', 'tools.duplicates': 'Trouver les doublons…',
|
||||
|
||||
Vendored
+13
@@ -30,6 +30,7 @@ import {lotwusers} from '../models';
|
||||
import {lookup} from '../models';
|
||||
import {netctl} from '../models';
|
||||
import {scp} from '../models';
|
||||
import {watchlist} from '../models';
|
||||
|
||||
export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
@@ -1357,6 +1358,18 @@ export function UploadCallsign(arg1:string):Promise<string>;
|
||||
|
||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||
|
||||
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function WatchlistEntries():Promise<Array<watchlist.Entry>>;
|
||||
|
||||
export function WatchlistRemove(arg1:string):Promise<void>;
|
||||
|
||||
export function WatchlistSetContest(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function WatchlistSetNotify(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function WatchlistWorkedSlots(arg1:Array<main.WatchlistSlotQuery>):Promise<Array<boolean>>;
|
||||
|
||||
export function WebPublishColumns():Promise<Array<Record<string, string>>>;
|
||||
|
||||
export function WinkeyerBackspace():Promise<void>;
|
||||
|
||||
@@ -2654,6 +2654,30 @@ export function UploadQSOsManual(arg1, arg2) {
|
||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function WatchlistAdd(arg1, arg2) {
|
||||
return window['go']['main']['App']['WatchlistAdd'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function WatchlistEntries() {
|
||||
return window['go']['main']['App']['WatchlistEntries']();
|
||||
}
|
||||
|
||||
export function WatchlistRemove(arg1) {
|
||||
return window['go']['main']['App']['WatchlistRemove'](arg1);
|
||||
}
|
||||
|
||||
export function WatchlistSetContest(arg1, arg2) {
|
||||
return window['go']['main']['App']['WatchlistSetContest'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function WatchlistSetNotify(arg1, arg2) {
|
||||
return window['go']['main']['App']['WatchlistSetNotify'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function WatchlistWorkedSlots(arg1) {
|
||||
return window['go']['main']['App']['WatchlistWorkedSlots'](arg1);
|
||||
}
|
||||
|
||||
export function WebPublishColumns() {
|
||||
return window['go']['main']['App']['WebPublishColumns']();
|
||||
}
|
||||
|
||||
@@ -4301,6 +4301,24 @@ export namespace main {
|
||||
this.text = source["text"];
|
||||
}
|
||||
}
|
||||
export class WatchlistSlotQuery {
|
||||
call: string;
|
||||
band: string;
|
||||
mode: string;
|
||||
contest: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new WatchlistSlotQuery(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.call = source["call"];
|
||||
this.band = source["band"];
|
||||
this.mode = source["mode"];
|
||||
this.contest = source["contest"];
|
||||
}
|
||||
}
|
||||
export class WebPublishStatus {
|
||||
last_run: string;
|
||||
last_err: string;
|
||||
@@ -5990,6 +6008,68 @@ export namespace udp {
|
||||
|
||||
}
|
||||
|
||||
export namespace watchlist {
|
||||
|
||||
export class Entry {
|
||||
callsign: string;
|
||||
// Go type: time
|
||||
lastSeen: any;
|
||||
lastSeenStr: string;
|
||||
// Go type: time
|
||||
addedAt: any;
|
||||
spotCount: number;
|
||||
isContest: boolean;
|
||||
notify: boolean;
|
||||
isExpedition: boolean;
|
||||
clubLogQSOs24h: number;
|
||||
clubLogTotalQSOs: number;
|
||||
clubLogHasOQRS: boolean;
|
||||
clubLogLiveStream: boolean;
|
||||
// Go type: time
|
||||
clubLogUpdatedAt?: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Entry(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.callsign = source["callsign"];
|
||||
this.lastSeen = this.convertValues(source["lastSeen"], null);
|
||||
this.lastSeenStr = source["lastSeenStr"];
|
||||
this.addedAt = this.convertValues(source["addedAt"], null);
|
||||
this.spotCount = source["spotCount"];
|
||||
this.isContest = source["isContest"];
|
||||
this.notify = source["notify"];
|
||||
this.isExpedition = source["isExpedition"];
|
||||
this.clubLogQSOs24h = source["clubLogQSOs24h"];
|
||||
this.clubLogTotalQSOs = source["clubLogTotalQSOs"];
|
||||
this.clubLogHasOQRS = source["clubLogHasOQRS"];
|
||||
this.clubLogLiveStream = source["clubLogLiveStream"];
|
||||
this.clubLogUpdatedAt = this.convertValues(source["clubLogUpdatedAt"], null);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace webpub {
|
||||
|
||||
export class Config {
|
||||
|
||||
Reference in New Issue
Block a user