diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
index 13a6c42c4..56feadc93 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
@@ -1087,3 +1087,73 @@ describe('RoadtripModeSwitch', () => {
expect(onChange).toHaveBeenCalledWith(false)
})
})
+
+describe('RoadtripSidebar with a ride (#2428)', () => {
+ const terminal = (role: 'departure' | 'arrival') =>
+ stop({
+ assignmentId: role === 'departure' ? -3000000140 : -3000000141,
+ name: role === 'departure' ? 'Hamburg Airport' : 'Munich Airport',
+ placeId: -70,
+ lat: role === 'departure' ? 53.63 : 48.35,
+ lng: role === 'departure' ? 9.99 : 11.78,
+ time: role === 'departure' ? '12:20' : '14:30',
+ dwellMinutes: role === 'departure' ? 60 : 0,
+ legMode: role === 'departure' ? 'flight' : null,
+ incomingLegMode: role === 'arrival' ? 'flight' : null,
+ carrier: {
+ reservationId: 70,
+ type: 'flight',
+ role,
+ title: 'LH 2020',
+ code: role === 'departure' ? 'HAM' : 'MUC',
+ at: role === 'departure' ? '13:20' : '14:30',
+ },
+ })
+ const flightDay = () => {
+ const stops = [
+ stop({ assignmentId: 1, name: 'Hamburg' }),
+ terminal('departure'),
+ terminal('arrival'),
+ stop({ assignmentId: 2, name: 'Munich' }),
+ ]
+ return day({
+ stops,
+ legs: [leg(), leg({ mode: 'flight', distance: 0, duration: 4200, distanceText: '', durationText: '1 h 10 min' }), leg()],
+ schedule: {
+ entries: [
+ { arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 },
+ { arrival: '12:20', departure: '13:20', anchored: true, dayOffset: 0 },
+ { arrival: '14:30', departure: '14:30', anchored: true, dayOffset: 0 },
+ { arrival: '15:30', departure: '15:30', anchored: false, dayOffset: 0 },
+ ],
+ warnings: [],
+ },
+ })
+ }
+
+ it('FE-ROADTRIP-SIDEBAR-049: the terminals carry the timetable and no number, the ride carries the booking and its minutes', () => {
+ wrap()
+ expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
+ expect(screen.getByText('HAM')).toBeInTheDocument()
+ expect(screen.getByText('Departure 13:20')).toBeInTheDocument()
+ expect(screen.getByText('Arrival 14:30')).toBeInTheDocument()
+ expect(screen.getByText('LH 2020 · 1 h 10 min')).toBeInTheDocument()
+ // Hamburg is 1 and Munich is 2: the terminals between them take no number, and
+ // the day header counts two stops.
+ expect(screen.queryByText('3')).not.toBeInTheDocument()
+ expect(screen.getByText('2 stops')).toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-050: a terminal and the ride open the booking; the road out of the arrival offers no other ways', () => {
+ const onOpenCarrier = vi.fn()
+ const onAskAlternatives = vi.fn()
+ wrap()
+ fireEvent.click(screen.getByText('Hamburg Airport'))
+ fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min'))
+ expect(onOpenCarrier).toHaveBeenCalledTimes(2)
+ expect(onOpenCarrier).toHaveBeenCalledWith(70)
+ // One shuffle: the road into the departure terminal. Not the ride, not the road out
+ // of the arrival, whose via would be filed at the terminal's index.
+ expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
+ })
+})
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index f80292918..f92b22bbf 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -17,7 +17,7 @@ import { formatDistance } from '../../utils/units'
import { formatDate, formatClockTime } from '../../utils/formatters'
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel'
import { STOP_KIND_BY_KEY } from './stopKinds'
-import { legReroutable } from './roadtripRowModel'
+import { destinationCount, legReroutable } from './roadtripRowModel'
import { spurWorthLabelling } from './accessSpur'
import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker'
@@ -31,6 +31,7 @@ import { FS } from './typeScale'
import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop'
+import { carrierIcon, rideText, terminalLine } from './carrierRide'
import type { StayDraft } from './RoadtripStayModal'
import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading'
@@ -102,6 +103,11 @@ interface RoadtripSidebarProps {
collapsedDayIds?: Set
/** Absent leaves every card open and its header unclickable. */
onToggleDay?: (dayId: number) => void
+ /**
+ * Opens the booking a terminal or a ride stands for (#2428). Absent leaves the
+ * terminal rows and the ride pills as plain text.
+ */
+ onOpenCarrier?: (reservationId: number) => void
}
const MODE_ICON: Record = {
@@ -748,6 +754,122 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
)
}
+/**
+ * One end of a ride: the airport, station or port the drive stops at or resumes from.
+ *
+ * No number, no stay, no kind picker and no drag handle: it is not a stop anybody chose
+ * and cannot be moved or turned into anything, it is where the booking puts the
+ * traveller. What it shows is the booking's own clock under its name, and on the right
+ * the time the chain wants the traveller there, which for a departure is a check-in ahead
+ * of the timetable. The whole row opens the booking.
+ */
+function TerminalStop({ stop, entry, late, selected, continues, starts, onOpen }: {
+ stop: RoadtripStop
+ entry: ScheduleEntry | undefined
+ late: ScheduleWarning[]
+ selected: boolean
+ continues: boolean
+ starts?: boolean
+ onOpen?: () => void
+}): React.ReactElement {
+ const { t } = useTranslation()
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+ const carrier = stop.carrier!
+ const Icon = carrierIcon(carrier.type)
+ const line = terminalLine(carrier, t, is12h)
+ return (
+
+ )
+}
+
+/**
+ * The ride between two terminals, where a drive band would be.
+ *
+ * The booking and its minutes, no distance and no other ways: a flight has none. It opens
+ * the booking, the same thing the terminals do, so everything about the ride is one tap
+ * away from the chain.
+ */
+function RideBand({ carrier, seg, onOpen }: {
+ carrier: NonNullable
+ seg: RouteSegment | undefined
+ onOpen?: () => void
+}): React.ReactElement {
+ const { t } = useTranslation()
+ const Icon = carrierIcon(carrier.type)
+ const band = (
+ <>
+
+
+ {rideText(carrier, seg)}
+
+ >
+ )
+ return (
+
+
+
+
+
+ {onOpen ? (
+
+
+
+ ) : (
+
+ {band}
+
+ )}
+
+
+ )
+}
+
/**
* A charger, a filling station or a rest stop — a stop that interrupts the drive.
*
@@ -1289,7 +1411,7 @@ function SpillBlock({ spill, children }: {
* move, a stay edit or a refuel offer still names the day the server knows it by. See
* `nightSpill.ts`.
*/
-function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
+function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
day: RoadtripDay
/** Folded down to the header, and off the map with it. */
@@ -1303,6 +1425,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
drag: DragState
onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives']
openAlternatives?: RoadtripSidebarProps['openAlternatives']
+ onOpenCarrier?: RoadtripSidebarProps['onOpenCarrier']
onEditStay?: RoadtripSidebarProps['onEditStay']
onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
onSetStopFill?: RoadtripSidebarProps['onSetStopFill']
@@ -1356,12 +1479,41 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
)) : null}
)
- const service = isServiceStopType(stop.stopType)
- if (!service) counted += 1
// Every finding at this index is read on its own. Taking the first match let an
// overnight crossing swallow the "you arrive late" flag without a trace, and a stop
// can be late for the time it is reached and the time it is left at both at once.
const lateness = day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
+ // A terminal: not draggable, not numbered, not a place. Between a departure and its
+ // arrival the leg is the ride, drawn as the booking; behind an arrival the road goes
+ // on as usual, without other ways to drive it (`legReroutable`).
+ if (stop.carrier) {
+ const next = day.stops[i + 1]
+ const ride = stop.carrier.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId
+ const open = onOpenCarrier ? () => onOpenCarrier(stop.carrier!.reservationId) : undefined
+ return (
+
+ )
+ }
+ const service = isServiceStopType(stop.stopType)
+ if (!service) counted += 1
const ownDay = stop.ownerDayId
const ownIndex = stop.ownerIndex
return (
@@ -1575,7 +1727,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
) : null}
{day.legs.length > 0 ? (
- {t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })}
+ {t('roadtrip.day.stopCount', { count: destinationCount(day) })}
) : null}
{/* The other half of "where possible". The setting is a weighting, so a day
@@ -1737,7 +1889,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
* them is a pill just for being a number.
*/
export default function RoadtripSidebar({
- routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
+ routes, selectedAssignmentId, onSelectStop, onOpenCarrier, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
collapsedDayIds, onToggleDay, onFocusPoint,
}: RoadtripSidebarProps): React.ReactElement {
@@ -1795,6 +1947,7 @@ export default function RoadtripSidebar({
drag={drag}
onAskAlternatives={onAskAlternatives}
openAlternatives={openAlternatives}
+ onOpenCarrier={onOpenCarrier}
onEditStay={onEditStay}
onSetStopKind={onSetStopKind}
onSetStopFill={onSetStopFill}
diff --git a/client/src/components/Roadtrip/carrierRide.ts b/client/src/components/Roadtrip/carrierRide.ts
new file mode 100644
index 000000000..2c9fdcba2
--- /dev/null
+++ b/client/src/components/Roadtrip/carrierRide.ts
@@ -0,0 +1,45 @@
+import { Bus, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
+import { formatDurationShort } from './roadtripModel'
+import { formatClockTime } from '../../utils/formatters'
+import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
+import type { TranslationFn } from '../../types'
+
+/**
+ * How a ride and its two terminals read on the rail and in the phone chain (#2428).
+ *
+ * One module for both shells, because the two would otherwise carry the same icon table
+ * and the same three sentences each, and the duplication budget does not stretch to that.
+ * The icons are the booking panel's for the same types, so a flight looks like a flight
+ * wherever the trip shows it.
+ */
+const CARRIER_ICON: Record = {
+ flight: Plane,
+ train: Train,
+ ferry: Sailboat,
+ cruise: Ship,
+ bus: Bus,
+}
+
+export function carrierIcon(type: string): LucideIcon {
+ return CARRIER_ICON[type] ?? Plane
+}
+
+/**
+ * The line under a terminal's name: the timetable's clock at it, in the reader's own
+ * clock format. Nothing when the booking names no time.
+ */
+export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h: boolean): string | null {
+ if (!carrier.at) return null
+ const time = formatClockTime(carrier.at, is12h)
+ return carrier.role === 'departure' ? t('roadtrip.ride.departure', { time }) : t('roadtrip.ride.arrival', { time })
+}
+
+/**
+ * What the ride's pill says: the booking, then how long it takes when the timetable gives
+ * both ends. A ride with no minutes is still the booking, and says so without a duration.
+ */
+export function rideText(carrier: CarrierTerminal, seg: RouteSegment | undefined): string {
+ const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0
+ const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '')
+ return duration ? `${carrier.title} · ${duration}` : carrier.title
+}
diff --git a/client/src/components/Roadtrip/roadtripRowModel.test.ts b/client/src/components/Roadtrip/roadtripRowModel.test.ts
index 9295537bf..070800139 100644
--- a/client/src/components/Roadtrip/roadtripRowModel.test.ts
+++ b/client/src/components/Roadtrip/roadtripRowModel.test.ts
@@ -520,3 +520,50 @@ describe('legReroutable', () => {
expect(legReroutable(extra, -1)).toBe(false)
})
})
+
+describe('roadtripRows with a ride (#2428)', () => {
+ const terminal = (name: string, role: 'departure' | 'arrival', at: string | null = '13:20') =>
+ stop(name, {
+ assignmentId: role === 'departure' ? -3000000014 : -3000000015,
+ placeId: -7,
+ carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: role === 'departure' ? 'MUC' : 'HAM', at },
+ legMode: role === 'departure' ? 'flight' : null,
+ incomingLegMode: role === 'arrival' ? 'flight' : null,
+ })
+ const ride: RouteSegment = { ...seg(9), distance: 0, duration: 4200, mode: 'flight', distanceText: '', durationText: '1 h 10 min' }
+ const flightDay = () =>
+ day([stop('Bremen'), terminal('Munich Airport', 'departure'), terminal('Hamburg Airport', 'arrival', '14:30'), stop('Hotel')], {
+ legs: [seg(0), ride, seg(2)],
+ })
+
+ it('FE-RTROW-045: the terminals are stop rows without a number, and the numbering skips them', () => {
+ const rows = stopRows(roadtripRows(flightDay()))
+ expect(rows.map(r => [r.stop.name, r.number])).toEqual([
+ ['Bremen', 1],
+ ['Munich Airport', null],
+ ['Hamburg Airport', null],
+ ['Hotel', 2],
+ ])
+ expect(destinationCount(flightDay())).toBe(2)
+ })
+
+ it('FE-RTROW-046: between a departure and its arrival the row is the ride, not a leg; the road out of the arrival is a leg', () => {
+ const rows = roadtripRows(flightDay())
+ expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'ride', 'stop', 'leg', 'stop'])
+ const rideRow = rows[3]
+ expect(rideRow.kind === 'ride' && rideRow.carrier.title).toBe('LH 2020')
+ expect(rideRow.kind === 'ride' && rideRow.seg).toBe(ride)
+ })
+
+ it('FE-RTROW-047: the road into a departure terminal can be offered other ways, nothing leaving a terminal can', () => {
+ const d = flightDay()
+ expect(legReroutable(d, 0)).toBe(true)
+ expect(legReroutable(d, 1)).toBe(false)
+ expect(legReroutable(d, 2)).toBe(false)
+ })
+
+ it('FE-RTROW-048: a terminal never answers for a place, however its id reads', () => {
+ expect(firstStopOfPlace([flightDay()], -7)).toBeNull()
+ expect(firstStopOfPlace([flightDay()], 100)?.name).toBe('Bremen')
+ })
+})
diff --git a/client/src/components/Roadtrip/roadtripRowModel.ts b/client/src/components/Roadtrip/roadtripRowModel.ts
index 8659e006b..9778b114c 100644
--- a/client/src/components/Roadtrip/roadtripRowModel.ts
+++ b/client/src/components/Roadtrip/roadtripRowModel.ts
@@ -1,5 +1,5 @@
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
-import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
+import type { CarrierTerminal, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
import { readStay } from './stayReading'
/**
@@ -21,13 +21,18 @@ export type RoadtripRow =
| { kind: 'spill'; fromDayNumber: number; departs: string | null; stops: StopRow[] }
| StopRow
| { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null }
+ /**
+ * The ride between a departure terminal and its arrival (#2428): the booking, not a
+ * drive. Drawn in place of the leg row, with the booking's minutes and no distance.
+ */
+ | { kind: 'ride'; index: number; carrier: CarrierTerminal; seg: RouteSegment | undefined }
| { kind: 'dry'; legIndex: number; intoLegKm: number; sinceKm: number }
| { kind: 'auto'; phase: 'end' | 'resume'; time: string | null }
export interface StopRow {
kind: 'stop'
stop: RoadtripStop
- /** Position in the day's numbering, or null for a service stop and an automatic night. */
+ /** Position in the day's numbering, or null for a service stop, a terminal and an automatic night. */
number: number | null
service: boolean
entry: ScheduleEntry | undefined
@@ -156,14 +161,20 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
time: day.schedule.entries[i]?.arrival ?? null,
})
} else {
- if (!isServiceStopType(stop.stopType)) number += 1
- rows.push(stopRow(stop, i, isServiceStopType(stop.stopType) ? null : number, day.schedule, day.driveWarnings))
+ // A terminal is where the drive stops or resumes, not a place the trip is for: it
+ // carries no number, the way a service stop carries none.
+ const unnumbered = isServiceStopType(stop.stopType) || !!stop.carrier
+ if (!unnumbered) number += 1
+ rows.push(stopRow(stop, i, unnumbered ? null : number, day.schedule, day.driveWarnings))
}
// The leg AFTER this stop, plus the dry point that falls on it. Both belong
// between two stops, so they are emitted here rather than in their own pass.
const seg = day.legs[i]
- if (i < day.stops.length - 1 && !isStationary(seg)) {
+ const next = day.stops[i + 1]
+ if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) {
+ rows.push({ kind: 'ride', index: i, carrier: stop.carrier, seg })
+ } else if (i < day.stops.length - 1 && !isStationary(seg)) {
rows.push({ kind: 'leg', index: i, seg, mode: day.stops[i + 1]?.incomingLegMode ?? stop.legMode ?? null })
const dry = (day.dryPoints ?? []).find(p => p.legIndex === i)
if (dry) {
@@ -193,11 +204,16 @@ export function legReroutable(day: RoadtripDay, index: number): boolean {
&& !!day.legs[index]
&& !day.stops[index].automaticNight
&& !day.stops[index + 1].automaticNight
+ // A leg leaving a terminal is either the ride, which has no other way, or the road
+ // out of an arrival, whose via would be filed at the terminal's index, which is the
+ // index of the stop after it. The road INTO a departure terminal leaves a stored
+ // stop and can be offered other ways like any other.
+ && !day.stops[index].carrier
}
/** Stops that carry a number, for a count that agrees with the numbering above. */
export function destinationCount(day: RoadtripDay): number {
- return day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length
+ return day.stops.filter(s => !s.automaticNight && !s.carrier && !isServiceStopType(s.stopType)).length
}
/**
@@ -276,7 +292,7 @@ export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
*/
export function firstStopOfPlace(days: readonly RoadtripDay[], placeId: number): RoadtripStop | null {
for (const day of days) {
- const stop = day.stops.find(s => !s.automaticNight && s.placeId === placeId)
+ const stop = day.stops.find(s => !s.automaticNight && !s.carrier && s.placeId === placeId)
if (stop) return stop
}
return null
diff --git a/client/src/components/Roadtrip/useCorridorPois.test.ts b/client/src/components/Roadtrip/useCorridorPois.test.ts
index ecf2c2032..1b9ebc292 100644
--- a/client/src/components/Roadtrip/useCorridorPois.test.ts
+++ b/client/src/components/Roadtrip/useCorridorPois.test.ts
@@ -261,4 +261,26 @@ describe('useCorridorPois', () => {
expect(result.current.failedAreas).toBeGreaterThan(0)
expect(pois.mock.calls.length).toBeLessThan(result.current.progress.total + 1)
})
+ it('FE-ROADTRIP-CORRIDOR-018: a ride is left out of the search, and a hit under the flight path is dropped (#2428)', async () => {
+ // A long line whose middle is a flight: boxes are asked for the two driven ends only,
+ // and nothing found between the two terminals is on the way.
+ const from = LONG[3]
+ const to = LONG[8]
+ pois.mockResolvedValue(answer(hit('under-the-flight', LONG[5].lat, LONG[5].lng), hit('on-the-road', LONG[1].lat, LONG[1].lng + 0.01)))
+ const { result } = renderHook(() => useCorridorPois(LONG, ['fuel'], 10, { gaps: [{ from, to }] }))
+ act(() => { result.current.search() })
+ await waitFor(() => expect(result.current.loading).toBe(false))
+
+ const asked = pois.mock.calls.map(c => c[1] as { south: number; north: number })
+ // No box covers the middle of the ride.
+ expect(asked.some(b => b.south <= LONG[5].lat && b.north >= LONG[5].lat && b.north - b.south < 0.2)).toBe(false)
+ expect(result.current.results.map(r => r.osm_id)).toEqual(['on-the-road'])
+
+ // The same line without the ride searches straight through.
+ const { result: plain } = renderHook(() => useCorridorPois(LONG, ['fuel'], 10))
+ act(() => { plain.current.search() })
+ await waitFor(() => expect(plain.current.loading).toBe(false))
+ expect(plain.current.results.map(r => r.osm_id).sort()).toEqual(['on-the-road', 'under-the-flight'])
+ expect(plain.current.progress.total).toBeGreaterThan(result.current.progress.total)
+ })
})
diff --git a/client/src/components/Roadtrip/useCorridorPois.ts b/client/src/components/Roadtrip/useCorridorPois.ts
index 16c863053..4e79d93bd 100644
--- a/client/src/components/Roadtrip/useCorridorPois.ts
+++ b/client/src/components/Roadtrip/useCorridorPois.ts
@@ -85,9 +85,22 @@ export function useCorridorPois(
* is not a desk and a search that keeps the radio warm for a minute is a search that
* costs battery somebody is navigating on.
*/
- options?: { budget?: CorridorBudget },
+ options?: {
+ budget?: CorridorBudget
+ /**
+ * Stretches of the line the car never drives (#2428): a flight, a ferry, a train
+ * between two terminals. The line runs straight from one terminal to the other
+ * there, and boxing that would search open sea, or the countryside under a flight
+ * path, and offer what it found as being on the way. Each pair is projected onto the
+ * spine and the stretch between the two is left out of the search, and a hit that
+ * lands in it is dropped.
+ */
+ gaps?: { from: LatLng; to: LatLng }[]
+ },
): CorridorSearch {
const budget = options?.budget ?? DESKTOP_CORRIDOR_BUDGET
+ const gaps = options?.gaps
+ const gapKey = (gaps ?? []).map(g => `${g.from.lat.toFixed(5)},${g.from.lng.toFixed(5)}>${g.to.lat.toFixed(5)},${g.to.lng.toFixed(5)}`).join(';')
const { locale } = useTranslation()
const [results, setResults] = useState([])
const [progress, setProgress] = useState({ done: 0, total: 0 })
@@ -139,7 +152,31 @@ export function useCorridorPois(
// spine would renumber both the moment the window moved, and a hit would land at the
// wrong position in the day's chain.
const tileLine = window ? sliceAtMeters(spine, window.fromKm * 1000, window.toKm * 1000) : spine
- const allTiles = corridorTiles(tileLine, widthKm)
+ // The ridden stretches, as kilometres along the spine, in order.
+ const skipped = (gaps ?? [])
+ .map(gap => {
+ const a = projectOntoRoute(gap.from, spine)?.alongKm
+ const b = projectOntoRoute(gap.to, spine)?.alongKm
+ return a === undefined || b === undefined ? null : { fromKm: Math.min(a, b), toKm: Math.max(a, b) }
+ })
+ .filter((g): g is { fromKm: number; toKm: number } => g !== null && g.toKm > g.fromKm)
+ .sort((a, b) => a.fromKm - b.fromKm)
+ const inGap = (alongKm: number): boolean => skipped.some(g => alongKm > g.fromKm + 0.05 && alongKm < g.toKm - 0.05)
+ // The tile line cut around the gaps: one piece per driven stretch.
+ const pieces: LatLng[][] = []
+ if (skipped.length) {
+ const start = window?.fromKm ?? 0
+ const end = window?.toKm ?? Number.POSITIVE_INFINITY
+ let cursor = start
+ for (const gap of skipped) {
+ if (gap.fromKm > cursor) pieces.push(sliceAtMeters(spine, cursor * 1000, Math.min(gap.fromKm, end) * 1000))
+ cursor = Math.max(cursor, gap.toKm)
+ }
+ if (cursor < end) pieces.push(Number.isFinite(end) ? sliceAtMeters(spine, cursor * 1000, end * 1000) : sliceAtMeters(spine, cursor * 1000, Number.MAX_SAFE_INTEGER))
+ } else {
+ pieces.push(tileLine)
+ }
+ const allTiles = pieces.filter(piece => piece.length > 1).flatMap(piece => corridorTiles(piece, widthKm))
const tiles = allTiles.slice(0, budget.maxTiles)
const startedAt = Date.now()
const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs
@@ -184,7 +221,7 @@ export function useCorridorPois(
for (const poi of data.pois) {
if (seen.has(poi.osm_id)) continue
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
- if (!hit || hit.offRouteKm > widthKm) continue
+ if (!hit || hit.offRouteKm > widthKm || inGap(hit.alongKm)) continue
seen.set(poi.osm_id, {
...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null,
opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null,
@@ -251,7 +288,10 @@ export function useCorridorPois(
setError(failures === jobs.length && failures > 0)
setLoading(false)
})()
- }, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs])
+ // gapKey stands in for `gaps`: the same terminals mean the same gaps, and a fresh
+ // array every render would make every search a new function.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs, gapKey])
useEffect(() => () => abortRef.current?.abort(), [])
diff --git a/client/src/components/Roadtrip/useRoadtripCorridor.ts b/client/src/components/Roadtrip/useRoadtripCorridor.ts
index 2b922cfec..c9bcb5b9a 100644
--- a/client/src/components/Roadtrip/useRoadtripCorridor.ts
+++ b/client/src/components/Roadtrip/useRoadtripCorridor.ts
@@ -119,7 +119,21 @@ export function useRoadtripCorridor(
return day.stops.map(s => ({ lat: s.lat, lng: s.lng }))
}, [day])
- const search = useCorridorPois(line, categories, widthKm, options)
+ // The rides on this day: between a departure terminal and its arrival the car is not
+ // on the road, so that stretch of the line is not searched (#2428).
+ const gaps = useMemo(() => {
+ if (!day) return []
+ const out: { from: LatLng; to: LatLng }[] = []
+ day.stops.forEach((stop, i) => {
+ const next = day.stops[i + 1]
+ if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) {
+ out.push({ from: { lat: stop.lat, lng: stop.lng }, to: { lat: next.lat, lng: next.lng } })
+ }
+ })
+ return out
+ }, [day])
+
+ const search = useCorridorPois(line, categories, widthKm, useMemo(() => ({ ...options, gaps }), [options, gaps]))
/**
* Whether what the panel looks for has been decided yet.
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
index 02f986fbb..de0bed182 100644
--- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
@@ -849,3 +849,96 @@ describe('a visit End on the road trip', () => {
vi.mock('../../hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
}))
+
+describe('a booking the traveller rides (#2428)', () => {
+ const MUC: [number, number] = [48.3538, 11.7861]
+ const HAM: [number, number] = [53.6304, 9.9882]
+ const flight = (over: Record = {}) => ({
+ id: 70,
+ trip_id: 7,
+ title: 'LH 2020 HAM → MUC',
+ type: 'flight',
+ status: 'confirmed',
+ day_id: 1,
+ end_day_id: 1,
+ reservation_time: '13:20',
+ reservation_end_time: '14:30',
+ endpoints: [
+ { role: 'from', sequence: 0, name: 'Hamburg Airport', code: 'HAM', lat: HAM[0], lng: HAM[1], timezone: null, local_time: null, local_date: null },
+ { role: 'to', sequence: 1, name: 'Munich Airport', code: 'MUC', lat: MUC[0], lng: MUC[1], timezone: null, local_time: null, local_date: null },
+ ],
+ ...over,
+ }) as unknown as import('../../types').Reservation
+
+ /** An hour for whatever pair the router is handed. */
+ const hourly = (points: { lat: number; lng: number }[]) => ({
+ coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
+ distance: 100000 * (points.length - 1),
+ duration: 3600 * (points.length - 1),
+ legs: points.slice(1).map(() => ({ distance: 100000, duration: 3600, text: '' })),
+ })
+
+ afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
+
+ it('FE-ROADTRIP-ROUTES-040: the terminals seam the day, the ride is never routed, and the roads to and from them are', async () => {
+ calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
+ const stops: StopSpec[] = [
+ { id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
+ { id: 2, at: LUENEBURG, time: '10:00', dwell: 0 },
+ { id: 3, at: BERLIN, dwell: 0 },
+ ]
+ const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [flight()]))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ const d = result.current.days[0]
+ expect(d.stops.map(s => s.carrier?.role ?? s.name)).toEqual(['Stop 1', 'Stop 2', 'departure', 'arrival', 'Stop 3'])
+ // Two road runs went to the router: up to the departure terminal, and from the arrival on.
+ expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
+ const asked = calculateRouteWithLegs.mock.calls.map(c => c[0].map((p: { lat: number }) => p.lat))
+ expect(asked).toEqual([[HAMBURG[0], LUENEBURG[0], HAM[0]], [MUC[0], BERLIN[0]]])
+ expect(d.legs.map(l => l?.mode)).toEqual(['driving', 'driving', 'flight', 'driving'])
+ expect(d.legs[2]).toMatchObject({ distance: 0, duration: 70 * 60 })
+ // An hour ahead of the flight at the airport, off on the timetable, landed on it.
+ expect(d.schedule.entries.map(e => [e.arrival, e.departure])).toEqual([
+ ['09:00', '09:00'],
+ ['10:00', '10:00'],
+ ['12:20', '13:20'],
+ ['14:30', '14:30'],
+ ['15:30', '15:30'],
+ ])
+ // The ride draws no road of its own; the three road legs do.
+ expect(result.current.lines).toHaveLength(3)
+ expect(result.current.totalStops).toBe(3)
+ expect(result.current.totalDistance).toBe(300000)
+ })
+
+ it('FE-ROADTRIP-ROUTES-041: a ride landing on a later day seams two cards, and with connected days the ride is the join', async () => {
+ calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
+ act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_connect_days: true } }))
+ const days = [day(1, 1), day(2, 2)]
+ const assignments = {
+ ...map(1, [{ id: 1, at: HAMBURG, time: '18:00', dwell: 0 }]),
+ ...map(2, [{ id: 2, at: BERLIN, dwell: 0 }]),
+ }
+ const overnight = flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 })
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], [], [overnight]))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ await waitFor(() => expect(result.current.days[1]?.arrivingLeg?.mode).toBe('flight'))
+ expect(result.current.days[0].stops.map(s => s.carrier?.role ?? s.name)).toEqual(['Stop 1', 'departure'])
+ expect(result.current.days[1].stops.map(s => s.carrier?.role ?? s.name)).toEqual(['arrival', 'Stop 2'])
+ expect(result.current.days[1].schedule.entries[0].arrival).toBe('07:00')
+ // The join between the days is the ride, and the router was asked for the two roads only.
+ expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
+ expect(result.current.days[1].arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600, distance: 0 })
+ })
+
+ it('FE-ROADTRIP-ROUTES-042: a booking without located terminals, or one the traveller drives, changes nothing', async () => {
+ calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
+ const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
+ const unlocated = flight({ endpoints: [] })
+ const hireCar = flight({ id: 71, type: 'car' })
+ const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [unlocated, hireCar]))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ expect(result.current.days[0].stops.every(s => !s.carrier)).toBe(true)
+ expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts
index 4ca36efb4..f393dfc5b 100644
--- a/client/src/components/Roadtrip/useRoadtripRoutes.ts
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts
@@ -1,5 +1,5 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
-import { assembleRoadtrip, foldRouteRun, standsAsDay, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
+import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, seatCarrierStops, standsAsDay, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
import { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
import { resolveLegMode } from '../Planner/legMode'
@@ -7,7 +7,7 @@ import { splitIntoRuns, parseAvoid, type DriveLimits } from './roadtripModel'
import { spillChains } from './nightSpill'
import { useSettingsStore } from '../../store/settingsStore'
import { useVehicleRange } from './useVehicleRange'
-import type { Assignment, AssignmentsMap, Accommodation, Day, RouteAvoidClass, SnappedWaypoint } from '../../types'
+import type { Assignment, AssignmentsMap, Accommodation, Day, Reservation, RouteAvoidClass, SnappedWaypoint } from '../../types'
import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared'
import { dayWindow } from './dayWindow'
import { useTranslation } from '../../i18n/TranslationContext'
@@ -60,9 +60,7 @@ const stopKey = (s: RoadtripStop): string =>
* so the two cannot drift and quietly stop refetching.
*/
const seamShape = (from: RoadtripStop, viasByDay: Record): string =>
- (viasByDay[from.ownerDayId] ?? [])
- .filter(v => v.after_order_index === from.ownerIndex)
- .sort((a, b) => a.sequence - b.sequence)
+ viasLeaving(from, viasByDay[from.ownerDayId] ?? [])
.map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`)
.join('|')
@@ -70,6 +68,7 @@ const seamShape = (from: RoadtripStop, viasByDay: Record)
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
const EMPTY_ACCOMMODATIONS: Accommodation[] = []
+const EMPTY_RESERVATIONS: Reservation[] = []
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[]): RoadtripStop | null => {
const p = a.place
@@ -127,6 +126,13 @@ export function useRoadtripRoutes(
viasByDay: Record = {},
boundaries: RoadtripDayBoundary[] = [],
accommodations: Accommodation[] = EMPTY_ACCOMMODATIONS,
+ /**
+ * The trip's bookings, for the ones the traveller rides (#2428). A flight, train,
+ * ferry, cruise or bus with located terminals seams the drive: the road ends at the
+ * terminal it leaves from and starts again at the one it lands at, and the leg
+ * between the two is the ride, never a road.
+ */
+ reservations: Reservation[] = EMPTY_RESERVATIONS,
): RoadtripRoutes {
const { t } = useTranslation()
const routeProfile = fallbackProfile || 'driving'
@@ -202,47 +208,45 @@ export function useRoadtripRoutes(
[legMinutes, dayMinutes, planningRangeKm, fillPercent],
)
- const plan = useMemo(() => {
+ // The seams the bookings make. Only the rides matter here, and only their days,
+ // clocks, terminals and seats, so a renamed booking or a new note on it changes nothing.
+ const rideSeams = useMemo(
+ () => reservations.map(r => carrierSeam(r)).filter((seam): seam is CarrierSeam => seam !== null),
+ [reservations],
+ )
+
+ // Every stored day as the road trip reads it: its located stops in stored order, with
+ // the terminals of its rides seated among them. Built once and split below, because
+ // the two halves are one list read two ways.
+ const storedDays = useMemo(() => {
return [...days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(d => {
- const stops = (assignments[String(d.id)] ?? [])
+ const located = (assignments[String(d.id)] ?? [])
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
- .map(a => asStop(a, d.id, 0, accommodations))
- .filter((s): s is RoadtripStop => s !== null)
- // The index is filled in after the drop, because it is the index into THIS
- // list: an assignment whose place has no coordinates never becomes a stop, and
- // counting before the filter would name a row the rail does not draw.
- .map((s, i) => ({ ...s, ownerIndex: i }))
+ .map(a => ({ assignment: a, stop: asStop(a, d.id, 0, accommodations) }))
+ .filter((x): x is { assignment: Assignment; stop: RoadtripStop } => x.stop !== null)
+ // The index is filled in after the drop, because it is the index into THIS
+ // list: an assignment whose place has no coordinates never becomes a stop, and
+ // counting before the filter would name a row the rail does not draw.
+ const stops = located.map((x, i) => ({ ...x.stop, ownerIndex: i }))
return {
dayId: d.id,
dayNumber: d.day_number ?? 0,
date: d.date ?? null,
title: d.title ?? null,
- stops,
+ stops: seatCarrierStops(d.id, stops, located.map((x, i) => x.assignment.order_index ?? i), rideSeams),
}
})
- .filter(d => standsAsDay(d.stops))
- }, [days, assignments, accommodations])
+ }, [days, assignments, accommodations, rideSeams])
- const quietDays = useMemo(() => {
- return [...days]
- .sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
- .map(d => ({
- dayId: d.id,
- dayNumber: d.day_number ?? 0,
- date: d.date ?? null,
- title: d.title ?? null,
- stops: (assignments[String(d.id)] ?? [])
- .slice()
- .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
- .map(a => asStop(a, d.id, 0, accommodations))
- .filter((s): s is RoadtripStop => s !== null)
- .map((s, i) => ({ ...s, ownerIndex: i })),
- }))
- .filter(d => !standsAsDay(d.stops))
- }, [days, assignments, accommodations])
+ const plan = useMemo(() => storedDays.filter(d => standsAsDay(d.stops)), [storedDays])
+
+ const quietDays = useMemo(() => storedDays.filter(d => !standsAsDay(d.stops)), [storedDays])
+
+ /** Days apart, for a ride that lands on a later day than it left. */
+ const dayNumberOf = (dayId: number): number => days.find(d => d.id === dayId)?.day_number ?? 0
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
// editing its notes must not fire a routing round.
@@ -301,6 +305,15 @@ export function useRoadtripRoutes(
* into a burst at the one host that refused it.
*/
const enqueueRun = (day: PlanDay, dayLegs: Record, run: RoadtripStop[], mode: string): void => {
+ // A ride is no road: the leg between its terminals is the booking's minutes, and
+ // no router is asked. Still a task of its own, so it is published with the rest
+ // and a day that is nothing but a ride is not left waiting for a round that never
+ // comes.
+ const rides = carrierLegsFor(run, mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey)
+ if (rides) {
+ tasks.push(async () => { Object.assign(dayLegs, rides) })
+ return
+ }
/** The same stops, as one request per pair. A pair has nothing left to split. */
const splitIntoPairs = (): void => {
if (run.length <= 2) return
@@ -323,9 +336,7 @@ export function useRoadtripRoutes(
// index — this used to be the position within the chain, so on any day that
// received a night drive every via matched nothing and quietly stopped
// shaping the road.
- ;(viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
- .filter(v => v.after_order_index === (stop.ownerIndex ?? i))
- .sort((a, b) => a.sequence - b.sequence)
+ viasLeaving(stop, viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
.forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
})
@@ -528,14 +539,20 @@ export function useRoadtripRoutes(
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
)
+ // A ride that lands on a later day is the one seam that is never a road: its
+ // terminals are on two cards, and the leg between them is the booking's minutes.
+ const ride = carrierLegsFor([seam.from, seam.to], mode, (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId), legKey)
+ if (ride) {
+ const key = legKey(seam.from, seam.to)
+ setSeamLegs(prev => ({ ...prev, [key]: { ...ride[key], shape: seamShape(seam.from, viasByDay) } }))
+ continue
+ }
try {
// The via points on this seam, threaded in the same way the day runs thread
// theirs. Without them a seam is the one stretch of the trip a via cannot
// shape: it is asked for on its own, so the points the traveller dropped on it
// never reached the router and dragging one did visibly nothing.
- const shaping = (viasByDay[seam.from.ownerDayId] ?? [])
- .filter(v => v.after_order_index === seam.from.ownerIndex)
- .sort((a, b) => a.sequence - b.sequence)
+ const shaping = viasLeaving(seam.from, viasByDay[seam.from.ownerDayId] ?? [])
const r = await calculateRouteWithLegs(
[
{ lat: seam.from.lat, lng: seam.from.lng },
diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx
index e392f5bd6..724b85b78 100644
--- a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx
+++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx
@@ -3,13 +3,14 @@ import type { CSSProperties, ReactNode } from 'react'
import MDancingTrek from '../../../components/MDancingTrek'
import MIconBtn from '../../../components/MIconBtn'
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
+import { carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
import { formatDistance } from '../../../../utils/units'
import { formatClockTime } from '../../../../utils/formatters'
import type { StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch'
import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
-import type { DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
+import type { CarrierTerminal, DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../../../types'
/**
@@ -52,6 +53,16 @@ export interface RowChrome {
*/
function Disc({ row, t, onPickKind }: { row: StopRow; t: TranslationFn; onPickKind?: () => void }) {
const kind = row.stop.stopType ? STOP_KIND_BY_KEY[row.stop.stopType] : undefined
+ // A terminal wears the booking's icon and is not a control: nobody turns an airport
+ // into a petrol station.
+ if (row.stop.carrier) {
+ const Icon = carrierIcon(row.stop.carrier.type)
+ return (
+
+
+
+ )
+ }
const face = row.service && kind
? (
{warningMark(row.warning, chrome)})
+ const terminal = row.stop.carrier ? terminalLine(row.stop.carrier, t, chrome.is12h) : null
+ if (terminal) marks.push({terminal})
if (!marks.length && row.offRoadMeters) {
marks.push(
}>
@@ -173,6 +186,40 @@ const LEG_ICONS: Record = {
cycling: Bike,
}
+/**
+ * The ride between two terminals, where a leg pill would be (#2428): the booking and its
+ * minutes, no distance and no other ways of driving it. The pill is the tap target and
+ * opens the booking, the same sheet the map's endpoint badge opens.
+ */
+export function RtRideRow({ carrier, seg, onOpen }: {
+ carrier: CarrierTerminal
+ seg: RouteSegment | undefined
+ onOpen?: () => void
+}) {
+ const Icon = carrierIcon(carrier.type)
+ const body = (
+ <>
+
+ {rideText(carrier, seg)}
+ >
+ )
+ return (
+
+
+
+
+ {onOpen ? (
+
+ ) : (
+ {body}
+ )}
+
+
+ )
+}
+
/**
* What a leg pill says: how far and how long, or that it is not routed yet.
*
diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx
index d84836846..7fde9b98f 100644
--- a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx
+++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx
@@ -6,7 +6,7 @@ import { useMRtCorridor } from './useMRtCorridor'
import { useMRtAlternatives } from './useMRtAlternatives'
import MRtCorridorBar from './MRtCorridorBar'
import MRtAlternativesBar from './MRtAlternativesBar'
-import { RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
+import { RtAutoRow, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
import MBadge from '../../../components/MBadge'
import MDancingTrek from '../../../components/MDancingTrek'
import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
@@ -77,7 +77,9 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
const canEditPlaces = planner.can('place_edit', planner.trip)
const openStop = (row: StopRow) => {
- shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
+ // A terminal is the booking's, and opens it: there is no stop sheet for an airport.
+ if (row.stop.carrier) shell.openSheet('transport', { reservationId: row.stop.carrier.reservationId })
+ else shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
}
// The search bar sits in the same band on both halves, at the same offset, so the
@@ -230,7 +232,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
row={row}
chrome={chrome}
onOpen={() => openStop(row)}
- onPickKind={canEditPlaces
+ onPickKind={canEditPlaces && !row.stop.carrier
? () => shell.openSheet('rtkind', {
placeId: row.stop.placeId,
stopType: row.stop.stopType ?? null,
@@ -256,6 +258,9 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
/>
)
}
+ if (row.kind === 'ride') {
+ return shell.openSheet('transport', { reservationId: row.carrier.reservationId })} />
+ }
if (row.kind === 'auto') return
if (row.kind === 'spill') {
return
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 500a4cfb0..d78af0cd6 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -296,7 +296,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
stayRelease, setStayRelease, confirmStayRelease,
- visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
+ visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
@@ -445,7 +445,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
hasDayDetail={!!showDayDetail && !selectedPlace}
reservations={reservations}
showReservationStats={true}
- visibleConnectionIds={visibleConnections}
+ // In road trip mode the rides that seam the drive are drawn as their own arcs
+ // beside the roads, on top of what the reader switched on under Days.
+ visibleConnectionIds={roadtripActive ? roadtripConnections : visibleConnections}
onReservationClick={(rid) => {
const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r)
@@ -596,6 +598,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onFocusPoint={focusRoadtripPoint}
selectedAssignmentId={selectedAssignmentId}
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)}
+ onOpenCarrier={(rid) => {
+ const r = reservations.find(x => x.id === rid)
+ if (r) setMapTransportDetail(r)
+ }}
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index f03cd3909..72e225332 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -30,6 +30,7 @@ import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDay
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
+import { carrierReservationIds } from '@trek/shared/roadtrip'
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
@@ -76,6 +77,7 @@ import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlace
/** 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
@@ -743,7 +745,18 @@ export function useTripPlanner() {
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.
@@ -1646,7 +1659,11 @@ export function useTripPlanner() {
* looks exactly like a drag that did nothing.
*/
const anchorFor = useCallback((lat: number, lng: number, onlyDayId?: number) => {
- let best: { dayId: number; afterIndex: number; offRouteKm: number } | null = null
+ // `terminal` names the anchor when it is one end of a ride rather than a stored stop
+ // (#2428). Nothing can be filed against a terminal: it stands in for no assignment,
+ // so a via anchored to it would be stored at a position that belongs to the stop
+ // after it and bend that stop's road instead. The callers decide what to refuse.
+ let best: { dayId: number; afterIndex: number; offRouteKm: number; terminal: 'departure' | 'arrival' | null } | null = null
for (const day of roadtripRoutes.days) {
// NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored
// on, but that day's stops are no longer all on the card of the same name: after a
@@ -1682,7 +1699,7 @@ export function useTripPlanner() {
if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) {
const owner = arrivedFrom.ownerDayId ?? day.dayId
if (onlyDayId !== undefined && owner !== onlyDayId) continue
- best = { dayId: owner, afterIndex: arrivedFrom.ownerIndex ?? 0, offRouteKm: hit.offRouteKm }
+ best = { dayId: owner, afterIndex: arrivedFrom.ownerIndex ?? 0, offRouteKm: hit.offRouteKm, terminal: arrivedFrom.carrier?.role ?? null }
continue
}
const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
@@ -1699,7 +1716,7 @@ export function useTripPlanner() {
const owner = anchor.ownerDayId ?? day.dayId
// A drag stays on its own day; a fresh click may land wherever it landed.
if (onlyDayId !== undefined && owner !== onlyDayId) continue
- best = { dayId: owner, afterIndex: anchor.ownerIndex ?? at, offRouteKm: hit.offRouteKm }
+ best = { dayId: owner, afterIndex: anchor.ownerIndex ?? at, offRouteKm: hit.offRouteKm, terminal: anchor.carrier?.role ?? null }
}
return best
}, [roadtripRoutes.days])
@@ -1720,7 +1737,9 @@ export function useTripPlanner() {
*/
const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => {
const anchor = anchorFor(lat, lng)
- if (!anchor) return null
+ // Nothing is stopped at on a flight. Behind an arrival terminal is a road, and a stop
+ // there is the first stop after landing.
+ if (!anchor || anchor.terminal === 'departure') return null
for (const day of roadtripRoutes.days) {
const at = day.stops.findIndex(stop => stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
if (at >= 0) return { dayId: day.dayId, position: at + 1, offRouteKm: anchor.offRouteKm }
@@ -1805,8 +1824,10 @@ export function useTripPlanner() {
const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
if (!can('day_edit', trip)) return
const best = anchorFor(lat, lng)
- // A click that landed on some other line is not a via anywhere.
- if (!best || best.offRouteKm > 2) return
+ // A click that landed on some other line is not a via anywhere. Neither is one on a
+ // ride, or on the road out of a terminal: a via is filed by the position of a stored
+ // stop, and a terminal is not one.
+ if (!best || best.offRouteKm > 2 || best.terminal) return
try {
await roadtripVias.add(best.dayId, best.afterIndex, lat, lng)
} catch (err: unknown) {
@@ -1826,7 +1847,7 @@ export function useTripPlanner() {
// just says which leg gets bent, and the router answers the rest.
const anchor = anchorFor(lat, lng, dayId)
try {
- await roadtripVias.move(dayId, id, lat, lng, anchor?.afterIndex)
+ await roadtripVias.move(dayId, id, lat, lng, anchor && !anchor.terminal ? anchor.afterIndex : undefined)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
@@ -2642,7 +2663,7 @@ export function useTripPlanner() {
routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
- visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
+ visibleConnections, roadtripConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts
index 7e99ceb05..bd1c07cf3 100644
--- a/server/src/nest/roadtrip/roadtrip-plan.service.ts
+++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts
@@ -8,6 +8,8 @@ import { Injectable, HttpException } from '@nestjs/common';
import { type RoadtripPreferences } from '@trek/shared';
import {
assembleRoadtrip,
+ carrierLegsFor,
+ carrierSeam,
foldRouteRun,
splitIntoRuns,
spillChains,
@@ -16,6 +18,10 @@ import {
parseAvoid,
formatDistance,
formatDurationShort,
+ seatCarrierStops,
+ viasLeaving,
+ type CarrierBooking,
+ type CarrierSeam,
type DistanceUnit,
type RoadtripStop,
type PlanDay,
@@ -32,6 +38,22 @@ interface StoredDay {
title: string | null;
default_transport_mode: string | null;
}
+/**
+ * A booking the traveller rides, with what the seam needs of it: the days and clocks at
+ * both ends, the terminals, and where the day plan seats it. Same projection the client
+ * reads off the reservation list, so the two plans seat a flight in the same place.
+ */
+interface CarrierRow {
+ id: number;
+ type: string;
+ title: string;
+ day_id: number | null;
+ end_day_id: number | null;
+ reservation_time: string | null;
+ reservation_end_time: string | null;
+ metadata: string | null;
+ day_plan_position: number | null;
+}
interface VisitRow {
/** The booking this stop stands for on its check-in day, when there is one. */
stay_id: number | null;
@@ -41,6 +63,7 @@ interface VisitRow {
checkout_day: number | null;
id: number;
day_id: number;
+ order_index: number;
place_id: number;
name: string;
lat: number | null;
@@ -75,7 +98,7 @@ export class RoadtripPlanService {
tripId,
);
const visits = this.db.all(
- `SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng,
+ `SELECT a.id, a.day_id, a.order_index, a.place_id, p.name, p.lat, p.lng,
COALESCE(a.assignment_time, p.place_time) AS time, COALESCE(a.assignment_end_time, p.end_time) AS end_time,
p.duration_minutes, a.end_day,
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
@@ -88,6 +111,7 @@ export class RoadtripPlanService {
return {
days,
visits,
+ carriers: this.carriers(tripId),
settings: this.preferences.read(tripId),
profiles: this.router.profiles(),
vias: this.roadtrip.listForTrip(tripId),
@@ -96,6 +120,47 @@ export class RoadtripPlanService {
};
}
+ /**
+ * The bookings that seam the drive: a flight, train, ferry, cruise or bus on a day, with
+ * its terminals and the slots the day plan gave it. The reservation service reads the
+ * same three tables for the booking list; this is the road trip's own cut of them, so
+ * the module stays out of the reservations graph.
+ */
+ private carriers(tripId: number): CarrierBooking[] {
+ const rows = this.db.all(
+ `SELECT id, type, title, day_id, end_day_id, reservation_time, reservation_end_time, metadata, day_plan_position
+ FROM reservations WHERE trip_id = ? AND type IN ('flight', 'train', 'ferry', 'cruise', 'bus') AND day_id IS NOT NULL`,
+ tripId,
+ );
+ if (!rows.length) return [];
+ const endpoints = this.db.all<{
+ reservation_id: number;
+ role: string;
+ sequence: number;
+ name: string;
+ code: string | null;
+ lat: number;
+ lng: number;
+ }>(
+ `SELECT e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng
+ FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id
+ WHERE r.trip_id = ? ORDER BY e.reservation_id, e.sequence`,
+ tripId,
+ );
+ const positions = this.db.all<{ reservation_id: number; day_id: number; position: number }>(
+ `SELECT p.reservation_id, p.day_id, p.position
+ FROM reservation_day_positions p JOIN reservations r ON r.id = p.reservation_id WHERE r.trip_id = ?`,
+ tripId,
+ );
+ return rows.map((row) => ({
+ ...row,
+ endpoints: endpoints.filter((e) => e.reservation_id === row.id),
+ day_positions: Object.fromEntries(
+ positions.filter((p) => p.reservation_id === row.id).map((p) => [String(p.day_id), p.position]),
+ ),
+ }));
+ }
+
async calculate(tripId: number, userId: number, overrides?: RoadtripPreferences) {
const context = this.context(tripId, userId);
const preferences = { ...context.settings, ...overrides };
@@ -106,14 +171,12 @@ export class RoadtripPlanService {
);
if (preferences.roadtrip_day_start && preferences.roadtrip_day_end && !window)
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
- const plan: PlanDay[] = context.days.map((day) => ({
- dayId: day.id,
- dayNumber: day.day_number,
- date: day.date,
- title: day.title,
- stops: context.visits
- .filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null)
- .map((v, index) => ({
+ const seams = context.carriers.map(carrierSeam).filter((seam): seam is CarrierSeam => seam !== null);
+ const dayNumberOf = (dayId: number): number => context.days.find((d) => d.id === dayId)?.day_number ?? 0;
+ const plan: PlanDay[] = context.days.map((day) => {
+ const visits = context.visits.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null);
+ const stops = visits.map(
+ (v, index): RoadtripStop => ({
assignmentId: v.id,
ownerDayId: day.id,
ownerIndex: index,
@@ -133,8 +196,22 @@ export class RoadtripPlanService {
incomingLegMode: v.incoming_leg_transport_mode,
stopType: v.stop_type,
fillPercent: v.fill_percent,
- })),
- }));
+ }),
+ );
+ return {
+ dayId: day.id,
+ dayNumber: day.day_number,
+ date: day.date,
+ title: day.title,
+ // The terminals of the day's rides, seated where the day plan shows the booking.
+ stops: seatCarrierStops(
+ day.id,
+ stops,
+ visits.map((v) => v.order_index),
+ seams,
+ ),
+ };
+ });
const allLegs: Record = {};
const snapByDay: Record> = {};
const missedByDay: Record = {};
@@ -147,21 +224,28 @@ export class RoadtripPlanService {
const distanceUnit: DistanceUnit =
this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric';
const fetchRun = async (stops: RoadtripStop[], dayId: number, profile: string) => {
+ const pairs = stops.slice(0, -1).map((from, index) => ({ from, to: stops[index + 1] }));
+ pairs.forEach(({ from, to }) => asked.add(stopKey(from) + '>' + stopKey(to)));
+ // A ride is no road: the leg between its terminals is the booking's minutes, and
+ // the router is never asked for it.
+ const rides = carrierLegsFor(
+ stops,
+ profile,
+ (from, to) => dayNumberOf(to.ownerDayId) - dayNumberOf(from.ownerDayId),
+ (from, to) => stopKey(from) + '>' + stopKey(to),
+ );
+ if (rides) {
+ Object.assign(allLegs, rides);
+ return;
+ }
const points: { lat: number; lng: number }[] = [];
const stopAt: number[] = [];
stops.forEach((stop, index) => {
stopAt.push(points.length);
points.push({ lat: stop.lat, lng: stop.lng });
if (index < stops.length - 1)
- points.push(
- ...context.vias
- .filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex)
- .sort((a, b) => a.sequence - b.sequence)
- .map((v) => ({ lat: v.lat, lng: v.lng })),
- );
+ points.push(...viasLeaving(stop, context.vias).map((v) => ({ lat: v.lat, lng: v.lng })));
});
- const pairs = stops.slice(0, -1).map((from, index) => ({ from, to: stops[index + 1] }));
- pairs.forEach(({ from, to }) => asked.add(stopKey(from) + '>' + stopKey(to)));
try {
if (points.length > 100) throw new Error('Too many waypoints');
const routed = await this.router.route(userId, tripId, dayId, points, profile, avoid);
diff --git a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts
index dd7fd0019..863ee5e9c 100644
--- a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts
+++ b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts
@@ -28,7 +28,7 @@ export class RoadtripPlanningMcp {
@Tool({
name: 'get_roadtrip_context',
description:
- 'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
+ 'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, manual day endings and the carrier bookings (flight, train, ferry, cruise, bus) that seam the drive at their terminals. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
inputSchema: { tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
@@ -41,7 +41,7 @@ export class RoadtripPlanningMcp {
@Tool({
name: 'calculate_roadtrip',
description:
- 'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
+ 'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
inputSchema: roadtripPlanRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
diff --git a/server/tests/unit/nest/roadtrip-plan.service.test.ts b/server/tests/unit/nest/roadtrip-plan.service.test.ts
index 75c8fc09f..67e4fce33 100644
--- a/server/tests/unit/nest/roadtrip-plan.service.test.ts
+++ b/server/tests/unit/nest/roadtrip-plan.service.test.ts
@@ -116,3 +116,71 @@ describe('a visit end time on the road trip', () => {
expect(calculated.days[0].stops[1].leaveAt).toBeNull();
});
});
+
+describe('a booking the traveller rides (#2428)', () => {
+ function withFlight(dayId: number, tripId: number, over: { end_day_id?: number; dep?: string; arr?: string } = {}) {
+ const result = db
+ .prepare(
+ `INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
+ VALUES (?, ?, 'flight', ?, ?, ?, ?)`,
+ )
+ .run(tripId, 'LH 2020 HAM → MUC', dayId, over.end_day_id ?? dayId, over.dep ?? '13:20', over.arr ?? '14:30');
+ const id = Number(result.lastInsertRowid);
+ const endpoint = db.prepare(
+ 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ );
+ endpoint.run(id, 'from', 0, 'Hamburg Airport', 'HAM', 53.63, 9.99);
+ endpoint.run(id, 'to', 1, 'Munich Airport', 'MUC', 48.35, 11.78);
+ return id;
+ }
+
+ it('seats the terminals in the day and never asks the router for the ride', async () => {
+ const { user, trip, visits, plans } = setup();
+ db.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(visits[1].id);
+ const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
+ const flightId = withFlight(day.day_id, trip.id);
+
+ const { context, calculated } = await plans.calculate(trip.id, user.id);
+
+ expect(context.carriers).toHaveLength(1);
+ expect(context.carriers[0].endpoints).toHaveLength(2);
+ const card = calculated.days[0];
+ // Behind Lueneburg (10:00), before the untimed Celle: the day plan's own seat.
+ expect(card.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['Hamburg', 'Lueneburg', 'departure', 'arrival', 'Celle']);
+ const departure = card.stops[2];
+ expect(departure.carrier).toMatchObject({ reservationId: flightId, type: 'flight', code: 'HAM', at: '13:20' });
+ expect(departure.assignmentId).toBeLessThan(-2_000_000_000);
+ // The ride is a leg of the booking's minutes with no road under it; the roads to
+ // and from the terminals were routed like any other leg.
+ expect(card.legs.map((l) => [l?.mode, l?.duration])).toEqual([
+ ['driving', 3600],
+ ['driving', 3600],
+ ['flight', 70 * 60],
+ ['driving', 3600],
+ ]);
+ expect(card.legs[2]?.distance).toBe(0);
+ // Reached an hour ahead of the flight, off on the timetable, landed on the timetable.
+ expect(card.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
+ ['09:00', '09:00'],
+ ['10:00', '10:30'],
+ ['12:20', '13:20'],
+ ['14:30', '14:30'],
+ ['15:30', '16:00'],
+ ]);
+ // The day's figures are the drive's: three roads of 60 km, no flight in them.
+ expect(card.distance).toBe(180_000);
+ expect(calculated.totalStops).toBe(3);
+ });
+
+ it('lets a booking without located terminals fall through, and ignores a hire car', async () => {
+ const { user, trip, visits, plans } = setup();
+ const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
+ db.prepare("INSERT INTO reservations (trip_id, title, type, day_id) VALUES (?, 'Hire car', 'car', ?)").run(trip.id, day.day_id);
+ db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Somewhere', 'train', ?, '11:00')").run(trip.id, day.day_id);
+
+ const { context, calculated } = await plans.calculate(trip.id, user.id);
+
+ expect(context.carriers.map((c) => c.type)).toEqual(['train']);
+ expect(calculated.days[0].stops.every((s) => !s.carrier)).toBe(true);
+ });
+});
diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts
index 89b30ddb8..1fc96267d 100644
--- a/shared/src/i18n/ar/roadtrip.ts
+++ b/shared/src/i18n/ar/roadtrip.ts
@@ -264,5 +264,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'ستُزال ليلة المبيت في "{name}". تبقى المحطة على الرحلة كاستراحة.',
'roadtrip.stay.releaseBookedBody': 'ستُزال ليلة المبيت في "{name}" مع الحجز "{booking}" وأي مصروف مرتبط به. تبقى المحطة على الرحلة كاستراحة.',
'roadtrip.stay.releaseAction': 'اجعلها استراحة',
+ 'roadtrip.ride.departure': 'المغادرة {time}',
+ 'roadtrip.ride.arrival': 'الوصول {time}',
+ 'roadtrip.ride.open': 'فتح الحجز',
+ 'roadtrip.ride.beThere': 'الحضور قبل {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts
index fa47f3f4a..d532cc9db 100644
--- a/shared/src/i18n/br/roadtrip.ts
+++ b/shared/src/i18n/br/roadtrip.ts
@@ -269,5 +269,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'A noite em “{name}” será removida. A paragem continua na viagem como pausa.',
'roadtrip.stay.releaseBookedBody': 'A noite em “{name}” será removida juntamente com a reserva “{booking}” e qualquer despesa associada. A paragem continua na viagem como pausa.',
'roadtrip.stay.releaseAction': 'Tornar uma pausa',
+ 'roadtrip.ride.departure': 'Loc\'hañ {time}',
+ 'roadtrip.ride.arrival': 'Erruout {time}',
+ 'roadtrip.ride.open': 'Digeriñ ar mirout',
+ 'roadtrip.ride.beThere': 'Bezañ eno a-raok {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts
index aebd4b320..bec503b54 100644
--- a/shared/src/i18n/ca/roadtrip.ts
+++ b/shared/src/i18n/ca/roadtrip.ts
@@ -272,5 +272,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Es traurà la nit a «{name}». La parada es manté a la ruta com a pausa.',
'roadtrip.stay.releaseBookedBody': 'Es traurà la nit a «{name}» juntament amb la reserva «{booking}» i qualsevol despesa vinculada. La parada es manté a la ruta com a pausa.',
'roadtrip.stay.releaseAction': 'Converteix en pausa',
+ 'roadtrip.ride.departure': 'Sortida {time}',
+ 'roadtrip.ride.arrival': 'Arribada {time}',
+ 'roadtrip.ride.open': 'Obre la reserva',
+ 'roadtrip.ride.beThere': 'Sigues-hi abans de les {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts
index 3f442361f..e6c78ed25 100644
--- a/shared/src/i18n/cs/roadtrip.ts
+++ b/shared/src/i18n/cs/roadtrip.ts
@@ -267,5 +267,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Noc v „{name}“ bude odstraněna. Zastávka zůstane na trase jako pauza.',
'roadtrip.stay.releaseBookedBody': 'Noc v „{name}“ bude odstraněna spolu s rezervací „{booking}“ a všemi navázanými výdaji. Zastávka zůstane na trase jako pauza.',
'roadtrip.stay.releaseAction': 'Změnit na pauzu',
+ 'roadtrip.ride.departure': 'Odjezd {time}',
+ 'roadtrip.ride.arrival': 'Příjezd {time}',
+ 'roadtrip.ride.open': 'Otevřít rezervaci',
+ 'roadtrip.ride.beThere': 'Být na místě do {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts
index f64530bc6..29ef5da53 100644
--- a/shared/src/i18n/de/roadtrip.ts
+++ b/shared/src/i18n/de/roadtrip.ts
@@ -266,5 +266,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Die Nacht in „{name}“ wird entfernt. Der Stopp bleibt als Pause auf der Fahrt.',
'roadtrip.stay.releaseBookedBody': 'Die Nacht in „{name}“ wird zusammen mit der Buchung „{booking}“ und jeder damit verknüpften Ausgabe entfernt. Der Stopp bleibt als Pause auf der Fahrt.',
'roadtrip.stay.releaseAction': 'Als Pause behalten',
+ 'roadtrip.ride.departure': 'Abfahrt {time}',
+ 'roadtrip.ride.arrival': 'Ankunft {time}',
+ 'roadtrip.ride.open': 'Buchung öffnen',
+ 'roadtrip.ride.beThere': 'Bis {time} da sein',
};
export default roadtrip;
diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts
index c3f028809..cdab22737 100644
--- a/shared/src/i18n/en/roadtrip.ts
+++ b/shared/src/i18n/en/roadtrip.ts
@@ -264,5 +264,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'The night at “{name}” will be removed. The stop stays on the drive as a pause.',
'roadtrip.stay.releaseBookedBody': 'The night at “{name}” will be removed together with the booking “{booking}” and any expense linked to it. The stop stays on the drive as a pause.',
'roadtrip.stay.releaseAction': 'Make it a pause',
+ 'roadtrip.ride.departure': 'Departure {time}',
+ 'roadtrip.ride.arrival': 'Arrival {time}',
+ 'roadtrip.ride.open': 'Open booking',
+ 'roadtrip.ride.beThere': 'Be there by {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts
index 7be1d83b2..6c789adf5 100644
--- a/shared/src/i18n/es/roadtrip.ts
+++ b/shared/src/i18n/es/roadtrip.ts
@@ -271,5 +271,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Se quitará la noche en «{name}». La parada se mantiene en la ruta como pausa.',
'roadtrip.stay.releaseBookedBody': 'Se quitará la noche en «{name}» junto con la reserva «{booking}» y cualquier gasto vinculado. La parada se mantiene en la ruta como pausa.',
'roadtrip.stay.releaseAction': 'Convertir en pausa',
+ 'roadtrip.ride.departure': 'Salida {time}',
+ 'roadtrip.ride.arrival': 'Llegada {time}',
+ 'roadtrip.ride.open': 'Abrir reserva',
+ 'roadtrip.ride.beThere': 'Estar allí antes de las {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts
index 7ef4eeebd..2c666273c 100644
--- a/shared/src/i18n/fr/roadtrip.ts
+++ b/shared/src/i18n/fr/roadtrip.ts
@@ -271,5 +271,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'La nuit à « {name} » sera supprimée. L’arrêt reste sur le trajet comme pause.',
'roadtrip.stay.releaseBookedBody': 'La nuit à « {name} » sera supprimée avec la réservation « {booking} » et toute dépense qui y est liée. L’arrêt reste sur le trajet comme pause.',
'roadtrip.stay.releaseAction': 'En faire une pause',
+ 'roadtrip.ride.departure': 'Départ {time}',
+ 'roadtrip.ride.arrival': 'Arrivée {time}',
+ 'roadtrip.ride.open': 'Ouvrir la réservation',
+ 'roadtrip.ride.beThere': 'Être sur place avant {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts
index f49df7d5c..6d995cbc0 100644
--- a/shared/src/i18n/gr/roadtrip.ts
+++ b/shared/src/i18n/gr/roadtrip.ts
@@ -273,5 +273,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Η διανυκτέρευση στο «{name}» θα αφαιρεθεί. Η στάση παραμένει στη διαδρομή ως παύση.',
'roadtrip.stay.releaseBookedBody': 'Η διανυκτέρευση στο «{name}» θα αφαιρεθεί μαζί με την κράτηση «{booking}» και κάθε συνδεδεμένο έξοδο. Η στάση παραμένει στη διαδρομή ως παύση.',
'roadtrip.stay.releaseAction': 'Κάνε το παύση',
+ 'roadtrip.ride.departure': 'Αναχώρηση {time}',
+ 'roadtrip.ride.arrival': 'Άφιξη {time}',
+ 'roadtrip.ride.open': 'Άνοιγμα κράτησης',
+ 'roadtrip.ride.beThere': 'Να είστε εκεί μέχρι τις {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts
index efd692224..68156c0eb 100644
--- a/shared/src/i18n/hu/roadtrip.ts
+++ b/shared/src/i18n/hu/roadtrip.ts
@@ -268,5 +268,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'A(z) „{name}” helyen töltött éjszaka törlődik. A megálló szünetként az útvonalon marad.',
'roadtrip.stay.releaseBookedBody': 'A(z) „{name}” helyen töltött éjszaka a(z) „{booking}” foglalással és a hozzá kapcsolt kiadásokkal együtt törlődik. A megálló szünetként az útvonalon marad.',
'roadtrip.stay.releaseAction': 'Legyen szünet',
+ 'roadtrip.ride.departure': 'Indulás {time}',
+ 'roadtrip.ride.arrival': 'Érkezés {time}',
+ 'roadtrip.ride.open': 'Foglalás megnyitása',
+ 'roadtrip.ride.beThere': 'Legyél ott {time}-ig',
};
export default roadtrip;
diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts
index 6374679e8..90b91a6d6 100644
--- a/shared/src/i18n/id/roadtrip.ts
+++ b/shared/src/i18n/id/roadtrip.ts
@@ -271,5 +271,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Malam di “{name}” akan dihapus. Perhentian tetap ada di perjalanan sebagai jeda.',
'roadtrip.stay.releaseBookedBody': 'Malam di “{name}” akan dihapus bersama pemesanan “{booking}” dan pengeluaran yang terkait. Perhentian tetap ada di perjalanan sebagai jeda.',
'roadtrip.stay.releaseAction': 'Jadikan jeda',
+ 'roadtrip.ride.departure': 'Berangkat {time}',
+ 'roadtrip.ride.arrival': 'Tiba {time}',
+ 'roadtrip.ride.open': 'Buka pemesanan',
+ 'roadtrip.ride.beThere': 'Sudah di sana sebelum {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts
index dd62d23e5..7caf65b57 100644
--- a/shared/src/i18n/it/roadtrip.ts
+++ b/shared/src/i18n/it/roadtrip.ts
@@ -271,5 +271,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'La notte a «{name}» verrà rimossa. La tappa resta sul percorso come pausa.',
'roadtrip.stay.releaseBookedBody': 'La notte a «{name}» verrà rimossa insieme alla prenotazione «{booking}» e a ogni spesa collegata. La tappa resta sul percorso come pausa.',
'roadtrip.stay.releaseAction': 'Rendi una pausa',
+ 'roadtrip.ride.departure': 'Partenza {time}',
+ 'roadtrip.ride.arrival': 'Arrivo {time}',
+ 'roadtrip.ride.open': 'Apri prenotazione',
+ 'roadtrip.ride.beThere': 'Essere lì entro le {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts
index 3199e8256..9524af31b 100644
--- a/shared/src/i18n/ja/roadtrip.ts
+++ b/shared/src/i18n/ja/roadtrip.ts
@@ -266,5 +266,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': '「{name}」での宿泊を取り消します。停車地は休憩として行程に残ります。',
'roadtrip.stay.releaseBookedBody': '「{name}」での宿泊を、予約「{booking}」と関連する支出とともに取り消します。停車地は休憩として行程に残ります。',
'roadtrip.stay.releaseAction': '休憩にする',
+ 'roadtrip.ride.departure': '出発 {time}',
+ 'roadtrip.ride.arrival': '到着 {time}',
+ 'roadtrip.ride.open': '予約を開く',
+ 'roadtrip.ride.beThere': '{time} までに到着',
};
export default roadtrip;
diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts
index cf519ab56..99b1b93e8 100644
--- a/shared/src/i18n/ko/roadtrip.ts
+++ b/shared/src/i18n/ko/roadtrip.ts
@@ -264,5 +264,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': '“{name}”에서의 숙박이 삭제됩니다. 이 정차지는 휴식으로 경로에 남습니다.',
'roadtrip.stay.releaseBookedBody': '“{name}”에서의 숙박이 예약 “{booking}” 및 연결된 지출과 함께 삭제됩니다. 이 정차지는 휴식으로 경로에 남습니다.',
'roadtrip.stay.releaseAction': '휴식으로 바꾸기',
+ 'roadtrip.ride.departure': '출발 {time}',
+ 'roadtrip.ride.arrival': '도착 {time}',
+ 'roadtrip.ride.open': '예약 열기',
+ 'roadtrip.ride.beThere': '{time}까지 도착',
};
export default roadtrip;
diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts
index 29d57ad42..45061a9ba 100644
--- a/shared/src/i18n/nl/roadtrip.ts
+++ b/shared/src/i18n/nl/roadtrip.ts
@@ -270,5 +270,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'De nacht bij ‘{name}’ wordt verwijderd. De stop blijft als pauze op de route.',
'roadtrip.stay.releaseBookedBody': 'De nacht bij ‘{name}’ wordt verwijderd, samen met de boeking ‘{booking}’ en elke gekoppelde uitgave. De stop blijft als pauze op de route.',
'roadtrip.stay.releaseAction': 'Maak er een pauze van',
+ 'roadtrip.ride.departure': 'Vertrek {time}',
+ 'roadtrip.ride.arrival': 'Aankomst {time}',
+ 'roadtrip.ride.open': 'Boeking openen',
+ 'roadtrip.ride.beThere': 'Uiterlijk om {time} aanwezig zijn',
};
export default roadtrip;
diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts
index 33503162d..736045c07 100644
--- a/shared/src/i18n/pl/roadtrip.ts
+++ b/shared/src/i18n/pl/roadtrip.ts
@@ -268,5 +268,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Nocleg w „{name}” zostanie usunięty. Przystanek pozostaje na trasie jako postój.',
'roadtrip.stay.releaseBookedBody': 'Nocleg w „{name}” zostanie usunięty razem z rezerwacją „{booking}” i powiązanymi wydatkami. Przystanek pozostaje na trasie jako postój.',
'roadtrip.stay.releaseAction': 'Zamień na postój',
+ 'roadtrip.ride.departure': 'Odjazd {time}',
+ 'roadtrip.ride.arrival': 'Przyjazd {time}',
+ 'roadtrip.ride.open': 'Otwórz rezerwację',
+ 'roadtrip.ride.beThere': 'Być na miejscu do {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts
index c5af9c272..2de6b01c3 100644
--- a/shared/src/i18n/ru/roadtrip.ts
+++ b/shared/src/i18n/ru/roadtrip.ts
@@ -270,5 +270,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Ночёвка в «{name}» будет удалена. Остановка останется на маршруте как пауза.',
'roadtrip.stay.releaseBookedBody': 'Ночёвка в «{name}» будет удалена вместе с бронированием «{booking}» и связанными с ним расходами. Остановка останется на маршруте как пауза.',
'roadtrip.stay.releaseAction': 'Сделать паузой',
+ 'roadtrip.ride.departure': 'Отправление {time}',
+ 'roadtrip.ride.arrival': 'Прибытие {time}',
+ 'roadtrip.ride.open': 'Открыть бронирование',
+ 'roadtrip.ride.beThere': 'Быть на месте к {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts
index 2c05d9463..e1ba7aa78 100644
--- a/shared/src/i18n/sv/roadtrip.ts
+++ b/shared/src/i18n/sv/roadtrip.ts
@@ -267,5 +267,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Natten på ”{name}” tas bort. Stoppet ligger kvar på resan som en paus.',
'roadtrip.stay.releaseBookedBody': 'Natten på ”{name}” tas bort tillsammans med bokningen ”{booking}” och alla utgifter som hör till den. Stoppet ligger kvar på resan som en paus.',
'roadtrip.stay.releaseAction': 'Gör till en paus',
+ 'roadtrip.ride.departure': 'Avgång {time}',
+ 'roadtrip.ride.arrival': 'Ankomst {time}',
+ 'roadtrip.ride.open': 'Öppna bokning',
+ 'roadtrip.ride.beThere': 'Var på plats senast {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts
index 3100f0b7f..345f9dd6a 100644
--- a/shared/src/i18n/tr/roadtrip.ts
+++ b/shared/src/i18n/tr/roadtrip.ts
@@ -270,5 +270,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': '“{name}” konumundaki gece kaldırılacak. Durak, yolculukta mola olarak kalır.',
'roadtrip.stay.releaseBookedBody': '“{name}” konumundaki gece, “{booking}” rezervasyonu ve ona bağlı tüm harcamalarla birlikte kaldırılacak. Durak, yolculukta mola olarak kalır.',
'roadtrip.stay.releaseAction': 'Molaya çevir',
+ 'roadtrip.ride.departure': 'Kalkış {time}',
+ 'roadtrip.ride.arrival': 'Varış {time}',
+ 'roadtrip.ride.open': 'Rezervasyonu aç',
+ 'roadtrip.ride.beThere': '{time} itibarıyla orada olun',
};
export default roadtrip;
diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts
index b10dd4b16..5058a5425 100644
--- a/shared/src/i18n/uk/roadtrip.ts
+++ b/shared/src/i18n/uk/roadtrip.ts
@@ -269,5 +269,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Ночівлю в «{name}» буде вилучено. Зупинка залишиться на маршруті як пауза.',
'roadtrip.stay.releaseBookedBody': 'Ночівлю в «{name}» буде вилучено разом із бронюванням «{booking}» і пов’язаними витратами. Зупинка залишиться на маршруті як пауза.',
'roadtrip.stay.releaseAction': 'Зробити паузою',
+ 'roadtrip.ride.departure': 'Відправлення {time}',
+ 'roadtrip.ride.arrival': 'Прибуття {time}',
+ 'roadtrip.ride.open': 'Відкрити бронювання',
+ 'roadtrip.ride.beThere': 'Бути на місці до {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts
index e521f1ee0..28c3b0ca9 100644
--- a/shared/src/i18n/vi/roadtrip.ts
+++ b/shared/src/i18n/vi/roadtrip.ts
@@ -268,5 +268,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'Đêm nghỉ tại “{name}” sẽ bị xóa. Điểm dừng vẫn ở trên hành trình như một điểm nghỉ.',
'roadtrip.stay.releaseBookedBody': 'Đêm nghỉ tại “{name}” sẽ bị xóa cùng với đặt chỗ “{booking}” và mọi chi phí liên quan. Điểm dừng vẫn ở trên hành trình như một điểm nghỉ.',
'roadtrip.stay.releaseAction': 'Chuyển thành điểm nghỉ',
+ 'roadtrip.ride.departure': 'Khởi hành {time}',
+ 'roadtrip.ride.arrival': 'Đến nơi {time}',
+ 'roadtrip.ride.open': 'Mở đặt chỗ',
+ 'roadtrip.ride.beThere': 'Có mặt trước {time}',
};
export default roadtrip;
diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts
index 9542e0c4d..33862a4ef 100644
--- a/shared/src/i18n/zh-TW/roadtrip.ts
+++ b/shared/src/i18n/zh-TW/roadtrip.ts
@@ -255,5 +255,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': '將取消在「{name}」的過夜。該停靠點會作為休息保留在行程中。',
'roadtrip.stay.releaseBookedBody': '將取消在「{name}」的過夜,並同時刪除預訂「{booking}」及其關聯的費用。該停靠點會作為休息保留在行程中。',
'roadtrip.stay.releaseAction': '改為休息',
+ 'roadtrip.ride.departure': '出發 {time}',
+ 'roadtrip.ride.arrival': '抵達 {time}',
+ 'roadtrip.ride.open': '開啟預訂',
+ 'roadtrip.ride.beThere': '請於 {time} 前抵達',
};
export default roadtrip;
diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts
index 54284d222..b1cb993d2 100644
--- a/shared/src/i18n/zh/roadtrip.ts
+++ b/shared/src/i18n/zh/roadtrip.ts
@@ -256,5 +256,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': '将取消在“{name}”的过夜。该停靠点会作为休息保留在行程中。',
'roadtrip.stay.releaseBookedBody': '将取消在“{name}”的过夜,并同时删除预订“{booking}”及其关联的费用。该停靠点会作为休息保留在行程中。',
'roadtrip.stay.releaseAction': '改为休息',
+ 'roadtrip.ride.departure': '出发 {time}',
+ 'roadtrip.ride.arrival': '到达 {time}',
+ 'roadtrip.ride.open': '打开预订',
+ 'roadtrip.ride.beThere': '请于 {time} 前到达',
};
export default roadtrip;
diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts
index 1f41abca1..80053652f 100644
--- a/shared/src/roadtrip/assemble.ts
+++ b/shared/src/roadtrip/assemble.ts
@@ -1,4 +1,5 @@
import { spurFor } from './accessSpur';
+import { carriesTheCar, isCarrierMode } from './carriers';
import { pointAtMeters } from './corridor';
import type { RoadtripDayBoundary } from './day-boundary.schema';
import { planDayWindow, type DayWindow } from './dayWindow';
@@ -160,7 +161,8 @@ export function assembleRoadtrip({
const opening = chain.stops[i]?.automaticNight;
lineJoins.push(opening?.phase === 'start' && Number.isInteger(opening.position ?? 0));
}
- segments.push(leg.seg);
+ // A ride has no road to label: the map draws the booking's own arc for it.
+ if (!isCarrierMode(leg.seg.mode)) segments.push(leg.seg);
}
const geometry: [number, number][] = [];
@@ -171,10 +173,15 @@ export function assembleRoadtrip({
}
const legs = routed.map((l) => l?.seg);
const inbound = [...inboundAt.values()].map((l) => l.seg);
+ // The day's figures are the drive's: a ride's hours belong to the booking, not to the
+ // wheel, and its distance was never measured (`carrierLeg` stores none).
+ const driven = (l: RouteSegment | undefined): boolean => !!l && !isCarrierMode(l.mode);
const distance =
- legs.reduce((sum, l) => sum + (l?.distance ?? 0), 0) + inbound.reduce((sum, l) => sum + (l?.distance ?? 0), 0);
+ legs.filter(driven).reduce((sum, l) => sum + (l?.distance ?? 0), 0) +
+ inbound.filter(driven).reduce((sum, l) => sum + (l?.distance ?? 0), 0);
const duration =
- legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0) + inbound.reduce((sum, l) => sum + (l?.duration ?? 0), 0);
+ legs.filter(driven).reduce((sum, l) => sum + (l?.duration ?? 0), 0) +
+ inbound.filter(driven).reduce((sum, l) => sum + (l?.duration ?? 0), 0);
const schedule = chain.schedule;
const legVias = routed.map((l) => l?.vias ?? []);
const stops = chain.stops.map((s) => {
@@ -194,7 +201,11 @@ export function assembleRoadtrip({
let drivingSeconds = 0;
for (let i = 0; i < stops.length; i++) {
const incoming = inboundAt.get(i);
- if (incoming?.seg) {
+ if (incoming?.seg && isCarrierMode(incoming.seg.mode)) {
+ // The night's ride into this day: no fuel spent, and after one the car could not
+ // take, the tank the drive goes on with is another car's.
+ if (!carriesTheCar(incoming.seg.mode!)) carryKm = 0;
+ } else if (incoming?.seg) {
// Slot 1 is the stop the leg ARRIVES at, which for an inbound leg is this
// one. Hard-coded false, the range warning could never be suppressed by
// the very charger or petrol station that resolves it.
@@ -216,6 +227,10 @@ export function assembleRoadtrip({
if (!incoming.seg.mode || incoming.seg.mode === 'driving') drivingSeconds += incoming.seg.duration ?? 0;
}
const leg = routed[i];
+ if (leg && isCarrierMode(leg.seg.mode)) {
+ if (!carriesTheCar(leg.seg.mode!)) carryKm = 0;
+ continue;
+ }
const outgoing = deriveDriveWarnings(
i < stops.length - 1 ? [leg?.seg] : [],
// Departure, then arrival: this leg leaves stop i and reaches stop i + 1,
@@ -313,7 +328,7 @@ export function assembleRoadtrip({
totalDuration: drives.reduce((s, d) => s + d.duration, 0),
totalStops: drives.reduce(
- (s, d) => s + d.stops.filter((st) => !st.automaticNight && !isServiceStopType(st.stopType)).length,
+ (s, d) => s + d.stops.filter((st) => !st.automaticNight && !st.carrier && !isServiceStopType(st.stopType)).length,
0,
),
diff --git a/shared/src/roadtrip/carriers.spec.ts b/shared/src/roadtrip/carriers.spec.ts
new file mode 100644
index 000000000..abd5bb952
--- /dev/null
+++ b/shared/src/roadtrip/carriers.spec.ts
@@ -0,0 +1,399 @@
+/**
+ * ROADTRIP-CARRIERS-001..014: a booking the traveller rides becomes a seam in the drive.
+ *
+ * The road ends at the terminal the ride leaves from and starts again at the one it
+ * lands at (#2428). Pinned here: which bookings count, where their terminals are seated
+ * among the day's stops, what the ride between them is worth to the chain, and that
+ * nothing about a terminal reads as a stored stop.
+ */
+import { assembleRoadtrip } from './assemble';
+import {
+ CHECK_IN_MINUTES,
+ carrierClock,
+ carrierLeg,
+ carrierLegsFor,
+ carrierReservationIds,
+ carrierRideMinutes,
+ carrierSeam,
+ carriesTheCar,
+ isCarrierMode,
+ isCarrierStop,
+ seatCarrierStops,
+ terminalAssignmentId,
+ viasLeaving,
+ type CarrierBooking,
+} from './carriers';
+import { planDayWindow } from './dayWindow';
+import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
+import { roadtripLegKey } from './routeRun';
+
+import { describe, expect, it } from 'vitest';
+
+const stop = (over: Partial & { ownerIndex: number; ownerDayId?: number }): RoadtripStop => ({
+ assignmentId: 100 + over.ownerIndex,
+ ownerDayId: 1,
+ placeId: 200 + over.ownerIndex,
+ name: `Stop ${over.ownerIndex}`,
+ lat: 50 + over.ownerIndex * 0.1,
+ lng: 10,
+ time: null,
+ dwellMinutes: 30,
+ legMode: null,
+ incomingLegMode: null,
+ stopType: null,
+ ...over,
+});
+
+const flight = (over: Partial = {}): CarrierBooking => ({
+ id: 7,
+ type: 'flight',
+ title: 'LH 2020 MUC → HAM',
+ day_id: 1,
+ end_day_id: 1,
+ reservation_time: '2026-06-01T13:20',
+ reservation_end_time: '2026-06-01T14:30',
+ endpoints: [
+ { role: 'from', sequence: 0, name: 'Munich Airport', code: 'MUC', lat: 48.35, lng: 11.78 },
+ { role: 'to', sequence: 1, name: 'Hamburg Airport', code: 'HAM', lat: 53.63, lng: 9.99 },
+ ],
+ ...over,
+});
+
+const road = (km: number): RoutedLeg => ({
+ seg: {
+ mid: [50, 10],
+ from: [50, 10],
+ to: [51, 10],
+ distance: km * 1000,
+ duration: km * 60,
+ walkingText: '',
+ drivingText: '',
+ distanceText: `${km} km`,
+ mode: 'driving',
+ },
+ line: [
+ [50, 10],
+ [51, 10],
+ ],
+ vias: [],
+});
+
+describe('carrierSeam', () => {
+ it('ROADTRIP-CARRIERS-001: a flight with two located terminals is a seam with the timetable on both ends', () => {
+ const seam = carrierSeam(flight());
+ expect(seam).not.toBeNull();
+ expect(seam!.departure).toMatchObject({ dayId: 1, clock: '13:20', code: 'MUC', lat: 48.35 });
+ expect(seam!.arrival).toMatchObject({ dayId: 1, clock: '14:30', code: 'HAM', lng: 9.99 });
+ expect(seam!.departure.position).toBeNull();
+ });
+
+ it('ROADTRIP-CARRIERS-002: a hire car, a taxi and a hop on transit are not seams, and neither is a ride nobody put on a day', () => {
+ expect(carrierSeam(flight({ type: 'car' }))).toBeNull();
+ expect(carrierSeam(flight({ type: 'taxi' }))).toBeNull();
+ expect(carrierSeam(flight({ type: 'transit' }))).toBeNull();
+ expect(carrierSeam(flight({ day_id: null }))).toBeNull();
+ });
+
+ it('ROADTRIP-CARRIERS-003: a terminal without coordinates leaves no seam, because the drive cannot resume from nowhere', () => {
+ const half = flight({
+ endpoints: [
+ { role: 'from', sequence: 0, name: 'Munich Airport', code: 'MUC', lat: 48.35, lng: 11.78 },
+ { role: 'to', sequence: 1, name: 'Somewhere', code: null, lat: null, lng: null },
+ ],
+ });
+ expect(carrierSeam(half)).toBeNull();
+ });
+
+ it('ROADTRIP-CARRIERS-004: a booking with a stopover is one seam from its first departure to its last arrival, on the legs’ own days and clocks', () => {
+ const legs = JSON.stringify({
+ legs: [
+ { from: 'MUC', to: 'FRA', dep_day_id: 1, dep_time: '18:00', arr_day_id: 1, arr_time: '19:00' },
+ {
+ from: 'FRA',
+ to: 'JFK',
+ dep_day_id: 1,
+ dep_time: '22:00',
+ arr_day_id: 2,
+ arr_time: '01:30',
+ day_positions: { '2': 0 },
+ },
+ ],
+ });
+ const seam = carrierSeam(
+ flight({
+ reservation_time: null,
+ reservation_end_time: null,
+ metadata: legs,
+ endpoints: [
+ { role: 'from', sequence: 0, name: 'Munich', code: 'MUC', lat: 48.35, lng: 11.78 },
+ { role: 'stop', sequence: 1, name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.56 },
+ { role: 'to', sequence: 2, name: 'New York', code: 'JFK', lat: 40.64, lng: -73.78 },
+ ],
+ }),
+ );
+ expect(seam!.departure).toMatchObject({ dayId: 1, clock: '18:00', code: 'MUC' });
+ expect(seam!.arrival).toMatchObject({ dayId: 2, clock: '01:30', code: 'JFK', position: 0 });
+ });
+
+ it('ROADTRIP-CARRIERS-005: a position somebody dragged the booking to is read per day, the booking’s own slot as the fallback', () => {
+ const seam = carrierSeam(flight({ day_positions: { '1': 2 }, day_plan_position: 5 }));
+ expect(seam!.departure.position).toBe(2);
+ const legacy = carrierSeam(flight({ day_plan_position: 5 }));
+ expect(legacy!.departure.position).toBe(5);
+ });
+
+ it('ROADTRIP-CARRIERS-006: clocks come out as HH:mm whichever way the column spells them', () => {
+ expect(carrierClock('2026-06-01T13:20')).toBe('13:20');
+ expect(carrierClock('7:05')).toBe('07:05');
+ expect(carrierClock('13:20:00')).toBe('13:20');
+ expect(carrierClock(null)).toBeNull();
+ expect(carrierClock('noon')).toBeNull();
+ });
+});
+
+describe('carrierRideMinutes', () => {
+ it('ROADTRIP-CARRIERS-007: the ride is what the clocks say, across the days the booking spans, and never negative', () => {
+ const same = carrierSeam(flight())!;
+ expect(carrierRideMinutes(same, 0)).toBe(70);
+ const overnight = carrierSeam(flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 }))!;
+ expect(carrierRideMinutes(overnight, 1)).toBe(540);
+ // Westward across the date line the clock lands before it left; the chain cannot
+ // run backwards, so the ride counts as none rather than as a day.
+ const dateLine = carrierSeam(flight({ reservation_time: '10:00', reservation_end_time: '06:00' }))!;
+ expect(carrierRideMinutes(dateLine, 0)).toBe(0);
+ expect(carrierRideMinutes(carrierSeam(flight({ reservation_end_time: null }))!, 0)).toBeNull();
+ });
+});
+
+describe('seatCarrierStops', () => {
+ const day = [
+ stop({ ownerIndex: 0, time: '09:00' }),
+ stop({ ownerIndex: 1, time: '10:00' }),
+ stop({ ownerIndex: 2 }),
+ stop({ ownerIndex: 3, time: '17:00' }),
+ ];
+
+ it('ROADTRIP-CARRIERS-008: a same-day ride is seated behind the last timed stop at or before its departure, arrival right behind departure', () => {
+ const seam = carrierSeam(flight())!;
+ const seated = seatCarrierStops(1, day, [0, 1, 2, 3], [seam]);
+ // Behind Stop 1 (10:00), and the untimed Stop 2 follows the ride: the day plan reads
+ // an untimed item as standing behind the timed one before it.
+ expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual([
+ 'Stop 0',
+ 'Stop 1',
+ 'departure',
+ 'arrival',
+ 'Stop 2',
+ 'Stop 3',
+ ]);
+ const dep = seated[2]!;
+ const arr = seated[3]!;
+ // Pinned a check-in ahead of the timetable and left at the timetable's minute.
+ expect(dep.time).toBe('12:20');
+ expect(dep.leaveAt).toBe('13:20');
+ expect(dep.dwellMinutes).toBe(CHECK_IN_MINUTES.flight);
+ expect(arr.time).toBe('14:30');
+ expect(arr.dwellMinutes).toBe(0);
+ // The ride is the leg's mode, on the departure's way out and the arrival's way in.
+ expect(dep.legMode).toBe('flight');
+ expect(arr.incomingLegMode).toBe('flight');
+ // Neither is a stored stop: ids below every assignment and every automatic night,
+ // a negative place, and the index of the stop that follows them in the stored list.
+ expect(dep.assignmentId).toBe(terminalAssignmentId(7, 'departure'));
+ expect(arr.assignmentId).toBe(terminalAssignmentId(7, 'arrival'));
+ expect(dep.assignmentId).toBeLessThan(-2_000_000_000);
+ expect(dep.placeId).toBeLessThan(0);
+ expect(dep.ownerIndex).toBe(2);
+ expect(arr.ownerIndex).toBe(2);
+ expect(isCarrierStop(dep)).toBe(true);
+ expect(isCarrierStop(day[0])).toBe(false);
+ });
+
+ it('ROADTRIP-CARRIERS-009: a dragged position decides among untimed stops, and a ride before every timed stop opens the day', () => {
+ // Three stops without a clock: the ride would close the day, but somebody dragged
+ // the booking behind the first stop in the day plan, and that is where it sits.
+ const untimedDay = [stop({ ownerIndex: 0 }), stop({ ownerIndex: 1 }), stop({ ownerIndex: 2 })];
+ const dragged = carrierSeam(flight({ day_positions: { '1': 0 } }))!;
+ expect(seatCarrierStops(1, untimedDay, [0, 1, 2], [dragged]).map((s) => s.carrier?.role ?? s.name)).toEqual([
+ 'Stop 0',
+ 'departure',
+ 'arrival',
+ 'Stop 1',
+ 'Stop 2',
+ ]);
+ // Among timed stops the clock has the last word, as it has in the day plan: a booking
+ // dragged to the top of the day still reads after the stops that come before it in time.
+ expect(seatCarrierStops(1, day, [0, 1, 2, 3], [dragged]).map((s) => s.carrier?.role ?? s.name)).toEqual([
+ 'Stop 0',
+ 'Stop 1',
+ 'Stop 2',
+ 'departure',
+ 'arrival',
+ 'Stop 3',
+ ]);
+ const early = carrierSeam(flight({ reservation_time: '06:00', reservation_end_time: '07:10' }))!;
+ expect(seatCarrierStops(1, day, [0, 1, 2, 3], [early]).map((s) => s.carrier?.role ?? s.name)).toEqual([
+ 'departure',
+ 'arrival',
+ 'Stop 0',
+ 'Stop 1',
+ 'Stop 2',
+ 'Stop 3',
+ ]);
+ });
+
+ it('ROADTRIP-CARRIERS-010: a ride without a clock and without a position goes to the end of the day, as the day plan shows it', () => {
+ const untimed = carrierSeam(flight({ reservation_time: null, reservation_end_time: null }))!;
+ const seated = seatCarrierStops(1, day, [0, 1, 2, 3], [untimed]);
+ expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual([
+ 'Stop 0',
+ 'Stop 1',
+ 'Stop 2',
+ 'Stop 3',
+ 'departure',
+ 'arrival',
+ ]);
+ expect(seated[4]!.time).toBeNull();
+ expect(seated[4]!.dwellMinutes).toBe(0);
+ });
+
+ it('ROADTRIP-CARRIERS-011: a ride landing on a later day seats its arrival on that day, and only there', () => {
+ const overnight = carrierSeam(flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 }))!;
+ const first = seatCarrierStops(1, day, [0, 1, 2, 3], [overnight]);
+ expect(first.map((s) => s.carrier?.role ?? s.name)).toEqual(['Stop 0', 'Stop 1', 'Stop 2', 'Stop 3', 'departure']);
+ // The arrival opens its day even when nothing on that day has a clock.
+ const next = [stop({ ownerIndex: 0, ownerDayId: 2 })];
+ const second = seatCarrierStops(2, next, [0], [overnight]);
+ expect(second.map((s) => s.carrier?.role ?? s.name)).toEqual(['arrival', 'Stop 0']);
+ expect(second[0]!.ownerDayId).toBe(2);
+ expect(second[0]!.ownerIndex).toBe(0);
+ expect(seatCarrierStops(3, [], [], [overnight])).toEqual([]);
+ });
+});
+
+describe('the ride as a leg', () => {
+ it('ROADTRIP-CARRIERS-012: a ride is a leg with minutes, no road and no distance, in the booking’s mode', () => {
+ const seam = carrierSeam(flight())!;
+ const [dep, arr] = seatCarrierStops(1, [], [], [seam]);
+ const legs = carrierLegsFor([dep!, arr!], 'flight', () => 0, roadtripLegKey)!;
+ const ride = legs[roadtripLegKey(dep!, arr!)]!;
+ expect(ride.seg.mode).toBe('flight');
+ expect(ride.seg.duration).toBe(70 * 60);
+ expect(ride.seg.distance).toBe(0);
+ expect(ride.line).toEqual([]);
+ expect(ride.seg.durationText).toBe('1 h 10 min');
+ expect(isCarrierMode(ride.seg.mode)).toBe(true);
+ expect(carrierLegsFor([dep!, arr!], 'driving', () => 0, roadtripLegKey)).toBeNull();
+ expect(carrierLeg(dep!, arr!, null).seg.durationText).toBe('');
+ expect(carriesTheCar('ferry')).toBe(true);
+ expect(carriesTheCar('flight')).toBe(false);
+ });
+
+ it('ROADTRIP-CARRIERS-013: no via leaves a terminal, however many are filed at the index it stands in for', () => {
+ const vias = [{ day_id: 1, after_order_index: 2, sequence: 0, lat: 1, lng: 1 }];
+ const seam = carrierSeam(flight())!;
+ const seated = seatCarrierStops(
+ 1,
+ [
+ stop({ ownerIndex: 0, time: '09:00' }),
+ stop({ ownerIndex: 1, time: '10:00' }),
+ stop({ ownerIndex: 2, time: '17:00' }),
+ ],
+ [0, 1, 2],
+ [seam],
+ );
+ const arrival = seated.find((s) => s.carrier?.role === 'arrival')!;
+ expect(arrival.ownerIndex).toBe(2);
+ expect(viasLeaving(arrival, vias)).toEqual([]);
+ expect(viasLeaving(seated[4]!, vias)).toHaveLength(1);
+ expect(carrierReservationIds([{ stops: seated }])).toEqual([7]);
+ });
+});
+
+describe('the ride in the drive', () => {
+ const seam = carrierSeam(flight({ reservation_time: '13:20', reservation_end_time: '14:30' }))!;
+ const stops = seatCarrierStops(
+ 1,
+ [stop({ ownerIndex: 0, time: '09:00', dwellMinutes: 0 }), stop({ ownerIndex: 1, time: '10:00', dwellMinutes: 0 })],
+ [0, 1],
+ [seam],
+ );
+ const [a, b, dep, arr] = stops as [RoadtripStop, RoadtripStop, RoadtripStop, RoadtripStop];
+ const allLegs: Record = {
+ [roadtripLegKey(a, b)]: road(60),
+ [roadtripLegKey(b, dep)]: road(30),
+ ...carrierLegsFor([dep, arr], 'flight', () => 0, roadtripLegKey)!,
+ };
+ const plan: PlanDay[] = [{ dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops }];
+
+ it('ROADTRIP-CARRIERS-014: the day counts its road and not its ride, and the terminals are not destinations', () => {
+ const routes = assembleRoadtrip({
+ plan,
+ quietDays: [],
+ window: null,
+ distanceUnit: 'metric',
+ allLegs,
+ snapByDay: {},
+ missedByDay: {},
+ loading: false,
+ limits: { legMinutes: null, dayMinutes: null, rangeKm: 50 },
+ vehicleKind: 'combustion',
+ connectDays: false,
+ boundaries: [],
+ labels: { start: 'go', end: 'stop' },
+ });
+ const day = routes.days[0]!;
+ expect(day.distance).toBe(90_000);
+ expect(day.duration).toBe(90 * 60);
+ expect(routes.totalStops).toBe(2);
+ // The ride is no line on the map and no labelled segment; the arc is the booking's.
+ expect(routes.lines).toHaveLength(2);
+ expect(routes.segments.map((s) => s.mode)).toEqual(['driving', 'driving']);
+ // The chain still runs through it: the terminal is reached a check-in ahead of the
+ // flight, the flight leaves on the timetable, the far end lands on the timetable.
+ expect(day.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
+ ['09:00', '09:00'],
+ ['10:00', '10:00'],
+ ['12:20', '13:20'],
+ ['14:30', '14:30'],
+ ]);
+ // A 50 km range and 90 km of road before the flight: the warning belongs to the
+ // drive, and after the flight the tank is another car's, so nothing carries over.
+ expect(day.driveWarnings.some((w) => w.code === 'range')).toBe(true);
+ expect(day.dryPoints?.every((p) => p.legIndex < 2)).toBe(true);
+ });
+
+ it('ROADTRIP-CARRIERS-015: with a daily window, an overnight ride carries the clock to the day it lands on without a night marker on the flight', () => {
+ const overnight = carrierSeam(flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 }))!;
+ const dayOne = seatCarrierStops(1, [stop({ ownerIndex: 0, time: '18:00', dwellMinutes: 0 })], [0], [overnight]);
+ const dayTwo = seatCarrierStops(
+ 2,
+ [stop({ ownerIndex: 0, ownerDayId: 2, dwellMinutes: 0, lat: 53.7, lng: 10.1 })],
+ [0],
+ [overnight],
+ );
+ const [origin, departure] = dayOne as [RoadtripStop, RoadtripStop];
+ const [arrival, hotel] = dayTwo as [RoadtripStop, RoadtripStop];
+ const legs: Record = {
+ [roadtripLegKey(origin, departure)]: road(30),
+ [roadtripLegKey(arrival, hotel)]: road(20),
+ ...carrierLegsFor([departure, arrival], 'flight', () => 1, roadtripLegKey)!,
+ };
+ const days: PlanDay[] = [
+ { dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops: dayOne },
+ { dayId: 2, dayNumber: 2, date: '2026-06-02', title: null, stops: dayTwo },
+ ];
+ const timed = planDayWindow(days, { start: 8 * 60, end: 20 * 60 }, (f, t) => legs[roadtripLegKey(f, t)], 'metric', {
+ start: 'go',
+ end: 'stop',
+ });
+ expect(timed.issue).toBeNull();
+ const chainOne = timed.chains.find((c) => c.dayNumber === 1)!;
+ const chainTwo = timed.chains.find((c) => c.dayNumber === 2)!;
+ expect(chainOne.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['Stop 0', 'departure']);
+ expect(chainOne.schedule.entries[1]).toMatchObject({ arrival: '21:00', departure: '22:00' });
+ expect(chainTwo.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['arrival', 'Stop 0']);
+ expect(chainTwo.schedule.entries[0]).toMatchObject({ arrival: '07:00' });
+ expect(chainTwo.stops.some((s) => s.automaticNight)).toBe(false);
+ });
+});
diff --git a/shared/src/roadtrip/carriers.ts b/shared/src/roadtrip/carriers.ts
new file mode 100644
index 000000000..9601e6903
--- /dev/null
+++ b/shared/src/roadtrip/carriers.ts
@@ -0,0 +1,436 @@
+import { chronoOrder } from '../day/chrono-order';
+import type { CarrierTerminal, RoadtripStop, RoutedLeg } from './planning-types';
+import { formatClock, formatDurationShort, parseClock } from './roadtripModel';
+
+/**
+ * Bookings the traveller rides and then leaves behind, the same set the day plan calls a
+ * carrier (`isCarrierTransport` in the client): the vehicle carries them out of the day's
+ * geography and keeps going without them. A hire car, a taxi or a transit hop is not one
+ * of these, because the traveller stays in the drive's geography the whole way.
+ *
+ * On the road trip such a booking is a seam: the road ends at the terminal it leaves
+ * from and starts again at the one it lands at. Routing straight across it drew a
+ * two-thousand-kilometre drive where a flight was booked (#2428).
+ */
+export const CARRIER_TYPES = ['flight', 'train', 'ferry', 'cruise', 'bus'] as const;
+
+export function isCarrierType(type: string | null | undefined): boolean {
+ return !!type && (CARRIER_TYPES as readonly string[]).includes(type);
+}
+
+/** A leg mode that is a ride rather than a road: the booking's own type is the mode. */
+export function isCarrierMode(mode: string | null | undefined): boolean {
+ return isCarrierType(mode);
+}
+
+/**
+ * How long before the timetable's departure the traveller has to be at the terminal.
+ *
+ * The pin on the departure terminal is this much before the departure, not the departure
+ * itself: a drive that reaches the airport as the doors close is late, however the model
+ * counts it. The ride then leaves at the timetable's minute.
+ */
+export const CHECK_IN_MINUTES: Record = {
+ flight: 60,
+ cruise: 60,
+ ferry: 30,
+ train: 10,
+ bus: 10,
+};
+
+/**
+ * Whether the car goes along. A range budget carries across a ride the car takes too
+ * (a car ferry, a motorail) and starts afresh after one it cannot (nobody flies a car),
+ * because the tank the drive continues on is a different tank.
+ */
+export function carriesTheCar(type: string): boolean {
+ return type === 'ferry' || type === 'train';
+}
+
+/** A booking as either side stores it: the row with its endpoints and positions joined on. */
+export interface CarrierBooking {
+ id: number;
+ type: string;
+ title: string;
+ day_id?: number | null;
+ end_day_id?: number | null;
+ /** 'HH:mm' or 'YYYY-MM-DDTHH:mm', as the reservation form writes it. */
+ reservation_time?: string | null;
+ reservation_end_time?: string | null;
+ /** JSON as stored, or the parsed object; only `legs` is read here. */
+ metadata?: string | Record | null;
+ endpoints?: readonly {
+ role: string;
+ sequence?: number | null;
+ name: string;
+ code?: string | null;
+ lat: number | null;
+ lng: number | null;
+ }[];
+ day_positions?: Record | null;
+ day_plan_position?: number | null;
+}
+
+/** One end of a seam: where, when and on which day the drive stops or resumes. */
+export interface CarrierEnd {
+ dayId: number;
+ /** Where the terminal sits among the day's stops, when somebody placed the booking by hand. */
+ position: number | null;
+ /** The timetable's clock at this terminal, 'HH:mm', null when the booking names none. */
+ clock: string | null;
+ name: string;
+ code: string | null;
+ lat: number;
+ lng: number;
+}
+
+export interface CarrierSeam {
+ reservationId: number;
+ type: string;
+ title: string;
+ departure: CarrierEnd;
+ arrival: CarrierEnd;
+}
+
+interface LegRecord {
+ dep_day_id?: number | null;
+ dep_time?: string | null;
+ arr_day_id?: number | null;
+ arr_time?: string | null;
+ day_positions?: Record | null;
+}
+
+function parseMetadata(raw: CarrierBooking['metadata']): Record {
+ if (!raw) return {};
+ if (typeof raw !== 'string') return raw;
+ try {
+ let parsed: unknown = JSON.parse(raw);
+ // A booking saved by an earlier bug carries its JSON encoded twice; the day plan
+ // unwraps it once more on read and so does this.
+ if (typeof parsed === 'string') parsed = JSON.parse(parsed);
+ return parsed && typeof parsed === 'object' ? (parsed as Record) : {};
+ } catch {
+ return {};
+ }
+}
+
+/** The legs of a booking with a stopover, in order; none for a direct one. */
+function legsOf(booking: CarrierBooking): LegRecord[] {
+ const legs = parseMetadata(booking.metadata).legs;
+ return Array.isArray(legs) ? (legs as LegRecord[]) : [];
+}
+
+/** 'HH:mm' out of either form the reservation columns hold, null for anything else. */
+export function carrierClock(value: string | null | undefined): string | null {
+ if (!value) return null;
+ const text = value.includes('T') ? value.slice(value.indexOf('T') + 1) : value;
+ const minutes = parseClock(text);
+ return minutes === null ? null : formatClock(minutes);
+}
+
+function positionOn(positions: Record | null | undefined, dayId: number): number | null {
+ const value = positions?.[String(dayId)] ?? (positions as Record | null | undefined)?.[dayId];
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
+}
+
+/**
+ * The seam a booking makes, or null when it makes none: a booking on no day is not on the
+ * plan, and one without a located terminal at BOTH ends cannot say where the drive stops
+ * or where it goes on, so it is left to the day plan.
+ *
+ * A booking with a stopover is one seam from its first departure to its last arrival. The
+ * change of planes happens inside the ride, and the drive has no say in it.
+ */
+export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
+ if (!isCarrierType(booking.type)) return null;
+ const located = (booking.endpoints ?? [])
+ .filter(
+ (e) => typeof e.lat === 'number' && typeof e.lng === 'number' && Number.isFinite(e.lat) && Number.isFinite(e.lng),
+ )
+ .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
+ const from = located.find((e) => e.role === 'from') ?? located[0];
+ const to = [...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1];
+ if (!from || !to || from === to) return null;
+
+ const legs = legsOf(booking);
+ const first = legs[0];
+ const last = legs[legs.length - 1];
+ const depDayId = first?.dep_day_id ?? booking.day_id ?? null;
+ if (depDayId == null) return null;
+ const arrDayId = last?.arr_day_id ?? booking.end_day_id ?? depDayId;
+ const depClock = carrierClock(first?.dep_time ?? booking.reservation_time);
+ const arrClock = carrierClock(last?.arr_time ?? booking.reservation_end_time);
+ const bookingPosition = (dayId: number): number | null =>
+ positionOn(booking.day_positions, dayId) ??
+ (typeof booking.day_plan_position === 'number' ? booking.day_plan_position : null);
+
+ return {
+ reservationId: booking.id,
+ type: booking.type,
+ title: booking.title,
+ departure: {
+ dayId: depDayId,
+ position: positionOn(first?.day_positions, depDayId) ?? bookingPosition(depDayId),
+ clock: depClock,
+ name: from.name,
+ code: from.code ?? null,
+ lat: from.lat!,
+ lng: from.lng!,
+ },
+ arrival: {
+ dayId: arrDayId,
+ position: positionOn(last?.day_positions, arrDayId) ?? bookingPosition(arrDayId),
+ clock: arrClock,
+ name: to.name,
+ code: to.code ?? null,
+ lat: to.lat!,
+ lng: to.lng!,
+ },
+ };
+}
+
+/**
+ * Minutes of the ride as the clocks tell them, across the days the booking spans.
+ *
+ * Wall clocks on purpose, not elapsed time: the chain runs on the local clock, and after
+ * landing the day goes on in the destination's time. A ride that lands earlier on the
+ * clock than it left (westward across the date line) cannot be told from a ride of no
+ * length this way and counts as one. Null when either clock is missing.
+ */
+export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number | null {
+ const dep = parseClock(seam.departure.clock);
+ const arr = parseClock(seam.arrival.clock);
+ if (dep === null || arr === null) return null;
+ return Math.max(0, arr + Math.max(0, dayDelta) * 1440 - dep);
+}
+
+/** Below the automatic nights (-2e9 and down): a terminal is no assignment and no night. */
+const TERMINAL_ID_BASE = -3_000_000_000;
+
+export function terminalAssignmentId(reservationId: number, role: CarrierTerminal['role']): number {
+ return TERMINAL_ID_BASE - reservationId * 2 - (role === 'arrival' ? 1 : 0);
+}
+
+/** Whether a stop is a carrier terminal, which is what every writer has to refuse. */
+export function isCarrierStop(stop: Pick | null | undefined): boolean {
+ return !!stop?.carrier;
+}
+
+function terminalStop(
+ seam: CarrierSeam,
+ role: CarrierTerminal['role'],
+ dayId: number,
+ ownerIndex: number,
+): RoadtripStop {
+ const end = role === 'departure' ? seam.departure : seam.arrival;
+ const checkIn = CHECK_IN_MINUTES[seam.type] ?? 0;
+ const depart = parseClock(end.clock);
+ // The departure terminal is pinned a check-in ahead of the timetable and left at the
+ // timetable's minute; the arrival one is pinned at the timetable's minute and left at
+ // once. Neither has a stay of its own.
+ const time = role === 'departure' && depart !== null ? formatClock(depart - checkIn) : end.clock;
+ return {
+ carrier: {
+ reservationId: seam.reservationId,
+ type: seam.type,
+ role,
+ title: seam.title,
+ code: end.code,
+ at: end.clock,
+ },
+ assignmentId: terminalAssignmentId(seam.reservationId, role),
+ ownerDayId: dayId,
+ ownerIndex,
+ placeId: -seam.reservationId,
+ name: end.name,
+ lat: end.lat,
+ lng: end.lng,
+ time,
+ leaveAt: role === 'departure' ? end.clock : null,
+ dwellMinutes: role === 'departure' && depart !== null ? checkIn : 0,
+ checkInTime: null,
+ night: false,
+ endDay: false,
+ legMode: role === 'departure' ? seam.type : null,
+ incomingLegMode: role === 'arrival' ? seam.type : null,
+ stopType: null,
+ fillPercent: null,
+ };
+}
+
+/**
+ * The day's stops with the terminals of its rides seated among them.
+ *
+ * Each terminal takes the slot the day plan shows the booking in, worked out by the same
+ * rules (`getMergedItems` in the client): a position somebody dragged it to wins, otherwise
+ * it goes behind the last stop whose time is at or before its own, otherwise at the end,
+ * and the whole list is then read in clock order. Only the terminal moves, though. The
+ * stops keep the order they are stored in, which is the order the road trip has always
+ * driven them in, and the terminal is slotted in behind the stop that precedes it in the
+ * day plan's reading.
+ *
+ * A ride that leaves and lands on the same day seats its arrival right behind its
+ * departure: nothing is visited in between. One that lands on a later day seats the
+ * arrival on that day, where it opens the drive.
+ *
+ * `orderIndex` is each stop's stored `order_index`, which is what a dragged position is
+ * measured against; `clockOf` its time in minutes, or null.
+ */
+export function seatCarrierStops(
+ dayId: number,
+ stops: RoadtripStop[],
+ orderIndex: number[],
+ seams: CarrierSeam[],
+): RoadtripStop[] {
+ const departing = seams.filter((s) => s.departure.dayId === dayId);
+ const arriving = seams.filter((s) => s.arrival.dayId === dayId && s.departure.dayId !== dayId);
+ if (!departing.length && !arriving.length) return stops;
+
+ type Item =
+ | { kind: 'stop'; index: number; key: number; minutes: number | null }
+ | { kind: 'end'; seam: CarrierSeam; role: CarrierTerminal['role']; key: number; minutes: number | null };
+ const base: Item[] = stops.map((stop, index) => ({
+ kind: 'stop',
+ index,
+ key: orderIndex[index] ?? index,
+ minutes: parseClock(stop.time ?? stop.checkInTime ?? null),
+ }));
+ const ends: { seam: CarrierSeam; role: CarrierTerminal['role']; end: CarrierEnd }[] = [
+ ...departing.map((seam) => ({ seam, role: 'departure' as const, end: seam.departure })),
+ ...arriving.map((seam) => ({ seam, role: 'arrival' as const, end: seam.arrival })),
+ ];
+ const items: Item[] = [...base];
+ const lastKey = base.length ? Math.max(...base.map((b) => b.key)) : 0;
+ const firstKey = base.length ? Math.min(...base.map((b) => b.key)) : 0;
+ ends
+ .map((e) => ({ ...e, minutes: parseClock(e.end.clock) }))
+ .sort((a, b) => (a.minutes ?? 0) - (b.minutes ?? 0))
+ .forEach((e, ti) => {
+ let key = e.end.position;
+ if (key === null) {
+ const minutes = e.minutes ?? 0;
+ let after = -Infinity;
+ for (const item of items)
+ if (item.minutes !== null && item.minutes <= minutes) after = Math.max(after, item.key);
+ // With no timed stop ahead of it, a departure closes the day, the way the day plan
+ // lists it. An arrival that landed overnight OPENS the day it lands on: the day
+ // plan happens to file it last there too, but read as a drive that puts the
+ // morning's stops on the road before the traveller has landed.
+ const opens = e.role === 'arrival';
+ key =
+ after === -Infinity
+ ? opens
+ ? firstKey - 0.5 - ti * 0.01
+ : lastKey + 0.5 + ti * 0.01
+ : after + 0.01 + ti * 0.001;
+ }
+ items.push({ kind: 'end', seam: e.seam, role: e.role, key, minutes: e.minutes });
+ });
+ items.sort((a, b) => a.key - b.key || (a.kind === 'stop' ? -1 : 1));
+ // Untimed stops keep their place behind the timed item before them, as in the day plan.
+ // An arrival opening the day is timed and first, so the untimed stops behind it follow
+ // it rather than sorting ahead of it on a clock they never had.
+ const read = chronoOrder(items, (item) => item.minutes);
+
+ // Where each terminal lands: behind the stop before it in the day plan's reading, in
+ // the stored order. Two terminals behind the same stop keep their reading order.
+ const slots: { seam: CarrierSeam; role: CarrierTerminal['role']; after: number }[] = [];
+ let lastStop = -1;
+ for (const item of read) {
+ if (item.kind === 'stop') lastStop = item.index;
+ else slots.push({ seam: item.seam, role: item.role, after: lastStop });
+ }
+ const out: RoadtripStop[] = [];
+ const emit = (slot: (typeof slots)[number]): void => {
+ const insertAt = out.filter((s) => !s.carrier).length;
+ out.push(terminalStop(slot.seam, slot.role, dayId, insertAt));
+ if (slot.role === 'departure' && slot.seam.arrival.dayId === dayId) {
+ out.push(terminalStop(slot.seam, 'arrival', dayId, insertAt));
+ }
+ };
+ for (const slot of slots.filter((s) => s.after < 0)) emit(slot);
+ stops.forEach((stop, index) => {
+ out.push(stop);
+ for (const slot of slots.filter((s) => s.after === index)) emit(slot);
+ });
+ return out;
+}
+
+/**
+ * The ride between a departure terminal and its arrival, in the shape of a routed leg.
+ *
+ * Its mode is the booking's type, so every reader that tells driving from walking tells
+ * a ride apart the same way. No line and no distance: the map draws the booking's own
+ * arc for it, and the line here is what the corridor and the via points measure along,
+ * which a flight is not. The minutes are what the chain needs.
+ */
+export function carrierLeg(from: RoadtripStop, to: RoadtripStop, minutes: number | null): RoutedLeg {
+ const seconds = (minutes ?? 0) * 60;
+ const a: [number, number] = [from.lat, from.lng];
+ const b: [number, number] = [to.lat, to.lng];
+ const durationText = minutes === null ? '' : formatDurationShort(seconds);
+ return {
+ seg: {
+ from: a,
+ to: b,
+ mid: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
+ distance: 0,
+ duration: seconds,
+ mode: from.carrier?.type ?? to.carrier?.type ?? 'flight',
+ distanceText: '',
+ durationText,
+ drivingText: durationText,
+ walkingText: durationText,
+ },
+ line: [],
+ vias: [],
+ };
+}
+
+/**
+ * The seams a run of stops asks for, when the run is a ride and not a road: one leg per
+ * consecutive pair, each the ride between them. Null for a run that is a road.
+ */
+export function carrierLegsFor(
+ run: RoadtripStop[],
+ mode: string,
+ dayDelta: (from: RoadtripStop, to: RoadtripStop) => number,
+ key: (from: RoadtripStop, to: RoadtripStop) => string,
+): Record | null {
+ if (!isCarrierMode(mode)) return null;
+ const legs: Record = {};
+ for (let i = 0; i < run.length - 1; i++) {
+ const from = run[i]!;
+ const to = run[i + 1]!;
+ const dep = parseClock(from.carrier?.at);
+ const arr = parseClock(to.carrier?.at);
+ const minutes =
+ from.carrier && to.carrier && dep !== null && arr !== null
+ ? Math.max(0, arr + Math.max(0, dayDelta(from, to)) * 1440 - dep)
+ : null;
+ legs[key(from, to)] = carrierLeg(from, to, minutes);
+ }
+ return legs;
+}
+
+/**
+ * The via points shaping the drive that leaves a stop. None leave a terminal: a via is
+ * filed by the position of a stored stop, and a terminal stands in for none, so the
+ * points filed at its index belong to the stop that really has that index.
+ */
+export function viasLeaving(
+ stop: Pick,
+ vias: readonly V[],
+): V[] {
+ if (stop.carrier) return [];
+ return vias
+ .filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex)
+ .sort((a, b) => a.sequence - b.sequence);
+}
+
+/** The bookings whose ride the days draw, for the map to show their arcs beside the roads. */
+export function carrierReservationIds(days: readonly { stops: readonly Pick[] }[]): number[] {
+ const ids = new Set();
+ for (const day of days) for (const stop of day.stops) if (stop.carrier) ids.add(stop.carrier.reservationId);
+ return [...ids];
+}
diff --git a/shared/src/roadtrip/dayWindow.ts b/shared/src/roadtrip/dayWindow.ts
index 27d8d1125..6323d3ddf 100644
--- a/shared/src/roadtrip/dayWindow.ts
+++ b/shared/src/roadtrip/dayWindow.ts
@@ -178,6 +178,8 @@ export function planDayWindow(
const last = chain.stops[chain.stops.length - 1]!;
const end: RoadtripStop = {
...at,
+ // A marker placed where a terminal stands is a marker, not the terminal.
+ carrier: undefined,
assignmentId: -2000000000 - number * 2,
name: labels.end,
time: null,
@@ -215,6 +217,33 @@ export function planDayWindow(
// When the drive into this stop set out, on this day's clock: for one a night broke
// up, the morning it went on.
let setOut: number | null = null;
+ // A ride from its departure terminal to its arrival runs on the booking's clock and
+ // through the night if it must: no day end is placed on a flight and no morning
+ // resumes on one. The clock follows the ride, and the day counter follows the clock,
+ // to the day the booking lands on. A road leg gets the window's treatment below.
+ if (
+ previous?.carrier?.role === 'departure' &&
+ stop.carrier?.role === 'arrival' &&
+ previous.carrier.reservationId === stop.carrier.reservationId
+ ) {
+ const leg = legs[i - 1]!;
+ clock += leg.seg.duration / 60;
+ while (number < day.dayNumber && clock >= 1440) {
+ clock -= 1440;
+ number += 1;
+ }
+ // The booking's days are what it says they are, whatever its clocks add up to.
+ if (number !== day.dayNumber) {
+ number = day.dayNumber;
+ clock = ((clock % 1440) + 1440) % 1440;
+ }
+ if (pin !== null) clock = pin;
+ putLeg(previous, stop, leg);
+ position = i;
+ append(stop, clock);
+ previous = stop;
+ continue;
+ }
if (previous && number < day.dayNumber && !targets.has(number)) {
if (window.endMode !== 'stop') clock = Math.max(clock, window.end);
previous = night(previous);
@@ -312,7 +341,9 @@ export function planDayWindow(
}
let dwell = left ? left.departure - clock : Math.max(0, stop.dwellMinutes ?? 0);
while (dwell > 0) {
- const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock);
+ // The wait at a terminal is the booking's, not the window's: a check-in that runs
+ // past the travel hours is not a day ending at the airport.
+ const remaining = targets.has(number) || stop.carrier ? dwell : Math.max(0, window.end - clock);
const spend = Math.min(dwell, remaining);
clock += spend;
dwell -= spend;
diff --git a/shared/src/roadtrip/planning-types.ts b/shared/src/roadtrip/planning-types.ts
index 18d143ab9..2c3823541 100644
--- a/shared/src/roadtrip/planning-types.ts
+++ b/shared/src/roadtrip/planning-types.ts
@@ -4,8 +4,31 @@ import type { AutomaticNight } from './dayWindow';
import type { SpillMark } from './nightSpill';
import type { Schedule, ScheduleWarning, DayWarning, DryPoint } from './roadtripModel';
+/**
+ * A terminal of a booking the traveller rides rather than drives: the airport, station or
+ * port a flight, train, ferry, cruise or bus leaves from or lands at.
+ *
+ * The drive ends at the departure terminal and starts again at the arrival one. What
+ * happens in between is the booking's business, not the road's: the leg between the
+ * two carries the ride's minutes and nothing else, and no road is ever asked for it.
+ */
+export interface CarrierTerminal {
+ reservationId: number;
+ /** flight | train | ferry | cruise | bus, the booking's own type. */
+ type: string;
+ role: 'departure' | 'arrival';
+ /** The booking's title, which is what the rail prints on the ride. */
+ title: string;
+ /** IATA code or station code, when the booking carries one. */
+ code: string | null;
+ /** The timetable's clock at this terminal, 'HH:mm' local, null when the booking names none. */
+ at: string | null;
+}
+
export interface RoadtripStop {
automaticNight?: AutomaticNight;
+ /** Set on the two ends of a carrier ride. Such a stop is no place and belongs to no assignment. */
+ carrier?: CarrierTerminal;
assignmentId: number;
ownerDayId: number;
diff --git a/shared/src/roadtrip/planning.ts b/shared/src/roadtrip/planning.ts
index ce88a0a0f..367b5f392 100644
--- a/shared/src/roadtrip/planning.ts
+++ b/shared/src/roadtrip/planning.ts
@@ -7,3 +7,4 @@ export * from './units';
export * from './vehicleRange';
export * from './assemble';
export * from './routeRun';
+export * from './carriers';
diff --git a/wiki/Road-Trip.md b/wiki/Road-Trip.md
index 1bd9e4cc2..a61570970 100644
--- a/wiki/Road-Trip.md
+++ b/wiki/Road-Trip.md
@@ -282,6 +282,19 @@ A night booked anywhere, whether in the day panel, the booking form, on the phon
See [Accommodations](Accommodations#on-the-route) for the details.
+## Flights, trains and ferries on the drive
+
+A **Flight**, **Train**, **Ferry**, **Cruise** or **Bus** booking on a day is a seam in the drive: the road ends at the terminal the booking leaves from and starts again at the one it lands at. Nothing is routed across the ride, so a flight from Hamburg to Munich no longer turns into an eight-hour drive on the road trip map.
+
+- The two terminals appear on the day card as their own rows, with the booking's icon instead of a number and the timetable under the name (*Departure 13:20*, *Arrival 14:30*). Between them sits the ride: the booking and how long it takes. All three open the booking.
+- The departure terminal is pinned a check-in ahead of the timetable (an hour for a flight or a cruise, half an hour for a ferry, ten minutes for a train or a bus). A drive that cannot get there by then is late, the way a missed pinned time is. The arrival terminal is pinned at the timetable's arrival, and the drive continues from it.
+- The map draws the ride as the booking's own line, the same arc the day plan draws, beside the roads.
+- The ride's hours and kilometres are the booking's, not the drive's: the day's distance and driving time count the roads only, the range budget spends nothing on a flight, and after a ride the car cannot take (a flight, a cruise, a bus) the range starts afresh, because the next road is driven in another car. A car ferry or a motorail keeps the budget.
+- A booking that lands on a later day seats its arrival at the start of that day. With **Connect the days** on, the join between the two days is the ride. With daily travel times set, the ride runs on the booking's clock: no day ends at the airport and no morning resumes on the plane.
+- The terminals sit where the day plan lists the booking: a position you dragged it to under Days, otherwise behind the last stop whose time is before the departure, otherwise at the end of the day. Move the booking under Days to move the seam.
+
+What the seam does not do: it never plans the ride itself, and a booking without located terminals (an airport TREK could not place, a train with no station coordinates) makes no seam and is left to the day plan. Via points and other ways of driving are offered for the road into a departure terminal, not for the ride or for the road out of an arrival, and the search along the route skips the ride. A hire car, a taxi or a transit hop is not a seam: you stay on the road.
+
## Car bookings with stops
A **Car** booking can carry the stops of its drive between pick-up and return, the way a flight carries its layovers. In the booking form, **Stops along the way** lists them, each with a place and a time; **Add stop** adds one and the arrows put them in order. The booking's line on the map runs through them in that order. This works on the desktop and on the phone, and does not need the addon. See [Transport-Flights-Trains-Cars](Transport-Flights-Trains-Cars).