refactor(client): the planner map view toggles and first fit move into usePlannerMapView

The day route toggle and its travel profile, the whole-trip overview, the
map lock, the recorded Dawarich trail, the fit key and the effect that
fits each trip once move out of useTripPlanner into usePlannerMapView.
A new test calls every toggle and the automatic route, which no test
reached before.

useTripPlanner calls the hook where that code used to sit, so the
Dawarich fetch and the first fit still run in the same order and the
planner returns the same object. handleSelectDay now lists the map lock
ref, the phone ref and setFitKey it receives from the hook; they never
change identity, so nothing behaves differently.
This commit is contained in:
Maurice
2026-10-08 01:15:47 +00:00
parent 87ca3df781
commit 41dabbda6e
3 changed files with 329 additions and 92 deletions
@@ -0,0 +1,204 @@
// FE-TP-MAPVIEW-001 to FE-TP-MAPVIEW-012
//
// 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 type { Place, Trip } from '../../types'
import { usePlannerMapView } from './usePlannerMapView'
interface Props {
tripId: number
trip: Trip | null
places: Place[]
selectedDayId: number | null
isMobile: boolean
}
function renderMapView(initial: Partial<Props> = {}) {
const initialProps: Props = { tripId: 7, trip: null, places: [], selectedDayId: null, isMobile: false, ...initial }
return renderHook((props: Props) => usePlannerMapView(props), { initialProps })
}
beforeEach(() => {
vi.restoreAllMocks()
resetAllStores()
})
describe('usePlannerMapView', () => {
it('FE-TP-MAPVIEW-001: a trip nobody has looked at opens with every layer off and the map free', () => {
const { result } = renderMapView()
expect(result.current.routeShown).toBe(false)
expect(result.current.transitRoutesShown).toBe(false)
expect(result.current.routeProfile).toBe('driving')
expect(result.current.overviewShown).toBe(false)
expect(result.current.mapLocked).toBe(false)
expect(result.current.mapLockedRef.current).toBe(false)
expect(result.current.dawarichTrailShown).toBe(false)
expect(result.current.dawarichTrail.status).toBe('idle')
expect(result.current.fitKey).toBe(0)
})
it('FE-TP-MAPVIEW-002: the day route remembers an explicit choice per trip, given as a value or an updater', () => {
const { result, rerender } = renderMapView()
act(() => { result.current.setRouteShown(true) })
expect(result.current.routeShown).toBe(true)
expect(localStorage.getItem('trek:day-route:7')).toBe('true')
// No day selected: the transit lines stay off whatever the toggle says (#2019).
expect(result.current.transitRoutesShown).toBe(false)
rerender({ tripId: 7, trip: null, places: [], selectedDayId: 3, isMobile: false })
expect(result.current.transitRoutesShown).toBe(true)
act(() => { result.current.setRouteShown(prev => !prev) })
expect(result.current.routeShown).toBe(false)
expect(localStorage.getItem('trek:day-route:7')).toBe('false')
expect(result.current.transitRoutesShown).toBe(false)
})
it('FE-TP-MAPVIEW-003: a stored choice comes back with the trip, and anything else counts as no choice', () => {
localStorage.setItem('trek:day-route:7', 'true')
localStorage.setItem('trek:day-route:8', 'false')
localStorage.setItem('trek:day-route:9', 'maybe')
expect(renderMapView({ tripId: 7 }).result.current.routeShown).toBe(true)
expect(renderMapView({ tripId: 8 }).result.current.routeShown).toBe(false)
const unknown = renderMapView({ tripId: 9 }).result
expect(unknown.current.routeShown).toBe(false)
act(() => { unknown.current.autoShowRoute() })
expect(unknown.current.routeShown).toBe(true)
})
it('FE-TP-MAPVIEW-004: the automatic route is a default that never overrides an explicit off or reaches storage', () => {
const fresh = renderMapView().result
act(() => { fresh.current.autoShowRoute() })
expect(fresh.current.routeShown).toBe(true)
expect(localStorage.getItem('trek:day-route:7')).toBeNull()
localStorage.setItem('trek:day-route:8', 'false')
const declined = renderMapView({ tripId: 8 }).result
act(() => { declined.current.autoShowRoute() })
expect(declined.current.routeShown).toBe(false)
expect(localStorage.getItem('trek:day-route:8')).toBe('false')
})
it('FE-TP-MAPVIEW-005: without a trip id the route toggle works but is remembered nowhere', () => {
const setItem = vi.spyOn(Storage.prototype, 'setItem')
const { result } = renderMapView({ tripId: Number.NaN })
act(() => { result.current.setRouteShown(true) })
expect(result.current.routeShown).toBe(true)
expect(setItem).not.toHaveBeenCalled()
})
it('FE-TP-MAPVIEW-006: the route profile is a plain per-session choice', () => {
const { result } = renderMapView()
act(() => { result.current.setRouteProfile('plugin:bikes/gravel') })
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', () => {
const { result } = renderMapView()
act(() => { result.current.toggleOverview() })
expect(result.current.overviewShown).toBe(true)
expect(sessionStorage.getItem('trip-overview-7')).toBe('1')
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')
})
it('FE-TP-MAPVIEW-008: the map lock flips, is read through its ref and is remembered by the browser', () => {
const { result } = renderMapView()
act(() => { result.current.toggleMapLocked() })
expect(result.current.mapLocked).toBe(true)
expect(result.current.mapLockedRef.current).toBe(true)
expect(localStorage.getItem('trek:map-locked')).toBe('1')
// A browser-wide choice, so another trip opens locked too.
expect(renderMapView({ tripId: 8 }).result.current.mapLocked).toBe(true)
act(() => { result.current.toggleMapLocked() })
expect(result.current.mapLocked).toBe(false)
expect(result.current.mapLockedRef.current).toBe(false)
expect(localStorage.getItem('trek:map-locked')).toBe('0')
})
it('FE-TP-MAPVIEW-009: a storage that refuses the lock still leaves a working toggle', () => {
const realGet = Storage.prototype.getItem
const realSet = Storage.prototype.setItem
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(function (this: Storage, key: string) {
if (key === 'trek:map-locked') throw new DOMException('denied', 'SecurityError')
return realGet.call(this, key)
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) {
if (key === 'trek:map-locked') throw new DOMException('denied', 'SecurityError')
realSet.call(this, key, value)
})
const { result } = renderMapView()
expect(result.current.mapLocked).toBe(false)
act(() => { result.current.toggleMapLocked() })
expect(result.current.mapLocked).toBe(true)
})
it('FE-TP-MAPVIEW-010: the phone flag is read through a ref that follows every render', () => {
const { result, rerender } = renderMapView()
expect(result.current.isMobileRef.current).toBe(false)
rerender({ tripId: 7, trip: null, places: [], selectedDayId: null, isMobile: true })
expect(result.current.isMobileRef.current).toBe(true)
})
it('FE-TP-MAPVIEW-011: the recorded trail flips and is kept per trip for the session', () => {
const { result } = renderMapView()
act(() => { result.current.toggleDawarichTrail() })
expect(result.current.dawarichTrailShown).toBe(true)
expect(sessionStorage.getItem('trip-dawarich-7')).toBe('1')
// 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)
act(() => { result.current.toggleDawarichTrail() })
expect(result.current.dawarichTrailShown).toBe(false)
expect(sessionStorage.getItem('trip-dawarich-7')).toBe('0')
})
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 })
const unplaced = buildPlace({ lat: null, lng: null })
const placed = buildPlace({ lat: 46.95, lng: 7.45 })
const { result, rerender } = renderMapView({ places: [placed] })
const show = (over: Partial<Props>) => rerender({ tripId: 7, trip: null, places: [], selectedDayId: null, isMobile: false, ...over })
// No trip loaded yet: nothing to fit.
expect(result.current.fitKey).toBe(0)
show({ trip: tripA, places: [unplaced] })
expect(result.current.fitKey).toBe(0)
show({ trip: tripA, places: [unplaced, placed] })
expect(result.current.fitKey).toBe(1)
show({ trip: tripA, places: [placed] })
expect(result.current.fitKey).toBe(1)
show({ trip: tripB, places: [placed] })
expect(result.current.fitKey).toBe(2)
act(() => { result.current.setFitKey(k => k + 1) })
expect(result.current.fitKey).toBe(3)
})
})
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
import type { TripStoreState } from '../../store/tripStore'
import type { Place } from '../../types'
import type { PlannerBase } from './plannerTypes'
interface PlannerMapViewOptions extends Pick<PlannerBase, 'tripId' | 'trip'> {
places: Place[]
selectedDayId: TripStoreState['selectedDayId']
isMobile: boolean
}
/**
* How the planner's map is shown: the day route toggle and its travel profile, the
* whole-trip overview, the map lock, the recorded Dawarich trail, and the fit key
* with the one automatic fit each trip gets when it first has places to show.
*/
export function usePlannerMapView(options: PlannerMapViewOptions) {
const { tripId, trip, places, selectedDayId, isMobile } = options
// Manual route planning: off by default, toggled from the day-plan footer. Mode
// is per-session and selects which travel time the connectors show: either a
// built-in OSRM profile or a plugin route profile ('plugin:<id>/<profile>').
// Per-trip route visibility. `null` = the user has never said anything, which
// is what lets the mobile map switch it on by default; an explicit false has to
// survive every later map entry, and it used to be clobbered on each one (#2003).
const routeStorageKey = tripId ? `trek:day-route:${tripId}` : null
const [routeChoice, setRouteChoice] = useState<boolean | null>(() => {
if (typeof window === 'undefined' || !routeStorageKey) return null
const raw = window.localStorage.getItem(routeStorageKey)
return raw === 'true' ? true : raw === 'false' ? false : null
})
const routeShown = routeChoice === true
const setRouteShown = useCallback((v: boolean | ((prev: boolean) => boolean)) => {
setRouteChoice(prev => {
const next = typeof v === 'function' ? v(prev === true) : v
if (routeStorageKey && typeof window !== 'undefined') {
window.localStorage.setItem(routeStorageKey, String(next))
}
return next
})
}, [routeStorageKey])
// The mobile map opens with the day's route drawn. That is a default, not a choice,
// so it never overwrites an explicit off and is never written to storage itself.
const autoShowRoute = useCallback(() => {
setRouteChoice(prev => (prev === null ? true : prev))
}, [])
// What the planner maps actually draw. The persisted toggle can rehydrate as
// true while no day is selected yet (trip re-entry resets the selection, and
// a second click on the day header clears it). Without a day context the
// per-day transit filter is off, so the map would draw every automated
// transport in the trip (#2019).
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])
// 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.
const [mapLocked, setMapLocked] = useState<boolean>(() => {
try { return localStorage.getItem('trek:map-locked') === '1' } catch { return false }
})
const mapLockedRef = useRef(mapLocked)
mapLockedRef.current = mapLocked
const isMobileRef = useRef(isMobile)
isMobileRef.current = isMobile
const toggleMapLocked = useCallback(() => {
setMapLocked(prev => {
const next = !prev
try { localStorage.setItem('trek:map-locked', next ? '1' : '0') } catch { /* private mode keeps it for the session */ }
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])
// 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)
const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null)
useEffect(() => {
if (!trip) return
if (initialFitTripId.current === trip.id) return
const hasGeoPlaces = places.some(p => p.lat != null && p.lng != null)
if (!hasGeoPlaces) return
initialFitTripId.current = trip.id
setFitKey(k => k + 1)
}, [trip, places])
return {
routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile,
overviewShown, toggleOverview, mapLocked, toggleMapLocked, mapLockedRef, isMobileRef,
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, fitKey, setFitKey,
}
}
export type PlannerMapView = ReturnType<typeof usePlannerMapView>
+7 -92
View File
@@ -35,7 +35,6 @@ import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
import { carrierReservationIds, isStoredStop, viasLeaving, type CarrierTerminal } from '@trek/shared/roadtrip'
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
import { PHONE_CORRIDOR_OPTIONS } from '../../components/Roadtrip/corridorSearchModel'
@@ -86,6 +85,7 @@ import { pendingStayPlaceIds } from '../../utils/pendingStays'
import { useDayDelete } from './useDayDelete'
import { useDayAdd } from './useDayAdd'
import { usePlannerDialogs } from './usePlannerDialogs'
import { usePlannerMapView } from './usePlannerMapView'
import { useIsPhone } from '../../mobile/useIsPhone'
/** Stable empty list so the road trip hook stays inert while its mode is off. */
@@ -373,87 +373,11 @@ export function useTripPlanner() {
// by the global widget, so by then the state has remounted back to its default.
// The value comes off the persisted job (#2076).
const importKindRef = useRef<'transports' | 'bookings'>('bookings')
// Manual route planning: off by default, toggled from the day-plan footer. Mode
// is per-session and selects which travel time the connectors show — either a
// built-in OSRM profile or a plugin route profile ('plugin:<id>/<profile>').
// Per-trip route visibility. `null` = the user has never said anything, which
// is what lets the mobile map switch it on by default; an explicit false has to
// survive every later map entry, and it used to be clobbered on each one (#2003).
const routeStorageKey = tripId ? `trek:day-route:${tripId}` : null
const [routeChoice, setRouteChoice] = useState<boolean | null>(() => {
if (typeof window === 'undefined' || !routeStorageKey) return null
const raw = window.localStorage.getItem(routeStorageKey)
return raw === 'true' ? true : raw === 'false' ? false : null
})
const routeShown = routeChoice === true
const setRouteShown = useCallback((v: boolean | ((prev: boolean) => boolean)) => {
setRouteChoice(prev => {
const next = typeof v === 'function' ? v(prev === true) : v
if (routeStorageKey && typeof window !== 'undefined') {
window.localStorage.setItem(routeStorageKey, String(next))
}
return next
})
}, [routeStorageKey])
// The mobile map opens with the day's route drawn — a default, not a choice, so
// it never overwrites an explicit off and is never written to storage itself.
const autoShowRoute = useCallback(() => {
setRouteChoice(prev => (prev === null ? true : prev))
}, [])
// What the planner maps actually draw. The persisted toggle can rehydrate as
// true while no day is selected yet (trip re-entry resets the selection, and
// a second click on the day header clears it) — without a day context the
// per-day transit filter is off, so the map would draw every automated
// transport in the trip (#2019).
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])
// 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.
const [mapLocked, setMapLocked] = useState<boolean>(() => {
try { return localStorage.getItem('trek:map-locked') === '1' } catch { return false }
})
const mapLockedRef = useRef(mapLocked)
mapLockedRef.current = mapLocked
const isMobileRef = useRef(isMobile)
isMobileRef.current = isMobile
const toggleMapLocked = useCallback(() => {
setMapLocked(prev => {
const next = !prev
try { localStorage.setItem('trek:map-locked', next ? '1' : '0') } catch { /* private mode keeps it for the session */ }
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])
// 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)
const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null)
const {
mapLocked, toggleMapLocked, mapLockedRef, isMobileRef, fitKey, setFitKey,
overviewShown, toggleOverview, dawarichTrailShown, toggleDawarichTrail, dawarichTrail,
routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile,
} = usePlannerMapView({ tripId, trip, places, selectedDayId, isMobile })
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
const mobilePlanScrollTopRef = useRef<number>(0)
const mobilePlacesScrollTopRef = useRef<number>(0)
@@ -498,15 +422,6 @@ export function useTripPlanner() {
[deletePlaceIds, bookedNightsNote],
)
useEffect(() => {
if (!trip) return
if (initialFitTripId.current === trip.id) return
const hasGeoPlaces = places.some(p => p.lat != null && p.lng != null)
if (!hasGeoPlaces) return
initialFitTripId.current = trip.id
setFitKey(k => k + 1)
}, [trip, places])
useEffect(() => {
// The server runs the import when EITHER kitinerary or the LLM parser is
// there (booking-import.service.ts), so gating the entry point on kitinerary
@@ -761,7 +676,7 @@ export function useTripPlanner() {
if (!skipFit && !(mapLockedRef.current && !isMobileRef.current)) setFitKey(k => k + 1)
setMobileSidebarOpen(null)
updateRouteForDay(dayId)
}, [updateRouteForDay])
}, [updateRouteForDay, isMobileRef, mapLockedRef, setFitKey])
const handlePlaceClick = useCallback((placeId: number | null, assignmentId?: number | null) => {
if (assignmentId) {