From fa35966ab8e6eb08a5074d0dea9810bc195c907a Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 21 Sep 2026 16:03:21 +0200 Subject: [PATCH] feat(roadtrip): a flight, train or ferry booking seams the drive at its terminals The road trip routed straight from one stop to the next, whatever stood between them: a flight booked for the afternoon became an eight-hour drive on the map, with the hotel reached by road from a town the traveller had flown out of (issue #2428). The day plan has treated such bookings as carriers for a while, and the drive now reads them the same way. A flight, train, ferry, cruise or bus with located terminals is a seam. Its two terminals are seated among the day's stops where the day plan lists the booking, as stops of their own: no number, no stay, no kind, not draggable, and an id below every assignment and every automatic night, because they stand for none. The road ends at the departure terminal and starts again at the arrival one; the leg between them is the ride, in the booking's own mode, with the timetable's minutes and no distance, and no router is ever asked for it. The departure terminal is pinned a check-in ahead of the timetable and left on the timetable's minute, so a drive that cannot make the airport in time is late the way a missed pin is. A ride that lands on a later day seats its arrival at the start of that day; with connected days the join between the two is the ride, and with daily travel times set the ride runs on the booking's clock through the night, with no day end placed on a flight. The map draws the ride as the booking's own arc, the one the day plan already draws, beside the roads. The day's distance and driving time count the roads only, the range budget spends nothing on a ride, and after one the car cannot take it starts afresh. The corridor search leaves the ride out and drops a hit under the flight path; a via cannot be filed against a terminal, and other ways of driving are offered for the road into a departure terminal only. The server plan reads the same three tables and seats the same stops, so calculate_roadtrip answers the same as the rail. Closes #2428 --- .../Roadtrip/RoadtripSidebar.test.tsx | 70 +++ .../components/Roadtrip/RoadtripSidebar.tsx | 165 ++++++- client/src/components/Roadtrip/carrierRide.ts | 45 ++ .../Roadtrip/roadtripRowModel.test.ts | 47 ++ .../components/Roadtrip/roadtripRowModel.ts | 30 +- .../Roadtrip/useCorridorPois.test.ts | 22 + .../components/Roadtrip/useCorridorPois.ts | 48 +- .../Roadtrip/useRoadtripCorridor.ts | 16 +- .../Roadtrip/useRoadtripRoutes.test.ts | 93 ++++ .../components/Roadtrip/useRoadtripRoutes.ts | 95 ++-- .../screens/trip/roadtrip/MRoadtripRows.tsx | 49 +- .../screens/trip/roadtrip/MRoadtripTab.tsx | 11 +- client/src/pages/TripPlannerPage.tsx | 10 +- .../src/pages/tripPlanner/useTripPlanner.ts | 37 +- .../nest/roadtrip/roadtrip-plan.service.ts | 122 ++++- .../nest/roadtrip/roadtrip-planning.mcp.ts | 4 +- .../unit/nest/roadtrip-plan.service.test.ts | 68 +++ shared/src/i18n/ar/roadtrip.ts | 4 + shared/src/i18n/br/roadtrip.ts | 4 + shared/src/i18n/ca/roadtrip.ts | 4 + shared/src/i18n/cs/roadtrip.ts | 4 + shared/src/i18n/de/roadtrip.ts | 4 + shared/src/i18n/en/roadtrip.ts | 4 + shared/src/i18n/es/roadtrip.ts | 4 + shared/src/i18n/fr/roadtrip.ts | 4 + shared/src/i18n/gr/roadtrip.ts | 4 + shared/src/i18n/hu/roadtrip.ts | 4 + shared/src/i18n/id/roadtrip.ts | 4 + shared/src/i18n/it/roadtrip.ts | 4 + shared/src/i18n/ja/roadtrip.ts | 4 + shared/src/i18n/ko/roadtrip.ts | 4 + shared/src/i18n/nl/roadtrip.ts | 4 + shared/src/i18n/pl/roadtrip.ts | 4 + shared/src/i18n/ru/roadtrip.ts | 4 + shared/src/i18n/sv/roadtrip.ts | 4 + shared/src/i18n/tr/roadtrip.ts | 4 + shared/src/i18n/uk/roadtrip.ts | 4 + shared/src/i18n/vi/roadtrip.ts | 4 + shared/src/i18n/zh-TW/roadtrip.ts | 4 + shared/src/i18n/zh/roadtrip.ts | 4 + shared/src/roadtrip/assemble.ts | 25 +- shared/src/roadtrip/carriers.spec.ts | 399 ++++++++++++++++ shared/src/roadtrip/carriers.ts | 436 ++++++++++++++++++ shared/src/roadtrip/dayWindow.ts | 33 +- shared/src/roadtrip/planning-types.ts | 23 + shared/src/roadtrip/planning.ts | 1 + wiki/Road-Trip.md | 13 + 47 files changed, 1856 insertions(+), 98 deletions(-) create mode 100644 client/src/components/Roadtrip/carrierRide.ts create mode 100644 shared/src/roadtrip/carriers.spec.ts create mode 100644 shared/src/roadtrip/carriers.ts 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 ( +
  • + + {ride ? ( + + ) : i < last && (!next?.automaticNight || day.legs[i]?.distance !== 0) ? ( + + ) : null} + {i < last && !ride ? (day.legVias[i] ?? []).map((via, vi) => ( + + )) : null} +
  • + ) + } + 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 = ( + <> +