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