From f81cf88ecbe5ad68171436d3c67a4808ea48fb3e Mon Sep 17 00:00:00 2001 From: BigBodyCobain <43977454+BigBodyCobain@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:33:24 -0600 Subject: [PATCH] Persist dashboard layer, filter, style, and panel UI preferences in localStorage. Hydrate saved toggles after client mount so SSR does not reset operator layer layouts on refresh, and store map style, filters, and section expand state alongside layer visibility. --- .../__tests__/lib/layerPreferences.test.ts | 79 ++++++ frontend/src/app/page.tsx | 120 ++++----- .../src/components/WorldviewLeftPanel.tsx | 62 ++++- frontend/src/i18n/translations/en.json | 1 + frontend/src/i18n/translations/fa.json | 1 + frontend/src/i18n/translations/fr.json | 1 + frontend/src/i18n/translations/zh-CN.json | 1 + frontend/src/lib/layerPreferences.ts | 244 ++++++++++++++++++ 8 files changed, 422 insertions(+), 87 deletions(-) create mode 100644 frontend/src/__tests__/lib/layerPreferences.test.ts create mode 100644 frontend/src/lib/layerPreferences.ts diff --git a/frontend/src/__tests__/lib/layerPreferences.test.ts b/frontend/src/__tests__/lib/layerPreferences.test.ts new file mode 100644 index 0000000..b345704 --- /dev/null +++ b/frontend/src/__tests__/lib/layerPreferences.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + DASHBOARD_PREFS_STORAGE_KEY, + LAYER_PREFERENCES_STORAGE_KEY, + clearActiveLayersPreference, + getDefaultActiveLayers, + getDefaultLayerSectionExpanded, + loadActiveFilters, + loadActiveLayers, + loadLayerSectionExpanded, + loadMapStyle, + mergeLayerSectionExpanded, + saveActiveFilters, + saveActiveLayers, + saveLayerSectionExpanded, + saveMapStyle, +} from '@/lib/layerPreferences'; + +describe('layerPreferences', () => { + afterEach(() => { + localStorage.removeItem(DASHBOARD_PREFS_STORAGE_KEY); + localStorage.removeItem(LAYER_PREFERENCES_STORAGE_KEY); + }); + + it('getDefaultActiveLayers returns a full boolean map', () => { + const defaults = getDefaultActiveLayers(); + expect(defaults.flights).toBe(true); + expect(defaults.firms).toBe(false); + expect(Object.values(defaults).every((value) => typeof value === 'boolean')).toBe(true); + }); + + it('saveActiveLayers persists and loadActiveLayers restores', () => { + const customized = { ...getDefaultActiveLayers(), flights: false, cctv: true }; + saveActiveLayers(customized); + expect(loadActiveLayers()).toEqual(customized); + }); + + it('migrates legacy sb_active_layers_v1 storage', () => { + localStorage.setItem( + LAYER_PREFERENCES_STORAGE_KEY, + JSON.stringify({ version: 1, layers: { flights: false } }), + ); + expect(loadActiveLayers().flights).toBe(false); + }); + + it('persists map style without clobbering layers', () => { + saveActiveLayers({ ...getDefaultActiveLayers(), flights: false }); + saveMapStyle('SATELLITE'); + expect(loadMapStyle()).toBe('SATELLITE'); + expect(loadActiveLayers().flights).toBe(false); + }); + + it('persists active filters', () => { + saveActiveFilters({ airline: ['UAL'], ship_type: ['Cargo'] }); + expect(loadActiveFilters()).toEqual({ airline: ['UAL'], ship_type: ['Cargo'] }); + }); + + it('persists layer section expand/collapse by stable id', () => { + const defaults = getDefaultLayerSectionExpanded(); + saveLayerSectionExpanded({ ...defaults, aircraft: true, overlays: false }); + expect(loadLayerSectionExpanded().aircraft).toBe(true); + expect(loadLayerSectionExpanded().overlays).toBe(false); + }); + + it('mergeLayerSectionExpanded keeps defaults for unknown section ids', () => { + const defaults = getDefaultLayerSectionExpanded(); + const merged = mergeLayerSectionExpanded(defaults, { aircraft: true }); + expect(merged.aircraft).toBe(true); + expect(merged.maritime).toBe(defaults.maritime); + }); + + it('clearActiveLayersPreference removes only layers from dashboard prefs', () => { + saveActiveLayers({ ...getDefaultActiveLayers(), flights: false }); + saveMapStyle('SATELLITE'); + clearActiveLayersPreference(); + expect(loadActiveLayers()).toEqual(getDefaultActiveLayers()); + expect(loadMapStyle()).toBe('SATELLITE'); + }); +}); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 8931f49..cbaf31e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -28,6 +28,17 @@ import OnboardingModal, { useOnboarding } from '@/components/OnboardingModal'; import ChangelogModal, { useChangelog } from '@/components/ChangelogModal'; import StartupWarmupModal, { useStartupWarmupNotice } from '@/components/StartupWarmupModal'; import type { ActiveLayers, KiwiSDR, Scanner, SelectedEntity } from '@/types/dashboard'; +import { + getDefaultActiveLayers, + getDefaultMapStyle, + loadActiveFilters, + loadActiveLayers, + loadMapStyle, + saveActiveFilters, + saveActiveLayers, + saveMapStyle, + type MapStyle, +} from '@/lib/layerPreferences'; import type { ShodanSearchMatch } from '@/types/shodan'; import { API_BASE } from '@/lib/api'; import { useDataPolling, LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling'; @@ -179,74 +190,37 @@ export default function Dashboard() { }); }, []); - const [activeLayers, setActiveLayers] = useState({ - // Aircraft — all ON - flights: true, - private: true, - jets: true, - military: true, - tracked: true, - gps_jamming: true, - // Maritime — all ON - ships_military: true, - ships_cargo: true, - ships_civilian: true, - ships_passenger: true, - ships_tracked_yachts: true, - fishing_activity: true, - // Space — only satellites - satellites: true, - gibs_imagery: false, - highres_satellite: false, - sentinel_hub: false, - viirs_nightlights: false, - road_corridor_trends: false, - malware_c2: false, - submarine_cables: false, - scm_suppliers: false, - cyber_threats: false, - telegram_osint: true, - // Hazards — no fire, rest ON - earthquakes: true, - firms: false, - ukraine_alerts: true, - weather_alerts: true, - volcanoes: true, - air_quality: true, - // Infrastructure — military bases + internet outages only - cctv: false, - datacenters: false, - internet_outages: true, - power_plants: false, - military_bases: true, - trains: false, - // SIGINT — all ON except HF digital spots - kiwisdr: true, - psk_reporter: false, - satnogs: true, - tinygs: true, - scanners: true, - sigint_meshtastic: true, - sigint_aprs: true, - // Overlays - ukraine_frontline: true, - global_incidents: true, - day_night: true, - correlations: true, - contradictions: true, - uap_sightings: true, - // Biosurveillance - wastewater: true, - // CrowdThreat is operator opt-in only. - crowdthreat: false, - gt_risk: false, - // Shodan - shodan_overlay: false, - // AI Intel - ai_intel: true, - // SAR (Synthetic Aperture Radar) - sar: true, - }); + const [activeLayers, setActiveLayers] = useState(getDefaultActiveLayers); + const [activeStyle, setActiveStyle] = useState(getDefaultMapStyle); + const [activeFilters, setActiveFilters] = useState>({}); + const [layerPrefsHydrated, setLayerPrefsHydrated] = useState(false); + + // SSR/hydration cannot read localStorage in useState — load saved UI prefs after mount. + useEffect(() => { + setActiveLayers(loadActiveLayers()); + setActiveStyle(loadMapStyle()); + setActiveFilters(loadActiveFilters()); + setLayerPrefsHydrated(true); + }, []); + + useEffect(() => { + if (!layerPrefsHydrated) return; + saveActiveLayers(activeLayers); + }, [activeLayers, layerPrefsHydrated]); + + useEffect(() => { + if (!layerPrefsHydrated) return; + saveMapStyle(activeStyle); + }, [activeStyle, layerPrefsHydrated]); + + useEffect(() => { + if (!layerPrefsHydrated) return; + saveActiveFilters(activeFilters); + }, [activeFilters, layerPrefsHydrated]); + + const resetActiveLayers = useCallback(() => { + setActiveLayers(getDefaultActiveLayers()); + }, []); const regionLat = selectedEntity?.type === 'region_dossier' ? selectedEntity.extra?.lat : undefined; const regionLng = @@ -323,7 +297,7 @@ export default function Dashboard() { const layersTimerRef = useRef | null>(null); const initialLayerSyncRef = useRef(false); useEffect(() => { - if (!secondaryBootReady) return; + if (!secondaryBootReady || !layerPrefsHydrated) return; const syncLayers = (triggerRefetch: boolean) => fetch(`${API_BASE}/api/layers`, { method: 'POST', @@ -347,7 +321,7 @@ export default function Dashboard() { return () => { if (layersTimerRef.current) clearTimeout(layersTimerRef.current); }; - }, [activeLayers, secondaryBootReady]); + }, [activeLayers, secondaryBootReady, layerPrefsHydrated]); // Left panel accordion state const [leftDataMinimized, setLeftDataMinimized] = useState(false); @@ -430,8 +404,6 @@ export default function Dashboard() { bloom: true, }); - const [activeStyle, setActiveStyle] = useState('DEFAULT'); - const memoizedEffects = useMemo( () => ({ ...effects, bloom: effects.bloom && activeStyle !== 'DEFAULT', style: activeStyle }), [effects, activeStyle], @@ -478,14 +450,13 @@ export default function Dashboard() { const cycleStyle = () => { setActiveStyle((prev) => { const idx = stylesList.indexOf(prev); - const next = stylesList[(idx + 1) % stylesList.length]; + const next = stylesList[(idx + 1) % stylesList.length] as MapStyle; // Auto-toggle High-Res Satellite layer with SATELLITE style setActiveLayers((l) => ({ ...l, highres_satellite: next === 'SATELLITE' })); return next; }); }; - const [activeFilters, setActiveFilters] = useState>({}); const firstPaintActiveLayers = useMemo(() => { if (secondaryBootReady) return activeLayers; return { @@ -618,6 +589,7 @@ export default function Dashboard() { setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} diff --git a/frontend/src/components/WorldviewLeftPanel.tsx b/frontend/src/components/WorldviewLeftPanel.tsx index 3a71dad..de97815 100644 --- a/frontend/src/components/WorldviewLeftPanel.tsx +++ b/frontend/src/components/WorldviewLeftPanel.tsx @@ -57,6 +57,11 @@ import { useTranslation } from '@/i18n'; import SarModeChooserModal from './SarModeChooserModal'; import KiwiSdrConsentDialog from './ui/KiwiSdrConsentDialog'; import { extractGtAlerts } from '@/lib/gtAlerts'; +import { + getDefaultLayerSectionExpanded, + loadLayerSectionExpanded, + saveLayerSectionExpanded, +} from '@/lib/layerPreferences'; import { gtLeanLayerWarning, useRuntimeProfile } from '@/hooks/useRuntimeProfile'; function relativeTime(iso: string | undefined): string { @@ -703,6 +708,7 @@ const TOGGLE_ALL_EXCLUDED_LAYERS = new Set([ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ activeLayers, setActiveLayers, + onResetLayers, onSettingsClick, onLegendClick, gibsDate, @@ -729,6 +735,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ }: { activeLayers: ActiveLayers; setActiveLayers: React.Dispatch>; + onResetLayers?: () => void; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; @@ -1076,6 +1083,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ const sections = [ { + id: 'aircraft', label: t('layers.aircraft').toUpperCase(), icon: Plane, layers: [ @@ -1124,6 +1132,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'maritime', label: t('layers.maritime').toUpperCase(), icon: Ship, layers: [ @@ -1172,6 +1181,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'space', label: t('layers.space').toUpperCase(), icon: Satellite, layers: [ @@ -1235,6 +1245,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'hazards', label: t('layers.hazards').toUpperCase(), icon: AlertTriangle, layers: [ @@ -1295,6 +1306,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'uap', label: t('layers.uapSightings').toUpperCase(), icon: Eye, layers: [ @@ -1308,6 +1320,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'biosurveillance', label: t('layers.biosurveillance').toUpperCase(), icon: Droplets, layers: [ @@ -1321,6 +1334,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'infrastructure', label: t('layers.infrastructure').toUpperCase(), icon: Server, layers: [ @@ -1397,6 +1411,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'shodan', label: t('layers.shodanOverlay').toUpperCase(), icon: Search, layers: [ @@ -1410,6 +1425,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'sigint', label: t('layers.sigint').toUpperCase(), icon: Radio, layers: [ @@ -1465,6 +1481,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ ], }, { + id: 'overlays', label: t('layers.overlays').toUpperCase(), icon: Globe, layers: [ @@ -1538,16 +1555,20 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ }, ]; - const [expandedSections, setExpandedSections] = useState>(() => { - const initial: Record = {}; - sections.forEach((s) => { - // Keep high-traffic intel overlays visible on first paint (GDELT, Telegram, etc.) - initial[s.label] = s.layers.some((l) => - ['global_incidents', 'telegram_osint', 'ukraine_frontline', 'gt_risk'].includes(l.id), - ); - }); - return initial; - }); + const [expandedSections, setExpandedSections] = useState>( + getDefaultLayerSectionExpanded, + ); + const [sectionPrefsHydrated, setSectionPrefsHydrated] = useState(false); + + useEffect(() => { + setExpandedSections(loadLayerSectionExpanded()); + setSectionPrefsHydrated(true); + }, []); + + useEffect(() => { + if (!sectionPrefsHydrated) return; + saveLayerSectionExpanded(expandedSections); + }, [expandedSections, sectionPrefsHydrated]); const shipIcon = (
+ {onResetLayers ? ( + + ) : null}