feat(awards): find the slots worked but not confirmed
The Challenge line says 1832 worked against 1554 confirmed and stops there — 278 band-slots waiting for a card with nothing pointing at them. Every existing filter works per REFERENCE, so an entity confirmed on 20 m answers 'confirmed' and its unconfirmed 15 m contact stays invisible. The new filter keeps the references holding at least one such slot, and the count beside the reference total is the gap itself, recomputed over whatever the other filters left on screen and over the bands actually displayed — so it always adds up to the columns in front of the operator.
This commit is contained in:
@@ -65,6 +65,19 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
|
||||
if (r.bands?.includes(band)) return 'worked';
|
||||
return 'none';
|
||||
}
|
||||
// slotsToConfirm counts the band-slots worked with this reference and not yet
|
||||
// confirmed on any of them — the QSLs still outstanding, one per cell showing W.
|
||||
//
|
||||
// It is the difference the Challenge line makes visible in the aggregate (1832
|
||||
// worked against 1554 confirmed) without saying WHERE it is. Counted over the
|
||||
// bands actually on screen, so it always adds up to the columns in front of the
|
||||
// operator rather than to a band set they filtered out.
|
||||
function slotsToConfirm(r: AwardRef, bands: string[]): number {
|
||||
let n = 0;
|
||||
for (const b of bands) if (cellStatus(r, b) === 'worked') n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
const CELL_STYLE: Record<CellStatus, string> = {
|
||||
validated: 'bg-success text-success-foreground',
|
||||
confirmed: 'bg-warning text-warning-foreground',
|
||||
@@ -112,7 +125,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
const [refSearch, setRefSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf' | 'slots_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.
|
||||
@@ -304,6 +317,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
if (refFilter === 'worked' && !r.worked) return false;
|
||||
if (refFilter === 'notworked' && r.worked) return false;
|
||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||
// Worked-not-confirmed by SLOT, not by reference: an entity confirmed on
|
||||
// 20 m still has a 15 m contact waiting for its card, and every filter
|
||||
// above answers "no" for it because the entity itself is confirmed.
|
||||
if (refFilter === 'slots_notconf' && slotsToConfirm(r, gridBands) === 0) 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
|
||||
@@ -339,7 +356,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
}
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
});
|
||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir, gridBands]);
|
||||
|
||||
// The gap itself, over whatever the other filters left on screen: the number
|
||||
// of cells an operator would have to turn green to close it.
|
||||
const slotGap = useMemo(
|
||||
() => filteredRefs.reduce((n, r) => n + slotsToConfirm(r, gridBands), 0),
|
||||
[filteredRefs, gridBands],
|
||||
);
|
||||
|
||||
// The group column earns its width only when the list actually carries one
|
||||
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
|
||||
@@ -468,7 +492,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
|
||||
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')], ['slots_notconf', t('awp.filterSlotsNotCfmd')]] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setRefFilter(k)}
|
||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
@@ -484,6 +508,11 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
{slotGap > 0 && (
|
||||
<span className="text-xs text-muted-foreground" title={t('awp.slotGapTip')}>
|
||||
· <span className="font-semibold text-foreground">{slotGap}</span> {t('awp.slotGap')}
|
||||
</span>
|
||||
)}
|
||||
{/* Only for an award scoped to a DXCC entity. "In this award's
|
||||
scope but with no reference" needs a scope to be in: on a
|
||||
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
|
||||
|
||||
@@ -128,6 +128,8 @@ const en: Dict = {
|
||||
'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)',
|
||||
'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs',
|
||||
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
|
||||
'awp.filterSlotsNotCfmd': 'Slots to confirm', 'awp.slotGap': 'slots to confirm',
|
||||
'awp.slotGapTip': 'Band-slots worked and not yet confirmed — the difference between the worked and confirmed totals above.',
|
||||
'mx.markWork': 'This callsign worked', 'mx.markConf': 'This callsign confirmed',
|
||||
'mx.tipThisCall': 'already worked with this callsign',
|
||||
'mx.tipThisCallConf': 'already confirmed with this callsign',
|
||||
@@ -618,6 +620,8 @@ const fr: Dict = {
|
||||
'mx.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)',
|
||||
'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO',
|
||||
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
|
||||
'awp.filterSlotsNotCfmd': 'Slots à confirmer', 'awp.slotGap': 'slots à confirmer',
|
||||
'awp.slotGapTip': "Créneaux bande contactés et pas encore confirmés — l'écart entre les totaux contactés et confirmés ci-dessus.",
|
||||
'mx.markWork': 'Cet indicatif contacté', 'mx.markConf': 'Cet indicatif confirmé',
|
||||
'mx.tipThisCall': 'déjà contacté avec cet indicatif',
|
||||
'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif',
|
||||
|
||||
Reference in New Issue
Block a user