fix(autocall): the QSO in progress outranks the ladder
Six faults from an evening on 60 m, all in the same family: the engine judging a station by what the log wants from it and forgetting what is already under way. - An exchange was abandoned mid-QSO. The reply lands in the same period the ladder is re-read, and that period was judged before the reply was taken into account, so a better-ranked caller took the slot from a station that had just come back to us. The answer is settled first now, and our own report counts as being inside the exchange too — which also protects a QSO the operator started by hand. - A station just picked started with misses against it. Its transmit slot was unknown until a second decode, and with the parity unknown every period counted, including the one spent transmitting to it. - The freed slot after "it is working somebody else" was thrown away: the period's decodes are in hand, so the next station is picked from them rather than fifteen seconds later. Never mid-over. - Auto-call is never armed from a stored setting — not at launch, not on a profile switch. It is the one feature that puts the station on the air by itself and OpsLog starts with Windows. - It says what it is waiting for: a wanted station in a QSO with somebody else now shows beside the Auto button instead of looking idle. - Switching profile left the previous logbook's verdicts on screen. The worked-index, chase-new and the frontend's cached verdicts are dropped when the logbook changes. FT decodes: distance column, a message addressed to you set whole in green (the station you are calling keeps a tint — most of what it sends goes to other people), badge order L / Wkd / WL, list cleared when the RIG changes band. Rotor: new world-map compass from EC1KD's design, with the Ultrabeam boom and second lobe restored and the compact form preserved; the classic dial is kept and Settings → Rotator chooses between them. Stop no longer flickers on a rotor standing still — movement was inferred from a degree, less than the jitter a controller reports at rest.
This commit is contained in:
@@ -17,6 +17,8 @@ import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, B
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { chaseAllows } from '@/lib/spotDisplay';
|
||||
import { pathBetween } from '@/lib/maidenhead';
|
||||
import { distanceValue, distanceUnit, subscribeDistanceUnit } from '@/lib/units';
|
||||
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
|
||||
@@ -105,6 +107,9 @@ interface Props {
|
||||
// point the panels at it. Absent, a click falls back to onCall.
|
||||
onSelect?: (d: Decode) => void;
|
||||
myCall?: string;
|
||||
// The station's own square: distance is measured from it, and without one the
|
||||
// column stays empty rather than guessing.
|
||||
myGrid?: string;
|
||||
// Drop every decode and transmit message held for this panel. The list is a
|
||||
// live view, not data — clearing it costs nothing but the seconds until the
|
||||
// next period lands.
|
||||
@@ -121,7 +126,7 @@ interface Props {
|
||||
autoCallOn?: boolean;
|
||||
onToggleAutoCall?: () => void;
|
||||
// The engine's own account of what it is doing, straight from the backend.
|
||||
autoCall?: { target: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
|
||||
autoCall?: { target: string; waiting: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
|
||||
// The chase list, here as well as in Preferences: naming the station you are
|
||||
// waiting for is done WHILE watching the band, not in a settings tree.
|
||||
autoCallOnly?: string;
|
||||
@@ -271,7 +276,7 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
|
||||
// One declaration per column, in display order: the header, the widths and the
|
||||
// resize handles all read from this, so a column cannot be resized in the header
|
||||
// and stay the old width in the body.
|
||||
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
|
||||
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'dist' | 'state' | 'country' | 'status';
|
||||
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
||||
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and
|
||||
@@ -292,6 +297,11 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
|
||||
// For the WAS chasers: the badge carries the two letters, the name spells
|
||||
// them out — "SD" alone is a quiz for a European.
|
||||
// Next to the square it is computed from. A four-character grid is a square
|
||||
// tens of kilometres wide, so this is rounded to whole units and never
|
||||
// pretends to more: it answers "is that station across the pond or across the
|
||||
// valley", which is what decides whether the report is worth anything.
|
||||
{ key: 'dist', tkey: 'dec.colDist', def: 70, min: 46 },
|
||||
{ key: 'state', tkey: 'dec.colState', def: 120, min: 56 },
|
||||
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
|
||||
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
|
||||
@@ -446,16 +456,31 @@ 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, me: string, calling: string) {
|
||||
function renderMsg(msg: string, me: string, calling: string, toMe: boolean) {
|
||||
if (!msg) return null;
|
||||
// THE WHOLE LINE, when it is addressed to YOU.
|
||||
//
|
||||
// Token colouring is for reading the band — it picks your call out of a wall
|
||||
// of other people's traffic. A message sent TO you is a different question:
|
||||
// not "is my call in there somewhere" but "what did he just send me", read
|
||||
// from the far side of the shack, so the whole line carries the colour.
|
||||
//
|
||||
// Only that case. The station you are calling also transmits to everybody
|
||||
// else — "LA8WRA LA1RAU/P +00" is your target reporting to another caller —
|
||||
// and setting those lines in the same strong colour said you were in a QSO
|
||||
// you were not in. Its callsign is picked out by the token pass below, which
|
||||
// is what "he is on the air, working somebody else" should look like.
|
||||
if (toMe) {
|
||||
return <span className="font-bold text-success">{msg}</span>;
|
||||
}
|
||||
// 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+)/);
|
||||
const parts = msg.split(/(\s+)/);
|
||||
return (
|
||||
<>
|
||||
{parts.map((tok, i) => {
|
||||
if (/^s+$/.test(tok)) return tok;
|
||||
if (/^\s+$/.test(tok)) return tok;
|
||||
const bare = tok.replace(/[<>]/g, '').toUpperCase();
|
||||
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
|
||||
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
|
||||
@@ -583,7 +608,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: 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/
|
||||
@@ -674,6 +699,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
// opens with our callsign, sometimes bracketed when the sender compressed a
|
||||
// non-standard call.
|
||||
const me = (myCall ?? '').toUpperCase();
|
||||
const [, bumpUnit] = useState(0);
|
||||
useEffect(() => subscribeDistanceUnit(() => bumpUnit((n) => n + 1)), []);
|
||||
const calling = (txState?.dx_call ?? '').toUpperCase();
|
||||
const answersMe = (msg?: string): boolean => {
|
||||
if (!me || !msg) return false;
|
||||
@@ -988,6 +1015,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{/* Wanted, decoded, and in a QSO with somebody else. Holding fire
|
||||
looks exactly like having nothing to do, and the operator had no
|
||||
way to tell them apart. */}
|
||||
{autoCallOn && !autoCall?.target && autoCall?.waiting && (
|
||||
<span className="font-mono text-xs animate-pulse" title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
|
||||
{autoCall.waiting} ⏳
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* The chase list. Raw text while typing, committed on blur or Enter:
|
||||
@@ -1145,9 +1180,9 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
i < cols.length - 1 && 'border-r border-border/30',
|
||||
// The three numeric columns label their own right edge, where the
|
||||
// figures are.
|
||||
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
|
||||
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end')}
|
||||
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
|
||||
<span className="truncate">{t(c.tkey)}</span>
|
||||
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
|
||||
<ColResizer
|
||||
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
||||
onReset={() => setColWidth(c.key, c.def)}
|
||||
@@ -1227,7 +1262,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
title={t('dec.callTitle', { call: d.call })}
|
||||
style={{ gridTemplateColumns: template, width: tableW }}
|
||||
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
|
||||
replying ? 'bg-success/20 hover:bg-success/25'
|
||||
replying ? 'bg-success/25 hover:bg-success/30 border-l-2 border-l-success'
|
||||
// The station being called: a tint saying "he is on the
|
||||
// air", not the QSO treatment above — most of what he
|
||||
// sends is to other people.
|
||||
: worked ? 'bg-danger/15 hover:bg-danger/20'
|
||||
: mine ? 'bg-info/10'
|
||||
: 'hover:bg-muted/50')}
|
||||
@@ -1282,7 +1320,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
{/* The message carries the callsign already — which is why
|
||||
there is no column repeating it. */}
|
||||
<span className={cn(CELL, 'font-mono text-[13px]')}>
|
||||
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span>
|
||||
<span className="truncate">{renderMsg(d.msg ?? '', me, calling, replying)}</span>
|
||||
</span>
|
||||
|
||||
{/* The decode's own grid first, the remembered one as the
|
||||
@@ -1295,6 +1333,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
<span className="truncate">{d.grid || e?.grid || ''}</span>
|
||||
</span>
|
||||
|
||||
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums text-muted-foreground/80')}>
|
||||
{(() => {
|
||||
const g = d.grid || e?.grid || '';
|
||||
const path = myGrid && g ? pathBetween(myGrid, g) : null;
|
||||
return path ? distanceValue(path.distanceShort).toLocaleString() : '';
|
||||
})()}
|
||||
</span>
|
||||
|
||||
<span className={cn(CELL, 'gap-1.5')}>
|
||||
{e?.state && (
|
||||
<>
|
||||
@@ -1315,20 +1361,20 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
{e?.lotw && (
|
||||
<span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</span>
|
||||
)}
|
||||
{/* After the L, which is one letter and always in the same
|
||||
place: a badge in front of it moved the whole column
|
||||
sideways from row to row. */}
|
||||
{e?.worked_call && (
|
||||
<span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
|
||||
{t('dec.wkd')}
|
||||
</span>
|
||||
)}
|
||||
{/* Plainest first: the LoTW letter, the worked note, then
|
||||
the coloured badges — the watch list leading them, being
|
||||
the operator's own answer rather than the log's. */}
|
||||
{isWatched(d.call, watchlist) && (
|
||||
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white"
|
||||
style={{ background: '#f472b6' }} title={t('dec.wlTip')}>
|
||||
{t('dec.wl')}
|
||||
</span>
|
||||
)}
|
||||
{e?.worked_call && (
|
||||
<span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
|
||||
{t('dec.wkd')}
|
||||
</span>
|
||||
)}
|
||||
{entities.map((b) => (
|
||||
<span key={b.label}
|
||||
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
|
||||
|
||||
Reference in New Issue
Block a user