feat: filter award references by mode class

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.
This commit is contained in:
2026-07-31 16:09:11 +02:00
parent d90f953df4
commit 3e22c4d1a3
6 changed files with 111 additions and 13 deletions
+25 -1
View File
@@ -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<AwardStats | null>(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 (
<div className="flex h-full min-h-0">
@@ -361,6 +377,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
</button>
))}
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
<button key={k} onClick={() => setModeFilter(k)}
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label}
</button>
))}
</div>
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
<button
onClick={() => setShowMissing(true)}