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) + } +}