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 = ( + <> +