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).",