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.
|
// 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
@@ -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
@@ -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);
|
||||||
|
|||||||
+38
-1
@@ -47,7 +47,15 @@ func MergeNonZero(dst *QSO, src QSO) {
|
|||||||
// import/export. Pointers are used to distinguish "absent" from "zero".
|
// import/export. Pointers are used to distinguish "absent" from "zero".
|
||||||
// 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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user