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)
|
applog.Printf("startup: logbook backend = %s", backend)
|
||||||
a.logDb = logbookConn
|
a.logDb = logbookConn
|
||||||
a.qso = qso.NewRepo(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.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
||||||
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
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
|
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
|
q.ID = id
|
||||||
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
|
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
|
||||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
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).
|
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
|
||||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||||
a.saveQSORecording(&q)
|
a.saveQSORecording(&q)
|
||||||
@@ -2515,6 +2517,7 @@ func (a *App) SaveAwardDefs(defs []award.Def) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go a.mirrorAwardsToFolder(defs)
|
go a.mirrorAwardsToFolder(defs)
|
||||||
|
a.recomputeAwardRefsAsync() // definitions changed → refresh every row's award_refs
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3639,71 +3642,224 @@ func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AwardRefsForQSOs returns, per QSO id, a map of award code → the reference(s)
|
// awardMatCtx bundles the precompiled award state needed to derive a QSO's
|
||||||
// that QSO contributes to (joined when several). Powers the per-award columns in
|
// materialised references. Built ONCE (newAwardMatCtx) and reused across a batch
|
||||||
// the Recent QSOs / Worked-before grids. The reference metadata is computed ONCE
|
// or a full recompute so award.Compute's per-pattern compilation isn't repeated.
|
||||||
// for the whole batch so a page of QSOs stays cheap.
|
type awardMatCtx struct {
|
||||||
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) {
|
defs []award.Def
|
||||||
out := map[int64]map[string]string{}
|
metas map[string][]award.RefMeta
|
||||||
if a.qso == nil || len(ids) == 0 {
|
fieldByCode map[string]string
|
||||||
return out, nil
|
dispByCode map[string]string
|
||||||
}
|
nameOf award.NameResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) newAwardMatCtx() awardMatCtx {
|
||||||
defs := a.awardDefs()
|
defs := a.awardDefs()
|
||||||
metas := a.awardRefMetas(defs)
|
|
||||||
fieldByCode := map[string]string{}
|
fieldByCode := map[string]string{}
|
||||||
dispByCode := map[string]string{}
|
dispByCode := map[string]string{}
|
||||||
for _, d := range defs {
|
for _, d := range defs {
|
||||||
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
||||||
dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay))
|
dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay))
|
||||||
}
|
}
|
||||||
nameOf := func(field, ref string) string {
|
return awardMatCtx{
|
||||||
switch field {
|
defs: defs,
|
||||||
case "dxcc":
|
metas: a.awardRefMetas(defs),
|
||||||
if n, err := strconv.Atoi(ref); err == nil {
|
fieldByCode: fieldByCode,
|
||||||
return dxcc.NameForDXCC(n)
|
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 ""
|
||||||
return continentName(ref)
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ""
|
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 {
|
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
|
||||||
a.enrichQSOForAwards(&q)
|
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
|
||||||
results := award.Compute(defs, []qso.QSO{q}, metas, nameOf)
|
changes[q.ID] = js
|
||||||
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 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
|
out[q.ID] = m
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -3749,6 +3905,17 @@ func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) {
|
|||||||
// UpdateAwardReferenceList downloads the latest reference list for an award and
|
// UpdateAwardReferenceList downloads the latest reference list for an award and
|
||||||
// replaces the stored set. Returns the new reference count.
|
// replaces the stored set. Returns the new reference count.
|
||||||
func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
|
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 {
|
if a.awardRefs == nil {
|
||||||
return AwardRefMeta{}, fmt.Errorf("db not initialized")
|
return AwardRefMeta{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
@@ -3785,7 +3952,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) {
|
|||||||
if !awardref.CanUpdate(code) {
|
if !awardref.CanUpdate(code) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
meta, err := a.UpdateAwardReferenceList(code)
|
meta, err := a.updateAwardReferenceList(code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
parts = append(parts, fmt.Sprintf("%s ✗", code))
|
parts = append(parts, fmt.Sprintf("%s ✗", code))
|
||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
@@ -3795,6 +3962,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) {
|
|||||||
}
|
}
|
||||||
parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count))
|
parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count))
|
||||||
}
|
}
|
||||||
|
a.recomputeAwardRefsAsync() // one bulk recompute after all lists updated
|
||||||
return strings.Join(parts, " · "), firstErr
|
return strings.Join(parts, " · "), firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3838,6 +4006,7 @@ func (a *App) DeleteAwardReference(code, refCode string) error {
|
|||||||
}
|
}
|
||||||
a.markAwardEdited(code)
|
a.markAwardEdited(code)
|
||||||
a.mirrorAwards()
|
a.mirrorAwards()
|
||||||
|
a.recomputeAwardRefsAsync()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3881,6 +4050,7 @@ func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, err
|
|||||||
}
|
}
|
||||||
a.markAwardEdited(code)
|
a.markAwardEdited(code)
|
||||||
a.mirrorAwards()
|
a.mirrorAwards()
|
||||||
|
a.recomputeAwardRefsAsync() // reference list replaced → refresh every row's award_refs
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4485,6 +4655,7 @@ func (a *App) UpdateQSO(q qso.QSO) error {
|
|||||||
err := a.qso.Update(a.ctx, q)
|
err := a.qso.Update(a.ctx, q)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
a.invalidateAwardStats()
|
a.invalidateAwardStats()
|
||||||
|
a.materializeAwardRefs(q) // fields may have changed → refresh award_refs
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -4812,6 +4983,7 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
|
|||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
a.invalidateAwardStats()
|
a.invalidateAwardStats()
|
||||||
|
a.materializeAwardRefsForIDs(ids) // the edited field may feed an award → refresh
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
@@ -4961,7 +5133,11 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
|
|||||||
im.OnProgress = func(processed, total int) {
|
im.OnProgress = func(processed, total int) {
|
||||||
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
|
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
|
// 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 {
|
if changed > 0 {
|
||||||
a.invalidateAwardStats()
|
a.invalidateAwardStats()
|
||||||
|
a.materializeAwardRefsForIDs(ids) // entity fields changed → refresh award_refs
|
||||||
}
|
}
|
||||||
return changed, nil
|
return changed, nil
|
||||||
}
|
}
|
||||||
@@ -8835,6 +9012,7 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
|
|||||||
}
|
}
|
||||||
if changed > 0 {
|
if changed > 0 {
|
||||||
a.invalidateAwardStats()
|
a.invalidateAwardStats()
|
||||||
|
a.materializeAwardRefsForIDs(ids) // entity/geo fields changed → refresh award_refs
|
||||||
}
|
}
|
||||||
return changed, nil
|
return changed, nil
|
||||||
}
|
}
|
||||||
@@ -9290,6 +9468,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
}
|
}
|
||||||
q.ID = id
|
q.ID = id
|
||||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||||
|
a.materializeAwardRefs(q)
|
||||||
a.saveQSORecording(&q)
|
a.saveQSORecording(&q)
|
||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
a.extsvc.OnQSOLogged(id)
|
a.extsvc.OnQSOLogged(id)
|
||||||
|
|||||||
+57
-26
@@ -41,9 +41,8 @@ import {
|
|||||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref,
|
GetUIPref, GetActiveProfile,
|
||||||
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
|
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
|
||||||
AwardRefsForQSOs,
|
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
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 { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { setGridPrefsProfile } from '@/lib/gridPrefs';
|
||||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -135,6 +135,22 @@ const emptyDetails: DetailsState = {
|
|||||||
award_refs: '',
|
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 {
|
function fmtDateUTC(s: any): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
const d = new Date(s);
|
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
|
// 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).
|
// to the rows (the grids render one hideable column per award).
|
||||||
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
||||||
// Bumped whenever award definitions are saved so the grid columns AND the
|
// Bumped when award definitions change (or the backend finishes a bulk
|
||||||
// per-QSO refs re-fetch — the ref/name display choice is computed live, so
|
// award_refs recompute) so the set of award COLUMNS re-reads from GetAwardDefs.
|
||||||
// changing it updates ALL contacts (old included) with no restart.
|
// 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);
|
const [awardsVersion, setAwardsVersion] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetAwardDefs().then((defs: any[]) =>
|
GetAwardDefs().then((defs: any[]) =>
|
||||||
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}, [awardsVersion]);
|
}, [awardsVersion]);
|
||||||
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
// Award references are now MATERIALISED on the QSO row (the award_refs JSON
|
||||||
useEffect(() => {
|
// column, written by the backend on log/edit and bulk-recomputed when awards
|
||||||
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
|
// change). The grid reads them straight from the row — no per-page backend
|
||||||
if (ids.length === 0 || awardCols.length === 0) { setQsoAwardRefs({}); return; }
|
// recompute — so here we just parse the stored JSON string into the code→ref
|
||||||
let alive = true;
|
// object the award columns expect (keys are already upper-case).
|
||||||
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
|
|
||||||
return () => { alive = false; };
|
|
||||||
}, [qsos, awardCols.length, awardsVersion]);
|
|
||||||
const qsosWithAwards = useMemo(
|
const qsosWithAwards = useMemo(
|
||||||
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
|
() => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
|
||||||
[qsos, qsoAwardRefs],
|
[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(
|
const wbWithAwards = useMemo(
|
||||||
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
|
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: parseAwardRefs(e.award_refs) })) } : null),
|
||||||
[wb, wbAwardRefs],
|
[wb],
|
||||||
);
|
);
|
||||||
// Always-current copy of the entry callsign, so the UDP event handlers
|
// Always-current copy of the entry callsign, so the UDP event handlers
|
||||||
// (which live in a []-deps effect with a stale `callsign` closure) can
|
// (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); };
|
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
||||||
}, [refresh]);
|
}, [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).
|
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
|
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
|
||||||
@@ -2025,11 +2040,25 @@ export default function App() {
|
|||||||
} catch { /* keyer not configured */ }
|
} 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
|
// 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
|
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
||||||
// side reloads its managers; this keeps the React state in sync.
|
// side reloads its managers; this keeps the React state in sync.
|
||||||
useEffect(() => {
|
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();
|
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
|
||||||
// The chat is per shared logbook — clear the previous profile's messages
|
// 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).
|
// and reload for the new logbook (or hide if it isn't a MySQL log).
|
||||||
@@ -3610,6 +3639,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
key={`rqg-${activeProfileId ?? 'x'}`}
|
||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
total={total}
|
total={total}
|
||||||
awardCols={awardCols}
|
awardCols={awardCols}
|
||||||
@@ -4636,6 +4666,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
key={`rqg2-${activeProfileId ?? 'x'}`}
|
||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
total={total}
|
total={total}
|
||||||
awardCols={awardCols}
|
awardCols={awardCols}
|
||||||
|
|||||||
@@ -6,11 +6,32 @@
|
|||||||
// back to the DB copy and re-seed the cache.
|
// back to the DB copy and re-seed the cache.
|
||||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
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
|
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||||
// to apply instantly, before the async DB round-trip).
|
// to apply instantly, before the async DB round-trip).
|
||||||
export function loadLocal(key: string): any[] | null {
|
export function loadLocal(key: string): any[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(lsKey(key));
|
||||||
const v = raw ? JSON.parse(raw) : null;
|
const v = raw ? JSON.parse(raw) : null;
|
||||||
return Array.isArray(v) ? v : null;
|
return Array.isArray(v) ? v : null;
|
||||||
} catch {
|
} 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[]) {
|
export function saveState(key: string, state: any[]) {
|
||||||
const json = JSON.stringify(state);
|
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 */ });
|
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedLocal writes a value into the cache without touching the DB (used after
|
// seedLocal writes a value into the cache without touching the DB (used after
|
||||||
// hydrating the cache from the DB on a fresh machine).
|
// hydrating the cache from the DB on a fresh machine).
|
||||||
export function seedLocal(key: string, state: any[]) {
|
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.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
'opslog.activeTab', // last selected tab
|
'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:
|
// 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 QuitApp():Promise<void>;
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs():Promise<number>;
|
||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function RefreshSolar():Promise<void>;
|
export function RefreshSolar():Promise<void>;
|
||||||
|
|||||||
@@ -1290,6 +1290,10 @@ export function QuitApp() {
|
|||||||
return window['go']['main']['App']['QuitApp']();
|
return window['go']['main']['App']['QuitApp']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs() {
|
||||||
|
return window['go']['main']['App']['RecomputeAllAwardRefs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshCtyDat() {
|
export function RefreshCtyDat() {
|
||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3648,6 +3648,7 @@ export namespace qso {
|
|||||||
my_arrl_sect?: string;
|
my_arrl_sect?: string;
|
||||||
my_vucc_grids?: string;
|
my_vucc_grids?: string;
|
||||||
extras?: Record<string, string>;
|
extras?: Record<string, string>;
|
||||||
|
award_refs?: string;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
created_at: any;
|
created_at: any;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
@@ -3785,6 +3786,7 @@ export namespace qso {
|
|||||||
this.my_arrl_sect = source["my_arrl_sect"];
|
this.my_arrl_sect = source["my_arrl_sect"];
|
||||||
this.my_vucc_grids = source["my_vucc_grids"];
|
this.my_vucc_grids = source["my_vucc_grids"];
|
||||||
this.extras = source["extras"];
|
this.extras = source["extras"];
|
||||||
|
this.award_refs = source["award_refs"];
|
||||||
this.created_at = this.convertValues(source["created_at"], null);
|
this.created_at = this.convertValues(source["created_at"], null);
|
||||||
this.updated_at = this.convertValues(source["updated_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.
|
// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
|
||||||
Extras map[string]string `json:"extras,omitempty"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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,
|
credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
|
||||||
extras_json`
|
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.
|
// columnCount is derived from columnList at init so they can never drift.
|
||||||
var columnCount = countColumns(columnList)
|
var columnCount = countColumns(columnList)
|
||||||
@@ -813,6 +824,49 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error
|
|||||||
return nil
|
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.
|
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||||
if q.ID == 0 {
|
if q.ID == 0 {
|
||||||
@@ -2374,6 +2428,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
creditGranted, creditSubmitted sql.NullString
|
creditGranted, creditSubmitted sql.NullString
|
||||||
myARRLSect, myVUCCGrids sql.NullString
|
myARRLSect, myVUCCGrids sql.NullString
|
||||||
extrasJSON sql.NullString
|
extrasJSON sql.NullString
|
||||||
|
awardRefs sql.NullString
|
||||||
createdStr, updatedStr string
|
createdStr, updatedStr string
|
||||||
)
|
)
|
||||||
if err := s.Scan(
|
if err := s.Scan(
|
||||||
@@ -2401,7 +2456,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
||||||
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
||||||
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
||||||
&extrasJSON, &createdStr, &updatedStr,
|
&extrasJSON, &awardRefs, &createdStr, &updatedStr,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
||||||
}
|
}
|
||||||
@@ -2598,6 +2653,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
q.MyARRLSect = myARRLSect.String
|
q.MyARRLSect = myARRLSect.String
|
||||||
q.MyVUCCGrids = myVUCCGrids.String
|
q.MyVUCCGrids = myVUCCGrids.String
|
||||||
q.Extras = decodeExtras(extrasJSON.String)
|
q.Extras = decodeExtras(extrasJSON.String)
|
||||||
|
q.AwardRefs = awardRefs.String
|
||||||
return q, nil
|
return q, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user