fix(planner): road trip mode, the dawarich trail and the route overview follow the account to another browser

The three planner view switches lived in sessionStorage only, so a new tab,
another browser or another device opened every trip on the plain view again.
They are now kept per trip in one user setting, planner_trip_views, validated
against a contract in @trek/shared. The server keeps only switches that are on
and only trips the user can still open, so a deleted trip or one the user left
drops out on the next write. The tab copy in sessionStorage stays and answers
until the settings have loaded.

Refs #2639
This commit is contained in:
Maurice
2026-10-12 17:41:56 +02:00
parent adb88142ab
commit fbba8f96b7
18 changed files with 502 additions and 51 deletions
@@ -1,11 +1,12 @@
// FE-TP-MAPVIEW-001 to FE-TP-MAPVIEW-012
// FE-TP-MAPVIEW-001 to FE-TP-MAPVIEW-013
//
// The planner's map view switches and its first fit, driven straight through
// usePlannerMapView. Every toggle is called here, together with the storage each one
// remembers its state in: the planner tests only ever read these values.
import { act, renderHook } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace, buildTrip } from '../../../tests/helpers/factories'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildPlace, buildSettings, buildTrip } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
import type { Place, Trip } from '../../types'
import { usePlannerMapView } from './usePlannerMapView'
@@ -103,18 +104,21 @@ describe('usePlannerMapView', () => {
expect(result.current.routeProfile).toBe('plugin:bikes/gravel')
})
it('FE-TP-MAPVIEW-007: the whole-trip overview flips and is kept per trip for the session', () => {
it('FE-TP-MAPVIEW-007: the whole-trip overview flips and is kept per trip, in the tab and on the account', () => {
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true })
const { result } = renderMapView()
act(() => { result.current.toggleOverview() })
expect(result.current.overviewShown).toBe(true)
expect(sessionStorage.getItem('trip-overview-7')).toBe('1')
expect(useSettingsStore.getState().settings.planner_trip_views).toEqual({ '7': { overview: true } })
expect(renderMapView().result.current.overviewShown).toBe(true)
expect(renderMapView({ tripId: 8 }).result.current.overviewShown).toBe(false)
act(() => { result.current.toggleOverview() })
expect(result.current.overviewShown).toBe(false)
expect(sessionStorage.getItem('trip-overview-7')).toBe('0')
expect(useSettingsStore.getState().settings.planner_trip_views).toEqual({})
})
it('FE-TP-MAPVIEW-008: the map lock flips, is read through its ref and is remembered by the browser', () => {
@@ -160,12 +164,14 @@ describe('usePlannerMapView', () => {
expect(result.current.isMobileRef.current).toBe(true)
})
it('FE-TP-MAPVIEW-011: the recorded trail flips and is kept per trip for the session', () => {
it('FE-TP-MAPVIEW-011: the recorded trail flips and is kept per trip, in the tab and on the account', () => {
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true })
const { result } = renderMapView()
act(() => { result.current.toggleDawarichTrail() })
expect(result.current.dawarichTrailShown).toBe(true)
expect(sessionStorage.getItem('trip-dawarich-7')).toBe('1')
expect(useSettingsStore.getState().settings.planner_trip_views).toEqual({ '7': { dawarich: true } })
// The addon is off in this store, so switching the layer on asks nothing of the network.
expect(result.current.dawarichTrail.status).toBe('idle')
expect(renderMapView().result.current.dawarichTrailShown).toBe(true)
@@ -175,6 +181,19 @@ describe('usePlannerMapView', () => {
expect(sessionStorage.getItem('trip-dawarich-7')).toBe('0')
})
it('FE-TP-MAPVIEW-013: the overview and the trail switched on in another browser are on here too (#2639)', () => {
// A fresh tab: nothing in sessionStorage, only the account's settings.
seedStore(useSettingsStore, {
settings: buildSettings({ planner_trip_views: { '7': { overview: true, dawarich: true } } }),
isLoaded: true,
})
const { result } = renderMapView()
expect(result.current.overviewShown).toBe(true)
expect(result.current.dawarichTrailShown).toBe(true)
expect(renderMapView({ tripId: 8 }).result.current.overviewShown).toBe(false)
})
it('FE-TP-MAPVIEW-012: each trip gets one automatic fit, once it has a place to fit to', () => {
const tripA = buildTrip({ id: 1 })
const tripB = buildTrip({ id: 2 })
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
import { usePlannerTripView } from './usePlannerTripView'
import type { TripStoreState } from '../../store/tripStore'
import type { Place } from '../../types'
import type { PlannerBase } from './plannerTypes'
@@ -52,16 +53,10 @@ export function usePlannerMapView(options: PlannerMapViewOptions) {
const transitRoutesShown = routeShown && selectedDayId != null
const [routeProfile, setRouteProfile] = useState<string>('driving')
// Whole-trip route overview (#1736): every day's route at once, each in its own
// colour. Per trip and per session like road trip mode: it answers "what does the
// whole thing look like", which is a question you ask of one trip, not a preference.
const [overviewShown, setOverviewShown] = useState<boolean>(() => sessionStorage.getItem(`trip-overview-${tripId}`) === '1')
const toggleOverview = useCallback(() => {
setOverviewShown(prev => {
const next = !prev
sessionStorage.setItem(`trip-overview-${tripId}`, next ? '1' : '0')
return next
})
}, [tripId])
// colour. Per trip like road trip mode, and remembered on the account the same
// way (#2639): it answers "what does the whole thing look like", which is a
// question you ask of one trip, not a preference.
const [overviewShown, toggleOverview] = usePlannerTripView(tripId, 'overview')
// A locked map stays where the traveller put it (#2010): picking a day or a place no
// longer zooms or pans it. Remembered per browser, like the other view toggles; the
// first frame of a trip still fits, or the map would open on nothing.
@@ -79,19 +74,11 @@ export function usePlannerMapView(options: PlannerMapViewOptions) {
return next
})
}, [])
// The recorded route from Dawarich (#2279), per trip and per session for the
// same reason as the overview above: it answers "what actually happened on
// this trip", which is a question about one trip rather than a preference.
const [dawarichTrailShown, setDawarichTrailShown] = useState<boolean>(
() => sessionStorage.getItem(`trip-dawarich-${tripId}`) === '1',
)
const toggleDawarichTrail = useCallback(() => {
setDawarichTrailShown(prev => {
const next = !prev
sessionStorage.setItem(`trip-dawarich-${tripId}`, next ? '1' : '0')
return next
})
}, [tripId])
// The recorded route from Dawarich (#2279), per trip and remembered on the
// account for the same reason as the overview above: it answers "what
// actually happened on this trip", a question about one trip rather than a
// preference.
const [dawarichTrailShown, toggleDawarichTrail] = usePlannerTripView(tripId, 'dawarich')
// Fetched here rather than in MapViewAuto so the desktop and the phone share
// one request, and so the pill that toggles it can show why there is no line.
const dawarichTrail = useDawarichTrail(tripId, dawarichTrailShown)
@@ -0,0 +1,113 @@
// FE-TP-TRIPVIEW-001 to FE-TP-TRIPVIEW-006
//
// The planner's view switches the account remembers per trip (#2639): road trip
// mode, the Dawarich trail and the route overview. The tab keeps a copy that
// answers before the settings arrive.
import { act, renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildSettings } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
import { usePlannerTripView } from './usePlannerTripView'
function captureSettingWrites() {
const writes: Array<{ key: string; value: unknown }> = []
server.use(http.put('/api/settings', async ({ request }) => {
const body = await request.json() as { key: string; value: unknown }
writes.push(body)
return HttpResponse.json({ success: true, key: body.key, value: body.value })
}))
return writes
}
beforeEach(() => {
resetAllStores()
})
describe('usePlannerTripView', () => {
it('FE-TP-TRIPVIEW-001: a fresh browser opens the trip with the switches the account has on', () => {
seedStore(useSettingsStore, {
settings: buildSettings({ planner_trip_views: { '42': { roadtrip: true } } }),
isLoaded: true,
})
expect(renderHook(() => usePlannerTripView(42, 'roadtrip')).result.current[0]).toBe(true)
expect(renderHook(() => usePlannerTripView(42, 'dawarich')).result.current[0]).toBe(false)
expect(renderHook(() => usePlannerTripView(43, 'roadtrip')).result.current[0]).toBe(false)
})
it('FE-TP-TRIPVIEW-002: the tab copy answers until the settings are here, then the account decides', () => {
sessionStorage.setItem('trip-roadtrip-42', '1')
const { result } = renderHook(() => usePlannerTripView(42, 'roadtrip'))
expect(result.current[0]).toBe(true)
// Switched off on another device since this tab last looked.
act(() => { seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }) })
expect(result.current[0]).toBe(false)
})
it('FE-TP-TRIPVIEW-003: a switch is saved on the account next to the other trips, and only what is on', async () => {
const writes = captureSettingWrites()
seedStore(useSettingsStore, {
settings: buildSettings({ planner_trip_views: { '7': { overview: true }, '42': { dawarich: true } } }),
isLoaded: true,
})
const { result } = renderHook(() => usePlannerTripView(42, 'roadtrip'))
act(() => { result.current[1]() })
expect(result.current[0]).toBe(true)
expect(sessionStorage.getItem('trip-roadtrip-42')).toBe('1')
await waitFor(() => expect(writes).toHaveLength(1))
expect(writes[0]).toEqual({
key: 'planner_trip_views',
value: { '7': { overview: true }, '42': { dawarich: true, roadtrip: true } },
})
act(() => { result.current[1]() })
expect(result.current[0]).toBe(false)
await waitFor(() => expect(writes).toHaveLength(2))
expect(writes[1].value).toEqual({ '7': { overview: true }, '42': { dawarich: true } })
})
it('FE-TP-TRIPVIEW-004: a switch made before the settings arrived waits for them instead of erasing other trips', async () => {
const writes = captureSettingWrites()
server.use(http.get('/api/settings', () => HttpResponse.json({
settings: buildSettings({ planner_trip_views: { '7': { overview: true } } }),
})))
const { result } = renderHook(() => usePlannerTripView(42, 'dawarich'))
act(() => { result.current[1]() })
expect(result.current[0]).toBe(true)
await waitFor(() => expect(writes).toHaveLength(1))
expect(writes[0].value).toEqual({ '7': { overview: true }, '42': { dawarich: true } })
expect(result.current[0]).toBe(true)
})
it('FE-TP-TRIPVIEW-005: a save that fails keeps the switch for the session', async () => {
let tried = false
server.use(http.put('/api/settings', () => {
tried = true
return HttpResponse.json({ error: 'nope' }, { status: 500 })
}))
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true })
const { result } = renderHook(() => usePlannerTripView(42, 'overview'))
act(() => { result.current[1]() })
await waitFor(() => expect(tried).toBe(true))
await act(async () => { await Promise.resolve() })
expect(result.current[0]).toBe(true)
expect(sessionStorage.getItem('trip-overview-42')).toBe('1')
})
it('FE-TP-TRIPVIEW-006: settings that cannot be loaded leave the tab copy in charge and write nothing', async () => {
const writes = captureSettingWrites()
server.use(http.get('/api/settings', () => HttpResponse.json({ error: 'offline' }, { status: 503 })))
vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => usePlannerTripView(42, 'roadtrip'))
act(() => { result.current[1]() })
await act(async () => { await new Promise(r => setTimeout(r, 20)) })
expect(result.current[0]).toBe(true)
expect(writes).toHaveLength(0)
})
})
@@ -0,0 +1,70 @@
import { useCallback, useState } from 'react'
import { PLANNER_TRIP_VIEWS_KEY, compactPlannerTripViews, type PlannerTripViewFlag } from '@trek/shared'
import { useSettingsStore } from '../../store/settingsStore'
function readSession(key: string): boolean | null {
try {
const raw = sessionStorage.getItem(key)
return raw === '1' ? true : raw === '0' ? false : null
} catch {
return null
}
}
function writeSession(key: string, on: boolean): void {
try { sessionStorage.setItem(key, on ? '1' : '0') } catch { /* the account row still has it */ }
}
interface TabCopy {
key: string
on: boolean | null
/** A switch made here that the account row has not taken yet. */
pending: boolean
}
/**
* One of the planner's remembered view switches: road trip mode, the Dawarich
* trail or the route overview (#2639). The account keeps them per trip, so they
* are back in another browser or on another device. The tab keeps a copy of
* its own that answers until the account's settings have arrived, so a reload
* does not open on the plain view and then jump. Once they are here they
* decide, because a switch flipped on another device is newer than this tab.
*/
export function usePlannerTripView(tripId: number, flag: PlannerTripViewFlag): [boolean, () => void] {
const sessionKey = `trip-${flag}-${tripId}`
const saved = useSettingsStore(s => s.settings.planner_trip_views?.[String(tripId)]?.[flag] === true)
const loaded = useSettingsStore(s => s.isLoaded)
const [local, setLocal] = useState<TabCopy>(() => ({ key: sessionKey, on: readSession(sessionKey), pending: false }))
// A different trip in the same planner reads its own copy instead of keeping this one.
const copy = local.key === sessionKey ? local : { key: sessionKey, on: readSession(sessionKey), pending: false }
const shown = loaded && !copy.pending ? saved : (copy.on ?? saved)
const toggle = useCallback(() => {
const next = !shown
setLocal({ key: sessionKey, on: next, pending: true })
writeSession(sessionKey, next)
void remember(tripId, flag, next).finally(() => {
setLocal(prev => (prev.key === sessionKey && prev.on === next ? { ...prev, pending: false } : prev))
})
}, [shown, sessionKey, tripId, flag])
return [shown, toggle]
}
async function remember(tripId: number, flag: PlannerTripViewFlag, on: boolean): Promise<void> {
const store = useSettingsStore.getState()
// The row holds every trip, so it is only written over once the account's
// own copy is here; otherwise one switch would erase all the others. The
// store takes the new value before the request goes out, so an offline save
// still shows for the rest of the session.
if (!store.isLoaded) await store.loadSettings()
const { settings, isLoaded, updateSetting } = useSettingsStore.getState()
if (!isLoaded) return
const views = { ...settings.planner_trip_views }
views[String(tripId)] = { ...views[String(tripId)], [flag]: on }
try {
await updateSetting(PLANNER_TRIP_VIEWS_KEY, compactPlannerTripViews(views))
} catch {
// Offline or refused: the store and the tab copy keep the switch for this session.
}
}
@@ -344,8 +344,11 @@ beforeEach(() => {
}) as unknown as typeof window.__addToast
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true, placesPhotosEnabled: false })
// The two switches this whole file depends on.
// The two switches this whole file depends on. The mode sits on the account as well
// as in the tab (#2639), with the settings already loaded, so a toggle saves straight
// away instead of leaving a settings fetch behind that lands in the next test.
sessionStorage.setItem('trip-roadtrip-42', '1')
useSettingsStore.setState(s => ({ isLoaded: true, settings: { ...s.settings, planner_trip_views: { '42': { roadtrip: true } } } }))
vi.spyOn(addonsApi, 'enabled').mockResolvedValue({ addons: [{ id: 'roadtrip' }] } as never)
vi.spyOn(authApi, 'getAppConfig').mockResolvedValue({})
+5 -11
View File
@@ -54,6 +54,7 @@ import { usePlaceEdits } from './usePlaceEdits'
import { usePlaceFormOpeners } from './usePlaceFormOpeners'
import { useDayPlanEdits } from './useDayPlanEdits'
import { useBookingEdits } from './useBookingEdits'
import { usePlannerTripView } from './usePlannerTripView'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
@@ -116,10 +117,10 @@ export function useTripPlanner() {
// asked to open ('collab' in particular, guessed off) survives the gap.
const [addonsLoaded, setAddonsLoaded] = useState<boolean>(false)
// Road trip mode swaps the plan view's left rail (and later its map layer) for the
// drive-first reading of the same trip. Per trip and per session, like the tab choice:
// someone planning a road trip stays in it across reloads without it leaking into
// their next, non-driving trip.
const [storedRoadtripMode, setRoadtripMode] = useState<boolean>(() => sessionStorage.getItem(`trip-roadtrip-${tripId}`) === '1')
// drive-first reading of the same trip. Remembered per trip on the account (#2639):
// someone planning a road trip finds it again in another browser without it
// leaking into their next, non-driving trip.
const [storedRoadtripMode, toggleRoadtripMode] = usePlannerTripView(tripId, 'roadtrip')
// Declared here rather than with the other layout state further down, because
// road-trip mode is decided on it and the assignment and place lists below are
// decided on that. One subscriber for the whole hook.
@@ -151,13 +152,6 @@ export function useTripPlanner() {
Object.entries(storedAssignments).map(([dayId, visits]) => [dayId, visits.filter(v => !hidden(v))]),
)
}, [roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, storedAssignments])
const toggleRoadtripMode = useCallback(() => {
setRoadtripMode(prev => {
const next = !prev
sessionStorage.setItem(`trip-roadtrip-${tripId}`, next ? '1' : '0')
return next
})
}, [tripId])
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; links: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, links: true, polls: true, whatsnext: true })
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
const places = useMemo(
+3
View File
@@ -6,6 +6,7 @@
// each documented against the producing service). Re-exported here so the rest
// of the client keeps importing from '../types' unchanged.
import type {
PlannerTripViews,
TrekWsEventName,
TrekWsPluginEventName,
Trip,
@@ -237,6 +238,8 @@ export interface Settings {
start_page?: 'dashboard' | 'active_trip'
/** Which planner tab 'active_trip' opens on — a TripTabId (constants/tripTabs). */
start_trip_tab?: string
/** Road trip mode, the Dawarich trail and the route overview per trip, only those that are on (#2639). */
planner_trip_views?: PlannerTripViews
// 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'