From f39bda110a651a918289697036035e4bc358f5d9 Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 1 Sep 2026 11:56:20 +0200 Subject: [PATCH] fix(chase): the unconfirmed flag survives the next QSO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as 'South Africa shows NEW BAND although I have worked 15m'. The verdict was right — his hunt counts only LoTW and paper QSL, and no 15m contact is confirmed by either — but the badge drew SOLID, saying 'never worked here' about a band with four contacts in the log. Proved with his own data: the confirmed ledger holds 10/12/17/20/30/40/80 and not 15, the all-QSO ledger holds 15m FT8 four times, and the backend sets UnconfStatus correctly for exactly that case (new test, end to end through ClusterSpotStatuses). The flag died in the frontend. FOUR hand-written copies of the same backend-result mapping, and they had drifted: the two that RE-fetch — after a QSO is logged, and when a pane becomes visible — rebuilt each entry without the unconf_* flags, so the first refresh after any contact turned every dimmed badge solid for good. None of the four ever copied grid_state either, so a known-but-unconfirmed square never dimmed at all. One mapper now, used by all four. --- changelog.json | 6 ++- frontend/src/App.tsx | 89 +++++++++++++++++++------------------------- unconfband_test.go | 74 ++++++++++++++++++++++++++++++++++++ unconfrepro_test.go | 70 ++++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 52 deletions(-) create mode 100644 unconfband_test.go create mode 100644 unconfrepro_test.go diff --git a/changelog.json b/changelog.json index e5331c9..bb6afd8 100644 --- a/changelog.json +++ b/changelog.json @@ -9,7 +9,8 @@ "HamQTH joins the places the other services already were: two Recent-QSOs columns (sent status and date), the QSO filter, and bulk edit — the last one so a log uploaded to HamQTH by hand can be marked as sent instead of being offered for upload all over again.", "HamQTH whole-log upload: it reports itself in the console like every other action — the callsign it is scoped to, how many of the logbook’s QSOs that leaves, the file size, and HamQTH’s own reply — and says plainly that HamQTH imports the file in the background and e-mails any ADIF errors, so a site count that lags or stops short is explained rather than mysterious.", "QSO editor: correcting a frequency now moves its band with it, TX and RX — a QSO fixed to 7.1 MHz no longer stays filed on 20m. The band is only touched when the frequency lands in a known allocation, so a half-typed number never blanks it.", - "CAT: an option to put the radio in USB for digital modes (Settings → CAT), for every backend. Clicking an FT8 spot on a rig whose CAT layer resolves “digital” to RTTY/FSK — OmniRig does, per rig file — landed the operator in FSK, which cannot pass FT8 at all. The QSO is still logged as FT8: only the radio changes." + "CAT: an option to put the radio in USB for digital modes (Settings → CAT), for every backend. Clicking an FT8 spot on a rig whose CAT layer resolves “digital” to RTTY/FSK — OmniRig does, per rig file — landed the operator in FSK, which cannot pass FT8 at all. The QSO is still logged as FT8: only the radio changes.", + "Fixed: a “worked but not confirmed” badge turned solid again after the next QSO, so an entity worked on a band still read NEW BAND as if it had never been worked there. Four hand-written copies of the same status mapping had drifted apart — the two that re-fetch dropped the unconfirmed flags, and none of them ever carried the grid one. There is one mapping now." ], "fr": [ "DX Cluster : un serveur déconnecté garde sa pastille et peut donc être reconnecté — le déconnecter le faisait disparaître avec le seul moyen d’y revenir.", @@ -18,7 +19,8 @@ "HamQTH rejoint les endroits où les autres services étaient déjà : deux colonnes dans les QSO récents (statut et date d’envoi), le filtre de QSO et l’édition groupée — cette dernière pour qu’un log envoyé à la main sur HamQTH puisse être marqué comme envoyé au lieu d’être reproposé à l’envoi.", "Envoi du log complet HamQTH : il rend compte dans la console comme toutes les autres actions — l’indicatif retenu, combien de QSO du journal cela représente, la taille du fichier et la réponse de HamQTH — et indique clairement que HamQTH importe le fichier en arrière-plan et envoie les erreurs ADIF par e-mail : un compteur en retard ou incomplet sur le site est ainsi expliqué au lieu d’être mystérieux.", "Éditeur de QSO : corriger une fréquence déplace désormais sa bande avec elle, TX comme RX — un QSO corrigé à 7,1 MHz ne reste plus classé en 20m. La bande n’est touchée que si la fréquence tombe dans une allocation connue : un nombre à moitié tapé ne l’efface jamais.", - "CAT : une option pour mettre la radio en USB sur les modes numériques (Réglages → CAT), pour tous les backends. Cliquer un spot FT8 sur un poste dont la couche CAT traduit « numérique » par RTTY/FSK — c’est le cas d’OmniRig, selon le fichier radio — faisait basculer l’opérateur en FSK, incapable de passer du FT8. Le QSO reste enregistré en FT8 : seule la radio change." + "CAT : une option pour mettre la radio en USB sur les modes numériques (Réglages → CAT), pour tous les backends. Cliquer un spot FT8 sur un poste dont la couche CAT traduit « numérique » par RTTY/FSK — c’est le cas d’OmniRig, selon le fichier radio — faisait basculer l’opérateur en FSK, incapable de passer du FT8. Le QSO reste enregistré en FT8 : seule la radio change.", + "Corrigé : un badge « contacté mais non confirmé » redevenait plein dès le QSO suivant, si bien qu’une entité contactée sur une bande affichait NEW BAND comme si elle ne l’avait jamais été. Quatre copies écrites à la main de la même conversion de statut avaient divergé — les deux qui rafraîchissent perdaient les drapeaux « non confirmé », et aucune ne transportait celui des locators. Il n’y en a plus qu’une." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f32289..8d9a64c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2098,7 +2098,41 @@ export default function App() { // worked_slot must be carried explicitly like every other field: this map is // assembled field by field, so a backend flag that nobody copies here simply // never reaches the panels — silently, since the extra key is just dropped. - const [spotStatus, setSpotStatus] = useState>({}); + // spotStatusEntry maps ONE backend verdict onto the entry the panels read. + // + // There were four of these, written by hand at each call site, and they had + // drifted apart: the two that RE-fetch — after a QSO is logged, and when a + // pane becomes visible — rebuilt every entry without the unconfirmed flags. + // So a badge drawn correctly as "worked, QSL missing" turned solid at the + // next refresh and stayed that way, which is how South Africa came to read + // NEW BAND on a band with four contacts in the log. grid_state was never + // copied by any of them, so a known-but-unconfirmed square never dimmed at + // all. One function now, so a field added to the backend cannot reach three + // callers and miss the fourth. + const spotStatusEntry = (r: any) => ({ + status: r.status ?? '', + country: r.country, + continent: r.continent, + worked_call: !!r.worked_call, + worked_slot: !!r.worked_slot, + new_county: !!r.new_county, + lotw: !!r.lotw, + spotter_continent: r.spotter_continent, + grid: r.grid, + grid_state: r.grid_state, + new_grid: !!r.new_grid, + county: r.county, + state: r.state, + new_state: !!r.new_state, + new_pota: !!r.new_pota, + new_pfx: !!r.new_pfx, + pfx: r.pfx, + unconf_status: !!r.unconf_status, + unconf_pfx: !!r.unconf_pfx, + unconf_cty: !!r.unconf_cty, + unconf_state: !!r.unconf_state, + }); + const [spotStatus, setSpotStatus] = useState>({}); // Live mirror of spotStatus so the incoming-spot buffer can tell which slots // still need resolving without re-subscribing the cluster:spot listener. const spotStatusRef = useRef(spotStatus); @@ -2171,11 +2205,7 @@ export default function App() { const next = { ...prev }; for (const r of res) { const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; - next[k] = { - status: r.status ?? '', country: r.country, continent: (r as any).continent, - worked_call: !!(r as any).worked_call, worked_slot: !!(r as any).worked_slot, new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state, - new_pota: !!(r as any).new_pota, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx, - }; + next[k] = spotStatusEntry(r); } return next; }); @@ -3654,22 +3684,7 @@ export default function App() { const next = { ...prev }; for (const r of res) { const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; - next[k] = { - status: r.status ?? '', - country: r.country, - continent: (r as any).continent, - worked_call: !!(r as any).worked_call, - worked_slot: !!(r as any).worked_slot, - new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state, - new_state: !!(r as any).new_state, - unconf_status: !!(r as any).unconf_status, - unconf_pfx: !!(r as any).unconf_pfx, - unconf_cty: !!(r as any).unconf_cty, - unconf_state: !!(r as any).unconf_state, - new_pota: !!(r as any).new_pota, - new_pfx: !!(r as any).new_pfx, - pfx: (r as any).pfx, - }; + next[k] = spotStatusEntry(r); } return next; }); @@ -3763,23 +3778,7 @@ export default function App() { const next = { ...prev }; for (const r of res) { const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; - next[k] = { - status: r.status ?? '', - country: r.country, - continent: (r as any).continent, - worked_call: !!(r as any).worked_call, - worked_slot: !!(r as any).worked_slot, - new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, - grid: (r as any).grid, new_grid: !!(r as any).new_grid, - county: (r as any).county, state: (r as any).state, - new_state: !!(r as any).new_state, - unconf_status: !!(r as any).unconf_status, - unconf_pfx: !!(r as any).unconf_pfx, - unconf_cty: !!(r as any).unconf_cty, - unconf_state: !!(r as any).unconf_state, - new_pota: !!(r as any).new_pota, - new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx, - }; + next[k] = spotStatusEntry(r); } return next; }); @@ -4277,17 +4276,7 @@ export default function App() { const next = { ...prev }; for (const r of res) { const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; - next[k] = { - status: r.status ?? '', - country: r.country, - continent: (r as any).continent, - worked_call: !!(r as any).worked_call, - worked_slot: !!(r as any).worked_slot, - new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state, - new_pota: !!(r as any).new_pota, - new_pfx: !!(r as any).new_pfx, - pfx: (r as any).pfx, - }; + next[k] = spotStatusEntry(r); } return next; }); diff --git a/unconfband_test.go b/unconfband_test.go new file mode 100644 index 0000000..8933381 --- /dev/null +++ b/unconfband_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "testing" + "time" + + "hamlog/internal/qso" +) + +// The operator's report: South Africa worked on 15m FT8 but confirmed only by +// eQSL/Club Log/QRZ, with the chase set to "new + unconfirmed" over LoTW and +// paper QSL. The verdict must be NEW BAND — that is what the settings ask for — +// and it must be flagged UNCONFIRMED, so the badge reads "worked, QSL missing" +// rather than "never worked here". +func TestUnconfirmedBandIsFlagged(t *testing.T) { + a := syncTestApp(t) + hz := int64(21074000) + add := func(call, band, mode string, lotw, eqsl string, dxcc int) { + q := qso.QSO{ + Callsign: call, Band: band, Mode: mode, + QSODate: time.Now().UTC().Add(-48 * time.Hour), + FreqHz: &hz, DXCC: &dxcc, Country: "South Africa", + LOTWRcvd: lotw, EQSLRcvd: eqsl, + } + if _, err := a.qso.Add(a.ctx, q); err != nil { + t.Fatalf("add %s: %v", call, err) + } + } + // 20m is LoTW-confirmed, so the ENTITY is confirmed… + add("ZS1AAA", "20m", "FT8", "Y", "Y", 462) + // …but every 15m contact is confirmed only by eQSL, which this operator + // does not count. + add("ZS6GAV", "15m", "FT8", "N", "Y", 462) + add("ZS4AW", "15m", "FT8", "N", "N", 462) + + pred := qso.ConfirmSourcesPredicate([]string{"lotw", "card"}) + all, err := a.qso.EntitySlotMapPred(a.ctx, func(_ string, dx int, _ string) int { return dx }, nil, "") + if err != nil { + t.Fatalf("all ledger: %v", err) + } + conf, err := a.qso.EntitySlotMapPred(a.ctx, func(_ string, dx int, _ string) int { return dx }, nil, pred) + if err != nil { + t.Fatalf("confirmed ledger: %v", err) + } + + if _, ok := all[462]; !ok { + t.Fatal("the all-QSO ledger has no South Africa at all") + } + if _, ok := all[462].Bands["15m"]; !ok { + t.Error("all-QSO ledger: 15m missing — the dimming test can never fire") + } + if _, ok := all[462].Slots["15m"]["FT8"]; !ok { + t.Error("all-QSO ledger: 15m/FT8 slot missing") + } + c, ok := conf[462] + if !ok { + t.Fatal("confirmed ledger: South Africa missing — the 20m LoTW QSO should put it there") + } + if _, ok := c.Bands["15m"]; ok { + t.Error("confirmed ledger has 15m, but no 15m QSO is confirmed by LoTW or card") + } + + // The two halves the UI shows. + status := spotEntityStatus(true, false, true, false, "FT8") + if status != "new-band" { + t.Errorf("status = %q, want new-band", status) + } + _, bAll := all[462].Bands["15m"] + _, mAll := all[462].Modes["FT8"] + _, sAll := all[462].Slots["15m"]["FT8"] + if got := spotEntityStatus(true, bAll, mAll, sAll, "FT8"); got != "worked" { + t.Errorf("all-ledger verdict = %q, want worked (→ the badge should be dimmed)", got) + } +} diff --git a/unconfrepro_test.go b/unconfrepro_test.go new file mode 100644 index 0000000..bb855b4 --- /dev/null +++ b/unconfrepro_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + "time" + + "hamlog/internal/db" + "hamlog/internal/dxcc" + "hamlog/internal/qso" + "hamlog/internal/settings" +) + +// The operator's exact case, end to end through the real verdict path. +// +// South Africa is confirmed (LoTW) on 20m and worked-but-unconfirmed on 15m, +// with the hunt set to "new + unconfirmed" over LoTW and paper QSL. A 15m FT8 +// decode must come back NEW BAND — that is what the settings ask — and carry +// the UNCONFIRMED flag, which is what draws the badge as a missing QSL rather +// than a band never worked. +func TestClusterStatusFlagsUnconfirmedBand(t *testing.T) { + dir := t.TempDir() + conn, err := db.Open(filepath.Join(dir, "log.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { conn.Close() }) + + a := &App{ctx: context.Background(), qso: qso.NewRepo(conn), settings: settings.NewStore(conn)} + a.settingsScoped.Store(true) + if err := a.settings.Set(a.ctx, keyChaseMode, "new_unconfirmed"); err != nil { + t.Fatalf("set chase mode: %v", err) + } + if err := a.settings.Set(a.ctx, keyChaseConfirm, "lotw,card"); err != nil { + t.Fatalf("set chase sources: %v", err) + } + + // The real prefix table, so ZS4AW resolves the way it does in the app. + a.dxcc = dxcc.NewManager(filepath.Join("build", "bin", "data")) + if err := a.dxcc.LoadFromDisk(); err != nil { + t.Skipf("cty.dat not available here: %v", err) + } + + za := 462 + add := func(call, band, mode, lotw string) { + hz := int64(21074000) + if _, err := a.qso.Add(a.ctx, qso.QSO{ + Callsign: call, Band: band, Mode: mode, FreqHz: &hz, + QSODate: time.Now().UTC().Add(-72 * time.Hour), + DXCC: &za, Country: "South Africa", LOTWRcvd: lotw, + }); err != nil { + t.Fatalf("add %s: %v", call, err) + } + } + add("ZS1AAA", "20m", "FT8", "Y") // the entity IS confirmed, elsewhere + add("ZS6GAV", "15m", "FT8", "N") // 15m worked, never confirmed + add("ZS4AW", "15m", "FT8", "N") + + got := a.ClusterSpotStatuses([]SpotQuery{{Call: "ZS4AW", Band: "15m", Mode: "FT8"}}) + if len(got) != 1 { + t.Fatalf("got %d results", len(got)) + } + if got[0].Status != "new-band" { + t.Fatalf("status = %q, want new-band", got[0].Status) + } + if !got[0].UnconfStatus { + t.Error("UnconfStatus is false — the badge draws solid, as if 15m had never been worked") + } +}