refactor(client): the road trip feed and the layers it draws move into useRoadtripFeed

The road trip mode and feed flags, the driving preferences and the limit
save, the day boundaries with their reset and drag controls, the vias,
the refuel search, the routes, the corridor search, the followed track,
the via counts and the via index helpers, the days folded in the rail and
the map layers that follow those folds move out of useTripPlanner into
useRoadtripFeed.

useTripPlanner calls the hook where the feed used to sit, right after the
trip overview, so the preference load, the boundary and via hooks, the
routing round and the corridor search still run in the same order and the
planner returns the same object. The helpers, the folds and the boundary
controls are plain memos and callbacks, so computing them earlier changes
nothing.
This commit is contained in:
Maurice
2026-10-08 01:55:13 +00:00
parent 1db227cb5b
commit bde734c50f
3 changed files with 849 additions and 311 deletions
@@ -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<number, Array<{ id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }>> },
viasArgs: [] as unknown[],
routes: {
days: [] as Array<Record<string, unknown>>,
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<typeof useRoadtripFeed>[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> = {}): 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<Options> = {}) {
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')
})
})
@@ -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<PlannerBase, 'tripId' | 'trip' | 'can' | 'toast' | 't'> {
isMobile: boolean
activeTab: string
enabledAddons: Record<string, boolean>
/** 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<number, number> = {}
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<Set<number>>(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<number>()
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<DayBoundaryControls | undefined>(() => {
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<typeof useRoadtripFeed>
+17 -311
View File
@@ -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<number, number> = {}
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<Set<number>>(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<number>()
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<DayBoundaryControls | undefined>(() => {
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])