feat(decodes): an FT decodes tab fed by the inbound UDP link

Every FTx decode WSJT-X, JTDX or MSHV puts on the wire, grouped by T/R
period. Optional and closable, from Tools -> FT decodes; its open state is
remembered, because an operator running digital modes leaves it open for
the session rather than consulting and closing it.

The period is the point, and what separates this from the cluster list.
FT8 is a sequence of fifteen-second slots and a band is read by watching
them go by: who called CQ this slot, who answered, what I was sending while
they did. A flat list sorted by time loses exactly that, so the list is
grouped one section per period, newest first, with the operator's own
transmission shown inside the slot it went out in.

Three fields had to be carried up from the wire to make it possible:

  - the decode's OWN timestamp, which the parser read and threw away. It is
    what assigns a slot: a period's decodes arrive in one burst a second or
    two after it closes, so arrival time piles a whole period into the next
    one. Rebuilt to UTC from milliseconds-since-midnight, with the
    day-boundary case handled - a decode stamped 23:59:58 arriving at
    00:00:01 would otherwise be dated a day ahead and sit at the top of the
    list for the rest of the session.
  - the decoded line itself. The exchange is what says where a station is in
    a QSO, and no set of extracted fields reads like "R-09" does.
  - tx_message and transmitting from Status, which nothing parsed before.
    Recorded once per message rather than on every Status, which repeats it
    about once a second for the whole over.

Also picked up on the way: is_new, low_confidence, off_air, the operator's
own call and grid, and the T/R period itself - better authority on slot
length than the mode name, which says nothing about a custom period. The
Status tail is read defensively: those fields were appended over successive
schema versions and JTDX and MSHV each stop at their own point, so a short
packet is normal and keeps whatever parsed.

Status flags come from ClusterSpotStatuses, the resolver the cluster list
and band map already use, filling the same cache. One verdict per call:
"new band" in this panel and plain worked in the cluster two seconds later
would be worse than no flag at all. Clicking a call goes through the same
handler as a cluster spot, so answering a station is one gesture whether it
came off telnet or off the receiver.

Filters: CQ only, new-anything only, band, mode, continent, an SNR floor
and a free search. The band, mode and continent choices are built from what
is actually on the feed - offering 160 m to a station whose receivers are
all on 6 m is noise.

