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".
This commit is contained in:
Maurice
2026-08-08 21:03:49 +02:00
committed by Maurice
parent 688ce0fae2
commit 44eff7177e
11 changed files with 339 additions and 15 deletions
+75
View File
@@ -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', () => {
+34 -4
View File
@@ -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 ? <Phone /> : <Desktop />
}
/**
* 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<string | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
@@ -183,7 +213,7 @@ function RootRedirect() {
)
}
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />
return <Navigate to={isAuthenticated ? (target ?? '/dashboard') : '/login'} replace />
}
/**
+4 -1
View File
@@ -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<string, unknown>) => 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<ActiveTripResponse> => 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),
@@ -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(<LoginPage />);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true });
expect(mockNavigate).toHaveBeenCalledWith(START_DESTINATION_ROUTE, { replace: true });
});
});
});
+30 -5
View File
@@ -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 () => {
+6 -3
View File
@@ -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 {
+4
View File
@@ -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<AuthState>()(
// 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.
+8
View File
@@ -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<SettingsState>((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<SettingsState>((set, get) => ({
settings: { ...state.settings, [key]: next },
}))
if (key === 'language') localStorage.setItem('app_language', next as string)
rememberStartDestination({ [key]: next } as Partial<Settings>)
try {
await settingsApi.set(key, next)
} catch (err: unknown) {
@@ -130,6 +137,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
set((state) => ({
settings: { ...state.settings, ...patch },
}))
rememberStartDestination(patch)
try {
await settingsApi.setBulk(patch)
} catch (err: unknown) {
+4
View File
@@ -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'
+89
View File
@@ -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<Settings>): 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}`
}
@@ -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')
})
})