diff --git a/client/src/pages/tripPlanner/useRoadtripFeed.test.ts b/client/src/pages/tripPlanner/useRoadtripFeed.test.ts new file mode 100644 index 000000000..b99672dc8 --- /dev/null +++ b/client/src/pages/tripPlanner/useRoadtripFeed.test.ts @@ -0,0 +1,462 @@ +// FE-TP-FEED-001 to FE-TP-FEED-015 +// +// The road trip as the planner routes and draws it, driven straight through +// useRoadtripFeed. The preference, boundary, via, routing, corridor and follow-track +// hooks are fixtures with suites of their own; what is tested here is what the feed +// asks of them and what it builds from their answers. +import { act, renderHook } from '@testing-library/react' +import { MAX_TRIP_DAYS, type RoadtripDayBoundary } from '@trek/shared' +import { PHONE_CORRIDOR_OPTIONS } from '../../components/Roadtrip/corridorSearchModel' +import { dayColor } from '../../components/Roadtrip/dayColors' +import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo' +import { useAuthStore } from '../../store/authStore' +import { useRoadtripPreferencesStore } from '../../store/roadtripPreferencesStore' +import { resetAllStores } from '../../../tests/helpers/store' +import { buildAssignment, buildDay, buildPlace, buildReservation, buildTrip, buildUser } from '../../../tests/helpers/factories' +import type { AssignmentsMap } from '../../types' +import type { Can, Translate } from './plannerTypes' +import { useRoadtripFeed } from './useRoadtripFeed' + +const rt = vi.hoisted(() => ({ + prefs: { ready: true, failed: false }, + prefsArgs: [] as unknown[], + boundaries: { + boundaries: [] as RoadtripDayBoundary[], + editable: false, + stale: false, + pending: false, + save: vi.fn(async (_day: number, _boundary: unknown) => true), + }, + boundaryArgs: [] as unknown[], + vias: { byDay: {} as Record> }, + viasArgs: [] as unknown[], + routes: { + days: [] as Array>, + quietDays: [] as unknown[], + lines: [] as unknown[], + lineDays: [] as number[], + boundaryPath: undefined as unknown[] | undefined, + validateBoundaries: vi.fn((_next: unknown[]): string | null => null), + }, + routesArgs: [] as unknown[], + corridor: { day: undefined }, + corridorArgs: [] as unknown[], + follow: { busy: false }, + followArgs: [] as unknown[], + refuel: { offered: [] as unknown[] }, +})) + +vi.mock('../../hooks/useRoadtripSettings', () => ({ + useLoadRoadtripSettings: (...args: unknown[]) => { rt.prefsArgs = args; return rt.prefs }, +})) +vi.mock('../../components/Roadtrip/useDayBoundaries', () => ({ + useDayBoundaries: (...args: unknown[]) => { rt.boundaryArgs = args; return rt.boundaries }, +})) +vi.mock('../../components/Roadtrip/useRoadtripVias', () => ({ + useRoadtripVias: (...args: unknown[]) => { rt.viasArgs = args; return rt.vias }, +})) +vi.mock('../../components/Roadtrip/useRefuelSearch', () => ({ useRefuelSearch: () => rt.refuel })) +vi.mock('../../components/Roadtrip/useRoadtripRoutes', () => ({ + useRoadtripRoutes: (...args: unknown[]) => { rt.routesArgs = args; return rt.routes }, +})) +vi.mock('../../components/Roadtrip/useRoadtripCorridor', () => ({ + useRoadtripCorridor: (...args: unknown[]) => { rt.corridorArgs = args; return rt.corridor }, +})) +vi.mock('../../components/Roadtrip/useFollowTrack', () => ({ + useFollowTrack: (...args: unknown[]) => { rt.followArgs = args; return rt.follow }, +})) +vi.mock('../../repo/roadtripPreferencesRepo', () => ({ + roadtripPreferencesRepo: { update: vi.fn(async () => ({ roadtrip_max_drive_minutes: 300 })) }, +})) + +type Options = Parameters[0] + +const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() } +const t: Translate = (key, params) => (params ? `${key} ${JSON.stringify(params)}` : key) +let allowed = true +const can: Can = vi.fn(() => allowed) +const trip = buildTrip({ id: 42 }) + +// Day 1 is stored as three rows: two stops with coordinates out of order, and a note +// without any. The plan view's own list leaves the service stop on day 2 out. +const dayOne = buildDay({ id: 1, day_number: 1, date: '2026-06-01' }) +const dayTwo = buildDay({ id: 2, day_number: 2, date: '2026-06-02' }) +const days = [dayOne, dayTwo] +const lake = buildPlace({ id: 1, name: 'Lake', lat: 50, lng: 10 }) +const castle = buildPlace({ id: 2, name: 'Castle', lat: 50, lng: 11 }) +const note = buildPlace({ id: 3, name: 'Note', lat: null, lng: null }) +const pump = buildPlace({ id: 4, name: 'Pump', lat: 50, lng: 12, stop_type: 'fuel' }) +const harbour = buildPlace({ id: 5, name: 'Harbour', lat: 50, lng: 13 }) +const hotel = buildPlace({ id: 6, name: 'Hotel', lat: 50, lng: 14 }) +const pool = buildPlace({ id: 7, name: 'Pool', lat: 50, lng: 15 }) +const stored: AssignmentsMap = { + 1: [ + buildAssignment({ id: 11, day_id: 1, place: castle, order_index: 1 }), + buildAssignment({ id: 12, day_id: 1, place: lake, order_index: 0 }), + buildAssignment({ id: 13, day_id: 1, place: note, order_index: 2 }), + ], + 2: [ + buildAssignment({ id: 21, day_id: 2, place: castle, order_index: 0 }), + buildAssignment({ id: 22, day_id: 2, place: pump, order_index: 1 }), + buildAssignment({ id: 23, day_id: 2, place: harbour, order_index: 2 }), + ], +} +const plan: AssignmentsMap = { 1: stored[1], 2: [stored[2][0], stored[2][2]] } + +function baseOptions(over: Partial = {}): Options { + return { + tripId: 42, trip, can, toast, t, + isMobile: false, activeTab: 'plan', enabledAddons: { roadtrip: true }, roadtripMode: true, roadtripSettings: {}, + days, storedAssignments: stored, assignments: plan, tripAccommodations: [], reservations: [], + places: [lake, castle], visibleConnections: [], routeProfile: 'driving', mapPlaces: [], expandedDayIds: null, + ...over, + } +} + +function renderFeed(over: Partial = {}) { + return renderHook((props: Options) => useRoadtripFeed(props), { initialProps: baseOptions(over) }) +} + +const boundary = (day: number, from: number): RoadtripDayBoundary => ( + { day_number: day, from_assignment_id: from, to_assignment_id: null, fraction: 0.5 } +) + +beforeEach(() => { + vi.clearAllMocks() + resetAllStores() + allowed = true + rt.prefs = { ready: true, failed: false } + rt.boundaries.boundaries = [] + rt.boundaries.editable = false + rt.boundaries.stale = false + rt.vias.byDay = {} + rt.routes.days = [] + rt.routes.lines = [] + rt.routes.lineDays = [] + rt.routes.boundaryPath = undefined +}) + +describe('useRoadtripFeed', () => { + it('FE-TP-FEED-001: with the addon off nothing is fed, and the empty lists keep their identity', () => { + const { result, rerender } = renderFeed({ enabledAddons: { roadtrip: false } }) + + expect(result.current.roadtripActive).toBe(false) + expect(result.current.roadtripFeedActive).toBe(false) + expect(rt.prefsArgs).toEqual([42, false]) + expect(rt.viasArgs).toEqual([42, false]) + expect(rt.boundaryArgs[1]).toBe(false) + const [tripId, routedDays, routedAssignments, profile, , , , routedReservations] = rt.routesArgs + expect(tripId).toBe(42) + expect(routedDays).toEqual([]) + // Off, the plan's own list goes in, and with no days it routes nothing anyway. + expect(routedAssignments).toBe(plan) + expect(profile).toBe('driving') + expect(routedReservations).toEqual([]) + + rerender(baseOptions({ enabledAddons: { roadtrip: false }, days: [...days], reservations: [buildReservation()] })) + expect(rt.routesArgs[1]).toBe(routedDays) + expect(rt.routesArgs[7]).toBe(routedReservations) + }) + + it('FE-TP-FEED-002: on the desk the mode feeds the whole trip, from the stored lists', () => { + const reservations = [buildReservation({ id: 5 })] + const accommodations = [{ id: 8 }] as Options['tripAccommodations'] + const { result } = renderFeed({ + reservations, tripAccommodations: accommodations, + roadtripSettings: { roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' }, + }) + + expect(result.current.roadtripActive).toBe(true) + expect(result.current.roadtripFeedActive).toBe(true) + expect(result.current.dailyTimesActive).toBe(true) + expect(rt.prefsArgs).toEqual([42, true]) + expect(rt.viasArgs).toEqual([42, true]) + expect(rt.boundaryArgs).toEqual([42, true, plan]) + expect(rt.routesArgs[1]).toBe(days) + expect(rt.routesArgs[2]).toBe(stored) + expect(rt.routesArgs[4]).toBe(rt.vias.byDay) + expect(rt.routesArgs[5]).toBe(rt.boundaries.boundaries) + expect(rt.routesArgs[6]).toBe(accommodations) + expect(rt.routesArgs[7]).toBe(reservations) + expect(rt.corridorArgs).toEqual([rt.routes, 42, undefined]) + expect(rt.followArgs).toEqual([42, [lake, castle], rt.routes, rt.vias]) + expect(result.current.roadtripRoutes).toBe(rt.routes) + expect(result.current.roadtripVias).toBe(rt.vias) + expect(result.current.roadtripCorridor).toBe(rt.corridor) + expect(result.current.followTrack).toBe(rt.follow) + expect(result.current.refuel).toBe(rt.refuel) + }) + + it('FE-TP-FEED-003: the days wait for the driving preferences before they are routed', () => { + rt.prefs = { ready: false, failed: false } + const { result } = renderFeed() + + expect(result.current.roadtripFeedActive).toBe(true) + expect(rt.routesArgs[1]).toEqual([]) + expect(rt.routesArgs[1]).not.toBe(days) + expect(result.current.roadtripPreferencesState).toBe(rt.prefs) + }) + + it('FE-TP-FEED-004: the phone feeds the drive once its tab was opened and keeps it fed after', () => { + const phone = { isMobile: true, roadtripMode: false } + const { result, rerender } = renderFeed({ ...phone, activeTab: 'plan' }) + expect(result.current.roadtripFeedActive).toBe(false) + expect(rt.corridorArgs[2]).toBe(PHONE_CORRIDOR_OPTIONS) + + rerender(baseOptions({ ...phone, activeTab: 'roadtrip' })) + expect(result.current.roadtripFeedActive).toBe(true) + expect(result.current.roadtripActive).toBe(false) + expect(rt.routesArgs[2]).toBe(stored) + + rerender(baseOptions({ ...phone, activeTab: 'plan' })) + expect(result.current.roadtripFeedActive).toBe(true) + + // At desk width the tab is never a way in: only the mode is. + const desk = renderFeed({ roadtripMode: false, activeTab: 'roadtrip' }) + expect(desk.result.current.roadtripFeedActive).toBe(false) + }) + + it('FE-TP-FEED-005: preferences that fail to load and boundaries gone stale each say so', () => { + rt.prefs = { ready: false, failed: true } + rt.boundaries.stale = true + renderFeed() + + expect(toast.error).toHaveBeenCalledWith('common.error') + expect(toast.error).toHaveBeenCalledWith('trip.toast.loadError') + }) + + it('FE-TP-FEED-006: day boundaries are only loaded for a drive with a valid daily window', () => { + const none = renderFeed() + expect(none.result.current.dailyTimesActive).toBe(false) + expect(rt.boundaryArgs[1]).toBe(false) + + const backwards = renderFeed({ roadtripSettings: { roadtrip_day_start: '18:00', roadtrip_day_end: '08:00' } }) + expect(backwards.result.current.dailyTimesActive).toBe(false) + + const unfed = renderFeed({ + enabledAddons: { roadtrip: false }, + roadtripSettings: { roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' }, + }) + expect(unfed.result.current.dailyTimesActive).toBe(true) + expect(rt.boundaryArgs[1]).toBe(false) + }) + + it('FE-TP-FEED-007: the boundaries reset is offered only when there is something it may reset', () => { + rt.boundaries.boundaries = [boundary(1, 11), boundary(2, 21)] + expect(renderFeed().result.current.resetDayBoundaries).toBeUndefined() + + rt.boundaries.editable = true + allowed = false + expect(renderFeed().result.current.resetDayBoundaries).toBeUndefined() + + allowed = true + rt.boundaries.boundaries = [] + expect(renderFeed().result.current.resetDayBoundaries).toBeUndefined() + }) + + it('FE-TP-FEED-008: the reset clears every boundary and reports a failure', async () => { + rt.boundaries.editable = true + rt.boundaries.boundaries = [boundary(1, 11), boundary(2, 21)] + const { result } = renderFeed() + + await act(async () => { await result.current.resetDayBoundaries!() }) + expect(rt.boundaries.save).toHaveBeenNthCalledWith(1, 1, null) + expect(rt.boundaries.save).toHaveBeenNthCalledWith(2, 2, null) + expect(toast.error).not.toHaveBeenCalled() + + rt.boundaries.save.mockRejectedValueOnce(new Error('offline')) + await act(async () => { await result.current.resetDayBoundaries!() }) + expect(toast.error).toHaveBeenLastCalledWith('offline') + + rt.boundaries.save.mockRejectedValueOnce('nope') + await act(async () => { await result.current.resetDayBoundaries!() }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + }) + + it('FE-TP-FEED-009: the drag controls need editable boundaries, the right and a path to drag along', () => { + rt.boundaries.editable = true + expect(renderFeed().result.current.dayBoundaryControls).toBeUndefined() + + rt.routes.boundaryPath = [] + expect(renderFeed().result.current.dayBoundaryControls).toBeUndefined() + + rt.routes.boundaryPath = [{ dayNumber: 1 }] + allowed = false + expect(renderFeed().result.current.dayBoundaryControls).toBeUndefined() + + rt.boundaries.editable = false + allowed = true + expect(renderFeed().result.current.dayBoundaryControls).toBeUndefined() + + rt.boundaries.editable = true + const controls = renderFeed().result.current.dayBoundaryControls! + expect(controls.path).toBe(rt.routes.boundaryPath) + expect(controls.hint).toBe('roadtrip.window.dragHint') + }) + + it('FE-TP-FEED-010: a dragged boundary is checked before it is saved, and a removal never is', async () => { + rt.boundaries.editable = true + rt.boundaries.boundaries = [boundary(1, 11), boundary(2, 21)] + rt.routes.boundaryPath = [{ dayNumber: 1 }] + const controls = renderFeed().result.current.dayBoundaryControls! + const moved = boundary(2, 22) + + rt.routes.validateBoundaries.mockReturnValueOnce('tooMany') + await expect(controls.move(2, moved)).resolves.toBe(false) + expect(rt.routes.validateBoundaries).toHaveBeenLastCalledWith([rt.boundaries.boundaries[0], moved]) + expect(toast.error).toHaveBeenLastCalledWith(`roadtrip.window.tooMany ${JSON.stringify({ days: MAX_TRIP_DAYS })}`) + expect(rt.boundaries.save).not.toHaveBeenCalled() + + rt.routes.validateBoundaries.mockReturnValueOnce('tooMany') + await expect(controls.move(2, null)).resolves.toBe(true) + expect(rt.routes.validateBoundaries).toHaveBeenLastCalledWith([rt.boundaries.boundaries[0]]) + expect(rt.boundaries.save).toHaveBeenLastCalledWith(2, null) + + rt.boundaries.save.mockResolvedValueOnce(false) + await expect(controls.move(2, moved)).resolves.toBe(false) + expect(rt.boundaries.save).toHaveBeenLastCalledWith(2, moved) + + rt.boundaries.save.mockRejectedValueOnce(new Error('conflict')) + await expect(controls.move(2, moved)).resolves.toBe(false) + expect(toast.error).toHaveBeenLastCalledWith('conflict') + + rt.boundaries.save.mockRejectedValueOnce(42) + await expect(controls.move(2, moved)).resolves.toBe(false) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + }) + + it('FE-TP-FEED-011: the rides the drive is seamed by join the connections drawn, and the via counts follow the days', () => { + rt.vias.byDay = { + 1: [{ id: 7, day_id: 1, after_order_index: 0, sequence: 0, lat: 50, lng: 10.2 }], + 2: [], + } + const visibleConnections = [3] + const plain = renderFeed({ visibleConnections }) + expect(plain.result.current.roadtripConnections).toBe(visibleConnections) + expect(plain.result.current.roadtripViaCounts).toEqual({ 1: 1, 2: 0 }) + + rt.routes.days = [ + { dayId: 1, dayNumber: 1, stops: [{ placeId: 1, carrier: { type: 'flight', reservationId: 11, role: 'departure' } }] }, + { dayId: 2, dayNumber: 2, stops: [{ placeId: 2, carrier: { type: 'flight', reservationId: 3, role: 'arrival' } }, { placeId: 5 }] }, + ] + expect(renderFeed({ visibleConnections }).result.current.roadtripConnections).toEqual([3, 11]) + }) + + it('FE-TP-FEED-012: the via helpers count stops on the stored list and measure along the drawn road', () => { + rt.routes.days = [{ dayId: 1, dayNumber: 1, stops: [], geometry: [[50, 10], [50, 11], [50, 12]] }] + rt.vias.byDay = { + 1: [ + { id: 7, day_id: 1, after_order_index: 0, sequence: 0, lat: 50, lng: 10.2 }, + { id: 8, day_id: 1, after_order_index: 0, sequence: 1, lat: 50, lng: 10.9 }, + { id: 9, day_id: 1, after_order_index: 1, sequence: 0, lat: 50, lng: 11.5 }, + ], + } + const { result } = renderFeed() + const feed = result.current + + // Sorted by order_index, the row without coordinates left out. + expect(feed.roadtripStopsOf(1).map(a => a.id)).toEqual([12, 11]) + expect(feed.roadtripStopsOf(99)).toEqual([]) + + const beforeHalfway = feed.viaLiesBefore(1, { lat: 50, lng: 10.5 }) + expect(beforeHalfway({ lat: 50, lng: 10.2 })).toBe(true) + expect(beforeHalfway({ lat: 50, lng: 10.9 })).toBe(false) + // A day with no road drawn keeps every via where it was. + expect(feed.viaLiesBefore(2, { lat: 50, lng: 10.5 })({ lat: 50, lng: 30 })).toBe(true) + + // Appending moves nothing, a place without coordinates is not a stop, and a row at + // the end of the list is an append however many unroutable rows sit in front of it. + expect(feed.viasAfterInsert(1, undefined, { lat: 50, lng: 10.5 })).toBeNull() + expect(feed.viasAfterInsert(1, 1, { lat: null, lng: null })).toBeNull() + expect(feed.viasAfterInsert(1, 1, undefined)).toBeNull() + expect(feed.viasAfterInsert(1, 3, { lat: 50, lng: 10.5 })).toBeNull() + expect(feed.viasAfterInsert(1, 1, { lat: 50, lng: 10.5 })).toEqual({ + vias: [{ id: 8, after_order_index: 1 }, { id: 9, after_order_index: 2 }], + remove: [], + }) + }) + + it('FE-TP-FEED-013: folding a day takes its line, its colours and its stops off the map', () => { + rt.routes.days = [ + { dayId: 1, dayNumber: 1, stops: [{ placeId: 1 }, { placeId: 2 }] }, + { + dayId: 2, dayNumber: 2, + stops: [{ placeId: 2 }, { placeId: 5 }, { placeId: 4, automaticNight: true }, { placeId: 6, bookend: true }], + }, + ] + rt.routes.lines = ['day 1 out', 'day 1 back', 'day 2'] + rt.routes.lineDays = [1, 1, 2] + const mapPlaces = [lake, castle, pump, harbour, hotel, pool] + const { result, rerender } = renderFeed({ mapPlaces }) + const ids = () => result.current.roadtripMapPlaces.map(p => p.id) + + expect(result.current.collapsedRoadtripDays.size).toBe(0) + expect(result.current.roadtripMapLines).toBe(rt.routes.lines) + expect(result.current.roadtripLineColors).toBeUndefined() + // Planned on a day, or the hotel a day's drive ends at; the pool and the hidden pump stay off. + expect(ids()).toEqual([1, 2, 5, 6]) + + act(() => { result.current.toggleRoadtripDay(1) }) + expect(result.current.collapsedRoadtripDays.has(1)).toBe(true) + expect(result.current.roadtripMapLines).toEqual(['day 2']) + // The castle is drawn on day 2 as well, so only the lake goes. + expect(ids()).toEqual([2, 5, 6]) + + rerender(baseOptions({ mapPlaces, roadtripSettings: { roadtrip_day_colors: true } })) + expect(result.current.roadtripLineColors).toEqual([dayColor(2)]) + + act(() => { result.current.toggleRoadtripDay(1) }) + expect(result.current.collapsedRoadtripDays.size).toBe(0) + expect(result.current.roadtripLineColors).toEqual([dayColor(1), dayColor(1), dayColor(2)]) + expect(ids()).toEqual([1, 2, 5, 6]) + }) + + it('FE-TP-FEED-014: the recorded trail hides the folded dates, and keeps one set while they stay the same', () => { + const { result, rerender } = renderFeed() + expect(result.current.dawarichHiddenDates).toBeNull() + + act(() => { result.current.toggleRoadtripDay(1) }) + const hidden = result.current.dawarichHiddenDates + expect([...hidden!]).toEqual(['2026-06-01']) + + rerender(baseOptions({ days: [...days] })) + expect(result.current.dawarichHiddenDates).toBe(hidden) + + // Out of road trip mode the rail's folds no longer count; the day plan's do. + rerender(baseOptions({ roadtripMode: false })) + expect(result.current.dawarichHiddenDates).toBeNull() + rerender(baseOptions({ roadtripMode: false, expandedDayIds: new Set([1]) })) + expect([...result.current.dawarichHiddenDates!]).toEqual(['2026-06-02']) + }) + + it('FE-TP-FEED-015: a driving limit is saved for the traveller only when it may be', async () => { + const update = vi.mocked(roadtripPreferencesRepo.update) + const { result, rerender } = renderFeed() + + // Nobody signed in: nothing to file the preference under. + await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_drive_minutes', 300) }) + expect(update).not.toHaveBeenCalled() + + useAuthStore.setState({ user: buildUser({ id: 9 }) }) + allowed = false + rerender(baseOptions()) + await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_drive_minutes', 300) }) + expect(update).not.toHaveBeenCalled() + + allowed = true + rt.prefs = { ready: false, failed: false } + rerender(baseOptions()) + await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_drive_minutes', 300) }) + expect(update).not.toHaveBeenCalled() + + rt.prefs = { ready: true, failed: false } + rerender(baseOptions()) + await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_drive_minutes', 300) }) + expect(update).toHaveBeenCalledWith(42, { roadtrip_max_drive_minutes: 300 }) + expect(useRoadtripPreferencesStore.getState().byTrip['9:42']).toEqual({ roadtrip_max_drive_minutes: 300 }) + + update.mockRejectedValueOnce(new Error('offline')) + await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_drive_minutes', 240) }) + expect(toast.error).toHaveBeenLastCalledWith('places.saveError') + }) +}) diff --git a/client/src/pages/tripPlanner/useRoadtripFeed.ts b/client/src/pages/tripPlanner/useRoadtripFeed.ts new file mode 100644 index 000000000..c07c108d3 --- /dev/null +++ b/client/src/pages/tripPlanner/useRoadtripFeed.ts @@ -0,0 +1,370 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { MAX_TRIP_DAYS, type RoadtripPreferences } from '@trek/shared' +import { carrierReservationIds } from '@trek/shared/roadtrip' +import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag' +import { collapsedDayDates } from '../../components/Map/dawarichTrail' +import { projectOntoRoute, type LatLng } from '../../components/Roadtrip/corridor' +import { PHONE_CORRIDOR_OPTIONS } from '../../components/Roadtrip/corridorSearchModel' +import { dayColor } from '../../components/Roadtrip/dayColors' +import { dayWindow } from '../../components/Roadtrip/dayWindow' +import { reanchorAfterInsert } from '../../components/Roadtrip/roadtripModel' +import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries' +import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack' +import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch' +import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor' +import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes' +import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias' +import { useLoadRoadtripSettings } from '../../hooks/useRoadtripSettings' +import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo' +import { useAuthStore } from '../../store/authStore' +import { publishRoadtripPreferences } from '../../store/roadtripPreferencesStore' +import type { TripStoreState } from '../../store/tripStore' +import type { Accommodation, Day, Place, Reservation } from '../../types' +import type { PlannerBase } from './plannerTypes' +import type { MapPlaces } from './useMapPlaces' +import type { PlannerMapView } from './usePlannerMapView' + +/** Stable empty list so the road trip hook stays inert while its mode is off. */ +const EMPTY_DAYS: Day[] = [] +const EMPTY_RESERVATIONS: Reservation[] = [] + +interface RoadtripFeedOptions extends Pick { + isMobile: boolean + activeTab: string + enabledAddons: Record + /** The trip's road trip switch, already off on a phone. */ + roadtripMode: boolean + roadtripSettings: RoadtripPreferences + days: TripStoreState['days'] + storedAssignments: TripStoreState['assignments'] + /** The day lists read as a plan, without the stops only the drive wants. */ + assignments: TripStoreState['assignments'] + tripAccommodations: Accommodation[] + reservations: TripStoreState['reservations'] + places: Place[] + visibleConnections: number[] + routeProfile: PlannerMapView['routeProfile'] + mapPlaces: MapPlaces['mapPlaces'] + expandedDayIds: MapPlaces['expandedDayIds'] +} + +/** + * The road trip as it is routed and drawn: whether the drive is shown and fed, the + * traveller's driving preferences and limits, the day boundaries and their drag + * controls, the vias, the refuel search, the routes, the corridor search, the track + * being followed, the index helpers every via correction counts with, the days folded + * away in the rail and the map layers that follow those folds. + * + * Its effects run where useTripPlanner calls it, right after the trip overview, in the + * order they always had. + */ +export function useRoadtripFeed(options: RoadtripFeedOptions) { + const { + tripId, trip, can, toast, t, isMobile, activeTab, enabledAddons, roadtripMode, roadtripSettings, + days, storedAssignments, assignments, tripAccommodations, reservations, places, visibleConnections, + routeProfile, mapPlaces, expandedDayIds, + } = options + // Road trip mode reads the whole trip, not the selected day, so it owns its own legs. + // Passing no days while the mode is off keeps it inert: no routing requests, no state. + const roadtripActive = !!enabledAddons.roadtrip && roadtripMode + // The phone's own way into the drive, and the reason `roadtripMode` in useTripPlanner + // stays as it is. + // + // `roadtripMode` is not a view switch, it is a data switch: `assignments` and + // `places` are derived from it for the whole planner, and every permanently mounted + // sheet of the phone shell reads those same lists. Letting the phone flip it would + // bring back exactly the regressions the comment on `roadtripMode` describes, on paths that + // never touch a tab. The second tap on a day chip opens the day sheet, the More + // button opens the PDF export. + // + // So the phone feeds the routing round instead, and nothing else. Once the tab has + // been opened the feed stays on for as long as the trip is: a tab switch must not + // throw away legs that cost a rate-limited request each. + const roadtripTabSeen = useRef(false) + if (activeTab === 'roadtrip') roadtripTabSeen.current = true + const roadtripFeedActive = !!enabledAddons.roadtrip + && (roadtripMode || (isMobile && (activeTab === 'roadtrip' || roadtripTabSeen.current))) + const roadtripPreferencesState = useLoadRoadtripSettings(tripId, !!enabledAddons.roadtrip) + useEffect(() => { + if (roadtripPreferencesState.failed) toast.error(t('common.error')) + }, [roadtripPreferencesState.failed, toast, t]) + const dailyTimesActive = !!dayWindow(roadtripSettings.roadtrip_day_start, roadtripSettings.roadtrip_day_end) + // Fed from the same flag as the routing round: without the boundaries the phone + // would draw a drive that never ends for the night. + const dayBoundaries = useDayBoundaries(tripId, roadtripFeedActive && dailyTimesActive, assignments) + useEffect(() => { if (dayBoundaries.stale) toast.error(t('trip.toast.loadError')) }, [dayBoundaries.stale, toast, t]) + const resetDayBoundaries = dayBoundaries.editable && dayBoundaries.boundaries.length && can('day_edit', trip) ? async () => { + try { + for (const boundary of dayBoundaries.boundaries) await dayBoundaries.save(boundary.day_number, null) + } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } + } : undefined + // Fed whenever the addon is on, not only while the mode is being looked at. + // The three handlers that re-anchor a day's vias (assign, remove, reorder) + // are reachable from the place inspector in both modes, and with an empty list + // they computed an empty correction and wrote nothing while the server kept + // every via pointing at a position that had moved. Deleting a stop with the + // switch off then left the detour on the wrong leg, for the traveller and for + // a collaborator who never turned the mode on at all. + const roadtripVias = useRoadtripVias(tripId, !!enabledAddons.roadtrip) + const refuel = useRefuelSearch() + const roadtripRoutes = useRoadtripRoutes( + tripId, + roadtripFeedActive && roadtripPreferencesState.ready ? days : EMPTY_DAYS, + // Deliberately the stored list, not the filtered one: the drive wants the + // service stops and the night a lodging booking put on the map, and the filter + // useTripPlanner puts on `assignments` only exists to keep those out of a day list + // read as a plan. + roadtripFeedActive ? storedAssignments : assignments, + routeProfile, + roadtripVias.byDay, + dayBoundaries.boundaries, + tripAccommodations, + roadtripFeedActive ? reservations : EMPTY_RESERVATIONS, + ) + /** + * The bookings the drive is seamed by, drawn as their own arcs beside the roads: a + * flight's route on the road trip map is the booking's line, the same one the day plan + * draws, so the ride shows where the road stops (#2428). On top of whatever the reader + * switched on by hand under Days. + */ + const roadtripConnections = useMemo(() => { + const rides = carrierReservationIds(roadtripRoutes.days) + return rides.length ? [...new Set([...visibleConnections, ...rides])] : visibleConnections + }, [roadtripRoutes.days, visibleConnections]) + // Lives here rather than in the panel because the map draws what it finds. + // The trip comes with it for the vehicle: an electric car looks for chargers rather + // than for pumps, and that preference is stored per trip. + // The phone searches under a smaller ceiling: fewer boxes, a capped retry pass and a + // deadline, because a search that keeps the radio warm costs battery somebody is + // navigating on. Which stretch of the day it asks about is decided per search. + const roadtripCorridor = useRoadtripCorridor(roadtripRoutes, tripId, isMobile ? PHONE_CORRIDOR_OPTIONS : undefined) + // Applying a track is a long job, a routing round trip per refinement, so it lives + // above the dialog: a component that unmounted halfway would leave the day holding + // half a chain of vias. + const followTrack = useFollowTrack(tripId, places, roadtripRoutes, roadtripVias) + /** How many vias each day carries, for the rail's badge. */ + const roadtripViaCounts = useMemo(() => { + const counts: Record = {} + for (const [dayId, list] of Object.entries(roadtripVias.byDay)) counts[Number(dayId)] = list.length + return counts + }, [roadtripVias.byDay]) + + /** + * The stops of a day as the road trip counts them, in the order it drives them. + * + * This is the index space `after_order_index` lives in: sorted by `order_index`, and + * filtered to the rows that have coordinates, because a place the map cannot put + * anywhere is not a point the router is given. Built from the STORED list, the same + * one the routing round and the server count over. The day list read as a plan + * hides the stop a lodging booking put on the day and, behind the switch, the + * service stops; the handlers that correct a day's vias run in that mode too, and a + * plan measured on the shorter list deleted a via behind a hidden hotel or pinned + * it to a leg nobody drew. Built from the list rather than from `roadtripRoutes` so + * it also answers for a day with one stop or none, which is exactly the day a stop + * gets pushed onto when a leg turns out too long. + */ + const roadtripStopsOf = useCallback((dayId: number) => + (storedAssignments[String(dayId)] ?? []) + .slice() + .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) + .filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number'), + [storedAssignments]) + + /** + * Where a place dropped at `position` in a day's row list lands among the stops the + * road trip counts. + * + * The day plan hands over a row index and the store splices the new row in at that + * index, so the stop's place in the chain is the number of routable rows ahead of + * it, not the index itself: a row without coordinates is never a stop. + */ + const roadtripIndexOf = useCallback((dayId: number, position: number) => + (storedAssignments[String(dayId)] ?? []) + .slice() + .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) + .slice(0, Math.max(0, position)) + .filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number') + .length, + [storedAssignments]) + + /** + * Which half of a split leg a via belongs to, measured on the road actually driven. + * + * Both the via and the new stop are projected onto the day's current routed line, so + * the comparison is "which one does the car reach first" rather than a straight-line + * guess. The line already includes the detour the via causes, so the via sits exactly + * on it and its position along the drive is exact. + * + * Falls back to keeping the via on the first half. A projection only fails when the + * point is nowhere near the drive, and in that case leaving the anchor where it was is + * the answer that changes least. + */ + const viaLiesBefore = useCallback((dayId: number, at: { lat: number; lng: number }) => { + const line: LatLng[] = (roadtripRoutes.days.find(d => d.dayId === dayId)?.geometry ?? []) + .map(([lat, lng]) => ({ lat, lng })) + const insertAt = line.length ? projectOntoRoute(at, line)?.alongKm ?? null : null + return (via: { lat: number; lng: number }) => { + if (insertAt === null) return true + const viaAt = projectOntoRoute({ lat: via.lat, lng: via.lng }, line)?.alongKm + return viaAt === undefined || viaAt === null ? true : viaAt < insertAt + } + }, [roadtripRoutes.days]) + + /** + * How a day's vias move when a place at `at` lands on it at row `position`, or at its + * end without one. Null when none do: appending moves nothing, and a place without + * coordinates is never a stop. + * + * Worked out before the stop lands, the same way the road-trip popup does it: once the + * list has shifted there is no record of which leg each via was drawn for. The + * predicate decides which side of the new stop a via falls on when it is dropped into + * the middle of a leg. + */ + const viasAfterInsert = useCallback((dayId: number, position: number | undefined, at: { lat?: number | null; lng?: number | null } | undefined) => { + const stopsBefore = roadtripStopsOf(dayId) + // The position is a row index in the day list, the anchors count stops. + const insertAt = position === undefined ? stopsBefore.length : roadtripIndexOf(dayId, position) + if (insertAt >= stopsBefore.length || typeof at?.lat !== 'number' || typeof at?.lng !== 'number') return null + return reanchorAfterInsert(roadtripVias.byDay[dayId] ?? [], insertAt, viaLiesBefore(dayId, { lat: at.lat, lng: at.lng })) + }, [roadtripStopsOf, roadtripIndexOf, roadtripVias.byDay, viaLiesBefore]) + + /** + * Days folded down to their header in the road trip rail. + * + * Its own state rather than the day plan's `expandedDayIds`: that one is owned by + * `DayPlanSidebar`, which republishes it from its own state every time it mounts, so a + * day folded in the rail would spring back open the moment the other view was visited. + */ + const [collapsedRoadtripDays, setCollapsedRoadtripDays] = useState>(new Set()) + const toggleRoadtripDay = useCallback((dayId: number) => { + setCollapsedRoadtripDays(prev => { + const next = new Set(prev) + if (!next.delete(dayId)) next.add(dayId) + return next + }) + }, []) + + /** + * The road trip's lines, minus the days that are folded away. + * + * Folding a card takes that day off the map, which is most of what folding is for: a + * rail entry can be scrolled past, a line across the map cannot be looked away from. + * `lineDays` runs parallel to `lines`, so both are filtered in one pass and the colours + * stay lined up with what is left. + */ + const roadtripMapLines = useMemo(() => { + if (!collapsedRoadtripDays.size) return roadtripRoutes.lines + const hidden = new Set( + roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber), + ) + return roadtripRoutes.lines.filter((_, i) => !hidden.has(roadtripRoutes.lineDays[i])) + }, [roadtripRoutes.lines, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays]) + + /** + * The map's places with the folded cards' stops taken out. + * + * A second pass rather than a branch inside `mapPlaces`: that memo runs long before the + * road trip is routed, and the answer here needs the CHAINS: a card is a date, and + * after a night drive it holds stops stored on the day before (`nightSpill.ts`), so + * what a folded card hides is what is drawn on it, not what is filed under its day. + * + * A place still drawn on some other card stays, which is the rule the day plan's own + * declutter follows. + */ + const roadtripMapPlaces = useMemo(() => { + const plannedIds = new Set(Object.values(assignments).flat().map(a => a.place_id)) + // The hotel a day sets out from or ends at is drawn with its drive whether or not a day + // still holds its stop: with that stop removed, the line and the walk to the door ended + // at a spot with no pin, where the phone's stage map has one (`stagePlaceIds`). + for (const day of roadtripRoutes.days) { + for (const stop of day.stops) if (stop.bookend) plannedIds.add(stop.placeId) + } + const plannedPlaces = mapPlaces.filter(p => plannedIds.has(p.id)) + if (!collapsedRoadtripDays.size) return plannedPlaces + const hidden = new Set() + for (const day of roadtripRoutes.days) { + if (!collapsedRoadtripDays.has(day.dayId)) continue + for (const stop of day.stops) if (!stop.automaticNight) hidden.add(stop.placeId) + } + for (const day of roadtripRoutes.days) { + if (collapsedRoadtripDays.has(day.dayId)) continue + for (const stop of day.stops) if (!stop.automaticNight) hidden.delete(stop.placeId) + } + return plannedPlaces.filter(p => !hidden.has(p.id)) + }, [mapPlaces, assignments, roadtripRoutes.days, collapsedRoadtripDays]) + + // The recorded route follows the same folds as the places above, so a + // collapsed day does not leave its line behind on the map. Keyed on the joined + // dates rather than on the Set, because `days` changes identity on every store + // update and the GL overlay rebuilds its source whenever this reference moves. + const dawarichHiddenKey = useMemo( + () => collapsedDayDates(days, expandedDayIds, roadtripActive ? collapsedRoadtripDays : null).join('|'), + [days, expandedDayIds, roadtripActive, collapsedRoadtripDays], + ) + const dawarichHiddenDates = useMemo( + () => (dawarichHiddenKey ? new Set(dawarichHiddenKey.split('|')) : null), + [dawarichHiddenKey], + ) + + /** + * A colour per drawn line, or none at all. + * + * Off is not "all the same colour" but an absent array: the map then paints the blue it + * has always painted, so a trip that never turns this on renders byte for byte the way + * it did. + */ + const roadtripLineColors = useMemo( + () => { + if (!roadtripSettings.roadtrip_day_colors) return undefined + const hidden = new Set( + roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber), + ) + return roadtripRoutes.lineDays.filter(n => !hidden.has(n)).map(n => dayColor(n)) + }, + [roadtripSettings.roadtrip_day_colors, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays], + ) + + /** + * Stores one of the three driving limits. + * + * Straight to the settings store rather than through the offline queue: these are + * per-user preferences on the settings table, the same path the map provider and the + * distance unit take, and they are read locally the moment they change. + */ + const saveRoadtripLimit = useCallback(async (key: string, value: number | string | boolean) => { + try { + if (!can('day_edit', trip) || !roadtripPreferencesState.ready) return + const userId = useAuthStore.getState().user?.id + if (!userId) return + const preferences = await roadtripPreferencesRepo.update(tripId, { [key]: value }) + publishRoadtripPreferences(userId, tripId, preferences) + } catch { + toast.error(t('places.saveError')) + } + }, [tripId, trip, can, roadtripPreferencesState.ready, toast, t]) + + const dayBoundaryControls = useMemo(() => { + if (!dayBoundaries.editable || !can('day_edit', trip) || !roadtripRoutes.boundaryPath?.length) return undefined + return { + path: roadtripRoutes.boundaryPath, + hint: t('roadtrip.window.dragHint'), + move: async (day, boundary) => { + const next = dayBoundaries.boundaries.filter(b => b.day_number !== day) + if (boundary) next.push(boundary) + const issue = roadtripRoutes.validateBoundaries?.(next) + if (boundary && issue) { toast.error(t(`roadtrip.window.${issue}`, { days: MAX_TRIP_DAYS })); return false } + try { return await dayBoundaries.save(day, boundary) } + catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')); return false } + }, + } + }, [dayBoundaries.editable, dayBoundaries.boundaries, dayBoundaries.save, can, trip, roadtripRoutes, t, toast]) + + return { + roadtripActive, roadtripFeedActive, roadtripPreferencesState, dailyTimesActive, dayBoundaries, resetDayBoundaries, + roadtripVias, refuel, roadtripRoutes, roadtripConnections, roadtripCorridor, followTrack, roadtripViaCounts, + roadtripStopsOf, viaLiesBefore, viasAfterInsert, collapsedRoadtripDays, toggleRoadtripDay, + roadtripMapLines, roadtripMapPlaces, dawarichHiddenDates, roadtripLineColors, saveRoadtripLimit, dayBoundaryControls, + } +} + +export type RoadtripFeed = ReturnType diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index e70aaa5be..ca45aea11 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -1,12 +1,9 @@ -import { useLoadRoadtripSettings, useRoadtripSettings } from '../../hooks/useRoadtripSettings' -import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo' -import { publishRoadtripPreferences } from '../../store/roadtripPreferencesStore' +import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useParams, useNavigate, useSearchParams } from 'react-router' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useSettingsStore } from '../../store/settingsStore' -import { dayColor } from '../../components/Roadtrip/dayColors' import { getCached, fetchPhoto } from '../../services/photoService' import { useToast } from '../../components/shared/Toast' import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Mountain, Route } from 'lucide-react' @@ -28,21 +25,13 @@ import { useAuthStore } from '../../store/authStore' import { useResizablePanels } from '../../hooks/useResizablePanels' import { useTripWebSocket } from '../../hooks/useTripWebSocket' import { useRouteCalculation } from '../../hooks/useRouteCalculation' -import { useRoadtripRoutes, type RoadtripStop } from '../../components/Roadtrip/useRoadtripRoutes' +import type { RoadtripStop } from '../../components/Roadtrip/useRoadtripRoutes' import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDayPoints' -import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries' -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 { roadtripInsertion } from '../../components/Roadtrip/dayWindow' +import { isStoredStop, viasLeaving, type CarrierTerminal } from '@trek/shared/roadtrip' import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview' -import { collapsedDayDates } from '../../components/Map/dawarichTrail' -import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor' -import { PHONE_CORRIDOR_OPTIONS } from '../../components/Roadtrip/corridorSearchModel' -import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias' import { useDayClear } from './useDayClear' -import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch' import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion' -import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack' import { openOn, useRouteAlternatives, type RailDrive } from '../../components/Roadtrip/useRouteAlternatives' import { alternativesBusy, buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays' import { pinAlternative, railDriveOn, railLegAt, refusalHint, type PinProof } from '../../components/Roadtrip/alternativePins' @@ -58,7 +47,7 @@ import { import type { ManualStopTarget, ServiceStopMode } from '../../components/Roadtrip/manualStop' import type { StayDraft } from '../../components/Roadtrip/RoadtripStayModal' import { inspectorStay } from '../../components/Roadtrip/stayReading' -import { MAX_TRIP_DAYS, normalizePlaceWebsite, type RoadtripStopType } from '@trek/shared' +import { normalizePlaceWebsite, type RoadtripStopType } from '@trek/shared' import { usePlaceSelection } from '../../hooks/usePlaceSelection' import { useTourPlaceIds } from '../../hooks/useTourPlaceIds' import { useAddonStore } from '../../store/addonStore' @@ -84,12 +73,9 @@ import { useDayAdd } from './useDayAdd' import { usePlannerDialogs } from './usePlannerDialogs' import { usePlannerMapView } from './usePlannerMapView' import { useMapPlaces } from './useMapPlaces' +import { useRoadtripFeed } from './useRoadtripFeed' import { useIsPhone } from '../../mobile/useIsPhone' -/** Stable empty list so the road trip hook stays inert while its mode is off. */ -const EMPTY_DAYS: Day[] = [] -const EMPTY_RESERVATIONS: Reservation[] = [] - /** * Trip planner page logic, the big one. Owns the trip store wiring, addon * gating, accommodations/members loading, the tab + resizable-panel + selection @@ -518,88 +504,17 @@ export function useTripPlanner() { const overviewActive = overviewShown && !roadtripMode const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive, places) - // Road trip mode reads the whole trip, not the selected day, so it owns its own legs. - // Passing no days while the mode is off keeps it inert — no routing requests, no state. - const roadtripActive = !!enabledAddons.roadtrip && roadtripMode - // The phone's own way into the drive, and the reason line 148 above stays as it is. - // - // `roadtripMode` is not a view switch, it is a data switch: `assignments` and - // `places` are derived from it for the whole hook, and every permanently mounted - // sheet of the phone shell reads those same lists. Letting the phone flip it would - // bring back exactly the regressions the comment up there describes, on paths that - // never touch a tab. The second tap on a day chip opens the day sheet, the More - // button opens the PDF export. - // - // So the phone feeds the routing round instead, and nothing else. Once the tab has - // been opened the feed stays on for as long as the trip is: a tab switch must not - // throw away legs that cost a rate-limited request each. - const roadtripTabSeen = useRef(false) - if (activeTab === 'roadtrip') roadtripTabSeen.current = true - const roadtripFeedActive = !!enabledAddons.roadtrip - && (roadtripMode || (isMobile && (activeTab === 'roadtrip' || roadtripTabSeen.current))) - const roadtripPreferencesState = useLoadRoadtripSettings(tripId, !!enabledAddons.roadtrip) - useEffect(() => { - if (roadtripPreferencesState.failed) toast.error(t('common.error')) - }, [roadtripPreferencesState.failed, toast, t]) - const dailyTimesActive = !!dayWindow(roadtripSettings.roadtrip_day_start, roadtripSettings.roadtrip_day_end) - // Fed from the same flag as the routing round: without the boundaries the phone - // would draw a drive that never ends for the night. - const dayBoundaries = useDayBoundaries(tripId, roadtripFeedActive && dailyTimesActive, assignments) - useEffect(() => { if (dayBoundaries.stale) toast.error(t('trip.toast.loadError')) }, [dayBoundaries.stale, toast, t]) - const resetDayBoundaries = dayBoundaries.editable && dayBoundaries.boundaries.length && can('day_edit', trip) ? async () => { - try { - for (const boundary of dayBoundaries.boundaries) await dayBoundaries.save(boundary.day_number, null) - } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - } : undefined - // Fed whenever the addon is on, not only while the mode is being looked at. - // The three handlers that re-anchor a day's vias — assign, remove, reorder — - // are reachable from the place inspector in both modes, and with an empty list - // they computed an empty correction and wrote nothing while the server kept - // every via pointing at a position that had moved. Deleting a stop with the - // switch off then left the detour on the wrong leg, for the traveller and for - // a collaborator who never turned the mode on at all. - const roadtripVias = useRoadtripVias(tripId, !!enabledAddons.roadtrip) - const refuel = useRefuelSearch() - const roadtripRoutes = useRoadtripRoutes( - tripId, - roadtripFeedActive && roadtripPreferencesState.ready ? days : EMPTY_DAYS, - // Deliberately the stored list, not the filtered one: the drive wants the - // service stops and the night a lodging booking put on the map, and the filter - // above only exists to keep those out of a day list read as a plan. - roadtripFeedActive ? storedAssignments : assignments, - routeProfile, - roadtripVias.byDay, - dayBoundaries.boundaries, - tripAccommodations, - roadtripFeedActive ? reservations : EMPTY_RESERVATIONS, - ) - /** - * The bookings the drive is seamed by, drawn as their own arcs beside the roads: a - * flight's route on the road trip map is the booking's line, the same one the day plan - * draws, so the ride shows where the road stops (#2428). On top of whatever the reader - * switched on by hand under Days. - */ - const roadtripConnections = useMemo(() => { - const rides = carrierReservationIds(roadtripRoutes.days) - return rides.length ? [...new Set([...visibleConnections, ...rides])] : visibleConnections - }, [roadtripRoutes.days, visibleConnections]) - // Lives here rather than in the panel because the map draws what it finds. - // The trip comes with it for the vehicle: an electric car looks for chargers rather - // than for pumps, and that preference is stored per trip. - // The phone searches under a smaller ceiling: fewer boxes, a capped retry pass and a - // deadline, because a search that keeps the radio warm costs battery somebody is - // navigating on. Which stretch of the day it asks about is decided per search. - const roadtripCorridor = useRoadtripCorridor(roadtripRoutes, tripId, isMobile ? PHONE_CORRIDOR_OPTIONS : undefined) - // Applying a track is a long job — a routing round trip per refinement — so it lives - // above the dialog: a component that unmounted halfway would leave the day holding - // half a chain of vias. - const followTrack = useFollowTrack(tripId, places, roadtripRoutes, roadtripVias) - /** How many vias each day carries, for the rail's badge. */ - const roadtripViaCounts = useMemo(() => { - const counts: Record = {} - for (const [dayId, list] of Object.entries(roadtripVias.byDay)) counts[Number(dayId)] = list.length - return counts - }, [roadtripVias.byDay]) + // Called here so the road trip's effects keep their place, right after the overview's. + const { + roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripVias, roadtripCorridor, refuel, followTrack, + roadtripPreferencesState, dailyTimesActive, dayBoundaries, resetDayBoundaries, dayBoundaryControls, saveRoadtripLimit, + roadtripStopsOf, viaLiesBefore, viasAfterInsert, roadtripConnections, roadtripViaCounts, + collapsedRoadtripDays, toggleRoadtripDay, roadtripMapLines, roadtripMapPlaces, roadtripLineColors, dawarichHiddenDates, + } = useRoadtripFeed({ + tripId, trip, can, toast, t, enabledAddons, roadtripMode, roadtripSettings, isMobile, activeTab, + days, reservations, storedAssignments, assignments, tripAccommodations, places, + mapPlaces, expandedDayIds, routeProfile, visibleConnections, + }) const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => { tripActions.setSelectedDay(dayId) @@ -780,85 +695,6 @@ export function useTripPlanner() { openAddPlaceFromPoi(poi, roadtripFeedActive ? target?.dayId ?? roadtripDayId : undefined) }, [openAddPlaceFromPoi, roadtripFeedActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, roadtripRoutes.days, overnightOptions, can, trip, setStopDraft]) - /** - * The stops of a day as the road trip counts them, in the order it drives them. - * - * This is the index space `after_order_index` lives in: sorted by `order_index`, and - * filtered to the rows that have coordinates, because a place the map cannot put - * anywhere is not a point the router is given. Built from the STORED list, the same - * one the routing round and the server count over. The day list read as a plan - * hides the stop a lodging booking put on the day and, behind the switch, the - * service stops; the handlers that correct a day's vias run in that mode too, and a - * plan measured on the shorter list deleted a via behind a hidden hotel or pinned - * it to a leg nobody drew. Built from the list rather than from `roadtripRoutes` so - * it also answers for a day with one stop or none, which is exactly the day a stop - * gets pushed onto when a leg turns out too long. - */ - const roadtripStopsOf = useCallback((dayId: number) => - (storedAssignments[String(dayId)] ?? []) - .slice() - .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) - .filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number'), - [storedAssignments]) - - /** - * Where a place dropped at `position` in a day's row list lands among the stops the - * road trip counts. - * - * The day plan hands over a row index and the store splices the new row in at that - * index, so the stop's place in the chain is the number of routable rows ahead of - * it, not the index itself: a row without coordinates is never a stop. - */ - const roadtripIndexOf = useCallback((dayId: number, position: number) => - (storedAssignments[String(dayId)] ?? []) - .slice() - .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) - .slice(0, Math.max(0, position)) - .filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number') - .length, - [storedAssignments]) - - /** - * Which half of a split leg a via belongs to, measured on the road actually driven. - * - * Both the via and the new stop are projected onto the day's current routed line, so - * the comparison is "which one does the car reach first" rather than a straight-line - * guess. The line already includes the detour the via causes, so the via sits exactly - * on it and its position along the drive is exact. - * - * Falls back to keeping the via on the first half. A projection only fails when the - * point is nowhere near the drive, and in that case leaving the anchor where it was is - * the answer that changes least. - */ - const viaLiesBefore = useCallback((dayId: number, at: { lat: number; lng: number }) => { - const line: LatLng[] = (roadtripRoutes.days.find(d => d.dayId === dayId)?.geometry ?? []) - .map(([lat, lng]) => ({ lat, lng })) - const insertAt = line.length ? projectOntoRoute(at, line)?.alongKm ?? null : null - return (via: { lat: number; lng: number }) => { - if (insertAt === null) return true - const viaAt = projectOntoRoute({ lat: via.lat, lng: via.lng }, line)?.alongKm - return viaAt === undefined || viaAt === null ? true : viaAt < insertAt - } - }, [roadtripRoutes.days]) - - /** - * How a day's vias move when a place at `at` lands on it at row `position`, or at its - * end without one. Null when none do: appending moves nothing, and a place without - * coordinates is never a stop. - * - * Worked out before the stop lands, the same way the road-trip popup does it: once the - * list has shifted there is no record of which leg each via was drawn for. The - * predicate decides which side of the new stop a via falls on when it is dropped into - * the middle of a leg. - */ - const viasAfterInsert = useCallback((dayId: number, position: number | undefined, at: { lat?: number | null; lng?: number | null } | undefined) => { - const stopsBefore = roadtripStopsOf(dayId) - // The position is a row index in the day list, the anchors count stops. - const insertAt = position === undefined ? stopsBefore.length : roadtripIndexOf(dayId, position) - if (insertAt >= stopsBefore.length || typeof at?.lat !== 'number' || typeof at?.lng !== 'number') return null - return reanchorAfterInsert(roadtripVias.byDay[dayId] ?? [], insertAt, viaLiesBefore(dayId, { lat: at.lat, lng: at.lng })) - }, [roadtripStopsOf, roadtripIndexOf, roadtripVias.byDay, viaLiesBefore]) - /** * Saves a corridor hit as a stop: the place itself, then its position in the day. * @@ -866,121 +702,6 @@ export function useTripPlanner() { * fuel — the road-trip kinds are their own dimension, deliberately not one of the * traveller's editable categories, so the palette and the meaning stay put. */ - /** - * Stores one of the three driving limits. - * - * Straight to the settings store rather than through the offline queue: these are - * per-user preferences on the settings table, the same path the map provider and the - * distance unit take, and they are read locally the moment they change. - */ - /** - * A colour per drawn line, or none at all. - * - * Off is not "all the same colour" but an absent array: the map then paints the blue it - * has always painted, so a trip that never turns this on renders byte for byte the way - * it did. - */ - /** - * Days folded down to their header in the road trip rail. - * - * Its own state rather than the day plan's `expandedDayIds`: that one is owned by - * `DayPlanSidebar`, which republishes it from its own state every time it mounts — so a - * day folded in the rail would spring back open the moment the other view was visited. - */ - const [collapsedRoadtripDays, setCollapsedRoadtripDays] = useState>(new Set()) - const toggleRoadtripDay = useCallback((dayId: number) => { - setCollapsedRoadtripDays(prev => { - const next = new Set(prev) - if (!next.delete(dayId)) next.add(dayId) - return next - }) - }, []) - - /** - * The road trip's lines, minus the days that are folded away. - * - * Folding a card takes that day off the map, which is most of what folding is for: a - * rail entry can be scrolled past, a line across the map cannot be looked away from. - * `lineDays` runs parallel to `lines`, so both are filtered in one pass and the colours - * stay lined up with what is left. - */ - const roadtripMapLines = useMemo(() => { - if (!collapsedRoadtripDays.size) return roadtripRoutes.lines - const hidden = new Set( - roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber), - ) - return roadtripRoutes.lines.filter((_, i) => !hidden.has(roadtripRoutes.lineDays[i])) - }, [roadtripRoutes.lines, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays]) - - /** - * The map's places with the folded cards' stops taken out. - * - * A second pass rather than a branch inside `mapPlaces`: that memo runs long before the - * road trip is routed, and the answer here needs the CHAINS — a card is a date, and - * after a night drive it holds stops stored on the day before (`nightSpill.ts`), so - * what a folded card hides is what is drawn on it, not what is filed under its day. - * - * A place still drawn on some other card stays, which is the rule the day plan's own - * declutter follows. - */ - const roadtripMapPlaces = useMemo(() => { - const plannedIds = new Set(Object.values(assignments).flat().map(a => a.place_id)) - // The hotel a day sets out from or ends at is drawn with its drive whether or not a day - // still holds its stop: with that stop removed, the line and the walk to the door ended - // at a spot with no pin, where the phone's stage map has one (`stagePlaceIds`). - for (const day of roadtripRoutes.days) { - for (const stop of day.stops) if (stop.bookend) plannedIds.add(stop.placeId) - } - const plannedPlaces = mapPlaces.filter(p => plannedIds.has(p.id)) - if (!collapsedRoadtripDays.size) return plannedPlaces - const hidden = new Set() - for (const day of roadtripRoutes.days) { - if (!collapsedRoadtripDays.has(day.dayId)) continue - for (const stop of day.stops) if (!stop.automaticNight) hidden.add(stop.placeId) - } - for (const day of roadtripRoutes.days) { - if (collapsedRoadtripDays.has(day.dayId)) continue - for (const stop of day.stops) if (!stop.automaticNight) hidden.delete(stop.placeId) - } - return plannedPlaces.filter(p => !hidden.has(p.id)) - }, [mapPlaces, assignments, roadtripRoutes.days, collapsedRoadtripDays]) - - // The recorded route follows the same folds as the places above, so a - // collapsed day does not leave its line behind on the map. Keyed on the joined - // dates rather than on the Set, because `days` changes identity on every store - // update and the GL overlay rebuilds its source whenever this reference moves. - const dawarichHiddenKey = useMemo( - () => collapsedDayDates(days, expandedDayIds, roadtripActive ? collapsedRoadtripDays : null).join('|'), - [days, expandedDayIds, roadtripActive, collapsedRoadtripDays], - ) - const dawarichHiddenDates = useMemo( - () => (dawarichHiddenKey ? new Set(dawarichHiddenKey.split('|')) : null), - [dawarichHiddenKey], - ) - - const roadtripLineColors = useMemo( - () => { - if (!roadtripSettings.roadtrip_day_colors) return undefined - const hidden = new Set( - roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber), - ) - return roadtripRoutes.lineDays.filter(n => !hidden.has(n)).map(n => dayColor(n)) - }, - [roadtripSettings.roadtrip_day_colors, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays], - ) - - const saveRoadtripLimit = useCallback(async (key: string, value: number | string | boolean) => { - try { - if (!can('day_edit', trip) || !roadtripPreferencesState.ready) return - const userId = useAuthStore.getState().user?.id - if (!userId) return - const preferences = await roadtripPreferencesRepo.update(tripId, { [key]: value }) - publishRoadtripPreferences(userId, tripId, preferences) - } catch { - toast.error(t('places.saveError')) - } - }, [tripId, trip, can, roadtripPreferencesState.ready, toast, t]) - const saveStopDraft = useCallback(async ( { stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number }, { releaseStay = false }: { releaseStay?: boolean } = {}, @@ -1396,21 +1117,6 @@ export function useTripPlanner() { automaticPoints.focusPoint(lat, lng) }, [refuel, routeAlternatives, automaticPoints]) const roadtripMapVias = automaticPoints.markers - const dayBoundaryControls = useMemo(() => { - if (!dayBoundaries.editable || !can('day_edit', trip) || !roadtripRoutes.boundaryPath?.length) return undefined - return { - path: roadtripRoutes.boundaryPath, - hint: t('roadtrip.window.dragHint'), - move: async (day, boundary) => { - const next = dayBoundaries.boundaries.filter(b => b.day_number !== day) - if (boundary) next.push(boundary) - const issue = roadtripRoutes.validateBoundaries?.(next) - if (boundary && issue) { toast.error(t(`roadtrip.window.${issue}`, { days: MAX_TRIP_DAYS })); return false } - try { return await dayBoundaries.save(day, boundary) } - catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')); return false } - }, - } - }, [dayBoundaries.editable, dayBoundaries.boundaries, dayBoundaries.save, can, trip, roadtripRoutes, t, toast]) const mapFocusPoints = useMemo<[number, number][]>( () => (refuel.offered.length ? refuel.offered.map(p => [p.lat, p.lng] as [number, number])