diff --git a/client/src/pages/tripPlanner/usePlannerMapView.test.ts b/client/src/pages/tripPlanner/usePlannerMapView.test.ts new file mode 100644 index 000000000..a5599856f --- /dev/null +++ b/client/src/pages/tripPlanner/usePlannerMapView.test.ts @@ -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 = {}) { + 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) => 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) + }) +}) diff --git a/client/src/pages/tripPlanner/usePlannerMapView.ts b/client/src/pages/tripPlanner/usePlannerMapView.ts new file mode 100644 index 000000000..f43c3dc77 --- /dev/null +++ b/client/src/pages/tripPlanner/usePlannerMapView.ts @@ -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 { + 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:/'). + // 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(() => { + 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('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(() => 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(() => { + 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( + () => 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(0) + const initialFitTripId = useRef(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 diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 75e3c07d4..5827412ee 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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:/'). - // 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(() => { - 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('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(() => 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(() => { - 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( - () => 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(0) - const initialFitTripId = useRef(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(0) const mobilePlacesScrollTopRef = useRef(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) {