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
+38 -1
View File
@@ -47,7 +47,15 @@ func MergeNonZero(dst *QSO, src QSO) {
// import/export. Pointers are used to distinguish "absent" from "zero".
// Anything in ADIF that is not a promoted column lands in Extras.
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"`
QSODate time.Time `json:"qso_date"` // start, 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()
}
// 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()
}