feat(dxped): the announced DX, judged against your own log

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.
This commit is contained in:
2026-08-31 17:51:45 +02:00
parent 629bd8d84f
commit 187ac9aa84
13 changed files with 1202 additions and 9 deletions
+71 -1
View File
@@ -1,7 +1,7 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react';
import {
@@ -76,6 +76,7 @@ import { AutoEQSL } from '@/components/qsl/AutoEQSL';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { FTMapPanel } from '@/components/FTMapPanel';
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
@@ -1315,6 +1316,17 @@ export default function App() {
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
}
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
function openDxpedTab() {
setDxpedTabOpen(true);
writeUiPref('opslog.dxpedTab', '1');
setActiveTab('dxped');
}
function closeDxpedTab() {
setDxpedTabOpen(false);
writeUiPref('opslog.dxpedTab', '0');
setActiveTab((t) => (t === 'dxped' ? 'recent' : t));
}
function openFtmapTab() {
setFtmapTabOpen(true);
writeUiPref('opslog.ftmapTab', '1');
@@ -2346,6 +2358,18 @@ export default function App() {
}, [showSettings]);
const [showDuplicates, setShowDuplicates] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
// Watchlist membership changes announce themselves here rather than inside
// the watchlist panel: a call can be added from the cluster or the
// DXpeditions tab, and the old inline message lived on a page nobody was
// looking at.
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
const wlNoticeTimer = useRef<number | undefined>(undefined);
useEffect(() => EventsOn('watchlist:changed', (e: any) => {
if (!e?.call) return;
setWlNotice({ call: String(e.call), added: !!e.added });
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
}), []);
const [checkingUpdate, setCheckingUpdate] = useState(false);
// Fresh update check on demand (opening About), so it never shows a stale
// "you're up to date". Clears updateInfo when the latest check finds nothing.
@@ -5126,6 +5150,7 @@ export default function App() {
]},
{ name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('station.title'), action: 'tools.station' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
@@ -5180,6 +5205,7 @@ export default function App() {
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
case 'tools.dxped': openDxpedTab(); break;
case 'tools.decodes': openDecodesTab(); break;
case 'tools.ftmap': openFtmapTab(); break;
case 'tools.grids': openGridsTab(); break;
@@ -7076,6 +7102,26 @@ export default function App() {
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
)}
{wlNotice && (
<div className={cn('fixed right-4 z-[150] w-72 rounded-lg border bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in',
wlNotice.added ? 'border-warning/40' : 'border-border',
// Stacked above the update card when both are up.
updateInfo ? 'bottom-44' : 'bottom-4')}>
<div className="flex items-start gap-2">
<Star className={cn('size-4 mt-0.5 shrink-0', wlNotice.added ? 'fill-current text-warning' : 'text-muted-foreground')} />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">
{t(wlNotice.added ? 'wlnote.added' : 'wlnote.removed', { call: wlNotice.call })}
</p>
<p className="text-xs text-muted-foreground">{t('wlnote.hint')}</p>
</div>
<button onClick={() => setWlNotice(null)}
className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Close">
<X className="size-3.5" />
</button>
</div>
</div>
)}
{updateInfo && (
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
<div className="flex items-start gap-2">
@@ -7816,6 +7862,21 @@ export default function App() {
</span>
</TabsTrigger>
)}
{dxpedTabOpen && (
<TabsTrigger value="dxped" className="gap-1.5">
{t('dxp.tab')}
<span
role="button"
aria-label="Close DXpeditions"
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(); closeDxpedTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{ftmapTabOpen && (
<TabsTrigger value="ftmap" className="gap-1.5">
{t('ftmap.tab')}
@@ -8429,6 +8490,15 @@ export default function App() {
</TabsContent>
)}
{dxpedTabOpen && (
<TabsContent value="dxped" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'dxped' && (
<div className="h-full w-full min-h-0 p-1">
<DXpeditionsPanel />
</div>
)}
</TabsContent>
)}
{ftmapTabOpen && (
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'ftmap' && (