up
This commit is contained in:
@@ -359,6 +359,7 @@ type App struct {
|
|||||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||||
pttMu sync.Mutex
|
pttMu sync.Mutex
|
||||||
|
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||||
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
||||||
@@ -5273,11 +5274,18 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||||
a.applyQSLDefaults(&q)
|
a.applyQSLDefaults(&q)
|
||||||
|
|
||||||
// ── Dedup ──
|
// ── Dedup (serialised) ──
|
||||||
// Match by call + band + mode within a ±2-minute window: a QSO logged
|
// Match by call + band + mode within a ±2-minute window: a QSO logged
|
||||||
// manually in OpsLog and re-broadcast by Log4OM over UDP often differs by
|
// manually in OpsLog and re-broadcast by Log4OM over UDP often differs by
|
||||||
// a minute (the two apps stamp their own time), so a minute-exact key
|
// a minute (the two apps stamp their own time), so a minute-exact key
|
||||||
// missed it and the contact got duplicated.
|
// missed it and the contact got duplicated.
|
||||||
|
//
|
||||||
|
// The check + insert is guarded by udpLogMu: MSHV/WSJT can deliver the same
|
||||||
|
// logged-QSO packet twice in quick succession (re-broadcast, or two
|
||||||
|
// listeners), and without serialisation both goroutines read the dedup set
|
||||||
|
// BEFORE either inserts, both pass, and the QSO lands twice.
|
||||||
|
a.udpLogMu.Lock()
|
||||||
|
defer a.udpLogMu.Unlock()
|
||||||
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
|
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
base := q.QSODate.UTC()
|
base := q.QSODate.UTC()
|
||||||
|
|||||||
+98
-47
@@ -242,10 +242,10 @@ export default function App() {
|
|||||||
setLocks((s) => {
|
setLocks((s) => {
|
||||||
const wasLocked = s[k];
|
const wasLocked = s[k];
|
||||||
const next = { ...s, [k]: !wasLocked };
|
const next = { ...s, [k]: !wasLocked };
|
||||||
|
if (wasLocked) {
|
||||||
// Unlocking → restore automatic behavior. Without this the locked
|
// Unlocking → restore automatic behavior. Without this the locked
|
||||||
// value would linger forever: a stale Start time would never refresh
|
// value would linger forever: a stale Start time would never refresh
|
||||||
// even after a new callsign is entered.
|
// even after a new callsign is entered.
|
||||||
if (wasLocked) {
|
|
||||||
if (k === 'start') {
|
if (k === 'start') {
|
||||||
// If a QSO is currently in progress (callsign typed), snap start
|
// If a QSO is currently in progress (callsign typed), snap start
|
||||||
// to now since we missed the auto-start moment. Otherwise clear.
|
// to now since we missed the auto-start moment. Otherwise clear.
|
||||||
@@ -254,6 +254,12 @@ export default function App() {
|
|||||||
// Drop the frozen end so the field tracks the live UTC clock.
|
// Drop the frozen end so the field tracks the live UTC clock.
|
||||||
setQsoEndedAt(null);
|
setQsoEndedAt(null);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Locking (manual / deferred entry) → pre-fill with today's date + the
|
||||||
|
// current UTC time so the fields aren't empty; the operator just adjusts.
|
||||||
|
const now = new Date();
|
||||||
|
if (k === 'start') setQsoStartedAt((d) => d ?? now);
|
||||||
|
else if (k === 'end') setQsoEndedAt((d) => d ?? now);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -857,6 +863,24 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// tuneRigCAT sends freq + mode to the rig, sequenced with a short settle
|
||||||
|
// delay so an older transceiver — busy after a band/freq change — doesn't drop
|
||||||
|
// the second command (which forced a second click to apply the mode). The
|
||||||
|
// "mode before frequency" order (à la Log4OM) is an option for rigs that need
|
||||||
|
// the mode set first; default is frequency-then-mode.
|
||||||
|
async function tuneRigCAT(freqHz: number, mode: string) {
|
||||||
|
const modeFirst = localStorage.getItem('opslog.catModeBeforeFreq') === '1';
|
||||||
|
const doFreq = () => SetCATFrequency(freqHz).catch(() => {});
|
||||||
|
const doMode = () => (mode ? SetCATMode(mode).catch(() => {}) : Promise.resolve());
|
||||||
|
const settle = () => new Promise((r) => window.setTimeout(r, 150));
|
||||||
|
if (modeFirst) {
|
||||||
|
await doMode(); await settle(); await doFreq();
|
||||||
|
} else {
|
||||||
|
await doFreq();
|
||||||
|
if (mode) { await settle(); await doMode(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// applyModeFromSpot updates the mode AND its RST default for a fresh target
|
// applyModeFromSpot updates the mode AND its RST default for a fresh target
|
||||||
// (clicked spot / rig-driven mode change). Unlike a manual mode tweak, this
|
// (clicked spot / rig-driven mode change). Unlike a manual mode tweak, this
|
||||||
// is a new contact, so we clear the "user edited RST" flag first — otherwise
|
// is a new contact, so we clear the "user edited RST" flag first — otherwise
|
||||||
@@ -889,16 +913,30 @@ export default function App() {
|
|||||||
loadStation();
|
loadStation();
|
||||||
loadLists();
|
loadLists();
|
||||||
loadCATCfg();
|
loadCATCfg();
|
||||||
// Hydrate CAT state on mount (the backend may already be polling).
|
})();
|
||||||
try { setCatState(await GetCATState()); } catch {}
|
// Poll the CAT state at launch until the rig reports a frequency: the
|
||||||
|
// backend connects asynchronously and only PUSHES cat:state on change, so
|
||||||
|
// the first (freq-carrying) event can fire before this listener mounts and
|
||||||
|
// be missed — leaving the display blank until the operator next moves the
|
||||||
|
// VFO. Stops once a freq arrives, CAT is off, or after ~13s.
|
||||||
|
(async () => {
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
try {
|
||||||
|
const s = await GetCATState();
|
||||||
|
applyCatState(s);
|
||||||
|
if (!s?.enabled) break;
|
||||||
|
if (s?.connected && s.freq_hz && s.freq_hz > 0) break;
|
||||||
|
} catch { /* not ready yet */ }
|
||||||
|
await new Promise((r) => window.setTimeout(r, 800));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// CAT live updates. Push freq/band/mode into the entry strip when the rig
|
// Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user
|
||||||
// moves, unless the user just typed something (1.5s grace window).
|
// just typed something (freeze window) or locked a field. Shared by the live
|
||||||
useEffect(() => {
|
// cat:state event and the startup poll below.
|
||||||
const unsub = EventsOn('cat:state', (s: CATState) => {
|
function applyCatState(s: CATState) {
|
||||||
setCatState(s);
|
setCatState(s);
|
||||||
if (!s?.connected) return;
|
if (!s?.connected) return;
|
||||||
if (Date.now() < catFreezeUntilRef.current) return;
|
if (Date.now() < catFreezeUntilRef.current) return;
|
||||||
@@ -907,8 +945,6 @@ export default function App() {
|
|||||||
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
||||||
}
|
}
|
||||||
// RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX.
|
// RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX.
|
||||||
// In split we take the rig's real RX freq; otherwise RX mirrors TX
|
|
||||||
// (the user can still override it by hand). The freq lock covers both.
|
|
||||||
if (!lk.freq) {
|
if (!lk.freq) {
|
||||||
if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) {
|
if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) {
|
||||||
setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5));
|
setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5));
|
||||||
@@ -918,12 +954,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (!lk.band && s.band) setBand(s.band);
|
if (!lk.band && s.band) setBand(s.band);
|
||||||
|
|
||||||
// Mode resolution priority:
|
// Mode resolution priority: digital watering-hole → CAT's DATA → CAT mode.
|
||||||
// 1. If freq matches a known digital watering hole, pick the specific
|
|
||||||
// mode for that hole (FT8 / FT4) — beats whatever CAT reports.
|
|
||||||
// 2. Else if CAT reports DATA (generic), use the user's configured
|
|
||||||
// default digital mode (FT8 by default).
|
|
||||||
// 3. Else trust CAT (SSB, CW, AM, FM…).
|
|
||||||
if (!lk.mode) {
|
if (!lk.mode) {
|
||||||
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
|
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
|
||||||
let nextMode = '';
|
let nextMode = '';
|
||||||
@@ -932,12 +963,15 @@ export default function App() {
|
|||||||
else if (s.mode) nextMode = s.mode;
|
else if (s.mode) nextMode = s.mode;
|
||||||
if (nextMode) {
|
if (nextMode) {
|
||||||
setMode(nextMode);
|
setMode(nextMode);
|
||||||
// Flip the RST default (599↔59) when the rig changes mode. Respects a
|
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
|
||||||
// user-edited RST (applyModePreset early-returns when edited).
|
|
||||||
applyModePreset(nextMode);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// CAT live updates. Push freq/band/mode into the entry strip when the rig
|
||||||
|
// moves, unless the user just typed something (1.5s grace window).
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = EventsOn('cat:state', (s: CATState) => applyCatState(s));
|
||||||
return () => { unsub?.(); };
|
return () => { unsub?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1161,12 +1195,16 @@ export default function App() {
|
|||||||
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
|
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
|
||||||
const rxFreqHz = rxFreqMhz.trim() ? Math.round(parseFloat(rxFreqMhz) * 1_000_000) : undefined;
|
const rxFreqHz = rxFreqMhz.trim() ? Math.round(parseFloat(rxFreqMhz) * 1_000_000) : undefined;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const end = (locks.end && qsoEndedAt) ? qsoEndedAt : now;
|
const baseStart = qsoStartedAt ?? now;
|
||||||
|
// End: explicit when locked; for a deferred back-entry (start locked) it
|
||||||
|
// defaults to the back-dated start so the QSO doesn't span to today;
|
||||||
|
// otherwise it's now.
|
||||||
|
const end = (locks.end && qsoEndedAt) ? qsoEndedAt : (locks.start ? baseStart : now);
|
||||||
// Option: log TIME_ON = TIME_OFF (the moment the QSO completes). Useful
|
// Option: log TIME_ON = TIME_OFF (the moment the QSO completes). Useful
|
||||||
// when you call a station for a long time — otherwise TIME_ON is frozen at
|
// when you call a station for a long time — otherwise TIME_ON is frozen at
|
||||||
// when you first entered the call (minutes early) and won't match LoTW.
|
// when you first entered the call (minutes early) and won't match LoTW.
|
||||||
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
|
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
|
||||||
const start = (startEqualsEnd && !locks.start) ? end : (qsoStartedAt ?? now);
|
const start = (startEqualsEnd && !locks.start) ? end : baseStart;
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
callsign: callsign.trim().toUpperCase(),
|
callsign: callsign.trim().toUpperCase(),
|
||||||
qso_date: start.toISOString(),
|
qso_date: start.toISOString(),
|
||||||
@@ -1407,6 +1445,11 @@ export default function App() {
|
|||||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||||
}));
|
}));
|
||||||
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
||||||
|
// Begin the recording once the call resolves (a real, ≥3-char callsign,
|
||||||
|
// not 1–2 stray letters). Covers the fast CW workflow (type → Enter to log
|
||||||
|
// via the WinKeyer, no blur). No-op if the recorder is off or already
|
||||||
|
// running; the pre-roll covers the lead-in.
|
||||||
|
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setLookupResult(null);
|
setLookupResult(null);
|
||||||
setLookupError(String(e?.message ?? e));
|
setLookupError(String(e?.message ?? e));
|
||||||
@@ -1453,11 +1496,6 @@ export default function App() {
|
|||||||
// start is locked: the user is back-entering a past QSO and set a
|
// start is locked: the user is back-entering a past QSO and set a
|
||||||
// specific time manually.
|
// specific time manually.
|
||||||
setQsoStartedAt(new Date());
|
setQsoStartedAt(new Date());
|
||||||
// Begin the recording here too: a fast CW workflow (type the call then
|
|
||||||
// hit Enter to log, exchanging with the WinKeyer) never blurs the call
|
|
||||||
// field, so the blur-based start was missed. No-op if the recorder is off
|
|
||||||
// or already running; the pre-roll covers the lead-in.
|
|
||||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
|
||||||
} else if (isEmpty && !locks.start) {
|
} else if (isEmpty && !locks.start) {
|
||||||
// Callsign wiped → user abandoned this QSO; reset the timer.
|
// Callsign wiped → user abandoned this QSO; reset the timer.
|
||||||
setQsoStartedAt(null);
|
setQsoStartedAt(null);
|
||||||
@@ -1703,6 +1741,26 @@ export default function App() {
|
|||||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
// Deferred-entry date: only shown when the start time is locked (back-entering
|
||||||
|
// a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time.
|
||||||
|
const dateBlock = locks.start ? (
|
||||||
|
<div className="flex flex-col w-40">
|
||||||
|
<Label className="mb-1 h-3.5 text-emerald-700">Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const d = e.target.value; // YYYY-MM-DD
|
||||||
|
if (!d) return;
|
||||||
|
const [y, mo, da] = d.split('-').map(Number);
|
||||||
|
const next = new Date(qsoStartedAt ?? new Date());
|
||||||
|
next.setUTCFullYear(y, mo - 1, da);
|
||||||
|
setQsoStartedAt(next);
|
||||||
|
}}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
const startBlock = (
|
const startBlock = (
|
||||||
<div className="flex flex-col w-28">
|
<div className="flex flex-col w-28">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700">Start UTC <LockBtn k="start" title="start time" /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700">Start UTC <LockBtn k="start" title="start time" /></Label>
|
||||||
@@ -1728,7 +1786,12 @@ export default function App() {
|
|||||||
<Input
|
<Input
|
||||||
readOnly={!locks.end}
|
readOnly={!locks.end}
|
||||||
tabIndex={locks.end ? 0 : -1}
|
tabIndex={locks.end ? 0 : -1}
|
||||||
value={endFocused ? endInputStr : (locks.end ? (qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : '') : (qsoStartedAt ? utcNow.slice(11) : '—'))}
|
value={endFocused ? endInputStr : (
|
||||||
|
locks.end ? (qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : '')
|
||||||
|
// Deferred entry (start locked): the QSO is back-dated, so the end
|
||||||
|
// follows the start instead of the live clock.
|
||||||
|
: locks.start ? (qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : '')
|
||||||
|
: (qsoStartedAt ? utcNow.slice(11) : '—'))}
|
||||||
onFocus={() => { setEndInputStr(qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : ''); setEndFocused(true); }}
|
onFocus={() => { setEndInputStr(qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : ''); setEndFocused(true); }}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setEndFocused(false);
|
setEndFocused(false);
|
||||||
@@ -1812,18 +1875,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const cqBlock = (
|
// CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
|
||||||
<div className="flex flex-col w-10"><Label className="mb-1 h-3.5 text-[10px]">CQ</Label>
|
|
||||||
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-0.5 text-xs h-7" value={details.cqz ?? ''} placeholder="—"
|
|
||||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const ituBlock = (
|
|
||||||
<div className="flex flex-col w-10"><Label className="mb-1 h-3.5 text-[10px]">ITU</Label>
|
|
||||||
<Input inputMode="numeric" maxLength={2} className="font-mono text-center px-0.5 text-xs h-7" value={details.ituz ?? ''} placeholder="—"
|
|
||||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const freqBlock = (
|
const freqBlock = (
|
||||||
<div className="flex flex-col w-32">
|
<div className="flex flex-col w-32">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1">{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1">{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" /></Label>
|
||||||
@@ -2243,14 +2295,14 @@ export default function App() {
|
|||||||
) : (
|
) : (
|
||||||
/* Full Log4OM-style columnar layout. */
|
/* Full Log4OM-style columnar layout. */
|
||||||
<>
|
<>
|
||||||
{/* Row 1: Callsign + RST + CQ/ITU zones, then Start/End at right. */}
|
{/* Row 1: Callsign + RST, then Start/End at right. CQ/ITU zones moved
|
||||||
|
to the Info (F2) tab. */}
|
||||||
<div className="flex gap-2 items-end">
|
<div className="flex gap-2 items-end">
|
||||||
{callsignBlock}
|
{callsignBlock}
|
||||||
{rstTxBlock}
|
{rstTxBlock}
|
||||||
{rstRxBlock}
|
{rstRxBlock}
|
||||||
{cqBlock}
|
|
||||||
{ituBlock}
|
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex gap-2">
|
||||||
|
{dateBlock}
|
||||||
{startBlock}
|
{startBlock}
|
||||||
{endBlock}
|
{endBlock}
|
||||||
</div>
|
</div>
|
||||||
@@ -2303,6 +2355,7 @@ export default function App() {
|
|||||||
wbBusy={wbBusy}
|
wbBusy={wbBusy}
|
||||||
band={band}
|
band={band}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
|
bands={bands}
|
||||||
tab={detailTab}
|
tab={detailTab}
|
||||||
onTab={setDetailTab}
|
onTab={setDetailTab}
|
||||||
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
|
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
|
||||||
@@ -2690,8 +2743,7 @@ export default function App() {
|
|||||||
onSpotClick={(s) => {
|
onSpotClick={(s) => {
|
||||||
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||||
if (catState.connected) {
|
if (catState.connected) {
|
||||||
SetCATFrequency(s.freq_hz).catch(() => {});
|
tuneRigCAT(s.freq_hz, m);
|
||||||
if (m) SetCATMode(m).catch(() => {});
|
|
||||||
} else {
|
} else {
|
||||||
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
||||||
if (s.band) setBand(s.band);
|
if (s.band) setBand(s.band);
|
||||||
@@ -2915,8 +2967,7 @@ export default function App() {
|
|||||||
onSpotClick={(s) => {
|
onSpotClick={(s) => {
|
||||||
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||||
if (catState.connected) {
|
if (catState.connected) {
|
||||||
SetCATFrequency(s.freq_hz).catch(() => {});
|
tuneRigCAT(s.freq_hz, m);
|
||||||
if (m) SetCATMode(m).catch(() => {});
|
|
||||||
} else {
|
} else {
|
||||||
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
||||||
if (s.band) setBand(s.band);
|
if (s.band) setBand(s.band);
|
||||||
@@ -2979,7 +3030,7 @@ export default function App() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{editingQSO && (
|
{editingQSO && (
|
||||||
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} />
|
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} bands={bands} modes={modes} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SendSpotModal
|
<SendSpotModal
|
||||||
|
|||||||
@@ -299,6 +299,20 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [band, containerH, currentFreqHz, range, lo, hi]);
|
}, [band, containerH, currentFreqHz, range, lo, hi]);
|
||||||
|
|
||||||
|
// Re-centre on the rig frequency whenever the zoom level changes — zooming
|
||||||
|
// would otherwise drift away from where you're operating. Skips the initial
|
||||||
|
// mount (the band-centre effect above handles that).
|
||||||
|
const firstZoomRef = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstZoomRef.current) { firstZoomRef.current = false; return; }
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el || !range || containerH <= 0) return;
|
||||||
|
const kHz = currentFreqHz && currentFreqHz / 1000 >= lo && currentFreqHz / 1000 <= hi
|
||||||
|
? currentFreqHz / 1000 : (lo + hi) / 2;
|
||||||
|
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [zoomIdx]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|||||||
@@ -11,10 +11,21 @@ interface Props {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
currentBand: string;
|
currentBand: string;
|
||||||
currentMode: string;
|
currentMode: string;
|
||||||
|
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||||
}
|
}
|
||||||
|
|
||||||
// 13-column band layout — no 4m, no SHF (per user preference).
|
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||||
const BANDS: { tag: string; label: string }[] = [
|
// strip the trailing "m" for meter bands (160m→160), and shorten cm bands
|
||||||
|
// (13cm→13c) so the column stays narrow.
|
||||||
|
function bandColLabel(tag: string): string {
|
||||||
|
if (tag === '2m') return 'V';
|
||||||
|
if (tag === '70cm') return 'U';
|
||||||
|
if (tag.endsWith('cm')) return tag.replace('cm', 'c');
|
||||||
|
return tag.replace(/m$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default 13-column band layout, used when the operator hasn't configured bands.
|
||||||
|
const DEFAULT_BANDS: { tag: string; label: string }[] = [
|
||||||
{ tag: '160m', label: '160' },
|
{ tag: '160m', label: '160' },
|
||||||
{ tag: '80m', label: '80' },
|
{ tag: '80m', label: '80' },
|
||||||
{ tag: '60m', label: '60' },
|
{ tag: '60m', label: '60' },
|
||||||
@@ -67,7 +78,13 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
|||||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Props) {
|
||||||
|
// Columns from the operator's configured bands (so the matrix shows only the
|
||||||
|
// bands they actually use), falling back to the built-in default set.
|
||||||
|
const cols = useMemo(
|
||||||
|
() => (bands && bands.length ? bands.map((tag) => ({ tag, label: bandColLabel(tag) })) : DEFAULT_BANDS),
|
||||||
|
[bands],
|
||||||
|
);
|
||||||
const dxcc = wb?.dxcc ?? 0;
|
const dxcc = wb?.dxcc ?? 0;
|
||||||
const dxccName = wb?.dxcc_name ?? '';
|
const dxccName = wb?.dxcc_name ?? '';
|
||||||
const dxccCount = wb?.dxcc_count ?? 0;
|
const dxccCount = wb?.dxcc_count ?? 0;
|
||||||
@@ -136,7 +153,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-[26px]" />
|
<th className="w-[26px]" />
|
||||||
{BANDS.map((b) => (
|
{cols.map((b) => (
|
||||||
<th
|
<th
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -162,7 +179,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
|||||||
>
|
>
|
||||||
{cls}
|
{cls}
|
||||||
</th>
|
</th>
|
||||||
{BANDS.map((b) => {
|
{cols.map((b) => {
|
||||||
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
||||||
const isCurrent = b.tag === currentBand && classCurrent;
|
const isCurrent = b.tag === currentBand && classCurrent;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ interface Props {
|
|||||||
wbBusy?: boolean;
|
wbBusy?: boolean;
|
||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
|
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
onOpenImage?: () => void;
|
onOpenImage?: () => void;
|
||||||
// Optional controlled active tab (so the app can switch it via keyboard).
|
// Optional controlled active tab (so the app can switch it via keyboard).
|
||||||
@@ -117,7 +118,7 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, tab, onTab, keyerActive }: Props) {
|
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
// Bearing/distance from operator's home grid to the remote station.
|
// Bearing/distance from operator's home grid to the remote station.
|
||||||
@@ -181,7 +182,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
<div className="overflow-y-auto min-h-0">
|
<div className="overflow-y-auto min-h-0">
|
||||||
{open === 'stats' && (
|
{open === 'stats' && (
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5">
|
||||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} />
|
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -196,9 +197,16 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
<Field label="Prefix">
|
<Field label="Prefix">
|
||||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||||
</Field>
|
</Field>
|
||||||
{/* DXCC #, CQ zone, ITU zone, Continent and Azimuth SP live in the
|
<Field label="CQ zone">
|
||||||
main entry strip — visible without opening F2. F2 keeps the
|
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.cqz ?? ''} placeholder="—"
|
||||||
less-needed long-path bearing and both distances. */}
|
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||||
|
</Field>
|
||||||
|
<Field label="ITU zone">
|
||||||
|
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
||||||
|
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||||
|
</Field>
|
||||||
|
{/* DXCC #, Continent and Azimuth SP live in the main entry strip /
|
||||||
|
bandeau. F2 keeps CQ/ITU zones, the long-path bearing and distances. */}
|
||||||
<Field label="Azimuth LP">
|
<Field label="Azimuth LP">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ interface Props {
|
|||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
countries?: string[];
|
countries?: string[];
|
||||||
|
bands?: string[];
|
||||||
|
modes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocalISO(d: any): string {
|
function toLocalISO(d: any): string {
|
||||||
@@ -131,7 +133,20 @@ function QslSelect({ value, onChange }: { value?: string; onChange: (v: string)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }: Props) {
|
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||||||
|
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
||||||
|
// fall back to the built-in sets. Always include the QSO's own band/mode so an
|
||||||
|
// imported/legacy value is never silently dropped from the dropdown.
|
||||||
|
const bandList = useMemo(() => {
|
||||||
|
const base = (bands && bands.length ? bands : BANDS).slice();
|
||||||
|
if (qso.band && !base.includes(qso.band)) base.unshift(qso.band);
|
||||||
|
return base;
|
||||||
|
}, [bands, qso.band]);
|
||||||
|
const modeList = useMemo(() => {
|
||||||
|
const base = (modes && modes.length ? modes : MODES).slice();
|
||||||
|
if (qso.mode && !base.includes(qso.mode)) base.unshift(qso.mode);
|
||||||
|
return base;
|
||||||
|
}, [modes, qso.mode]);
|
||||||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||||||
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
||||||
const splitHz = (hz?: number) => hz
|
const splitHz = (hz?: number) => hz
|
||||||
@@ -366,21 +381,21 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
|||||||
<Label className="w-20 shrink-0">Band</Label>
|
<Label className="w-20 shrink-0">Band</Label>
|
||||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
<SelectContent>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">RX Band</Label>
|
<Label className="w-20 shrink-0">RX Band</Label>
|
||||||
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
||||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="_">—</SelectItem>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
<SelectContent><SelectItem value="_">—</SelectItem>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">Mode</Label>
|
<Label className="w-20 shrink-0">Mode</Label>
|
||||||
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||||||
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
<SelectContent>{modeList.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
|
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||||
|
|
||||||
// E-mail / SMTP (send QSO recordings).
|
// E-mail / SMTP (send QSO recordings).
|
||||||
type EmailCfg = {
|
type EmailCfg = {
|
||||||
@@ -2939,6 +2940,20 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={catModeBeforeFreq}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setCatModeBeforeFreq(v); writeUiPref('opslog.catModeBeforeFreq', v ? '1' : '0'); }}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Set CAT mode before frequency (older rigs)
|
||||||
|
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||||
|
When clicking a spot, send the mode to the rig first, then the frequency. Some older transceivers drop the mode command if it arrives right after a band change, needing a second click. Both commands are also spaced out slightly to let the rig settle.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
|
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||||
|
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
Reference in New Issue
Block a user