From 18eeb16aa00e2801eddfe6227515b75414f53e14 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:18:13 +0200 Subject: [PATCH] refactor(dashboard): share the widget logic and plugin hooks between desktop and phone The currency converter, the world clocks, the collection lists, the plugin widgets and card badges and the card date format each existed once per dashboard. They now live in useCurrencyConverter, useDashboardTimezones, useCollectionLists, useDashboardPlugins and dashboardModel; the phone keeps aborting a pending rate request through an option. --- .../Dashboard/CollectionsWidget.tsx | 23 +-- .../Dashboard/useCollectionLists.test.ts | 36 +++++ .../Dashboard/useCollectionLists.ts | 29 ++++ .../Dashboard/useCurrencyConverter.test.ts | 117 ++++++++++++++ .../Dashboard/useCurrencyConverter.ts | 94 +++++++++++ .../Dashboard/useDashboardPlugins.test.ts | 52 ++++++ .../Dashboard/useDashboardPlugins.ts | 22 +++ .../Dashboard/useDashboardTimezones.test.ts | 100 ++++++++++++ .../Dashboard/useDashboardTimezones.ts | 113 +++++++++++++ .../mobile/screens/dashboard/MDashWidgets.tsx | 150 ++---------------- .../mobile/screens/dashboard/MDashboard.tsx | 20 +-- client/src/pages/DashboardPage.tsx | 140 ++-------------- .../pages/dashboard/dashboardModel.test.ts | 22 ++- client/src/pages/dashboard/dashboardModel.ts | 15 ++ 14 files changed, 625 insertions(+), 308 deletions(-) create mode 100644 client/src/components/Dashboard/useCollectionLists.test.ts create mode 100644 client/src/components/Dashboard/useCollectionLists.ts create mode 100644 client/src/components/Dashboard/useCurrencyConverter.test.ts create mode 100644 client/src/components/Dashboard/useCurrencyConverter.ts create mode 100644 client/src/components/Dashboard/useDashboardPlugins.test.ts create mode 100644 client/src/components/Dashboard/useDashboardPlugins.ts create mode 100644 client/src/components/Dashboard/useDashboardTimezones.test.ts create mode 100644 client/src/components/Dashboard/useDashboardTimezones.ts diff --git a/client/src/components/Dashboard/CollectionsWidget.tsx b/client/src/components/Dashboard/CollectionsWidget.tsx index 7ed605957..aa6325989 100644 --- a/client/src/components/Dashboard/CollectionsWidget.tsx +++ b/client/src/components/Dashboard/CollectionsWidget.tsx @@ -1,10 +1,9 @@ -import React, { useEffect, useState } from 'react' +import React from 'react' import { Bookmark, ArrowRight, MapPin } from 'lucide-react' import { useNavigate } from 'react-router' import { useTranslation } from '../../i18n' -import { collectionsApi } from '../../api/collections' import { entityGradient } from '../../utils/gradients' -import type { Collection } from '@trek/shared' +import { useCollectionLists } from './useCollectionLists' /** * Dashboard sidebar widget — a glassy `.tool` card that surfaces the user's @@ -16,23 +15,7 @@ import type { Collection } from '@trek/shared' export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): React.ReactElement { const { t } = useTranslation() const navigate = useNavigate() - const [lists, setLists] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - let cancelled = false - ;void (async () => { - try { - const data = await collectionsApi.list() - if (!cancelled) setLists(data.collections) - } catch { - if (!cancelled) setLists([]) - } finally { - if (!cancelled) setLoading(false) - } - })() - return () => { cancelled = true } - }, []) + const { lists, loading } = useCollectionLists() return (
diff --git a/client/src/components/Dashboard/useCollectionLists.test.ts b/client/src/components/Dashboard/useCollectionLists.test.ts new file mode 100644 index 000000000..f7b755f6b --- /dev/null +++ b/client/src/components/Dashboard/useCollectionLists.test.ts @@ -0,0 +1,36 @@ +// FE-COMP-COLLISTS-001 to -003: the saved-place lists both dashboards show. +import { renderHook, waitFor } from '@testing-library/react'; +import type { Collection } from '@trek/shared'; + +import { collectionsApi } from '../../api/collections'; +import { useCollectionLists } from './useCollectionLists'; + +afterEach(() => vi.restoreAllMocks()); + +describe('useCollectionLists', () => { + it('FE-COMP-COLLISTS-001: starts loading, then holds the fetched lists', async () => { + const lists = [{ id: 1, name: 'Food' }] as unknown as Collection[]; + vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: lists } as never); + const { result } = renderHook(() => useCollectionLists()); + expect(result.current).toEqual({ lists: [], loading: true }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.lists).toBe(lists); + }); + + it('FE-COMP-COLLISTS-002: a failed fetch ends loading with no lists', async () => { + vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline')); + const { result } = renderHook(() => useCollectionLists()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.lists).toEqual([]); + }); + + it('FE-COMP-COLLISTS-003: an answer after unmount is dropped', async () => { + let resolve!: (v: { collections: Collection[] }) => void; + vi.spyOn(collectionsApi, 'list').mockImplementation(() => new Promise((r) => (resolve = r)) as never); + const { result, unmount } = renderHook(() => useCollectionLists()); + unmount(); + resolve({ collections: [{ id: 2 } as unknown as Collection] }); + await Promise.resolve(); + expect(result.current).toEqual({ lists: [], loading: true }); + }); +}); diff --git a/client/src/components/Dashboard/useCollectionLists.ts b/client/src/components/Dashboard/useCollectionLists.ts new file mode 100644 index 000000000..9c4b79883 --- /dev/null +++ b/client/src/components/Dashboard/useCollectionLists.ts @@ -0,0 +1,29 @@ +import type { Collection } from '@trek/shared'; +import { useEffect, useState } from 'react'; + +import { collectionsApi } from '../../api/collections'; + +/** The user's saved-place lists for the dashboard widgets, loaded once on mount. */ +export function useCollectionLists() { + const [lists, setLists] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const data = await collectionsApi.list(); + if (!cancelled) setLists(data.collections); + } catch { + if (!cancelled) setLists([]); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return { lists, loading }; +} diff --git a/client/src/components/Dashboard/useCurrencyConverter.test.ts b/client/src/components/Dashboard/useCurrencyConverter.test.ts new file mode 100644 index 000000000..d55521078 --- /dev/null +++ b/client/src/components/Dashboard/useCurrencyConverter.test.ts @@ -0,0 +1,117 @@ +// FE-COMP-FXHOOK-001 to -007: the currency converter both dashboards share. +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { useSettingsStore } from '../../store/settingsStore'; +import { CURRENCIES } from '../Budget/BudgetPanel.constants'; +import { useCurrencyConverter } from './useCurrencyConverter'; + +const RATES = [ + { quote: 'USD', rate: 1.1 }, + { quote: 'CHF', rate: 0.95 }, +]; + +const updateSetting = vi.fn<(key: string, value: unknown) => Promise>(); +let fetchMock: ReturnType; + +function seedSettings(over: Record = {}, isLoaded = false) { + useSettingsStore.setState((s) => ({ + isLoaded, + updateSetting: updateSetting as unknown as typeof s.updateSetting, + settings: { ...s.settings, dashboard_fx_from: undefined, dashboard_fx_to: undefined, ...over } as typeof s.settings, + })); +} + +function respond(body: unknown) { + return Promise.resolve({ json: () => Promise.resolve(body) } as Response); +} + +beforeEach(() => { + updateSetting.mockReset().mockResolvedValue(undefined); + fetchMock = vi.fn(() => respond(RATES)); + vi.stubGlobal('fetch', fetchMock); + localStorage.clear(); + seedSettings(); +}); +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +describe('useCurrencyConverter', () => { + it('FE-COMP-FXHOOK-001: defaults to EUR to USD and converts the amount with the fetched rate', async () => { + const { result } = renderHook(() => useCurrencyConverter({ abortStale: false })); + expect(result.current.from).toBe('EUR'); + expect(result.current.to).toBe('USD'); + expect(result.current.currencies).toBe(CURRENCIES); + await waitFor(() => expect(result.current.rate).toBe(1.1)); + expect(result.current.converted).toBeCloseTo(110); + expect(result.current.currencies).toEqual(['CHF', 'EUR', 'USD']); + act(() => result.current.setAmount('2,5')); + expect(result.current.converted).toBeCloseTo(2.75); + }); + + it('FE-COMP-FXHOOK-002: the desktop mode fetches without a signal, the phone mode with one', async () => { + renderHook(() => useCurrencyConverter({ abortStale: false })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(fetchMock.mock.calls[0]).toEqual(['https://api.frankfurter.dev/v2/rates?base=EUR']); + + renderHook(() => useCurrencyConverter({ abortStale: true })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(fetchMock.mock.calls[1][1]).toEqual({ signal: expect.any(AbortSignal) }); + }); + + it('FE-COMP-FXHOOK-003: the phone mode aborts the request on unmount and on a refetch', async () => { + fetchMock.mockImplementation(() => new Promise(() => {})); + const { result, unmount } = renderHook(() => useCurrencyConverter({ abortStale: true })); + const first = (fetchMock.mock.calls[0][1] as { signal: AbortSignal }).signal; + act(() => result.current.fetchRates()); + expect(first.aborted).toBe(true); + const second = (fetchMock.mock.calls[1][1] as { signal: AbortSignal }).signal; + unmount(); + expect(second.aborted).toBe(true); + }); + + it('FE-COMP-FXHOOK-004: a failed or malformed answer leaves no rate', async () => { + fetchMock.mockImplementationOnce(() => Promise.reject(new Error('offline'))); + const failed = renderHook(() => useCurrencyConverter({ abortStale: false })); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(failed.result.current.rate).toBeNull(); + expect(failed.result.current.converted).toBeNull(); + + fetchMock.mockImplementationOnce(() => respond({ error: 'nope' })); + const malformed = renderHook(() => useCurrencyConverter({ abortStale: true })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(malformed.result.current.rate).toBeNull(); + }); + + it('FE-COMP-FXHOOK-005: picking and swapping write the pair to the settings', () => { + seedSettings({ dashboard_fx_from: 'GBP', dashboard_fx_to: 'JPY' }); + const { result } = renderHook(() => useCurrencyConverter({ abortStale: false })); + act(() => result.current.swap()); + expect(updateSetting).toHaveBeenCalledWith('dashboard_fx_from', 'JPY'); + expect(updateSetting).toHaveBeenCalledWith('dashboard_fx_to', 'GBP'); + act(() => result.current.setFrom('CHF')); + expect(updateSetting).toHaveBeenLastCalledWith('dashboard_fx_from', 'CHF'); + }); + + it('FE-COMP-FXHOOK-006: once settings are loaded, the old localStorage pair moves into them', async () => { + localStorage.setItem('trek_fx_from', 'CAD'); + localStorage.setItem('trek_fx_to', 'AUD'); + seedSettings({}, true); + renderHook(() => useCurrencyConverter({ abortStale: false })); + expect(updateSetting).toHaveBeenCalledWith('dashboard_fx_from', 'CAD'); + expect(updateSetting).toHaveBeenCalledWith('dashboard_fx_to', 'AUD'); + await waitFor(() => expect(localStorage.getItem('trek_fx_from')).toBeNull()); + expect(localStorage.getItem('trek_fx_to')).toBeNull(); + }); + + it('FE-COMP-FXHOOK-007: a failed migration keeps the localStorage pair for the next load', async () => { + localStorage.setItem('trek_fx_from', 'CAD'); + updateSetting.mockRejectedValue(new Error('down')); + seedSettings({}, true); + renderHook(() => useCurrencyConverter({ abortStale: false })); + await waitFor(() => expect(updateSetting).toHaveBeenCalled()); + await Promise.resolve(); + expect(localStorage.getItem('trek_fx_from')).toBe('CAD'); + }); +}); diff --git a/client/src/components/Dashboard/useCurrencyConverter.ts b/client/src/components/Dashboard/useCurrencyConverter.ts new file mode 100644 index 000000000..623b96584 --- /dev/null +++ b/client/src/components/Dashboard/useCurrencyConverter.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useSettingsStore } from '../../store/settingsStore'; +import { CURRENCIES } from '../Budget/BudgetPanel.constants'; + +/** + * The dashboard's currency converter: the pair lives in the user's settings, the + * rates come from Frankfurter for the `from` currency. + * + * `abortStale` (the phone widget) aborts a request still in flight when a new one + * starts or the widget goes away, so a late answer never lands on a gone tree. + */ +export function useCurrencyConverter({ abortStale }: { abortStale: boolean }) { + 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); + const inFlight = useRef(null); + + const fetchRates = useCallback(() => { + let signal: AbortSignal | undefined; + if (abortStale) { + inFlight.current?.abort(); + const controller = new AbortController(); + inFlight.current = controller; + signal = controller.signal; + } + const url = `https://api.frankfurter.dev/v2/rates?base=${from}`; + (signal ? fetch(url, { signal }) : fetch(url)) + .then((r) => r.json()) + .then((d: Array<{ quote: string; rate: number }>) => { + if (signal?.aborted) return; + if (!Array.isArray(d)) { + setRates(null); + return; + } + // Frankfurter omits the base's own self-rate; seed it so `from` stays selectable. + const map: Record = { [from]: 1 }; + for (const r of d) map[r.quote] = r.rate; + setRates(map); + }) + .catch(() => { + // An abort is not a failure: it means nobody is waiting for the answer. + if (!signal?.aborted) setRates(null); + }); + }, [from, abortStale]); + + useEffect(() => { + fetchRates(); + if (!abortStale) return; + const pending = inFlight; + return () => pending.current?.abort(); + }, [fetchRates, abortStale]); + + // 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; + const writes: Promise[] = []; + if (lf) writes.push(updateSetting('dashboard_fx_from', lf)); + if (lt) writes.push(updateSetting('dashboard_fx_to', lt)); + // Only drop the localStorage source once the server has durably stored the values, so a + // failed write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load. + Promise.all(writes) + .then(() => { + localStorage.removeItem('trek_fx_from'); + localStorage.removeItem('trek_fx_to'); + }) + .catch(() => { + /* keep localStorage; retry on next load */ + }); + }, [isLoaded, updateSetting]); + + const currencies: string[] = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES; + const rate = rates?.[to] ?? null; + const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null; + const swap = () => { + setFrom(to); + setTo(from); + }; + + return { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates }; +} diff --git a/client/src/components/Dashboard/useDashboardPlugins.test.ts b/client/src/components/Dashboard/useDashboardPlugins.test.ts new file mode 100644 index 000000000..1d812c104 --- /dev/null +++ b/client/src/components/Dashboard/useDashboardPlugins.test.ts @@ -0,0 +1,52 @@ +// FE-COMP-DASHPLUGINS-001 to -003: what plugins add to both dashboards. +import { renderHook } from '@testing-library/react'; + +import { usePluginStore } from '../../store/pluginStore'; +import { useDashboardPlugins } from './useDashboardPlugins'; + +const badges = vi.fn<(ids: number[], enabled: boolean) => (id: number) => unknown[]>(() => () => []); +vi.mock('../Plugins/TripCardBadges', () => ({ + useTripCardBadges: (ids: number[], enabled: boolean) => badges(ids, enabled), +})); + +type Plugins = ReturnType['plugins']; + +function seedPlugins(plugins: Array<{ id: string; type: string; slot?: string }>) { + usePluginStore.setState({ plugins: plugins as unknown as Plugins }); +} + +beforeEach(() => badges.mockClear()); + +describe('useDashboardPlugins', () => { + it('FE-COMP-DASHPLUGINS-001: keeps only the widgets meant for the dashboard column', () => { + seedPlugins([ + { id: 'side', type: 'widget' }, + { id: 'slotted', type: 'widget', slot: 'dashboard' }, + { id: 'hero', type: 'widget', slot: 'hero' }, + { id: 'place', type: 'widget', slot: 'place-detail' }, + { id: 'day', type: 'widget', slot: 'day-detail' }, + { id: 'res', type: 'widget', slot: 'reservation-detail' }, + { id: 'page', type: 'page' }, + ]); + const { result } = renderHook(() => useDashboardPlugins([1])); + expect(result.current.widgetPlugins.map((p) => p.id)).toEqual(['side', 'slotted']); + }); + + it('FE-COMP-DASHPLUGINS-002: asks for card badges only while a plugin is active', () => { + seedPlugins([]); + renderHook(() => useDashboardPlugins([1, 2])); + expect(badges).toHaveBeenLastCalledWith([1, 2], false); + + seedPlugins([{ id: 'page', type: 'page' }]); + renderHook(() => useDashboardPlugins([3])); + expect(badges).toHaveBeenLastCalledWith([3], true); + }); + + it('FE-COMP-DASHPLUGINS-003: hands back the badge lookup', () => { + const lookup = () => []; + badges.mockReturnValueOnce(lookup); + seedPlugins([]); + const { result } = renderHook(() => useDashboardPlugins([])); + expect(result.current.badgesFor).toBe(lookup); + }); +}); diff --git a/client/src/components/Dashboard/useDashboardPlugins.ts b/client/src/components/Dashboard/useDashboardPlugins.ts new file mode 100644 index 000000000..8017c8495 --- /dev/null +++ b/client/src/components/Dashboard/useDashboardPlugins.ts @@ -0,0 +1,22 @@ +import { usePluginStore } from '../../store/pluginStore'; +import { useTripCardBadges } from '../Plugins/TripCardBadges'; + +/** + * What plugins add to the dashboard: the widgets for the side column (hero widgets + * mount on the spotlight, place-detail/day-detail/reservation-detail ones inside the + * planner, so none of those) and the badges on the trip cards. The badges cost one + * fetch for all cards and only run while at least one plugin is active. + */ +export function useDashboardPlugins(tripIds: number[]) { + const widgetPlugins = usePluginStore((s) => s.plugins).filter( + (p) => + p.type === 'widget' && + p.slot !== 'hero' && + p.slot !== 'place-detail' && + p.slot !== 'day-detail' && + p.slot !== 'reservation-detail' + ); + const anyPluginActive = usePluginStore((s) => s.plugins).length > 0; + const badgesFor = useTripCardBadges(tripIds, anyPluginActive); + return { widgetPlugins, badgesFor }; +} diff --git a/client/src/components/Dashboard/useDashboardTimezones.test.ts b/client/src/components/Dashboard/useDashboardTimezones.test.ts new file mode 100644 index 000000000..c2fb01c13 --- /dev/null +++ b/client/src/components/Dashboard/useDashboardTimezones.test.ts @@ -0,0 +1,100 @@ +// FE-COMP-TZHOOK-001 to -007: the world clocks both dashboards share. +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { useSettingsStore } from '../../store/settingsStore'; +import { shortZone, useDashboardTimezones } from './useDashboardTimezones'; + +const updateSetting = vi.fn<(key: string, value: unknown) => Promise>(); +const home = Intl.DateTimeFormat().resolvedOptions().timeZone; + +function seedSettings(zones: string[] | undefined, isLoaded = false) { + useSettingsStore.setState((s) => ({ + isLoaded, + updateSetting: updateSetting as unknown as typeof s.updateSetting, + settings: { ...s.settings, dashboard_timezones: zones } as typeof s.settings, + })); +} + +beforeEach(() => { + updateSetting.mockReset().mockResolvedValue(undefined); + localStorage.clear(); + seedSettings(undefined); +}); +afterEach(() => { + vi.useRealTimers(); + localStorage.clear(); +}); + +describe('shortZone', () => { + it('FE-COMP-TZHOOK-001: keeps the city and spaces out underscores', () => { + expect(shortZone('America/New_York')).toBe('New York'); + expect(shortZone('UTC')).toBe('UTC'); + }); +}); + +describe('useDashboardTimezones', () => { + it('FE-COMP-TZHOOK-002: an unset list falls back to home plus the defaults, a stored one is honoured', () => { + const fallback = renderHook(() => useDashboardTimezones('en')); + expect(fallback.result.current.zones).toEqual([home, 'Europe/London', 'Asia/Tokyo']); + + seedSettings([]); + const empty = renderHook(() => useDashboardTimezones('en')); + expect(empty.result.current.zones).toEqual([]); + }); + + it('FE-COMP-TZHOOK-003: adding a new zone writes the list and closes the picker, a known one only closes it', () => { + seedSettings(['UTC']); + const { result } = renderHook(() => useDashboardTimezones('en')); + act(() => result.current.setAdding(true)); + act(() => result.current.addZone('Asia/Tokyo')); + expect(updateSetting).toHaveBeenCalledWith('dashboard_timezones', ['UTC', 'Asia/Tokyo']); + expect(result.current.adding).toBe(false); + + updateSetting.mockClear(); + act(() => result.current.setAdding(true)); + act(() => result.current.addZone('UTC')); + expect(updateSetting).not.toHaveBeenCalled(); + expect(result.current.adding).toBe(false); + }); + + it('FE-COMP-TZHOOK-004: removing drops the zone from the stored list', () => { + seedSettings(['UTC', 'Asia/Tokyo']); + const { result } = renderHook(() => useDashboardTimezones('en')); + act(() => result.current.removeZone('UTC')); + expect(updateSetting).toHaveBeenCalledWith('dashboard_timezones', ['Asia/Tokyo']); + }); + + it('FE-COMP-TZHOOK-005: the clocks read the zone time and tick every 30 seconds', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-15T12:00:00Z')); + seedSettings(['UTC']); + const { result } = renderHook(() => useDashboardTimezones('en-GB')); + expect(result.current.timeIn('UTC')).toBe('12:00'); + expect(result.current.timeIn('Asia/Tokyo')).toBe('21:00'); + expect(result.current.offsetLabel('UTC')).toBe('UTC'); + act(() => vi.advanceTimersByTime(30000)); + expect(result.current.timeIn('UTC')).toBe('12:00'); + act(() => vi.advanceTimersByTime(30000)); + expect(result.current.timeIn('UTC')).toBe('12:01'); + }); + + it('FE-COMP-TZHOOK-006: once settings are loaded, an old localStorage list moves into them', async () => { + localStorage.setItem('trek_dashboard_tz', JSON.stringify(['Europe/Paris'])); + seedSettings(undefined, true); + renderHook(() => useDashboardTimezones('en')); + expect(updateSetting).toHaveBeenCalledWith('dashboard_timezones', ['Europe/Paris']); + await waitFor(() => expect(localStorage.getItem('trek_dashboard_tz')).toBeNull()); + }); + + it('FE-COMP-TZHOOK-007: a malformed localStorage value is dropped without a write', () => { + localStorage.setItem('trek_dashboard_tz', '{not json'); + seedSettings(undefined, true); + renderHook(() => useDashboardTimezones('en')); + expect(localStorage.getItem('trek_dashboard_tz')).toBeNull(); + + localStorage.setItem('trek_dashboard_tz', JSON.stringify({ zone: 'UTC' })); + renderHook(() => useDashboardTimezones('en')); + expect(localStorage.getItem('trek_dashboard_tz')).toBeNull(); + expect(updateSetting).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Dashboard/useDashboardTimezones.ts b/client/src/components/Dashboard/useDashboardTimezones.ts new file mode 100644 index 000000000..bc7ca6d3d --- /dev/null +++ b/client/src/components/Dashboard/useDashboardTimezones.ts @@ -0,0 +1,113 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useSettingsStore } from '../../store/settingsStore'; + +const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo']; + +// Fallback for the rare browser without Intl.supportedValuesOf. +const FALLBACK_ZONES = [ + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Madrid', + 'Europe/Moscow', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Sao_Paulo', + 'Asia/Dubai', + 'Asia/Kolkata', + 'Asia/Bangkok', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Asia/Singapore', + 'Australia/Sydney', + 'Pacific/Auckland', + 'UTC', +]; + +/** The city part of a zone id, so America/New_York reads New York. */ +export function shortZone(tz: string): string { + const city = tz.split('/').pop() || tz; + return city.replace(/_/g, ' '); +} + +/** + * The dashboard's world clocks: the zone list lives in the user's settings, the + * clocks tick every 30 seconds, `adding` toggles the zone picker. + */ +export function useDashboardTimezones(locale: string) { + const home = Intl.DateTimeFormat().resolvedOptions().timeZone; + const [now, setNow] = useState(() => new Date()); + 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. + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 30000); + return () => clearInterval(id); + }, []); + + // 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; + let parsed: unknown; + // A malformed/non-array value can never be written, so drop it now to avoid retrying forever. + try { + parsed = JSON.parse(raw); + } catch { + localStorage.removeItem('trek_dashboard_tz'); + return; + } + if (!Array.isArray(parsed)) { + localStorage.removeItem('trek_dashboard_tz'); + return; + } + // Only drop the localStorage source once the server has durably stored the value, so a failed + // write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load. + updateSetting('dashboard_timezones', parsed) + .then(() => { + localStorage.removeItem('trek_dashboard_tz'); + }) + .catch(() => { + /* keep localStorage; retry on next load */ + }); + }, [isLoaded, updateSetting]); + + const allZones = useMemo(() => { + const supported = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf; + try { + return supported ? supported('timeZone') : FALLBACK_ZONES; + } catch { + return FALLBACK_ZONES; + } + }, []); + + 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) => { + const part = new Intl.DateTimeFormat(locale, { timeZone: tz, timeZoneName: 'short' }) + .formatToParts(now) + .find((p) => p.type === 'timeZoneName'); + return part?.value || ''; + }; + + return { zones, adding, setAdding, allZones, addZone, removeZone, timeIn, offsetLabel }; +} diff --git a/client/src/mobile/screens/dashboard/MDashWidgets.tsx b/client/src/mobile/screens/dashboard/MDashWidgets.tsx index 1846c798e..cef1c7cd2 100644 --- a/client/src/mobile/screens/dashboard/MDashWidgets.tsx +++ b/client/src/mobile/screens/dashboard/MDashWidgets.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React from 'react' import { useNavigate } from 'react-router' import { ArrowRight, ArrowRightLeft, Bookmark, Calendar, ChevronDown, ChevronRight, @@ -7,12 +7,13 @@ import { import { useTranslation } from '../../../i18n' import { useSettingsStore } from '../../../store/settingsStore' import { useAddonStore } from '../../../store/addonStore' -import { collectionsApi } from '../../../api/collections' import { entityGradient } from '../../../utils/gradients' -import { CURRENCIES } from '../../../components/Budget/BudgetPanel.constants' +import { useCurrencyConverter } from '../../../components/Dashboard/useCurrencyConverter' +import { useCollectionLists } from '../../../components/Dashboard/useCollectionLists' +import { shortZone, useDashboardTimezones } from '../../../components/Dashboard/useDashboardTimezones' import { formatTime, splitReservationDateTime } from '../../../utils/formatters' -import { normalizeAppearance, MOBILE_DASH_TOKENS, type MobileDashToken, type Collection } from '@trek/shared' -import { upcomingKey, type UpcomingReservation } from '../../../pages/dashboard/dashboardModel' +import { normalizeAppearance, MOBILE_DASH_TOKENS, type MobileDashToken } from '@trek/shared' +import { MOMENT_LABEL, upcomingKey, type UpcomingReservation } from '../../../pages/dashboard/dashboardModel' const RES_ICON: Record = { flight: , @@ -24,9 +25,6 @@ const RES_ICON: Record = { checkout: , } -/** The label a stay's moment carries in place of a location. */ -const MOMENT_LABEL: Record = { checkin: 'day.checkIn', checkout: 'day.checkOut' } - /** * Inline dashboard widget panels (currency, collections, timezones, upcoming * reservations) for the mobile dashboard. The blocks are rendered individually @@ -101,67 +99,9 @@ function WidgetPanel({ icon, title, action, children }: { // ── Currency converter ─────────────────────────────────────────────────────── function MCurrencyWidget(): React.ReactElement { const { t } = useTranslation() - 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) - // The request outlives the widget: this is a third-party endpoint on a mobile - // screen a user can leave immediately, and both handlers below set state. Left - // unguarded it settles after unmount, React schedules an update against a gone - // tree, and under a test runner that surfaces as an unhandled rejection long - // after the case that triggered it has passed. - const inFlight = useRef(null) - - const fetchRates = useCallback(() => { - inFlight.current?.abort() - const controller = new AbortController() - inFlight.current = controller - fetch(`https://api.frankfurter.dev/v2/rates?base=${from}`, { signal: controller.signal }) - .then(r => r.json()) - .then((d: Array<{ quote: string; rate: number }>) => { - if (controller.signal.aborted) return - if (!Array.isArray(d)) { setRates(null); return } - // Frankfurter omits the base's own self-rate; seed it so `from` stays selectable. - const map: Record = { [from]: 1 } - for (const r of d) map[r.quote] = r.rate - setRates(map) - }) - .catch(() => { - // An abort is not a failure: it means nobody is waiting for the answer. - if (!controller.signal.aborted) setRates(null) - }) - }, [from]) - - useEffect(() => { - fetchRates() - return () => inFlight.current?.abort() - }, [fetchRates]) - - // Same one-time localStorage → settings migration the desktop widget runs, so - // a phone-only user's pre-3.1.3 pair survives an upgrade too (#1311). - useEffect(() => { - if (!isLoaded) return - const lf = localStorage.getItem('trek_fx_from') - const lt = localStorage.getItem('trek_fx_to') - if (!lf && !lt) return - const writes: Promise[] = [] - if (lf) writes.push(updateSetting('dashboard_fx_from', lf)) - if (lt) writes.push(updateSetting('dashboard_fx_to', lt)) - Promise.all(writes).then(() => { - localStorage.removeItem('trek_fx_from') - localStorage.removeItem('trek_fx_to') - }).catch(() => { /* keep localStorage; retry on next load */ }) - }, [isLoaded, updateSetting]) - - const currencies = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : [...CURRENCIES] - const rate = rates?.[to] ?? null - const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null - const swap = () => { setFrom(to); setTo(from) } + // screen a user can leave immediately, so a pending one is aborted on the way out. + const { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates } = useCurrencyConverter({ abortStale: true }) return ( ([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - let cancelled = false - collectionsApi.list() - .then(data => { if (!cancelled) setLists(data.collections) }) - .catch(() => { if (!cancelled) setLists([]) }) - .finally(() => { if (!cancelled) setLoading(false) }) - return () => { cancelled = true } - }, []) + const { lists, loading } = useCollectionLists() return ( new Date()) - 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) - - useEffect(() => { - const id = setInterval(() => setNow(new Date()), 30000) - return () => clearInterval(id) - }, []) - - // Same one-time localStorage → settings migration the desktop widget runs, so - // a phone-only user's pre-3.1.3 zone list survives an upgrade too (#1311). - useEffect(() => { - if (!isLoaded) return - const raw = localStorage.getItem('trek_dashboard_tz') - if (!raw) return - let parsed: unknown - // A malformed/non-array value can never be written, so drop it now to avoid retrying forever. - try { parsed = JSON.parse(raw) } catch { localStorage.removeItem('trek_dashboard_tz'); return } - if (!Array.isArray(parsed)) { localStorage.removeItem('trek_dashboard_tz'); return } - // Only drop the localStorage source once the server has durably stored the value. - updateSetting('dashboard_timezones', parsed) - .then(() => { localStorage.removeItem('trek_dashboard_tz') }) - .catch(() => { /* keep localStorage; retry on next load */ }) - }, [isLoaded, updateSetting]) - - const allZones = React.useMemo(() => { - const supported = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf - try { return supported ? supported('timeZone') : FALLBACK_ZONES } catch { return FALLBACK_ZONES } - }, []) - - 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) => { - const part = new Intl.DateTimeFormat(locale, { timeZone: tz, timeZoneName: 'short' }).formatToParts(now).find(p => p.type === 'timeZoneName') - return part?.value || '' - } + const { zones, adding, setAdding, allZones, addZone, removeZone, timeIn, offsetLabel } = useDashboardTimezones(locale) return ( s.plugins).filter(p => p.type === 'widget' && p.slot !== 'hero' && p.slot !== 'place-detail' && p.slot !== 'day-detail' && p.slot !== 'reservation-detail') - const anyPluginActive = usePluginStore(s => s.plugins).length > 0 - const badgesFor = useTripCardBadges(gridTrips.map(trip => trip.id), anyPluginActive) + const { widgetPlugins, badgesFor } = useDashboardPlugins(gridTrips.map(trip => trip.id)) // Mobile-only dashboard arrangement: the featured trip stays on top, then the // trip list + widgets render in the user's chosen order (Settings → Appearance). diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index a1ab4c927..c92a9f5f9 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React from 'react' import { useTranslation } from '../i18n' import Navbar from '../components/Layout/Navbar' import DemoBanner from '../components/Layout/DemoBanner' @@ -14,7 +14,7 @@ import HelpAnchor from '../components/Help/HelpAnchor' import { useDashboard } from './dashboard/useDashboard' import { type DashboardTrip, type HeroBundle, type TravelStats, type UpcomingReservation, - MS_PER_DAY, daysUntil, getTripStatus, upcomingKey, + MOMENT_LABEL, MS_PER_DAY, daysUntil, fullDate, getTripStatus, upcomingKey, } from './dashboard/dashboardModel' import { Plus, Edit2, Trash2, Archive, ArchiveRestore, Copy, ArrowRight, MapPin, @@ -23,13 +23,15 @@ import { } from 'lucide-react' import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal' import CollectionsWidget from '../components/Dashboard/CollectionsWidget' +import { useCurrencyConverter } from '../components/Dashboard/useCurrencyConverter' +import { useDashboardPlugins } from '../components/Dashboard/useDashboardPlugins' +import { shortZone, useDashboardTimezones } from '../components/Dashboard/useDashboardTimezones' import PluginWidgets from '../components/Plugins/PluginWidgets' import PluginFrame from '../components/Plugins/PluginFrame' -import { TripCardBadges, useTripCardBadges } from '../components/Plugins/TripCardBadges' +import { TripCardBadges } from '../components/Plugins/TripCardBadges' import type { TripCardBadge } from '../api/client' import { usePluginStore } from '../store/pluginStore' import { formatTime, splitReservationDateTime } from '../utils/formatters' -import { CURRENCIES } from '../components/Budget/BudgetPanel.constants' import { convertDistance, getDistanceUnitLabel } from '../utils/units' import { useSettingsStore } from '../store/settingsStore' import { useAddonStore } from '../store/addonStore' @@ -63,18 +65,6 @@ function splitDate(dateStr: string | null | undefined, locale: string): { d: str } } -// Localized date for the cards. The year is included only when it isn't the -// current year, and order/punctuation follow the locale (EN "Sep 10, 2026", -// DE "10. Sep 2026" — vs a plain "Sep 10" this year), never a hard-coded layout. -function fullDate(dateStr: string | null | undefined, locale: string): string | null { - if (!dateStr) return null - const date = new Date(dateStr + 'T00:00:00Z') - if (Number.isNaN(date.getTime())) return null - const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', timeZone: 'UTC' } - if (date.getUTCFullYear() !== new Date().getUTCFullYear()) opts.year = 'numeric' - return date.toLocaleDateString(locale, opts) -} - function buddyColor(seed: number): string { const pairs = [ ['#6366f1', '#8b5cf6'], ['#10b981', '#059669'], ['#f59e0b', '#d97706'], @@ -100,8 +90,6 @@ const RES_ICON: Record = { const RES_TYPE_CLASS: Record = { flight: 'flight', hotel: 'hotel', restaurant: 'food', checkin: 'hotel', checkout: 'hotel', } -/** The label a stay's moment carries in place of a location. */ -const MOMENT_LABEL: Record = { checkin: 'day.checkIn', checkout: 'day.checkOut' } export default function DashboardPage(): React.ReactElement { // ViewportRoute in App.tsx picks the branch now, so the phone screen is a @@ -137,13 +125,10 @@ function DashboardPageDesktop(): React.ReactElement { // Desktop has a master toggle for the whole right column; off → centered layout. // Only true dashboard widgets belong here — hero mounts on the boarding pass, and // place-detail/day-detail widgets live inside the planner panels, not the sidebar. - const widgetPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot !== 'hero' && p.slot !== 'place-detail' && p.slot !== 'day-detail' && p.slot !== 'reservation-detail') - const sidebarVisible = dashCfg.desktop.sidebar && (showCurrency || showCollections || showTimezones || showUpcoming || widgetPlugins.length > 0) - // Plugin-contributed badges on the trip cards (tripCardProvider hook). One fetch for // all visible cards; only runs when at least one plugin is active. Fail-safe. - const anyPluginActive = usePluginStore(s => s.plugins).length > 0 - const badgesFor = useTripCardBadges(gridTrips.map(t => t.id), anyPluginActive) + const { widgetPlugins, badgesFor } = useDashboardPlugins(gridTrips.map(t => t.id)) + const sidebarVisible = dashCfg.desktop.sidebar && (showCurrency || showCollections || showTimezones || showUpcoming || widgetPlugins.length > 0) return ( <> @@ -677,53 +662,8 @@ function TripCard({ trip, locale, badges, matchedPlaces, onOpen, onEdit, onCopy, // ── Currency tool (self-contained, mirrors the design's fx widget) ─────────── function CurrencyTool(): React.ReactElement { const { t } = useTranslation() - 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) - - const fetchRate = React.useCallback(() => { - fetch(`https://api.frankfurter.dev/v2/rates?base=${from}`) - .then(r => r.json()) - .then((d: Array<{ quote: string; rate: number }>) => { - if (!Array.isArray(d)) { setRates(null); return } - // Frankfurter omits the base's own self-rate; seed it so `from` stays selectable. - const map: Record = { [from]: 1 } - for (const r of d) map[r.quote] = r.rate - setRates(map) - }) - .catch(() => setRates(null)) - }, [from]) - - useEffect(() => { fetchRate() }, [fetchRate]) - // 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 - const writes: Promise[] = [] - if (lf) writes.push(updateSetting('dashboard_fx_from', lf)) - if (lt) writes.push(updateSetting('dashboard_fx_to', lt)) - // Only drop the localStorage source once the server has durably stored the values, so a - // failed write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load. - Promise.all(writes).then(() => { - localStorage.removeItem('trek_fx_from') - localStorage.removeItem('trek_fx_to') - }).catch(() => { /* keep localStorage; retry on next load */ }) - }, [isLoaded, updateSetting]) - - const currencies = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES + const { from, to, setFrom, setTo, amount, setAmount, currencies, rate, converted, swap, fetchRates: fetchRate } = useCurrencyConverter({ abortStale: false }) const ccyOptions = currencies.map(c => ({ value: c, label: c })) - const rate = rates?.[to] ?? null - const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null - - const swap = () => { setFrom(to); setTo(from) } return (
@@ -752,74 +692,14 @@ function CurrencyTool(): React.ReactElement { } // ── Timezone tool ──────────────────────────────────────────────────────────── -const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo'] - -// Fallback for the rare browser without Intl.supportedValuesOf. -const FALLBACK_ZONES = [ - 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', 'Europe/Moscow', - 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Sao_Paulo', - 'Asia/Dubai', 'Asia/Kolkata', 'Asia/Bangkok', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Singapore', - 'Australia/Sydney', 'Pacific/Auckland', 'UTC', -] - -function shortZone(tz: string): string { - const city = tz.split('/').pop() || tz - return city.replace(/_/g, ' ') -} - function TimezoneTool({ locale }: { locale: string }): React.ReactElement { const { t } = useTranslation() - const home = Intl.DateTimeFormat().resolvedOptions().timeZone - const [now, setNow] = useState(() => new Date()) - 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. - useEffect(() => { - const id = setInterval(() => setNow(new Date()), 30000) - return () => clearInterval(id) - }, []) - - // 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 - let parsed: unknown - // A malformed/non-array value can never be written, so drop it now to avoid retrying forever. - try { parsed = JSON.parse(raw) } catch { localStorage.removeItem('trek_dashboard_tz'); return } - if (!Array.isArray(parsed)) { localStorage.removeItem('trek_dashboard_tz'); return } - // Only drop the localStorage source once the server has durably stored the value, so a failed - // write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load. - updateSetting('dashboard_timezones', parsed) - .then(() => { localStorage.removeItem('trek_dashboard_tz') }) - .catch(() => { /* keep localStorage; retry on next load */ }) - }, [isLoaded, updateSetting]) - - const allZones = React.useMemo(() => { - const supported = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf - try { return supported ? supported('timeZone') : FALLBACK_ZONES } catch { return FALLBACK_ZONES } - }, []) + const { zones, adding, setAdding, allZones, addZone, removeZone, timeIn, offsetLabel } = useDashboardTimezones(locale) const tzOptions = allZones .filter(z => !zones.includes(z)) .map(z => ({ value: z, label: z.replace(/_/g, ' '), searchLabel: z })) - 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) => { - const part = new Intl.DateTimeFormat(locale, { timeZone: tz, timeZoneName: 'short' }).formatToParts(now).find(p => p.type === 'timeZoneName') - return part?.value || '' - } - return (
diff --git a/client/src/pages/dashboard/dashboardModel.test.ts b/client/src/pages/dashboard/dashboardModel.test.ts index c18bb5da2..5a5fe32fc 100644 --- a/client/src/pages/dashboard/dashboardModel.test.ts +++ b/client/src/pages/dashboard/dashboardModel.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { daysUntil, getTripStatus, sortTrips, localIsoToday } from './dashboardModel' +import { MOMENT_LABEL, daysUntil, fullDate, getTripStatus, sortTrips, localIsoToday } from './dashboardModel' /** * The dashboard classifies trips against the user's WALL CLOCK — `daysUntil` @@ -49,3 +49,23 @@ describe('dashboardModel — "today" is the user\'s local date', () => { expect(sorted.map((t: { id: number }) => t.id)).toEqual([2, 1]) }) }) + +describe('dashboardModel: fullDate', () => { + afterEach(() => { vi.useRealTimers() }) + + it('shows the year only for another year and survives a missing or malformed date', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-01T12:00:00Z')) + expect(fullDate('2026-09-10', 'en-US')).toBe('Sep 10') + expect(fullDate('2024-09-10', 'en-US')).toBe('Sep 10, 2024') + expect(fullDate(null, 'en-US')).toBeNull() + expect(fullDate('not-a-date', 'en-US')).toBeNull() + }) +}) + +describe('dashboardModel: MOMENT_LABEL', () => { + it('labels only the two moments of a stay', () => { + expect(MOMENT_LABEL).toEqual({ checkin: 'day.checkIn', checkout: 'day.checkOut' }) + expect(MOMENT_LABEL.flight).toBeUndefined() + }) +}) diff --git a/client/src/pages/dashboard/dashboardModel.ts b/client/src/pages/dashboard/dashboardModel.ts index a9e41af95..981bcfc68 100644 --- a/client/src/pages/dashboard/dashboardModel.ts +++ b/client/src/pages/dashboard/dashboardModel.ts @@ -38,6 +38,9 @@ export function upcomingKey(r: UpcomingReservation): string { return `${r.type}:${r.id}` } +/** The label a stay's moment carries in place of a location. */ +export const MOMENT_LABEL: Record = { checkin: 'day.checkIn', checkout: 'day.checkOut' } + export const MS_PER_DAY = 86400000 /** @@ -56,6 +59,18 @@ export function daysUntil(dateStr: string | null | undefined): number | null { return Math.round((d.getTime() - today.getTime()) / MS_PER_DAY) } +// Localized date for the cards. The year is included only when it isn't the +// current year, and order/punctuation follow the locale (EN "Sep 10, 2026", +// DE "10. Sep 2026", vs a plain "Sep 10" this year), never a hard-coded layout. +export function fullDate(dateStr: string | null | undefined, locale: string): string | null { + if (!dateStr) return null + const date = new Date(dateStr + 'T00:00:00Z') + if (Number.isNaN(date.getTime())) return null + const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', timeZone: 'UTC' } + if (date.getUTCFullYear() !== new Date().getUTCFullYear()) opts.year = 'numeric' + return date.toLocaleDateString(locale, opts) +} + export function getTripStatus(trip: DashboardTrip): 'ongoing' | 'today' | 'tomorrow' | 'future' | 'past' | null { const today = localIsoToday() if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 'ongoing'