From 44eff7177e90143966c57915df14b77a1f215232 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 8 Aug 2026 21:03:49 +0200 Subject: [PATCH] feat(client): open TREK on the active trip when asked to Settings -> General -> Startup: start on the dashboard (unchanged default) or straight in the active trip, on a tab of your choice. Three clicks and three loads less for someone entering expenses while actually travelling, which is where the request came from. The redirect sits on '/' because that is what a shortcut, a bookmark and the installed PWA all launch (manifest start_url). /dashboard keeps working as itself - redirecting there too would leave no way back to it. The preference is mirrored into localStorage, the way the language and the theme snapshot already are, so the first paint can decide without waiting on GET /api/settings. That keeps the default launch exactly as fast as before: only someone who asked for the trip pays for the lookup, and if it fails or they have no trip they get the dashboard. Right after login the store already holds the real settings, so those win there. Login stops hardcoding /dashboard as its fallback and hands the decision to the same place, so there is one answer to "where does TREK open". --- client/src/App.test.tsx | 75 ++++++++++++++++ client/src/App.tsx | 38 +++++++- client/src/api/client.ts | 5 +- .../pages/LoginPage.oidc-redirect.test.tsx | 5 +- client/src/pages/login/useLogin.test.tsx | 35 ++++++-- client/src/pages/login/useLogin.ts | 9 +- client/src/store/authStore.ts | 4 + client/src/store/settingsStore.ts | 8 ++ client/src/types.ts | 4 + client/src/utils/startDestination.ts | 89 +++++++++++++++++++ .../tests/unit/utils/startDestination.test.ts | 82 +++++++++++++++++ 11 files changed, 339 insertions(+), 15 deletions(-) create mode 100644 client/src/utils/startDestination.ts create mode 100644 client/tests/unit/utils/startDestination.test.ts diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 5eae3cab1..2d9cb475f 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -81,6 +81,81 @@ describe('RootRedirect', () => { }) }) +// ── RootRedirect — startup destination ──────────────────────────────────────── + +describe('RootRedirect — startup destination', () => { + /** Serves GET /api/trips/active and reports whether it was asked at all. */ + function stubActiveTrip(trip: { id: number; title: string } | null) { + const calls: string[] = [] + server.use( + http.get('/api/trips/active', ({ request }) => { + calls.push(request.url) + return HttpResponse.json({ trip }) + }), + ) + return calls + } + + it('FE-COMP-APP-026: opens the active trip on the chosen tab', async () => { + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ + isLoaded: true, + settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }), + }) + stubActiveTrip({ id: 42, title: 'Japan' }) + + renderApp('/') + await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()) + }) + + it('FE-COMP-APP-027: falls back to the dashboard when the user has no trip', async () => { + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ + isLoaded: true, + settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }), + }) + stubActiveTrip(null) + + renderApp('/') + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) + }) + + it('FE-COMP-APP-028: falls back to the dashboard when the lookup fails', async () => { + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ + isLoaded: true, + settings: buildSettings({ start_page: 'active_trip' }), + }) + server.use(http.get('/api/trips/active', () => HttpResponse.error())) + + renderApp('/') + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) + }) + + // The whole point of the localStorage mirror: the default launch must not pay + // for a lookup it doesn't need. + it('FE-COMP-APP-029: never asks for the active trip when starting on the dashboard', async () => { + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'dashboard' }) }) + const calls = stubActiveTrip({ id: 42, title: 'Japan' }) + + renderApp('/') + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) + expect(calls).toHaveLength(0) + }) + + it('FE-COMP-APP-030: reads the preference from localStorage before settings have loaded', async () => { + localStorage.setItem('trek_start_page', 'active_trip') + localStorage.setItem('trek_start_trip_tab', 'finanzplan') + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ isLoaded: false }) + stubActiveTrip({ id: 42, title: 'Japan' }) + + renderApp('/') + await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()) + }) +}) + // ── ProtectedRoute — unauthenticated ────────────────────────────────────────── describe('ProtectedRoute — unauthenticated', () => { diff --git a/client/src/App.tsx b/client/src/App.tsx index ac01a09bb..4a5013acc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, ReactNode, Suspense } from 'react' +import React, { useEffect, useState, ReactNode, Suspense } from 'react' import { Routes, Route, Navigate, useLocation } from 'react-router' import { useAuthStore } from './store/authStore' import { useSettingsStore } from './store/settingsStore' @@ -19,7 +19,8 @@ import ErrorBoundary from './components/shared/ErrorBoundary' import { lazyWithRetry } from './utils/lazyWithRetry' import { useIsPhone } from './mobile/useIsPhone' import { TranslationProvider, useTranslation } from './i18n' -import { authApi } from './api/client' +import { authApi, tripsApi } from './api/client' +import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from './utils/startDestination' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers' @@ -172,10 +173,39 @@ function ViewportRoute({ phone: Phone, desktop: Desktop }: { return isPhone ? : } +/** + * The entry point every shortcut, bookmark and the installed PWA share + * (manifest start_url is '/'), which is why the startup destination is decided + * here rather than on /dashboard — landing on the dashboard directly has to keep + * working, or there would be no way back to it. + * + * The preference is read from the localStorage mirror, so the default path stays + * exactly as fast as before: no settings request to wait for. Only someone who + * asked for 'active_trip' pays for the one lookup that finds their trip, and a + * failure there just falls back to the dashboard. + */ function RootRedirect() { const { isAuthenticated, isLoading } = useAuthStore() + const settingsLoaded = useSettingsStore((s) => s.isLoaded) + const settings = useSettingsStore((s) => s.settings) + const [target, setTarget] = useState(null) - if (isLoading) { + useEffect(() => { + if (isLoading || !isAuthenticated || target) return + // Straight after login the real settings are already in the store, so use + // them; on a cold start they aren't, and waiting would defeat the point. + const { page, tab } = settingsLoaded + ? { page: settings.start_page ?? DEFAULT_START_PAGE, tab: settings.start_trip_tab ?? DEFAULT_START_TRIP_TAB } + : readStartDestination() + if (page !== 'active_trip') { setTarget('/dashboard'); return } + let cancelled = false + tripsApi.active() + .then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') }) + .catch(() => { if (!cancelled) setTarget('/dashboard') }) + return () => { cancelled = true } + }, [isLoading, isAuthenticated, settingsLoaded, target]) + + if (isLoading || (isAuthenticated && !target)) { return (
@@ -183,7 +213,7 @@ function RootRedirect() { ) } - return + return } /** diff --git a/client/src/api/client.ts b/client/src/api/client.ts index b23843bb9..dcb2da327 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -19,7 +19,7 @@ import { type TripAddMemberRequest, type TripTransferOwnershipRequest, type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest, type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, - type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, + type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse, type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest, type PlaceCreateRequest, type PlaceUpdateRequest, type ReservationCreateRequest, type ReservationUpdateRequest, @@ -372,6 +372,9 @@ export const tripsApi = { list: (params?: Record) => apiClient.get('/trips', { params }).then(r => r.data), create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data), get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data), + // The startup redirect's one lookup — deliberately not list(), which would pull + // every trip with its counts just to read one id. + active: (): Promise => apiClient.get('/trips/active').then(r => r.data), update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data), delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data), uploadCover: (id: number | string, formData: FormData) => postMultipart(`/trips/${id}/cover`, formData), diff --git a/client/src/pages/LoginPage.oidc-redirect.test.tsx b/client/src/pages/LoginPage.oidc-redirect.test.tsx index 780a88c57..37db7bfd5 100644 --- a/client/src/pages/LoginPage.oidc-redirect.test.tsx +++ b/client/src/pages/LoginPage.oidc-redirect.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '../../tests/helpers/render'; import { http, HttpResponse } from 'msw'; import { server } from '../../tests/helpers/msw/server'; import { resetAllStores } from '../../tests/helpers/store'; +import { START_DESTINATION_ROUTE } from '../utils/startDestination'; import LoginPage from './LoginPage'; const mockNavigate = vi.fn(); @@ -81,12 +82,12 @@ describe('LoginPage — OIDC redirect preservation', () => { expect(sessionStorage.getItem('oidc_redirect')).toBeNull(); }); - it('falls back to /dashboard when no sessionStorage redirect is set', async () => { + it('falls back to the startup destination when no sessionStorage redirect is set', async () => { setSearch('?oidc_code=testcode123'); render(); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + expect(mockNavigate).toHaveBeenCalledWith(START_DESTINATION_ROUTE, { replace: true }); }); }); }); diff --git a/client/src/pages/login/useLogin.test.tsx b/client/src/pages/login/useLogin.test.tsx index 80f33c838..3db9a55b7 100644 --- a/client/src/pages/login/useLogin.test.tsx +++ b/client/src/pages/login/useLogin.test.tsx @@ -11,6 +11,7 @@ import { TranslationProvider } from '../../i18n/TranslationContext'; import { useAuthStore, type LoginResult } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; import { startAuthentication } from '@simplewebauthn/browser'; +import { START_DESTINATION_ROUTE } from '../../utils/startDestination'; import { useLogin } from './useLogin'; const mockNavigate = vi.fn(); @@ -242,6 +243,30 @@ describe('useLogin — redirect target', () => { expect(sessionStorage.getItem('oidc_redirect')).toBeNull(); }); + + it('FE-LOGIN-HOOK-056: without a redirect param it defers to the startup destination, not the dashboard', async () => { + setSearch(''); + const { result } = renderLogin(); + await ready(result); + + // Nothing stashed: '/' is the resolver route, not a place worth returning to. + expect(sessionStorage.getItem('oidc_redirect')).toBeNull(); + + vi.useFakeTimers({ shouldAdvanceTime: true }); + await act(async () => { + await result.current.handleDemoLogin(); + }); + act(() => { vi.advanceTimersByTime(2600); }); + expect(mockNavigate).toHaveBeenCalledWith(START_DESTINATION_ROUTE); + }); + + it('FE-LOGIN-HOOK-057: an explicit redirect still beats the startup destination', async () => { + setSearch('?redirect=/trips/9%3Ftab%3Dfinanzplan'); + const { result } = renderLogin(); + await ready(result); + + expect(sessionStorage.getItem('oidc_redirect')).toBe('/trips/9?tab=finanzplan'); + }); }); describe('useLogin — invite links', () => { @@ -293,7 +318,7 @@ describe('useLogin — OIDC callback', () => { ); renderLogin(); - await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true })); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith(START_DESTINATION_ROUTE, { replace: true })); // Switching language rebuilds `t`, which the callback effect depends on. await act(async () => { @@ -507,7 +532,7 @@ describe('useLogin — passkey login', () => { act(() => { vi.advanceTimersByTime(2600); }); - expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + expect(mockNavigate).toHaveBeenCalledWith(START_DESTINATION_ROUTE); }); it.each(['NotAllowedError', 'AbortError'])( @@ -654,7 +679,7 @@ describe('useLogin — password submit', () => { act(() => { vi.advanceTimersByTime(2600); }); - expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + expect(mockNavigate).toHaveBeenCalledWith(START_DESTINATION_ROUTE); }); it('FE-LOGIN-HOOK-040: stops on a dropped Secure cookie instead of dead-ending later', async () => { @@ -790,7 +815,7 @@ describe('useLogin — MFA step', () => { act(() => { vi.advanceTimersByTime(2600); }); - expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + expect(mockNavigate).toHaveBeenCalledWith(START_DESTINATION_ROUTE); }); it('FE-LOGIN-HOOK-047: hands over to the password-change step after MFA when the account is flagged', async () => { @@ -888,7 +913,7 @@ describe('useLogin — forced password change', () => { act(() => { vi.advanceTimersByTime(2600); }); - expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + expect(mockNavigate).toHaveBeenCalledWith(START_DESTINATION_ROUTE); }); it('FE-LOGIN-HOOK-052: surfaces a rejected password change', async () => { diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index e8c642c03..a7f8bf378 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -6,6 +6,7 @@ import { useTranslation, detectBrowserLanguage } from '../../i18n' import { startAuthentication } from '@simplewebauthn/browser' import { authApi, configApi } from '../../api/client' import { getApiErrorMessage } from '../../types' +import { START_DESTINATION_ROUTE } from '../../utils/startDestination' interface AppConfig { has_users: boolean @@ -73,11 +74,13 @@ export function useLogin() { if (redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')) { return redirect } - return '/dashboard' + // No page to return to: hand the choice to RootRedirect, which is the one + // place that knows whether this user starts on the dashboard or in a trip. + return START_DESTINATION_ROUTE }, []) useEffect(() => { - if (redirectTarget !== '/dashboard') { + if (redirectTarget !== START_DESTINATION_ROUTE) { sessionStorage.setItem('oidc_redirect', redirectTarget) } }, [redirectTarget]) @@ -110,7 +113,7 @@ export function useLogin() { window.history.replaceState({}, '', '/login') if (data.token) { await loadUser() - const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard' + const savedRedirect = sessionStorage.getItem('oidc_redirect') || START_DESTINATION_ROUTE sessionStorage.removeItem('oidc_redirect') navigate(savedRedirect, { replace: true }) } else { diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 93ec69d96..48b161ebc 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -11,6 +11,7 @@ import { unregisterSyncTriggers } from '../sync/syncTriggers' import { useSystemNoticeStore } from './systemNoticeStore.js' import { clearAppearanceSnapshot } from '../theme/applyAppearance' import { clearAllPluginSessions } from './pluginStore' +import { forgetStartDestination } from '../utils/startDestination' interface AuthResponse { user: User @@ -199,6 +200,9 @@ export const useAuthStore = create()( // Same reason for the brokered plugin session state: it is keyed by user id, // but sessionStorage outlives a logout within the tab. clearAllPluginSessions() + // And the startup-destination mirror, or the next account on this browser + // gets bounced into a trip it may not even be able to see. + forgetStartDestination() // 4. Tell server to clear the httpOnly cookie (best-effort). await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) // 5. Clear service worker caches containing sensitive data. diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index c8bc6f8b6..2d9548f90 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -5,6 +5,7 @@ import { DEFAULT_APPEARANCE } from '@trek/shared' import { getApiErrorMessage } from '../types' import { SUPPORTED_LANGUAGE_CODES } from '../i18n/supportedLanguages' import { normalizeTileUrl } from '../utils/tileUrl' +import { rememberStartDestination, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../utils/startDestination' interface SettingsState { settings: Settings @@ -49,6 +50,8 @@ export const DEFAULT_SETTINGS: Settings = { mapbox_quality_mode: false, dashboard_fx_from: 'EUR', dashboard_fx_to: 'USD', + start_page: DEFAULT_START_PAGE, + start_trip_tab: DEFAULT_START_TRIP_TAB, appearance: DEFAULT_APPEARANCE, // dashboard_timezones is intentionally left unset so the widget can tell "never // chosen" (fall back to home + defaults) from an explicitly emptied list. @@ -84,6 +87,9 @@ export const useSettingsStore = create((set, get) => ({ settings: { ...state.settings, ...incoming }, isLoaded: true, })) + // The startup redirect runs before this ever resolves, so keep a mirror + // it can read synchronously on the next launch. + rememberStartDestination(incoming) } catch (err: unknown) { // Leave isLoaded false so a transient failure — offline at launch, or a // (docker) server cold-start racing the first request — is retried on @@ -104,6 +110,7 @@ export const useSettingsStore = create((set, get) => ({ settings: { ...state.settings, [key]: next }, })) if (key === 'language') localStorage.setItem('app_language', next as string) + rememberStartDestination({ [key]: next } as Partial) try { await settingsApi.set(key, next) } catch (err: unknown) { @@ -130,6 +137,7 @@ export const useSettingsStore = create((set, get) => ({ set((state) => ({ settings: { ...state.settings, ...patch }, })) + rememberStartDestination(patch) try { await settingsApi.setBulk(patch) } catch (err: unknown) { diff --git a/client/src/types.ts b/client/src/types.ts index 50283db18..7820afdcf 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -133,6 +133,10 @@ export interface Settings { dashboard_fx_from?: string dashboard_fx_to?: string dashboard_timezones?: string[] + /** Where opening TREK lands: the dashboard, or straight in the active trip. */ + start_page?: 'dashboard' | 'active_trip' + /** Which planner tab 'active_trip' opens on — a TripTabId (constants/tripTabs). */ + start_trip_tab?: string // AI booking-import fallback (per-user config; used when the admin has not set // instance-wide config on the llm_parsing addon). llm_api_key is masked on read. llm_provider?: 'local' | 'openai' | 'anthropic' diff --git a/client/src/utils/startDestination.ts b/client/src/utils/startDestination.ts new file mode 100644 index 000000000..ea5d461b1 --- /dev/null +++ b/client/src/utils/startDestination.ts @@ -0,0 +1,89 @@ +import { isTripTabId } from '../constants/tripTabs' +import type { Settings } from '../types' + +/** + * Where opening TREK lands. 'dashboard' is what TREK has always done; with + * 'active_trip' the app skips straight into the trip the dashboard would + * feature, on the tab picked below — three clicks and three loads less for + * someone entering expenses while they're actually travelling. + */ +export type StartPage = 'dashboard' | 'active_trip' + +export const DEFAULT_START_PAGE: StartPage = 'dashboard' +export const DEFAULT_START_TRIP_TAB = 'plan' + +/** + * The route that resolves the destination (RootRedirect in App.tsx). Anywhere + * that used to navigate to '/dashboard' for lack of a better idea sends people + * here instead, so the preference is applied in exactly one place. + */ +export const START_DESTINATION_ROUTE = '/' + +export interface StartDestination { + page: StartPage + tab: string +} + +// Mirrored in localStorage, not read from the settings store, because the +// redirect has to be decided on the first paint — waiting for GET /api/settings +// would put a spinner in front of every launch, including the default one that +// isn't redirecting anywhere. Same trick as 'app_language' and the theme boot +// snapshot: the server's answer wins on the next load, this is only what we +// knew last time. +const PAGE_KEY = 'trek_start_page' +const TAB_KEY = 'trek_start_trip_tab' + +const isStartPage = (value: unknown): value is StartPage => + value === 'dashboard' || value === 'active_trip' + +export function readStartDestination(): StartDestination { + try { + const page = localStorage.getItem(PAGE_KEY) + const tab = localStorage.getItem(TAB_KEY) + return { + page: isStartPage(page) ? page : DEFAULT_START_PAGE, + tab: isTripTabId(tab) ? tab : DEFAULT_START_TRIP_TAB, + } + } catch { + // Private-mode Safari and friends throw on access, not just on write. + return { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB } + } +} + +/** Called whenever the settings store learns a value — load, single or bulk save. */ +export function rememberStartDestination(settings: Partial): void { + try { + if ('start_page' in settings) { + const page = settings.start_page + if (isStartPage(page)) localStorage.setItem(PAGE_KEY, page) + else localStorage.removeItem(PAGE_KEY) + } + if ('start_trip_tab' in settings) { + const tab = settings.start_trip_tab + if (isTripTabId(tab)) localStorage.setItem(TAB_KEY, tab) + else localStorage.removeItem(TAB_KEY) + } + } catch { + // A browser that won't persist this just falls back to the dashboard. + } +} + +/** On logout, so the next account on this browser starts on its own preference. */ +export function forgetStartDestination(): void { + try { + localStorage.removeItem(PAGE_KEY) + localStorage.removeItem(TAB_KEY) + } catch { + // Nothing to clean up if storage is unavailable. + } +} + +/** + * The planner URL for a startup redirect. The tab always goes on the query + * string, including 'plan' — leaving it off would hand the decision back to + * whichever tab the last session happened to end on. + */ +export function tripStartPath(tripId: number, tab: string): string { + const safeTab = isTripTabId(tab) ? tab : DEFAULT_START_TRIP_TAB + return `/trips/${tripId}?tab=${safeTab}` +} diff --git a/client/tests/unit/utils/startDestination.test.ts b/client/tests/unit/utils/startDestination.test.ts new file mode 100644 index 000000000..4de37375e --- /dev/null +++ b/client/tests/unit/utils/startDestination.test.ts @@ -0,0 +1,82 @@ +// FE-UTIL-START-001 to FE-UTIL-START-009 +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { + readStartDestination, + rememberStartDestination, + forgetStartDestination, + tripStartPath, + DEFAULT_START_PAGE, + DEFAULT_START_TRIP_TAB, +} from '../../../src/utils/startDestination' + +beforeEach(() => { + localStorage.clear() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('readStartDestination', () => { + it('FE-UTIL-START-001: falls back to the dashboard when nothing was ever mirrored', () => { + expect(readStartDestination()).toEqual({ page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB }) + }) + + it('FE-UTIL-START-002: returns what was mirrored', () => { + rememberStartDestination({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }) + expect(readStartDestination()).toEqual({ page: 'active_trip', tab: 'finanzplan' }) + }) + + // A value a user (or an older/newer build) could have written by hand must not + // become a route we then navigate to. + it('FE-UTIL-START-003: ignores values that are not a known page or tab', () => { + localStorage.setItem('trek_start_page', 'somewhere_else') + localStorage.setItem('trek_start_trip_tab', '../../etc/passwd') + expect(readStartDestination()).toEqual({ page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB }) + }) + + it('FE-UTIL-START-004: survives a browser that refuses storage access', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError') + }) + expect(readStartDestination()).toEqual({ page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB }) + }) +}) + +describe('rememberStartDestination', () => { + it('FE-UTIL-START-005: only touches the keys present in the patch', () => { + rememberStartDestination({ start_page: 'active_trip', start_trip_tab: 'dateien' }) + rememberStartDestination({ start_trip_tab: 'buchungen' }) + expect(readStartDestination()).toEqual({ page: 'active_trip', tab: 'buchungen' }) + }) + + it('FE-UTIL-START-006: clears a key that comes back invalid instead of keeping a stale one', () => { + rememberStartDestination({ start_page: 'active_trip' }) + rememberStartDestination({ start_page: undefined }) + expect(localStorage.getItem('trek_start_page')).toBeNull() + }) + + it('FE-UTIL-START-007: swallows a quota error rather than failing the settings save', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError') + }) + expect(() => rememberStartDestination({ start_page: 'active_trip' })).not.toThrow() + }) +}) + +describe('forgetStartDestination', () => { + it('FE-UTIL-START-008: leaves the next account on this browser with the default', () => { + rememberStartDestination({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }) + forgetStartDestination() + expect(readStartDestination()).toEqual({ page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB }) + }) +}) + +describe('tripStartPath', () => { + it('FE-UTIL-START-009: always spells the tab out, and never a tab that does not exist', () => { + // Explicit even for 'plan', or the last session's tab would decide instead. + expect(tripStartPath(7, 'plan')).toBe('/trips/7?tab=plan') + expect(tripStartPath(7, 'finanzplan')).toBe('/trips/7?tab=finanzplan') + expect(tripStartPath(7, 'nonsense')).toBe('/trips/7?tab=plan') + }) +})