fix(distance): compute it where QSOs are made, not in a settings button

The button was the wrong shape twice over. A maintenance chore does not belong
beside the options an operator actually chooses, and nobody should have to be
told their log is missing a field before it gets filled in.

So the distance is computed where QSOs come from: on the logging path and on the
ADIF import. fillDistance is its own function rather than part of
applyStationDefaults, because the import only applies those when the operator
ticks a box — and a distance is not a station default. It is derived from the
QSO's own two grids and is true whatever was chosen about profile fields.

What is already in the log is handled by a one-time migration at startup,
recorded by a settings key. In the background: on a large log over a remote
MySQL that is thousands of row updates and startup must not wait for a tidy-up.
Marked done only on SUCCESS, so a run cut short by a closed program tries again
next time rather than leaving half the log filled for ever.

An imported value still wins. It came from the log that made the contact, which
knew the real positions rather than two four-character squares.
This commit is contained in:
2026-08-11 20:14:27 +02:00
parent deee8c4618
commit 75a2f73992
4 changed files with 65 additions and 28 deletions
+60
View File
@@ -1376,6 +1376,8 @@ func (a *App) startup(ctx context.Context) {
// PSK Reporter, when the opening watch is on. After the operator's grid is
// known: without it there is no distance to measure and the feed stays down.
a.startBandOpenFeed()
// One-time tidy-up of a field nothing used to record. Background, once.
a.backfillDistancesOnce()
fmt.Println("OpsLog: db ready at", a.dbPath)
}
@@ -2620,6 +2622,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
}()
a.applyStationDefaults(&q, true)
fillDistance(&q)
a.applyDXCCNumber(&q)
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
@@ -2963,6 +2966,27 @@ func (a *App) refineDistrictZones(q *qso.QSO) {
}
}
// fillDistance computes DISTANCE from the two locators when nothing supplied one.
//
// Its own function, NOT part of applyStationDefaults, because the import only
// applies those when the operator ticks the box — and a distance is not a
// station default. It is derived from the QSO's own two grids and is true
// whatever the operator chose about profile fields.
//
// Nothing recorded it before, so the field went out empty in every ADIF export
// and left the same gap in whoever imported the file: a hole that travels. An
// imported value always wins, having come from the log that made the contact,
// which knew the real positions rather than two four-character squares.
func fillDistance(q *qso.QSO) {
if q == nil || (q.Distance != nil && *q.Distance > 0) {
return
}
if km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid); ok && km > 0 {
v := math.Round(km)
q.Distance = &v
}
}
// applyStationDefaults fills any empty MY_* / station field on q with the
// currently-active profile's values. Multi-profile support means a user
// can be /P with a different callsign + grid + SOTA ref than home — the
@@ -6454,6 +6478,8 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
a.applyClublogException(q, true) // force: explicit import-time correction
}
}
// Unconditional: see fillDistance.
fillDistance(q)
if applyStation {
// Backfill empty MY_* descriptive fields from the active profile
// (identity fields left alone to keep mixed-call routing intact).
@@ -10337,6 +10363,40 @@ func (a *App) DownloadULSCounties() error {
}
// BackfillUSCountiesResult summarises a bulk county/grid backfill over the log.
// keyDistanceBackfilled marks the one-time distance fill as done.
//
// A migration, not a setting. It was briefly a button in Preferences, which was
// the wrong shape twice over: a maintenance chore does not belong beside the
// options an operator actually chooses, and nobody should have to be TOLD their
// log is missing a field before it gets filled in. It runs once, in the
// background, and never asks.
const keyDistanceBackfilled = "migr.distance_from_grids.v1"
// backfillDistancesOnce fills DISTANCE across the log the first time this
// version runs, then records that it is done.
//
// In the background: on a large log over a remote MySQL this is thousands of
// row updates, and startup must not wait for a tidy-up. Marked done only on
// success — a run cut short by a closed program should try again next time
// rather than leave half the log filled for ever.
func (a *App) backfillDistancesOnce() {
if a.settings == nil || a.qso == nil {
return
}
if v, _ := a.settings.GetGlobal(a.ctx, keyDistanceBackfilled); v == "1" {
return
}
go func() {
res, err := a.BackfillDistances()
if err != nil {
applog.Printf("distance backfill: %v — will try again next start", err)
return
}
_ = a.settings.SetGlobal(a.ctx, keyDistanceBackfilled, "1")
applog.Printf("distance backfill: done once for this log (%d filled)", res.Filled)
}()
}
// BackfillDistancesResult reports what a distance backfill did.
type BackfillDistancesResult struct {
Scanned int `json:"scanned"` // QSOs examined