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 = { '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([]); const [news, setNews] = useState([]); const [watched, setWatched] = useState>(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 (
{/* ── Announcements (ADXO) ── */}
{t('dxp.announced')} {t('dxp.source', { name: 'NG3K ADXO' })}
{err &&
{err}
}
{shown.length === 0 && !busy && (

{t('dxp.none')}

)} {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 (
{p.callsign || '—'} {p.dxcc} {p.status === 'active' && ( {t('dxp.onAir')} )} {badge && ( {t(badge.label)} )} {p.start_date} → {p.end_date}
{!!p.bands?.length && {p.bands.join(' · ')}} {!!p.modes?.length && {p.modes.join(' ')}} {p.qsl && QSL: {p.qsl}} {p.source && · {p.source}}
{p.operators &&

{p.operators}

}
{p.link && ( )}
); })}
{/* ── News (DX-World) ── */}
{t('dxp.news')} {t('dxp.source', { name: 'DX-World' })}
{news.length === 0 && !busy && (

{t('dxp.noNews')}

)} {news.map((n, i) => { const newsCalls = n.calls ?? []; const newsAllWatched = newsCalls.length > 0 && newsCalls.every((c) => watched.has(c.toUpperCase())); return (
{n.image_url && ( { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> )}
{n.tag && ( {n.tag} )}

{n.excerpt}

{/* The callsigns are shown, not clicked: watching is the same one button as an announcement, so the gesture is learned once for the whole tab. */}
{n.pub_date && ( {new Date(n.pub_date).toLocaleDateString()} )} {(n.calls ?? []).map((c) => ( {c} ))}
{n.link && ( )}
); })}
); }