feat(decodes): say when the decoder's band is not the rig's

A decoding application that loses its CAT link keeps announcing the last
dial frequency it knew, and every decode after that carries a stale
band. Nothing downstream can tell: the entity verdicts, the band filter
and the FT map all believe it. Seen for real — MSHV kept saying 80 m,
and Korea read as a NEW BAND because on 80 m it would have been.

Said, not decided. Taking the rig's band instead would be wrong for
anyone decoding a second receiver on another band, and a warning costs
that setup nothing but a line to read past. Shown only while CAT is
actually connected: an empty band means there is nothing to compare
with, never that the rig is on no band. Opens 0.27.9.
This commit is contained in:
2026-09-02 10:25:17 +02:00
parent ae8e5b1bc6
commit b7c87def5b
4 changed files with 47 additions and 4 deletions
+32 -2
View File
@@ -13,7 +13,7 @@
// 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 { useEffect, useMemo, useState } from 'react';
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
@@ -95,6 +95,9 @@ interface Props {
// receiver reported last, which is a coin toss — each pane needs its own.
txStates?: Record<string, TxMsg>;
spotStatus: Record<string, StatusEntry>;
// The band the RIG is on, when CAT is connected. Only ever compared with what
// the decoder announces — see the drift warning.
rigBand?: string;
onCall: (d: Decode) => void;
myCall?: string;
// Drop every decode and transmit message held for this panel. The list is a
@@ -539,7 +542,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
}));
}
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/
@@ -597,6 +600,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// The mode currently on the air, for the slot clock. The newest decode knows
// best; between overs the transmit state still does.
// A decoder that has lost its CAT link keeps announcing the last dial
// frequency it knew, and every decode after that carries a stale band. Nothing
// downstream can tell: the entity verdicts, the band filter and the FT map all
// believe what the decoder said, and an operator ends up reading NEW BAND for a
// band they are not on. (Seen for real: MSHV lost CAT, kept saying 80 m, and
// Korea showed as a new band because on 80 m it would have been.)
//
// Said, not decided. Using the rig's band instead would be wrong for anyone
// decoding a second receiver on another band, and a warning costs that setup
// nothing but a line it can read past.
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
const bandDrift = !!rigBand && !!decoderBand
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
@@ -733,6 +751,18 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
what the transmit state reports, so it is right the moment anything
is heard and keeps running when the band goes quiet. */}
<PeriodClock trSec={liveTr} mode={liveMode} />
{bandDrift && (
<span
title={t('dec.bandDriftTip')}
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
<AlertTriangle className="size-3.5" />
{t('dec.bandDrift', {
app: driftInstance || t('dec.bandDriftApp'),
dec: decoderBand.toUpperCase(),
rig: (rigBand ?? '').toUpperCase(),
})}
</span>
)}
<span className="w-px h-5 bg-border/60 mx-1" />
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>