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:
2026-08-27 21:38:25 +02:00
parent 08d316b1e0
commit 0e77e8d61f
3 changed files with 40 additions and 5 deletions
+4 -2
View File
@@ -7,14 +7,16 @@
"Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell. Its two colours (worked / confirmed with this callsign) are in Appearance with the other matrix colours.", "Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell. Its two colours (worked / confirmed with this callsign) are in Appearance with the other matrix colours.",
"Icom spectrum scope: the command shape (with or without the main/sub selector) is now worked out from the radios own answers instead of a list of models. A radio that has scope control but no waveform stream over CI-V — the IC-7851 — says so in the panel rather than showing a black rectangle.", "Icom spectrum scope: the command shape (with or without the main/sub selector) is now worked out from the radios own answers instead of a list of models. A radio that has scope control but no waveform stream over CI-V — the IC-7851 — says so in the panel rather than showing a black rectangle.",
"Elecraft KPA: the status-bar chip now shows the amplifier as connected and switches it between OPERATE and STANDBY like the other brands, and an offline KPA no longer calls itself an Acom.", "Elecraft KPA: the status-bar chip now shows the amplifier as connected and switches it between OPERATE and STANDBY like the other brands, and an offline KPA no longer calls itself an Acom.",
"Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSOs SOTA award reference, as a POTA spot already did. Turn the column on in Columns." "Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSOs SOTA award reference, as a POTA spot already did. Turn the column on in Columns.",
"Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers."
], ],
"fr": [ "fr": [
"Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.", "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.",
"Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité. Ses deux couleurs (contacté / confirmé avec cet indicatif) se règlent dans Apparence avec les autres couleurs de la matrice.", "Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité. Ses deux couleurs (contacté / confirmé avec cet indicatif) se règlent dans Apparence avec les autres couleurs de la matrice.",
"Scope Icom : la forme des commandes (avec ou sans le sélecteur main/sub) est déduite des réponses de la radio au lieu d'une liste de modèles. Une radio qui pilote son scope mais ne l'envoie pas en CI-V — l'IC-7851 — l'indique dans le panneau au lieu d'afficher un rectangle noir.", "Scope Icom : la forme des commandes (avec ou sans le sélecteur main/sub) est déduite des réponses de la radio au lieu d'une liste de modèles. Une radio qui pilote son scope mais ne l'envoie pas en CI-V — l'IC-7851 — l'indique dans le panneau au lieu d'afficher un rectangle noir.",
"Elecraft KPA : la pastille de la barre d'état montre enfin l'amplificateur comme connecté et bascule OPERATE / STANDBY comme les autres marques, et un KPA hors ligne ne s'annonce plus comme un Acom.", "Elecraft KPA : la pastille de la barre d'état montre enfin l'amplificateur comme connecté et bascule OPERATE / STANDBY comme les autres marques, et un KPA hors ligne ne s'annonce plus comme un Acom.",
"Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes." "Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes.",
"Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres."
] ]
}, },
{ {
+32 -3
View File
@@ -65,6 +65,19 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
if (r.bands?.includes(band)) return 'worked'; if (r.bands?.includes(band)) return 'worked';
return 'none'; 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> = { const CELL_STYLE: Record<CellStatus, string> = {
validated: 'bg-success text-success-foreground', validated: 'bg-success text-success-foreground',
confirmed: 'bg-warning text-warning-foreground', confirmed: 'bg-warning text-warning-foreground',
@@ -112,7 +125,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
const [refSearch, setRefSearch] = useState(''); const [refSearch, setRefSearch] = useState('');
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid'); 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 // 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 // confirmed" is two questions at once, and answering only one of them is what
// sends an operator to a spreadsheet. // 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 === 'worked' && !r.worked) return false;
if (refFilter === 'notworked' && r.worked) return false; if (refFilter === 'notworked' && r.worked) return false;
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) 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') { if (modeFilter !== 'all' && refFilter !== 'notworked') {
// A reference never worked has no mode, so "not worked" plus a mode is // A reference never worked has no mode, so "not worked" plus a mode is
// a contradiction: the mode filter stands aside rather than emptying // 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; 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 // 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 // (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)} /> <Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div> </div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm"> <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)} <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')}> className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label} {label}
@@ -484,6 +508,11 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
))} ))}
</div> </div>
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span> <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 {/* 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 scope but with no reference" needs a scope to be in: on a
worldwide reference award — POTA, SOTA, IOTA, WWFF — every worldwide reference award — POTA, SOTA, IOTA, WWFF — every
+4
View File
@@ -128,6 +128,8 @@ const en: Dict = {
'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)', 'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)',
'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs', '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.', '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.markWork': 'This callsign worked', 'mx.markConf': 'This callsign confirmed',
'mx.tipThisCall': 'already worked with this callsign', 'mx.tipThisCall': 'already worked with this callsign',
'mx.tipThisCallConf': 'already confirmed 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.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)',
'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO', '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.", '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.markWork': 'Cet indicatif contacté', 'mx.markConf': 'Cet indicatif confirmé',
'mx.tipThisCall': 'déjà contacté avec cet indicatif', 'mx.tipThisCall': 'déjà contacté avec cet indicatif',
'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif', 'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif',