From 7322e2ab669fe33481b2b58c6e9c2b05d061984a Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 29 Aug 2026 00:39:07 +0200 Subject: [PATCH] feat(watchlist): dockable in the Main tab; sticky filters; Needed-only hides worked lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.tsx | 11 +++++++++-- frontend/src/components/SettingsModal.tsx | 2 +- frontend/src/components/WatchlistTab.tsx | 24 +++++++++++++++++------ frontend/src/lib/i18n.tsx | 6 +++--- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index daa3138..dd5d77f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2018,7 +2018,7 @@ export default function App() { // so it's loaded async on mount and re-read on profile:changed below. // 'none' is only ever stored for the third and fourth panes: the first two are // the Main view, and a layout with no panes at all is not a layout. - type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'elecraft' | 'tci' | 'netcontrol' | 'decodes' | 'none'; + type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'elecraft' | 'tci' | 'netcontrol' | 'decodes' | 'watchlist' | 'none'; const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now const [mainPaneLeft, setMainPaneLeft] = useState('map1'); const [mainPaneRight, setMainPaneRight] = useState('map2'); @@ -2029,7 +2029,7 @@ export default function App() { // quarter-width map is unreadable. const [mainLayout4, setMainLayout4] = useState<'cols' | 'quad'>('quad'); const loadMainPanes = useCallback(async () => { - const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'tci' || v === 'netcontrol' || v === 'decodes'; + const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'tci' || v === 'netcontrol' || v === 'decodes' || v === 'watchlist'; const [l, r, p3, p4, lay] = await Promise.all([ GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''), @@ -6339,6 +6339,13 @@ export default function App() { { setRstSent(r); rstUserEditedRef.current = true; }} /> ); + case 'watchlist': + return ( +
+ +
+ ); case 'netcontrol': return (
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 77748d0..ac20cb9 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1086,7 +1086,7 @@ function RelayAutoPanel() { // panes. The first two are the view; the third and fourth can be left empty, and // an empty one is not drawn at all. Per-profile (stored via SetUIPref, which is // profile-prefixed). Self-contained so it owns its async-loaded state. -const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes']; +const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes', 'watchlist']; const PANE_NONE = 'none'; function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable, elecraftAvailable, tciAvailable }: { onChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean; elecraftAvailable?: boolean; tciAvailable?: boolean }) { const { t } = useI18n(); diff --git a/frontend/src/components/WatchlistTab.tsx b/frontend/src/components/WatchlistTab.tsx index 29972a7..b16607d 100644 --- a/frontend/src/components/WatchlistTab.tsx +++ b/frontend/src/components/WatchlistTab.tsx @@ -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(); }} />
) : 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 (