diff --git a/changelog.json b/changelog.json index 18a5727..c489428 100644 --- a/changelog.json +++ b/changelog.json @@ -2,8 +2,12 @@ { "version": "0.25.3", "date": "", - "en": [], - "fr": [] + "en": [ + "Confirmations: a LoTW contact marked V (verified) counted for the awards but not for the band/mode matrix, the slot statistics or the row colours — they only accepted Y. One entity could read “validated” in Awards and “worked” beside it." + ], + "fr": [ + "Confirmations : un contact LoTW marqué V (vérifié) comptait pour les diplômes mais pas pour la matrice bande/mode, les statistiques de créneaux ni la coloration des lignes — elles n’acceptaient que Y. Une même entité pouvait être « validée » dans Diplômes et « travaillée » juste à côté." + ] }, { "version": "0.25.2", diff --git a/cmd/dbdiag/main.go b/cmd/dbdiag/main.go index 1c9066b..8578621 100644 --- a/cmd/dbdiag/main.go +++ b/cmd/dbdiag/main.go @@ -109,9 +109,9 @@ func probeWB(conn *sql.DB, call string, dxcc int) { SELECT band, mode, MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END), MAX(CASE WHEN callsign = ? - AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y') + AND (lotw_rcvd IN ('Y','V') OR qsl_rcvd IN ('Y','V') OR eqsl_rcvd IN ('Y','V')) THEN 1 ELSE 0 END), - MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y' + MAX(CASE WHEN lotw_rcvd IN ('Y','V') OR qsl_rcvd IN ('Y','V') OR eqsl_rcvd IN ('Y','V') THEN 1 ELSE 0 END) FROM qso WHERE dxcc = ? GROUP BY band, mode diff --git a/frontend/src/components/AwardsPanel.tsx b/frontend/src/components/AwardsPanel.tsx index 1434a5d..74b3721 100644 --- a/frontend/src/components/AwardsPanel.tsx +++ b/frontend/src/components/AwardsPanel.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { cn } from '@/lib/utils'; +import { isQSLConfirmed } from '@/lib/qsl'; import { AwardEditor } from '@/components/AwardEditor'; import { useI18n } from '@/lib/i18n'; import { writeUiPref } from '@/lib/uiPref'; @@ -818,7 +819,7 @@ function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: {q.callsign} {q.band} {q.mode} - {[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && 'eQSL'].filter(Boolean).join(', ')} + {[isQSLConfirmed(q.lotw_rcvd) && 'LoTW', isQSLConfirmed(q.qsl_rcvd) && 'QSL', isQSLConfirmed(q.eqsl_rcvd) && 'eQSL'].filter(Boolean).join(', ')} ))} diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index dfd3550..8aa5675 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -3,6 +3,7 @@ import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { sunTimes } from '@/lib/sun'; +import { isQSLConfirmed } from '@/lib/qsl'; import { BandSlotQSOs } from '../../wailsjs/go/main/App'; import type { WorkedBeforeView } from '@/types'; @@ -426,7 +427,7 @@ function SlotQSOModal({ call, dxcc, entity, band, cls, onClose, onEdit }: { {rows.map((q, i) => { - const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y'; + const cfm = isQSLConfirmed(q.lotw_rcvd) || isQSLConfirmed(q.eqsl_rcvd) || isQSLConfirmed(q.qsl_rcvd); const mine = q.callsign === call; return ( diff --git a/frontend/src/components/CallHistoryPanel.tsx b/frontend/src/components/CallHistoryPanel.tsx index c6fe434..fdf0877 100644 --- a/frontend/src/components/CallHistoryPanel.tsx +++ b/frontend/src/components/CallHistoryPanel.tsx @@ -1,6 +1,7 @@ import { Star } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { useI18n } from '@/lib/i18n'; +import { isQSLConfirmed } from '@/lib/qsl'; import type { WorkedBeforeView } from '@/types'; type WorkedBefore = WorkedBeforeView; @@ -97,10 +98,10 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) { {e.rst_sent ?? ''} {e.rst_rcvd ?? ''} - {e.lotw_rcvd === 'Y' && ( + {isQSLConfirmed(e.lotw_rcvd) && ( L )} - {e.qsl_rcvd === 'Y' && ( + {isQSLConfirmed(e.qsl_rcvd) && ( B )} diff --git a/frontend/src/lib/qsl.ts b/frontend/src/lib/qsl.ts new file mode 100644 index 0000000..ab3023b --- /dev/null +++ b/frontend/src/lib/qsl.ts @@ -0,0 +1,17 @@ +// One answer to "is this QSL received". +// +// ADIF's QSL_Rcvd enumeration has BOTH Y and V: Y is "received", V is +// "verified" — and V is what a LoTW download writes for a confirmation the ARRL +// has validated. Testing for 'Y' alone therefore misses exactly the +// confirmations an operator cares most about. +// +// It showed on screen: the Awards panel had Morocco validated on five bands +// while the band/mode matrix beside it showed the entity as merely worked. The +// award engine accepted Y or V; every other test in the app accepted Y. +// +// The Go side has the same rule twice over — award.isYes and qso.ConfirmedValues +// — and all three have to agree. If you add a value here, add it there. +export function isQSLConfirmed(v: unknown): boolean { + const s = String(v ?? '').trim().toUpperCase(); + return s === 'Y' || s === 'V'; +} diff --git a/frontend/src/lib/rowColors.ts b/frontend/src/lib/rowColors.ts index 7e564c0..39e5748 100644 --- a/frontend/src/lib/rowColors.ts +++ b/frontend/src/lib/rowColors.ts @@ -1,3 +1,4 @@ +import { isQSLConfirmed } from '@/lib/qsl'; // Row colouring for the log grid, by QSL status. // // Four categories, each scoped to the channels the operator cares about — @@ -32,7 +33,9 @@ const FIELDS: Record = { // ADIF QSL fields are single letters. Y is the only one that means "yes"; // R (requested) and Q (queued) mean it has not gone out yet — a different state, // and the one an operator looks for when deciding what to send. -const yes = (v: any) => String(v ?? '').trim().toUpperCase() === 'Y'; +// Y or V — see lib/qsl. A LoTW-verified contact is confirmed, and colouring it +// as unconfirmed is the same bug the band/mode matrix had. +const yes = (v: any) => isQSLConfirmed(v); const owed = (v: any) => { const s = String(v ?? '').trim().toUpperCase(); return s === 'R' || s === 'Q'; diff --git a/internal/qso/confirmedvalues_test.go b/internal/qso/confirmedvalues_test.go new file mode 100644 index 0000000..bcb49d1 --- /dev/null +++ b/internal/qso/confirmedvalues_test.go @@ -0,0 +1,65 @@ +package qso + +import ( + "context" + "database/sql" + "testing" + + _ "modernc.org/sqlite" +) + +// A LoTW confirmation the ARRL has validated arrives as V, not Y — ADIF's +// QSL_Rcvd enumeration has both. Every SQL query here compared against 'Y' +// alone, so the band/mode matrix showed an entity as merely worked while the +// Awards panel beside it showed the same entity validated on five bands. +// +// This drives the real queries against a real database rather than asserting on +// the constant: the constant being right is not the point, the queries using it +// is. +func TestConfirmedCountsVerifiedNotJustYes(t *testing.T) { + db, err := sql.Open("sqlite", "file:confirmedvalues?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE qso ( + id INTEGER PRIMARY KEY, callsign TEXT, dxcc INTEGER, band TEXT, mode TEXT, + lotw_rcvd TEXT, qsl_rcvd TEXT, eqsl_rcvd TEXT)`); err != nil { + t.Fatal(err) + } + // Two Morocco contacts: one verified through LoTW, one not confirmed at all. + if _, err := db.Exec(`INSERT INTO qso (callsign, dxcc, band, mode, lotw_rcvd, qsl_rcvd, eqsl_rcvd) + VALUES ('CN8ABC', 446, '30m', 'FT8', 'V', '', ''), + ('CN8XYZ', 446, '20m', 'FT8', 'N', '', '')`); err != nil { + t.Fatal(err) + } + + var confirmed30, confirmed20 int + q := `SELECT band, MAX(CASE WHEN lotw_rcvd IN ` + ConfirmedValues + + ` OR qsl_rcvd IN ` + ConfirmedValues + ` OR eqsl_rcvd IN ` + ConfirmedValues + + ` THEN 1 ELSE 0 END) FROM qso WHERE dxcc = 446 GROUP BY band` + rows, err := db.QueryContext(context.Background(), q) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var band string + var c int + if err := rows.Scan(&band, &c); err != nil { + t.Fatal(err) + } + switch band { + case "30m": + confirmed30 = c + case "20m": + confirmed20 = c + } + } + if confirmed30 != 1 { + t.Error("a LoTW 'V' (verified) was not counted as confirmed — the matrix would show the entity as merely worked") + } + if confirmed20 != 0 { + t.Error("an 'N' was counted as confirmed") + } +} diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 339fe8e..78eba1e 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1907,9 +1907,9 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int, SELECT band, mode, MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END), MAX(CASE WHEN callsign = ? - AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y') + AND (lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+`) THEN 1 ELSE 0 END), - MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y' + MAX(CASE WHEN lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+` THEN 1 ELSE 0 END) FROM qso WHERE dxcc = ? @@ -2792,12 +2792,27 @@ type SlotStats struct { DIGConfirmed int `json:"dig_confirmed"` } +// ConfirmedValues is what a QSL-received field holds when the contact IS +// confirmed, as an SQL list. +// +// Y AND V. ADIF's QSL_Rcvd enumeration has both: Y is "received", V is +// "verified" — and that is what a LoTW download writes for a confirmation the +// ARRL has validated. Testing only = 'Y' therefore misses exactly the +// confirmations an operator cares most about. +// +// This was visible on screen: the Awards panel showed Morocco validated on five +// bands while the band/mode matrix beside it showed the entity as merely worked, +// because the award engine's isYes accepts "Y" or "V" and every SQL query here +// compared against 'Y' alone. One definition of confirmed, in one place, is the +// only way those two agree. +const ConfirmedValues = "('Y','V')" + // GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass. // "Confirmed" = LoTW or paper QSL received (the award-valid sources). func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) { rows, err := r.db.QueryContext(ctx, ` SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,'')), - CASE WHEN lotw_rcvd='Y' OR qsl_rcvd='Y' THEN 1 ELSE 0 END + CASE WHEN lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` THEN 1 ELSE 0 END FROM qso`) if err != nil { return SlotStats{}, err