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}
|
||||
|
||||
Reference in New Issue
Block a user