Compare commits

..
13 Commits
Author SHA1 Message Date
rouggy f7d2de0777 chore: release v0.24.6 2026-08-11 21:14:59 +02:00
rouggy 344e8b2091 fix(lookup): /QRP is about power, not about place
QRZ has no record under M0BFS/QRP, so the lookup falls back to the home call —
that part worked. What followed did not: the home record's location is then
discarded, on the reasoning that a portable operator is not at their registered
address. True for /P and /M, and simply wrong for /QRP, which says something
about the transmitter and nothing about where it is.

So the grid came back empty for M0BFS/QRP while the same call without the suffix
answered perfectly, which is exactly how it was reported.

The distinction is now explicit rather than lumped in with the other operational
suffixes. /P and /M keep clearing the location, because mobile and portable both
mean somewhere other than home — that behaviour was correct and is untouched.
A power suffix stacked on a portable one (F4BPO/P/QRP) still clears: one of them
moved the operator.
2026-08-11 21:12:37 +02:00
rouggy c5e0ec9033 fix(filter): "equals nothing" found nothing
A NULL never equals '', so a condition written that way returned zero rows. The
question is perfectly clear — the operator wants the ones with nothing in that
field — and answering it with silence makes the filter look broken rather than
mis-stated. eq and ne with a blank value now run the empty / not-empty test.

Empty on a NUMERIC column also had a real fault behind it. IFNULL(col,'')=''
compares 0 against '', which SQLite calls false and MySQL calls true — one
expression quietly answering two different questions depending on where the
logbook lives. Numeric columns test NULL or zero explicitly now; text keeps the
string test, where '' is a real value and 0 is not.
2026-08-11 21:02:33 +02:00
rouggy 210a99983e fix(bulk): a group missing from a hand-written list never rendered
The dialog walked a hand-written GROUPS array. "The contact" was added to the
fields and not to it, so mode, submode and RST existed, passed every check I had
written, and could not be picked — the same shape of fault as the missing column
mapping, one layer further out. Two lists that must agree, with nothing making
them agree.

GROUPS is derived from the fields now, in declaration order, so adding a group
is adding a field. A group with no translation falls back to its own name rather
than rendering an empty heading: a missing label should look untidy, not
invisible.

Also stacks the DX sunrise/sunset in the band-slot header. Side by side it cost
about 150 px of a row that also carries the callsign, the badges and the band
grid, and it was what pushed that row onto a second line. Two short times one
above the other cost a fraction of it and no extra height — the row is already
taller than one line of text. The "UTC" label goes to the tooltip: the times are
monospaced and always UTC everywhere in OpsLog, so it was spending width to
repeat a convention the operator already lives by.
2026-08-11 20:46:59 +02:00
rouggy 2a6e09a1d7 fix(bulk): mode and RST were offered but could not be saved
A bulk-editable field passes through THREE tables: the list the dialog shows,
bulkFieldColumns in app.go, and bulkEditableCols in internal/qso. I added mode,
submode and RST to the first and the third and missed the middle one, so the
dialog offered them and every save came back "unknown field".

Nothing warns about that. Each table is perfectly valid on its own, and the
mismatch only surfaces when an operator picks the one field that falls through
the gap — which is exactly how it was found.

So both directions are pinned now: every mapped field must be writable by the
qso layer, and every writable column must have a field mapping to it. A column
nothing maps to looks supported from the inside and cannot be reached from
outside, which is the same fault wearing the other hat.
2026-08-11 20:39:46 +02:00
rouggy 1b7f8ec9c1 docs: one entry for the distance work, not two 2026-08-11 20:33:08 +02:00
rouggy 75a2f73992 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.
2026-08-11 20:14:27 +02:00
rouggy deee8c4618 fix(rda): 1015 districts were on the wrong DXCC entity
Every one of the 2660 references was filed under European Russia. Corrected
against the RDA reference list: 991 are Asiatic Russia (15), and the 24 KA-
districts are Kaliningrad (126), which is not Russia for DXCC purposes at all.
1645 were already right.

dxcc_filter went from [15,54] to [15,54,126] with them. That filter decides
which QSOs are even considered, so leaving it alone would have made the 24
Kaliningrad districts unclaimable — a correction that quietly removes references
is worse than the error it fixes.

BA-27 (Mezhgorye) and BO-33 (Schebekino) are not in the reference list at all
and were left exactly as they were. Guessing an entity for a district nobody
lists is how a second wrong answer gets added to the first.

Only the dxcc field changed. Names differ in transliteration between the two
sources — Maykop against Maikop — and rewriting those was not asked for and
would churn 2660 lines to no purpose.
2026-08-11 19:55:37 +02:00
rouggy 92a5f30ac0 feat(db): fill in the distances nothing ever recorded
The published page derives a distance as it renders, which fixed the empty
column there but not the cause: the field is empty in the database, so it goes
out empty in every ADIF export and leaves the same gap in whoever imports it.

BackfillDistances computes it from the two locators for QSOs that have both.
Only where it is EMPTY: a stored distance came from the log that recorded the
contact, which knew the real positions, and two four-character squares are a
worse answer that must not overwrite a better one. Whole kilometres, for the
same reason the published column is.

