fix(entity): show the QSO's own entity, and date the ClubLog exception
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+6
-2
@@ -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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+24
-5
@@ -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<number[]>([]);
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -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)}
|
||||
/>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
-2
@@ -703,9 +703,9 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
export function LookupCallsign(arg1:string,arg2:string):Promise<lookup.Result>;
|
||||
|
||||
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||
export function LookupCallsignFresh(arg1:string,arg2:string):Promise<lookup.Result>;
|
||||
|
||||
export function MotorNudgeKHz(arg1:number):Promise<void>;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user