From 3e22c4d1a34912041f0536e2582c36f75fc43973 Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 31 Jul 2026 16:09:11 +0200 Subject: [PATCH] feat: filter award references by mode class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All / CW / Phone / Digital, stacked on top of the worked/confirmed filter. The question an operator actually has — which DXCC entities have I worked on CW but not confirmed on CW — needs both at once; answering only one of them is what sends people to a spreadsheet. References carried bands but not modes, so the award computation now aggregates mode CLASSES per reference, worked and confirmed separately. Classes, not ADIF modes: nobody chasing CW cares whether the digital side was FT8 or RTTY, and a list of twenty mode names would not answer the question. An unrecognised mode counts as data rather than as nothing, so a new digital mode does not vanish from the filter the day it appears. Two rules the filter needs to be honest: - Not-confirmed means not confirmed ON THIS MODE. An entity worked on CW and confirmed on SSB is still a CW entity to chase, and that is exactly what the filter is for. - "Not worked" plus a mode is a contradiction — an unworked reference has no mode — so the mode filter stands aside there instead of emptying the list. --- changelog.json | 6 ++- frontend/src/components/AwardsPanel.tsx | 26 +++++++++++- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/models.ts | 4 ++ internal/award/award.go | 56 +++++++++++++++++++++---- internal/award/modeclass_test.go | 28 +++++++++++++ 6 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 internal/award/modeclass_test.go diff --git a/changelog.json b/changelog.json index 6ed3947..8359895 100644 --- a/changelog.json +++ b/changelog.json @@ -11,7 +11,8 @@ "FlexRadio: one table per band for the antennas and the TX power per mode class (phone / CW / digital). Antennas follow the band, power follows the band and the mode; an empty box leaves the power alone.", "Grouping the digital modes into one slot now takes effect on the spots already on screen, instead of only on those arriving afterwards.", "The QSO right-click menu no longer runs off the bottom of the window: it opens above the cursor when there is not enough room below.", - "QRZ.com confirmations: only QRZ own confirmed marker counts now. The download also clears QSOs an earlier version marked confirmed when QRZ says they are not — run it once to correct your log." + "QRZ.com confirmations: only QRZ own confirmed marker counts now. The download also clears QSOs an earlier version marked confirmed when QRZ says they are not — run it once to correct your log.", + "Awards: a mode filter (All / CW / Phone / Digital) that stacks with the worked/confirmed one — so \"worked on CW but not confirmed on CW\" is one click." ], "fr": [ "FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.", @@ -22,7 +23,8 @@ "FlexRadio : un seul tableau par bande pour les antennes et la puissance d'émission par type de mode (phonie / CW / numérique). Les antennes suivent la bande, la puissance suit la bande et le mode ; une case vide ne touche pas à la puissance.", "Le regroupement des modes numériques en un seul créneau s'applique désormais aux spots déjà affichés, et non plus seulement à ceux qui arrivent ensuite.", "Le menu contextuel des QSO ne déborde plus en bas de la fenêtre : il s'ouvre au-dessus du curseur quand la place manque en dessous.", - "Confirmations QRZ.com : seul le marqueur de confirmation propre à QRZ compte désormais. Le téléchargement efface aussi les QSO qu'une version antérieure avait marqués confirmés alors que QRZ dit le contraire — lancez-le une fois pour corriger votre journal." + "Confirmations QRZ.com : seul le marqueur de confirmation propre à QRZ compte désormais. Le téléchargement efface aussi les QSO qu'une version antérieure avait marqués confirmés alors que QRZ dit le contraire — lancez-le une fois pour corriger votre journal.", + "Diplômes : un filtre par mode (Tous / CW / Phonie / Numérique) qui se cumule avec le filtre contacté/confirmé — « contacté en CW mais pas confirmé en CW » se lit en un clic." ] }, { diff --git a/frontend/src/components/AwardsPanel.tsx b/frontend/src/components/AwardsPanel.tsx index 949768b..db6599d 100644 --- a/frontend/src/components/AwardsPanel.tsx +++ b/frontend/src/components/AwardsPanel.tsx @@ -15,6 +15,8 @@ type AwardRef = { ref: string; name?: string; group?: string; subgrp?: string; worked: boolean; confirmed: boolean; validated: boolean; bands: string[]; confirmed_bands: string[]; validated_bands: string[]; + // Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes. + modes?: string[]; confirmed_modes?: string[]; }; type AwardResult = { code: string; name: string; dimension: string; @@ -75,6 +77,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n const [editing, setEditing] = useState(false); const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid'); const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all'); + // Mode filter, stacked ON TOP of the status one. "Worked on CW but not + // confirmed" is two questions at once, and answering only one of them is what + // sends an operator to a spreadsheet. + const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('all'); const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null); const [showMissing, setShowMissing] = useState(false); const [stats, setStats] = useState(null); @@ -222,6 +228,16 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n if (refFilter === 'worked' && !r.worked) return false; if (refFilter === 'notworked' && r.worked) return false; if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false; + if (modeFilter !== 'all' && refFilter !== 'notworked') { + // A reference never worked has no mode, so "not worked" plus a mode is + // a contradiction: the mode filter stands aside rather than emptying + // the list. + if (!(r.modes ?? []).includes(modeFilter)) return false; + // Not-confirmed must mean not confirmed ON THIS MODE. An entity worked + // on CW and confirmed on SSB is still a CW entity to chase, and the + // whole point of the filter is to find those. + if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) return false; + } if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false; return true; }); @@ -237,7 +253,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n } return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir; }); - }, [current, refSearch, refFilter, refSort, refSortDir]); + }, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]); return (
@@ -361,6 +377,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n ))}
+
+ {([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => ( + + ))} +
{filteredRefs.length} {t('awp.refs')}