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
@@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { RefreshCw, Star, ExternalLink } from 'lucide-react';
import { GetDXpeditions, GetDXWorldNews, RefreshDXpeditions, WatchlistEntries, WatchlistAdd } from '../../wailsjs/go/main/App';
import { BrowserOpenURL, EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// DXpeditions — the two feeds the DX world announces itself on, side by side.
//
// Left: NG3K's ADXO, the structured announcements, each judged against THIS log
// so the list reads as "what I still need" rather than "what is on". Right:
// DX-World's headlines, whose callsigns are mined out of the title so they can
// be watched with the same one click.
type DXped = {
dxcc: string; callsign: string; calls?: string[];
start_date: string; end_date: string;
bands?: string[]; modes?: string[];
qsl: string; operators: string; source: string; link: string;
status: string; // active | upcoming
status_chase: string; // new | new-band-mode | new-band | new-mode | new-slot | worked | ''
unconfirmed?: boolean;
};
type News = {
title: string; link: string; pub_date: string; excerpt: string;
creator: string; image_url: string; tag: string; calls?: string[];
};
// One badge per expedition, the strongest verdict winning — the same palette
// and the same words the cluster uses, so the two views teach one vocabulary.
const CHASE_BADGE: Record<string, { label: string; colour: string }> = {
'new': { label: 'clg2.newDxcc', colour: 'var(--danger)' },
'new-band-mode': { label: 'clg2.newBandMode', colour: 'var(--danger)' },
'new-band': { label: 'clg2.newBand', colour: 'var(--warning)' },
'new-mode': { label: 'clg2.newMode', colour: 'var(--caution)' },
'new-slot': { label: 'clg2.newSlot', colour: '#5AC8FA' },
'worked': { label: 'wl.worked', colour: 'var(--info)' },
};
export function DXpeditionsPanel() {
const { t } = useI18n();
const [peds, setPeds] = useState<DXped[]>([]);
const [news, setNews] = useState<News[]>([]);
const [watched, setWatched] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [neededOnly, setNeededOnly] = useState(() => localStorage.getItem('opslog.dxpedNeeded') === '1');
const loadWatchlist = useCallback(async () => {
try {
const e: any[] = await WatchlistEntries();
setWatched(new Set((e ?? []).map((x) => String(x.callsign ?? '').toUpperCase())));
} catch { /* the list simply shows every call as unwatched */ }
}, []);
const load = useCallback(async () => {
setBusy(true);
setErr('');
const [p, n] = await Promise.allSettled([GetDXpeditions(), GetDXWorldNews()]);
if (p.status === 'fulfilled') setPeds((p.value as any) ?? []);
else setErr(String((p.reason as any)?.message ?? p.reason));
if (n.status === 'fulfilled') setNews((n.value as any) ?? []);
setBusy(false);
}, []);
useEffect(() => { void load(); void loadWatchlist(); }, [load, loadWatchlist]);
useEffect(() => EventsOn('watchlist:changed', () => { void loadWatchlist(); }), [loadWatchlist]);
const refresh = async () => { await RefreshDXpeditions(); await load(); };
const addAll = async (calls: string[]) => {
for (const c of calls) {
if (!watched.has(c.toUpperCase())) {
try { await WatchlistAdd(c, false); } catch { /* reported by the notice */ }
}
}
await loadWatchlist();
};
const shown = useMemo(
() => (neededOnly ? peds.filter((p) => p.status_chase && p.status_chase !== 'worked') : peds),
[peds, neededOnly]);
// A row's calls: the mined ones, else whatever the announcement called it.
const callsOf = (p: DXped) => (p.calls?.length ? p.calls : p.callsign ? [p.callsign] : []);
return (
<div className="flex h-full min-h-0 gap-3">
{/* ── Announcements (ADXO) ── */}
<section className="flex flex-col min-h-0 flex-[3] rounded-lg border border-border bg-card overflow-hidden">
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<span className="text-sm font-semibold">{t('dxp.announced')}</span>
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'NG3K ADXO' })}</span>
<label className="ml-auto flex items-center gap-1.5 text-[11px] cursor-pointer text-muted-foreground hover:text-foreground">
<input type="checkbox" checked={neededOnly}
onChange={(e) => { setNeededOnly(e.target.checked); localStorage.setItem('opslog.dxpedNeeded', e.target.checked ? '1' : '0'); }} />
{t('dxp.neededOnly')}
</label>
<Button variant="outline" size="sm" onClick={refresh} disabled={busy}>
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('dxp.refresh')}
</Button>
</header>
{err && <div className="px-3 py-2 text-xs text-danger shrink-0">{err}</div>}
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
{shown.length === 0 && !busy && (
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.none')}</p>
)}
{shown.map((p, i) => {
const calls = callsOf(p);
const badge = CHASE_BADGE[p.status_chase];
const allWatched = calls.length > 0 && calls.every((c) => watched.has(c.toUpperCase()));
return (
<article key={`${p.callsign}-${p.start_date}-${i}`}
className={cn('rounded-md border p-2 text-xs',
p.status === 'active' ? 'border-success/40 bg-success/5' : 'border-border bg-muted/20')}>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-bold text-sm text-info">{p.callsign || '—'}</span>
<span className="font-medium">{p.dxcc}</span>
{p.status === 'active' && (
<span className="px-1 py-px rounded text-[10px] font-bold uppercase bg-success-muted text-success-muted-foreground">
{t('dxp.onAir')}
</span>
)}
{badge && (
<span className="px-1 py-px rounded text-[10px] font-bold uppercase tracking-wide border"
title={p.unconfirmed ? t('dec.unconfTip') : undefined}
style={p.unconfirmed
? { color: badge.colour, borderColor: badge.colour, borderStyle: 'dashed', opacity: 0.6 }
: { color: badge.colour, borderColor: badge.colour }}>
{t(badge.label)}
</span>
)}
<span className="ml-auto text-[11px] text-muted-foreground whitespace-nowrap">
{p.start_date} {p.end_date}
</span>
</div>
<div className="mt-1 flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground">
{!!p.bands?.length && <span>{p.bands.join(' · ')}</span>}
{!!p.modes?.length && <span className="text-foreground/70">{p.modes.join(' ')}</span>}
{p.qsl && <span>QSL: {p.qsl}</span>}
{p.source && <span>· {p.source}</span>}
</div>
{p.operators && <p className="mt-0.5 text-[11px] text-muted-foreground truncate">{p.operators}</p>}
<div className="mt-1.5 flex items-center gap-2">
<Button variant={allWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
disabled={calls.length === 0 || allWatched}
onClick={() => addAll(calls)}
title={t('dxp.watchTip')}>
<Star className={cn('size-3', allWatched && 'fill-current text-warning')} />
{allWatched ? t('dxp.watched') : t('dxp.watch')}
</Button>
{p.link && (
<button type="button" onClick={() => BrowserOpenURL(p.link)}
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
<ExternalLink className="size-3" /> {t('dxp.open')}
</button>
)}
</div>
</article>
);
})}
</div>
</section>
{/* ── News (DX-World) ── */}
<section className="flex flex-col min-h-0 flex-[2] rounded-lg border border-border bg-card overflow-hidden">
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<span className="text-sm font-semibold">{t('dxp.news')}</span>
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'DX-World' })}</span>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
{news.length === 0 && !busy && (
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.noNews')}</p>
)}
{news.map((n, i) => (
<article key={`${n.link}-${i}`} className="rounded-md border border-border bg-muted/20 p-2">
<div className="flex items-start gap-2">
{n.image_url && (
<img src={n.image_url} alt="" className="size-12 rounded object-cover shrink-0"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
{n.tag && (
<span className="px-1 py-px rounded text-[9px] font-bold uppercase bg-primary/15 text-primary">{n.tag}</span>
)}
<button type="button" onClick={() => n.link && BrowserOpenURL(n.link)}
className="text-xs font-medium text-left hover:underline">{n.title}</button>
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground line-clamp-3">{n.excerpt}</p>
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
{n.pub_date && (
<span className="text-[10px] text-muted-foreground">
{new Date(n.pub_date).toLocaleDateString()}
</span>
)}
{(n.calls ?? []).map((c) => (
<button key={c} type="button"
disabled={watched.has(c.toUpperCase())}
onClick={() => addAll([c])}
title={watched.has(c.toUpperCase()) ? t('dxp.watched') : t('dxp.watchTip')}
className={cn('px-1 py-px rounded font-mono text-[10px] border',
watched.has(c.toUpperCase())
? 'border-warning/50 text-warning'
: 'border-border text-info hover:bg-muted')}>
{c}
</button>
))}
</div>
</div>
</div>
</article>
))}
</div>
</section>
</div>
);
}
+3 -2
View File
@@ -181,7 +181,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
try {
await WatchlistAdd(c, addContest);
setAddCall('');
flash(t(addContest ? 'wl.addedContest' : 'wl.added', { call: c }), false);
// The confirmation is the app-level notice (App.tsx, on watchlist:changed)
// — saying it twice, once per place a call can be added from, was noise.
await refresh();
} catch (e: any) { flash(String(e?.message ?? e), true); }
};
@@ -190,7 +191,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
// 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(); }
try { await WatchlistRemove(call); await refresh(); }
catch (e: any) { flash(String(e?.message ?? e), true); }
};