feat: materialize award references into the QSO row (award_refs column)
Award refs are now computed once and stored on the QSO as a JSON column
(award_refs, e.g. {"DDFM":"74","WAJA":"12"}) instead of recomputing the whole
award engine on every grid page load. Written on log/edit/UDP-import and
bulk-recomputed when an award definition or reference list changes; a one-time
per-logbook backfill materializes pre-existing QSOs. The grid reads the column
directly like any other field, so it's fast and available everywhere (including
the shared MySQL logbook).
Also fix per-profile grid column layout: the DB copy was already per-profile,
but the localStorage cache used one global namespace and shadowed it, so a
profile switch kept the previous profile's columns/widths. gridPrefs now scopes
the cache by active profile id and the grids remount on profile:changed.
This commit is contained in:
@@ -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)
|
||||
|
||||
+57
-26
@@ -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<string, string> {
|
||||
if (!s) return {};
|
||||
if (typeof s === 'object') return s as Record<string, string>;
|
||||
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<Record<string, Record<string, string>>>({});
|
||||
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<Record<string, Record<string, string>>>({});
|
||||
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<number | null>(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 (
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<RecentQSOsGrid
|
||||
key={`rqg-${activeProfileId ?? 'x'}`}
|
||||
rows={qsosWithAwards as any}
|
||||
total={total}
|
||||
awardCols={awardCols}
|
||||
@@ -4636,6 +4666,7 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<RecentQSOsGrid
|
||||
key={`rqg2-${activeProfileId ?? 'x'}`}
|
||||
rows={qsosWithAwards as any}
|
||||
total={total}
|
||||
awardCols={awardCols}
|
||||
|
||||
@@ -6,11 +6,32 @@
|
||||
// back to the DB copy and re-seed the cache.
|
||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
|
||||
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
|
||||
// the active profile id). The localStorage cache, however, is one namespace for
|
||||
// the whole WebView, so without scoping it too a profile switch would keep
|
||||
// serving the previous profile's cached layout and the correct per-profile DB
|
||||
// value would never win. lsScope makes the cache per-profile as well; it's set
|
||||
// once the active profile is known and updated on every profile switch.
|
||||
let lsScope = '';
|
||||
|
||||
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
|
||||
// the grids read their state (at startup) and again whenever the active profile
|
||||
// changes so each profile keeps its own column layout / widths.
|
||||
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||
}
|
||||
|
||||
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
|
||||
function lsKey(key: string): string {
|
||||
return lsScope + key;
|
||||
}
|
||||
|
||||
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||
// to apply instantly, before the async DB round-trip).
|
||||
export function loadLocal(key: string): any[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const raw = localStorage.getItem(lsKey(key));
|
||||
const v = raw ? JSON.parse(raw) : null;
|
||||
return Array.isArray(v) ? v : null;
|
||||
} catch {
|
||||
@@ -29,15 +50,16 @@ export async function loadRemote(key: string): Promise<any[] | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 */ }
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+2
@@ -666,6 +666,8 @@ export function QSOAudioRestart():Promise<boolean>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RecomputeAllAwardRefs():Promise<number>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function RefreshSolar():Promise<void>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -3648,6 +3648,7 @@ export namespace qso {
|
||||
my_arrl_sect?: string;
|
||||
my_vucc_grids?: string;
|
||||
extras?: Record<string, string>;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+58
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user