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