Compare commits

...
6 Commits
Author SHA1 Message Date
rouggy 77a752efe3 chore: release v0.20.4 2026-07-20 12:06:32 +02:00
rouggy 781fbfaa30 fix: wire up File - Exit (was disabled and had no handler)
The File menu's Exit item was hard-disabled and its action had no case in the
menu dispatcher, so it did nothing. Enable it and route file.exit to QuitApp,
which runs the same graceful beforeClose shutdown as the window close button.
2026-07-20 12:05:12 +02:00
rouggy 61c11c0fe3 fix: clear entry-form award refs when the callsign is wiped
Live award detection merges pickable refs into award_refs per call, but nothing
dropped them when the call was cleared, so a previous call's ref lingered onto
the next (IT9AOT's ref still showing after typing F4BPO, both listed in the F3
Awards tab). Clear award_refs the moment the callsign field is emptied.
2026-07-20 11:57:04 +02:00
rouggy 64b746f007 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.
2026-07-20 11:51:49 +02:00
rouggy 9cc72c7575 fix: OmniRig reads VFO A first (FreqA→Freq fallback), fixing IC-7610 CAT
The 7610-only FreqA gate broke CAT on the 7610 when FreqA was momentarily 0.
Match DXHunter/WSJT-X: read FreqA first for all rigs, fall back to the generic
Freq (IC-9100 etc.), then FreqB. OmniRig's generic Freq maps to VFO B on the
7610, which is why keying off FreqA is correct.
2026-07-20 10:11:10 +02:00
rouggy 9e4f43f648 fix: offline operators removed from live_status + award-column visibility
Live status: when an operator goes offline (no QSO in the window) OpsLog now
DELETEs its live_status row instead of just flipping online=0, so a status page
that lists present/recent rows shows them as gone without having to read the
online column — the whole point of the feature. The row reappears on the next
QSO.

