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.
This commit is contained in:
Maurice
2026-10-09 10:18:13 +02:00
parent 080afa9cff
commit 18eeb16aa0
14 changed files with 625 additions and 308 deletions
@@ -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<Collection[]>([])
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 (
<div className="tool">
@@ -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 });
});
});
@@ -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<Collection[]>([]);
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 };
}
@@ -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<void>>();
let fetchMock: ReturnType<typeof vi.fn>;
function seedSettings(over: Record<string, unknown> = {}, 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');
});
});
@@ -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<Record<string, number> | null>(null);
const inFlight = useRef<AbortController | null>(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<string, number> = { [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<void>[] = [];
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 };
}
@@ -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<typeof usePluginStore.getState>['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);
});
});
@@ -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 };
}
@@ -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<void>>();
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();
});
});
@@ -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<string[]>(() => {
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 };
}
@@ -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<string, React.ReactElement> = {
flight: <Plane size={14} strokeWidth={2} />,
@@ -24,9 +25,6 @@ const RES_ICON: Record<string, React.ReactElement> = {
checkout: <LogOut size={14} strokeWidth={2} />,
}
/** The label a stay's moment carries in place of a location. */
const MOMENT_LABEL: Record<string, string> = { 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<Record<string, number> | 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<AbortController | null>(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<string, number> = { [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<void>[] = []
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 (
<WidgetPanel
@@ -232,17 +172,7 @@ function CurrencyPicker({ value, currencies, onChange }: {
function MCollectionsWidget(): React.ReactElement {
const { t } = useTranslation()
const navigate = useNavigate()
const [lists, setLists] = useState<Collection[]>([])
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 (
<WidgetPanel
@@ -278,69 +208,9 @@ function MCollectionsWidget(): React.ReactElement {
}
// ── Timezones ────────────────────────────────────────────────────────────────
const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo']
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 MTimezonesWidget(): React.ReactElement {
const { t, locale } = 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)
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<string[]>(() => {
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 (
<WidgetPanel
@@ -7,12 +7,11 @@ import { useTranslation } from '../../../i18n'
import MDancingTrek from '../../components/MDancingTrek'
import { useDashboard } from '../../../pages/dashboard/useDashboard'
import {
type DashboardTrip, MS_PER_DAY, daysUntil, getTripStatus,
type DashboardTrip, MS_PER_DAY, daysUntil, fullDate, getTripStatus,
} from '../../../pages/dashboard/dashboardModel'
import { useAuthStore } from '../../../store/authStore'
import { useInAppNotificationStore } from '../../../store/inAppNotificationStore'
import { usePluginStore } from '../../../store/pluginStore'
import { useTripCardBadges } from '../../../components/Plugins/TripCardBadges'
import { useDashboardPlugins } from '../../../components/Dashboard/useDashboardPlugins'
import type { TripCardBadge } from '../../../api/client'
import DemoBanner from '../../../components/Layout/DemoBanner'
import { IcsSubscribeModal } from '../../../components/Planner/IcsSubscribeModal'
@@ -27,17 +26,6 @@ import { useMobileDashOrder, useMobileDashVisibility, MobileDashWidget } from '.
import MNewTripSheet from './MNewTripSheet'
import type { MobileDashToken } from '@trek/shared'
// Localized short date for the pills; the year only shows when it isn't the
// current one (same rule as the desktop cards).
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)
}
type TripFilter = 'planned' | 'archive' | 'completed'
interface CardAction {
@@ -72,9 +60,7 @@ export default function MDashboard(): React.ReactElement {
// Plugin dashboard widgets + trip-card badges, mirroring the desktop page:
// same slot filter (only true dashboard widgets), one badge fetch for all
// visible cards, gated on any plugin being active. Fail-safe like desktop.
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(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).
+10 -130
View File
@@ -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<string, React.ReactElement> = {
const RES_TYPE_CLASS: Record<string, string> = {
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<string, string> = { 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<Record<string, number> | 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<string, number> = { [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<void>[] = []
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 (
<div className="tool">
@@ -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<string[]>(() => {
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 (
<div className="tool">
<div className="tool-head">
@@ -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()
})
})
@@ -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<string, string> = { 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'