feat(watchlist): header counters, mode filter — and the alert checks worked FIRST

DXHunter's header row, ported: Watchlist / Active / Needed counts up front and
the All-Modes select beside the other filters (DIGI matches the digital class,
SSB folds USB/LSB). Counters, card lists and the Active/Needed-only filters all
read the same mode-filtered view, so the numbers add up to what is on screen.

And the notify alert now asks the SAME worked-slot question the tab asks —
before making a sound. It used to fire on the raw spot while the tab's verdict
arrived on a debounce, so the bell rang for a slot that showed Worked a moment
later. Judged in the backend at emit time: exact slot for named modes, digital
class for generic DATA, today-only for contest entries.
This commit is contained in:
2026-08-29 01:03:37 +02:00
parent cb2f8aba72
commit 441eb295c6
4 changed files with 65 additions and 5 deletions
+40 -3
View File
@@ -65,6 +65,17 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
const setNeededOnly = (f: (v: boolean) => boolean) => setNeededOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlNeededOnly', nv ? '1' : '0'); } catch {} return nv; });
const setActiveOnly = (f: (v: boolean) => boolean) => setActiveOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlActiveOnly', nv ? '1' : '0'); } catch {} return nv; });
const setFamily = (v: 'all' | 'normal' | 'contest') => { setFamilyRaw(v); try { localStorage.setItem('opslog.wlFamily', v); } catch {} };
// Mode filter — DXHunter's "All Modes" select. DIGI matches the digital class
// (FT8, FT4, a generic DATA spot…), the named modes match exactly.
const [modeFilter, setModeFilterRaw] = useState(() => localStorage.getItem('opslog.wlMode') || 'ALL');
const setModeFilter = (v: string) => { setModeFilterRaw(v); try { localStorage.setItem('opslog.wlMode', v); } catch {} };
const modeMatches = (s2: ClusterSpot): boolean => {
if (modeFilter === 'ALL') return true;
const m = (inferSpotMode(s2.comment ?? '', s2.freq_hz) || '').toUpperCase();
if (modeFilter === 'DIGI') return !['', 'CW', 'SSB', 'USB', 'LSB', 'FM', 'AM'].includes(m);
if (modeFilter === 'SSB') return m === 'SSB' || m === 'USB' || m === 'LSB';
return m === modeFilter;
};
const [error, setError] = useState('');
const [removeArm, setRemoveArm] = useState('');
// worked answer per "call|band|modeclass|contest" key.
@@ -162,11 +173,23 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
if (family === 'normal' && e.isContest) return false;
if (family === 'contest' && !e.isContest) return false;
if (search && !e.callsign.includes(search.trim().toUpperCase())) return false;
if (activeOnly && (spotsFor.get(e.callsign) ?? []).length === 0) return false;
if (neededOnly && !(spotsFor.get(e.callsign) ?? []).some((s) => !workedFor(e, s))) return false;
const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches);
if (activeOnly && list.length === 0) return false;
if (neededOnly && !list.some((s2) => !workedFor(e, s2))) return false;
return true;
});
const counters = useMemo(() => {
let active = 0, needed = 0;
for (const e of entries) {
const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches);
if (list.length > 0) active++;
if (list.some((s2) => !workedFor(e, s2))) needed++;
}
return { total: entries.length, active, needed };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entries, spotsFor, worked, modeFilter]);
// The cluster's own badge for a spot, read from the shared status index.
const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => {
const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)];
@@ -194,6 +217,13 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
{/* toolbar */}
<div className="flex items-center gap-x-2 gap-y-1.5 flex-wrap">
<Eye className="size-4 text-primary shrink-0" />
<span className="text-[11px] text-muted-foreground shrink-0">
{t('wl.cTotal')} <b className="text-foreground">{counters.total}</b>
<span className="mx-1 opacity-50">·</span>
{t('wl.cActive')} <b className="text-info">{counters.active}</b>
<span className="mx-1 opacity-50">·</span>
{t('wl.cNeeded')} <b className="text-warning">{counters.needed}</b>
</span>
<Input className="h-8 w-44 font-mono uppercase" placeholder={t('wl.addPh')} value={addCall}
onChange={(e) => setAddCall(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void add(); }} />
@@ -226,6 +256,13 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
</button>
))}
</div>
<select value={modeFilter} onChange={(e) => setModeFilter(e.target.value)}
title={t('wl.modeFilter')}
className="h-7 px-1.5 rounded-md border border-border bg-background text-xs text-foreground">
{['ALL', 'CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'DIGI'].map((m) => (
<option key={m} value={m}>{m === 'ALL' ? t('wl.allModes') : m}</option>
))}
</select>
<button type="button" onClick={() => setActiveOnly((v) => !v)}
className={cn('px-2 py-1 rounded-md border text-xs', activeOnly ? 'bg-accent border-border font-medium' : 'border-border text-muted-foreground hover:bg-accent/50')}>
{t('wl.activeOnly')}
@@ -244,7 +281,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
{entries.length === 0 ? t('wl.empty') : t('wl.noneMatch')}
</div>
) : shown.map((e) => {
const all = spotsFor.get(e.callsign) ?? [];
const all = (spotsFor.get(e.callsign) ?? []).filter(modeMatches);
const needed = all.filter((s) => !workedFor(e, s)).length;
// Needed-only hides the worked LINES as well as the all-worked cards:
// a filter that says needed and still lists five green Worked rows is