refactor(decodes): read the list the way DXHunter's reads

Compared side by side, DXHunter's decode list is plainly easier to read,
and three things account for the gap.

The Call column repeated what the message already said. Every FT8 line
opens with the callsigns — "CQ A93MO LL56", "PG5FRL JH3CUL PM74" — so a
column in front of it printed the same token twice and pushed the line
everyone actually reads to the middle of the row. It is gone; the message
is the identity, and the tokens worth finding are picked out inside it:
green for CQ and for our own call when someone answers, red for the station
we are calling. The grid went with it, being the message's last token.

Time is on every row now, compact, no separators. A decode belongs to a
period and the section heading names it — but once a slot runs past a
screenful the heading is somewhere above, and an instant you cannot read
where the decode is is not an instant you have.

Band and mode became their own columns, band as a chip, and the status
flags moved into one column at the right edge with LoTW and a "worked"
marker beside them. Rows are tighter: 13 px for the message and the report,
11 px for the figures, 10 px for the badges, and the column rules run
through the lot.
This commit is contained in:
2026-08-18 10:14:40 +02:00
parent 8c68a7d711
commit 40ecfb9fa9
2 changed files with 92 additions and 69 deletions
+90 -67
View File
@@ -152,13 +152,13 @@ function trSeconds(mode?: string, reported?: number): number {
// Message is the one elastic column, with a floor so it does not collapse; the
// slack lands there rather than between two fixed columns, which is what read as
// a hole in the middle of every line.
const ROW = 'grid grid-cols-[3px_120px_58px_54px_62px_64px_minmax(240px,1fr)_222px_160px_34px] items-stretch';
const ROW = 'grid grid-cols-[64px_50px_44px_56px_50px_44px_minmax(240px,1fr)_140px_186px] items-stretch';
// CELL draws the column rule. items-stretch above plus a right border here is
// what makes the lines run unbroken from the header to the bottom of the list —
// the thing that turns rows of text into a table you can follow across.
const CELL = 'flex items-center min-w-0 px-3 border-r border-border/40';
const CELL_LAST = 'flex items-center justify-center min-w-0 px-2';
const CELL = 'flex items-center min-w-0 px-2 border-r border-border/30';
const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
// The "new" badges. Every one of these is a REASON TO CALL, which is why they
// get a column of their own rather than a coloured edge: a stripe says something
@@ -219,13 +219,22 @@ function periodLabel(ms: number, trSec: number): string {
// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying
// the same thing twice. Highlighting the word already in the line keeps the
// scannability and drops the stutter.
function renderMsg(msg: string) {
const m = /^(CQ(?:\s+DX)?)\s+(.*)$/i.exec(msg);
if (!m) return <span className="text-muted-foreground">{msg}</span>;
function renderMsg(msg: string, me: string, calling: string) {
if (!msg) return null;
// Split on whitespace and colour the tokens that matter, rather than the
// whole line: an operator scanning a slot is looking for their own call in
// the first position (someone answering) and for the station being called.
const parts = msg.split(/(s+)/);
return (
<>
<span className="font-bold text-success">{m[1].toUpperCase()}</span>
<span className="text-muted-foreground">{' ' + m[2]}</span>
{parts.map((tok, i) => {
if (/^s+$/.test(tok)) return tok;
const bare = tok.replace(/[<>]/g, '').toUpperCase();
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
if (calling && bare === calling) return <span key={i} className="font-bold text-danger">{tok}</span>;
return <span key={i} className="text-foreground/85">{tok}</span>;
})}
</>
);
}
@@ -276,6 +285,17 @@ function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
);
}
// hhmmssCompact is the per-row time, HHMMSS with no separators.
//
// Every row carries it. A decode belongs to a period, and the section heading
// names that period — but once a slot runs past a screenful, the heading is
// somewhere above and the instant is no longer readable where the decode is.
function hhmmssCompact(at: string): string {
const ms = Date.parse(at);
if (!Number.isFinite(ms)) return '';
return new Date(ms).toISOString().slice(11, 19).replace(/:/g, '');
}
// snrTone colours the report by readability rather than as 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.
@@ -570,17 +590,16 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
{/* ── Column header ──────────────────────────────────────────── */}
<div className="shrink-0 border-b border-border bg-background">
<div className={cn(ROW, 'h-8 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground')}>
<span />
<span className={CELL}>{t('dec.colCall')}</span>
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}>
<span className={CELL}>{t('dec.colTime')}</span>
<span className={cn(CELL, 'justify-end')}>{t('dec.colSnr')}</span>
<span className={cn(CELL, 'justify-end')} title={t('dec.colDtTitle')}>{t('dec.colDt')}</span>
<span className={cn(CELL, 'justify-end')} title={t('dec.colFreqTitle')}>{t('dec.colFreq')}</span>
<span className={CELL}>{t('dec.colGrid')}</span>
<span className={CELL}>{t('dec.colBand')}</span>
<span className={CELL}>{t('dec.colMode')}</span>
<span className={CELL}>{t('dec.colMsg')}</span>
<span className={CELL}>{t('dec.colFlags')}</span>
<span className={CELL}>{t('dec.colCountry')}</span>
<span className={CELL_LAST} title="LoTW">L</span>
<span className={CELL_LAST}>{t('dec.colStatus')}</span>
</div>
</div>
@@ -647,84 +666,88 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
type="button"
onClick={() => onCall(d)}
title={t('dec.callTitle', { call: d.call })}
className={cn(ROW, 'w-full min-h-[26px] text-left border-b border-border/25 transition-colors',
className={cn(ROW, 'w-full text-left border-b border-border/20 transition-colors',
replying ? 'bg-success/20 hover:bg-success/25'
: worked ? 'bg-danger/10 hover:bg-danger/15'
: worked ? 'bg-danger/15 hover:bg-danger/20'
: mine ? 'bg-info/10'
: 'hover:bg-muted/50')}
>
{/* A thin accent. Green while someone is answering us, red on
the station we are calling, and otherwise only a hint that
the row carries a reason — the reasons themselves are
spelled out in the flags column, where they can be read. */}
<span className={cn(
replying ? 'bg-success' : worked ? 'bg-danger' : hot ? 'bg-success/60' : 'bg-transparent',
)} />
{/* Time on EVERY row, compact. A decode belongs to a slot, but
it also has an instant, and reading one off a section
heading three screens up is not reading it. */}
<span className={cn(CELL, 'font-mono text-[11px] tabular-nums',
replying ? 'text-success' : worked ? 'text-danger' : 'text-muted-foreground/70')}>
{hhmmssCompact(d.at)}
</span>
<span className={cn(CELL, 'font-mono text-sm font-bold',
worked ? 'text-danger'
: replying ? 'text-success'
: e?.worked_call ? 'text-muted-foreground'
: 'text-foreground')}>
<span className="truncate">{d.call}</span>
{replying && (
<span className="ml-1.5 text-[10px] font-bold uppercase tracking-wider text-success shrink-0">
{t('dec.toYou')}
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr}
</span>
{/* Past about two seconds a station is drifting out of the
window, which is worth a tint rather than a shrug. */}
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums',
Math.abs(d.dt ?? 0) > 2 ? 'text-warning' : 'text-muted-foreground/70')}>
{d.dt == null ? '' : d.dt.toFixed(1)}
</span>
{/* The audio offset in the passband — what WSJT-X calls Freq.
Not the RF frequency, which is the same for every station
here and would say nothing. */}
<span className={cn(CELL, 'justify-end font-mono text-[11px] text-muted-foreground/70 tabular-nums')}>
{d.audio_hz ?? ''}
</span>
<span className={CELL}>
{d.band && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-info-muted text-info-muted-foreground">
{d.band}
</span>
)}
</span>
<span className={cn(CELL, 'justify-end font-mono text-sm tabular-nums', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr}
<span className={cn(CELL, 'font-mono text-[10px] text-muted-foreground/70')}>
{d.mode ?? ''}
</span>
{/* DT — how far into the slot the transmission started. Past
about ±2 s a station is drifting out of the window, so the
figure earns a warning tint rather than staying grey. */}
<span className={cn(CELL, 'justify-end font-mono text-xs tabular-nums',
Math.abs(d.dt ?? 0) > 2 ? 'text-warning' : 'text-muted-foreground')}>
{d.dt == null ? '' : d.dt.toFixed(1)}
{/* The message carries the callsign already — which is why
there is no column repeating it. */}
<span className={cn(CELL, 'font-mono text-[13px]')}>
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span>
</span>
{/* The audio offset inside the passband, which is what WSJT-X
calls Freq — not the RF frequency, which is the same for
every station in the list and would say nothing. */}
<span className={cn(CELL, 'justify-end font-mono text-xs text-muted-foreground tabular-nums')}>
{d.audio_hz ?? ''}
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
<span className="truncate">{e?.country ?? ''}</span>
</span>
<span className={cn(CELL, 'font-mono text-xs text-muted-foreground')}>
{d.grid ?? ''}
</span>
<span className={cn(CELL, 'font-mono text-sm')}>
<span className="truncate">{renderMsg(d.msg ?? '')}</span>
</span>
{/* Flags — the column this panel exists for. */}
<span className={cn(CELL, 'gap-1 overflow-hidden')}>
{/* Status: everything worth acting on, in one place at the
right edge, shortest first so the eye can scan the column
rather than read it. */}
<span className={CELL_LAST}>
{e?.lotw && (
<span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</span>
)}
{e?.worked_call && (
<span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
{t('dec.wkd')}
</span>
)}
{entity && (
<span className={cn('text-[11px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded shrink-0', entity.cls)}>
<span className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', entity.cls)}>
{t(entity.label)}
</span>
)}
{extras.map((b) => (
<span key={b.key as string}
className="text-[11px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border bg-transparent shrink-0"
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
style={{ borderColor: markerColour(b.marker), color: markerColour(b.marker) }}>
{t(b.label)}
</span>
))}
</span>
<span className={cn(CELL, 'text-xs text-muted-foreground')}>
<span className="truncate">{e?.country ?? ''}</span>
</span>
<span className={CELL_LAST}>
{e?.lotw && (
<span className="text-[11px] font-bold px-1.5 py-0.5 rounded bg-info-muted text-info-muted-foreground"
title="LoTW">L</span>
{replying && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-success text-success-foreground shrink-0">
{t('dec.toYou')}
</span>
)}
</span>
</button>
+2 -2
View File
@@ -136,7 +136,7 @@ const en: Dict = {
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Audio offset inside the passband (Hz)',
'dec.txNow': 'Transmitting', 'dec.txIdle': 'Transmit', 'dec.working': 'calling', 'dec.toYou': 'to you',
'dec.txUnknown': 'this application does not report its transmit text',
'dec.colCall': 'Call', 'dec.colSnr': 'SNR', 'dec.colGrid': 'Grid', 'dec.colMsg': 'Message', 'dec.colFlags': 'New', 'dec.colCountry': 'Country',
'dec.colTime': 'Time', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.colCountry': 'Country', 'dec.colBand': 'Band', 'dec.colMode': 'Mode', 'dec.colStatus': 'Status', 'dec.wkd': 'Wkd',
'dec.bgPota': 'POTA', 'dec.bgGrid': 'GRID', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY',
'dec.stNew': 'NEW', 'dec.stBand': 'BAND', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'CALL',
'dec.empty': 'Nothing decoded yet. Decodes arrive from WSJT-X, JTDX or MSHV over the inbound UDP link (Settings -> UDP).',
@@ -609,7 +609,7 @@ const fr: Dict = {
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Decalage audio dans la bande passante (Hz)',
'dec.txNow': 'En emission', 'dec.txIdle': 'Emission', 'dec.working': 'appelle', 'dec.toYou': 'pour toi',
'dec.txUnknown': 'ce logiciel ne communique pas son texte d emission',
'dec.colCall': 'Indicatif', 'dec.colSnr': 'SNR', 'dec.colGrid': 'Locator', 'dec.colMsg': 'Message', 'dec.colFlags': 'Nouveau', 'dec.colCountry': 'Pays',
'dec.colTime': 'Heure', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.colCountry': 'Pays', 'dec.colBand': 'Bande', 'dec.colMode': 'Mode', 'dec.colStatus': 'Statut', 'dec.wkd': 'Fait',
'dec.bgPota': 'POTA', 'dec.bgGrid': 'LOC', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY',
'dec.stNew': 'NOUV', 'dec.stBand': 'BANDE', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'IND',
'dec.empty': "Aucun decode pour l'instant. Ils arrivent de WSJT-X, JTDX ou MSHV par le lien UDP entrant (Reglages -> UDP).",