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') + }) +})