feat(roadtrip): hire car desks, bookings under stops, the ride as one block

A hire car is no seam, but its desks are where the drive starts and ends: the
pick-up opens its day when nothing timed comes first, the return closes the
day it is handed back on, the road runs through both and the range budget
starts from a full tank at the pick-up. The return is only placed when the
booking names a return day and a located return desk.

A table, tickets or a tour on the day hang under the stop they are pinned to
or booked at as chips that open the booking; one for no stop is listed under
the day. A ride that leaves and lands on the same day is one block on the
rail and in the phone chain, not two terminal rows and a band. Both shells
read the same helpers (carrierRide, stopBookings, roadtripRowModel).

Server reads car bookings into the plan context; the MCP descriptions say so.
The pt-BR locale had the three ride strings in Breton, fixed alongside.

Refs #2428
This commit is contained in:
Maurice
2026-09-21 17:05:42 +02:00
parent 0b4230ff4c
commit bdb031a1e9
43 changed files with 1069 additions and 189 deletions
@@ -8,7 +8,7 @@ import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRou
import type { DryPoint } from './roadtripModel' import type { DryPoint } from './roadtripModel'
import type { RefuelSearch } from './useRefuelSearch' import type { RefuelSearch } from './useRefuelSearch'
import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion' import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion'
import type { RouteSegment } from '../../types' import type { Reservation, RouteSegment } from '../../types'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>) const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
@@ -1131,7 +1131,7 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
}) })
} }
it('FE-ROADTRIP-SIDEBAR-049: the terminals carry the timetable and no number, the ride carries the booking and its minutes', () => { it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes over both terminals with their timetable, and no number', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} />) wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} />)
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument() expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument() expect(screen.getByText('HAM')).toBeInTheDocument()
@@ -1144,16 +1144,107 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
expect(screen.getByText('2 stops')).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', () => { it('FE-ROADTRIP-SIDEBAR-050: the ride block opens the booking; the road out of the arrival offers no other ways', () => {
const onOpenCarrier = vi.fn() const onOpenBooking = vi.fn()
const onAskAlternatives = vi.fn() const onAskAlternatives = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenCarrier={onOpenCarrier} onAskAlternatives={onAskAlternatives} />) wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={onAskAlternatives} />)
// One control for the whole ride, not one per terminal and one for the band.
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
fireEvent.click(screen.getByText('Hamburg Airport')) fireEvent.click(screen.getByText('Hamburg Airport'))
fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min')) fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min'))
expect(onOpenCarrier).toHaveBeenCalledTimes(2) expect(onOpenBooking).toHaveBeenCalledTimes(2)
expect(onOpenCarrier).toHaveBeenCalledWith(70) expect(onOpenBooking).toHaveBeenCalledWith(70)
// One shuffle: the road into the departure terminal. Not the ride, not the road out // 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. // of the arrival, whose via would be filed at the terminal's index.
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1) expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
}) })
it('FE-ROADTRIP-SIDEBAR-051: a ride landing tomorrow leaves its departure as a row of its own, with the road into it', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), terminal('departure')]
const overnight = day({
stops,
legs: [leg()],
schedule: {
entries: [
{ arrival: '18:00', departure: '18:00', anchored: true, dayOffset: 0 },
{ arrival: '21:00', departure: '22:00', anchored: true, dayOffset: 0 },
],
warnings: [],
},
})
wrap(<RoadtripSidebar routes={routes({ days: [overnight], totalStops: 1 })} onOpenBooking={vi.fn()} onAskAlternatives={vi.fn()} />)
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
expect(screen.getByText('Departure 13:20')).toBeInTheDocument()
expect(screen.queryByText('LH 2020 · 1 h 10 min')).not.toBeInTheDocument()
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
})
})
describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () => {
const desk = (role: 'pickup' | 'return') =>
stop({
assignmentId: role === 'pickup' ? -3000000180 : -3000000181,
name: role === 'pickup' ? 'Sixt Hauptbahnhof' : 'Sixt Airport',
placeId: -90,
time: role === 'pickup' ? '09:00' : '11:30',
dwellMinutes: 0,
carrier: { reservationId: 90, type: 'car', role, title: 'Sixt Hamburg', code: role === 'return' ? 'HAM' : null, at: role === 'pickup' ? '09:00' : '11:30' },
})
const booking = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => {
const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
const rental = day({
stops,
legs: [leg(), leg(), leg()],
schedule: {
entries: [
{ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 },
{ arrival: '09:20', departure: '09:50', anchored: false, dayOffset: 0 },
{ arrival: '10:50', departure: '11:20', anchored: false, dayOffset: 0 },
{ arrival: '11:30', departure: '11:30', anchored: true, dayOffset: 0 },
],
warnings: [],
},
})
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [rental], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
expect(screen.getByText('Pick-up 09:00')).toBeInTheDocument()
expect(screen.getByText('Return 11:30')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(screen.queryByText('3')).not.toBeInTheDocument()
expect(screen.getByText('2 stops')).toBeInTheDocument()
fireEvent.click(screen.getByText('Sixt Airport'))
expect(onOpenBooking).toHaveBeenCalledWith(90)
// Hamburg to Lübeck and Lübeck to the desk leave stored stops; the road out of the
// pick-up desk does not.
expect(screen.getAllByLabelText('Other ways')).toHaveLength(2)
})
it('FE-ROADTRIP-SIDEBAR-053: a table pinned to a stop, or booked at its place on its day, hangs under the stop as a chip that opens it; one for no stop is listed under the day', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
booking({ id: 12, title: 'Café Niederegger', type: 'restaurant', reservation_time: '2026-10-05T11:30', place_id: 20 }),
booking({ id: 13, title: 'Elbphilharmonie', type: 'event', reservation_time: '2026-10-05T20:00', place_id: 999 }),
booking({ id: 14, title: 'Tomorrow', type: 'event', day_id: 2, place_id: 10 }),
booking({ id: 15, title: 'Hotel Hafen', type: 'hotel', place_id: 10 }),
]
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
expect(screen.getByText('19:30')).toBeInTheDocument()
expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
expect(screen.getByText('Also booked this day')).toBeInTheDocument()
expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument()
// Another day's booking is not this day's, and the night is the stay, not a chip.
expect(screen.queryByText('Tomorrow')).not.toBeInTheDocument()
expect(screen.queryByText('Hotel Hafen')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(onOpenBooking).toHaveBeenCalledWith(11)
// The chips are the stop's, not stops: the count stays at two.
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
}) })
@@ -1,6 +1,6 @@
import ChargingInfo from './ChargingInfo' import ChargingInfo from './ChargingInfo'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import React, { useState } from 'react' import React, { useMemo, useState } from 'react'
import { import {
CarFront, Footprints, Bike, Zap, AlertTriangle, CarFront, Footprints, Bike, Zap, AlertTriangle,
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone,
@@ -32,6 +32,8 @@ import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop' import AutomaticDayStop from './AutomaticDayStop'
import { carrierIcon, rideText, terminalLine } from './carrierRide' import { carrierIcon, rideText, terminalLine } from './carrierRide'
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
import type { Reservation } from '../../types'
import type { StayDraft } from './RoadtripStayModal' import type { StayDraft } from './RoadtripStayModal'
import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading' import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading'
@@ -104,10 +106,15 @@ interface RoadtripSidebarProps {
/** Absent leaves every card open and its header unclickable. */ /** Absent leaves every card open and its header unclickable. */
onToggleDay?: (dayId: number) => void onToggleDay?: (dayId: number) => void
/** /**
* Opens the booking a terminal or a ride stands for (#2428). Absent leaves the * The trip's bookings, for the rail to hang under the stops they are for (#2428).
* terminal rows and the ride pills as plain text. * Absent draws the chain without them.
*/ */
onOpenCarrier?: (reservationId: number) => void reservations?: Reservation[]
/**
* Opens a booking: the ride a terminal stands for, the hire car a desk belongs to,
* the table or the tickets under a stop. Absent leaves them all as plain text.
*/
onOpenBooking?: (reservationId: number) => void
} }
const MODE_ICON: Record<string, LucideIcon> = { const MODE_ICON: Record<string, LucideIcon> = {
@@ -823,48 +830,135 @@ function TerminalStop({ stop, entry, late, selected, continues, starts, onOpen }
} }
/** /**
* The ride between two terminals, where a drive band would be. * One end of a ride inside the ride's block: the terminal's name and code, the
* * timetable's clock under it, and on the right the time the chain has the traveller
* The booking and its minutes, no distance and no other ways: a flight has none. It opens * there. The same three things the lone terminal row shows, without a row of its own.
* 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 }: { function RideEnd({ stop, entry, late }: {
carrier: NonNullable<RoadtripStop['carrier']> stop: RoadtripStop
entry: ScheduleEntry | undefined
late: ScheduleWarning[]
}): React.ReactElement {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const line = terminalLine(stop.carrier!, t, is12h)
return (
<span className="flex min-w-0 items-start gap-2">
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
<span className="min-w-0 break-words">{stop.name}</span>
{stop.carrier!.code ? (
<span className="shrink-0 font-geist font-medium text-content-faint" style={{ fontSize: FS.meta }}>{stop.carrier!.code}</span>
) : null}
</span>
<span className="flex flex-wrap items-center gap-1">
{line ? <span className="text-content-muted" style={{ fontSize: FS.meta }}>{line}</span> : null}
{late.map(w => <LateBadge key={w.code} late={w} />)}
</span>
</span>
{entry?.arrival ? <Arrival entry={entry} /> : null}
</span>
)
}
/**
* A ride that leaves and lands on the same day, as one thing in the chain: the booking
* at the top, the terminal it leaves from, the minutes in the air, the terminal it lands
* at. One block on one disc rather than three rows, because it IS one thing, a flight,
* and three rows read as three places the day went to. The whole block opens the
* booking; the road resumes below it as usual.
*/
function RideBlock({ departure, arrival, entries, late, seg, continues, starts, onOpen }: {
departure: RoadtripStop
arrival: RoadtripStop
entries: [ScheduleEntry | undefined, ScheduleEntry | undefined]
late: [ScheduleWarning[], ScheduleWarning[]]
seg: RouteSegment | undefined seg: RouteSegment | undefined
continues: boolean
starts?: boolean
onOpen?: () => void onOpen?: () => void
}): React.ReactElement { }): React.ReactElement {
const { t } = useTranslation() const { t } = useTranslation()
const carrier = departure.carrier!
const Icon = carrierIcon(carrier.type) const Icon = carrierIcon(carrier.type)
const band = ( return (
<> <button
<Icon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden /> type="button"
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}> onClick={onOpen}
{rideText(carrier, seg)} disabled={!onOpen}
aria-label={onOpen ? t('roadtrip.ride.open') : undefined}
className="group grid w-full rounded-lg text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent disabled:cursor-default"
style={RAIL_GRID}
>
<span className="flex flex-col items-center">
{starts ? null : <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden />}
<span className={`${DISC} my-1 bg-surface-tertiary text-content-secondary`}>
<Icon size={13} strokeWidth={2} aria-hidden />
</span>
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
</span> </span>
</> <span className="my-0.5 flex min-w-0 flex-col gap-1 rounded-lg border border-edge-faint bg-surface-tertiary px-2 pb-1.5 pt-1 transition-colors group-hover:bg-surface-selected">
<span className="flex min-w-0 items-center gap-1.5 text-content-muted">
<Icon size={11} strokeWidth={1.8} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate font-geist font-medium uppercase tracking-[0.09em]" style={{ fontSize: FS.label }}>
{rideText(carrier, seg)}
</span>
</span>
<RideEnd stop={departure} entry={entries[0]} late={late[0]} />
<RideEnd stop={arrival} entry={entries[1]} late={late[1]} />
</span>
</button>
) )
}
/**
* The bookings a stop carries, under its row: the table, the tickets, the tour, each a
* chip with the booking panel's icon, its name and the clock it starts at. Chips because
* a booking is a fact about the stop, not a stop of its own: the count, the numbering
* and the drive are none the different for it. Each opens its booking.
*
* Under the stop's button rather than inside it, because a button cannot hold buttons.
* The rail keeps its line through the row so the chain reads unbroken.
*/
function BookingChips({ bookings, continues, onOpen }: {
bookings: Reservation[]
continues: boolean
onOpen?: (reservationId: number) => void
}): React.ReactElement {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
return ( return (
<div className="grid" style={RAIL_GRID}> <div className="grid" style={RAIL_GRID}>
<span className="relative z-[1] flex flex-col items-center" aria-hidden> <span className="flex flex-col items-center" aria-hidden>
<span className="flex-1" style={RAIL_DASH} /> {continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" /> : null}
</span> </span>
<div className="min-w-0"> <div className="flex min-w-0 flex-wrap gap-1 px-1.5 pb-1.5">
{onOpen ? ( {bookings.map(r => {
<Tooltip label={t('roadtrip.ride.open')}> const Icon = bookingIcon(r.type)
<button const clock = bookingClock(r)
type="button" const body = (
onClick={onOpen} <>
className="my-1.5 flex w-full items-center gap-1.5 rounded-lg bg-surface-tertiary py-1 pe-2 ps-2 text-start text-content-muted transition-colors hover:bg-surface-selected focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent" <Icon size={10} strokeWidth={2} className="shrink-0" aria-hidden />
> <span className="min-w-0 truncate">{r.title}</span>
{band} {clock ? <span className="shrink-0 font-medium tabular-nums text-content-faint">{formatClockTime(clock, is12h)}</span> : null}
</button> </>
</Tooltip> )
) : ( const chip = 'inline-flex h-[20px] max-w-full items-center gap-1 rounded-md border border-edge bg-surface-card px-1.5 text-content-secondary'
<div className="my-1.5 flex items-center gap-1.5 rounded-lg bg-surface-tertiary py-1 pe-2 ps-2 text-content-muted"> return onOpen ? (
{band} <Tooltip key={r.id} label={t('roadtrip.ride.open')}>
</div> <button
)} type="button"
onClick={() => onOpen(r.id)}
className={`${chip} transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
style={{ fontSize: FS.meta }}
>
{body}
</button>
</Tooltip>
) : (
<span key={r.id} className={chip} style={{ fontSize: FS.meta }}>{body}</span>
)
})}
</div> </div>
</div> </div>
) )
@@ -1411,7 +1505,7 @@ function SpillBlock({ spill, children }: {
* move, a stay edit or a refuel offer still names the day the server knows it by. See * move, a stay edit or a refuel offer still names the day the server knows it by. See
* `nightSpill.ts`. * `nightSpill.ts`.
*/ */
function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: { function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
onFocusPoint?: RoadtripSidebarProps['onFocusPoint'] onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
day: RoadtripDay day: RoadtripDay
/** Folded down to the header, and off the map with it. */ /** Folded down to the header, and off the map with it. */
@@ -1425,7 +1519,8 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
drag: DragState drag: DragState
onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives'] onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives']
openAlternatives?: RoadtripSidebarProps['openAlternatives'] openAlternatives?: RoadtripSidebarProps['openAlternatives']
onOpenCarrier?: RoadtripSidebarProps['onOpenCarrier'] onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
reservations?: Reservation[]
onEditStay?: RoadtripSidebarProps['onEditStay'] onEditStay?: RoadtripSidebarProps['onEditStay']
onSetStopKind?: RoadtripSidebarProps['onSetStopKind'] onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
onSetStopFill?: RoadtripSidebarProps['onSetStopFill'] onSetStopFill?: RoadtripSidebarProps['onSetStopFill']
@@ -1442,6 +1537,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
// Which disc the picker hangs under, and for which stop. One at a time: two open // Which disc the picker hangs under, and for which stop. One at a time: two open
// popovers over the same rail is two answers to one question. // popovers over the same rail is two answers to one question.
const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const bookings = useMemo(() => dayBookings(day, reservations ?? []), [day, reservations])
const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const { t, language } = useTranslation() const { t, language } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
@@ -1464,7 +1560,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
* `stop.ownerIndex` instead, because a stop driven onto this date through the night is * `stop.ownerIndex` instead, because a stop driven onto this date through the night is
* still stored on the day it set off from (`nightSpill.ts`). * still stored on the day it set off from (`nightSpill.ts`).
*/ */
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement => { const latenessAt = (i: number): ScheduleWarning[] =>
day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement | null => {
if (stop.automaticNight) return ( if (stop.automaticNight) return (
<li key={stop.assignmentId}> <li key={stop.assignmentId}>
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} /> <AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
@@ -1482,31 +1580,50 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
// Every finding at this index is read on its own. Taking the first match let an // 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 // 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. // 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')) const lateness = latenessAt(i)
// A terminal: not draggable, not numbered, not a place. Between a departure and its // A terminal or a hire car's desk: not draggable, not numbered, not a place. A ride
// arrival the leg is the ride, drawn as the booking; behind an arrival the road goes // that lands on the day it left is one block from its departure to its arrival, and
// on as usual, without other ways to drive it (`legReroutable`). // the arrival index draws nothing of its own. A lone terminal (a ride landing
// tomorrow, a desk) is a row. Behind either the road goes on as usual, without other
// ways to drive it (`legReroutable`).
if (stop.carrier) { if (stop.carrier) {
const prev = day.stops[i - 1]
const next = day.stops[i + 1] const next = day.stops[i + 1]
const ride = stop.carrier.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId const sameRide = (other: RoadtripStop | undefined): boolean => other?.carrier?.reservationId === stop.carrier!.reservationId
const open = onOpenCarrier ? () => onOpenCarrier(stop.carrier!.reservationId) : undefined if (stop.carrier.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return null
const ride = stop.carrier.role === 'departure' && next?.carrier?.role === 'arrival' && sameRide(next)
const open = onOpenBooking ? () => onOpenBooking(stop.carrier!.reservationId) : undefined
// The index the road leaves from: the arrival's when the block holds both ends.
const tail = ride ? i + 1 : i
const after = day.stops[tail + 1]
return ( return (
<li key={stop.assignmentId}> <li key={stop.assignmentId}>
<TerminalStop
stop={stop}
entry={day.schedule.entries[i]}
late={lateness}
selected={false}
continues={i < last}
starts={i === 0}
onOpen={open}
/>
{ride ? ( {ride ? (
<RideBand carrier={stop.carrier} seg={day.legs[i]} onOpen={open} /> <RideBlock
) : i < last && (!next?.automaticNight || day.legs[i]?.distance !== 0) ? ( departure={stop}
<DriveBand leg={day.legs[i]} /> arrival={next!}
entries={[day.schedule.entries[i], day.schedule.entries[i + 1]]}
late={[lateness, latenessAt(i + 1)]}
seg={day.legs[i]}
continues={tail < last}
starts={i === 0}
onOpen={open}
/>
) : (
<TerminalStop
stop={stop}
entry={day.schedule.entries[i]}
late={lateness}
selected={false}
continues={i < last}
starts={i === 0}
onOpen={open}
/>
)}
{tail < last && (!after?.automaticNight || day.legs[tail]?.distance !== 0) ? (
<DriveBand leg={day.legs[tail]} />
) : null} ) : null}
{i < last && !ride ? (day.legVias[i] ?? []).map((via, vi) => ( {tail < last ? (day.legVias[tail] ?? []).map((via, vi) => (
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} /> <RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
)) : null} )) : null}
</li> </li>
@@ -1578,6 +1695,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
/> />
)} )}
{bookings.atStop.has(i) ? (
<BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} onOpen={onOpenBooking} />
) : null}
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? ( {i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
<DriveBand <DriveBand
leg={day.legs[i]} leg={day.legs[i]}
@@ -1789,6 +1909,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
</React.Fragment> </React.Fragment>
) )
))} ))}
{/* Booked on this day, for no stop the day drives to: the rail has nowhere
better to put them and leaving them out would make the mode look like it
lost them. */}
{bookings.loose.length ? (
<li className="mt-1.5">
<div className="grid" style={RAIL_GRID}>
<span aria-hidden />
<span className={`${STAT_LABEL} px-1.5`} style={{ fontSize: FS.micro }}>{t('roadtrip.bookings.loose')}</span>
</div>
<BookingChips bookings={bookings.loose} continues={false} onOpen={onOpenBooking} />
</li>
) : null}
</ol> </ol>
{picking ? ( {picking ? (
<StopKindPicker <StopKindPicker
@@ -1889,7 +2021,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
* them is a pill just for being a number. * them is a pill just for being a number.
*/ */
export default function RoadtripSidebar({ export default function RoadtripSidebar({
routes, selectedAssignmentId, onSelectStop, onOpenCarrier, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay, routes, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel, onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
collapsedDayIds, onToggleDay, onFocusPoint, collapsedDayIds, onToggleDay, onFocusPoint,
}: RoadtripSidebarProps): React.ReactElement { }: RoadtripSidebarProps): React.ReactElement {
@@ -1947,7 +2079,8 @@ export default function RoadtripSidebar({
drag={drag} drag={drag}
onAskAlternatives={onAskAlternatives} onAskAlternatives={onAskAlternatives}
openAlternatives={openAlternatives} openAlternatives={openAlternatives}
onOpenCarrier={onOpenCarrier} onOpenBooking={onOpenBooking}
reservations={reservations}
onEditStay={onEditStay} onEditStay={onEditStay}
onSetStopKind={onSetStopKind} onSetStopKind={onSetStopKind}
onSetStopFill={onSetStopFill} onSetStopFill={onSetStopFill}
@@ -1,11 +1,12 @@
import { Bus, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react' import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
import { formatDurationShort } from './roadtripModel' import { formatDurationShort } from './roadtripModel'
import { formatClockTime } from '../../utils/formatters' import { formatClockTime } from '../../utils/formatters'
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip' import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../types' import type { TranslationFn } from '../../types'
/** /**
* How a ride and its two terminals read on the rail and in the phone chain (#2428). * How a ride and its two terminals, and a hire car's two desks, 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 * 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. * and the same three sentences each, and the duplication budget does not stretch to that.
@@ -18,6 +19,7 @@ const CARRIER_ICON: Record<string, LucideIcon> = {
ferry: Sailboat, ferry: Sailboat,
cruise: Ship, cruise: Ship,
bus: Bus, bus: Bus,
car: Car,
} }
export function carrierIcon(type: string): LucideIcon { export function carrierIcon(type: string): LucideIcon {
@@ -31,7 +33,7 @@ export function carrierIcon(type: string): LucideIcon {
export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h: boolean): string | null { export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h: boolean): string | null {
if (!carrier.at) return null if (!carrier.at) return null
const time = formatClockTime(carrier.at, is12h) const time = formatClockTime(carrier.at, is12h)
return carrier.role === 'departure' ? t('roadtrip.ride.departure', { time }) : t('roadtrip.ride.arrival', { time }) return t(`roadtrip.ride.${carrier.role}`, { time })
} }
/** /**
@@ -536,23 +536,50 @@ describe('roadtripRows with a ride (#2428)', () => {
legs: [seg(0), ride, seg(2)], legs: [seg(0), ride, seg(2)],
}) })
it('FE-RTROW-045: the terminals are stop rows without a number, and the numbering skips them', () => { it('FE-RTROW-045: a same-day ride is one row holding both terminals, unnumbered, and the numbering skips them', () => {
const rows = stopRows(roadtripRows(flightDay())) const rows = roadtripRows(flightDay())
expect(rows.map(r => [r.stop.name, r.number])).toEqual([ expect(stopRows(rows).map(r => [r.stop.name, r.number])).toEqual([
['Bremen', 1], ['Bremen', 1],
['Munich Airport', null],
['Hamburg Airport', null],
['Hotel', 2], ['Hotel', 2],
]) ])
const rideRow = rows.find(r => r.kind === 'ride')
expect(rideRow?.kind === 'ride' && [rideRow.departure.stop.name, rideRow.departure.number, rideRow.arrival.stop.name, rideRow.arrival.number])
.toEqual(['Munich Airport', null, 'Hamburg Airport', null])
expect(destinationCount(flightDay())).toBe(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', () => { it('FE-RTROW-046: the ride row stands where the departure stood and carries the booking; the road out of the arrival follows it as a leg', () => {
const rows = roadtripRows(flightDay()) const rows = roadtripRows(flightDay())
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'ride', 'stop', 'leg', 'stop']) expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'ride', 'leg', 'stop'])
const rideRow = rows[3] const rideRow = rows[2]
expect(rideRow.kind === 'ride' && rideRow.carrier.title).toBe('LH 2020') expect(rideRow.kind === 'ride' && rideRow.carrier.title).toBe('LH 2020')
expect(rideRow.kind === 'ride' && rideRow.seg).toBe(ride) expect(rideRow.kind === 'ride' && rideRow.seg).toBe(ride)
expect(rideRow.kind === 'ride' && rideRow.arrival.time).toBe(rideRow.kind === 'ride' ? rideRow.arrival.entry?.arrival ?? null : null)
// The leg after the ride is the arrival's, at the arrival's index.
expect(rows[3]).toMatchObject({ kind: 'leg', index: 2 })
})
it('FE-RTROW-049: a ride landing tomorrow leaves its departure as a lone stop row, and the arrival opens the next day as one', () => {
const rows = roadtripRows(day([stop('Bremen'), terminal('Munich Airport', 'departure')], { legs: [seg(0)] }))
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
const landing = roadtripRows(day([terminal('Hamburg Airport', 'arrival', '07:00'), stop('Hotel')], { legs: [seg(0)] }))
expect(landing.map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
expect(stopRows(landing).map(r => [r.stop.name, r.number])).toEqual([['Hamburg Airport', null], ['Hotel', 1]])
})
it('FE-RTROW-050: a hire car\'s desks are unnumbered stop rows the road runs through', () => {
const desk = (name: string, role: 'pickup' | 'return') =>
stop(name, {
assignmentId: role === 'pickup' ? -3000000018 : -3000000019,
placeId: -9,
carrier: { reservationId: 9, type: 'car', role, title: 'Sixt', code: null, at: '09:00' },
})
const rows = roadtripRows(day([desk('Sixt Hbf', 'pickup'), stop('Bremen'), desk('Sixt Airport', 'return')], { legs: [seg(0), seg(1)] }))
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop'])
expect(stopRows(rows).map(r => [r.stop.name, r.number])).toEqual([['Sixt Hbf', null], ['Bremen', 1], ['Sixt Airport', null]])
const d = day([desk('Sixt Hbf', 'pickup'), stop('Bremen'), desk('Sixt Airport', 'return')], { legs: [seg(0), seg(1)] })
expect(legReroutable(d, 0)).toBe(false)
expect(legReroutable(d, 1)).toBe(true)
}) })
it('FE-RTROW-047: the road into a departure terminal can be offered other ways, nothing leaving a terminal can', () => { it('FE-RTROW-047: the road into a departure terminal can be offered other ways, nothing leaving a terminal can', () => {
@@ -22,10 +22,11 @@ export type RoadtripRow =
| StopRow | StopRow
| { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null } | { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null }
/** /**
* The ride between a departure terminal and its arrival (#2428): the booking, not a * A ride that leaves and lands on the day (#2428), as one row: the booking with both
* drive. Drawn in place of the leg row, with the booking's minutes and no distance. * its terminals inside it, in place of two stop rows and the leg between them. The
* booking's minutes, no distance, and the road goes on from `arrival`.
*/ */
| { kind: 'ride'; index: number; carrier: CarrierTerminal; seg: RouteSegment | undefined } | { kind: 'ride'; index: number; carrier: CarrierTerminal; seg: RouteSegment | undefined; departure: StopRow; arrival: StopRow }
| { kind: 'dry'; legIndex: number; intoLegKm: number; sinceKm: number } | { kind: 'dry'; legIndex: number; intoLegKm: number; sinceKm: number }
| { kind: 'auto'; phase: 'end' | 'resume'; time: string | null } | { kind: 'auto'; phase: 'end' | 'resume'; time: string | null }
@@ -153,6 +154,13 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
}) })
} }
const next = day.stops[i + 1]
const prev = day.stops[i - 1]
const sameRide = (other: RoadtripStop | undefined): boolean =>
!!stop.carrier && other?.carrier?.reservationId === stop.carrier.reservationId
// The arrival of a same-day ride is inside the ride row its departure made.
if (stop.carrier?.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return
const automatic = !!stop.automaticNight const automatic = !!stop.automaticNight
if (automatic) { if (automatic) {
rows.push({ rows.push({
@@ -160,28 +168,44 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
phase: stop.automaticNight?.phase === 'start' ? 'resume' : 'end', phase: stop.automaticNight?.phase === 'start' ? 'resume' : 'end',
time: day.schedule.entries[i]?.arrival ?? null, time: day.schedule.entries[i]?.arrival ?? null,
}) })
} else if (stop.carrier?.role === 'departure' && next?.carrier?.role === 'arrival' && sameRide(next)) {
rows.push({
kind: 'ride',
index: i,
carrier: stop.carrier,
seg: day.legs[i],
departure: stopRow(stop, i, null, day.schedule, day.driveWarnings),
arrival: stopRow(next!, i + 1, null, day.schedule, day.driveWarnings),
})
// The road out of the arrival belongs to this row too; the pass below only
// looks at the index it is on.
pushLeg(i + 1)
return
} else { } else {
// A terminal is where the drive stops or resumes, not a place the trip is for: it // A terminal or a hire car's desk is where the drive stops or resumes, not a
// carries no number, the way a service stop carries none. // place the trip is for: it carries no number, the way a service stop carries none.
const unnumbered = isServiceStopType(stop.stopType) || !!stop.carrier const unnumbered = isServiceStopType(stop.stopType) || !!stop.carrier
if (!unnumbered) number += 1 if (!unnumbered) number += 1
rows.push(stopRow(stop, i, unnumbered ? null : number, day.schedule, day.driveWarnings)) 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 pushLeg(i)
// between two stops, so they are emitted here rather than in their own pass. })
// The leg AFTER stop `i`, plus the dry point that falls on it. Both belong between
// two stops, so they are emitted with the stop before them rather than in a pass of
// their own.
function pushLeg(i: number): void {
const stop = day.stops[i]!
const seg = day.legs[i] const seg = day.legs[i]
const next = day.stops[i + 1] if (i < day.stops.length - 1 && !isStationary(seg)) {
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 }) 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) const dry = (day.dryPoints ?? []).find(p => p.legIndex === i)
if (dry) { if (dry) {
rows.push({ kind: 'dry', legIndex: i, intoLegKm: dry.intoLegKm, sinceKm: dry.sinceKm }) rows.push({ kind: 'dry', legIndex: i, intoLegKm: dry.intoLegKm, sinceKm: dry.sinceKm })
} }
} }
}) }
return rows return rows
} }
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { Car, Ticket, Utensils } from 'lucide-react'
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
import type { Reservation } from '../../types'
const booking = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation
const day = {
dayId: 1,
stops: [
{ assignmentId: -3000000140, placeId: -70, carrier: { reservationId: 70, type: 'flight', role: 'arrival' as const, title: 'LH', code: null, at: null } },
{ assignmentId: 11, placeId: 110 },
{ assignmentId: -2000000001, placeId: 110, automaticNight: { phase: 'end' as const, fromDayNumber: 1 } as never },
{ assignmentId: 12, placeId: 120 },
],
}
describe('dayBookings (#2428)', () => {
it('FE-STOPBOOKINGS-001: a booking hangs under the stop it is pinned to, or under the stop at its place on its day, in clock order', () => {
const { atStop, loose } = dayBookings(day, [
booking({ id: 3, title: 'Late', reservation_time: '2026-10-05T20:00', assignment_id: 11 }),
booking({ id: 2, title: 'Early', reservation_time: '2026-10-05T11:00', place_id: 110 }),
booking({ id: 4, title: 'Untimed', place_id: 110 }),
booking({ id: 5, title: 'Ticket', type: 'event', place_id: 120 }),
])
expect([...atStop.keys()]).toEqual([1, 3])
expect(atStop.get(1)!.map(r => r.title)).toEqual(['Early', 'Late', 'Untimed'])
expect(atStop.get(3)!.map(r => r.title)).toEqual(['Ticket'])
expect(loose).toEqual([])
})
it('FE-STOPBOOKINGS-002: a terminal and an automatic night hold nothing, even at the same place; a pinned booking needs no day', () => {
// The night marker shares the hotel's place id; the booking still lands on the stop.
const { atStop } = dayBookings(day, [booking({ id: 6, place_id: 110, day_id: 1 })])
expect([...atStop.keys()]).toEqual([1])
// Pinned to the assignment: on the stop whatever day the row names.
const pinned = dayBookings(day, [booking({ id: 7, assignment_id: 12, day_id: 9 })])
expect([...pinned.atStop.keys()]).toEqual([3])
// A booking at the flight's negative place id is nobody's.
expect(dayBookings(day, [booking({ id: 8, place_id: -70 })]).loose).toHaveLength(1)
})
it('FE-STOPBOOKINGS-003: the night, the hire car and the ride are drawn elsewhere; another day\'s booking is not this day\'s; a span ends on this day', () => {
const { atStop, loose } = dayBookings(day, [
booking({ id: 1, type: 'hotel', place_id: 110 }),
booking({ id: 2, type: 'car', place_id: 110 }),
booking({ id: 3, type: 'flight', place_id: 110 }),
booking({ id: 4, type: 'train', place_id: 110 }),
booking({ id: 5, title: 'Tomorrow', day_id: 2, place_id: 110 }),
booking({ id: 6, title: 'Tour ends here', type: 'tour', day_id: 0, end_day_id: 1 }),
])
expect(atStop.size).toBe(0)
expect(loose.map(r => r.title)).toEqual(['Tour ends here'])
expect(dayBookings(day, [])).toEqual({ atStop: new Map(), loose: [] })
})
it('FE-STOPBOOKINGS-004: the chip wears the booking panel\'s icon and the clock the booking starts at', () => {
expect(bookingIcon('restaurant')).toBe(Utensils)
expect(bookingIcon('car')).toBe(Car)
expect(bookingIcon('nonsense')).toBe(Ticket)
expect(bookingClock({ reservation_time: '2026-10-05T19:30' })).toBe('19:30')
expect(bookingClock({ reservation_time: null })).toBeNull()
})
})
@@ -0,0 +1,82 @@
import { Ticket, type LucideIcon } from 'lucide-react'
import { isCarrierType, isRentalType } from '@trek/shared/roadtrip'
import { RES_ICONS } from '../Planner/DayPlanSidebar.constants'
import { splitReservationDateTime } from '../../utils/formatters'
import type { Reservation } from '../../types'
import type { RoadtripStop } from '@trek/shared/roadtrip'
/**
* The bookings a day carries besides its nights, its rides and its hire car: the table,
* the tickets, the tour. On the road trip each hangs under the stop it is for, and one
* that is for no stop on the day is listed under the day, so nothing booked goes unseen
* in this mode.
*
* A booking is a stop's when it is pinned to the stop's assignment, or, failing that,
* when it is for the stop's place on the stop's day: a table booked at the Bullerei on
* the day the Bullerei is driven to is the Bullerei's, pinned or not. The night, the
* ride and the hire car are left out because the rail already draws them as the stay,
* the terminals and the desks.
*/
export interface DayBookings {
/** By index into `day.stops`, earliest first. Indexes without a booking are absent. */
atStop: Map<number, Reservation[]>
/** On the day, at no stop of it. */
loose: Reservation[]
}
const EMPTY: DayBookings = { atStop: new Map(), loose: [] }
function drawnElsewhere(type: string): boolean {
return type === 'hotel' || isRentalType(type) || isCarrierType(type)
}
/** Earliest first, untimed last, id as the tiebreaker: the day plan's own order. */
function byClock(a: Reservation, b: Reservation): number {
const at = a.reservation_time || ''
const bt = b.reservation_time || ''
if (at !== bt) {
if (!at) return 1
if (!bt) return -1
return at < bt ? -1 : 1
}
return a.id - b.id
}
export function dayBookings(
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight'>[] },
reservations: readonly Reservation[],
): DayBookings {
if (!reservations.length) return EMPTY
const atStop = new Map<number, Reservation[]>()
const loose: Reservation[] = []
for (const r of reservations) {
if (drawnElsewhere(r.type)) continue
const stops = day.stops
// Only a real stop can hold a booking: a terminal has a negative place and an
// automatic night has no assignment anybody pinned to.
const real = (i: number): boolean => !stops[i]!.carrier && !stops[i]!.automaticNight
let index = r.assignment_id ? stops.findIndex((s, i) => real(i) && s.assignmentId === r.assignment_id) : -1
const onDay = r.day_id === day.dayId || (r.end_day_id != null && r.end_day_id === day.dayId)
if (index < 0 && onDay && r.place_id) index = stops.findIndex((s, i) => real(i) && s.placeId === r.place_id)
if (index >= 0) {
const list = atStop.get(index) ?? []
list.push(r)
atStop.set(index, list)
} else if (onDay) {
loose.push(r)
}
}
for (const list of atStop.values()) list.sort(byClock)
loose.sort(byClock)
return atStop.size || loose.length ? { atStop, loose } : EMPTY
}
/** The booking panel's icon for the type, so a table reads as a table here too. */
export function bookingIcon(type: string): LucideIcon {
return (RES_ICONS as Record<string, LucideIcon>)[type] ?? Ticket
}
/** The clock the booking starts at, or null when it names none. */
export function bookingClock(r: Pick<Reservation, 'reservation_time'>): string | null {
return splitReservationDateTime(r.reservation_time).time || null
}
@@ -931,14 +931,40 @@ describe('a booking the traveller rides (#2428)', () => {
expect(result.current.days[1].arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600, distance: 0 }) 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 () => { it('FE-ROADTRIP-ROUTES-042: a booking without located terminals, or a taxi, changes nothing', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points)) calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }] const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
const unlocated = flight({ endpoints: [] }) const unlocated = flight({ endpoints: [] })
const hireCar = flight({ id: 71, type: 'car' }) const taxi = flight({ id: 71, type: 'taxi' })
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [unlocated, hireCar])) const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [unlocated, taxi]))
await waitFor(() => expect(result.current.loading).toBe(false)) await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[0].stops.every(s => !s.carrier)).toBe(true) expect(result.current.days[0].stops.every(s => !s.carrier)).toBe(true)
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1) expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
}) })
it('FE-ROADTRIP-ROUTES-043: a hire car puts its desks on the road, one run through both, and no ride', async () => {
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
const stops: StopSpec[] = [{ id: 1, at: LUENEBURG, time: '10:00', dwell: 0 }, { id: 2, at: BERLIN, time: '14:00', dwell: 0 }]
const hireCar = flight({
id: 71,
type: 'car',
title: 'Sixt',
reservation_time: '09:00',
reservation_end_time: '18:00',
endpoints: [
{ role: 'from', sequence: 0, name: 'Sixt Hamburg', code: null, lat: HAMBURG[0], lng: HAMBURG[1], timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'Sixt Berlin', code: null, lat: BERLIN[0] + 0.01, lng: BERLIN[1], timezone: null, local_time: null, local_date: null },
],
})
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [hireCar]))
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(['pickup', 'Stop 1', 'Stop 2', 'return'])
// One road, the whole day, desk to desk.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
expect(d.legs.map(l => l?.mode)).toEqual(['driving', 'driving', 'driving'])
expect(d.schedule.entries[0]).toMatchObject({ arrival: '09:00', departure: '09:00' })
expect(result.current.totalStops).toBe(2)
expect(result.current.lines).toHaveLength(3)
})
}) })
@@ -4,14 +4,15 @@ import MDancingTrek from '../../../components/MDancingTrek'
import MIconBtn from '../../../components/MIconBtn' import MIconBtn from '../../../components/MIconBtn'
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel' import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
import { carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide' import { carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
import { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings'
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds' import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
import { formatDistance } from '../../../../utils/units' import { formatDistance } from '../../../../utils/units'
import { formatClockTime } from '../../../../utils/formatters' import { formatClockTime } from '../../../../utils/formatters'
import type { StopRow } from '../../../../components/Roadtrip/roadtripRowModel' import type { RoadtripRow, StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch' import type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch'
import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion' import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
import type { CarrierTerminal, DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip' import type { DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../../../types' import type { Reservation, TranslationFn } from '../../../../types'
/** /**
* The four row types of the mobile drive chain, plus the two bands that interrupt it. * The four row types of the mobile drive chain, plus the two bands that interrupt it.
@@ -186,36 +187,101 @@ const LEG_ICONS: Record<string, typeof CarFront> = {
cycling: Bike, cycling: Bike,
} }
/** One end of a ride inside its block: name, code, the timetable line, the clock. */
function RideEnd({ row, chrome }: { row: StopRow; chrome: RowChrome }) {
const carrier = row.stop.carrier!
const line = terminalLine(carrier, chrome.t, chrome.is12h)
return (
<span className="flex items-center gap-[8px]">
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-[6px]">
<span className="line-clamp-2 text-[0.875rem] font-semibold leading-[1.25] text-m-ink">{row.stop.name}</span>
{carrier.code && <span className="flex-none font-geist text-[0.65625rem] font-medium text-m-faint">{carrier.code}</span>}
</span>
{line && <span className="mt-[2px] block font-geist text-[0.65625rem] font-medium text-m-muted">{line}</span>}
</span>
{row.time && (
<span dir="ltr" className={`whitespace-nowrap text-[0.8125rem] tabular-nums ${row.pinned ? 'font-semibold text-m-ink' : 'font-medium text-m-faint'}`}>
{formatClockTime(row.time, chrome.is12h)}
</span>
)}
</span>
)
}
/** /**
* The ride between two terminals, where a leg pill would be (#2428): the booking and its * A ride that leaves and lands on the day, as one block in the chain (#2428): the
* minutes, no distance and no other ways of driving it. The pill is the tap target and * booking and its minutes at the top, the terminal it leaves from, the terminal it lands
* opens the booking, the same sheet the map's endpoint badge opens. * at. One block on one disc, because it is one thing, and three rows read as three
* places the day went to. The block is the tap target and opens the booking, the same
* sheet the map's endpoint badge opens.
*/ */
export function RtRideRow({ carrier, seg, onOpen }: { export function RtRideRow({ row, chrome, onOpen }: {
carrier: CarrierTerminal row: Extract<RoadtripRow, { kind: 'ride' }>
seg: RouteSegment | undefined chrome: RowChrome
onOpen?: () => void onOpen?: () => void
}) { }) {
const Icon = carrierIcon(carrier.type) const Icon = carrierIcon(row.carrier.type)
const body = ( const body = (
<> <>
<Icon size={14} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" /> <span className="flex items-center gap-[6px] text-m-muted">
<span className="truncate text-[0.75rem] font-semibold tabular-nums text-m-ink">{rideText(carrier, seg)}</span> <Icon size={12} strokeWidth={2} className="flex-none" aria-hidden="true" />
<span className="truncate font-geist text-[0.65625rem] font-semibold uppercase tracking-[0.06em]">{rideText(row.carrier, row.seg)}</span>
</span>
<RideEnd row={row.departure} chrome={chrome} />
<RideEnd row={row.arrival} chrome={chrome} />
</> </>
) )
const cls = 'my-1 flex min-w-0 flex-col gap-[7px] rounded-[16px] bg-[color:var(--m-ic)] px-[12px] py-[9px] text-start'
return ( return (
<div className="grid items-center gap-x-[10px]" style={{ gridTemplateColumns: '34px 1fr auto' }}> <div className="grid items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr' }}>
<span className="flex min-h-[40px] flex-col items-center" aria-hidden="true"> <span className="flex justify-center">
<span className="w-[2px] flex-1" style={{ backgroundImage: 'repeating-linear-gradient(var(--m-conn) 0 4px, transparent 4px 8px)' }} /> <span className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
<Icon size={15} strokeWidth={2.1} aria-hidden="true" />
</span>
</span> </span>
{onOpen ? ( {onOpen ? (
<button type="button" onClick={onOpen} className="my-1.5 flex min-w-0 items-center gap-[7px] rounded-[13px] bg-[color:var(--m-ic)] px-[11px] py-[7px] text-start"> <button type="button" onClick={onOpen} className={cls}>{body}</button>
{body}
</button>
) : ( ) : (
<span className="my-1.5 flex min-w-0 items-center gap-[7px] rounded-[13px] bg-[color:var(--m-ic)] px-[11px] py-[7px]">{body}</span> <span className={cls}>{body}</span>
)} )}
<span className="h-10 w-10 flex-none" aria-hidden="true" /> </div>
)
}
/**
* The bookings a stop carries, under its row (#2428): the table, the tickets, the tour,
* each a chip with the booking panel's icon, its name and the clock it starts at. Chips
* because a booking is a fact about the stop and not a stop of its own. Each opens its
* booking; without `onOpen` they are plain.
*/
export function RtBookingChips({ bookings, chrome, onOpen }: {
bookings: Reservation[]
chrome: RowChrome
onOpen?: (reservation: Reservation) => void
}) {
return (
<div className="grid gap-x-[10px] pb-2" style={{ gridTemplateColumns: '34px 1fr' }}>
<span aria-hidden="true" />
<span className="flex flex-wrap gap-[6px]">
{bookings.map(r => {
const Icon = bookingIcon(r.type)
const clock = bookingClock(r)
const body = (
<>
<Icon size={11} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
<span className="min-w-0 truncate text-[0.75rem] font-medium text-m-ink">{r.title}</span>
{clock && <span className="flex-none font-geist text-[0.65625rem] tabular-nums text-m-faint">{formatClockTime(clock, chrome.is12h)}</span>}
</>
)
const cls = 'inline-flex h-[28px] max-w-full items-center gap-[5px] rounded-full border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-[10px]'
return onOpen ? (
<button key={r.id} type="button" onClick={() => onOpen(r)} className={`${cls} text-start`}>{body}</button>
) : (
<span key={r.id} className={cls}>{body}</span>
)
})}
</span>
</div> </div>
) )
} }
@@ -1,4 +1,4 @@
import { useRef } from 'react' import { Fragment, useMemo, useRef } from 'react'
import { AlertTriangle, MapPin, Navigation } from 'lucide-react' import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe' import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
import { showStopOnMap, useMRoadtrip } from './useMRoadtrip' import { showStopOnMap, useMRoadtrip } from './useMRoadtrip'
@@ -6,7 +6,7 @@ import { useMRtCorridor } from './useMRtCorridor'
import { useMRtAlternatives } from './useMRtAlternatives' import { useMRtAlternatives } from './useMRtAlternatives'
import MRtCorridorBar from './MRtCorridorBar' import MRtCorridorBar from './MRtCorridorBar'
import MRtAlternativesBar from './MRtAlternativesBar' import MRtAlternativesBar from './MRtAlternativesBar'
import { RtAutoRow, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows' import { RtAutoRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
import MBadge from '../../../components/MBadge' import MBadge from '../../../components/MBadge'
import MDancingTrek from '../../../components/MDancingTrek' import MDancingTrek from '../../../components/MDancingTrek'
import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel' import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
@@ -17,6 +17,8 @@ import { formatClockTime } from '../../../../utils/formatters'
import { isRtlLanguage } from '../../../../i18n' import { isRtlLanguage } from '../../../../i18n'
import type { MTripTabPanelProps } from '../MTripShell' import type { MTripTabPanelProps } from '../MTripShell'
import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel' import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import { dayBookings } from '../../../../components/Roadtrip/stopBookings'
import type { Reservation } from '../../../../types'
/** /**
* The road trip tab: one day of the drive, as a chain or on the map. * The road trip tab: one day of the drive, as a chain or on the map.
@@ -82,6 +84,26 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
else shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId }) else shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
} }
// The bookings under the stage's stops and the ones the stage has for no stop, keyed
// by the stop's assignment because the rows carry no index. Opened the way the place
// sheet opens its linked bookings: a transport in its sheet, anything else in its
// editor, and only for somebody allowed to.
const bookings = useMemo(() => {
const byIndex = stage ? dayBookings(stage, planner.reservations) : null
const atStop = new Map<number, Reservation[]>()
if (stage && byIndex) for (const [i, list] of byIndex.atStop) atStop.set(stage.stops[i]!.assignmentId, list)
return { atStop, loose: byIndex?.loose ?? [] }
}, [stage, planner.reservations])
const openBooking = (res: Reservation) => {
if (planner.TRANSPORT_TYPES.has(res.type)) {
shell.openSheet('transport', { reservationId: res.id })
return
}
if (!planner.can('reservation_edit', planner.trip)) return
planner.setEditingReservation(res)
planner.setShowReservationModal(true)
}
// The search bar sits in the same band on both halves, at the same offset, so the // The search bar sits in the same band on both halves, at the same offset, so the
// list/map switch never moves it: it is one stage seen two ways, and a control that // list/map switch never moves it: it is one stage seen two ways, and a control that
// jumps between them is a control you have to find twice. The band and the margins // jumps between them is a control you have to find twice. The band and the margins
@@ -226,20 +248,23 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
<section className="mt-2.5 overflow-hidden rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-3 pt-1"> <section className="mt-2.5 overflow-hidden rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-3 pt-1">
{rt.rows.map((row, i) => { {rt.rows.map((row, i) => {
if (row.kind === 'stop') { if (row.kind === 'stop') {
const chips = bookings.atStop.get(row.stop.assignmentId)
return ( return (
<RtStopRow <Fragment key={`s${i}`}>
key={`s${i}`} <RtStopRow
row={row} row={row}
chrome={chrome} chrome={chrome}
onOpen={() => openStop(row)} onOpen={() => openStop(row)}
onPickKind={canEditPlaces && !row.stop.carrier onPickKind={canEditPlaces && !row.stop.carrier
? () => shell.openSheet('rtkind', { ? () => shell.openSheet('rtkind', {
placeId: row.stop.placeId, placeId: row.stop.placeId,
stopType: row.stop.stopType ?? null, stopType: row.stop.stopType ?? null,
name: row.stop.name, name: row.stop.name,
}) })
: undefined} : undefined}
/> />
{chips && <RtBookingChips bookings={chips} chrome={chrome} onOpen={openBooking} />}
</Fragment>
) )
} }
if (row.kind === 'leg') { if (row.kind === 'leg') {
@@ -259,7 +284,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
) )
} }
if (row.kind === 'ride') { if (row.kind === 'ride') {
return <RtRideRow key={`r${i}`} carrier={row.carrier} seg={row.seg} onOpen={() => shell.openSheet('transport', { reservationId: row.carrier.reservationId })} /> return <RtRideRow key={`r${i}`} row={row} chrome={chrome} onOpen={() => shell.openSheet('transport', { reservationId: row.carrier.reservationId })} />
} }
if (row.kind === 'auto') return <RtAutoRow key={`a${i}`} phase={row.phase} time={row.time} chrome={chrome} /> if (row.kind === 'auto') return <RtAutoRow key={`a${i}`} phase={row.phase} time={row.time} chrome={chrome} />
if (row.kind === 'spill') { if (row.kind === 'spill') {
@@ -284,6 +309,15 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
/> />
) )
})} })}
{bookings.loose.length > 0 && (
<div className="mt-1">
<div className="grid gap-x-[10px]" style={{ gridTemplateColumns: '34px 1fr' }}>
<span aria-hidden="true" />
<span className="mb-[6px] block font-geist text-[0.65625rem] font-semibold uppercase tracking-[0.08em] text-m-faint">{t('roadtrip.bookings.loose')}</span>
</div>
<RtBookingChips bookings={bookings.loose} chrome={chrome} onOpen={openBooking} />
</div>
)}
</section> </section>
</> </>
)} )}
+7 -2
View File
@@ -599,9 +599,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onFocusPoint={focusRoadtripPoint} onFocusPoint={focusRoadtripPoint}
selectedAssignmentId={selectedAssignmentId} selectedAssignmentId={selectedAssignmentId}
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)} onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)}
onOpenCarrier={(rid) => { reservations={reservations}
onOpenBooking={(rid) => {
const r = reservations.find(x => x.id === rid) const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r) if (!r) return
// The day plan's own split: a transport has a detail view with
// an edit button on it, a table or a ticket only has its editor.
if (TRANSPORT_TYPES.has(r.type)) setMapTransportDetail(r)
else openLinkedReservation?.(r)
}} }}
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined} onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined} onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
@@ -30,7 +30,7 @@ import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDay
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries' import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag' import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow' import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
import { carrierReservationIds } from '@trek/shared/roadtrip' import { carrierReservationIds, type CarrierTerminal } from '@trek/shared/roadtrip'
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview' import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useDawarichTrail } from '../../components/Map/useDawarichTrail' import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
import { collapsedDayDates } from '../../components/Map/dawarichTrail' import { collapsedDayDates } from '../../components/Map/dawarichTrail'
@@ -1663,7 +1663,7 @@ export function useTripPlanner() {
// (#2428). Nothing can be filed against a terminal: it stands in for no assignment, // (#2428). Nothing can be filed against a terminal: it stands in for no assignment,
// so a via anchored to it would be stored at a position that belongs to the stop // so a via anchored to it would be stored at a position that belongs to the stop
// after it and bend that stop's road instead. The callers decide what to refuse. // after it and bend that stop's road instead. The callers decide what to refuse.
let best: { dayId: number; afterIndex: number; offRouteKm: number; terminal: 'departure' | 'arrival' | null } | null = null let best: { dayId: number; afterIndex: number; offRouteKm: number; terminal: CarrierTerminal['role'] | null } | null = null
for (const day of roadtripRoutes.days) { for (const day of roadtripRoutes.days) {
// NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored // NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored
// on, but that day's stops are no longer all on the card of the same name: after a // on, but that day's stops are no longer all on the card of the same name: after a
@@ -1737,8 +1737,8 @@ export function useTripPlanner() {
*/ */
const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => { const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => {
const anchor = anchorFor(lat, lng) const anchor = anchorFor(lat, lng)
// Nothing is stopped at on a flight. Behind an arrival terminal is a road, and a stop // Nothing is stopped at on a flight. Behind an arrival terminal or a hire car's desk
// there is the first stop after landing. // is a road, and a stop there is the first stop after landing or after the pick-up.
if (!anchor || anchor.terminal === 'departure') return null if (!anchor || anchor.terminal === 'departure') return null
for (const day of roadtripRoutes.days) { for (const day of roadtripRoutes.days) {
const at = day.stops.findIndex(stop => stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex) const at = day.stops.findIndex(stop => stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
@@ -121,15 +121,16 @@ export class RoadtripPlanService {
} }
/** /**
* The bookings that seam the drive: a flight, train, ferry, cruise or bus on a day, with * The bookings that seam the drive, a flight, train, ferry, cruise or bus on a day, and
* its terminals and the slots the day plan gave it. The reservation service reads the * the hire cars whose desks stand on it, with their terminals and the slots the day
* same three tables for the booking list; this is the road trip's own cut of them, so * plan gave them. The reservation service reads the same three tables for the booking
* the module stays out of the reservations graph. * list; this is the road trip's own cut of them, so the module stays out of the
* reservations graph.
*/ */
private carriers(tripId: number): CarrierBooking[] { private carriers(tripId: number): CarrierBooking[] {
const rows = this.db.all<CarrierRow>( const rows = this.db.all<CarrierRow>(
`SELECT id, type, title, day_id, end_day_id, reservation_time, reservation_end_time, metadata, day_plan_position `SELECT id, type, title, day_id, end_day_id, reservation_time, reservation_end_time, metadata, day_plan_position
FROM reservations WHERE trip_id = ? AND type IN ('flight', 'train', 'ferry', 'cruise', 'bus') AND day_id IS NOT NULL`, FROM reservations WHERE trip_id = ? AND type IN ('flight', 'train', 'ferry', 'cruise', 'bus', 'car') AND day_id IS NOT NULL`,
tripId, tripId,
); );
if (!rows.length) return []; if (!rows.length) return [];
@@ -171,7 +172,7 @@ export class RoadtripPlanService {
); );
if (preferences.roadtrip_day_start && preferences.roadtrip_day_end && !window) if (preferences.roadtrip_day_start && preferences.roadtrip_day_end && !window)
throw new HttpException({ error: 'Day end must be later than day start.' }, 400); throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
const seams = context.carriers.map(carrierSeam).filter((seam): seam is CarrierSeam => seam !== null); const seams = context.carriers.map((booking) => carrierSeam(booking)).filter((seam): seam is CarrierSeam => seam !== null);
const dayNumberOf = (dayId: number): number => context.days.find((d) => d.id === dayId)?.day_number ?? 0; const dayNumberOf = (dayId: number): number => context.days.find((d) => d.id === dayId)?.day_number ?? 0;
const plan: PlanDay[] = context.days.map((day) => { const plan: PlanDay[] = context.days.map((day) => {
const visits = context.visits.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null); const visits = context.visits.filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null);
@@ -28,7 +28,7 @@ export class RoadtripPlanningMcp {
@Tool({ @Tool({
name: 'get_roadtrip_context', name: 'get_roadtrip_context',
description: description:
'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, manual day endings and the carrier bookings (flight, train, ferry, cruise, bus) that seam the drive at their terminals. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.', 'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, manual day endings and the carrier bookings (flight, train, ferry, cruise, bus) that seam the drive at their terminals, plus the hire cars whose pick-up and return desks stand on it. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
inputSchema: { tripId: z.number().int().positive() }, inputSchema: { tripId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' }, access: { group: 'trips', mode: 'read' },
@@ -41,7 +41,7 @@ export class RoadtripPlanningMcp {
@Tool({ @Tool({
name: 'calculate_roadtrip', name: 'calculate_roadtrip',
description: description:
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.', 'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
inputSchema: roadtripPlanRequestSchema.shape, inputSchema: roadtripPlanRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' }, access: { group: 'trips', mode: 'read' },
@@ -172,15 +172,57 @@ describe('a booking the traveller rides (#2428)', () => {
expect(calculated.totalStops).toBe(3); expect(calculated.totalStops).toBe(3);
}); });
it('lets a booking without located terminals fall through, and ignores a hire car', async () => { it('lets a booking without located terminals fall through, a hire car without a desk among them', async () => {
const { user, trip, visits, plans } = setup(); const { user, trip, visits, plans } = setup();
const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number }; const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id) VALUES (?, 'Hire car', 'car', ?)").run(trip.id, day.day_id); db.prepare("INSERT INTO reservations (trip_id, title, type, day_id) VALUES (?, 'Hire car', 'car', ?)").run(trip.id, day.day_id);
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Somewhere', 'train', ?, '11:00')").run(trip.id, day.day_id); db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Somewhere', 'train', ?, '11:00')").run(trip.id, day.day_id);
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Cab', 'taxi', ?, '11:00')").run(trip.id, day.day_id);
const { context, calculated } = await plans.calculate(trip.id, user.id); const { context, calculated } = await plans.calculate(trip.id, user.id);
expect(context.carriers.map((c) => c.type)).toEqual(['train']); expect(context.carriers.map((c) => c.type).sort()).toEqual(['car', 'train']);
expect(calculated.days[0].stops.every((s) => !s.carrier)).toBe(true); expect(calculated.days[0].stops.every((s) => !s.carrier)).toBe(true);
}); });
it("puts a hire car's desks on the road: the pick-up opens the day, the return closes it, one run through both", async () => {
const { user, trip, visits, plans } = setup();
db.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(visits[1].id);
db.prepare("UPDATE day_assignments SET assignment_time = '12:00' WHERE id = ?").run(visits[2].id);
const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
const result = db
.prepare(
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
VALUES (?, 'Sixt', 'car', ?, ?, '08:00', '18:00')`,
)
.run(trip.id, day.day_id, day.day_id);
const carId = Number(result.lastInsertRowid);
const endpoint = db.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
endpoint.run(carId, 'from', 0, 'Sixt Hauptbahnhof', null, 53.55, 10.0);
endpoint.run(carId, 'to', 1, 'Sixt Airport', 'HAM', 53.63, 9.99);
const router = hourlyRouter();
const service = new RoadtripPlanService(
new DatabaseService(db),
{ getUserSettings: () => ({}) } as never,
{ read: () => ({}) } as never,
router as never,
{ listForTrip: () => [], tracksForTrip: () => [] } as never,
{ list: () => [] } as never,
);
const { calculated } = await service.calculate(trip.id, user.id);
const card = calculated.days[0];
expect(card.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Hamburg', 'Lueneburg', 'Celle', 'return']);
expect(card.stops[0].carrier).toMatchObject({ reservationId: carId, type: 'car', at: '08:00' });
expect(card.stops[4].carrier).toMatchObject({ role: 'return', code: 'HAM', at: '18:00' });
// One road, desk to desk: no ride, no seam, one routing run.
expect(router.route).toHaveBeenCalledTimes(1);
expect(card.legs.map((l) => l?.mode)).toEqual(['driving', 'driving', 'driving', 'driving']);
expect(card.schedule.entries[0]).toMatchObject({ arrival: '08:00', departure: '08:00' });
expect(card.distance).toBe(240_000);
expect(calculated.totalStops).toBe(3);
});
}); });
+3
View File
@@ -266,6 +266,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'اجعلها استراحة', 'roadtrip.stay.releaseAction': 'اجعلها استراحة',
'roadtrip.ride.departure': 'المغادرة {time}', 'roadtrip.ride.departure': 'المغادرة {time}',
'roadtrip.ride.arrival': 'الوصول {time}', 'roadtrip.ride.arrival': 'الوصول {time}',
'roadtrip.ride.pickup': 'الاستلام {time}',
'roadtrip.ride.return': 'الإرجاع {time}',
'roadtrip.ride.open': 'فتح الحجز', 'roadtrip.ride.open': 'فتح الحجز',
'roadtrip.bookings.loose': 'محجوز أيضًا في هذا اليوم',
}; };
export default roadtrip; export default roadtrip;
+6 -3
View File
@@ -269,8 +269,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseBody': 'A noite em “{name}” será removida. A paragem continua na viagem como pausa.', 'roadtrip.stay.releaseBody': 'A noite em “{name}” será removida. A paragem continua na viagem como pausa.',
'roadtrip.stay.releaseBookedBody': 'A noite em “{name}” será removida juntamente com a reserva “{booking}” e qualquer despesa associada. A paragem continua na viagem como pausa.', 'roadtrip.stay.releaseBookedBody': 'A noite em “{name}” será removida juntamente com a reserva “{booking}” e qualquer despesa associada. A paragem continua na viagem como pausa.',
'roadtrip.stay.releaseAction': 'Tornar uma pausa', 'roadtrip.stay.releaseAction': 'Tornar uma pausa',
'roadtrip.ride.departure': 'Loc\'hañ {time}', 'roadtrip.ride.departure': 'Partida {time}',
'roadtrip.ride.arrival': 'Erruout {time}', 'roadtrip.ride.arrival': 'Chegada {time}',
'roadtrip.ride.open': 'Digeriñ ar mirout', 'roadtrip.ride.pickup': 'Retirada {time}',
'roadtrip.ride.return': 'Devolução {time}',
'roadtrip.ride.open': 'Abrir reserva',
'roadtrip.bookings.loose': 'Também reservado neste dia',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -274,6 +274,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Converteix en pausa', 'roadtrip.stay.releaseAction': 'Converteix en pausa',
'roadtrip.ride.departure': 'Sortida {time}', 'roadtrip.ride.departure': 'Sortida {time}',
'roadtrip.ride.arrival': 'Arribada {time}', 'roadtrip.ride.arrival': 'Arribada {time}',
'roadtrip.ride.pickup': 'Recollida {time}',
'roadtrip.ride.return': 'Devolució {time}',
'roadtrip.ride.open': 'Obre la reserva', 'roadtrip.ride.open': 'Obre la reserva',
'roadtrip.bookings.loose': 'També reservat aquest dia',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -269,6 +269,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Změnit na pauzu', 'roadtrip.stay.releaseAction': 'Změnit na pauzu',
'roadtrip.ride.departure': 'Odjezd {time}', 'roadtrip.ride.departure': 'Odjezd {time}',
'roadtrip.ride.arrival': 'Příjezd {time}', 'roadtrip.ride.arrival': 'Příjezd {time}',
'roadtrip.ride.pickup': 'Vyzvednutí {time}',
'roadtrip.ride.return': 'Vrácení {time}',
'roadtrip.ride.open': 'Otevřít rezervaci', 'roadtrip.ride.open': 'Otevřít rezervaci',
'roadtrip.bookings.loose': 'Také rezervováno na tento den',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -268,6 +268,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Als Pause behalten', 'roadtrip.stay.releaseAction': 'Als Pause behalten',
'roadtrip.ride.departure': 'Abfahrt {time}', 'roadtrip.ride.departure': 'Abfahrt {time}',
'roadtrip.ride.arrival': 'Ankunft {time}', 'roadtrip.ride.arrival': 'Ankunft {time}',
'roadtrip.ride.pickup': 'Abholung {time}',
'roadtrip.ride.return': 'Rückgabe {time}',
'roadtrip.ride.open': 'Buchung öffnen', 'roadtrip.ride.open': 'Buchung öffnen',
'roadtrip.bookings.loose': 'Außerdem an diesem Tag gebucht',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -266,6 +266,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Make it a pause', 'roadtrip.stay.releaseAction': 'Make it a pause',
'roadtrip.ride.departure': 'Departure {time}', 'roadtrip.ride.departure': 'Departure {time}',
'roadtrip.ride.arrival': 'Arrival {time}', 'roadtrip.ride.arrival': 'Arrival {time}',
'roadtrip.ride.pickup': 'Pick-up {time}',
'roadtrip.ride.return': 'Return {time}',
'roadtrip.ride.open': 'Open booking', 'roadtrip.ride.open': 'Open booking',
'roadtrip.bookings.loose': 'Also booked this day',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Convertir en pausa', 'roadtrip.stay.releaseAction': 'Convertir en pausa',
'roadtrip.ride.departure': 'Salida {time}', 'roadtrip.ride.departure': 'Salida {time}',
'roadtrip.ride.arrival': 'Llegada {time}', 'roadtrip.ride.arrival': 'Llegada {time}',
'roadtrip.ride.pickup': 'Recogida {time}',
'roadtrip.ride.return': 'Devolución {time}',
'roadtrip.ride.open': 'Abrir reserva', 'roadtrip.ride.open': 'Abrir reserva',
'roadtrip.bookings.loose': 'También reservado este día',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'En faire une pause', 'roadtrip.stay.releaseAction': 'En faire une pause',
'roadtrip.ride.departure': 'Départ {time}', 'roadtrip.ride.departure': 'Départ {time}',
'roadtrip.ride.arrival': 'Arrivée {time}', 'roadtrip.ride.arrival': 'Arrivée {time}',
'roadtrip.ride.pickup': 'Prise en charge {time}',
'roadtrip.ride.return': 'Restitution {time}',
'roadtrip.ride.open': 'Ouvrir la réservation', 'roadtrip.ride.open': 'Ouvrir la réservation',
'roadtrip.bookings.loose': 'Également réservé ce jour',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -275,6 +275,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Κάνε το παύση', 'roadtrip.stay.releaseAction': 'Κάνε το παύση',
'roadtrip.ride.departure': 'Αναχώρηση {time}', 'roadtrip.ride.departure': 'Αναχώρηση {time}',
'roadtrip.ride.arrival': 'Άφιξη {time}', 'roadtrip.ride.arrival': 'Άφιξη {time}',
'roadtrip.ride.pickup': 'Παραλαβή {time}',
'roadtrip.ride.return': 'Επιστροφή {time}',
'roadtrip.ride.open': 'Άνοιγμα κράτησης', 'roadtrip.ride.open': 'Άνοιγμα κράτησης',
'roadtrip.bookings.loose': 'Επίσης κρατημένο αυτή τη μέρα',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -270,6 +270,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Legyen szünet', 'roadtrip.stay.releaseAction': 'Legyen szünet',
'roadtrip.ride.departure': 'Indulás {time}', 'roadtrip.ride.departure': 'Indulás {time}',
'roadtrip.ride.arrival': 'Érkezés {time}', 'roadtrip.ride.arrival': 'Érkezés {time}',
'roadtrip.ride.pickup': 'Felvétel {time}',
'roadtrip.ride.return': 'Visszaadás {time}',
'roadtrip.ride.open': 'Foglalás megnyitása', 'roadtrip.ride.open': 'Foglalás megnyitása',
'roadtrip.bookings.loose': 'Erre a napra még lefoglalva',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Jadikan jeda', 'roadtrip.stay.releaseAction': 'Jadikan jeda',
'roadtrip.ride.departure': 'Berangkat {time}', 'roadtrip.ride.departure': 'Berangkat {time}',
'roadtrip.ride.arrival': 'Tiba {time}', 'roadtrip.ride.arrival': 'Tiba {time}',
'roadtrip.ride.pickup': 'Pengambilan {time}',
'roadtrip.ride.return': 'Pengembalian {time}',
'roadtrip.ride.open': 'Buka pemesanan', 'roadtrip.ride.open': 'Buka pemesanan',
'roadtrip.bookings.loose': 'Juga dipesan hari ini',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Rendi una pausa', 'roadtrip.stay.releaseAction': 'Rendi una pausa',
'roadtrip.ride.departure': 'Partenza {time}', 'roadtrip.ride.departure': 'Partenza {time}',
'roadtrip.ride.arrival': 'Arrivo {time}', 'roadtrip.ride.arrival': 'Arrivo {time}',
'roadtrip.ride.pickup': 'Ritiro {time}',
'roadtrip.ride.return': 'Riconsegna {time}',
'roadtrip.ride.open': 'Apri prenotazione', 'roadtrip.ride.open': 'Apri prenotazione',
'roadtrip.bookings.loose': 'Prenotato anche questo giorno',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -268,6 +268,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': '休憩にする', 'roadtrip.stay.releaseAction': '休憩にする',
'roadtrip.ride.departure': '出発 {time}', 'roadtrip.ride.departure': '出発 {time}',
'roadtrip.ride.arrival': '到着 {time}', 'roadtrip.ride.arrival': '到着 {time}',
'roadtrip.ride.pickup': '受取 {time}',
'roadtrip.ride.return': '返却 {time}',
'roadtrip.ride.open': '予約を開く', 'roadtrip.ride.open': '予約を開く',
'roadtrip.bookings.loose': 'この日のその他の予約',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -266,6 +266,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': '휴식으로 바꾸기', 'roadtrip.stay.releaseAction': '휴식으로 바꾸기',
'roadtrip.ride.departure': '출발 {time}', 'roadtrip.ride.departure': '출발 {time}',
'roadtrip.ride.arrival': '도착 {time}', 'roadtrip.ride.arrival': '도착 {time}',
'roadtrip.ride.pickup': '픽업 {time}',
'roadtrip.ride.return': '반납 {time}',
'roadtrip.ride.open': '예약 열기', 'roadtrip.ride.open': '예약 열기',
'roadtrip.bookings.loose': '이날의 다른 예약',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -272,6 +272,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Maak er een pauze van', 'roadtrip.stay.releaseAction': 'Maak er een pauze van',
'roadtrip.ride.departure': 'Vertrek {time}', 'roadtrip.ride.departure': 'Vertrek {time}',
'roadtrip.ride.arrival': 'Aankomst {time}', 'roadtrip.ride.arrival': 'Aankomst {time}',
'roadtrip.ride.pickup': 'Ophalen {time}',
'roadtrip.ride.return': 'Inleveren {time}',
'roadtrip.ride.open': 'Boeking openen', 'roadtrip.ride.open': 'Boeking openen',
'roadtrip.bookings.loose': 'Ook geboekt op deze dag',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -270,6 +270,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Zamień na postój', 'roadtrip.stay.releaseAction': 'Zamień na postój',
'roadtrip.ride.departure': 'Odjazd {time}', 'roadtrip.ride.departure': 'Odjazd {time}',
'roadtrip.ride.arrival': 'Przyjazd {time}', 'roadtrip.ride.arrival': 'Przyjazd {time}',
'roadtrip.ride.pickup': 'Odbiór {time}',
'roadtrip.ride.return': 'Zwrot {time}',
'roadtrip.ride.open': 'Otwórz rezerwację', 'roadtrip.ride.open': 'Otwórz rezerwację',
'roadtrip.bookings.loose': 'Zarezerwowane też tego dnia',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -272,6 +272,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Сделать паузой', 'roadtrip.stay.releaseAction': 'Сделать паузой',
'roadtrip.ride.departure': 'Отправление {time}', 'roadtrip.ride.departure': 'Отправление {time}',
'roadtrip.ride.arrival': 'Прибытие {time}', 'roadtrip.ride.arrival': 'Прибытие {time}',
'roadtrip.ride.pickup': 'Получение {time}',
'roadtrip.ride.return': 'Возврат {time}',
'roadtrip.ride.open': 'Открыть бронирование', 'roadtrip.ride.open': 'Открыть бронирование',
'roadtrip.bookings.loose': 'Также забронировано в этот день',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -269,6 +269,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Gör till en paus', 'roadtrip.stay.releaseAction': 'Gör till en paus',
'roadtrip.ride.departure': 'Avgång {time}', 'roadtrip.ride.departure': 'Avgång {time}',
'roadtrip.ride.arrival': 'Ankomst {time}', 'roadtrip.ride.arrival': 'Ankomst {time}',
'roadtrip.ride.pickup': 'Upphämtning {time}',
'roadtrip.ride.return': 'Återlämning {time}',
'roadtrip.ride.open': 'Öppna bokning', 'roadtrip.ride.open': 'Öppna bokning',
'roadtrip.bookings.loose': 'Även bokat denna dag',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -272,6 +272,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Molaya çevir', 'roadtrip.stay.releaseAction': 'Molaya çevir',
'roadtrip.ride.departure': 'Kalkış {time}', 'roadtrip.ride.departure': 'Kalkış {time}',
'roadtrip.ride.arrival': 'Varış {time}', 'roadtrip.ride.arrival': 'Varış {time}',
'roadtrip.ride.pickup': 'Alış {time}',
'roadtrip.ride.return': 'İade {time}',
'roadtrip.ride.open': 'Rezervasyonu aç', 'roadtrip.ride.open': 'Rezervasyonu aç',
'roadtrip.bookings.loose': 'Bu gün için ayrıca rezerve edildi',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -271,6 +271,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Зробити паузою', 'roadtrip.stay.releaseAction': 'Зробити паузою',
'roadtrip.ride.departure': 'Відправлення {time}', 'roadtrip.ride.departure': 'Відправлення {time}',
'roadtrip.ride.arrival': 'Прибуття {time}', 'roadtrip.ride.arrival': 'Прибуття {time}',
'roadtrip.ride.pickup': 'Отримання {time}',
'roadtrip.ride.return': 'Повернення {time}',
'roadtrip.ride.open': 'Відкрити бронювання', 'roadtrip.ride.open': 'Відкрити бронювання',
'roadtrip.bookings.loose': 'Також заброньовано цього дня',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -270,6 +270,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': 'Chuyển thành điểm nghỉ', 'roadtrip.stay.releaseAction': 'Chuyển thành điểm nghỉ',
'roadtrip.ride.departure': 'Khởi hành {time}', 'roadtrip.ride.departure': 'Khởi hành {time}',
'roadtrip.ride.arrival': 'Đến nơi {time}', 'roadtrip.ride.arrival': 'Đến nơi {time}',
'roadtrip.ride.pickup': 'Nhận xe {time}',
'roadtrip.ride.return': 'Trả xe {time}',
'roadtrip.ride.open': 'Mở đặt chỗ', 'roadtrip.ride.open': 'Mở đặt chỗ',
'roadtrip.bookings.loose': 'Cũng đã đặt trong ngày này',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -257,6 +257,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': '改為休息', 'roadtrip.stay.releaseAction': '改為休息',
'roadtrip.ride.departure': '出發 {time}', 'roadtrip.ride.departure': '出發 {time}',
'roadtrip.ride.arrival': '抵達 {time}', 'roadtrip.ride.arrival': '抵達 {time}',
'roadtrip.ride.pickup': '取車 {time}',
'roadtrip.ride.return': '還車 {time}',
'roadtrip.ride.open': '開啟預訂', 'roadtrip.ride.open': '開啟預訂',
'roadtrip.bookings.loose': '當天的其他預訂',
}; };
export default roadtrip; export default roadtrip;
+3
View File
@@ -258,6 +258,9 @@ const roadtrip: TranslationStrings = {
'roadtrip.stay.releaseAction': '改为休息', 'roadtrip.stay.releaseAction': '改为休息',
'roadtrip.ride.departure': '出发 {time}', 'roadtrip.ride.departure': '出发 {time}',
'roadtrip.ride.arrival': '到达 {time}', 'roadtrip.ride.arrival': '到达 {time}',
'roadtrip.ride.pickup': '取车 {time}',
'roadtrip.ride.return': '还车 {time}',
'roadtrip.ride.open': '打开预订', 'roadtrip.ride.open': '打开预订',
'roadtrip.bookings.loose': '当天的其他预订',
}; };
export default roadtrip; export default roadtrip;
+3 -1
View File
@@ -1,5 +1,5 @@
import { spurFor } from './accessSpur'; import { spurFor } from './accessSpur';
import { carriesTheCar, isCarrierMode } from './carriers'; import { carriesTheCar, isCarrierMode, isPickupStop } from './carriers';
import { pointAtMeters } from './corridor'; import { pointAtMeters } from './corridor';
import type { RoadtripDayBoundary } from './day-boundary.schema'; import type { RoadtripDayBoundary } from './day-boundary.schema';
import { planDayWindow, type DayWindow } from './dayWindow'; import { planDayWindow, type DayWindow } from './dayWindow';
@@ -226,6 +226,8 @@ export function assembleRoadtrip({
} }
if (!incoming.seg.mode || incoming.seg.mode === 'driving') drivingSeconds += incoming.seg.duration ?? 0; if (!incoming.seg.mode || incoming.seg.mode === 'driving') drivingSeconds += incoming.seg.duration ?? 0;
} }
// A hire car is picked up full: whatever the drive had spent before, this tank is new.
if (isPickupStop(stops[i])) carryKm = 0;
const leg = routed[i]; const leg = routed[i];
if (leg && isCarrierMode(leg.seg.mode)) { if (leg && isCarrierMode(leg.seg.mode)) {
if (!carriesTheCar(leg.seg.mode!)) carryKm = 0; if (!carriesTheCar(leg.seg.mode!)) carryKm = 0;
+139 -5
View File
@@ -1,10 +1,12 @@
/** /**
* ROADTRIP-CARRIERS-001..014: a booking the traveller rides becomes a seam in the drive. * ROADTRIP-CARRIERS-001..019: a booking the traveller rides becomes a seam in the drive,
* and a hire car puts its desks on it.
* *
* The road ends at the terminal the ride leaves from and starts again at the one it * The road ends at the terminal the ride leaves from and starts again at the one it
* lands at (#2428). Pinned here: which bookings count, where their terminals are seated * lands at (#2428). Pinned here: which bookings count, where their terminals are seated
* among the day's stops, what the ride between them is worth to the chain, and that * among the day's stops, what the ride between them is worth to the chain, that
* nothing about a terminal reads as a stored stop. * nothing about a terminal reads as a stored stop, and that a hire car's pick-up and
* return are points the road runs through rather than a seam in it.
*/ */
import { assembleRoadtrip } from './assemble'; import { assembleRoadtrip } from './assemble';
import { import {
@@ -18,6 +20,7 @@ import {
carriesTheCar, carriesTheCar,
isCarrierMode, isCarrierMode,
isCarrierStop, isCarrierStop,
isPickupStop,
seatCarrierStops, seatCarrierStops,
terminalAssignmentId, terminalAssignmentId,
viasLeaving, viasLeaving,
@@ -87,8 +90,7 @@ describe('carrierSeam', () => {
expect(seam!.departure.position).toBeNull(); expect(seam!.departure.position).toBeNull();
}); });
it('ROADTRIP-CARRIERS-002: a hire car, a taxi and a hop on transit are not seams, and neither is a ride nobody put on a day', () => { it('ROADTRIP-CARRIERS-002: a taxi and a hop on transit are not seams, and neither is a ride nobody put on a day', () => {
expect(carrierSeam(flight({ type: 'car' }))).toBeNull();
expect(carrierSeam(flight({ type: 'taxi' }))).toBeNull(); expect(carrierSeam(flight({ type: 'taxi' }))).toBeNull();
expect(carrierSeam(flight({ type: 'transit' }))).toBeNull(); expect(carrierSeam(flight({ type: 'transit' }))).toBeNull();
expect(carrierSeam(flight({ day_id: null }))).toBeNull(); expect(carrierSeam(flight({ day_id: null }))).toBeNull();
@@ -151,6 +153,138 @@ describe('carrierSeam', () => {
}); });
}); });
describe('a hire car', () => {
const rental = (over: Partial<CarrierBooking> = {}): CarrierBooking =>
flight({
id: 9,
type: 'car',
title: 'Sixt Hamburg',
day_id: 1,
end_day_id: 3,
reservation_time: '2026-06-01T09:00',
reservation_end_time: '2026-06-03T11:30',
endpoints: [
{ role: 'from', sequence: 0, name: 'Sixt Hauptbahnhof', code: null, lat: 53.55, lng: 10.0 },
{ role: 'to', sequence: 1, name: 'Sixt Airport', code: 'HAM', lat: 53.63, lng: 9.99 },
],
...over,
});
it('ROADTRIP-CARRIERS-016: a hire car is a rental, not a ride: its desks are its ends, and the same desk twice is fine', () => {
const seam = carrierSeam(rental())!;
expect(seam.kind).toBe('rental');
expect(seam.departure).toMatchObject({ dayId: 1, clock: '09:00', name: 'Sixt Hauptbahnhof' });
expect(seam.arrival).toMatchObject({ dayId: 3, clock: '11:30', code: 'HAM' });
const sameDesk = carrierSeam(
rental({
endpoints: [
{ role: 'from', sequence: 0, name: 'Europcar MUC', code: 'MUC', lat: 48.35, lng: 11.78 },
{ role: 'to', sequence: 1, name: 'Europcar MUC', code: 'MUC', lat: 48.35, lng: 11.78 },
],
}),
)!;
expect(sameDesk.arrival).toMatchObject({ name: 'Europcar MUC' });
});
it('ROADTRIP-CARRIERS-017: a hire car without a return day or a return desk stands on the road at its pick-up only, nothing is guessed', () => {
const noDay = carrierSeam(rental({ end_day_id: null }))!;
expect(noDay.arrival).toBeNull();
const noDesk = carrierSeam(
rental({
endpoints: [{ role: 'from', sequence: 0, name: 'Sixt Hauptbahnhof', code: null, lat: 53.55, lng: 10.0 }],
}),
)!;
expect(noDesk.arrival).toBeNull();
expect(carrierSeam(rental({ endpoints: [] }))).toBeNull();
expect(carrierRideMinutes(noDay, 0)).toBeNull();
const seated = seatCarrierStops(1, [stop({ ownerIndex: 0, time: '10:00' })], [0], [noDay]);
expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 0']);
expect(seatCarrierStops(3, [stop({ ownerIndex: 0 })], [0], [noDay]).map((s) => s.carrier?.role ?? s.name)).toEqual([
'Stop 0',
]);
});
it('ROADTRIP-CARRIERS-018: the pick-up opens its day when nothing timed comes first, the return closes its day, and the road runs through both', () => {
const seam = carrierSeam(rental())!;
const first = seatCarrierStops(
1,
[stop({ ownerIndex: 0, time: '10:00' }), stop({ ownerIndex: 1 })],
[0, 1],
[seam],
);
expect(first.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 0', 'Stop 1']);
const pickup = first[0]!;
expect(pickup).toMatchObject({
time: '09:00',
leaveAt: null,
dwellMinutes: 0,
legMode: null,
incomingLegMode: null,
});
expect(pickup.assignmentId).toBe(terminalAssignmentId(9, 'pickup'));
expect(isCarrierStop(pickup)).toBe(true);
expect(isPickupStop(pickup)).toBe(true);
// No ride leaves a pick-up desk: the leg out of it is a road like any other.
expect(carrierLegsFor([pickup, first[1]!], 'driving', () => 0, roadtripLegKey)).toBeNull();
const last = seatCarrierStops(
3,
[stop({ ownerIndex: 0, ownerDayId: 3, time: '09:30' }), stop({ ownerIndex: 1, ownerDayId: 3, time: '10:30' })],
[0, 1],
[seam],
);
expect(last.map((s) => s.carrier?.role ?? s.name)).toEqual(['Stop 0', 'Stop 1', 'return']);
expect(last[2]).toMatchObject({ time: '11:30', dwellMinutes: 0, legMode: null, incomingLegMode: null });
expect(last[2]!.assignmentId).toBe(terminalAssignmentId(9, 'return'));
expect(isPickupStop(last[2])).toBe(false);
// Nothing of it on the day in between, and no arc on the map: its line is the road.
expect(seatCarrierStops(2, [stop({ ownerIndex: 0, ownerDayId: 2 })], [0], [seam])).toHaveLength(1);
expect(carrierReservationIds([{ stops: first }, { stops: last }])).toEqual([]);
});
it('ROADTRIP-CARRIERS-019: a car picked up and handed back on the same day seats both desks on their own, and the tank is full at the pick-up', () => {
const oneDay = carrierSeam(rental({ end_day_id: 1, reservation_end_time: '2026-06-01T18:00' }))!;
const seated = seatCarrierStops(
1,
[stop({ ownerIndex: 0, time: '10:00' }), stop({ ownerIndex: 1, time: '15:00' })],
[0, 1],
[oneDay],
);
expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 0', 'Stop 1', 'return']);
const [pickup, a, b, back] = seated as [RoadtripStop, RoadtripStop, RoadtripStop, RoadtripStop];
const before = stop({ ownerIndex: 5, lat: 49, time: '07:00' });
const stops = [before, pickup, a, b, back];
const legs: Record<string, RoutedLeg> = {
[roadtripLegKey(before, pickup)]: road(40),
[roadtripLegKey(pickup, a)]: road(30),
[roadtripLegKey(a, b)]: road(30),
[roadtripLegKey(b, back)]: road(30),
};
const routes = assembleRoadtrip({
plan: [{ dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops }],
quietDays: [],
window: null,
distanceUnit: 'metric',
allLegs: legs,
snapByDay: {},
missedByDay: {},
loading: false,
limits: { legMinutes: null, dayMinutes: null, rangeKm: 100 },
vehicleKind: 'combustion',
connectDays: false,
boundaries: [],
labels: { start: 'go', end: 'stop' },
});
const day = routes.days[0]!;
// 130 km of road on a 100 km range, but the 40 km before the desk were another
// car's: the hire car runs 90 km from a full tank and never runs dry.
expect(day.distance).toBe(130_000);
expect(day.driveWarnings.some((w) => w.code === 'range')).toBe(false);
expect(routes.totalStops).toBe(3);
});
});
describe('carrierRideMinutes', () => { describe('carrierRideMinutes', () => {
it('ROADTRIP-CARRIERS-007: the ride is what the clocks say, across the days the booking spans, and never negative', () => { it('ROADTRIP-CARRIERS-007: the ride is what the clocks say, across the days the booking spans, and never negative', () => {
const same = carrierSeam(flight())!; const same = carrierSeam(flight())!;
+93 -27
View File
@@ -18,6 +18,15 @@ export function isCarrierType(type: string | null | undefined): boolean {
return !!type && (CARRIER_TYPES as readonly string[]).includes(type); return !!type && (CARRIER_TYPES as readonly string[]).includes(type);
} }
/**
* A hire car: the traveller drives it, so its booking is no seam. Its pick-up and return
* desks are still where the drive starts and ends, and the day plan routes to them the
* same way, so they take their place on the road as points of their own.
*/
export function isRentalType(type: string | null | undefined): boolean {
return type === 'car';
}
/** A leg mode that is a ride rather than a road: the booking's own type is the mode. */ /** A leg mode that is a ride rather than a road: the booking's own type is the mode. */
export function isCarrierMode(mode: string | null | undefined): boolean { export function isCarrierMode(mode: string | null | undefined): boolean {
return isCarrierType(mode); return isCarrierType(mode);
@@ -88,8 +97,15 @@ export interface CarrierSeam {
reservationId: number; reservationId: number;
type: string; type: string;
title: string; title: string;
/** A ride seams the drive; a rental only puts its two desks on it. */
kind: 'ride' | 'rental';
departure: CarrierEnd; departure: CarrierEnd;
arrival: CarrierEnd; /**
* A ride always has both ends. A hire car has a return only when the booking says
* when and where: without a return day or a located return desk, nothing is guessed
* and only the pick-up stands on the road.
*/
arrival: CarrierEnd | null;
} }
interface LegRecord { interface LegRecord {
@@ -142,22 +158,37 @@ function positionOn(positions: Record<string, number> | null | undefined, dayId:
* change of planes happens inside the ride, and the drive has no say in it. * change of planes happens inside the ride, and the drive has no say in it.
*/ */
export function carrierSeam(booking: CarrierBooking): CarrierSeam | null { export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
if (!isCarrierType(booking.type)) return null; const kind: CarrierSeam['kind'] | null = isCarrierType(booking.type)
? 'ride'
: isRentalType(booking.type)
? 'rental'
: null;
if (!kind) return null;
const located = (booking.endpoints ?? []) const located = (booking.endpoints ?? [])
.filter( .filter(
(e) => typeof e.lat === 'number' && typeof e.lng === 'number' && Number.isFinite(e.lat) && Number.isFinite(e.lng), (e) => typeof e.lat === 'number' && typeof e.lng === 'number' && Number.isFinite(e.lat) && Number.isFinite(e.lng),
) )
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)); .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
const from = located.find((e) => e.role === 'from') ?? located[0]; const from = located.find((e) => e.role === 'from') ?? located[0];
const to = [...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1]; // A ride needs both ends to say where the drive stops and where it resumes, and takes
if (!from || !to || from === to) return null; // the last located stop for its arrival when no endpoint is marked as one. A hire car's
// return is only ever the desk marked as such: guessing one would put a stop on the
// road nobody booked. The same desk twice is fine for a car, and no seam for a ride.
const to =
kind === 'ride'
? ([...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1])
: [...located].reverse().find((e) => e.role === 'to');
if (!from) return null;
if (kind === 'ride' && (!to || from === to)) return null;
const legs = legsOf(booking); const legs = legsOf(booking);
const first = legs[0]; const first = legs[0];
const last = legs[legs.length - 1]; const last = legs[legs.length - 1];
const depDayId = first?.dep_day_id ?? booking.day_id ?? null; const depDayId = first?.dep_day_id ?? booking.day_id ?? null;
if (depDayId == null) return null; if (depDayId == null) return null;
const arrDayId = last?.arr_day_id ?? booking.end_day_id ?? depDayId; // A ride lands on the day it left unless the booking says otherwise. A hire car is
// handed back on the day the booking names, and on no day when it names none.
const arrDayId = last?.arr_day_id ?? booking.end_day_id ?? (kind === 'ride' ? depDayId : null);
const depClock = carrierClock(first?.dep_time ?? booking.reservation_time); const depClock = carrierClock(first?.dep_time ?? booking.reservation_time);
const arrClock = carrierClock(last?.arr_time ?? booking.reservation_end_time); const arrClock = carrierClock(last?.arr_time ?? booking.reservation_end_time);
const bookingPosition = (dayId: number): number | null => const bookingPosition = (dayId: number): number | null =>
@@ -168,6 +199,7 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
reservationId: booking.id, reservationId: booking.id,
type: booking.type, type: booking.type,
title: booking.title, title: booking.title,
kind,
departure: { departure: {
dayId: depDayId, dayId: depDayId,
position: positionOn(first?.day_positions, depDayId) ?? bookingPosition(depDayId), position: positionOn(first?.day_positions, depDayId) ?? bookingPosition(depDayId),
@@ -177,15 +209,18 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
lat: from.lat!, lat: from.lat!,
lng: from.lng!, lng: from.lng!,
}, },
arrival: { arrival:
dayId: arrDayId, to && arrDayId != null
position: positionOn(last?.day_positions, arrDayId) ?? bookingPosition(arrDayId), ? {
clock: arrClock, dayId: arrDayId,
name: to.name, position: positionOn(last?.day_positions, arrDayId) ?? bookingPosition(arrDayId),
code: to.code ?? null, clock: arrClock,
lat: to.lat!, name: to.name,
lng: to.lng!, code: to.code ?? null,
}, lat: to.lat!,
lng: to.lng!,
}
: null,
}; };
} }
@@ -199,7 +234,7 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
*/ */
export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number | null { export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number | null {
const dep = parseClock(seam.departure.clock); const dep = parseClock(seam.departure.clock);
const arr = parseClock(seam.arrival.clock); const arr = parseClock(seam.arrival?.clock);
if (dep === null || arr === null) return null; if (dep === null || arr === null) return null;
return Math.max(0, arr + Math.max(0, dayDelta) * 1440 - dep); return Math.max(0, arr + Math.max(0, dayDelta) * 1440 - dep);
} }
@@ -208,7 +243,12 @@ export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number
const TERMINAL_ID_BASE = -3_000_000_000; const TERMINAL_ID_BASE = -3_000_000_000;
export function terminalAssignmentId(reservationId: number, role: CarrierTerminal['role']): number { export function terminalAssignmentId(reservationId: number, role: CarrierTerminal['role']): number {
return TERMINAL_ID_BASE - reservationId * 2 - (role === 'arrival' ? 1 : 0); return TERMINAL_ID_BASE - reservationId * 2 - (role === 'arrival' || role === 'return' ? 1 : 0);
}
/** The two roles of a seam, by which end of it a terminal stands at. */
function rolesOf(seam: CarrierSeam): { start: CarrierTerminal['role']; end: CarrierTerminal['role'] } {
return seam.kind === 'rental' ? { start: 'pickup', end: 'return' } : { start: 'departure', end: 'arrival' };
} }
/** Whether a stop is a carrier terminal, which is what every writer has to refuse. */ /** Whether a stop is a carrier terminal, which is what every writer has to refuse. */
@@ -222,12 +262,14 @@ function terminalStop(
dayId: number, dayId: number,
ownerIndex: number, ownerIndex: number,
): RoadtripStop { ): RoadtripStop {
const end = role === 'departure' ? seam.departure : seam.arrival; // The callers only ask for an arrival or a return the seam has.
const end = role === 'departure' || role === 'pickup' ? seam.departure : seam.arrival!;
const checkIn = CHECK_IN_MINUTES[seam.type] ?? 0; const checkIn = CHECK_IN_MINUTES[seam.type] ?? 0;
const depart = parseClock(end.clock); const depart = parseClock(end.clock);
// The departure terminal is pinned a check-in ahead of the timetable and left at the // The departure terminal is pinned a check-in ahead of the timetable and left at the
// timetable's minute; the arrival one is pinned at the timetable's minute and left at // timetable's minute; the arrival one is pinned at the timetable's minute and left at
// once. Neither has a stay of its own. // once. Neither has a stay of its own. A hire car's desks are pinned at the booking's
// own clock, and the drive goes on from them without a stay.
const time = role === 'departure' && depart !== null ? formatClock(depart - checkIn) : end.clock; const time = role === 'departure' && depart !== null ? formatClock(depart - checkIn) : end.clock;
return { return {
carrier: { carrier: {
@@ -258,8 +300,14 @@ function terminalStop(
}; };
} }
/** Whether a terminal opens a day it has no timed stop before it on: an arrival or a pick-up. */
function opensTheDay(role: CarrierTerminal['role']): boolean {
return role === 'arrival' || role === 'pickup';
}
/** /**
* The day's stops with the terminals of its rides seated among them. * The day's stops with the terminals of its rides, and the desks of its hire cars, seated
* among them.
* *
* Each terminal takes the slot the day plan shows the booking in, worked out by the same * Each terminal takes the slot the day plan shows the booking in, worked out by the same
* rules (`getMergedItems` in the client): a position somebody dragged it to wins, otherwise * rules (`getMergedItems` in the client): a position somebody dragged it to wins, otherwise
@@ -271,7 +319,10 @@ function terminalStop(
* *
* A ride that leaves and lands on the same day seats its arrival right behind its * A ride that leaves and lands on the same day seats its arrival right behind its
* departure: nothing is visited in between. One that lands on a later day seats the * departure: nothing is visited in between. One that lands on a later day seats the
* arrival on that day, where it opens the drive. * arrival on that day, where it opens the drive. A hire car's pick-up opens the day the
* same way when nothing timed comes before it, and its return closes the day it is
* handed back on; the two are seated on their own, because the drive itself runs
* between them.
* *
* `orderIndex` is each stop's stored `order_index`, which is what a dragged position is * `orderIndex` is each stop's stored `order_index`, which is what a dragged position is
* measured against; `clockOf` its time in minutes, or null. * measured against; `clockOf` its time in minutes, or null.
@@ -283,7 +334,12 @@ export function seatCarrierStops(
seams: CarrierSeam[], seams: CarrierSeam[],
): RoadtripStop[] { ): RoadtripStop[] {
const departing = seams.filter((s) => s.departure.dayId === dayId); const departing = seams.filter((s) => s.departure.dayId === dayId);
const arriving = seams.filter((s) => s.arrival.dayId === dayId && s.departure.dayId !== dayId); // A ride landing on the day it left seats its arrival behind its departure below. A
// hire car handed back on the day it was picked up has two desks with nothing tying
// them together, so both are seated on their own.
const arriving = seams.filter(
(s) => s.arrival?.dayId === dayId && (s.departure.dayId !== dayId || s.kind === 'rental'),
);
if (!departing.length && !arriving.length) return stops; if (!departing.length && !arriving.length) return stops;
type Item = type Item =
@@ -296,8 +352,8 @@ export function seatCarrierStops(
minutes: parseClock(stop.time ?? stop.checkInTime ?? null), minutes: parseClock(stop.time ?? stop.checkInTime ?? null),
})); }));
const ends: { seam: CarrierSeam; role: CarrierTerminal['role']; end: CarrierEnd }[] = [ const ends: { seam: CarrierSeam; role: CarrierTerminal['role']; end: CarrierEnd }[] = [
...departing.map((seam) => ({ seam, role: 'departure' as const, end: seam.departure })), ...departing.map((seam) => ({ seam, role: rolesOf(seam).start, end: seam.departure })),
...arriving.map((seam) => ({ seam, role: 'arrival' as const, end: seam.arrival })), ...arriving.map((seam) => ({ seam, role: rolesOf(seam).end, end: seam.arrival! })),
]; ];
const items: Item[] = [...base]; const items: Item[] = [...base];
const lastKey = base.length ? Math.max(...base.map((b) => b.key)) : 0; const lastKey = base.length ? Math.max(...base.map((b) => b.key)) : 0;
@@ -316,7 +372,7 @@ export function seatCarrierStops(
// lists it. An arrival that landed overnight OPENS the day it lands on: the day // lists it. An arrival that landed overnight OPENS the day it lands on: the day
// plan happens to file it last there too, but read as a drive that puts the // plan happens to file it last there too, but read as a drive that puts the
// morning's stops on the road before the traveller has landed. // morning's stops on the road before the traveller has landed.
const opens = e.role === 'arrival'; const opens = opensTheDay(e.role);
key = key =
after === -Infinity after === -Infinity
? opens ? opens
@@ -344,7 +400,7 @@ export function seatCarrierStops(
const emit = (slot: (typeof slots)[number]): void => { const emit = (slot: (typeof slots)[number]): void => {
const insertAt = out.filter((s) => !s.carrier).length; const insertAt = out.filter((s) => !s.carrier).length;
out.push(terminalStop(slot.seam, slot.role, dayId, insertAt)); out.push(terminalStop(slot.seam, slot.role, dayId, insertAt));
if (slot.role === 'departure' && slot.seam.arrival.dayId === dayId) { if (slot.role === 'departure' && slot.seam.arrival?.dayId === dayId) {
out.push(terminalStop(slot.seam, 'arrival', dayId, insertAt)); out.push(terminalStop(slot.seam, 'arrival', dayId, insertAt));
} }
}; };
@@ -428,9 +484,19 @@ export function viasLeaving<V extends { day_id: number; after_order_index: numbe
.sort((a, b) => a.sequence - b.sequence); .sort((a, b) => a.sequence - b.sequence);
} }
/** The bookings whose ride the days draw, for the map to show their arcs beside the roads. */ /**
* The bookings whose ride the days draw, for the map to show their arcs beside the roads.
* A hire car is left out: its line on the map is the road the drive already draws.
*/
export function carrierReservationIds(days: readonly { stops: readonly Pick<RoadtripStop, 'carrier'>[] }[]): number[] { export function carrierReservationIds(days: readonly { stops: readonly Pick<RoadtripStop, 'carrier'>[] }[]): number[] {
const ids = new Set<number>(); const ids = new Set<number>();
for (const day of days) for (const stop of day.stops) if (stop.carrier) ids.add(stop.carrier.reservationId); for (const day of days)
for (const stop of day.stops)
if (stop.carrier && isCarrierType(stop.carrier.type)) ids.add(stop.carrier.reservationId);
return [...ids]; return [...ids];
} }
/** Whether a stop is where a hire car is picked up: the tank the drive goes on with is full. */
export function isPickupStop(stop: Pick<RoadtripStop, 'carrier'> | null | undefined): boolean {
return stop?.carrier?.role === 'pickup';
}
+10 -7
View File
@@ -5,18 +5,21 @@ import type { SpillMark } from './nightSpill';
import type { Schedule, ScheduleWarning, DayWarning, DryPoint } from './roadtripModel'; import type { Schedule, ScheduleWarning, DayWarning, DryPoint } from './roadtripModel';
/** /**
* A terminal of a booking the traveller rides rather than drives: the airport, station or * A point a booking puts on the drive: the airport, station or port a flight, train,
* port a flight, train, ferry, cruise or bus leaves from or lands at. * ferry, cruise or bus leaves from or lands at, or the desk a hire car is picked up at
* or handed back at.
* *
* The drive ends at the departure terminal and starts again at the arrival one. What * For a ride the drive ends at the departure terminal and starts again at the arrival
* happens in between is the booking's business, not the road's: the leg between the * one; what happens in between is the booking's business, not the road's, and the leg
* two carries the ride's minutes and nothing else, and no road is ever asked for it. * between the two carries the ride's minutes and nothing else. A hire car's pick-up and
* return are points ON the road: the drive runs through them, it just starts or ends
* there.
*/ */
export interface CarrierTerminal { export interface CarrierTerminal {
reservationId: number; reservationId: number;
/** flight | train | ferry | cruise | bus, the booking's own type. */ /** flight | train | ferry | cruise | bus for a ride, car for a hire car. */
type: string; type: string;
role: 'departure' | 'arrival'; role: 'departure' | 'arrival' | 'pickup' | 'return';
/** The booking's title, which is what the rail prints on the ride. */ /** The booking's title, which is what the rail prints on the ride. */
title: string; title: string;
/** IATA code or station code, when the booking carries one. */ /** IATA code or station code, when the booking carries one. */
+10 -2
View File
@@ -286,14 +286,22 @@ See [Accommodations](Accommodations#on-the-route) for the details.
A **Flight**, **Train**, **Ferry**, **Cruise** or **Bus** booking on a day is a seam in the drive: the road ends at the terminal the booking leaves from and starts again at the one it lands at. Nothing is routed across the ride, so a flight from Hamburg to Munich no longer turns into an eight-hour drive on the road trip map. A **Flight**, **Train**, **Ferry**, **Cruise** or **Bus** booking on a day is a seam in the drive: the road ends at the terminal the booking leaves from and starts again at the one it lands at. Nothing is routed across the ride, so a flight from Hamburg to Munich no longer turns into an eight-hour drive on the road trip map.
- The two terminals appear on the day card as their own rows, with the booking's icon instead of a number and the timetable under the name (*Departure 13:20*, *Arrival 14:30*). Between them sits the ride: the booking and how long it takes. All three open the booking. - A ride that leaves and lands on the same day is one block on the day card: the booking and how long it takes at the top, then the terminal it leaves from and the one it lands at, each with the timetable under its name (*Departure 13:20*, *Arrival 14:30*) and the time the drive has you there on the right. The block opens the booking. A ride that lands on a later day shows its departure at the end of one day and its arrival at the start of the next, each as a row of its own.
- The departure terminal is pinned a check-in ahead of the timetable (an hour for a flight or a cruise, half an hour for a ferry, ten minutes for a train or a bus). A drive that cannot get there by then is late, the way a missed pinned time is. The arrival terminal is pinned at the timetable's arrival, and the drive continues from it. - The departure terminal is pinned a check-in ahead of the timetable (an hour for a flight or a cruise, half an hour for a ferry, ten minutes for a train or a bus). A drive that cannot get there by then is late, the way a missed pinned time is. The arrival terminal is pinned at the timetable's arrival, and the drive continues from it.
- The map draws the ride as the booking's own line, the same arc the day plan draws, beside the roads. - The map draws the ride as the booking's own line, the same arc the day plan draws, beside the roads.
- The ride's hours and kilometres are the booking's, not the drive's: the day's distance and driving time count the roads only, the range budget spends nothing on a flight, and after a ride the car cannot take (a flight, a cruise, a bus) the range starts afresh, because the next road is driven in another car. A car ferry or a motorail keeps the budget. - The ride's hours and kilometres are the booking's, not the drive's: the day's distance and driving time count the roads only, the range budget spends nothing on a flight, and after a ride the car cannot take (a flight, a cruise, a bus) the range starts afresh, because the next road is driven in another car. A car ferry or a motorail keeps the budget.
- A booking that lands on a later day seats its arrival at the start of that day. With **Connect the days** on, the join between the two days is the ride. With daily travel times set, the ride runs on the booking's clock: no day ends at the airport and no morning resumes on the plane. - A booking that lands on a later day seats its arrival at the start of that day. With **Connect the days** on, the join between the two days is the ride. With daily travel times set, the ride runs on the booking's clock: no day ends at the airport and no morning resumes on the plane.
- The terminals sit where the day plan lists the booking: a position you dragged it to under Days, otherwise behind the last stop whose time is before the departure, otherwise at the end of the day. Move the booking under Days to move the seam. - The terminals sit where the day plan lists the booking: a position you dragged it to under Days, otherwise behind the last stop whose time is before the departure, otherwise at the end of the day. Move the booking under Days to move the seam.
What the seam does not do: it never plans the ride itself, and a booking without located terminals (an airport TREK could not place, a train with no station coordinates) makes no seam and is left to the day plan. Via points and other ways of driving are offered for the road into a departure terminal, not for the ride or for the road out of an arrival, and the search along the route skips the ride. A hire car, a taxi or a transit hop is not a seam: you stay on the road. What the seam does not do: it never plans the ride itself, and a booking without located terminals (an airport TREK could not place, a train with no station coordinates) makes no seam and is left to the day plan. Via points and other ways of driving are offered for the road into a departure terminal, not for the ride or for the road out of an arrival, and the search along the route skips the ride. A taxi or a transit hop is not a seam: you stay on the road.
### Hire cars
A **Car** booking is not a seam, you drive it, but its desks are where the drive starts and ends. The pick-up desk stands on the day card as a row with the car icon and *Pick-up 09:00* under its name, and it opens the day when nothing timed comes before it. The return desk closes the day it is handed back on, with *Return 11:30*. The road runs through both like any other stop, the range budget starts from a full tank at the pick-up, and both rows open the booking. The return only appears when the booking names a return day and a located return desk; nothing is guessed.
### Other bookings on the day
A table, tickets or a tour booked on the day appear as chips under the stop they belong to: the stop the booking is pinned to, or the stop at the booking's place on that day. A chip shows the booking's icon, its name and the time it starts, and opens the booking. A booking on the day that belongs to no stop is listed under *Also booked this day* at the end of the card, so nothing you booked goes unseen in road trip mode. Nights are the stays, and rides and hire cars have their own rows, so none of them become chips.
## Car bookings with stops ## Car bookings with stops