diff --git a/app.go b/app.go index 41839b0..22a3f83 100644 --- a/app.go +++ b/app.go @@ -359,6 +359,7 @@ type App struct { dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends 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 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) @@ -5273,11 +5274,18 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries a.applyQSLDefaults(&q) - // ── Dedup ── + // ── Dedup (serialised) ── // 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 // a minute (the two apps stamp their own time), so a minute-exact key // 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) if err == nil { base := q.QSODate.UTC() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 191d06a..ed43d56 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -242,10 +242,10 @@ export default function App() { setLocks((s) => { const wasLocked = s[k]; const next = { ...s, [k]: !wasLocked }; - // Unlocking → restore automatic behavior. Without this the locked - // value would linger forever: a stale Start time would never refresh - // even after a new callsign is entered. if (wasLocked) { + // Unlocking → restore automatic behavior. Without this the locked + // value would linger forever: a stale Start time would never refresh + // even after a new callsign is entered. if (k === 'start') { // If a QSO is currently in progress (callsign typed), snap start // 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. 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; }); @@ -857,6 +863,24 @@ export default function App() { // 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 // (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 @@ -889,55 +913,65 @@ export default function App() { loadStation(); loadLists(); 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 }, []); + // Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user + // just typed something (freeze window) or locked a field. Shared by the live + // cat:state event and the startup poll below. + function applyCatState(s: CATState) { + setCatState(s); + if (!s?.connected) return; + if (Date.now() < catFreezeUntilRef.current) return; + const lk = locksRef.current; + if (!lk.freq && s.freq_hz && s.freq_hz > 0) { + setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); + } + // RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX. + if (!lk.freq) { + if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) { + setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5)); + } else if (s.freq_hz && s.freq_hz > 0) { + setRxFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); + } + } + if (!lk.band && s.band) setBand(s.band); + + // Mode resolution priority: digital watering-hole → CAT's DATA → CAT mode. + if (!lk.mode) { + const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : ''; + let nextMode = ''; + if (inferred) nextMode = inferred; + else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8'; + else if (s.mode) nextMode = s.mode; + if (nextMode) { + setMode(nextMode); + applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits) + } + } + } + // 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) => { - setCatState(s); - if (!s?.connected) return; - if (Date.now() < catFreezeUntilRef.current) return; - const lk = locksRef.current; - if (!lk.freq && s.freq_hz && s.freq_hz > 0) { - setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); - } - // 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 (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) { - setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5)); - } else if (s.freq_hz && s.freq_hz > 0) { - setRxFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); - } - } - if (!lk.band && s.band) setBand(s.band); - - // Mode resolution priority: - // 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) { - const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : ''; - let nextMode = ''; - if (inferred) nextMode = inferred; - else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8'; - else if (s.mode) nextMode = s.mode; - if (nextMode) { - setMode(nextMode); - // Flip the RST default (599↔59) when the rig changes mode. Respects a - // user-edited RST (applyModePreset early-returns when edited). - applyModePreset(nextMode); - } - } - }); + const unsub = EventsOn('cat:state', (s: CATState) => applyCatState(s)); return () => { unsub?.(); }; // 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 rxFreqHz = rxFreqMhz.trim() ? Math.round(parseFloat(rxFreqMhz) * 1_000_000) : undefined; 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 // 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. 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 = { callsign: callsign.trim().toUpperCase(), qso_date: start.toISOString(), @@ -1407,6 +1445,11 @@ export default function App() { qsl_via: d.qsl_via || (r.qsl_via ?? ''), })); 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) { setLookupResult(null); 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 // specific time manually. 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) { // Callsign wiped → user abandoned this QSO; reset the timer. setQsoStartedAt(null); @@ -1703,6 +1741,26 @@ export default function App() { { setRstRcvd(v); rstUserEditedRef.current = true; }} /> ); + // 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 ? ( +
+ + { + 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" + /> +
+ ) : null; const startBlock = (
@@ -1728,7 +1786,12 @@ export default function App() { { setEndInputStr(qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : ''); setEndFocused(true); }} onBlur={() => { setEndFocused(false); @@ -1812,18 +1875,7 @@ export default function App() {
); - const cqBlock = ( -
- { const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} /> -
- ); - const ituBlock = ( -
- { const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} /> -
- ); + // CQ/ITU zones moved to the Info (F2) tab (DetailsPanel). const freqBlock = (
@@ -2243,14 +2295,14 @@ export default function App() { ) : ( /* 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. */}
{callsignBlock} {rstTxBlock} {rstRxBlock} - {cqBlock} - {ituBlock}
+ {dateBlock} {startBlock} {endBlock}
@@ -2303,6 +2355,7 @@ export default function App() { wbBusy={wbBusy} band={band} mode={mode} + bands={bands} tab={detailTab} onTab={setDetailTab} keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled} @@ -2690,8 +2743,7 @@ export default function App() { onSpotClick={(s) => { const m = inferSpotMode(s.comment ?? '', s.freq_hz); if (catState.connected) { - SetCATFrequency(s.freq_hz).catch(() => {}); - if (m) SetCATMode(m).catch(() => {}); + tuneRigCAT(s.freq_hz, m); } else { setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); if (s.band) setBand(s.band); @@ -2915,8 +2967,7 @@ export default function App() { onSpotClick={(s) => { const m = inferSpotMode(s.comment ?? '', s.freq_hz); if (catState.connected) { - SetCATFrequency(s.freq_hz).catch(() => {}); - if (m) SetCATMode(m).catch(() => {}); + tuneRigCAT(s.freq_hz, m); } else { setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); if (s.band) setBand(s.band); @@ -2979,7 +3030,7 @@ export default function App() { })()} {editingQSO && ( - setEditingQSO(null)} countries={countries} /> + setEditingQSO(null)} countries={countries} bands={bands} modes={modes} /> )} { + 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(() => { const el = scrollerRef.current; if (!el) return; diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index 1bb88ea..3935cd5 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -11,10 +11,21 @@ interface Props { busy: boolean; currentBand: 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). -const BANDS: { tag: string; label: string }[] = [ +// Compact column label for a band tag: keep the classic V/U for 2m/70cm, +// 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: '80m', label: '80' }, { 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' : ''}`; } -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 dxccName = wb?.dxcc_name ?? ''; const dxccCount = wb?.dxcc_count ?? 0; @@ -136,7 +153,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) { - {BANDS.map((b) => ( + {cols.map((b) => ( {cls} - {BANDS.map((b) => { + {cols.map((b) => { const st = statusMap.get(`${b.tag}|${cls}`) ?? ''; const isCurrent = b.tag === currentBand && classCurrent; return ( diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index 7cd57ed..7762058 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -55,6 +55,7 @@ interface Props { wbBusy?: boolean; band: string; mode: string; + bands?: string[]; // configured bands for the worked-before matrix columns imageUrl?: string; onOpenImage?: () => void; // 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('stats'); const open = tab ?? internalOpen; // controlled when `tab` is provided // Bearing/distance from operator's home grid to the remote station. @@ -181,7 +182,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
{open === 'stats' && (
- +
)} @@ -196,9 +197,16 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, - {/* DXCC #, CQ zone, ITU zone, Continent and Azimuth SP live in the - main entry strip — visible without opening F2. F2 keeps the - less-needed long-path bearing and both distances. */} + + { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} /> + + + { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} /> + + {/* DXCC #, Continent and Azimuth SP live in the main entry strip / + bandeau. F2 keeps CQ/ITU zones, the long-path bearing and distances. */} void; onClose: () => void; countries?: string[]; + bands?: string[]; + modes?: 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(() => JSON.parse(JSON.stringify(qso))); // Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save. const splitHz = (hz?: number) => hz @@ -366,21 +381,21 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 73c3114..5a758bf 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -426,6 +426,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0'); const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); + const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1'); // E-mail / SMTP (send QSO recordings). type EmailCfg = { @@ -2939,6 +2940,20 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { + +

ClubLog exceptions (DXpedition overrides)