From 93732ee563dbfddbcdf5e3eccba0371217d238e9 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Sun, 16 Aug 2026 21:34:15 +0200 Subject: [PATCH] fix(entity): show the QSO's own entity, and date the ClubLog exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults behind one screenshot: 3Y0K, recorded in the log as Bouvet Island, showing the Antarctica matrix. SELECTING A QSO now shows what that QSO records. The panel asked WorkedBefore with no DXCC hint, and a hint of zero makes the backend resolve the entity from cty.dat and the ClubLog exceptions AS THEY ARE TODAY — right for a live contact, wrong for one being looked at in the log. The repo's own "infer from past QSOs" path never ran, because the hint was no longer zero by the time it got there. Hence Antarctica, and "5 QSOs with this entity" against "11 with this call": two different entities, one of them nobody had asked about. The selected row's dxcc now travels with it and is passed as the hint. Browsing the log shows what the log says, even where the log is wrong. Correcting an entity stays a deliberate act — right-click, Update from ClubLog. BACK-ENTERING A QSO now resolves the exception at the CONTACT'S date. An exception carries a validity window and a DXpedition's window closes: 3Y0K typed months later, with the activation's own date in the form, was resolved against today, matched nothing, and fell back to cty.dat. Both callers pass the date they already hold — the entry strip's, and in the editor the record's own. The date is trusted to move the resolution BACKWARDS only. A half-typed "2026-0" must not send the lookup to the year 20, and a mistyped future date must not resolve against a window that has not opened; both fall back to now. Midday rather than midnight, because a window given in whole days is inclusive of its end date and 00:00 sits exactly on the boundary. --- app.go | 55 ++++++++++++++++++++---- changelog.json | 8 +++- frontend/src/App.tsx | 29 ++++++++++--- frontend/src/components/QSOEditModal.tsx | 5 ++- frontend/wailsjs/go/main/App.d.ts | 4 +- frontend/wailsjs/go/main/App.js | 8 ++-- lookupwhen_test.go | 45 +++++++++++++++++++ 7 files changed, 132 insertions(+), 22 deletions(-) create mode 100644 lookupwhen_test.go diff --git a/app.go b/app.go index 4e7c9d1..0712bf8 100644 --- a/app.go +++ b/app.go @@ -7127,8 +7127,14 @@ func (a *App) SaveCabrilloFile() (string, error) { // LookupCallsign returns the cached or freshly-fetched info for a callsign. // Errors are returned as-is to the frontend; ErrNotFound surfaces as // "callsign not found". -func (a *App) LookupCallsign(callsign string) (lookup.Result, error) { - return a.lookupCallsign(callsign, false) +// qsoDate is the entry form's date as "YYYY-MM-DD" (or empty for now). It is +// what the ClubLog exception is resolved AGAINST: an exception has a validity +// window, and a DXpedition's window closes. Entering 3Y0K by hand months later, +// with the activation's own date in the form, resolved the exception at TODAY's +// date, found none, and fell back to cty.dat — Antarctica instead of Bouvet +// Island. A backdated QSO must be enriched as of when it happened. +func (a *App) LookupCallsign(callsign string, qsoDate string) (lookup.Result, error) { + return a.lookupCallsign(callsign, false, qsoDate) } // LookupCallsignFresh is the same, but SKIPS the cache and refreshes it. @@ -7139,11 +7145,11 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) { // subscription went on getting the thin free-account record, and deleting the // cached row by hand was the only way out. A deliberate click must reach the // provider and overwrite what was stored. -func (a *App) LookupCallsignFresh(callsign string) (lookup.Result, error) { - return a.lookupCallsign(callsign, true) +func (a *App) LookupCallsignFresh(callsign string, qsoDate string) (lookup.Result, error) { + return a.lookupCallsign(callsign, true, qsoDate) } -func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error) { +func (a *App) lookupCallsign(callsign string, force bool, qsoDate string) (lookup.Result, error) { if a.lookup == nil { return lookup.Result{}, fmt.Errorf("lookup not initialized") } @@ -7188,10 +7194,15 @@ func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error) r.ImageURL = "" } } - // ClubLog exception override (live entry → today's date): for an active - // DXpedition the entered call gets the right entity/zones immediately. + // ClubLog exception override, resolved AT THE QSO'S DATE. + // + // An exception carries a validity window and a DXpedition's window closes. + // Resolving at today's date is right for a contact happening now and wrong + // for one being entered afterwards: 3Y0K typed months later, with the + // activation's date in the form, found no live exception and fell back to + // cty.dat — Antarctica, where the log says Bouvet Island. if a.clublogCtyEnabled() && a.clublog != nil { - if e, ok := a.clublog.Resolve(callsign, time.Now().UTC()); ok { + if e, ok := a.clublog.Resolve(callsign, lookupWhen(qsoDate)); ok { r.Country = titleEntity(e.Entity) if e.Cont != "" { r.Continent = e.Cont @@ -18512,3 +18523,31 @@ func wsjtLoggedQSO(q qso.QSO) udp.LoggedQSO { } return out } + +// lookupWhen turns the entry form's date into the instant a ClubLog exception +// should be resolved at. +// +// Empty, unparseable, or in the future → now. A date is only trusted to move +// the resolution BACKWARDS: a half-typed "2026-0" must not send the lookup to +// the year 20, and a mistyped future date must not resolve against an exception +// window that has not opened. +// +// Midday UTC rather than midnight: an exception window given in whole days is +// inclusive of its end date, and resolving at 00:00 of that day sits on the +// boundary where an off-by-one in either direction changes the answer. +func lookupWhen(qsoDate string) time.Time { + now := time.Now().UTC() + s := strings.TrimSpace(qsoDate) + if s == "" { + return now + } + t, err := time.Parse("2006-01-02", s) + if err != nil { + return now + } + t = t.Add(12 * time.Hour) + if t.After(now) { + return now + } + return t +} diff --git a/changelog.json b/changelog.json index e73217f..5f944b2 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,17 @@ "Opening the Awards panel no longer pulls the whole logbook several times at once — a large log briefly took gigabytes of memory.", "Awards: the Missing refs button now only appears where it means something — a worldwide award like POTA could only ever answer “nothing found”.", "Worked before: a prefixed call like ZA/OE8NDR matched every other visitor to that country instead of that one operator.", - "French: seventeen strings were still in English, the whole update panel among them, plus Spot lifetime and Chase new grids." + "French: seventeen strings were still in English, the whole update panel among them, plus Spot lifetime and Chase new grids.", + "Selecting a QSO shows the entity the QSO records, not one re-derived from its callsign — a 3Y0K contact logged as Bouvet showed the Antarctica matrix.", + "Back-entering a QSO resolves the ClubLog exception at the CONTACT’S date, so a DXpedition entered months later gets the entity it had then." ], "fr": [ "Ouvrir le panneau Awards ne tire plus plusieurs fois le journal entier en même temps — un gros log occupait brièvement des gigaoctets de mémoire.", "Awards : le bouton Réf. manquantes n’apparaît plus que là où il a un sens — un diplôme mondial comme POTA ne pouvait répondre que « aucun manque ».", "Déjà contacté : un indicatif préfixé comme ZA/OE8NDR rapprochait tous les autres visiteurs du pays au lieu de ce seul opérateur.", - "Français : dix-sept textes étaient restés en anglais, dont tout le panneau de mise à jour, la durée de vie des spots et Chasser les nouveaux locators." + "Français : dix-sept textes étaient restés en anglais, dont tout le panneau de mise à jour, la durée de vie des spots et Chasser les nouveaux locators.", + "Sélectionner un QSO affiche l’entité que le QSO enregistre, pas une recalculée depuis l’indicatif — un 3Y0K logué Bouvet montrait la matrice Antarctique.", + "Saisir un QSO a posteriori résout l’exception ClubLog à la date DU CONTACT : une DXpedition entrée des mois après retrouve l’entité qu’elle avait alors." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18144be..7b7826e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1780,7 +1780,10 @@ export default function App() { // Stats (F1) matrix whenever the entry form is empty — clicking a past contact // is the natural way to ask "what else have I got with this one?", and until // now the panel just sat blank. - const [selQso, setSelQso] = useState<{ call: string; band: string; mode: string } | null>(null); + // The selected row's own entity travels with it. Re-deriving it from the + // callsign is what showed Antarctica for a 3Y0K contact the log records as + // Bouvet Island — see the WorkedBefore call below. + const [selQso, setSelQso] = useState<{ call: string; band: string; mode: string; dxcc: number } | null>(null); const [bulkEditIds, setBulkEditIds] = useState([]); const [bulkEditOpen, setBulkEditOpen] = useState(false); const [showSettings, setShowSettings] = useState(false); @@ -3752,7 +3755,19 @@ export default function App() { if (!call || callsign.trim()) { setSelWb(null); return; } let dead = false; setSelWbBusy(true); - WorkedBefore(call, 0) + // The SELECTED QSO's own entity, not one re-derived from its callsign. + // + // Passing 0 lets the backend resolve the entity from cty.dat and the + // ClubLog exceptions AS THEY ARE TODAY, which is right for a live contact + // and wrong for one being looked at in the log. A 3Y0K contact recorded as + // Bouvet Island showed the Antarctica matrix, and counted five contacts + // "with this entity" against eleven with the call — two different entities, + // one of them nobody asked about. + // + // Browsing the log shows what the log says, even where the log is wrong. + // Correcting an entity is a deliberate act (right-click → Update from + // ClubLog), not something a panel does behind the operator's back. + WorkedBefore(call, selQso?.dxcc || 0) .then((w: any) => { if (!dead) setSelWb(w); }) .catch(() => { if (!dead) setSelWb(null); }) .finally(() => { if (!dead) setSelWbBusy(false); }); @@ -3861,7 +3876,11 @@ export default function App() { const gen = lookupGenRef.current; // invalidated by ESC / resetEntry setLookupBusy(true); try { - const r = await LookupCallsign(call); + // The ENTRY'S date, so a ClubLog exception resolves at the moment the + // contact happened. Live, that is now and nothing changes; back-entering + // a past QSO, it is what makes 3Y0K come back Bouvet Island instead of + // Antarctica — the exception's window had closed by today. + const r = await LookupCallsign(call, qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : ''); // Discard a STALE result: the operator already moved to another call // (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup // was in flight. Applying it would clobber the current fields and zoom the @@ -5359,7 +5378,7 @@ export default function App() { onExportFiltered={exportFilteredADIF} onDelete={(ids) => setDeletingIds(ids)} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} - onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)} + onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? ""), dxcc: Number(r.dxcc ?? 0) } : null)} /> ); @@ -6677,7 +6696,7 @@ export default function App() { onExportCabrilloFiltered={exportFilteredCabrillo} onDelete={(ids) => setDeletingIds(ids)} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} - onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)} + onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? ""), dxcc: Number(r.dxcc ?? 0) } : null)} />
diff --git a/frontend/src/components/QSOEditModal.tsx b/frontend/src/components/QSOEditModal.tsx index d376866..4dcef2c 100644 --- a/frontend/src/components/QSOEditModal.tsx +++ b/frontend/src/components/QSOEditModal.tsx @@ -374,7 +374,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b // refreshes it. A cached answer from a thinner QRZ subscription (or any // stale row) otherwise stayed for its whole 30-day life and the button // appeared to do nothing. - const r: any = await LookupCallsignFresh(call); + // The QSO's OWN date: a ClubLog exception is resolved as of when the + // contact happened, not as of today. Re-looking-up a DXpedition contact + // months later must not move it to whatever the prefix means now. + const r: any = await LookupCallsignFresh(call, (dateOn || '').slice(0, 10)); // The lookup WINS over what is in the record — that is the point of asking // for it. But an EMPTY result must never blank a good value: `??` only // guards against null, and Go marshals an unset string as "", so a QRZ diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index f4b741a..577c880 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -703,9 +703,9 @@ export function LogUDPLoggedADIF(arg1:string):Promise; export function LogUIError(arg1:string,arg2:string,arg3:string):Promise; -export function LookupCallsign(arg1:string):Promise; +export function LookupCallsign(arg1:string,arg2:string):Promise; -export function LookupCallsignFresh(arg1:string):Promise; +export function LookupCallsignFresh(arg1:string,arg2:string):Promise; export function MotorNudgeKHz(arg1:number):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 466e14e..ffda911 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1346,12 +1346,12 @@ export function LogUIError(arg1, arg2, arg3) { return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3); } -export function LookupCallsign(arg1) { - return window['go']['main']['App']['LookupCallsign'](arg1); +export function LookupCallsign(arg1, arg2) { + return window['go']['main']['App']['LookupCallsign'](arg1, arg2); } -export function LookupCallsignFresh(arg1) { - return window['go']['main']['App']['LookupCallsignFresh'](arg1); +export function LookupCallsignFresh(arg1, arg2) { + return window['go']['main']['App']['LookupCallsignFresh'](arg1, arg2); } export function MotorNudgeKHz(arg1) { diff --git a/lookupwhen_test.go b/lookupwhen_test.go new file mode 100644 index 0000000..e40709a --- /dev/null +++ b/lookupwhen_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "testing" + "time" +) + +// A ClubLog exception has a validity window, and a DXpedition's window closes. +// So a backdated entry must be enriched as of WHEN IT HAPPENED — 3Y0K typed +// months after the activation, with the activation's date in the form, resolved +// at today's date, found no live exception, and fell back to cty.dat: +// Antarctica, where the log says Bouvet Island. +// +// The date is trusted to move the resolution BACKWARDS only. A half-typed date +// must not send the lookup to the year 20, and a mistyped future one must not +// resolve against a window that has not opened. +func TestLookupWhen(t *testing.T) { + now := time.Now().UTC() + + if got := lookupWhen("2026-03-08"); got.Format("2006-01-02") != "2026-03-08" { + t.Errorf("a past date gave %v — the activation's own date is the whole point", got) + } + // Midday, not midnight: a window given in whole days is inclusive of its end + // date, and 00:00 sits exactly on the boundary. + if h := lookupWhen("2026-03-08").Hour(); h != 12 { + t.Errorf("resolved at %02d:00, want 12:00 — midnight sits on the window boundary", h) + } + + for name, in := range map[string]string{ + "empty": "", + "spaces": " ", + "half-typed": "2026-0", + "not a date": "hier", + "wrong format": "08/03/2026", + } { + if got := lookupWhen(in); got.Before(now.Add(-time.Minute)) { + t.Errorf("%s (%q) resolved to %v — anything unusable must mean now", name, in, got) + } + } + + future := now.AddDate(1, 0, 0).Format("2006-01-02") + if got := lookupWhen(future); got.After(now.Add(time.Minute)) { + t.Errorf("a future date (%s) resolved to %v — a mistyped year must not open a window early", future, got) + } +}