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