diff --git a/app.go b/app.go index 2b0d23d..daf0a53 100644 --- a/app.go +++ b/app.go @@ -812,6 +812,7 @@ func (a *App) startup(ctx context.Context) { applog.Printf("startup: logbook backend = %s", backend) a.logDb = logbookConn a.qso = qso.NewRepo(logbookConn) + a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag @@ -1873,6 +1874,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { q.ID = id a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh a.noteLiveQSO() // multi-op: flip this operator back "online" + a.materializeAwardRefs(q) // stamp award_refs so the grid columns show at once // Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT). wruntime.EventsEmit(a.ctx, "qso:logged", id) a.saveQSORecording(&q) @@ -2515,6 +2517,7 @@ func (a *App) SaveAwardDefs(defs []award.Def) error { return err } go a.mirrorAwardsToFolder(defs) + a.recomputeAwardRefsAsync() // definitions changed → refresh every row's award_refs return nil } @@ -3639,71 +3642,224 @@ func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) { return out, nil } -// AwardRefsForQSOs returns, per QSO id, a map of award code → the reference(s) -// that QSO contributes to (joined when several). Powers the per-award columns in -// the Recent QSOs / Worked-before grids. The reference metadata is computed ONCE -// for the whole batch so a page of QSOs stays cheap. -func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) { - out := map[int64]map[string]string{} - if a.qso == nil || len(ids) == 0 { - return out, nil - } +// awardMatCtx bundles the precompiled award state needed to derive a QSO's +// materialised references. Built ONCE (newAwardMatCtx) and reused across a batch +// or a full recompute so award.Compute's per-pattern compilation isn't repeated. +type awardMatCtx struct { + defs []award.Def + metas map[string][]award.RefMeta + fieldByCode map[string]string + dispByCode map[string]string + nameOf award.NameResolver +} + +func (a *App) newAwardMatCtx() awardMatCtx { defs := a.awardDefs() - metas := a.awardRefMetas(defs) fieldByCode := map[string]string{} dispByCode := map[string]string{} for _, d := range defs { fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field)) dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay)) } - nameOf := func(field, ref string) string { - switch field { - case "dxcc": - if n, err := strconv.Atoi(ref); err == nil { - return dxcc.NameForDXCC(n) + return awardMatCtx{ + defs: defs, + metas: a.awardRefMetas(defs), + fieldByCode: fieldByCode, + dispByCode: dispByCode, + nameOf: func(field, ref string) string { + switch field { + case "dxcc": + if n, err := strconv.Atoi(ref); err == nil { + return dxcc.NameForDXCC(n) + } + case "cont": + return continentName(ref) } - case "cont": - return continentName(ref) + return "" + }, + } +} + +// awardRefLabels derives, for one QSO, the map of award code → the reference(s) +// that QSO contributes to (joined when several), formatted per the award's +// RefDisplay choice (ref / name / both; DXCC shows the country name by default). +func (a *App) awardRefLabels(ac awardMatCtx, q qso.QSO) map[string]string { + a.enrichQSOForAwards(&q) + results := award.Compute(ac.defs, []qso.QSO{q}, ac.metas, ac.nameOf) + m := map[string]string{} + for i := range results { + r := &results[i] + code := strings.ToUpper(r.Code) + dxccField := ac.fieldByCode[code] == "dxcc" + var refs []string + for _, rf := range r.Refs { + if !rf.Worked { + continue + } + label := rf.Ref + switch ac.dispByCode[code] { + case "name": + if rf.Name != "" { + label = rf.Name + } + case "both": + if rf.Name != "" { + label = rf.Ref + " — " + rf.Name + } + default: // "" or "ref" + if dxccField && rf.Name != "" { + label = rf.Name + } + } + refs = append(refs, label) } + if len(refs) > 0 { + m[code] = strings.Join(refs, ", ") + } + } + return m +} + +// awardRefsJSONFor returns awardRefLabels marshalled to a compact JSON object +// keyed by award code, or "" when the QSO contributes to no award (blank column). +func (a *App) awardRefsJSONFor(ac awardMatCtx, q qso.QSO) string { + m := a.awardRefLabels(ac, q) + if len(m) == 0 { return "" } + b, err := json.Marshal(m) + if err != nil { + return "" + } + return string(b) +} + +// materializeAwardRefs computes ONE QSO's award references and stores them on the +// row (the award_refs column) so the grid columns read them straight from the DB. +// Cheap: an in-memory award.Compute plus one targeted UPDATE. Called on every +// log / edit / UDP-import. Never fatal — a failure just leaves the column stale +// until the next recompute. +func (a *App) materializeAwardRefs(q qso.QSO) { + if a.qso == nil || q.ID == 0 { + return + } + ac := a.newAwardMatCtx() + if err := a.qso.SetAwardRefs(a.ctx, q.ID, a.awardRefsJSONFor(ac, q)); err != nil { + applog.Printf("award_refs: store for qso %d failed: %v", q.ID, err) + } +} + +// materializeAwardRefsForIDs recomputes award_refs for a specific set of QSOs +// (e.g. after a bulk cty/QRZ/Club Log update or a bulk edit changed fields that +// feed awards). Writes only the changed rows, in one transaction. +func (a *App) materializeAwardRefsForIDs(ids []int64) { + if a.qso == nil || len(ids) == 0 { + return + } + ac := a.newAwardMatCtx() + changes := map[int64]string{} err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error { - a.enrichQSOForAwards(&q) - results := award.Compute(defs, []qso.QSO{q}, metas, nameOf) - m := map[string]string{} - for i := range results { - r := &results[i] - code := strings.ToUpper(r.Code) - dxccField := fieldByCode[code] == "dxcc" - var refs []string - for _, rf := range r.Refs { - if !rf.Worked { - continue - } - // Per-award display choice: ref (default), name (description), or - // both. DXCC keeps showing the country name under the default. - label := rf.Ref - switch dispByCode[code] { - case "name": - if rf.Name != "" { - label = rf.Name - } - case "both": - if rf.Name != "" { - label = rf.Ref + " — " + rf.Name - } - default: // "" or "ref" - if dxccField && rf.Name != "" { - label = rf.Name - } - } - refs = append(refs, label) - } - if len(refs) > 0 { - m[code] = strings.Join(refs, ", ") - } + if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs { + changes[q.ID] = js } - if len(m) > 0 { + return nil + }) + if err != nil { + applog.Printf("award_refs: recompute for ids failed: %v", err) + return + } + if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil { + applog.Printf("award_refs: batch write for ids failed: %v", err) + } +} + +// RecomputeAllAwardRefs rebuilds every QSO's materialised award references. Run +// when an award definition or reference list changes (stored labels would +// otherwise go stale) and once after upgrade to backfill existing rows. Rows +// whose result is unchanged are skipped; the changed ones are written in a single +// transaction (SetAwardRefsBatch) so even a large logbook on a remote MySQL is +// one round-trip's worth of work rather than N. Returns how many rows changed. +func (a *App) RecomputeAllAwardRefs() (int, error) { + if a.qso == nil { + return 0, fmt.Errorf("db not initialized") + } + ac := a.newAwardMatCtx() + // Collect updates during the scan; DON'T write mid-iteration (a nested query on + // the same connection can deadlock SQLite / trip MySQL "commands out of sync"). + changes := map[int64]string{} + err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error { + if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs { + changes[q.ID] = js + } + return nil + }) + if err != nil { + return 0, err + } + if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil { + return 0, err + } + return len(changes), nil +} + +// recomputeAwardRefsAsync runs a full recompute off the UI goroutine and, when +// done, tells the frontend to reload so the refreshed award columns show. Used +// wherever the set of matches could shift for MANY rows at once: an award +// definition / reference-list change, or a bulk ADIF import. +func (a *App) recomputeAwardRefsAsync() { + go func() { + n, err := a.RecomputeAllAwardRefs() + if err != nil { + applog.Printf("award_refs: bulk recompute failed: %v", err) + return + } + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "awards:recomputed", n) + } + }() +} + +// keyAwardsMaterialized marks (per profile / logbook) that the one-time award_refs +// backfill has run, so it doesn't re-scan the whole logbook on every launch. +const keyAwardsMaterialized = "awards.materialized.v1" + +// backfillAwardRefsOnce populates award_refs for QSOs that predate the feature +// (or were logged by an older client that didn't materialise them). It runs at +// most once per logbook — guarded by a per-profile flag — in the background so it +// never delays startup, even on a large remote MySQL logbook. +func (a *App) backfillAwardRefsOnce() { + if a.qso == nil || a.settings == nil { + return + } + if done, _ := a.settings.Get(a.ctx, keyAwardsMaterialized); done == "1" { + return + } + go func() { + n, err := a.RecomputeAllAwardRefs() + if err != nil { + applog.Printf("award_refs: initial backfill failed: %v", err) + return + } + _ = a.settings.Set(a.ctx, keyAwardsMaterialized, "1") + applog.Printf("award_refs: backfilled %d QSO(s)", n) + if a.ctx != nil && n > 0 { + wruntime.EventsEmit(a.ctx, "awards:recomputed", n) + } + }() +} + +// AwardRefsForQSOs returns, per QSO id, the award code → reference(s) map. It is +// the live (non-materialised) path, kept for callers that want fresh values +// without touching the DB. The grid now reads the stored award_refs column +// instead, but this stays available and is the single source of the label logic. +func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) { + out := map[int64]map[string]string{} + if a.qso == nil || len(ids) == 0 { + return out, nil + } + ac := a.newAwardMatCtx() + err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error { + if m := a.awardRefLabels(ac, q); len(m) > 0 { out[q.ID] = m } return nil @@ -3749,6 +3905,17 @@ func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) { // UpdateAwardReferenceList downloads the latest reference list for an award and // replaces the stored set. Returns the new reference count. func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) { + meta, err := a.updateAwardReferenceList(code) + if err == nil { + a.recomputeAwardRefsAsync() // new reference list → labels change → refresh rows + } + return meta, err +} + +// updateAwardReferenceList is the recompute-free core, so DownloadAllReferenceLists +// can update several lists in a loop and trigger ONE bulk recompute at the end +// rather than one per list. +func (a *App) updateAwardReferenceList(code string) (AwardRefMeta, error) { if a.awardRefs == nil { return AwardRefMeta{}, fmt.Errorf("db not initialized") } @@ -3785,7 +3952,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) { if !awardref.CanUpdate(code) { continue } - meta, err := a.UpdateAwardReferenceList(code) + meta, err := a.updateAwardReferenceList(code) if err != nil { parts = append(parts, fmt.Sprintf("%s ✗", code)) if firstErr == nil { @@ -3795,6 +3962,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) { } parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count)) } + a.recomputeAwardRefsAsync() // one bulk recompute after all lists updated return strings.Join(parts, " · "), firstErr } @@ -3838,6 +4006,7 @@ func (a *App) DeleteAwardReference(code, refCode string) error { } a.markAwardEdited(code) a.mirrorAwards() + a.recomputeAwardRefsAsync() return nil } @@ -3881,6 +4050,7 @@ func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, err } a.markAwardEdited(code) a.mirrorAwards() + a.recomputeAwardRefsAsync() // reference list replaced → refresh every row's award_refs return n, nil } @@ -4485,6 +4655,7 @@ func (a *App) UpdateQSO(q qso.QSO) error { err := a.qso.Update(a.ctx, q) if err == nil { a.invalidateAwardStats() + a.materializeAwardRefs(q) // fields may have changed → refresh award_refs } return err } @@ -4812,6 +4983,7 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) { } if n > 0 { a.invalidateAwardStats() + a.materializeAwardRefsForIDs(ids) // the edited field may feed an award → refresh } return n, nil } @@ -4961,7 +5133,11 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio im.OnProgress = func(processed, total int) { wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total}) } - return im.ImportFile(a.ctx, path) + res, err := im.ImportFile(a.ctx, path) + if err == nil && (res.Imported > 0 || res.Updated > 0) { + a.recomputeAwardRefsAsync() // materialise award_refs for the imported rows + } + return res, err } // SaveADIFFile shows a native Save-As dialog suggesting a timestamped @@ -8760,6 +8936,7 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) { } if changed > 0 { a.invalidateAwardStats() + a.materializeAwardRefsForIDs(ids) // entity fields changed → refresh award_refs } return changed, nil } @@ -8835,6 +9012,7 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) { } if changed > 0 { a.invalidateAwardStats() + a.materializeAwardRefsForIDs(ids) // entity/geo fields changed → refresh award_refs } return changed, nil } @@ -9290,6 +9468,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { } q.ID = id a.noteLiveQSO() // multi-op: flip this operator back "online" + a.materializeAwardRefs(q) a.saveQSORecording(&q) if a.extsvc != nil { a.extsvc.OnQSOLogged(id) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 76b9156..140f57f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,9 +41,8 @@ import { ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock, GetAwardDefs, - GetUIPref, + GetUIPref, GetActiveProfile, ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec, - AwardRefsForQSOs, } from '../wailsjs/go/main/App'; import { Combobox } from '@/components/ui/combobox'; import { applyAwardRefs } from '@/lib/awardRefs'; @@ -87,6 +86,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { RotorCompass } from '@/components/RotorCompass'; import { writeUiPref } from '@/lib/uiPref'; +import { setGridPrefsProfile } from '@/lib/gridPrefs'; import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel'; import { Button } from '@/components/ui/button'; @@ -135,6 +135,22 @@ const emptyDetails: DetailsState = { award_refs: '', }; +// parseAwardRefs turns the QSO row's materialised award_refs JSON string +// ({"DDFM":"74","WAJA":"12"}) into the code→ref object the grid columns read. +// Tolerant of empty / malformed values (returns {}), and passes an already-parsed +// object straight through. +function parseAwardRefs(s: any): Record { + if (!s) return {}; + if (typeof s === 'object') return s as Record; + if (typeof s !== 'string') return {}; + try { + const o = JSON.parse(s); + return o && typeof o === 'object' ? o : {}; + } catch { + return {}; + } +} + function fmtDateUTC(s: any): string { if (!s) return ''; const d = new Date(s); @@ -1234,38 +1250,28 @@ export default function App() { // list once, then compute each shown QSO's reference per award and attach it // to the rows (the grids render one hideable column per award). const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]); - // Bumped whenever award definitions are saved so the grid columns AND the - // per-QSO refs re-fetch — the ref/name display choice is computed live, so - // changing it updates ALL contacts (old included) with no restart. + // Bumped when award definitions change (or the backend finishes a bulk + // award_refs recompute) so the set of award COLUMNS re-reads from GetAwardDefs. + // The per-QSO values themselves ride on the row (award_refs) and refresh with + // the grid reload triggered alongside this bump. const [awardsVersion, setAwardsVersion] = useState(0); useEffect(() => { GetAwardDefs().then((defs: any[]) => setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))), ).catch(() => {}); }, [awardsVersion]); - const [qsoAwardRefs, setQsoAwardRefs] = useState>>({}); - useEffect(() => { - const ids = (qsos as any[]).map((q) => q.id).filter(Boolean); - if (ids.length === 0 || awardCols.length === 0) { setQsoAwardRefs({}); return; } - let alive = true; - AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {}); - return () => { alive = false; }; - }, [qsos, awardCols.length, awardsVersion]); + // Award references are now MATERIALISED on the QSO row (the award_refs JSON + // column, written by the backend on log/edit and bulk-recomputed when awards + // change). The grid reads them straight from the row — no per-page backend + // recompute — so here we just parse the stored JSON string into the code→ref + // object the award columns expect (keys are already upper-case). const qsosWithAwards = useMemo( - () => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })), - [qsos, qsoAwardRefs], + () => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })), + [qsos], ); - const [wbAwardRefs, setWbAwardRefs] = useState>>({}); - useEffect(() => { - const ids = ((wb?.entries ?? []) as any[]).map((e) => e.id).filter(Boolean); - if (ids.length === 0 || awardCols.length === 0) { setWbAwardRefs({}); return; } - let alive = true; - AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {}); - return () => { alive = false; }; - }, [wb, awardCols.length, awardsVersion]); const wbWithAwards = useMemo( - () => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null), - [wb, wbAwardRefs], + () => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: parseAwardRefs(e.award_refs) })) } : null), + [wb], ); // Always-current copy of the entry callsign, so the UDP event handlers // (which live in a []-deps effect with a stale `callsign` closure) can @@ -1426,6 +1432,15 @@ export default function App() { return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); }; }, [refresh]); + // The backend bulk-recomputed the materialised award_refs (an award definition + // or reference list changed, or the one-time backfill ran). Reload the rows so + // the award columns show the new values, and bump awardsVersion so the set of + // award COLUMNS refreshes too (a new award may have appeared). + useEffect(() => { + const off = EventsOn('awards:recomputed', () => { refresh(); setAwardsVersion((v) => v + 1); }); + return () => { off(); }; + }, [refresh]); + // Backend-emitted toast messages (e.g. recording auto-send result/skip). useEffect(() => { const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); }); @@ -2025,11 +2040,25 @@ export default function App() { } catch { /* keyer not configured */ } }, []); + // Active profile id — scopes the grids' column layout (visibility / width / + // order) so each profile keeps its own. Seeded once on mount and updated on + // every profile switch; the grids take it as their React key so they remount + // and re-read the now-correct per-profile layout. + const [activeProfileId, setActiveProfileId] = useState(null); + useEffect(() => { + GetActiveProfile().then((p: any) => { + if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); } + }).catch(() => {}); + }, []); + // Every setting is per-profile, so when the active profile changes the whole // main UI re-reads its config (station identity, lists, CAT, keyer). The Go // side reloads its managers; this keeps the React state in sync. useEffect(() => { - const off = EventsOn('profile:changed', () => { + const off = EventsOn('profile:changed', (id: any) => { + // Re-scope the grid column layout BEFORE the grids remount (key change). + setGridPrefsProfile(id ?? null); + setActiveProfileId(typeof id === 'number' ? id : null); loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); // The chat is per shared logbook — clear the previous profile's messages // and reload for the new logbook (or hide if it isn't a MySQL log). @@ -3610,6 +3639,7 @@ export default function App() { return (
{ } } -// saveState write-throughs to both the cache and the DB (fire-and-forget). +// saveState write-throughs to both the cache and the DB (fire-and-forget). Only +// the cache key is profile-scoped; the DB key is scoped by the backend. export function saveState(key: string, state: any[]) { const json = JSON.stringify(state); - try { localStorage.setItem(key, json); } catch { /* quota / private mode */ } + try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ } SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ }); } // seedLocal writes a value into the cache without touching the DB (used after // hydrating the cache from the DB on a fresh machine). export function seedLocal(key: string, state: any[]) { - try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ } + try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ } } diff --git a/frontend/src/lib/uiPref.ts b/frontend/src/lib/uiPref.ts index 3981992..7bcb798 100644 --- a/frontend/src/lib/uiPref.ts +++ b/frontend/src/lib/uiPref.ts @@ -32,7 +32,10 @@ const PORTABLE_KEYS = [ 'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter', 'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked', 'opslog.activeTab', // last selected tab - 'hamlog.awardColsShown', // which award columns are shown in the QSO grid + // NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here. + // They are handled by lib/gridPrefs, which scopes the localStorage cache PER + // PROFILE and mirrors to the DB (already per-profile) itself — mirroring them + // through this global path would fight that per-profile scoping. ]; // syncPortablePrefs reconciles the DB with the local cache at startup: diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index cb5d99b..42e6d4d 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -666,6 +666,8 @@ export function QSOAudioRestart():Promise; export function QuitApp():Promise; +export function RecomputeAllAwardRefs():Promise; + export function RefreshCtyDat():Promise; export function RefreshSolar():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 0aa9deb..3527173 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1290,6 +1290,10 @@ export function QuitApp() { return window['go']['main']['App']['QuitApp'](); } +export function RecomputeAllAwardRefs() { + return window['go']['main']['App']['RecomputeAllAwardRefs'](); +} + export function RefreshCtyDat() { return window['go']['main']['App']['RefreshCtyDat'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 7cbd68c..e652eb1 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -3648,6 +3648,7 @@ export namespace qso { my_arrl_sect?: string; my_vucc_grids?: string; extras?: Record; + award_refs?: string; // Go type: time created_at: any; // Go type: time @@ -3785,6 +3786,7 @@ export namespace qso { this.my_arrl_sect = source["my_arrl_sect"]; this.my_vucc_grids = source["my_vucc_grids"]; this.extras = source["extras"]; + this.award_refs = source["award_refs"]; this.created_at = this.convertValues(source["created_at"], null); this.updated_at = this.convertValues(source["updated_at"], null); } diff --git a/internal/db/migrations/0023_award_refs.sql b/internal/db/migrations/0023_award_refs.sql new file mode 100644 index 0000000..e1b518d --- /dev/null +++ b/internal/db/migrations/0023_award_refs.sql @@ -0,0 +1,10 @@ +-- Materialised award references per QSO. As soon as a QSO matches an award +-- (via the operator's award definitions) or a reference is set by hand, the +-- resolved reference(s) are stored here as a compact JSON object keyed by award +-- code, e.g. {"DDFM":"74","WAJA":"12"}. The grid's per-award columns then read +-- straight from the row like any other column instead of recomputing the whole +-- award engine on every page load — much faster, and easy to display anywhere +-- (including the shared MySQL logbook). Kept in step by the app: written on log +-- / edit / UDP-import and bulk-recomputed when an award definition or reference +-- list changes. SQLite ADD COLUMN is metadata-only, fast even on large logbooks. +ALTER TABLE qso ADD COLUMN award_refs TEXT; diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 0e4a919..e75a32f 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -197,6 +197,14 @@ type QSO struct { // ADIF field names (e.g. "MS_SHOWER"); values are the raw string content. Extras map[string]string `json:"extras,omitempty"` + // AwardRefs is the materialised award-reference JSON for this QSO — a compact + // object keyed by award code, e.g. {"DDFM":"74","WAJA":"12"}. Derived (the app + // computes it on log/edit and bulk-recomputes it when awards change) and stored + // so the grid's award columns read straight from the row. Written ONLY via + // SetAwardRefs, never through the normal insert/update column list, so an edit + // that doesn't know about it can't clobber it. + AwardRefs string `json:"award_refs,omitempty"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -250,7 +258,10 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids, extras_json` -const selectCols = `id, ` + columnList + `, created_at, updated_at` +// award_refs is read here but is NOT part of columnList (the insert/update +// write path) — it is a derived cache written only via SetAwardRefs, so a +// normal QSO write can never clobber it. +const selectCols = `id, ` + columnList + `, award_refs, created_at, updated_at` // columnCount is derived from columnList at init so they can never drift. var columnCount = countColumns(columnList) @@ -813,6 +824,49 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error return nil } +// SetAwardRefs stores the materialised award-reference JSON for one QSO. Like +// SetExtra it is a targeted single-column UPDATE — never a full-row write — so it +// cannot clobber a field another action changed meanwhile. updated_at is left +// UNTOUCHED on purpose: award_refs is a derived cache, and bumping updated_at +// would masquerade as a real edit (re-triggering uploads / sync that watch it). +func (r *Repo) SetAwardRefs(ctx context.Context, id int64, jsonStr string) error { + if id == 0 { + return fmt.Errorf("missing id") + } + if _, err := r.db.ExecContext(ctx, + `UPDATE qso SET award_refs = ? WHERE id = ?`, jsonStr, id); err != nil { + return fmt.Errorf("set award_refs: %w", err) + } + return nil +} + +// SetAwardRefsBatch stores materialised award refs for many QSOs in a single +// transaction — used by the bulk recompute so a large logbook on a remote MySQL +// is one round-trip's worth of work, not N. Like SetAwardRefs it touches only the +// award_refs column and leaves updated_at alone (derived cache). A nil/empty map +// is a no-op. +func (r *Repo) SetAwardRefsBatch(ctx context.Context, byID map[int64]string) error { + if len(byID) == 0 { + return nil + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + stmt, err := tx.PrepareContext(ctx, `UPDATE qso SET award_refs = ? WHERE id = ?`) + if err != nil { + return fmt.Errorf("prepare: %w", err) + } + defer stmt.Close() + for id, js := range byID { + if _, err := stmt.ExecContext(ctx, js, id); err != nil { + return fmt.Errorf("set award_refs %d: %w", id, err) + } + } + return tx.Commit() +} + // Update overwrites all editable fields of an existing QSO. updated_at is bumped. func (r *Repo) Update(ctx context.Context, q QSO) error { if q.ID == 0 { @@ -2374,6 +2428,7 @@ func scanQSO(s scanner) (QSO, error) { creditGranted, creditSubmitted sql.NullString myARRLSect, myVUCCGrids sql.NullString extrasJSON sql.NullString + awardRefs sql.NullString createdStr, updatedStr string ) if err := s.Scan( @@ -2401,7 +2456,7 @@ func scanQSO(s scanner) (QSO, error) { &skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class, &darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom, &creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids, - &extrasJSON, &createdStr, &updatedStr, + &extrasJSON, &awardRefs, &createdStr, &updatedStr, ); err != nil { return QSO{}, fmt.Errorf("scan qso: %w", err) } @@ -2598,6 +2653,7 @@ func scanQSO(s scanner) (QSO, error) { q.MyARRLSect = myARRLSect.String q.MyVUCCGrids = myVUCCGrids.String q.Extras = decodeExtras(extrasJSON.String) + q.AwardRefs = awardRefs.String return q, nil }