refactor(decodes): column rules, left-aligned grid, no CQ stutter

Three things from reading it on a real screen.

Column rules. The grid alone was not enough to follow a line across: cells
now carry a right border and the row stretches, so the rules run unbroken
from the header to the bottom of the list. That is what turns rows of text
into a table.

Left-aligned. The previous pass centred the grid inside a maximum width,
which on a wide screen opened a dead margin down the left before the first
callsign - trading the hole in the middle for a bigger one at the edge. Now
it fills the width and the slack lands in the message column, which is the
one that can use it and the one bounded by rules on both sides, so it reads
as a cell rather than a gap.

"CQ CQ PE1NAO JO32" - a green CQ badge in front of a message whose own
first word is CQ. The badge is gone; the word already in the line is picked
out instead, which scans the same and stutters not at all.
This commit is contained in:
2026-08-18 06:12:21 +02:00
parent 4f77d51ffe
commit ffaf6fc869
+100 -78
View File
@@ -71,12 +71,20 @@ interface Props {
const DEFAULT_TR = 15;
// ROW is the column template, shared by the header and every row so the two can
// never drift. Fixed widths for the short fields, one flexible column for the
// message — and a hard cap on the whole grid, because on a 2500 px screen a
// free-flowing row puts the country a foot away from the callsign it belongs to
// and the eye has to travel the gap on every line.
const ROW = 'grid grid-cols-[3px_112px_56px_60px_1fr_260px_170px_28px] gap-x-3 items-center';
const ROW_MAX = 'max-w-[1500px]';
// never drift. Full width and left-aligned — an earlier pass centred it inside a
// maximum width, which on a wide screen opened a huge dead margin down the left
// before the first callsign.
//
// 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_64px_68px_minmax(280px,1fr)_230px_180px_36px] 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';
// 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
@@ -122,6 +130,23 @@ function periodStart(at: string, tr: number): number {
const hhmmss = (epochSec: number) =>
new Date(epochSec * 1000).toISOString().slice(11, 19);
// renderMsg prints the decoded line with its leading CQ picked out.
//
// There used to be a separate green "CQ" badge in front of the message, which
// 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>;
return (
<>
<span className="font-bold text-success">{m[1].toUpperCase()}</span>
<span className="text-muted-foreground">{' ' + m[2]}</span>
</>
);
}
// 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.
@@ -308,16 +333,16 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
</div>
{/* ── Column header ──────────────────────────────────────────── */}
<div className="px-4 shrink-0 border-b border-border bg-background">
<div className={cn(ROW, ROW_MAX, 'mx-auto py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground')}>
<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>{t('dec.colCall')}</span>
<span className="text-right">{t('dec.colSnr')}</span>
<span>{t('dec.colGrid')}</span>
<span>{t('dec.colMsg')}</span>
<span>{t('dec.colFlags')}</span>
<span>{t('dec.colCountry')}</span>
<span className="text-center" title="LoTW">L</span>
<span className={CELL}>{t('dec.colCall')}</span>
<span className={cn(CELL, 'justify-end')}>{t('dec.colSnr')}</span>
<span className={CELL}>{t('dec.colGrid')}</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>
</div>
</div>
@@ -334,8 +359,8 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
{groups.map((g, gi) => (
<section key={g.start}>
{/* Period header — sticky, so the slot being read is always named. */}
<header className="sticky top-0 z-10 px-4 bg-muted/95 backdrop-blur border-y border-border/60">
<div className={cn(ROW_MAX, 'mx-auto flex items-center gap-2.5 py-1.5')}>
<header className="sticky top-0 z-10 px-3 bg-muted/95 backdrop-blur border-y border-border/60">
<div className="flex items-center gap-2.5 py-1.5">
<span className={cn('font-mono text-sm font-bold tabular-nums',
gi === 0 ? 'text-primary' : 'text-foreground')}>
{hhmmss(g.start)}
@@ -355,15 +380,14 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
{/* 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="px-4 bg-primary/10">
<div className={cn(ROW_MAX, 'mx-auto flex items-center gap-2.5 py-1.5 border-l-2 border-primary pl-2')}>
<ArrowUpRight className="size-4 text-primary shrink-0" />
<span className="text-[11px] font-bold uppercase tracking-wider text-primary shrink-0">
{t('dec.tx')}
</span>
<span className="font-mono text-sm text-foreground truncate">{m.msg}</span>
{m.band && <span className="ml-auto text-xs text-muted-foreground shrink-0">{m.band}</span>}
</div>
<div key={`tx-${i}`}
className="flex items-center gap-2.5 py-1.5 pl-2 pr-3 bg-primary/10 border-l-2 border-primary">
<ArrowUpRight className="size-4 text-primary shrink-0" />
<span className="text-[11px] font-bold uppercase tracking-wider text-primary shrink-0">
{t('dec.tx')}
</span>
<span className="font-mono text-sm text-foreground truncate">{m.msg}</span>
{m.band && <span className="ml-auto text-xs text-muted-foreground shrink-0">{m.band}</span>}
</div>
))}
@@ -375,65 +399,63 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
const mine = myCall && d.call === myCall.toUpperCase();
const hot = !!entity || extras.length > 0;
return (
<div key={`${d.call}-${d.freq_hz}-${i}`}
className={cn('px-4 transition-colors', mine ? 'bg-info/10' : 'hover:bg-muted/50')}>
<button
type="button"
onClick={() => onCall(d)}
title={t('dec.callTitle', { call: d.call })}
className={cn(ROW, ROW_MAX, 'mx-auto w-full py-1.5 text-left')}
>
{/* A thin accent, kept only as 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('h-5 rounded-full', hot ? 'bg-success' : 'bg-transparent')} />
<button
key={`${d.call}-${d.freq_hz}-${i}`}
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',
mine ? 'bg-info/10' : 'hover:bg-muted/50')}
>
{/* A thin accent, kept only as 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={hot ? 'bg-success' : 'bg-transparent'} />
<span className={cn('font-mono text-sm font-bold truncate',
e?.worked_call ? 'text-muted-foreground' : 'text-foreground')}>
{d.call}
</span>
<span className={cn(CELL, 'font-mono text-sm font-bold',
e?.worked_call ? 'text-muted-foreground' : 'text-foreground')}>
<span className="truncate">{d.call}</span>
</span>
<span className={cn('font-mono text-sm text-right tabular-nums', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr}
</span>
<span className={cn(CELL, 'justify-end font-mono text-sm tabular-nums', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr}
</span>
<span className="font-mono text-xs text-muted-foreground">
{d.grid ?? ''}
</span>
<span className={cn(CELL, 'font-mono text-xs text-muted-foreground')}>
{d.grid ?? ''}
</span>
<span className="font-mono text-sm truncate min-w-0">
{d.cq && <span className="font-bold text-success mr-1.5">CQ</span>}
<span className="text-muted-foreground">{d.msg}</span>
</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="flex items-center gap-1 overflow-hidden">
{entity && (
<span className={cn('text-[11px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded 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"
style={{ borderColor: markerColour(b.marker), color: markerColour(b.marker) }}>
{t(b.label)}
</span>
))}
</span>
{/* Flags — the column this panel exists for. */}
<span className={cn(CELL, 'gap-1 overflow-hidden')}>
{entity && (
<span className={cn('text-[11px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded 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"
style={{ borderColor: markerColour(b.marker), color: markerColour(b.marker) }}>
{t(b.label)}
</span>
))}
</span>
<span className="text-xs text-muted-foreground truncate">
{e?.country ?? ''}
</span>
<span className={cn(CELL, 'text-xs text-muted-foreground')}>
<span className="truncate">{e?.country ?? ''}</span>
</span>
<span className="text-center">
{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>
)}
</span>
</button>
</div>
<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>
)}
</span>
</button>
);
})}
</section>