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:
2026-09-05 23:02:31 +02:00
parent 8dbc4b7e62
commit 23323c91e0
14 changed files with 1752 additions and 389 deletions
+38 -3
View File
@@ -119,6 +119,8 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
import { GridSquareMap } from '@/components/GridSquareMap';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
@@ -2612,6 +2614,30 @@ export default function App() {
// The band each receiver was last decoding on. A band change empties that
// receiver's list — see the flush below.
const decoderBandRef = useRef<Map<string, string>>(new Map());
// The RIG changing band empties the list too, without waiting for the
// decoder to catch up.
//
// Clicking a watchlist row or a spot tunes the radio; the decoder follows on
// its own clock and its first decode on the new band is up to a period away.
// Until then the operator reads a screen of stations from a band they have
// left — which is the moment the list is most misleading, because it looks
// current. Only the receivers that were on the band we came FROM are
// cleared: a second decoder parked on another band has not moved.
const rigBandRef = useRef<string>('');
useEffect(() => {
const band = (catState.band ?? '').toLowerCase();
const was = rigBandRef.current;
rigBandRef.current = band;
if (!band || !was || was === band) return;
const stale = new Set<string>();
decoderBandRef.current.forEach((b2, inst) => { if (b2 === was) stale.add(inst); });
if (stale.size === 0) return;
stale.forEach((inst) => decoderBandRef.current.delete(inst));
pendingDecodesRef.current = pendingDecodesRef.current.filter((d) => !stale.has(d.instance ?? ''));
setDecodes((arr) => arr.filter((d) => !stale.has(d.instance ?? '')));
setTxMsgs((arr) => arr.filter((m) => !stale.has(m.instance ?? '')));
}, [catState.band]);
const pendingDecodeTimer = useRef<number | undefined>(undefined);
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
@@ -2774,6 +2800,11 @@ export default function App() {
// withholding them IS the compact mode, so there is no second layout to keep
// in step with the first.
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
// Which dial (Settings → Rotator). Subscribed rather than re-read on close, so
// the choice shows the moment it is made — it is picked by looking at it.
const [rotorDial, setRotorDial] = useState(() => rotorStyle());
useEffect(() => subscribeRotorStyle(() => setRotorDial(rotorStyle())), []);
const Compass2 = rotorDial === 'classic' ? RotorCompassClassic : RotorCompass;
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
// picked award references to the QSO field/extras each award actually reads.
@@ -6373,6 +6404,7 @@ export default function App() {
// compare with", never "the rig is on no band".
rigBand={catState.connected ? (catState.band || '') : ''}
myCall={station.callsign}
myGrid={station.my_grid}
// A DOUBLE click answers the station: it hands the decode back to
// WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in
// their own window. A single click only selects it — see onSelect below,
@@ -7558,10 +7590,13 @@ export default function App() {
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
rotator is configured or a DX bearing exists. Compact mode drops the
controls column, so the widget is just the dial and needs only its
width. */}
width. The classic dial sizes itself from the inside and expects a fixed
column; the current one asks for the width it needs. */}
{showRotor && (rotatorHeading.enabled || dxPath) && (
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')} style={{ order: wOrder('rotor') }}>
<RotorCompass
<div className={cn('shrink-0 min-h-0',
rotorCompact ? 'w-[196px]' : rotorDial === 'classic' ? 'w-[320px]' : 'w-auto')}
style={{ order: wOrder('rotor') }}>
<Compass2
presets={rotorCompact ? undefined : rotorPresets}
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
bearing={dxPath?.bearingShort ?? null}