feat(log): QSO number column, oldest contact = 1

Not the id: the primary key follows insertion order, so importing an old ADIF
gives the oldest contacts the highest ids. This is a rank over qso_date.

Computed over the WHOLE log, not the query result — ranking inside the result
would renumber every contact the moment a filter is applied, and QSO #1 would
change identity as the operator typed.

Held as one id-to-rank map, built from a single ordered id query and dropped
with the other derived indexes when the log changes. A contact logged from the
entry form is the newest, so it takes the next number without rereading the
log: AddQSO deliberately avoids full invalidation because a contest run would
pay for it once per QSO. A contact entered with an OLDER date belongs in the
middle of the order, so there the map is dropped and rebuilt rather than
mis-numbered.
This commit is contained in:
2026-08-13 00:31:38 +02:00
parent 3c8aadf41f
commit 62256942a1
7 changed files with 230 additions and 6 deletions
+85 -1
View File
@@ -603,6 +603,14 @@ type App struct {
// or when a setting that shapes the maps flips.
clusterStatusIdx *clusterStatusCache
clusterStatusMu sync.Mutex
// qsoNumbers maps a QSO id to its chronological position, oldest = 1. Built
// on demand from one ordered id query and dropped whenever the log changes,
// alongside the other derived indexes.
qsoNumbers map[int64]int
// qsoNumMax is the date of the newest contact the map has numbered, so a QSO
// logged now can be appended instead of forcing a rebuild.
qsoNumMax time.Time
qsoNumMu sync.Mutex
// decodeGrids maps a callsign to the 4-character grid it announced in a CQ
// heard over the WSJT-X UDP link. It is the ONLY source of grids we have for
// a spot: a DX-cluster line carries the spotter's grid at best, never the
@@ -2710,6 +2718,9 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock()
// Give the contact its number without rereading the log — same reason as
// above, a contest run must not pay a full scan per QSO.
a.noteQSONumbered(id, q.QSODate)
// Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry
// form clears immediately — the operator is not made to wait on the DB.
wruntime.EventsEmit(a.ctx, "qso:logged", id)
@@ -4468,6 +4479,11 @@ func (a *App) invalidateAwardStats() {
a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock()
// The numbering shifts whenever a contact is added or removed — and an
// imported ADIF inserts into the MIDDLE of the order, not at the end.
a.qsoNumMu.Lock()
a.qsoNumbers = nil
a.qsoNumMu.Unlock()
// Bulk QSO changes (import, delete, bulk edit) also land here — refresh the
// worked-index so alert "needed" checks stay accurate. Async: never block the
// mutation, and it's a single lightweight query.
@@ -5791,7 +5807,75 @@ func (a *App) ListQSOFiltered(f qso.QueryFilter) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
return a.qso.ListFiltered(a.ctx, f)
list, err := a.qso.ListFiltered(a.ctx, f)
if err != nil {
return nil, err
}
a.stampQSONumbers(list)
return list, nil
}
// stampQSONumbers fills each QSO's position in the log, oldest = 1.
//
// Stamped here rather than computed in the query because the number has to be
// the rank in the WHOLE log: ranking inside the result would renumber every
// contact the moment a filter is applied, and the operator would see QSO #1
// change identity as they typed.
func (a *App) stampQSONumbers(list []qso.QSO) {
idx := a.qsoNumberIndex()
if idx == nil {
return
}
for i := range list {
list[i].Number = idx[list[i].ID]
}
}
// qsoNumberIndex returns id → chronological position, building it once and
// keeping it until the log changes (invalidateAwardStats drops it).
func (a *App) qsoNumberIndex() map[int64]int {
a.qsoNumMu.Lock()
defer a.qsoNumMu.Unlock()
if a.qsoNumbers != nil {
return a.qsoNumbers
}
if a.qso == nil {
return nil
}
ids, newest, err := a.qso.OrderedIDs(a.ctx)
if err != nil {
applog.Printf("qso numbering: %v — the column will be empty", err)
return nil
}
m := make(map[int64]int, len(ids))
for i, id := range ids {
m[id] = i + 1
}
a.qsoNumbers = m
a.qsoNumMax = newest
return m
}
// noteQSONumbered keeps the numbering current for a contact just logged.
//
// A QSO logged from the entry form is the newest there is, so it simply takes
// the next number — a full rebuild would read every id in the log, which during
// a contest run would cost more than the three queries the grid refresh already
// makes. A contact entered with an OLDER date belongs in the middle of the
// order, so there the map is dropped and rebuilt correctly rather than
// mis-numbered.
func (a *App) noteQSONumbered(id int64, at time.Time) {
a.qsoNumMu.Lock()
defer a.qsoNumMu.Unlock()
if a.qsoNumbers == nil {
return // nothing built yet; the lazy build will see this QSO anyway
}
if at.Before(a.qsoNumMax) {
a.qsoNumbers = nil
return
}
a.qsoNumbers[id] = len(a.qsoNumbers) + 1
a.qsoNumMax = at
}
// CountQSOFiltered returns how many QSOs match the filter (ignoring the row