Recent QSOs columns: toggling an award column (e.g. DDFM) rebuilt columnDefs and
made AG Grid re-apply every colDef hide default, so hidden non-award columns
(QTH/Grid) came back and restoringRef stayed stuck true (saving off). The
restore-saved-state effect now also runs on awardShown changes, so the other
columns keep the user's visibility.
2026-07-20 09:55:14 +02:00
14 changed files with 444 additions and 132 deletions
+199 -20
View File
@@ -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,24 +3642,31 @@ 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{
defs: defs,
metas: a.awardRefMetas(defs),
fieldByCode: fieldByCode,
dispByCode: dispByCode,
nameOf: func(field, ref string) string {
switch field { switch field {
case "dxcc": case "dxcc":
if n, err := strconv.Atoi(ref); err == nil { if n, err := strconv.Atoi(ref); err == nil {
@@ -3666,24 +3676,28 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
return continentName(ref) return continentName(ref)
} }
return "" return ""
},
} }
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error { }
// 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) a.enrichQSOForAwards(&q)
results := award.Compute(defs, []qso.QSO{q}, metas, nameOf) results := award.Compute(ac.defs, []qso.QSO{q}, ac.metas, ac.nameOf)
m := map[string]string{} m := map[string]string{}
for i := range results { for i := range results {
r := &results[i] r := &results[i]
code := strings.ToUpper(r.Code) code := strings.ToUpper(r.Code)
dxccField := fieldByCode[code] == "dxcc" dxccField := ac.fieldByCode[code] == "dxcc"
var refs []string var refs []string
for _, rf := range r.Refs { for _, rf := range r.Refs {
if !rf.Worked { if !rf.Worked {
continue continue
} }
// Per-award display choice: ref (default), name (description), or
// both. DXCC keeps showing the country name under the default.
label := rf.Ref label := rf.Ref
switch dispByCode[code] { switch ac.dispByCode[code] {
case "name": case "name":
if rf.Name != "" { if rf.Name != "" {
label = rf.Name label = rf.Name
@@ -3703,7 +3717,149 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
m[code] = strings.Join(refs, ", ") m[code] = strings.Join(refs, ", ")
} }
} }
if len(m) > 0 { 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 {
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
changes[q.ID] = js
}
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)
+67 -28
View File
@@ -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, QuitApp,
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).
@@ -2643,7 +2672,14 @@ export default function App() {
// keeps the pre-roll from before this); clearing it discards the take. // keeps the pre-roll from before this); clearing it discards the take.
// Recording START happens on blur (leaving the callsign field), NOT here — // Recording START happens on blur (leaving the callsign field), NOT here —
// you may type a call and work it minutes later. Clearing it cancels. // you may type a call and work it minutes later. Clearing it cancels.
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; } if (v.trim() === '') {
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
// Callsign wiped → drop this contact's award references. They are auto-added
// per call (live detection merges pickable refs into award_refs), so without
// this they'd carry over to the NEXT call — e.g. IT9AOT's ref lingering when
// you then type F4BPO, showing both in the F3 Awards tab.
updateDetails({ award_refs: '' });
}
const isEmpty = v.trim() === ''; const isEmpty = v.trim() === '';
if (!isEmpty && !locks.start) { if (!isEmpty && !locks.start) {
// Restart the start time on every callsign change (each keystroke, a // Restart the start time on every callsign change (each keystroke, a
@@ -2726,7 +2762,7 @@ export default function App() {
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 }, { type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true }, { type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q' },
]}, ]},
{ name: 'edit', label: t('menu.edit'), items: [ { name: 'edit', label: t('menu.edit'), items: [
{ type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null }, { type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
@@ -2771,6 +2807,7 @@ export default function App() {
case 'file.export': exportAdif(); break; case 'file.export': exportAdif(); break;
case 'file.exportCabrillo': exportCabrillo(); break; case 'file.exportCabrillo': exportCabrillo(); break;
case 'file.deleteall': setShowDeleteAll(true); break; case 'file.deleteall': setShowDeleteAll(true); break;
case 'file.exit': QuitApp(); break;
case 'view.refresh': refresh(); break; case 'view.refresh': refresh(); break;
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break; case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break; case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
@@ -3610,6 +3647,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 +4674,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}
+8 -6
View File
@@ -377,11 +377,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
if (state) saveState(colStateKey, stripAwardCols(state)); if (state) saveState(colStateKey, stripAwardCols(state));
}, []); }, []);
// The award columns load asynchronously; when they arrive (or change) the // columnDefs is rebuilt whenever the award columns load OR the user toggles an
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide` // award column (both change the memo restoringRef flips true at line 316). Each
// default — wiping the user's saved visibility (award columns reappear, // rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after // saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
// every rebuild so the user's choices win. No-op before the grid is ready. // LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
// an award reset the other columns AND left restoringRef stuck true (saving off).
useEffect(() => { useEffect(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
const local = loadLocal(colStateKey); const local = loadLocal(colStateKey);
@@ -389,7 +391,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
// Re-enable saving once AG Grid has settled the column events from the rebuild. // Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0); const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [awardCols]); }, [awardCols, awardShown]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) { function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
+26 -4
View File
@@ -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 */ }
} }
+4 -1
View File
@@ -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:
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.3'; export const APP_VERSION = '0.20.4';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -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>;
+4
View File
@@ -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']();
} }
+2
View File
@@ -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);
} }
+10 -21
View File
@@ -87,14 +87,6 @@ func (o *OmniRig) Connect() error {
return nil return nil
} }
// isIC7610 reports whether the connected rig is an IC-7610. OmniRig's generic
// Freq property reads the wrong VFO on the 7610 (its Main/Sub model confuses the
// stock ini), so we read VFO A explicitly for it instead — matching what Log4OM
// shows.
func (o *OmniRig) isIC7610() bool {
return strings.Contains(strings.ToUpper(o.rigType), "7610")
}
func (o *OmniRig) Disconnect() { func (o *OmniRig) Disconnect() {
if o.rig != nil { if o.rig != nil {
o.rig.Release() o.rig.Release()
@@ -204,23 +196,20 @@ func (o *OmniRig) ReadState() (RigState, error) {
s.FreqHz, s.RxFreqHz = freqB, freqA s.FreqHz, s.RxFreqHz = freqB, freqA
} }
} else { } else {
// Simplex: the operating frequency is OmniRig's generic Freq (the active // Simplex: read VFO A first, fall back to the generic Freq — exactly like
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0. // DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
// stock ini), so keying off FreqA gives the operator the VFO they expect.
s.Split = false s.Split = false
s.RxFreqHz = 0 s.RxFreqHz = 0
switch {
case freqA != 0:
s.FreqHz = freqA
case freqMain != 0:
s.FreqHz = freqMain s.FreqHz = freqMain
// IC-7610 quirk: OmniRig's generic Freq reports VFO B (its Main/Sub model default:
// confuses the stock ini), so OpsLog showed the wrong VFO. Read VFO A
// explicitly for the 7610 — what the operator actually wants to see.
if o.isIC7610() && freqA != 0 {
s.FreqHz = freqA
}
if s.FreqHz == 0 {
if s.Vfo == "B" || s.Vfo == "BB" {
s.FreqHz = freqB s.FreqHz = freqB
} else {
s.FreqHz = freqA
}
} }
} }
return s, nil return s, nil
@@ -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
View File
@@ -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, &region, &silentKey, &swl, &qsoComplete, &qsoRandom, &darcDOK, &myDarcDOK, &region, &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
} }
+17 -13
View File
@@ -202,33 +202,37 @@ func (a *App) publishLiveStatus() {
} }
a.liveActMu.Unlock() a.liveActMu.Unlock()
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB) lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
// Online = a new contact was logged within the window. An operator who leaves // On air = a new contact was logged within the window. An operator who leaves
// the log open but stops working shows offline after `liveOnlineWindow`; the // the log open but stops working goes offline after `liveOnlineWindow`; the next
// next QSO flips them back on. never-logged (zero time) → offline. // QSO puts them back on. never-logged (zero time) → offline.
online := 0 online := !lastQSO.IsZero() && time.Since(lastQSO) < liveOnlineWindow
var lastQSOArg any
if !lastQSO.IsZero() {
lastQSOArg = lastQSO.UTC()
if time.Since(lastQSO) < liveOnlineWindow {
online = 1
}
}
if err := a.ensureLiveStatusTable(); err != nil { if err := a.ensureLiveStatusTable(); err != nil {
applog.Printf("livestatus: CREATE TABLE failed: %v", err) applog.Printf("livestatus: CREATE TABLE failed: %v", err)
return return
} }
// Offline → REMOVE the row entirely, not just flip a flag: a status page that
// lists the present rows (the common case, keyed on updated_at) then shows the
// operator as gone without having to read the online column. The row reappears
// on the next QSO. This is the whole point — no one shows on air when they're not.
if !online {
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
applog.Printf("livestatus: offline DELETE failed: %v", err)
}
return
}
lastQSOArg := lastQSO.UTC()
_, err := a.logDb.ExecContext(a.ctx, _, err := a.logDb.ExecContext(a.ctx,
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+ "INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+ "ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+ "band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()", "last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
op, station, freqHz, band, mode, online, appVersion, lastQSOArg) op, station, freqHz, band, mode, 1, appVersion, lastQSOArg)
if err != nil { if err != nil {
applog.Printf("livestatus: INSERT failed: %v", err) applog.Printf("livestatus: INSERT failed: %v", err)
return return
} }
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s online=%d", op, station, freqHz, band, mode, online) applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
} }
// LiveStation is one operator's live status for the multi-op "who's on air" widget. // LiveStation is one operator's live status for the multi-op "who's on air" widget.
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.3" appVersion = "0.20.4"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.