diff --git a/OpsLog-res.syso b/OpsLog-res.syso deleted file mode 100644 index 79a0989..0000000 Binary files a/OpsLog-res.syso and /dev/null differ diff --git a/app.go b/app.go index 07c7507..41839b0 100644 --- a/app.go +++ b/app.go @@ -3457,6 +3457,16 @@ func (a *App) QSOAudioBegin() bool { return a.qsoRec.Active() } +// QSOAudioRestart starts a fresh recording for a new target even if one is +// already in progress (new call+freq from a clicked spot or external app). +func (a *App) QSOAudioRestart() bool { + if a.qsoRec == nil { + return false + } + a.qsoRec.RestartQSO() + return a.qsoRec.Active() +} + // QSOAudioCancel drops the in-progress recording (callsign cleared, QSO // abandoned without logging). func (a *App) QSOAudioCancel() { @@ -4587,6 +4597,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe // that count for ARRL awards) so each incoming one is flagged NEW. sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"}) var items []ConfirmationItem + var unmatched []string perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { q, ok := adif.RecordToQSO(rec) if !ok { @@ -4611,6 +4622,12 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe keyIDs[key] = newID // guard against dup records in the report added++ } + } else { + // No local QSO matched this confirmation on (call, minute, band, + // mode). Record the specifics so the user can see WHICH one and + // why (time off by a minute, FT4 logged as MFSK, portable call…). + unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s", + q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode)) } // Build the result row + NEW flags (vs the pre-download snapshot), // then fold this slot into the sets so a repeat in the same batch @@ -4648,6 +4665,12 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe } else { emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total)) } + // Surface confirmations with no local match so the user sees WHICH one + // and why (time off by a minute, FT4 logged as MFSK, portable call, or + // never logged). Tick "Add not-found" to import them instead. + for _, u := range unmatched { + emit(" ⚠ no local QSO for: " + u) + } // Remember today so the next pull is incremental (per active profile). if a.settings != nil { _ = a.settings.Set(ctx, a.profileScope()+keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02")) @@ -4658,10 +4681,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe // and (when a window is requested) skip records older than sinceDate by // QSO date. sinceDate is "YYYY-MM-DD". sinceDate := resolveSince(keyExtQRZLastDownload) + emit(fmt.Sprintf("Window: since=%q → resolved date=%q (key %s%s)", since, sinceDate, a.profileScope(), keyExtQRZLastDownload)) if sinceDate != "" { - emit("Fetching QRZ.com logbook (QSOs since " + sinceDate + ")…") + emit("Fetching QRZ.com logbook (will skip QSOs before " + sinceDate + ")…") } else { - emit("Fetching QRZ.com logbook…") + emit("Fetching QRZ.com logbook (full — no since date)…") } fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL") if err != nil { @@ -4671,6 +4695,14 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe } adifText := fr.ADIF emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText))) + // Persist the last-download date NOW (right after a successful fetch), + // not at the end: the QRZ logbook can be huge (tens of thousands of + // records) and the user may close the panel mid-processing — storing it + // late meant the date was never saved, so "since last download" kept + // resolving to empty and re-pulled everything. + if a.settings != nil { + _ = a.settings.Set(ctx, a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02")) + } if snip := strings.TrimSpace(adifText); snip != "" { if len(snip) > 300 { snip = snip[:300] @@ -4781,10 +4813,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe sort.Strings(keys) emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", "))) emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total)) - // Remember today so a later "since last download" pull is incremental. - if a.settings != nil { - _ = a.settings.Set(ctx, a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02")) - } + // (last-download date already stored right after the fetch above) default: emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc)) @@ -6296,7 +6325,12 @@ func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) { out.AutoSpace = v == "1" } out.UsePTT = m[keyWKUsePTT] == "1" - out.SerialEcho = m[keyWKSerialEcho] == "1" + // Only override the default (true) when the key is actually stored — otherwise + // settings saved before serial_echo existed would silently disable the echo, + // and the TX text would stop showing as it's keyed. + if v := m[keyWKSerialEcho]; v != "" { + out.SerialEcho = v == "1" + } if v := m[keyWKMacros]; v != "" { var mac []WKMacro if json.Unmarshal([]byte(v), &mac) == nil && len(mac) > 0 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 20d26d2..9493bad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,7 +27,7 @@ import { GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus, WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, - QSOAudioBegin, QSOAudioCancel, + QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, GetAwardDefs, } from '../wailsjs/go/main/App'; import { Combobox } from '@/components/ui/combobox'; @@ -654,6 +654,11 @@ export default function App() { // tell whether an incoming DX call actually changed anything. const callsignValRef = useRef(''); useEffect(() => { callsignValRef.current = callsign; }, [callsign]); + // True while the operator is typing in the Call field. A call change that + // arrives while it's NOT focused is programmatic (clicked spot / external app + // via UDP) → we (re)start the recording immediately; typed changes wait for + // blur so we don't restart on every keystroke. + const callFocusedRef = useRef(false); // When the entered callsign turns out to be worked-before, jump to the // Worked-before tab so the history is front-and-centre. Only once per call, @@ -817,6 +822,16 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // 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 + // a 599 left from a CW QSO would stick when jumping to an SSB spot. + function applyModeFromSpot(m: string) { + if (!m) return; + setMode(m); + rstUserEditedRef.current = false; + applyModePreset(m); + } function applyModePreset(m: string) { if (rstUserEditedRef.current) return; // Prefer the user's configured preset RST; otherwise fall back to the mode @@ -876,12 +891,15 @@ export default function App() { // 3. Else trust CAT (SSB, CW, AM, FM…). if (!lk.mode) { const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : ''; - if (inferred) { - setMode(inferred); - } else if (s.mode === 'DATA') { - setMode(digitalDefaultRef.current || 'FT8'); - } else if (s.mode) { - setMode(s.mode); + 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); } } }); @@ -1394,6 +1412,13 @@ export default function App() { } setCallsign(v); scheduleLookup(v); + // Programmatic call change (clicked spot, or external app via UDP) for a new + // non-empty target → (re)start the recording now, even if one was already + // running for the previous contact. Typed changes (field focused) wait for + // blur so we don't restart per keystroke. + if (v.trim() !== '' && !callFocusedRef.current) { + QSOAudioRestart().then(setRecording).catch(() => {}); + } } function markEdited(field: string) { userEditedRef.current.add(field); } @@ -1615,10 +1640,11 @@ export default function App() { ref={callsignRef} className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card" value={callsign} + onFocus={() => { callFocusedRef.current = true; }} onChange={(e) => onCallsignInput(e.target.value)} // Start the QSO recording when leaving the callsign field (the pre-roll // covers the seconds before). No-op when the recorder is off. - onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }} + onBlur={() => { callFocusedRef.current = false; if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }} /> @@ -2585,17 +2611,18 @@ export default function App() { } else { setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); if (s.band) setBand(s.band); - if (m) setMode(m); } + // Set mode + flip the RST default (599↔59) for the new + // target — a plain setMode skipped the RST preset. + if (m) applyModeFromSpot(m); onCallsignInput(s.dx_call); // A POTA spot carries the park ref — pre-fill the POTA // award reference (like the State→RAC auto-match) so it's // logged without re-typing. n-fer refs (comma-separated) // become one POTA@ entry each. applySpotPOTA((s as any).pota_ref); - // Clicking a spot fills the call programmatically (no blur - // on the call field), so start the QSO recording here too. - if (s.dx_call.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); + // (recording (re)starts inside onCallsignInput — the call + // changed programmatically with the field unfocused.) }} /> ); @@ -2808,11 +2835,11 @@ export default function App() { } else { setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); if (s.band) setBand(s.band); - if (m) setMode(m); } + if (m) applyModeFromSpot(m); onCallsignInput(s.dx_call); applySpotPOTA((s as any).pota_ref); - if (s.dx_call.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); + // (recording (re)starts inside onCallsignInput — programmatic call change) }} onClose={() => setShowBandMap(false)} /> diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index a7079fa..88a9593 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -105,6 +105,14 @@ function fmtDateTimeUTC(s: any): string { type ColEntry = ColDef & { group: string; label: string; defaultVisible?: boolean }; +// statusFor resolves the precomputed spot status (new / new-band / new-slot / +// worked-call) for an ag-Grid cell's row. +function statusFor(p: any): SpotStatusEntry | undefined { + return p?.context?.spotStatus?.[ + spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz) + ]; +} + const COL_CATALOG: ColEntry[] = [ { group: 'Spot', label: 'Time', colId: 'time', @@ -117,28 +125,15 @@ const COL_CATALOG: ColEntry[] = [ group: 'Spot', label: 'Call', colId: 'call', headerName: 'Call', field: 'dx_call' as any, width: 120, defaultVisible: true, - cellRenderer: (p: any) => { - if (!p.value) return ''; - const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[ - spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz) - ]; - const isNew = status?.status === 'new'; - const workedCall = !!status?.worked_call; - const style: any = { - fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12, - }; - if (isNew) { - // New DXCC entity — soft rose pill, no clashing border. - style.backgroundColor = '#ffe4e6'; - style.color = '#be123c'; - style.padding = '1px 7px'; - style.borderRadius = 4; - } else if (workedCall) { - style.color = '#0369a1'; // already worked this exact call - } else { - style.color = '#b8410c'; // new call in a worked entity - } - return {p.value}; + cellClass: 'font-mono', + // New DXCC entity → fill the whole cell (no padded pill, so calls stay + // aligned with non-new rows). Text colour also flags worked-call vs new-call. + cellStyle: (p: any): any => (statusFor(p)?.status === 'new' + ? { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 } + : { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }), + tooltipValueGetter: (p: any) => { + const s = statusFor(p); + return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined; }, }, { @@ -159,46 +154,25 @@ const COL_CATALOG: ColEntry[] = [ group: 'Spot', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, defaultVisible: true, - cellClass: 'flex items-center', - cellRenderer: (p: any) => { - const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[ - spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz) - ]; - const newBand = status?.status === 'new-band'; - return p.value - ? {p.value} - : ''; - }, + cellClass: 'font-mono', + // NEW BAND for this entity → fill the cell (keeps the band text aligned). + cellStyle: (p: any) => (statusFor(p)?.status === 'new-band' + ? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 } + : undefined), + tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? 'NEW BAND for this entity' : undefined), }, { group: 'Spot', label: 'Mode', colId: 'mode', headerName: 'Mode', colSpan: undefined, width: 80, defaultVisible: true, - cellClass: 'flex items-center', + cellClass: 'font-mono', valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '', - cellRenderer: (p: any) => { - const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[ - spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz) - ]; - const newSlot = status?.status === 'new-slot'; - return p.value - ? {p.value} - : ; - }, + // NEW SLOT (mode not yet worked on this band) → fill the cell. + cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot' + ? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 } + : undefined), + cellRenderer: (p: any) => p.value ? p.value : , + tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined), }, { group: 'Spot', label: 'Pfx', colId: 'pfx', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 70e1372..fbd1626 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -237,6 +237,8 @@ export function QSOAudioBegin():Promise; export function QSOAudioCancel():Promise; +export function QSOAudioRestart():Promise; + export function QuitApp():Promise; export function RefreshCtyDat():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index f93a170..a2d47f0 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -446,6 +446,10 @@ export function QSOAudioCancel() { return window['go']['main']['App']['QSOAudioCancel'](); } +export function QSOAudioRestart() { + return window['go']['main']['App']['QSOAudioRestart'](); +} + export function QuitApp() { return window['go']['main']['App']['QuitApp'](); } diff --git a/internal/audio/recorder.go b/internal/audio/recorder.go index 1aa2e98..bbdd1eb 100644 --- a/internal/audio/recorder.go +++ b/internal/audio/recorder.go @@ -206,6 +206,21 @@ func (r *Recorder) BeginQSO() { r.active = true } +// RestartQSO begins a fresh accumulation even if one is already active — +// re-seeding from the pre-roll ring. Used when the target QSO changes (a new +// call+freq from a clicked spot or an external app) so the previous take is +// dropped and a new one starts from the pre-roll, rather than continuing to +// accumulate the old contact. +func (r *Recorder) RestartQSO() { + r.mu.Lock() + defer r.mu.Unlock() + if !r.running { + return + } + r.acc = append([]int16(nil), r.ring...) + r.active = true +} + // SaveQSO writes the accumulated recording to path as a WAV and stops // accumulating. Returns an error if no recording was active. func (r *Recorder) SaveQSO(path string) error {