diff --git a/frontend/src/components/DecodesPanel.tsx b/frontend/src/components/DecodesPanel.tsx index b73f63a..f26e1b8 100644 --- a/frontend/src/components/DecodesPanel.tsx +++ b/frontend/src/components/DecodesPanel.tsx @@ -12,8 +12,8 @@ // 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 { useEffect, useMemo, useState } from 'react'; +import { Radio, Search, X, Signal, ArrowUpRight, Timer } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers'; @@ -114,11 +114,36 @@ function catsOf(e: StatusEntry | undefined): Set { const CAT_KEY = 'opslog.decodeCats'; -// 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. +// DEFAULT_TR is the slot length assumed when nothing better is known. Fifteen +// seconds is FT8, the overwhelming majority of what arrives here; a wrong guess +// only mis-groups, it never loses a decode. const DEFAULT_TR = 15; +// MODE_TR is the authority on slot length, ahead of what the sender reports. +// +// Status carries the T/R period as a whole number of seconds, so FT4 arrives as +// 7 or 8 depending on which way the sender rounded — and a period that is out by +// half a second walks across the real boundary until decodes land in the wrong +// slot entirely. The mode name gives the exact figure, and the halving sequence +// is the whole family: 15, 7.5, 3.75. +const MODE_TR: Record = { + FT8: 15, + FT4: 7.5, + FT2: 3.75, + JT65: 60, + JT9: 60, + JS8: 15, +}; + +// trSeconds picks the slot length for a decode: the mode's own figure when we +// know it, the sender's rounded one otherwise, and FT8 as the last resort. +function trSeconds(mode?: string, reported?: number): number { + const m = MODE_TR[(mode ?? '').toUpperCase()]; + if (m) return m; + if (reported && reported > 0) return reported; + return DEFAULT_TR; +} + // ROW is the column template, shared by the header and every row so the two can // 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 @@ -165,19 +190,28 @@ const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: stri { key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' }, ]; -// 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; +// periodStartMs floors an instant to its slot, in MILLISECONDS. +// +// Milliseconds, not seconds, because FT4's slot is seven and a half of them and +// FT2's three and three quarters: flooring to whole seconds put two different +// FT4 periods in one bucket and split others down the middle. +// +// The instant is the decode's OWN timestamp, never its 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 periodStartMs(atMs: number, trSec: number): number { + const p = Math.max(0.5, trSec) * 1000; + return Math.floor(atMs / p) * p; } -const hhmmss = (epochSec: number) => - new Date(epochSec * 1000).toISOString().slice(11, 19); +// periodLabel names a slot. Sub-second slots get a decimal, or two FT4 periods +// inside the same second would print the same heading twice. +function periodLabel(ms: number, trSec: number): string { + const base = new Date(ms).toISOString().slice(11, 19); + if (Number.isInteger(trSec)) return base; + const tenths = Math.round((ms % 1000) / 100); + return tenths ? `${base}.${tenths}` : base; +} // renderMsg prints the decoded line with its leading CQ picked out. // @@ -196,6 +230,52 @@ function renderMsg(msg: string) { ); } +// PeriodClock shows where the current T/R slot is, against the UTC clock. +// +// Slots are anchored to UTC, not to when OpsLog started or when the last decode +// landed, so this is computed from the wall clock and nothing else — which also +// means it keeps running when the band is dead and there is nothing to group. +// +// It is the one moving thing on the panel, and it answers the question an +// operator actually has between overs: how long until the next batch. +function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + // 100 ms: smooth enough for a bar that fills in three and three quarter + // seconds at the fastest, cheap enough to leave running. + const id = window.setInterval(() => setNow(Date.now()), 100); + return () => window.clearInterval(id); + }, []); + + const p = Math.max(0.5, trSec) * 1000; + const into = now % p; + const left = (p - into) / 1000; + const pct = (into / p) * 100; + // The last fifth of a slot is when a decode is imminent and an operator + // deciding whether to answer has run out of time to think. + const closing = left <= trSec / 5; + + return ( + + + + + + + {left.toFixed(1)} + + + {mode ? `${mode} ${trSec}s` : `${trSec}s`} + + + ); +} + // 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. @@ -230,6 +310,26 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC const statusOf = (d: Decode): StatusEntry | undefined => spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`]; + // The mode currently on the air, for the slot clock. The newest decode knows + // best; between overs the transmit state still does. + const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode; + const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined); + + // Who I am calling, and what an answer to me looks like. + // + // These are the two lines on the screen that are not about the band but about + // the QSO in progress, and they are what an operator is actually watching for + // — the rest is context. A reply is addressed to us by name: the decoded line + // opens with our callsign, sometimes bracketed when the sender compressed a + // non-standard call. + const me = (myCall ?? '').toUpperCase(); + const calling = (txState?.dx_call ?? '').toUpperCase(); + const answersMe = (msg?: string): boolean => { + if (!me || !msg) return false; + const first = msg.trim().split(/\s+/)[0]?.replace(/[<>]/g, '').toUpperCase(); + return !!first && first === me; + }; + // The choices are built from what is actually on the feed, and a selector with // nothing to choose is HIDDEN. One MSHV is one band and one mode, so those two // dropdowns were pure furniture for most operators; they appear the day a @@ -278,13 +378,17 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC const groups = useMemo(() => { const by = new Map(); for (const d of filtered) { - const k = periodStart(d.at, d.tr_period ?? DEFAULT_TR); + const at = Date.parse(d.at); + if (!Number.isFinite(at)) continue; + const k = periodStartMs(at, trSeconds(d.mode, d.tr_period)); 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); + const at = Date.parse(m.at); + if (!Number.isFinite(at)) continue; + const k = periodStartMs(at, trSeconds(m.mode, undefined)); // A transmit slot CREATES its period when there is none. // // This is the whole alternation, and getting it wrong hid the feature @@ -299,6 +403,9 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC .sort((a, b) => b[0] - a[0]) .map(([start, g]) => ({ start, + // The slot length this period was cut with, so its heading is labelled + // the same way it was grouped. + tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period), 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 @@ -334,6 +441,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC {t('dec.title')} + {/* The slot clock. Taken from the newest decode's mode, falling back to + what the transmit state reports, so it is right the moment anything + is heard and keeps running when the band goes quiet. */} + + + @@ -488,7 +601,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
- {hhmmss(g.start)} + {periodLabel(g.start, g.tr)} {t('dec.periodCount', { n: g.decodes.length })} @@ -521,8 +634,13 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC const st = e?.status && e.status !== 'worked' ? e.status : ''; const entity = st ? ENTITY_BADGE[st] : undefined; const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]); - const mine = myCall && d.call === myCall.toUpperCase(); + const mine = !!me && d.call === me; const hot = !!entity || extras.length > 0; + // Someone answering us outranks everything else on the screen. + const replying = answersMe(d.msg); + // The station we are calling, so it can be picked out of a slot + // holding thirty others. + const worked = !!calling && d.call === calling; return (