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