chore: release v0.26.0

This commit is contained in:
2026-08-20 17:53:55 +02:00
parent 47992b5f03
commit 1e507225dd
44 changed files with 3197 additions and 240 deletions
+233 -16
View File
@@ -94,7 +94,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
import { AnswerDecode, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
import { applyMatrixColors } from '@/lib/matrixColors';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel';
@@ -108,6 +108,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 { GridSquareMap } from '@/components/GridSquareMap';
import { loadAutoCall, shouldAutoCall, autoCallKey, type AutoCallSettings } from '@/lib/autocall';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref';
@@ -1036,6 +1038,22 @@ export default function App() {
// unlike Statistics, which is consulted and closed, this one is a panel an
// operator running digital modes leaves open for the session.
const [decodesTabOpen, setDecodesTabOpen] = useState(() => localStorage.getItem('opslog.decodesTab') === '1');
// The grid-square map is its OWN closable tab, not a column beside the decode
// table. It is an analysis view — consulted a few times an evening — and the
// space beside the table is worth far more to a second receiver's decodes when
// two bands are running. Full width also makes it legible, which it was not in
// a 38 % column.
const [gridsTabOpen, setGridsTabOpen] = useState(() => localStorage.getItem('opslog.gridsTab') === '1');
function openGridsTab() {
setGridsTabOpen(true);
writeUiPref('opslog.gridsTab', '1');
setActiveTab('grids');
}
function closeGridsTab() {
setGridsTabOpen(false);
writeUiPref('opslog.gridsTab', '0');
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
}
function openDecodesTab() {
setDecodesTabOpen(true);
writeUiPref('opslog.decodesTab', '1');
@@ -1828,6 +1846,7 @@ export default function App() {
const [showSettings, setShowSettings] = useState(false);
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]);
// Openings under way, for the blinking status-bar badge. Polled rather than
// event-driven: the badge also has to go OUT when a band goes quiet, and
// nothing emits an event for something that stopped happening.
@@ -2068,7 +2087,99 @@ export default function App() {
// The LIVE transmit state, replaced on every Status — what is going out now
// and to whom, which the period history cannot answer between overs.
const [txState, setTxState] = useState<TxMsgRow | null>(null);
// The same, per receiver. With two instances the single txState is whichever
// one reported last, so a split view cannot say WHICH is on the air — which is
// the only question worth asking when both are calling.
const [txStates, setTxStates] = useState<Record<string, TxMsgRow>>({});
useEffect(() => { decodesRef.current = decodes; }, [decodes]);
// ── Auto-call ──────────────────────────────────────────────────────
//
// Answers a decode without the operator clicking it. The DECISION lives in
// lib/autocall (one pure function, so the dangerous part can be read and
// argued with); this is only the plumbing that runs it and keys the radio.
//
// Re-read when Preferences closes, like every other setting edited there.
const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall);
useEffect(() => { if (!showSettings) setAutoCall(loadAutoCall()); }, [showSettings]);
// When each callsign was last answered, so a station still calling CQ is not
// re-answered every slot while the QSO it started is still running.
const autoCalledRef = useRef<Map<string, number>>(new Map());
// Decodes are scanned once. Without this the same decode is reconsidered on
// every status refresh, and a cooldown that has just expired would fire again
// on a decode minutes old.
const autoSeenRef = useRef<Set<string>>(new Set());
// Set when a call goes out, so nothing else fires until the receiver's own
// status catches up and `busy` can be trusted again.
const autoHoldUntilRef = useRef(0);
// Per receiver, when the carrier was last up. Feeds the stale-exchange
// backstop below.
const lastTxAtRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
if (!autoCall.enabled) return;
const now = Date.now();
// A QSO is in progress somewhere if ANY receiver is transmitting or still
// holding a DX call it has not finished with. Both matter: between overs the
// carrier is down but the exchange is not over, and calling someone else
// then is exactly the "it never stops" behaviour.
const busy = Object.values(txStates).some((tx) => {
if (tx?.transmitting) return true;
// A DX call still set means the exchange is not finished — but ONLY while
// the sender still intends to transmit. The watchdog stops transmission
// and leaves the DX call behind, and reading the call alone left auto-call
// waiting for a QSO that had already been given up on, for ever.
if (!tx?.dx_call?.trim()) return false;
if (tx.tx_enabled === false) return false;
// Backstop for a sender that never reports the toggle: an exchange with no
// transmission for three minutes is over, whatever the DX call still says.
// Measured from the last TIME THE CARRIER WAS UP — Status itself arrives
// every second and so can never go stale.
const last = lastTxAtRef.current.get(tx.instance ?? '');
return last === undefined || now - last < 180_000;
})
// The hold closes the gap between sending a Reply and the receiver saying
// it has acted on it — about a second. Without it the OTHER instance still
// looks idle in that window and gets a call of its own.
|| now < autoHoldUntilRef.current;
for (const d of decodes) {
const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`;
if (autoSeenRef.current.has(seenKey)) continue;
// Only decodes from the CURRENT period are worth answering: replying to a
// slot that has closed asks the far end to match a decode it has dropped.
if (now - Date.parse(d.at) > 30_000) { autoSeenRef.current.add(seenKey); continue; }
const e = spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
// NOT marked seen until its status has resolved. Statuses land a few
// hundred milliseconds after the decode, so consuming it on first sight
// would throw away almost every decode unjudged — the effect re-runs when
// spotStatus changes, and this is what lets it look again.
if (!e) continue;
autoSeenRef.current.add(seenKey);
const verdict = shouldAutoCall(autoCall, d, e as any, {
busy,
calledAt: autoCalledRef.current,
now,
myCall: station.callsign,
});
if (!verdict.call) continue;
autoCalledRef.current.set(d.call.toUpperCase(), now);
autoHoldUntilRef.current = now + 12_000; // an FT8 slot, near enough
// Same reason as a manual click: put the transmitter on the decode's band
// before answering, or a second slice answers on the wrong one.
FlexTXOnBand(d.band ?? '').catch(() => {});
// Logged, always: an automatic transmission with no record of WHY is the
// one thing an operator cannot argue with after the fact.
LogUIError('auto-call', `calling ${d.call}${verdict.reason}`, '');
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
).catch((e2: any) => setError(String(e2?.message ?? e2)));
onCallsignInput(d.call, { force: true });
break; // one per pass: a period can hold several, and we work one station
}
// The seen-set is bounded by the same half hour the decode list keeps.
if (autoSeenRef.current.size > 20000) autoSeenRef.current.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus, autoCall, txStates]);
// Staged like the cluster's, so a period arriving as one burst of fifty
// packets costs one status lookup and one render, not fifty of each.
const pendingDecodesRef = useRef<DecodeRow[]>([]);
@@ -2213,6 +2324,11 @@ export default function App() {
const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []);
useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]);
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Compact rotor widget (Settings → Rotator): dial + SP/LP only. RotorCompass
// already draws exactly that when it is given neither presets nor onStop —
// 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');
// 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.
@@ -3131,6 +3247,14 @@ export default function App() {
// even between overs, and on a sender that never reports its transmit
// text at all.
setTxState(m as TxMsgRow);
if (m?.instance) {
setTxStates((prev) => ({ ...prev, [m.instance]: m as TxMsgRow }));
// When this receiver last actually TRANSMITTED, which is not the same as
// when it last spoke: Status arrives about once a second whether the
// carrier is up or not, so it can never say how long an exchange has
// been stalled.
if (m.transmitting) lastTxAtRef.current.set(m.instance, Date.now());
}
// The period history takes only real transmissions — Status repeats
// itself once a second whether the carrier is up or not.
if (!m?.transmitting || !String(m?.msg ?? '').trim()) return;
@@ -4313,6 +4437,7 @@ export default function App() {
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('station.title'), action: 'tools.station' },
{ type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
{ type: 'item', label: t('gsm.title'), action: 'tools.grids' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
{ type: 'separator' },
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
@@ -4363,6 +4488,7 @@ export default function App() {
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
case 'tools.decodes': openDecodesTab(); break;
case 'tools.grids': openGridsTab(); break;
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); break;
@@ -5192,18 +5318,26 @@ export default function App() {
|| (!!e?.new_grid && clusterStatusFilter.has('new-grid'));
if (!matches) return false;
}
// LoTW only, and the spotter's continent. Both are properties of the
// station rather than judgements about the spot, so they AND with the
// status chips instead of joining that OR: "a new band, and from Europe".
if (clusterLotwOnly || clusterSpotterConts.size > 0) {
// The spotter's continent comes from the SPOT, never from spotStatus.
// spotStatus is keyed by call|band|mode, and one DX call is spotted by
// skimmers on every continent within the same minute — so they all shared
// whichever spotter arrived first, and picking AF left the Indian and
// American skimmers exactly where they were. The spot carries its own.
//
// A spot whose spotter could not be resolved IS dropped here: the value
// arrives with the row, so there is no window to flicker through, and
// silently keeping unresolved rows is what made the old bug invisible.
if (clusterSpotterConts.size > 0) {
if (!clusterSpotterConts.has((s as any).spotter_continent ?? '')) return false;
}
// LoTW is a property of the DX station, so it stays on the status entry —
// where call|band|mode is exactly the right key.
if (clusterLotwOnly) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k];
// An unresolved spot is not filtered out. The status arrives a moment
// after the row does, and dropping it meanwhile made the list flicker.
if (e) {
if (clusterLotwOnly && !e.lotw) return false;
if (clusterSpotterConts.size > 0 && e.spotter_continent && !clusterSpotterConts.has(e.spotter_continent)) return false;
}
if (e && !e.lotw) return false;
}
if (clusterHideWorked) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
@@ -5455,6 +5589,15 @@ export default function App() {
decodes={decodes}
txMsgs={txMsgs}
txState={txState}
txStates={txStates}
autoCallOn={autoCall.enabled}
// Written through the same key Preferences uses, so the two can never
// disagree about whether the machine is armed.
onToggleAutoCall={() => setAutoCall((prev) => {
const next = { ...prev, enabled: !prev.enabled };
writeUiPref(autoCallKey, JSON.stringify(next));
return next;
})}
spotStatus={spotStatus as any}
myCall={station.callsign}
// A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as
@@ -5467,11 +5610,42 @@ export default function App() {
// for the VFO. The entry is still filled, so the QSO can be logged here.
onCall={(d) => {
onCallsignInput(d.call, { force: true });
// With two slices on two bands, the Reply reaches the right INSTANCE but
// the radio still transmits on whichever slice holds the TX flag. Move
// it to the decode's band first, or an 80 m answer goes out on 20 m.
// A no-op on any backend without slices.
FlexTXOnBand(d.band ?? '').catch(() => { /* not a Flex, or no such slice */ });
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
d.audio_hz ?? 0, d.mode ?? '', d.msg ?? '', !!d.low_conf,
// The RAW mode marker, not the resolved name: the receiving
// application matches the Reply against its decode list field for
// field, and JTDX drops one that says "FT8" where it decoded "~".
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
).catch((e: any) => setError(String(e?.message ?? e)));
}}
// Wipes the live view only — nothing here is stored, and the staging
// buffer goes too or the next flush would put back what was just cleared.
onClear={(instance) => {
if (!instance) {
pendingDecodesRef.current = [];
setDecodes([]);
setTxMsgs([]);
setTxState(null);
setTxStates({});
return;
}
// One receiver only: the other pane keeps everything it was showing,
// which is the whole reason for clearing just one.
pendingDecodesRef.current = pendingDecodesRef.current.filter((d) => (d.instance ?? '') !== instance);
setDecodes((a) => a.filter((d) => (d.instance ?? '') !== instance));
setTxMsgs((a) => a.filter((m) => (m.instance ?? '') !== instance));
setTxStates((prev) => { const n = { ...prev }; delete n[instance]; return n; });
}}
// An empty instance lets the backend fall back to whichever application
// last reported its status — the normal single-receiver case.
onHalt={(instance) => {
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
}}
/>
);
@@ -6489,12 +6663,14 @@ export default function App() {
</div>
)}
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
rotator is configured or a DX bearing exists. */}
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. */}
{showRotor && (rotatorHeading.enabled || dxPath) && (
<div className="w-[320px] shrink-0 min-h-0">
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')}>
<RotorCompass
presets={rotorPresets}
onStop={() => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
presets={rotorCompact ? undefined : rotorPresets}
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
bearing={dxPath?.bearingShort ?? null}
headings={beamHeadings}
boomHeading={boomHeading}
@@ -6773,6 +6949,21 @@ export default function App() {
</span>
</TabsTrigger>
)}
{gridsTabOpen && (
<TabsTrigger value="grids" className="gap-1.5">
{t('gsm.title')}
<span
role="button"
aria-label="Close grid squares"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeGridsTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{decodesTabOpen && (
<TabsTrigger value="decodes" className="gap-1.5">
{t('dec.tab')}
@@ -6832,7 +7023,8 @@ export default function App() {
<div className="relative flex-1">
<Input
className="w-full pr-8 font-mono"
placeholder="Search callsign…"
placeholder={t('rq.searchPh')}
title={t('rq.searchTip')}
value={filterCallsign}
onChange={(e) => setFilterCallsign(e.target.value.toUpperCase())}
/>
@@ -7201,7 +7393,26 @@ export default function App() {
updating) while you work on other tabs. */}
{qslTabOpen && (
<TabsContent value="qsl" forceMount className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<QSLManagerPanel onEditQSO={openEdit} />
<QSLManagerPanel
onEditQSO={openEdit}
// The same row actions the Recent QSOs grid offers. Only the
// selection-based exports: exporting "the filter" would mean
// the Recent-QSOs filter, not the rows shown here.
actions={{
onUpdateFromCty: bulkUpdateFromCty,
onUpdateFromQRZ: bulkUpdateFromQRZ,
onUpdateFromClublog: bulkUpdateFromClublog,
onUpdateCountyFromULS: ulsReady ? bulkUpdateCountyFromULS : undefined,
onSendTo: bulkSendTo,
onSendRecording: bulkSendRecording,
onSendEQSL: (ids) => setEqslQsoId(ids[0] ?? null),
onBulkEdit: openBulkEdit,
onExportSelected: exportSelectedADIF,
onExportSelectedFields: exportSelectedFields,
onExportCabrilloSelected: exportSelectedCabrillo,
onDelete: (ids) => setDeletingIds(ids),
}}
/>
</TabsContent>
)}
@@ -7240,6 +7451,12 @@ export default function App() {
</TabsContent>
)}
{gridsTabOpen && (
<TabsContent value="grids" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<GridSquareMap myGrid={station.my_grid} className="flex-1 min-h-0" />
</TabsContent>
)}
{decodesTabOpen && (
<TabsContent value="decodes" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{renderDecodesPanel()}