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. // or when a setting that shapes the maps flips.
clusterStatusIdx *clusterStatusCache clusterStatusIdx *clusterStatusCache
clusterStatusMu sync.Mutex 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 // 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 // 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 // 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.clusterStatusMu.Lock()
a.clusterStatusIdx = nil a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock() 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 // 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. // form clears immediately — the operator is not made to wait on the DB.
wruntime.EventsEmit(a.ctx, "qso:logged", id) wruntime.EventsEmit(a.ctx, "qso:logged", id)
@@ -4468,6 +4479,11 @@ func (a *App) invalidateAwardStats() {
a.clusterStatusMu.Lock() a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock() 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 // Bulk QSO changes (import, delete, bulk edit) also land here — refresh the
// worked-index so alert "needed" checks stay accurate. Async: never block the // worked-index so alert "needed" checks stay accurate. Async: never block the
// mutation, and it's a single lightweight query. // 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 { if a.qso == nil {
return nil, fmt.Errorf("db not initialized") 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 // CountQSOFiltered returns how many QSOs match the filter (ignoring the row
+4 -2
View File
@@ -7,14 +7,16 @@
"New option \"Chase new grids\": locators learnt from your decodes and from PSK Reporter are kept in their own database, with their source.", "New option \"Chase new grids\": locators learnt from your decodes and from PSK Reporter are kept in their own database, with their source.",
"Band openings: the PSK Reporter feed is now filtered at the broker, which cuts it from about 83 messages a second to under two.", "Band openings: the PSK Reporter feed is now filtered at the broker, which cuts it from about 83 messages a second to under two.",
"Band openings: unticking a band now actually stops its announcements, and puts its badge out.", "Band openings: unticking a band now actually stops its announcements, and puts its badge out.",
"Callsign lookup: the website, postal code and HamQTH profile picture are now read — the QSO web column was never filled by any lookup." "Callsign lookup: the website, postal code and HamQTH profile picture are now read — the QSO web column was never filled by any lookup.",
"New selectable column \"QSO number\": position in the log, 1 for the oldest contact."
], ],
"fr": [ "fr": [
"Cluster : le cache de locators garde 100 000 indicatifs et tourne au lieu de se vider, les locators ne disparaissent donc plus de la liste.", "Cluster : le cache de locators garde 100 000 indicatifs et tourne au lieu de se vider, les locators ne disparaissent donc plus de la liste.",
"Nouvelle option « Chasse aux nouveaux carrés » : les locators appris de tes décodes et de PSK Reporter sont conservés dans leur propre base, avec leur source.", "Nouvelle option « Chasse aux nouveaux carrés » : les locators appris de tes décodes et de PSK Reporter sont conservés dans leur propre base, avec leur source.",
"Ouvertures de bande : le flux PSK Reporter est désormais filtré chez le broker, ce qui le fait passer d environ 83 messages par seconde à moins de deux.", "Ouvertures de bande : le flux PSK Reporter est désormais filtré chez le broker, ce qui le fait passer d environ 83 messages par seconde à moins de deux.",
"Ouvertures de bande : décocher une bande arrête réellement ses annonces et éteint son badge.", "Ouvertures de bande : décocher une bande arrête réellement ses annonces et éteint son badge.",
"Recherche d indicatif : le site web, le code postal et la photo de profil HamQTH sont désormais lus — la colonne web du QSO n était jamais remplie." "Recherche d indicatif : le site web, le code postal et la photo de profil HamQTH sont désormais lus — la colonne web du QSO n était jamais remplie.",
"Nouvelle colonne sélectionnable « Numéro de QSO » : position dans le log, 1 pour le contact le plus ancien."
] ]
}, },
{ {
@@ -132,6 +132,10 @@ function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
// ── QSO basics ── // ── QSO basics ──
// Position in the log, oldest = 1. Filled by the backend over the WHOLE log,
// so it does not change when a filter narrows what is shown.
{ group: 'QSO', label: t('rqg.c.number'), colId: 'number', headerName: t('rqg.h.number'), field: 'number' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
comparator: (a, b) => (a ?? 0) - (b ?? 0) },
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true }, { group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) }, { group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true }, { group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
File diff suppressed because one or more lines are too long
+2
View File
@@ -4206,6 +4206,7 @@ export namespace qso {
} }
export class QSO { export class QSO {
id: number; id: number;
number?: number;
callsign: string; callsign: string;
// Go type: time // Go type: time
qso_date: any; qso_date: any;
@@ -4346,6 +4347,7 @@ export namespace qso {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"]; this.id = source["id"];
this.number = source["number"];
this.callsign = source["callsign"]; this.callsign = source["callsign"];
this.qso_date = this.convertValues(source["qso_date"], null); this.qso_date = this.convertValues(source["qso_date"], null);
this.qso_date_off = this.convertValues(source["qso_date_off"], null); this.qso_date_off = this.convertValues(source["qso_date_off"], null);
+37
View File
@@ -48,6 +48,14 @@ func MergeNonZero(dst *QSO, src QSO) {
// Anything in ADIF that is not a promoted column lands in Extras. // Anything in ADIF that is not a promoted column lands in Extras.
type QSO struct { type QSO struct {
ID int64 `json:"id"` ID int64 `json:"id"`
// Number is the contact's position in the log, oldest = 1.
//
// NOT a column and 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 by the App layer and stamped on the way out —
// which is why it is `json:"-"`-adjacent in spirit: nothing reads or writes
// it in SQL.
Number int `json:"number,omitempty"`
Callsign string `json:"callsign"` Callsign string `json:"callsign"`
QSODate time.Time `json:"qso_date"` // start, UTC QSODate time.Time `json:"qso_date"` // start, UTC
QSODateOff time.Time `json:"qso_date_off,omitempty"` // end, UTC QSODateOff time.Time `json:"qso_date_off,omitempty"` // end, UTC
@@ -3170,3 +3178,32 @@ func (r *Repo) WorkedGridKeys(ctx context.Context, normMode func(string) string)
} }
return out, rows.Err() return out, rows.Err()
} }
// OrderedIDs returns every QSO id in chronological order, oldest first.
//
// The mirror of ListFiltered's "qso_date DESC, id DESC", so the tie-break is the
// same one the grid displays and a contact cannot change number depending on
// which way it is read.
//
// One query, ids only: the caller turns it into a rank once and keeps it in
// memory. Asking the database for a row's rank per row would be a correlated
// count over the whole log for each of thirty thousand rows.
func (r *Repo) OrderedIDs(ctx context.Context) ([]int64, time.Time, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id, qso_date FROM qso ORDER BY qso_date ASC, id ASC`)
if err != nil {
return nil, time.Time{}, err
}
defer rows.Close()
out := make([]int64, 0, 4096)
var newest time.Time
for rows.Next() {
var id int64
var at time.Time
if err := rows.Scan(&id, &at); err != nil {
return nil, time.Time{}, err
}
out = append(out, id)
newest = at // ascending, so the last row read is the newest
}
return out, newest, rows.Err()
}
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"testing"
"time"
"hamlog/internal/qso"
)
func at(s string) time.Time {
t, err := time.Parse("2006-01-02 15:04", s)
if err != nil {
panic(err)
}
return t
}
// The number is the contact's rank in the WHOLE log, not its position in what
// the grid happens to be showing. Ranking inside the result would renumber
// every contact the moment a filter is applied, and QSO #1 would change
// identity as the operator typed.
func TestQSONumberIsGlobalNotPerPage(t *testing.T) {
a := &App{}
// The log, oldest first: ids are deliberately NOT in date order, which is
// exactly what an imported ADIF produces.
a.qsoNumbers = map[int64]int{
770: 1, // oldest, imported last so it has the highest id
101: 2,
102: 3,
103: 4,
}
// A filtered page holding only two of them, newest first as the grid asks.
page := []qso.QSO{{ID: 103}, {ID: 770}}
a.stampQSONumbers(page)
if page[0].Number != 4 {
t.Errorf("id 103 numbered %d, want 4", page[0].Number)
}
if page[1].Number != 1 {
t.Errorf("id 770 numbered %d, want 1 — the oldest contact, whatever its id", page[1].Number)
}
}
// A contact logged now is the newest, so it takes the next number without
// rereading the log — a full scan per QSO would be felt in a contest run.
func TestNewQSOTakesTheNextNumber(t *testing.T) {
a := &App{}
a.qsoNumbers = map[int64]int{1: 1, 2: 2, 3: 3}
a.qsoNumMax = at("2026-08-13 10:00")
a.noteQSONumbered(9, at("2026-08-13 11:00"))
if got := a.qsoNumbers[9]; got != 4 {
t.Errorf("new QSO numbered %d, want 4", got)
}
if !a.qsoNumMax.Equal(at("2026-08-13 11:00")) {
t.Error("the newest date was not carried forward")
}
}
// A contact entered with an OLDER date belongs in the middle of the order.
// Appending it would number it last, which is wrong — so the map is dropped and
// rebuilt correctly instead.
func TestBackdatedQSOForcesARebuild(t *testing.T) {
a := &App{}
a.qsoNumbers = map[int64]int{1: 1, 2: 2, 3: 3}
a.qsoNumMax = at("2026-08-13 10:00")
a.noteQSONumbered(9, at("2020-01-01 09:00"))
if a.qsoNumbers != nil {
t.Errorf("a back-dated QSO was appended as the newest: %v", a.qsoNumbers)
}
}
// Nothing built yet: the lazy build will see the new contact anyway, so this
// must not create a one-entry map that then numbers the whole log wrongly.
func TestNoteBeforeAnyBuildDoesNothing(t *testing.T) {
a := &App{}
a.noteQSONumbered(9, at("2026-08-13 11:00"))
if a.qsoNumbers != nil {
t.Errorf("built a map from a single contact: %v", a.qsoNumbers)
}
}
// With no index available the column is simply empty — never wrong.
func TestStampWithoutIndexLeavesZero(t *testing.T) {
a := &App{} // no qso repo, so the index cannot be built
page := []qso.QSO{{ID: 1}, {ID: 2}}
a.stampQSONumbers(page)
for _, q := range page {
if q.Number != 0 {
t.Errorf("invented a number without an index: %d", q.Number)
}
}
}