Decodes are held in the frontend and pruned to a rolling half hour: they
are a live view, not data, nothing outside the panel reads them, and a
night of FT8 on 20 m would otherwise grow a list no filter can rescue.
Arrivals are staged on a 300 ms timer so a period landing as fifty packets
costs one status lookup and one render.
This commit is contained in:
2026-08-18 05:51:34 +02:00
parent 9599c3e0b9
commit a197d124dc
7 changed files with 791 additions and 13 deletions
+380
View File
@@ -0,0 +1,380 @@
// DecodesPanel — every FTx decode from the inbound UDP feed, grouped by T/R period.
//
// The point of this panel, and what makes it different from the cluster list, is
// the PERIOD. FT8 is a sequence of fifteen-second slots, and an operator reads a
// band by watching them go by: who called CQ this period, who answered, what I
// was sending while they did. A flat list sorted by time loses exactly that —
// the slot boundaries are where the information is.
//
// So the list is grouped, one section per period, newest first, with the
// operator's own transmission shown INSIDE the period it went out in.
//
// Status flags (new entity / band / mode / slot / grid / prefix / POTA / county)
// come from the same resolver the cluster uses, so a call means the same thing in
// both panels rather than being judged twice by two rules.
import { useMemo, useState } from 'react';
import { Radio, Search, X, Signal, ArrowUpRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { activeMarkers, markerColour } from '@/lib/spotMarkers';
export type Decode = {
call: string;
grid?: string;
snr: number;
freq_hz: number;
dial_hz?: number;
band?: string;
mode?: string;
msg?: string;
cq?: boolean;
at: string;
tr_period?: number;
off_air?: boolean;
source?: string;
};
export type TxMsg = {
msg: string;
de_call?: string;
mode?: string;
band?: string;
freq_hz?: number;
at: string;
};
type StatusEntry = {
status?: string;
country?: string;
continent?: string;
worked_call?: boolean;
worked_slot?: boolean;
new_county?: boolean;
new_pota?: boolean;
new_pfx?: boolean;
new_grid?: boolean;
lotw?: boolean;
};
interface Props {
decodes: Decode[];
txMsgs: TxMsg[];
spotStatus: Record<string, StatusEntry>;
onCall: (d: Decode) => void;
myCall?: string;
}
// DEFAULT_TR is the slot length assumed when the sender never told us its T/R
// period. Fifteen seconds is FT8, which is the overwhelming majority of what
// arrives here; a wrong guess only mis-groups, it never loses a decode.
const DEFAULT_TR = 15;
// Status → the pill's look. The vocabulary is the cluster's, deliberately: the
// same fact must not be amber in one panel and green in the next.
const STATUS_STYLE: Record<string, string> = {
'new': 'bg-success text-success-foreground',
'new-band': 'bg-warning text-warning-foreground',
'new-mode': 'bg-info text-info-foreground',
'new-slot': 'bg-caution text-caution-foreground',
'new-call': 'bg-muted text-muted-foreground',
};
const STATUS_LABEL: Record<string, string> = {
'new': 'dec.stNew',
'new-band': 'dec.stBand',
'new-mode': 'dec.stMode',
'new-slot': 'dec.stSlot',
'new-call': 'dec.stCall',
};
// periodStart floors a decode's own timestamp to its slot. Its OWN timestamp,
// not arrival: a period's decodes reach us in one burst a second or two after
// the slot closes, so arrival time would pile a whole period into the next one.
function periodStart(at: string, tr: number): number {
const ms = Date.parse(at);
if (!Number.isFinite(ms)) return 0;
const s = Math.floor(ms / 1000);
const p = tr > 0 ? tr : DEFAULT_TR;
return Math.floor(s / p) * p;
}
const hhmmss = (epochSec: number) =>
new Date(epochSec * 1000).toISOString().slice(11, 19);
// snrTone colours the report by readability rather than by a gradient: an
// operator scanning a period wants "workable" to jump out, and -24 dB is not
// three shades worse than -6, it is a different decision.
function snrTone(snr: number): string {
if (snr >= -5) return 'text-success';
if (snr >= -15) return 'text-foreground';
return 'text-muted-foreground';
}
export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Props) {
const { t } = useI18n();
const [cqOnly, setCqOnly] = useState(false);
const [newOnly, setNewOnly] = useState(false);
const [bandSel, setBandSel] = useState('');
const [modeSel, setModeSel] = useState('');
const [contSel, setContSel] = useState('');
const [minSnr, setMinSnr] = useState('');
const [search, setSearch] = useState('');
const statusOf = (d: Decode): StatusEntry | undefined =>
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
// The band / mode / continent choices are built from what is actually on the
// feed, not from a fixed list: a selector offering 160 m to an operator whose
// receivers are all on 6 m is noise.
const { bands, modes, conts } = useMemo(() => {
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>();
for (const d of decodes) {
if (d.band) b.add(d.band);
if (d.mode) m.add(d.mode);
const ct = statusOf(d)?.continent;
if (ct) c.add(ct);
}
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort() };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus]);
const isNewSomething = (e: StatusEntry | undefined): boolean =>
!!e && ((!!e.status && e.status !== 'worked') || !!e.new_county || !!e.new_pota || !!e.new_pfx || !!e.new_grid);
const filtered = useMemo(() => {
const q = search.trim().toUpperCase();
const floor = minSnr.trim() === '' ? null : parseInt(minSnr, 10);
return decodes.filter((d) => {
if (cqOnly && !d.cq) return false;
if (bandSel && d.band !== bandSel) return false;
if (modeSel && d.mode !== modeSel) return false;
if (floor != null && Number.isFinite(floor) && d.snr < floor) return false;
const e = statusOf(d);
if (newOnly && !isNewSomething(e)) return false;
if (contSel && e?.continent !== contSel) return false;
if (q && !(d.call.includes(q) || (d.grid ?? '').toUpperCase().includes(q) || (d.msg ?? '').toUpperCase().includes(q))) return false;
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus, cqOnly, newOnly, bandSel, modeSel, contSel, minSnr, search]);
// Group into periods, newest first, and drop the operator's transmissions into
// the slot they went out in.
const groups = useMemo(() => {
const by = new Map<number, { decodes: Decode[]; tx: TxMsg[] }>();
for (const d of filtered) {
const k = periodStart(d.at, d.tr_period ?? DEFAULT_TR);
let g = by.get(k);
if (!g) { g = { decodes: [], tx: [] }; by.set(k, g); }
g.decodes.push(d);
}
for (const m of txMsgs) {
const k = periodStart(m.at, DEFAULT_TR);
// Only into a period the list is actually showing — a transmission alone
// in an empty slot would be a section with nothing to read.
const g = by.get(k);
if (g) g.tx.push(m);
}
return [...by.entries()]
.sort((a, b) => b[0] - a[0])
.map(([start, g]) => ({
start,
tx: g.tx,
// Strongest first inside a period: the eye should land on what is
// workable, and time within a slot means nothing — they were all
// transmitting simultaneously.
decodes: g.decodes.sort((x, y) => y.snr - x.snr),
}));
}, [filtered, txMsgs]);
const resetFilters = () => {
setCqOnly(false); setNewOnly(false); setBandSel(''); setModeSel('');
setContSel(''); setMinSnr(''); setSearch('');
};
const anyFilter = cqOnly || newOnly || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim();
const sel = 'h-7 rounded-md border border-border bg-background px-2 text-xs';
const chip = (on: boolean) => cn(
'h-7 px-2.5 rounded-full border text-xs font-medium transition-colors',
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted',
);
return (
<div className="flex flex-col h-full min-h-0">
{/* ── Filter bar ─────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-1.5 px-3 py-2 border-b border-border bg-muted/30 shrink-0">
<Radio className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mr-1">
{t('dec.title')}
</span>
<button type="button" className={chip(cqOnly)} onClick={() => setCqOnly((v) => !v)}>
{t('dec.cqOnly')}
</button>
<button type="button" className={chip(newOnly)} onClick={() => setNewOnly((v) => !v)}>
{t('dec.newOnly')}
</button>
<select className={sel} value={bandSel} onChange={(e) => setBandSel(e.target.value)}>
<option value="">{t('dec.allBands')}</option>
{bands.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
<select className={sel} value={modeSel} onChange={(e) => setModeSel(e.target.value)}>
<option value="">{t('dec.allModes')}</option>
{modes.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
<select className={sel} value={contSel} onChange={(e) => setContSel(e.target.value)}>
<option value="">{t('dec.allConts')}</option>
{conts.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<label className="flex items-center gap-1 text-xs text-muted-foreground">
<Signal className="size-3.5" />
<input
type="number" placeholder="dB" value={minSnr}
onChange={(e) => setMinSnr(e.target.value)}
className="h-7 w-16 rounded-md border border-border bg-background px-1.5 text-xs tabular-nums"
title={t('dec.minSnrTitle')}
/>
</label>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<input
value={search} onChange={(e) => setSearch(e.target.value)}
placeholder={t('dec.searchPh')}
className="h-7 w-44 rounded-md border border-border bg-background pl-7 pr-2 text-xs"
/>
</div>
{anyFilter && (
<button type="button" onClick={resetFilters}
className="h-7 px-2 rounded-md text-xs text-muted-foreground hover:bg-muted inline-flex items-center gap-1">
<X className="size-3" /> {t('dec.clearFilters')}
</button>
)}
<span className="flex-1" />
<span className="text-[11px] text-muted-foreground tabular-nums">
{t('dec.count', { shown: filtered.length, total: decodes.length })}
</span>
</div>
{/* ── Periods ────────────────────────────────────────────────── */}
<div className="flex-1 min-h-0 overflow-y-auto">
{groups.length === 0 && (
<div className="h-full flex items-center justify-center px-6 text-center">
<p className="text-sm text-muted-foreground max-w-md">
{decodes.length === 0 ? t('dec.empty') : t('dec.emptyFiltered')}
</p>
</div>
)}
{groups.map((g, gi) => (
<section key={g.start} className="border-b border-border/60">
{/* Period header — sticky so the slot you are reading is always named. */}
<header className={cn(
'sticky top-0 z-10 flex items-center gap-2 px-3 py-1 backdrop-blur',
'bg-muted/80 border-b border-border/60',
)}>
<span className={cn('font-mono text-xs font-bold tabular-nums',
gi === 0 ? 'text-primary' : 'text-foreground')}>
{hhmmss(g.start)}
</span>
<span className="text-[11px] text-muted-foreground">
{t('dec.periodCount', { n: g.decodes.length })}
</span>
{gi === 0 && (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-success">
<span className="size-1.5 rounded-full bg-success animate-pulse" />
{t('dec.live')}
</span>
)}
</header>
{/* The operator's own transmission, at the top of its slot: it is what
the stations below were answering (or ignoring). */}
{g.tx.map((m, i) => (
<div key={`tx-${i}`}
className="flex items-center gap-2 px-3 py-1 bg-primary/10 border-l-2 border-primary">
<ArrowUpRight className="size-3.5 text-primary shrink-0" />
<span className="text-[10px] font-bold uppercase tracking-wider text-primary shrink-0">
{t('dec.tx')}
</span>
<span className="font-mono text-xs text-foreground truncate">{m.msg}</span>
{m.band && <span className="ml-auto text-[10px] text-muted-foreground shrink-0">{m.band}</span>}
</div>
))}
{g.decodes.map((d, i) => {
const e = statusOf(d);
const st = e?.status && e.status !== 'worked' ? e.status : '';
const markers = activeMarkers(e as any);
const mine = myCall && d.call === myCall.toUpperCase();
return (
<button
key={`${d.call}-${d.freq_hz}-${i}`}
type="button"
onClick={() => onCall(d)}
title={t('dec.callTitle', { call: d.call })}
className={cn(
'w-full flex items-center gap-2 px-3 py-1 text-left transition-colors',
'hover:bg-muted/60 active:scale-[0.998]',
mine && 'bg-info/10',
)}
>
{/* Marker rail: one segment per orthogonal flag, same colours as
the cluster list and the band map. */}
<span className="flex flex-col w-1 h-5 rounded-full overflow-hidden shrink-0">
{markers.length === 0
? <span className="flex-1 bg-transparent" />
: markers.map((m) => (
<span key={m.key} className="flex-1" style={{ background: markerColour(m.key) }} />
))}
</span>
<span className={cn('font-mono text-xs font-bold w-[92px] shrink-0 truncate',
e?.worked_call ? 'text-muted-foreground' : 'text-foreground')}>
{d.call}
</span>
<span className={cn('font-mono text-xs w-11 text-right tabular-nums shrink-0', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr}
</span>
<span className="font-mono text-[11px] text-muted-foreground w-14 shrink-0">
{d.grid ?? ''}
</span>
{d.cq && (
<span className="text-[10px] font-bold text-success shrink-0">CQ</span>
)}
<span className="font-mono text-[11px] text-muted-foreground truncate flex-1 min-w-0">
{d.msg}
</span>
{e?.country && (
<span className="text-[11px] text-muted-foreground truncate max-w-[130px] shrink-0 hidden xl:block">
{e.country}
</span>
)}
{e?.lotw && (
<span className="text-[9px] font-bold px-1 rounded bg-info-muted text-info-muted-foreground shrink-0"
title="LoTW">L</span>
)}
{st && (
<span className={cn('text-[9px] font-bold uppercase tracking-wide px-1.5 py-px rounded shrink-0',
STATUS_STYLE[st] ?? 'bg-muted text-muted-foreground')}>
{t(STATUS_LABEL[st] ?? 'dec.stNew')}
</span>
)}
</button>
);
})}
</section>
))}
</div>
</div>
);
}