From f65018393650724a18fb6f8f597dc920439e4def Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 15 Jul 2026 22:35:05 +0200 Subject: [PATCH] feat: Added persistence on cluster filters and last tab selected --- frontend/src/App.tsx | 51 ++++++++++++++++++++++++++++++-------- frontend/src/lib/uiPref.ts | 5 ++++ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 90fcd84..56c4aea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -608,7 +608,16 @@ export default function App() { const [filterOpen, setFilterOpen] = useState(false); const [activeFilter, setActiveFilter] = useState({ conditions: [], match: 'AND' }); const [matchCount, setMatchCount] = useState(null); - const [activeTab, setActiveTab] = useState('recent'); + // The selected tab is remembered across restarts. Only the always-present tabs + // are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on + // a feature or CAT backend that isn't known this early, and restoring one that + // isn't there would show a blank pane. + const ALWAYS_TABS = ['main', 'recent', 'cluster', 'worked', 'awards', 'bandmap']; + const [activeTab, setActiveTab] = useState(() => { + const saved = localStorage.getItem('opslog.activeTab') || ''; + return ALWAYS_TABS.includes(saved) ? saved : 'recent'; + }); + useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]); // QSL Manager is a closable tab opened on demand from Tools → QSL Manager. const [qslTabOpen, setQslTabOpen] = useState(false); const [qslDesignerOpen, setQslDesignerOpen] = useState(false); @@ -909,8 +918,15 @@ export default function App() { // Ring buffer — only keep the last N spots; cluster firehose can be heavy. const [spots, setSpots] = useState([]); const SPOTS_CAP = 1000; - const [clusterFilterSource, setClusterFilterSource] = useState(''); - const [clusterGroup, setClusterGroup] = useState(true); + // Cluster filter selections persist across restarts (writeUiPref → localStorage + // + DB, so they also travel with a copied data/ folder). Loaders read the cache + // synchronously at first render; a single effect below writes them back. + const lsBool = (k: string, d: boolean) => { const v = localStorage.getItem(k); return v === null ? d : v === '1'; }; + const lsSet = (k: string): Set => { try { const a = JSON.parse(localStorage.getItem(k) || '[]'); return new Set(Array.isArray(a) ? a : []); } catch { return new Set(); } }; + const [clusterFilterSource, setClusterFilterSource] = useState(() => { + const v = localStorage.getItem('opslog.clusterFilterSource'); const n = v ? parseInt(v, 10) : NaN; return Number.isFinite(n) ? n : ''; + }); + const [clusterGroup, setClusterGroup] = useState(() => lsBool('opslog.clusterGroup', true)); const [clusterCmd, setClusterCmd] = useState(''); // Cluster console: the raw traffic. Spots are parsed out of the stream into the // grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded, @@ -938,22 +954,37 @@ export default function App() { if (atBottom) el.scrollTop = el.scrollHeight; }, [clusterLines]); // Multi-band filter: empty set = all bands. The user toggles chips. - const [clusterBands, setClusterBands] = useState>(new Set()); + const [clusterBands, setClusterBands] = useState>(() => lsSet('opslog.clusterBands')); // Lock-to-entry: when on, the band filter follows the entry's current // band and the mode filter follows the entry's current mode. - const [clusterLockBand, setClusterLockBand] = useState(false); - const [clusterLockMode, setClusterLockMode] = useState(false); + const [clusterLockBand, setClusterLockBand] = useState(() => lsBool('opslog.clusterLockBand', false)); + const [clusterLockMode, setClusterLockMode] = useState(() => lsBool('opslog.clusterLockMode', false)); // Status filter chips. Empty set = show every status (including // already-worked). Otherwise only matching spots pass. type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked'; - const [clusterStatusFilter, setClusterStatusFilter] = useState>(new Set()); + const [clusterStatusFilter, setClusterStatusFilter] = useState>(() => lsSet('opslog.clusterStatusFilter')); // Mode filter chips. Empty set = show every mode. Categories map the // inferred per-spot mode onto SSB (phone) / CW / DATA (digital). type SpotModeCat = 'SSB' | 'CW' | 'DATA'; - const [clusterModeFilter, setClusterModeFilter] = useState>(new Set()); - const [clusterSearch, setClusterSearch] = useState(''); + const [clusterModeFilter, setClusterModeFilter] = useState>(() => lsSet('opslog.clusterModeFilter')); + const [clusterSearch, setClusterSearch] = useState(() => localStorage.getItem('opslog.clusterSearch') || ''); // Hide spots already worked (exact call worked, or this band+mode slot done). - const [clusterHideWorked, setClusterHideWorked] = useState(false); + const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false)); + + // Persist every cluster filter selection whenever it changes, so it is still + // set after a close/reopen. + useEffect(() => { + writeUiPref('opslog.clusterFilterSource', clusterFilterSource === '' ? '' : String(clusterFilterSource)); + writeUiPref('opslog.clusterGroup', clusterGroup ? '1' : '0'); + writeUiPref('opslog.clusterBands', JSON.stringify([...clusterBands])); + writeUiPref('opslog.clusterLockBand', clusterLockBand ? '1' : '0'); + writeUiPref('opslog.clusterLockMode', clusterLockMode ? '1' : '0'); + writeUiPref('opslog.clusterStatusFilter', JSON.stringify([...clusterStatusFilter])); + writeUiPref('opslog.clusterModeFilter', JSON.stringify([...clusterModeFilter])); + writeUiPref('opslog.clusterSearch', clusterSearch); + writeUiPref('opslog.clusterHideWorked', clusterHideWorked ? '1' : '0'); + }, [clusterFilterSource, clusterGroup, clusterBands, clusterLockBand, clusterLockMode, + clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked]); // Bands shown side-by-side in the Band Map tab (portable). const [bandMapBands, setBandMapBands] = useState(() => { try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; } diff --git a/frontend/src/lib/uiPref.ts b/frontend/src/lib/uiPref.ts index 986fd1d..37687d0 100644 --- a/frontend/src/lib/uiPref.ts +++ b/frontend/src/lib/uiPref.ts @@ -26,6 +26,11 @@ const PORTABLE_KEYS = [ 'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing 'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane) 'opslog.mapBasemap', // world map basemap (light / street / satellite) + // Cluster filter selections — restored on reopen. + 'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands', + 'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter', + 'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked', + 'opslog.activeTab', // last selected tab ]; // syncPortablePrefs reconciles the DB with the local cache at startup: