mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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({})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user