diff --git a/frontend/src/components/DecodesPanel.tsx b/frontend/src/components/DecodesPanel.tsx
index f26e1b8..6da2545 100644
--- a/frontend/src/components/DecodesPanel.tsx
+++ b/frontend/src/components/DecodesPanel.tsx
@@ -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 {msg};
+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 (
<>
- {m[1].toUpperCase()}
- {' ' + m[2]}
+ {parts.map((tok, i) => {
+ if (/^s+$/.test(tok)) return tok;
+ const bare = tok.replace(/[<>]/g, '').toUpperCase();
+ if (i === 0 && /^CQ$/i.test(tok)) return {tok.toUpperCase()};
+ if (me && bare === me) return {tok};
+ if (calling && bare === calling) return {tok};
+ return {tok};
+ })}
>
);
}
@@ -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 ──────────────────────────────────────────── */}
-
-
-
{t('dec.colCall')}
+
+ {t('dec.colTime')}
{t('dec.colSnr')}
{t('dec.colDt')}
{t('dec.colFreq')}
- {t('dec.colGrid')}
+ {t('dec.colBand')}
+ {t('dec.colMode')}
{t('dec.colMsg')}
- {t('dec.colFlags')}
{t('dec.colCountry')}
- L
+ {t('dec.colStatus')}
@@ -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. */}
-
+ {/* 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. */}
+
+ {hhmmssCompact(d.at)}
+
-
- {d.call}
- {replying && (
-
- {t('dec.toYou')}
+
+ {d.snr > 0 ? `+${d.snr}` : d.snr}
+
+
+ {/* Past about two seconds a station is drifting out of the
+ window, which is worth a tint rather than a shrug. */}
+ 2 ? 'text-warning' : 'text-muted-foreground/70')}>
+ {d.dt == null ? '' : d.dt.toFixed(1)}
+
+
+ {/* 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. */}
+
+ {d.audio_hz ?? ''}
+
+
+
+ {d.band && (
+
+ {d.band}
)}
-
- {d.snr > 0 ? `+${d.snr}` : d.snr}
+
+ {d.mode ?? ''}
- {/* 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. */}
- 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. */}
+
+ {renderMsg(d.msg ?? '', me, calling)}
- {/* 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. */}
-
- {d.audio_hz ?? ''}
+
+ {e?.country ?? ''}
-
- {d.grid ?? ''}
-
-
-
- {renderMsg(d.msg ?? '')}
-
-
- {/* Flags — the column this panel exists for. */}
-
+ {/* 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. */}
+
+ {e?.lotw && (
+ L
+ )}
+ {e?.worked_call && (
+
+ {t('dec.wkd')}
+
+ )}
{entity && (
-
+
{t(entity.label)}
)}
{extras.map((b) => (
{t(b.label)}
))}
-
-
-
- {e?.country ?? ''}
-
-
-
- {e?.lotw && (
- L
+ {replying && (
+
+ {t('dec.toYou')}
+
)}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index 8211766..8e1180a 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -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).",