In the Database panel rather than beside the county backfill, which lives under
US Counties: this one touches the whole logbook, whatever country the QSOs are in.
2026-08-11 19:49:03 +02:00
rouggy d3a405f4f6 fix(webpub): the published page hid columns it had room for
The page was capped at 1100px — a comfortable reading width, and the right
choice for the eight default columns. Now that all 123 fields can be published,
anything past about eight sat behind a horizontal scrollbar on a screen wide
enough to show the lot.

Worse, Windows hides overlay scrollbars until something moves, so a table that
scrolled looked exactly like a table with its right-hand columns missing. That
is what was reported, and the report was reasonable: nothing on screen said
otherwise.

The wrapper grows with its content now, up to the window, and keeps a minimum so
a two-column table does not collapse on a large display. The table sizes to what
its cells need rather than being squeezed to the container first — that squeeze
could wrap a callsign while empty space sat further along the same row. And the
scrollbar is drawn permanently, thin and in the page's own colours.
2026-08-11 19:45:00 +02:00
rouggy f7b9bfd0bc fix(webpub): the Distance column was empty for every QSO logged here
Nothing computes a distance when a QSO is logged. The DISTANCE field is only
ever filled by an ADIF import that carried one, so publishing it straight gave
an empty column to anyone whose log was made in OpsLog — which is everyone who
reported it.

It falls back to the two locators, which are on the QSO already. A stored
distance still wins: it came from the log that recorded the contact, which knew
more than two four-character squares do. No grids means an empty cell, not a
zero — an empty cell is honest, a zero is a claim.

Rounded to whole kilometres. The squares are tens of kilometres across and a
decimal would assert an accuracy nobody has.

The geometry moved to internal/geo on the way. It lived in package main, which
internal packages cannot import, so the PSK Reporter watcher already had its
maths injected from main and this would have been a third copy. A bearing that
disagrees with itself between two panels is a fault nobody reports, because each
screen looks perfectly plausible on its own.
2026-08-11 19:41:47 +02:00
rouggy 102097c5c4 feat(bulk): mode, submode and RST are repair fields, so allow them
They were excluded as "per-QSO", alongside callsign and date. That rule confused
two different things: bulk edit is not for describing QSOs, it is for REPAIRING
a batch of them — an import that mapped every contact to SSB, an ADIF that
carried no MODE at all. Refusing because a hundred rows should not normally
share a value left the operator editing a hundred rows by hand.

Setting the mode CLEARS the submode. A submode belongs to the mode it was
recorded under; left behind it contradicts the new one, and "FT8 with a submode
of USB" is not a thing — worse, the submode is what most ADIF readers believe.

