feat(watchlist): dockable in the Main tab; sticky filters; Needed-only hides worked lines

The watchlist joins the Main-tab pane list (Settings → Main view), rendered in
a card like the other docked panels.

The three filters persist across tab switches — the component unmounts on every
switch, and filters that reset each time are filters nobody trusts.

Needed-only now hides the worked SPOT LINES as well as the all-worked cards,
which is DXHunter's own Not-Worked behaviour and the reported complaint: a
filter that says needed and still lists five green Worked rows answers a
different question than the one asked.

The add-row checkbox is relabelled 'as contest' — it marks the entry being
ADDED, and read as a filter beside the All/DX/Contest buttons it looked
redundant.
This commit is contained in:
2026-08-29 00:39:07 +02:00
parent 29d79fb5d1
commit 7322e2ab66
4 changed files with 31 additions and 12 deletions
+18 -6
View File
@@ -52,9 +52,17 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
const [addCall, setAddCall] = useState('');
const [addContest, setAddContest] = useState(false);
const [search, setSearch] = useState('');
const [neededOnly, setNeededOnly] = useState(false);
const [activeOnly, setActiveOnly] = useState(false);
const [family, setFamily] = useState<'all' | 'normal' | 'contest'>('all');
// The filters survive leaving the tab: the component unmounts on every tab
// switch, and filters that reset each time are filters nobody trusts.
const [neededOnly, setNeededOnlyRaw] = useState(() => localStorage.getItem('opslog.wlNeededOnly') === '1');
const [activeOnly, setActiveOnlyRaw] = useState(() => localStorage.getItem('opslog.wlActiveOnly') === '1');
const [family, setFamilyRaw] = useState<'all' | 'normal' | 'contest'>(() => {
const v = localStorage.getItem('opslog.wlFamily');
return v === 'normal' || v === 'contest' ? v : 'all';
});
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 {} };
const [error, setError] = useState('');
const [removeArm, setRemoveArm] = useState('');
// worked answer per "call|band|modeclass|contest" key.
@@ -178,7 +186,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
onKeyDown={(e) => { if (e.key === 'Enter') void add(); }} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('wl.contestHint')}>
<Checkbox checked={addContest} onCheckedChange={(c) => setAddContest(!!c)} />
<Trophy className="size-3.5 text-warning" /> {t('wl.contest')}
<Trophy className="size-3.5 text-warning" /> {t('wl.addAsContest')}
</label>
<Button size="sm" className="h-8" onClick={() => void add()} disabled={!addCall.trim()}>
<Plus className="size-3.5" /> {t('wl.add')}
@@ -218,8 +226,12 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
{entries.length === 0 ? t('wl.empty') : t('wl.noneMatch')}
</div>
) : shown.map((e) => {
const list = spotsFor.get(e.callsign) ?? [];
const needed = list.filter((s) => !workedFor(e, s)).length;
const all = spotsFor.get(e.callsign) ?? [];
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
// answering a different question than the one asked.
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
return (
<div key={e.callsign}
className={cn('rounded-lg border bg-card p-3',