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'