Band is still refused on its own, and that half of the rule stands. It travels
with the frequency through BulkSetFrequency, which writes the pair: a band
contradicting its own frequency is invalid ADIF, and every export would carry
the contradiction out into the world. Callsign and date stay out too — they
identify the contact rather than describe it.
2026-08-11 19:36:56 +02:00
rouggy 3b90ef6b7a chore: open 0.24.6
Empty block at the top so the next change has somewhere to go. 0.24.5 keeps its
nine entries; the release script stamps the version constants.
2026-08-11 18:20:42 +02:00
20 changed files with 1631 additions and 1085 deletions
+132 -40
View File
@@ -40,6 +40,7 @@ import (
"hamlog/internal/dxcc"
"hamlog/internal/email"
"hamlog/internal/extsvc"
"hamlog/internal/geo"
"hamlog/internal/integrations/udp"
"hamlog/internal/lookup"
"hamlog/internal/lotwusers"
@@ -746,48 +747,16 @@ type App struct {
udpLastMode string
}
// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the
// centre lat/lon in degrees. Returns ok=false on malformed input.
func gridToLatLon(grid string) (lat, lon float64, ok bool) {
g := strings.ToUpper(strings.TrimSpace(grid))
if len(g) < 4 {
return 0, 0, false
}
A := g[0] - 'A'
B := g[1] - 'A'
C := g[2] - '0'
D := g[3] - '0'
if A > 17 || B > 17 || C > 9 || D > 9 {
return 0, 0, false
}
lon = -180 + float64(A)*20 + float64(C)*2
lat = -90 + float64(B)*10 + float64(D)*1
if len(g) >= 6 {
E := g[4] - 'A'
F := g[5] - 'A'
if E <= 23 && F <= 23 {
lon += float64(E)*(5.0/60.0) + 2.5/60.0
lat += float64(F)*(2.5/60.0) + 1.25/60.0
return lat, lon, true
}
}
// 4-char locator: aim at the centre of the square.
lon += 1
lat += 0.5
return lat, lon, true
}
// gridToLatLon and haversineKm live in internal/geo, which the internal
// packages can import — package main cannot be imported by anything. These
// forward so there is exactly ONE implementation: the PSK Reporter watcher and
// the web publisher measure the same path the cluster does, and a bearing that
// disagrees with itself between two panels is a fault nobody reports, because
// each screen looks plausible alone.
func gridToLatLon(grid string) (lat, lon float64, ok bool) { return geo.GridToLatLon(grid) }
// haversineKm returns the great-circle distance between two lat/lon pairs
// in kilometres. Standard Haversine, mean Earth radius 6371 km.
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0
rad := math.Pi / 180.0
dLat := (lat2 - lat1) * rad
dLon := (lon2 - lon1) * rad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
return geo.HaversineKm(lat1, lon1, lat2, lon2)
}
// initialBearingDeg returns the initial great-circle bearing (azimuth) in
@@ -1407,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)
}
@@ -2651,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
@@ -2994,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
@@ -6169,6 +6162,12 @@ var bulkFieldColumns = map[string]string{
"iota": "iota",
"sig": "sig",
"sig_info": "sig_info",
// The contact itself — repair fields. Setting mode also clears submode, in
// qso.BulkSetField: a submode left over from the old mode contradicts the new.
"mode": "mode",
"submode": "submode",
"rst_sent": "rst_sent",
"rst_rcvd": "rst_rcvd",
// Misc text
"comment": "comment",
"notes": "notes",
@@ -6485,6 +6484,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).
@@ -10368,6 +10369,97 @@ 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
Filled int `json:"filled"` // QSOs that gained a distance
NoGrid int `json:"no_grid"` // skipped: one of the two locators is missing
}
// BackfillDistances computes DISTANCE for past QSOs that have both locators and
// no distance recorded.
//
// Nothing has ever computed it when logging — the column is only filled by an
// ADIF import that carried one — so a log made in OpsLog has it empty
// throughout. The published web page works around that by deriving the distance
// as it renders, but the column still travels empty into every ADIF export, and
// that is what leaves the gap in someone else's log.
//
// Only fills what is EMPTY. A stored distance came from the log that recorded
// the contact, which knew the real positions; two four-character squares are a
// worse answer and must not overwrite a better one.
func (a *App) BackfillDistances() (BackfillDistancesResult, error) {
var res BackfillDistancesResult
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: 1_000_000})
if err != nil {
return res, err
}
for i := range rows {
q := rows[i]
res.Scanned++
if q.Distance != nil && *q.Distance > 0 {
continue
}
km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid)
if !ok || km <= 0 {
res.NoGrid++
continue
}
// Whole kilometres: the squares are tens of kilometres across and a
// decimal would assert an accuracy the grids do not carry.
v := math.Round(km)
q.Distance = &v
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("backfill distance: QSO %d: %v", q.ID, err)
continue
}
res.Filled++
}
applog.Printf("backfill distance: %d scanned, %d filled, %d without both grids",
res.Scanned, res.Filled, res.NoGrid)
if res.Filled > 0 {
a.invalidateAwardStats()
}
return res, nil
}
type BackfillUSCountiesResult struct {
Scanned int `json:"scanned"` // US QSOs examined
County int `json:"county"` // QSOs that gained a county
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"testing"
"hamlog/internal/qso"
)
// A bulk-editable field passes through THREE tables: the field list in the UI,
// bulkFieldColumns here, and bulkEditableCols in internal/qso. Mode and RST were
// added to the first and the third and not the second, so the dialog offered
// them and the save failed with "unknown field" — reported from the field.
//
// Nothing warns about that: each table is valid on its own. This is the check.
func TestEveryMappedBulkFieldIsWhitelisted(t *testing.T) {
for id, col := range bulkFieldColumns {
if !qso.BulkEditable(col) {
t.Errorf("bulk field %q maps to column %q, which internal/qso refuses to write", id, col)
}
}
}
// And the reverse: a column the qso layer allows but nothing maps to is dead
// weight — it looks supported from the inside and cannot be reached from outside.
func TestNoUnreachableBulkColumn(t *testing.T) {
mapped := map[string]bool{}
for _, col := range bulkFieldColumns {
mapped[col] = true
}
for _, col := range qso.BulkEditableColumns() {
if !mapped[col] {
t.Errorf("column %q is bulk-writable but no field maps to it — unreachable", col)
}
}
}
+20
View File
@@ -1,4 +1,24 @@
[
{
"version": "0.24.6",
"date": "",
"en": [
"Bulk edit can now set mode, submode and RST. They were excluded as per-QSO fields, which missed the point: bulk edit is for repairing a batch — an import that mapped every contact to SSB, an ADIF with no mode at all — and refusing meant editing a hundred rows one at a time. Setting the mode clears the submode, since one left over from the old mode contradicts the new one. Band stays with frequency, which already sets the two together.",
"Web publishing: the page now widens to fit the table. It was capped at a comfortable reading width, so with more than about eight columns the rest sat behind a scrollbar on a screen wide enough to show them all — and Windows hides that scrollbar until something moves, which made a table that scrolls look like a table missing columns. Columns also take the width their contents need instead of being squeezed to fit first.",
"Every QSO now carries its distance. Nothing ever recorded one, so the field went out empty in every ADIF export and left the Distance column blank on a published page. It is computed from the two locators when a contact is logged and when an ADIF is imported, and a one-time pass fills in the QSOs already in your log the first time this version runs — in the background, without asking. A distance the imported file supplied is always kept.",
"RDA: 1015 districts were filed under the wrong DXCC entity. Every reference sat on European Russia; 991 belong to Asiatic Russia and the 24 KA- districts to Kaliningrad, which is a separate entity altogether. Corrected against the reference list, and Kaliningrad added to the award filter so those 24 can be claimed at all.",
"QSO filter: asking a field to equal nothing now finds the empty ones. SQL answers that question with nothing at all — a missing value never equals an empty one — so the filter looked broken rather than wrong. Empty on a numeric field also covers zero as well as missing, which SQLite and MySQL disagreed about.",
"Callsign lookup: a /QRP call now returns its locator. The lookup falls back to the home callsign when the slashed form is not registered, and then cleared the location — right for /P and /M, where the operator is somewhere other than their registered address, and wrong for /QRP, which says something about power and nothing about place. The grid came back empty while the same call without the suffix answered perfectly."
],
"fr": [
"L édition groupée sait enfin régler le mode, le sous-mode et le RST. Ils étaient exclus comme champs propres à chaque QSO, ce qui manquait l essentiel : l édition groupée sert à RÉPARER un lot — un import qui a tout mis en SSB, un ADIF sans aucun mode — et refuser obligeait à corriger cent lignes une par une. Régler le mode efface le sous-mode, celui de l ancien mode contredisant le nouveau. La bande reste avec la fréquence, qui pose déjà les deux ensemble.",
"Publication web : la page s élargit désormais à la taille du tableau. Elle était bridée à une largeur de lecture confortable, donc au-delà de huit colonnes environ le reste passait derrière une barre de défilement sur un écran assez large pour tout montrer — et Windows masque cette barre tant que rien ne bouge, si bien qu un tableau qui défile ressemblait à un tableau amputé. Les colonnes prennent aussi la largeur qu il leur faut au lieu d être comprimées d abord.",
"Chaque QSO porte désormais sa distance. Rien ne l enregistrait, elle partait donc vide dans chaque export ADIF et laissait la colonne Distance blanche sur une page publiée. Elle est calculée depuis les deux locators à l enregistrement d un contact et à l import d un ADIF, et une passe unique complète les QSO déjà présents au premier lancement de cette version — en tâche de fond, sans rien demander. Une distance fournie par le fichier importé est toujours conservée.",
"RDA : 1015 districts étaient rangés sous la mauvaise entité DXCC. Toutes les références étaient sur la Russie européenne ; 991 relèvent de la Russie asiatique et les 24 districts KA- de Kaliningrad, qui est une entité à part entière. Corrigé d après la liste de référence, et Kaliningrad ajouté au filtre de l award pour que ces 24 puissent être revendiqués.",
"Filtre QSO : demander à un champ d être égal à rien trouve désormais les vides. SQL répond à cette question par rien du tout — une valeur absente n est jamais égale à une valeur vide — et le filtre paraissait cassé plutôt que mal posé. Vide sur un champ numérique couvre aussi le zéro autant que l absence, ce sur quoi SQLite et MySQL n étaient pas d accord.",
"Recherche d indicatif : un indicatif en /QRP rend enfin son locator. La recherche se rabat sur l indicatif de base quand la forme avec barre n est pas enregistrée, puis effaçait la localisation — ce qui est juste pour /P et /M, où l opérateur n est pas à son adresse déclarée, et faux pour /QRP, qui parle de puissance et pas de lieu. Le locator revenait vide alors que le même indicatif sans le suffixe répondait parfaitement."
]
},
{
"version": "0.24.5",
"date": "",
+12 -4
View File
@@ -172,8 +172,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
[lat, lon],
);
// Stacked, not side by side. Laid out in a row this cost about 150 px of a
// header that has to hold the callsign, the badges and the band grid, and it
// was what pushed the whole row onto a second line. Two short times one above
// the other take a fraction of that and no extra height: the row is already
// taller than one line of text.
//
// "UTC" moves into the tooltip with them — the times are monospaced and always
// UTC everywhere in OpsLog, so the label was spending width to repeat a
// convention the operator already lives by.
const sunBlock = sun ? (
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
<div className="ml-auto flex flex-col items-end leading-tight text-xs shrink-0"
title="Sunrise / sunset at the DX station (UTC)">
{sun.polarDay ? (
<span className="font-semibold text-warning">midnight sun</span>
@@ -182,14 +191,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
) : (
<>
<span className="flex items-center gap-1">
<Sunrise className="size-3.5 text-warning" />
<Sunrise className="size-3 text-warning" />
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
</span>
<span className="flex items-center gap-1">
<Sunset className="size-3.5 text-info" />
<Sunset className="size-3 text-info" />
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
</span>
<span className="text-muted-foreground">UTC</span>
</>
)}
</div>
+19 -2
View File
@@ -91,6 +91,17 @@ const FIELDS: FieldDef[] = [
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
// The contact itself — repair fields, not description fields. An import that
// mapped every QSO to SSB, or an ADIF with no MODE at all, is fixed here
// instead of one row at a time.
//
// Band is deliberately absent: it travels with the frequency below, because a
// band contradicting its own frequency is invalid ADIF and every export would
// carry the contradiction.
{ id: 'mode', label: 'bulk.fMode', group: 'The contact', kind: 'text', upper: true },
{ id: 'submode', label: 'bulk.fSubmode', group: 'The contact', kind: 'text', upper: true },
{ id: 'rst_sent', label: 'bulk.fRstSent', group: 'The contact', kind: 'text' },
{ id: 'rst_rcvd', label: 'bulk.fRstRcvd', group: 'The contact', kind: 'text' },
// Misc
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
// logged on a stale/default frequency after CAT dropped.
@@ -110,7 +121,12 @@ const STATUS_VALUES: { v: string; label: string }[] = [
{ v: '_', label: 'bulk.statusBlank' },
];
const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
// Derived from the fields themselves, in the order they are declared.
//
// This used to be a hand-written list, and a group added to FIELDS but not to it
// simply never rendered — the fields existed, passed every check, and could not
// be picked. Two lists that must agree, with nothing to make them.
const GROUPS = [...new Set(FIELDS.map((f) => f.group))];
// Maps the internal group key → its i18n label key.
const GROUP_LABELS: Record<string, string> = {
'QSL / upload': 'bulk.groupQsl',
@@ -119,6 +135,7 @@ const GROUP_LABELS: Record<string, string> = {
'Contest': 'bulk.groupContest',
'Propagation': 'bulk.groupPropagation',
'Misc': 'bulk.groupMisc',
'The contact': 'bulk.groupContact',
};
type Props = {
@@ -177,7 +194,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
<SelectContent>
{GROUPS.map((g) => (
<div key={g}>
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{GROUP_LABELS[g] ? t(GROUP_LABELS[g]) : g}</div>
{FIELDS.filter((f) => f.group === g)
.map((f) => ({ f, txt: t(f.label) }))
.sort((a, b) => a.txt.localeCompare(b.txt))
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.24.5';
export const APP_VERSION = '0.24.6';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -88,6 +88,8 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
export function AwardsFolder():Promise<string>;
export function BackfillDistances():Promise<main.BackfillDistancesResult>;
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
+4
View File
@@ -118,6 +118,10 @@ export function AwardsFolder() {
return window['go']['main']['App']['AwardsFolder']();
}
export function BackfillDistances() {
return window['go']['main']['App']['BackfillDistances']();
}
export function BackfillUSCounties() {
return window['go']['main']['App']['BackfillUSCounties']();
}
+16
View File
@@ -1931,6 +1931,22 @@ export namespace main {
this.to = source["to"];
}
}
export class BackfillDistancesResult {
scanned: number;
filled: number;
no_grid: number;
static createFrom(source: any = {}) {
return new BackfillDistancesResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.scanned = source["scanned"];
this.filled = source["filled"];
this.no_grid = source["no_grid"];
}
}
export class BackfillUSCountiesResult {
scanned: number;
county: number;
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
// Package geo is the one place that turns Maidenhead locators into positions,
// and positions into distances and bearings.
//
// It exists because there were about to be three copies. These functions lived
// in package main, which the internal packages cannot import, so the PSK
// Reporter watcher had its geometry injected from main and the web publisher
// was about to grow its own. A bearing that disagrees with itself between two
// panels is the kind of fault nobody reports, because each screen looks
// plausible on its own.
package geo
import (
"math"
"strings"
)
// GridToLatLon parses a Maidenhead locator (4 or 6 characters) and returns the
// centre of that square in degrees. ok=false on malformed input.
func GridToLatLon(grid string) (lat, lon float64, ok bool) {
g := strings.ToUpper(strings.TrimSpace(grid))
if len(g) < 4 {
return 0, 0, false
}
A := g[0] - 'A'
B := g[1] - 'A'
C := g[2] - '0'
D := g[3] - '0'
if A > 17 || B > 17 || C > 9 || D > 9 {
return 0, 0, false
}
lon = -180 + float64(A)*20 + float64(C)*2
lat = -90 + float64(B)*10 + float64(D)*1
if len(g) >= 6 {
E := g[4] - 'A'
F := g[5] - 'A'
if E <= 23 && F <= 23 {
lon += float64(E)*(5.0/60.0) + 2.5/60.0
lat += float64(F)*(2.5/60.0) + 1.25/60.0
return lat, lon, true
}
}
// 4-character locator: aim at the centre of the square.
lon += 1
lat += 0.5
return lat, lon, true
}
// HaversineKm returns the great-circle distance between two positions in
// kilometres. Mean Earth radius 6371 km.
func HaversineKm(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0
rad := math.Pi / 180.0
dLat := (lat2 - lat1) * rad
dLon := (lon2 - lon1) * rad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
// DistanceBetweenGrids is the distance in kilometres between two locators,
// ok=false when either cannot be parsed.
func DistanceBetweenGrids(a, b string) (km float64, ok bool) {
lat1, lon1, ok1 := GridToLatLon(a)
lat2, lon2, ok2 := GridToLatLon(b)
if !ok1 || !ok2 {
return 0, false
}
return HaversineKm(lat1, lon1, lat2, lon2), true
}
+37 -2
View File
@@ -176,12 +176,20 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
r.Callsign = call
r.Source = p.Name()
r.FetchedAt = time.Now().UTC()
// The home record's location is the operator's HOME, not where they
// are portable now — clear it so cty.dat fills the real entity.
// The home record's location is the operator's HOME — clear it so
// cty.dat fills in where they actually are.
//
// UNLESS the suffix says nothing about location. /QRP is a statement
// about power, not about place: M0BFS/QRP is M0BFS, at home, running
// five watts. Wiping the grid there threw away the one field the
// operator was looking the call up for, and it came back empty while
// the same lookup without the suffix answered perfectly.
if !saysNothingAboutLocation(call) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
}
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
normalizeNames(&r)
_ = m.cache.Put(ctx, r)
@@ -232,6 +240,33 @@ var LogSink = func(string, ...any) {}
// right and must be looked up exactly as entered.
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}
// nonLocationSuffixes say nothing about WHERE the operator is.
//
// /QRP is a statement about power. /M and /P and their kin are not: mobile and
// portable both mean "somewhere other than the home station", which is exactly
// why the home record's location is discarded for them. Keeping that distinction
// is the difference between a grid that is stale and a grid that is absent.
var nonLocationSuffixes = map[string]bool{"QRP": true}
// saysNothingAboutLocation reports a call whose every suffix leaves the operator
// at their registered address — so the home record's location can be trusted.
func saysNothingAboutLocation(call string) bool {
parts := strings.Split(strings.ToUpper(strings.TrimSpace(call)), "/")
if len(parts) < 2 {
return false
}
base := strings.TrimSpace(parts[0])
if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
return false // "JW/OR1A": the first part is a prefix — a location change
}
for _, p := range parts[1:] {
if !nonLocationSuffixes[strings.TrimSpace(p)] {
return false
}
}
return true
}
// stripOpSuffix returns the bare callsign when call carries nothing but
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
+27
View File
@@ -0,0 +1,27 @@
package lookup
import "testing"
// A grid came back empty for M0BFS/QRP while the same lookup without the suffix
// answered perfectly. The home-call pass wiped the location on the grounds that
// a portable operator is not at their registered address — true for /P and /M,
// and simply wrong for /QRP, which is a statement about power.
func TestSaysNothingAboutLocation(t *testing.T) {
keep := []string{"M0BFS/QRP", "f4bpo/qrp", "G0ABC/QRP"}
for _, c := range keep {
if !saysNothingAboutLocation(c) {
t.Errorf("%s: the home location should be kept — /QRP does not move anyone", c)
}
}
// These DO move the operator, or change the entity outright.
drop := []string{"F4BPO/P", "F4BPO/M", "F4BPO/MM", "F4BPO/AM", "JW/OR1A", "VP8/F4BPO", "F4BPO/8", "F4BPO"}
for _, c := range drop {
if saysNothingAboutLocation(c) {
t.Errorf("%s: the home location must NOT be trusted", c)
}
}
// A power suffix on top of a portable one still moves them.
if saysNothingAboutLocation("F4BPO/P/QRP") {
t.Error("F4BPO/P/QRP is portable — location must not be kept")
}
}
+26
View File
@@ -0,0 +1,26 @@
package qso
import "testing"
// Mode, submode and RST are repair fields: an import that mapped every contact
// to SSB, or an ADIF that carried no MODE, is fixed in one pass instead of one
// row at a time. They were excluded as "per-QSO", which confused describing a
// QSO with repairing a batch of them.
func TestModeAndRSTAreBulkEditable(t *testing.T) {
for _, col := range []string{"mode", "submode", "rst_sent", "rst_rcvd"} {
if !bulkEditableCols[col] {
t.Errorf("%s should be bulk-editable", col)
}
}
}
// Band must NOT be bulk-editable on its own: it travels with the frequency
// through BulkSetFrequency. A band contradicting its own frequency is invalid
// ADIF, and every export would carry the contradiction out into the world.
func TestBandIsNotBulkEditableAlone(t *testing.T) {
for _, col := range []string{"band", "freq_hz", "callsign", "qso_date"} {
if bulkEditableCols[col] {
t.Errorf("%s must not be bulk-editable on its own", col)
}
}
}
+41
View File
@@ -0,0 +1,41 @@
package qso
import (
"strings"
"testing"
)
// "equals nothing" and "is empty" are the same question. SQL answers the first
// with nothing at all — a NULL never equals ” — so a filter written in plain
// words returned zero rows and looked broken rather than wrong.
func TestEqualsBlankMeansEmpty(t *testing.T) {
sql, args, err := conditionSQL(Condition{Field: "freq_hz", Op: "eq", Value: ""})
if err != nil {
t.Fatalf("eq blank: %v", err)
}
if len(args) != 0 || !strings.Contains(sql, "IS NULL") {
t.Errorf("sql = %q args = %v — want the empty test", sql, args)
}
sql, _, _ = conditionSQL(Condition{Field: "name", Op: "ne", Value: " "})
if !strings.Contains(sql, "<> ''") {
t.Errorf("ne blank on text gave %q — want the not-empty test", sql)
}
}
// A numeric column is empty when NULL *or* zero, and that must be explicit:
// SQLite compares 0 against ” as false while MySQL calls it true, so one
// expression would answer two different questions depending on the backend.
func TestEmptyOnNumericCoversZeroAndNull(t *testing.T) {
sql, _, err := conditionSQL(Condition{Field: "freq_hz", Op: "empty"})
if err != nil {
t.Fatalf("empty: %v", err)
}
if !strings.Contains(sql, "IS NULL") || !strings.Contains(sql, "= 0") {
t.Errorf("sql = %q — want both NULL and zero", sql)
}
// Text keeps the string test: '' is a real value there, 0 is not.
sql, _, _ = conditionSQL(Condition{Field: "name", Op: "empty"})
if !strings.Contains(sql, "IFNULL") || strings.Contains(sql, "= 0") {
t.Errorf("text empty gave %q", sql)
}
}
+87 -8
View File
@@ -734,13 +734,26 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
return nil
}
// bulkEditableCols whitelists the columns BulkSetField may write. Limited to
// TEXT fields where setting one value across many QSOs is meaningful: the
// per-service QSL/upload status fields, plus "my station"/operator fields that
// are naturally constant across a run (grid, antenna, rig, address, …). It
// deliberately excludes per-QSO fields (callsign, band, mode, date, RST, the
// contacted station's details) and numeric columns (power, zones, lat/lon),
// which would be corrupted or meaningless if bulk-set to a single value.
// bulkEditableCols whitelists the columns BulkSetField may write.
//
// Mostly TEXT fields where one value across many QSOs is meaningful: the
// per-service QSL/upload status fields, plus "my station"/operator fields
// naturally constant across a run (grid, antenna, rig, address, …).
//
// Mode, submode and RST are here too, which the original rule excluded as
// "per-QSO". That rule confused two different things. Bulk edit is not for
// describing QSOs, it is for REPAIRING a batch — an import that mapped every
// contact to SSB, an ADIF with no MODE at all — and refusing to fix a hundred
// rows because a hundred rows should not normally share a value leaves the
// operator editing them one at a time.
//
// Band is NOT here, and frequency is not either: both go through
// BulkSetFrequency, which writes the pair together. A band that contradicts its
// own frequency is invalid ADIF, and every export would carry the contradiction.
//
// Still excluded, and this part of the rule stands: callsign and date, which
// identify the contact rather than describe it, and the numeric columns (power,
// zones, lat/lon) that are meaningless shared.
var bulkEditableCols = map[string]bool{
// QSL / upload status
"lotw_sent": true,
@@ -820,6 +833,14 @@ var bulkEditableCols = map[string]bool{
"iota": true,
"sig": true,
"sig_info": true,
// The contact itself. Repair fields: an import that mapped everything to SSB,
// or an ADIF that carried no MODE. Setting mode CLEARS submode (see
// BulkSetField) — a submode left over from the old mode contradicts the new
// one, and "FT8 / USB" is not a thing.
"mode": true,
"submode": true,
"rst_sent": true,
"rst_rcvd": true,
// Misc text
"comment": true,
"notes": true,
@@ -843,8 +864,16 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
ph[i] = "?"
args = append(args, id)
}
set := column + " = ?, updated_at = ?"
if column == "mode" {
// A submode belongs to the mode it was recorded under. Left behind, it
// contradicts the new one — "FT8" with a submode of "USB" is not a thing,
// and it is the submode that most ADIF readers believe. Clearing it is the
// only outcome that leaves the row meaning what the operator asked for.
set += ", submode = ''"
}
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+column+` = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
`UPDATE qso SET `+set+` WHERE id IN (`+strings.Join(ph, ",")+`)`,
args...)
if err != nil {
return 0, fmt.Errorf("bulk set %s: %w", column, err)
@@ -867,6 +896,23 @@ var bulkEditableExtras = map[string]string{
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
func BulkExtraKey(field string) string { return bulkEditableExtras[field] }
// BulkEditable reports whether a COLUMN may be bulk-written. Exported so the
// app layer can check its own field mapping against this whitelist: the two
// lists are separate, valid on their own, and a field present in one and absent
// from the other fails only when an operator tries to use it.
func BulkEditable(column string) bool { return bulkEditableCols[column] }
// BulkEditableColumns lists every bulk-writable column, for the same check from
// the other side: a column nothing maps to looks supported and cannot be used.
func BulkEditableColumns() []string {
out := make([]string, 0, len(bulkEditableCols))
for c := range bulkEditableCols {
out = append(out, c)
}
sort.Strings(out)
return out
}
// BulkSetExtra sets one whitelisted extras_json field on every listed QSO,
// leaving the other extras untouched. An empty value REMOVES the key rather than
// storing a blank — an empty extra would otherwise be carried into every export.
@@ -1220,6 +1266,17 @@ var filterableColumns = map[string]bool{
// value compares on the date part (see conditionSQL) so day filters are exact.
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
// numericColumns are the filterable columns holding numbers rather than text.
//
// "Empty" means something different for them: NULL *or* zero. It has to be said
// explicitly because the two backends disagree — SQLite compares 0 against ”
// as false, MySQL calls it true — so one expression would quietly answer two
// different questions depending on where the logbook lives.
var numericColumns = map[string]bool{
"freq_hz": true, "freq_rx_hz": true, "dxcc": true, "cqz": true, "ituz": true,
"srx": true, "stx": true, "tx_pwr": true,
}
// bareDateRe matches a plain calendar date with no time component.
var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
@@ -1320,6 +1377,18 @@ func conditionSQL(c Condition) (string, []any, error) {
col = "substr(" + col + ",1,10)"
v = strings.TrimSpace(v)
}
// "equals nothing" and "is empty" are the same question, and SQL answers the
// first with nothing at all: a NULL never equals '', so a filter written that
// way returns zero rows and looks broken rather than wrong. Asking it in
// plain words is not a mistake worth punishing.
if strings.TrimSpace(v) == "" {
switch c.Op {
case "eq":
c.Op = "empty"
case "ne":
c.Op = "notempty"
}
}
switch c.Op {
case "eq":
return col + " = ?", []any{v}, nil
@@ -1370,8 +1439,18 @@ func conditionSQL(c Condition) (string, []any, error) {
}
return col + " IN (" + ph + ")", args, nil
case "empty":
// A numeric column is empty when it is NULL *or* zero, and that has to be
// said explicitly: SQLite compares 0 against '' as false while MySQL calls
// it true, so IFNULL(col,'')='' quietly means different things on the two
// backends OpsLog supports.
if numericColumns[strings.ToLower(strings.TrimSpace(c.Field))] {
return "(" + col + " IS NULL OR " + col + " = 0)", nil, nil
}
return "IFNULL(" + col + ",'') = ''", nil, nil
case "notempty":
if numericColumns[strings.ToLower(strings.TrimSpace(c.Field))] {
return "(" + col + " IS NOT NULL AND " + col + " <> 0)", nil, nil
}
return "IFNULL(" + col + ",'') <> ''", nil, nil
default:
return "", nil, fmt.Errorf("unknown operator %q", c.Op)
+39 -4
View File
@@ -33,6 +33,7 @@ import (
"github.com/jlaffaye/ftp"
"hamlog/internal/geo"
"hamlog/internal/qso"
)
@@ -93,6 +94,26 @@ func flt(p *float64) string {
return strconv.FormatFloat(*p, 'f', -1, 64)
}
// distanceKm is the path length for the Distance column.
//
// The stored DISTANCE field is only ever filled by an ADIF import that carried
// one — OpsLog does not compute it when logging — so publishing it straight gave
// an empty column for every QSO made here, which is how this was reported.
//
// So it falls back to the two locators, which are on the QSO already. Rounded to
// whole kilometres: the grids are squares tens of kilometres across, and a
// decimal on a figure that imprecise claims an accuracy nobody has.
func distanceKm(q *qso.QSO) string {
if q.Distance != nil && *q.Distance > 0 {
return flt(q.Distance)
}
km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid)
if !ok || km <= 0 {
return ""
}
return strconv.FormatFloat(km, 'f', 0, 64)
}
func stamp(t time.Time) string {
if t.IsZero() {
return ""
@@ -218,7 +239,7 @@ var Columns = []Column{
{"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }},
{"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }},
{"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }},
{"distance", "Distance", "Location", func(q *qso.QSO) string { return flt(q.Distance) }},
{"distance", "Distance", "Location", distanceKm},
{"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }},
{"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }},
{"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }},
@@ -380,11 +401,25 @@ func renderHTML(cfg Config, cols []Column, qsos []qso.QSO, stationCall string) [
*{box-sizing:border-box}
body{margin:0;padding:1.5rem 1rem;background:var(--bg);color:var(--fg);
font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.wrap{max-width:1100px;margin:0 auto}
/* The page grows with its table instead of being capped at a comfortable
READING width. 1100px suits eight columns and hides the rest behind a
scrollbar on a screen wide enough to show them all — which is how this was
reported. max-content lets a wide table use the window; min-width keeps a
narrow one from collapsing to nothing on a large display. */
.wrap{max-width:max-content;min-width:min(1100px,100%);margin:0 auto}
h1{margin:0 0 .25rem;font-size:1.35rem}
.meta{margin:0 0 1rem;color:var(--mut);font-size:.8rem}
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px}
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
/* A visible scrollbar. Windows hides overlay scrollbars until something moves,
so a table that scrolls looks exactly like a table that is missing columns. */
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px;
scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.scroll::-webkit-scrollbar{height:10px}
.scroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
/* width:auto, not 100%: columns take the width their contents need. With
width:100% the browser squeezes them to fit the container first and only then
overflows, so a callsign could end up wrapped while empty space sat further
along the row. min-width keeps a two-column table filling the frame. */
table{border-collapse:collapse;width:auto;min-width:100%;font-variant-numeric:tabular-nums}
th,td{padding:.45rem .6rem;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
th{position:sticky;top:0;background:var(--head);font-size:.72rem;letter-spacing:.05em;
text-transform:uppercase;color:var(--mut);cursor:pointer;user-select:none}
+39
View File
@@ -0,0 +1,39 @@
package webpub
import (
"testing"
"hamlog/internal/qso"
)
// The Distance column was published empty for every QSO logged in OpsLog: the
// stored DISTANCE field is only ever filled by an ADIF import that carried one,
// and nothing computes it when logging. It falls back to the two locators.
func TestDistanceFallsBackToTheGrids(t *testing.T) {
// JN36 (French Alps) to IO91 (southern England): a few hundred kilometres.
got := distanceKm(&qso.QSO{MyGrid: "JN36DG", Grid: "IO91"})
if got == "" {
t.Fatal("no distance from two perfectly good locators")
}
if got == "0" {
t.Errorf("distance = %q", got)
}
}
// A stored distance wins: it came from the log that recorded the QSO, which knew
// more than two four-character squares do.
func TestStoredDistanceWins(t *testing.T) {
d := 1234.0
if got := distanceKm(&qso.QSO{Distance: &d, MyGrid: "JN36", Grid: "IO91"}); got != "1234" {
t.Errorf("distance = %q, want the stored 1234", got)
}
}
// No grids, no invention. An empty cell is honest; a zero is a claim.
func TestNoGridsNoDistance(t *testing.T) {
for _, q := range []qso.QSO{{}, {MyGrid: "JN36"}, {Grid: "IO91"}, {MyGrid: "??", Grid: "IO91"}} {
if got := distanceKm(&q); got != "" {
t.Errorf("distance(%+v) = %q, want empty", q, got)
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.24.5"
appVersion = "0.24.6"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.