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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user