fix(confirmations): count a LoTW "V" everywhere, not only in the awards
An operator's screenshot had it side by side: the Awards panel showed Morocco validated on five bands, and the band/mode matrix two inches above showed the entity as merely worked. ADIF's QSL_Rcvd enumeration has both Y and V — "received" and "verified" — and V is what a LoTW download writes for a confirmation the ARRL has validated. The award engine's isYes accepted "Y" or "V". Everything else in the app compared against 'Y' alone: the worked-before status grid, the slot statistics, the row colouring by QSL status, the awards QSO list, the call history badges. So the confirmations an operator cares most about were the ones that did not count. Now one definition per side, named so the next reader finds the other: qso.ConfirmedValues on the Go side, used by the queries themselves, and isQSLConfirmed in lib/qsl on the frontend, which rowColors and the panels call instead of testing the letter. The Go test drives the real query shape against a real database with a 'V' row — the constant being right is not the point, the queries using it is.
This commit is contained in:
+6
-2
@@ -2,8 +2,12 @@
|
|||||||
{
|
{
|
||||||
"version": "0.25.3",
|
"version": "0.25.3",
|
||||||
"date": "",
|
"date": "",
|
||||||
"en": [],
|
"en": [
|
||||||
"fr": []
|
"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",
|
"version": "0.25.2",
|
||||||
|
|||||||
+2
-2
@@ -109,9 +109,9 @@ func probeWB(conn *sql.DB, call string, dxcc int) {
|
|||||||
SELECT band, mode,
|
SELECT band, mode,
|
||||||
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
||||||
MAX(CASE WHEN callsign = ?
|
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),
|
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)
|
THEN 1 ELSE 0 END)
|
||||||
FROM qso WHERE dxcc = ?
|
FROM qso WHERE dxcc = ?
|
||||||
GROUP BY band, mode
|
GROUP BY band, mode
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
import { AwardEditor } from '@/components/AwardEditor';
|
import { AwardEditor } from '@/components/AwardEditor';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
@@ -818,7 +819,7 @@ function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell:
|
|||||||
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
|
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
|
||||||
<td className="py-1 pr-2">{q.band}</td>
|
<td className="py-1 pr-2">{q.band}</td>
|
||||||
<td className="py-1 pr-2">{q.mode}</td>
|
<td className="py-1 pr-2">{q.mode}</td>
|
||||||
<td className="py-1 pr-3 text-muted-foreground">{[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && 'eQSL'].filter(Boolean).join(', ')}</td>
|
<td className="py-1 pr-3 text-muted-foreground">{[isQSLConfirmed(q.lotw_rcvd) && 'LoTW', isQSLConfirmed(q.qsl_rcvd) && 'QSL', isQSLConfirmed(q.eqsl_rcvd) && 'eQSL'].filter(Boolean).join(', ')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { sunTimes } from '@/lib/sun';
|
import { sunTimes } from '@/lib/sun';
|
||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
@@ -426,7 +427,7 @@ function SlotQSOModal({ call, dxcc, entity, band, cls, onClose, onEdit }: {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((q, i) => {
|
{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;
|
const mine = q.callsign === call;
|
||||||
return (
|
return (
|
||||||
<tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
|
<tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Star } from 'lucide-react';
|
import { Star } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
type WorkedBefore = WorkedBeforeView;
|
type WorkedBefore = WorkedBeforeView;
|
||||||
@@ -97,10 +98,10 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
|||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
||||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
||||||
{e.lotw_rcvd === 'Y' && (
|
{isQSLConfirmed(e.lotw_rcvd) && (
|
||||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
||||||
)}
|
)}
|
||||||
{e.qsl_rcvd === 'Y' && (
|
{isQSLConfirmed(e.qsl_rcvd) && (
|
||||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
// Row colouring for the log grid, by QSL status.
|
// Row colouring for the log grid, by QSL status.
|
||||||
//
|
//
|
||||||
// Four categories, each scoped to the channels the operator cares about —
|
// Four categories, each scoped to the channels the operator cares about —
|
||||||
@@ -32,7 +33,9 @@ const FIELDS: Record<string, { sent: string; rcvd: string }> = {
|
|||||||
// ADIF QSL fields are single letters. Y is the only one that means "yes";
|
// 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,
|
// 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.
|
// 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 owed = (v: any) => {
|
||||||
const s = String(v ?? '').trim().toUpperCase();
|
const s = String(v ?? '').trim().toUpperCase();
|
||||||
return s === 'R' || s === 'Q';
|
return s === 'R' || s === 'Q';
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-3
@@ -1907,9 +1907,9 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
|
|||||||
SELECT band, mode,
|
SELECT band, mode,
|
||||||
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
||||||
MAX(CASE WHEN callsign = ?
|
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),
|
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)
|
THEN 1 ELSE 0 END)
|
||||||
FROM qso
|
FROM qso
|
||||||
WHERE dxcc = ?
|
WHERE dxcc = ?
|
||||||
@@ -2792,12 +2792,27 @@ type SlotStats struct {
|
|||||||
DIGConfirmed int `json:"dig_confirmed"`
|
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.
|
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
|
||||||
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
|
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
|
||||||
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
|
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,'')),
|
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`)
|
FROM qso`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SlotStats{}, err
|
return SlotStats{}, err
|
||||||
|
|||||||
Reference in New Issue
Block a user