diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx
index 74f278c3..535c1997 100644
--- a/client/src/pages/DashboardPage.test.tsx
+++ b/client/src/pages/DashboardPage.test.tsx
@@ -874,4 +874,32 @@ describe('DashboardPage', () => {
expect(screen.getByText(/my trips/i)).toBeInTheDocument();
});
});
+
+ describe('FE-PAGE-DASH-034: dashboard widgets persist to settings, not localStorage (#1311)', () => {
+ it('reads the timezone widget zones from the settings store', async () => {
+ // A zone that is NOT in the hardcoded default ([home, London, Tokyo]) — its presence
+ // proves the widget reads the stored preference rather than the old localStorage default.
+ seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['America/New_York'] }), isLoaded: true });
+ render();
+ await waitFor(() => expect(screen.getByRole('button', { name: /add timezone/i })).toBeInTheDocument());
+ expect(screen.getByText('New York')).toBeInTheDocument();
+ });
+
+ it('migrates the pre-3.1.3 localStorage prefs into settings and clears the legacy keys', async () => {
+ localStorage.setItem('trek_fx_from', 'CAD');
+ localStorage.setItem('trek_fx_to', 'CHF');
+ localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
+ seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
+ render();
+ // The one-time migration runs on mount (settings already loaded) and removes the keys.
+ await waitFor(() => {
+ expect(localStorage.getItem('trek_fx_from')).toBeNull();
+ expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
+ });
+ const s = useSettingsStore.getState().settings;
+ expect(s.dashboard_fx_from).toBe('CAD');
+ expect(s.dashboard_fx_to).toBe('CHF');
+ expect(s.dashboard_timezones).toEqual(['America/New_York']);
+ });
+ });
});
diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx
index 353a847a..62f95353 100644
--- a/client/src/pages/DashboardPage.tsx
+++ b/client/src/pages/DashboardPage.tsx
@@ -491,8 +491,12 @@ const FX_FALLBACK = ['EUR', 'USD', 'GBP', 'CHF', 'JPY', 'CAD', 'AUD', 'CNY', 'SE
function CurrencyTool(): React.ReactElement {
const { t } = useTranslation()
- const [from, setFrom] = useState(() => localStorage.getItem('trek_fx_from') || 'EUR')
- const [to, setTo] = useState(() => localStorage.getItem('trek_fx_to') || 'USD')
+ const isLoaded = useSettingsStore(s => s.isLoaded)
+ const updateSetting = useSettingsStore(s => s.updateSetting)
+ const from = useSettingsStore(s => s.settings.dashboard_fx_from) || 'EUR'
+ const to = useSettingsStore(s => s.settings.dashboard_fx_to) || 'USD'
+ const setFrom = (v: string) => { updateSetting('dashboard_fx_from', v).catch(() => {}) }
+ const setTo = (v: string) => { updateSetting('dashboard_fx_to', v).catch(() => {}) }
const [amount, setAmount] = useState('100')
const [rates, setRates] = useState | null>(null)
@@ -510,7 +514,18 @@ function CurrencyTool(): React.ReactElement {
}, [from])
useEffect(() => { fetchRate() }, [fetchRate])
- useEffect(() => { localStorage.setItem('trek_fx_from', from); localStorage.setItem('trek_fx_to', to) }, [from, to])
+ // One-time migration of the pre-3.1.3 localStorage values into the user's settings,
+ // so a (docker) upgrade no longer resets the widget (#1311).
+ useEffect(() => {
+ if (!isLoaded) return
+ const lf = localStorage.getItem('trek_fx_from')
+ const lt = localStorage.getItem('trek_fx_to')
+ if (!lf && !lt) return
+ if (lf) updateSetting('dashboard_fx_from', lf).catch(() => {})
+ if (lt) updateSetting('dashboard_fx_to', lt).catch(() => {})
+ localStorage.removeItem('trek_fx_from')
+ localStorage.removeItem('trek_fx_to')
+ }, [isLoaded, updateSetting])
const currencies = rates ? Object.keys(rates).sort() : FX_FALLBACK
const ccyOptions = currencies.map(c => ({ value: c, label: c }))
@@ -565,13 +580,12 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
const { t } = useTranslation()
const home = Intl.DateTimeFormat().resolvedOptions().timeZone
const [now, setNow] = useState(() => new Date())
- const [zones, setZones] = useState(() => {
- try {
- const raw = localStorage.getItem('trek_dashboard_tz')
- if (raw) return JSON.parse(raw)
- } catch { /* ignore malformed storage */ }
- return [home, ...DEFAULT_ZONES]
- })
+ const isLoaded = useSettingsStore(s => s.isLoaded)
+ const updateSetting = useSettingsStore(s => s.updateSetting)
+ const stored = useSettingsStore(s => s.settings.dashboard_timezones)
+ // Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
+ const zones = stored ?? [home, ...DEFAULT_ZONES]
+ const setZones = (next: string[]) => { updateSetting('dashboard_timezones', next).catch(() => {}) }
const [adding, setAdding] = useState(false)
// A minute's resolution is plenty for clocks and keeps re-renders cheap.
@@ -580,7 +594,18 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
return () => clearInterval(id)
}, [])
- useEffect(() => { localStorage.setItem('trek_dashboard_tz', JSON.stringify(zones)) }, [zones])
+ // One-time migration of the pre-3.1.3 localStorage value into the user's settings,
+ // so a (docker) upgrade no longer resets the widget (#1311).
+ useEffect(() => {
+ if (!isLoaded) return
+ const raw = localStorage.getItem('trek_dashboard_tz')
+ if (!raw) return
+ try {
+ const parsed = JSON.parse(raw)
+ if (Array.isArray(parsed)) updateSetting('dashboard_timezones', parsed).catch(() => {})
+ } catch { /* ignore malformed storage */ }
+ localStorage.removeItem('trek_dashboard_tz')
+ }, [isLoaded, updateSetting])
const allZones = React.useMemo(() => {
const supported = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf
@@ -591,8 +616,8 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
.filter(z => !zones.includes(z))
.map(z => ({ value: z, label: z.replace(/_/g, ' '), searchLabel: z }))
- const addZone = (tz: string) => { if (tz) setZones(prev => prev.includes(tz) ? prev : [...prev, tz]); setAdding(false) }
- const removeZone = (tz: string) => setZones(prev => prev.filter(z => z !== tz))
+ const addZone = (tz: string) => { if (tz && !zones.includes(tz)) setZones([...zones, tz]); setAdding(false) }
+ const removeZone = (tz: string) => setZones(zones.filter(z => z !== tz))
const timeIn = (tz: string) => now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: tz })
const offsetLabel = (tz: string) => {
diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts
index 2b3fd6ad..84090c83 100644
--- a/client/src/store/settingsStore.ts
+++ b/client/src/store/settingsStore.ts
@@ -41,6 +41,10 @@ export const useSettingsStore = create((set, get) => ({
maplibre_style: '',
mapbox_3d_enabled: true,
mapbox_quality_mode: false,
+ dashboard_fx_from: 'EUR',
+ dashboard_fx_to: 'USD',
+ // dashboard_timezones is intentionally left unset so the widget can tell "never
+ // chosen" (fall back to home + defaults) from an explicitly emptied list.
},
isLoaded: false,
diff --git a/client/src/types.ts b/client/src/types.ts
index 5c7c2dd7..dab19cc1 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -124,6 +124,10 @@ export interface Settings {
maplibre_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
+ // Dashboard widget prefs — persisted server-side so a (docker) upgrade keeps them (#1311).
+ dashboard_fx_from?: string
+ dashboard_fx_to?: string
+ dashboard_timezones?: string[]
}
export interface AssignmentsMap {