mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -8,7 +8,7 @@ import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRou
|
||||
import type { DryPoint } from './roadtripModel'
|
||||
import type { RefuelSearch } from './useRefuelSearch'
|
||||
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>)
|
||||
|
||||
@@ -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 })} />)
|
||||
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
@@ -1144,16 +1144,107 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-050: a terminal and the ride open the booking; the road out of the arrival offers no other ways', () => {
|
||||
const onOpenCarrier = vi.fn()
|
||||
it('FE-ROADTRIP-SIDEBAR-050: the ride block opens the booking; the road out of the arrival offers no other ways', () => {
|
||||
const onOpenBooking = 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('LH 2020 · 1 h 10 min'))
|
||||
expect(onOpenCarrier).toHaveBeenCalledTimes(2)
|
||||
expect(onOpenCarrier).toHaveBeenCalledWith(70)
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(2)
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(70)
|
||||
// One shuffle: the road into the departure terminal. Not the ride, not the road out
|
||||
// of the arrival, whose via would be filed at the terminal's index.
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
|
||||
})
|
||||
|
||||
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 { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
CarFront, Footprints, Bike, Zap, AlertTriangle,
|
||||
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 AutomaticDayStop from './AutomaticDayStop'
|
||||
import { carrierIcon, rideText, terminalLine } from './carrierRide'
|
||||
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
|
||||
import type { Reservation } from '../../types'
|
||||
import type { StayDraft } from './RoadtripStayModal'
|
||||
import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading'
|
||||
|
||||
@@ -104,10 +106,15 @@ interface RoadtripSidebarProps {
|
||||
/** Absent leaves every card open and its header unclickable. */
|
||||
onToggleDay?: (dayId: number) => void
|
||||
/**
|
||||
* Opens the booking a terminal or a ride stands for (#2428). Absent leaves the
|
||||
* terminal rows and the ride pills as plain text.
|
||||
* The trip's bookings, for the rail to hang under the stops they are for (#2428).
|
||||
* 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> = {
|
||||
@@ -823,48 +830,135 @@ function TerminalStop({ stop, entry, late, selected, continues, starts, onOpen }
|
||||
}
|
||||
|
||||
/**
|
||||
* The ride between two terminals, where a drive band would be.
|
||||
*
|
||||
* The booking and its minutes, no distance and no other ways: a flight has none. It opens
|
||||
* the booking, the same thing the terminals do, so everything about the ride is one tap
|
||||
* away from the chain.
|
||||
* 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
|
||||
* there. The same three things the lone terminal row shows, without a row of its own.
|
||||
*/
|
||||
function RideBand({ carrier, seg, onOpen }: {
|
||||
carrier: NonNullable<RoadtripStop['carrier']>
|
||||
function RideEnd({ stop, entry, late }: {
|
||||
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
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const carrier = departure.carrier!
|
||||
const Icon = carrierIcon(carrier.type)
|
||||
const band = (
|
||||
<>
|
||||
<Icon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
|
||||
{rideText(carrier, seg)}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
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 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 (
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<span className="relative z-[1] flex flex-col items-center" aria-hidden>
|
||||
<span className="flex-1" style={RAIL_DASH} />
|
||||
<span className="flex flex-col items-center" aria-hidden>
|
||||
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" /> : null}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
{onOpen ? (
|
||||
<Tooltip label={t('roadtrip.ride.open')}>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{band}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<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">
|
||||
{band}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-wrap gap-1 px-1.5 pb-1.5">
|
||||
{bookings.map(r => {
|
||||
const Icon = bookingIcon(r.type)
|
||||
const clock = bookingClock(r)
|
||||
const body = (
|
||||
<>
|
||||
<Icon size={10} strokeWidth={2} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate">{r.title}</span>
|
||||
{clock ? <span className="shrink-0 font-medium tabular-nums text-content-faint">{formatClockTime(clock, is12h)}</span> : null}
|
||||
</>
|
||||
)
|
||||
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'
|
||||
return onOpen ? (
|
||||
<Tooltip key={r.id} label={t('roadtrip.ride.open')}>
|
||||
<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>
|
||||
)
|
||||
@@ -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
|
||||
* `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']
|
||||
day: RoadtripDay
|
||||
/** Folded down to the header, and off the map with it. */
|
||||
@@ -1425,7 +1519,8 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
|
||||
drag: DragState
|
||||
onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives']
|
||||
openAlternatives?: RoadtripSidebarProps['openAlternatives']
|
||||
onOpenCarrier?: RoadtripSidebarProps['onOpenCarrier']
|
||||
onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
|
||||
reservations?: Reservation[]
|
||||
onEditStay?: RoadtripSidebarProps['onEditStay']
|
||||
onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
|
||||
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
|
||||
// popovers over the same rail is two answers to one question.
|
||||
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 { t, language } = useTranslation()
|
||||
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
|
||||
* 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 (
|
||||
<li key={stop.assignmentId}>
|
||||
<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
|
||||
// overnight crossing swallow the "you arrive late" flag without a trace, and a stop
|
||||
// can be late for the time it is reached and the time it is left at both at once.
|
||||
const lateness = day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
|
||||
// A terminal: not draggable, not numbered, not a place. Between a departure and its
|
||||
// arrival the leg is the ride, drawn as the booking; behind an arrival the road goes
|
||||
// on as usual, without other ways to drive it (`legReroutable`).
|
||||
const lateness = latenessAt(i)
|
||||
// A terminal or a hire car's desk: not draggable, not numbered, not a place. A ride
|
||||
// that lands on the day it left is one block from its departure to its arrival, and
|
||||
// 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) {
|
||||
const prev = day.stops[i - 1]
|
||||
const next = day.stops[i + 1]
|
||||
const ride = stop.carrier.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId
|
||||
const open = onOpenCarrier ? () => onOpenCarrier(stop.carrier!.reservationId) : undefined
|
||||
const sameRide = (other: RoadtripStop | undefined): boolean => other?.carrier?.reservationId === stop.carrier!.reservationId
|
||||
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 (
|
||||
<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 ? (
|
||||
<RideBand carrier={stop.carrier} seg={day.legs[i]} onOpen={open} />
|
||||
) : i < last && (!next?.automaticNight || day.legs[i]?.distance !== 0) ? (
|
||||
<DriveBand leg={day.legs[i]} />
|
||||
<RideBlock
|
||||
departure={stop}
|
||||
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}
|
||||
{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} />
|
||||
)) : null}
|
||||
</li>
|
||||
@@ -1578,6 +1695,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
|
||||
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) ? (
|
||||
<DriveBand
|
||||
leg={day.legs[i]}
|
||||
@@ -1789,6 +1909,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
|
||||
</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>
|
||||
{picking ? (
|
||||
<StopKindPicker
|
||||
@@ -1889,7 +2021,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
|
||||
* them is a pill just for being a number.
|
||||
*/
|
||||
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,
|
||||
collapsedDayIds, onToggleDay, onFocusPoint,
|
||||
}: RoadtripSidebarProps): React.ReactElement {
|
||||
@@ -1947,7 +2079,8 @@ export default function RoadtripSidebar({
|
||||
drag={drag}
|
||||
onAskAlternatives={onAskAlternatives}
|
||||
openAlternatives={openAlternatives}
|
||||
onOpenCarrier={onOpenCarrier}
|
||||
onOpenBooking={onOpenBooking}
|
||||
reservations={reservations}
|
||||
onEditStay={onEditStay}
|
||||
onSetStopKind={onSetStopKind}
|
||||
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 { formatClockTime } from '../../utils/formatters'
|
||||
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
/**
|
||||
* How a ride and its two terminals read on the rail and in the phone chain (#2428).
|
||||
* 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
|
||||
* 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,
|
||||
cruise: Ship,
|
||||
bus: Bus,
|
||||
car: Car,
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!carrier.at) return null
|
||||
const time = formatClockTime(carrier.at, is12h)
|
||||
return carrier.role === 'departure' ? t('roadtrip.ride.departure', { time }) : t('roadtrip.ride.arrival', { time })
|
||||
return t(`roadtrip.ride.${carrier.role}`, { time })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -536,23 +536,50 @@ describe('roadtripRows with a ride (#2428)', () => {
|
||||
legs: [seg(0), ride, seg(2)],
|
||||
})
|
||||
|
||||
it('FE-RTROW-045: the terminals are stop rows without a number, and the numbering skips them', () => {
|
||||
const rows = stopRows(roadtripRows(flightDay()))
|
||||
expect(rows.map(r => [r.stop.name, r.number])).toEqual([
|
||||
it('FE-RTROW-045: a same-day ride is one row holding both terminals, unnumbered, and the numbering skips them', () => {
|
||||
const rows = roadtripRows(flightDay())
|
||||
expect(stopRows(rows).map(r => [r.stop.name, r.number])).toEqual([
|
||||
['Bremen', 1],
|
||||
['Munich Airport', null],
|
||||
['Hamburg Airport', null],
|
||||
['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)
|
||||
})
|
||||
|
||||
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())
|
||||
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'ride', 'stop', 'leg', 'stop'])
|
||||
const rideRow = rows[3]
|
||||
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'ride', 'leg', 'stop'])
|
||||
const rideRow = rows[2]
|
||||
expect(rideRow.kind === 'ride' && rideRow.carrier.title).toBe('LH 2020')
|
||||
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', () => {
|
||||
|
||||
@@ -22,10 +22,11 @@ export type RoadtripRow =
|
||||
| StopRow
|
||||
| { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null }
|
||||
/**
|
||||
* The ride between a departure terminal and its arrival (#2428): the booking, not a
|
||||
* drive. Drawn in place of the leg row, with the booking's minutes and no distance.
|
||||
* A ride that leaves and lands on the day (#2428), as one row: the booking with both
|
||||
* 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: '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
|
||||
if (automatic) {
|
||||
rows.push({
|
||||
@@ -160,28 +168,44 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
|
||||
phase: stop.automaticNight?.phase === 'start' ? 'resume' : 'end',
|
||||
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 {
|
||||
// A terminal is where the drive stops or resumes, not a place the trip is for: it
|
||||
// carries no number, the way a service stop carries none.
|
||||
// A terminal or a hire car's desk is where the drive stops or resumes, not a
|
||||
// place the trip is for: it carries no number, the way a service stop carries none.
|
||||
const unnumbered = isServiceStopType(stop.stopType) || !!stop.carrier
|
||||
if (!unnumbered) number += 1
|
||||
rows.push(stopRow(stop, i, unnumbered ? null : number, day.schedule, day.driveWarnings))
|
||||
}
|
||||
|
||||
// The leg AFTER this stop, plus the dry point that falls on it. Both belong
|
||||
// between two stops, so they are emitted here rather than in their own pass.
|
||||
pushLeg(i)
|
||||
})
|
||||
|
||||
// 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 next = day.stops[i + 1]
|
||||
if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) {
|
||||
rows.push({ kind: 'ride', index: i, carrier: stop.carrier, seg })
|
||||
} else if (i < day.stops.length - 1 && !isStationary(seg)) {
|
||||
if (i < day.stops.length - 1 && !isStationary(seg)) {
|
||||
rows.push({ kind: 'leg', index: i, seg, mode: day.stops[i + 1]?.incomingLegMode ?? stop.legMode ?? null })
|
||||
const dry = (day.dryPoints ?? []).find(p => p.legIndex === i)
|
||||
if (dry) {
|
||||
rows.push({ kind: 'dry', legIndex: i, intoLegKm: dry.intoLegKm, sinceKm: dry.sinceKm })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
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))
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
const unlocated = flight({ endpoints: [] })
|
||||
const hireCar = flight({ id: 71, type: 'car' })
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [unlocated, hireCar]))
|
||||
const taxi = flight({ id: 71, type: 'taxi' })
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [unlocated, taxi]))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[0].stops.every(s => !s.carrier)).toBe(true)
|
||||
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 { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
|
||||
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 { formatDistance } from '../../../../utils/units'
|
||||
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 { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
|
||||
import type { CarrierTerminal, DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
|
||||
import type { TranslationFn } from '../../../../types'
|
||||
import type { DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
|
||||
import type { Reservation, TranslationFn } from '../../../../types'
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
|
||||
/** 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
|
||||
* minutes, no distance and no other ways of driving it. The pill is the tap target and
|
||||
* opens the booking, the same sheet the map's endpoint badge opens.
|
||||
* A ride that leaves and lands on the day, as one block in the chain (#2428): the
|
||||
* booking and its minutes at the top, the terminal it leaves from, the terminal it lands
|
||||
* 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 }: {
|
||||
carrier: CarrierTerminal
|
||||
seg: RouteSegment | undefined
|
||||
export function RtRideRow({ row, chrome, onOpen }: {
|
||||
row: Extract<RoadtripRow, { kind: 'ride' }>
|
||||
chrome: RowChrome
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
const Icon = carrierIcon(carrier.type)
|
||||
const Icon = carrierIcon(row.carrier.type)
|
||||
const body = (
|
||||
<>
|
||||
<Icon size={14} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
|
||||
<span className="truncate text-[0.75rem] font-semibold tabular-nums text-m-ink">{rideText(carrier, seg)}</span>
|
||||
<span className="flex items-center gap-[6px] text-m-muted">
|
||||
<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 (
|
||||
<div className="grid items-center gap-x-[10px]" style={{ gridTemplateColumns: '34px 1fr auto' }}>
|
||||
<span className="flex min-h-[40px] flex-col items-center" aria-hidden="true">
|
||||
<span className="w-[2px] flex-1" style={{ backgroundImage: 'repeating-linear-gradient(var(--m-conn) 0 4px, transparent 4px 8px)' }} />
|
||||
<div className="grid items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr' }}>
|
||||
<span className="flex justify-center">
|
||||
<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>
|
||||
{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">
|
||||
{body}
|
||||
</button>
|
||||
<button type="button" onClick={onOpen} className={cls}>{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef } from 'react'
|
||||
import { Fragment, useMemo, useRef } from 'react'
|
||||
import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
|
||||
import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
|
||||
import { showStopOnMap, useMRoadtrip } from './useMRoadtrip'
|
||||
@@ -6,7 +6,7 @@ import { useMRtCorridor } from './useMRtCorridor'
|
||||
import { useMRtAlternatives } from './useMRtAlternatives'
|
||||
import MRtCorridorBar from './MRtCorridorBar'
|
||||
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 MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
|
||||
@@ -17,6 +17,8 @@ import { formatClockTime } from '../../../../utils/formatters'
|
||||
import { isRtlLanguage } from '../../../../i18n'
|
||||
import type { MTripTabPanelProps } from '../MTripShell'
|
||||
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.
|
||||
@@ -82,6 +84,26 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
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
|
||||
// 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
|
||||
@@ -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">
|
||||
{rt.rows.map((row, i) => {
|
||||
if (row.kind === 'stop') {
|
||||
const chips = bookings.atStop.get(row.stop.assignmentId)
|
||||
return (
|
||||
<RtStopRow
|
||||
key={`s${i}`}
|
||||
row={row}
|
||||
chrome={chrome}
|
||||
onOpen={() => openStop(row)}
|
||||
onPickKind={canEditPlaces && !row.stop.carrier
|
||||
? () => shell.openSheet('rtkind', {
|
||||
placeId: row.stop.placeId,
|
||||
stopType: row.stop.stopType ?? null,
|
||||
name: row.stop.name,
|
||||
})
|
||||
: undefined}
|
||||
/>
|
||||
<Fragment key={`s${i}`}>
|
||||
<RtStopRow
|
||||
row={row}
|
||||
chrome={chrome}
|
||||
onOpen={() => openStop(row)}
|
||||
onPickKind={canEditPlaces && !row.stop.carrier
|
||||
? () => shell.openSheet('rtkind', {
|
||||
placeId: row.stop.placeId,
|
||||
stopType: row.stop.stopType ?? null,
|
||||
name: row.stop.name,
|
||||
})
|
||||
: undefined}
|
||||
/>
|
||||
{chips && <RtBookingChips bookings={chips} chrome={chrome} onOpen={openBooking} />}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'leg') {
|
||||
@@ -259,7 +284,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
)
|
||||
}
|
||||
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 === '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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -599,9 +599,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onFocusPoint={focusRoadtripPoint}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)}
|
||||
onOpenCarrier={(rid) => {
|
||||
reservations={reservations}
|
||||
onOpenBooking={(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}
|
||||
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDay
|
||||
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
|
||||
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
|
||||
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
|
||||
import { carrierReservationIds } from '@trek/shared/roadtrip'
|
||||
import { carrierReservationIds, type CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
||||
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
|
||||
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,
|
||||
// so a via anchored to it would be stored at a position that belongs to the stop
|
||||
// after it and bend that stop's road instead. The callers decide what to refuse.
|
||||
let best: { dayId: number; afterIndex: number; offRouteKm: number; terminal: 'departure' | 'arrival' | null } | null = null
|
||||
let best: { dayId: number; afterIndex: number; offRouteKm: number; terminal: CarrierTerminal['role'] | null } | null = null
|
||||
for (const day of roadtripRoutes.days) {
|
||||
// NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored
|
||||
// on, but that day's stops are no longer all on the card of the same name: after a
|
||||
@@ -1737,8 +1737,8 @@ export function useTripPlanner() {
|
||||
*/
|
||||
const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => {
|
||||
const anchor = anchorFor(lat, lng)
|
||||
// Nothing is stopped at on a flight. Behind an arrival terminal is a road, and a stop
|
||||
// there is the first stop after landing.
|
||||
// Nothing is stopped at on a flight. Behind an arrival terminal or a hire car's desk
|
||||
// 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
|
||||
for (const day of roadtripRoutes.days) {
|
||||
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
|
||||
* its terminals and the slots the day plan gave it. The reservation service reads the
|
||||
* same three tables for the booking list; this is the road trip's own cut of them, so
|
||||
* the module stays out of the reservations graph.
|
||||
* The bookings that seam the drive, a flight, train, ferry, cruise or bus on a day, and
|
||||
* the hire cars whose desks stand on it, with their terminals and the slots the day
|
||||
* plan gave them. The reservation service reads the same three tables for the booking
|
||||
* list; this is the road trip's own cut of them, so the module stays out of the
|
||||
* reservations graph.
|
||||
*/
|
||||
private carriers(tripId: number): CarrierBooking[] {
|
||||
const rows = this.db.all<CarrierRow>(
|
||||
`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,
|
||||
);
|
||||
if (!rows.length) return [];
|
||||
@@ -171,7 +172,7 @@ export class RoadtripPlanService {
|
||||
);
|
||||
if (preferences.roadtrip_day_start && preferences.roadtrip_day_end && !window)
|
||||
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
|
||||
const 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 plan: PlanDay[] = context.days.map((day) => {
|
||||
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({
|
||||
name: 'get_roadtrip_context',
|
||||
description:
|
||||
'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, 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() },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
@@ -41,7 +41,7 @@ export class RoadtripPlanningMcp {
|
||||
@Tool({
|
||||
name: 'calculate_roadtrip',
|
||||
description:
|
||||
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. 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,
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
|
||||
@@ -172,15 +172,57 @@ describe('a booking the traveller rides (#2428)', () => {
|
||||
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 day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
|
||||
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id) VALUES (?, 'Hire car', 'car', ?)").run(trip.id, day.day_id);
|
||||
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Somewhere', 'train', ?, '11:00')").run(trip.id, day.day_id);
|
||||
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);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,6 +266,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'اجعلها استراحة',
|
||||
'roadtrip.ride.departure': 'المغادرة {time}',
|
||||
'roadtrip.ride.arrival': 'الوصول {time}',
|
||||
'roadtrip.ride.pickup': 'الاستلام {time}',
|
||||
'roadtrip.ride.return': 'الإرجاع {time}',
|
||||
'roadtrip.ride.open': 'فتح الحجز',
|
||||
'roadtrip.bookings.loose': 'محجوز أيضًا في هذا اليوم',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -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.releaseBookedBody': 'A noite em “{name}” será removida juntamente com a reserva “{booking}” e qualquer despesa associada. A paragem continua na viagem como pausa.',
|
||||
'roadtrip.stay.releaseAction': 'Tornar uma pausa',
|
||||
'roadtrip.ride.departure': 'Loc\'hañ {time}',
|
||||
'roadtrip.ride.arrival': 'Erruout {time}',
|
||||
'roadtrip.ride.open': 'Digeriñ ar mirout',
|
||||
'roadtrip.ride.departure': 'Partida {time}',
|
||||
'roadtrip.ride.arrival': 'Chegada {time}',
|
||||
'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;
|
||||
|
||||
@@ -274,6 +274,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Converteix en pausa',
|
||||
'roadtrip.ride.departure': 'Sortida {time}',
|
||||
'roadtrip.ride.arrival': 'Arribada {time}',
|
||||
'roadtrip.ride.pickup': 'Recollida {time}',
|
||||
'roadtrip.ride.return': 'Devolució {time}',
|
||||
'roadtrip.ride.open': 'Obre la reserva',
|
||||
'roadtrip.bookings.loose': 'També reservat aquest dia',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -269,6 +269,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Změnit na pauzu',
|
||||
'roadtrip.ride.departure': 'Odjezd {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.bookings.loose': 'Také rezervováno na tento den',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -268,6 +268,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Als Pause behalten',
|
||||
'roadtrip.ride.departure': 'Abfahrt {time}',
|
||||
'roadtrip.ride.arrival': 'Ankunft {time}',
|
||||
'roadtrip.ride.pickup': 'Abholung {time}',
|
||||
'roadtrip.ride.return': 'Rückgabe {time}',
|
||||
'roadtrip.ride.open': 'Buchung öffnen',
|
||||
'roadtrip.bookings.loose': 'Außerdem an diesem Tag gebucht',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -266,6 +266,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Make it a pause',
|
||||
'roadtrip.ride.departure': 'Departure {time}',
|
||||
'roadtrip.ride.arrival': 'Arrival {time}',
|
||||
'roadtrip.ride.pickup': 'Pick-up {time}',
|
||||
'roadtrip.ride.return': 'Return {time}',
|
||||
'roadtrip.ride.open': 'Open booking',
|
||||
'roadtrip.bookings.loose': 'Also booked this day',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Convertir en pausa',
|
||||
'roadtrip.ride.departure': 'Salida {time}',
|
||||
'roadtrip.ride.arrival': 'Llegada {time}',
|
||||
'roadtrip.ride.pickup': 'Recogida {time}',
|
||||
'roadtrip.ride.return': 'Devolución {time}',
|
||||
'roadtrip.ride.open': 'Abrir reserva',
|
||||
'roadtrip.bookings.loose': 'También reservado este día',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'En faire une pause',
|
||||
'roadtrip.ride.departure': 'Départ {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.bookings.loose': 'Également réservé ce jour',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -275,6 +275,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Κάνε το παύση',
|
||||
'roadtrip.ride.departure': 'Αναχώρηση {time}',
|
||||
'roadtrip.ride.arrival': 'Άφιξη {time}',
|
||||
'roadtrip.ride.pickup': 'Παραλαβή {time}',
|
||||
'roadtrip.ride.return': 'Επιστροφή {time}',
|
||||
'roadtrip.ride.open': 'Άνοιγμα κράτησης',
|
||||
'roadtrip.bookings.loose': 'Επίσης κρατημένο αυτή τη μέρα',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -270,6 +270,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Legyen szünet',
|
||||
'roadtrip.ride.departure': 'Indulá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.bookings.loose': 'Erre a napra még lefoglalva',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Jadikan jeda',
|
||||
'roadtrip.ride.departure': 'Berangkat {time}',
|
||||
'roadtrip.ride.arrival': 'Tiba {time}',
|
||||
'roadtrip.ride.pickup': 'Pengambilan {time}',
|
||||
'roadtrip.ride.return': 'Pengembalian {time}',
|
||||
'roadtrip.ride.open': 'Buka pemesanan',
|
||||
'roadtrip.bookings.loose': 'Juga dipesan hari ini',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -273,6 +273,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Rendi una pausa',
|
||||
'roadtrip.ride.departure': 'Partenza {time}',
|
||||
'roadtrip.ride.arrival': 'Arrivo {time}',
|
||||
'roadtrip.ride.pickup': 'Ritiro {time}',
|
||||
'roadtrip.ride.return': 'Riconsegna {time}',
|
||||
'roadtrip.ride.open': 'Apri prenotazione',
|
||||
'roadtrip.bookings.loose': 'Prenotato anche questo giorno',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -268,6 +268,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': '休憩にする',
|
||||
'roadtrip.ride.departure': '出発 {time}',
|
||||
'roadtrip.ride.arrival': '到着 {time}',
|
||||
'roadtrip.ride.pickup': '受取 {time}',
|
||||
'roadtrip.ride.return': '返却 {time}',
|
||||
'roadtrip.ride.open': '予約を開く',
|
||||
'roadtrip.bookings.loose': 'この日のその他の予約',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -266,6 +266,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': '휴식으로 바꾸기',
|
||||
'roadtrip.ride.departure': '출발 {time}',
|
||||
'roadtrip.ride.arrival': '도착 {time}',
|
||||
'roadtrip.ride.pickup': '픽업 {time}',
|
||||
'roadtrip.ride.return': '반납 {time}',
|
||||
'roadtrip.ride.open': '예약 열기',
|
||||
'roadtrip.bookings.loose': '이날의 다른 예약',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -272,6 +272,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Maak er een pauze van',
|
||||
'roadtrip.ride.departure': 'Vertrek {time}',
|
||||
'roadtrip.ride.arrival': 'Aankomst {time}',
|
||||
'roadtrip.ride.pickup': 'Ophalen {time}',
|
||||
'roadtrip.ride.return': 'Inleveren {time}',
|
||||
'roadtrip.ride.open': 'Boeking openen',
|
||||
'roadtrip.bookings.loose': 'Ook geboekt op deze dag',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -270,6 +270,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Zamień na postój',
|
||||
'roadtrip.ride.departure': 'Odjazd {time}',
|
||||
'roadtrip.ride.arrival': 'Przyjazd {time}',
|
||||
'roadtrip.ride.pickup': 'Odbiór {time}',
|
||||
'roadtrip.ride.return': 'Zwrot {time}',
|
||||
'roadtrip.ride.open': 'Otwórz rezerwację',
|
||||
'roadtrip.bookings.loose': 'Zarezerwowane też tego dnia',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -272,6 +272,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Сделать паузой',
|
||||
'roadtrip.ride.departure': 'Отправление {time}',
|
||||
'roadtrip.ride.arrival': 'Прибытие {time}',
|
||||
'roadtrip.ride.pickup': 'Получение {time}',
|
||||
'roadtrip.ride.return': 'Возврат {time}',
|
||||
'roadtrip.ride.open': 'Открыть бронирование',
|
||||
'roadtrip.bookings.loose': 'Также забронировано в этот день',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -269,6 +269,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Gör till en paus',
|
||||
'roadtrip.ride.departure': 'Avgång {time}',
|
||||
'roadtrip.ride.arrival': 'Ankomst {time}',
|
||||
'roadtrip.ride.pickup': 'Upphämtning {time}',
|
||||
'roadtrip.ride.return': 'Återlämning {time}',
|
||||
'roadtrip.ride.open': 'Öppna bokning',
|
||||
'roadtrip.bookings.loose': 'Även bokat denna dag',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -272,6 +272,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Molaya çevir',
|
||||
'roadtrip.ride.departure': 'Kalkış {time}',
|
||||
'roadtrip.ride.arrival': 'Varış {time}',
|
||||
'roadtrip.ride.pickup': 'Alış {time}',
|
||||
'roadtrip.ride.return': 'İade {time}',
|
||||
'roadtrip.ride.open': 'Rezervasyonu aç',
|
||||
'roadtrip.bookings.loose': 'Bu gün için ayrıca rezerve edildi',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -271,6 +271,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Зробити паузою',
|
||||
'roadtrip.ride.departure': 'Відправлення {time}',
|
||||
'roadtrip.ride.arrival': 'Прибуття {time}',
|
||||
'roadtrip.ride.pickup': 'Отримання {time}',
|
||||
'roadtrip.ride.return': 'Повернення {time}',
|
||||
'roadtrip.ride.open': 'Відкрити бронювання',
|
||||
'roadtrip.bookings.loose': 'Також заброньовано цього дня',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -270,6 +270,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': 'Chuyển thành điểm nghỉ',
|
||||
'roadtrip.ride.departure': 'Khởi hành {time}',
|
||||
'roadtrip.ride.arrival': 'Đến nơi {time}',
|
||||
'roadtrip.ride.pickup': 'Nhận xe {time}',
|
||||
'roadtrip.ride.return': 'Trả xe {time}',
|
||||
'roadtrip.ride.open': 'Mở đặt chỗ',
|
||||
'roadtrip.bookings.loose': 'Cũng đã đặt trong ngày này',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -257,6 +257,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': '改為休息',
|
||||
'roadtrip.ride.departure': '出發 {time}',
|
||||
'roadtrip.ride.arrival': '抵達 {time}',
|
||||
'roadtrip.ride.pickup': '取車 {time}',
|
||||
'roadtrip.ride.return': '還車 {time}',
|
||||
'roadtrip.ride.open': '開啟預訂',
|
||||
'roadtrip.bookings.loose': '當天的其他預訂',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -258,6 +258,9 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.releaseAction': '改为休息',
|
||||
'roadtrip.ride.departure': '出发 {time}',
|
||||
'roadtrip.ride.arrival': '到达 {time}',
|
||||
'roadtrip.ride.pickup': '取车 {time}',
|
||||
'roadtrip.ride.return': '还车 {time}',
|
||||
'roadtrip.ride.open': '打开预订',
|
||||
'roadtrip.bookings.loose': '当天的其他预订',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spurFor } from './accessSpur';
|
||||
import { carriesTheCar, isCarrierMode } from './carriers';
|
||||
import { carriesTheCar, isCarrierMode, isPickupStop } from './carriers';
|
||||
import { pointAtMeters } from './corridor';
|
||||
import type { RoadtripDayBoundary } from './day-boundary.schema';
|
||||
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;
|
||||
}
|
||||
// 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];
|
||||
if (leg && isCarrierMode(leg.seg.mode)) {
|
||||
if (!carriesTheCar(leg.seg.mode!)) carryKm = 0;
|
||||
|
||||
@@ -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
|
||||
* lands at (#2428). Pinned here: which bookings count, where their terminals are seated
|
||||
* among the day's stops, what the ride between them is worth to the chain, and that
|
||||
* nothing about a terminal reads as a stored stop.
|
||||
* 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, 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 {
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
carriesTheCar,
|
||||
isCarrierMode,
|
||||
isCarrierStop,
|
||||
isPickupStop,
|
||||
seatCarrierStops,
|
||||
terminalAssignmentId,
|
||||
viasLeaving,
|
||||
@@ -87,8 +90,7 @@ describe('carrierSeam', () => {
|
||||
expect(seam!.departure.position).toBeNull();
|
||||
});
|
||||
|
||||
it('ROADTRIP-CARRIERS-002: a hire car, a taxi and a hop on transit are not seams, and neither is a ride nobody put on a day', () => {
|
||||
expect(carrierSeam(flight({ type: 'car' }))).toBeNull();
|
||||
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: 'taxi' }))).toBeNull();
|
||||
expect(carrierSeam(flight({ type: 'transit' }))).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', () => {
|
||||
it('ROADTRIP-CARRIERS-007: the ride is what the clocks say, across the days the booking spans, and never negative', () => {
|
||||
const same = carrierSeam(flight())!;
|
||||
|
||||
@@ -18,6 +18,15 @@ export function isCarrierType(type: string | null | undefined): boolean {
|
||||
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. */
|
||||
export function isCarrierMode(mode: string | null | undefined): boolean {
|
||||
return isCarrierType(mode);
|
||||
@@ -88,8 +97,15 @@ export interface CarrierSeam {
|
||||
reservationId: number;
|
||||
type: string;
|
||||
title: string;
|
||||
/** A ride seams the drive; a rental only puts its two desks on it. */
|
||||
kind: 'ride' | 'rental';
|
||||
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 {
|
||||
@@ -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.
|
||||
*/
|
||||
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 ?? [])
|
||||
.filter(
|
||||
(e) => typeof e.lat === 'number' && typeof e.lng === 'number' && Number.isFinite(e.lat) && Number.isFinite(e.lng),
|
||||
)
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
|
||||
const from = located.find((e) => e.role === 'from') ?? located[0];
|
||||
const to = [...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1];
|
||||
if (!from || !to || from === to) return null;
|
||||
// A ride needs both ends to say where the drive stops and where it resumes, and takes
|
||||
// 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 first = legs[0];
|
||||
const last = legs[legs.length - 1];
|
||||
const depDayId = first?.dep_day_id ?? booking.day_id ?? null;
|
||||
if (depDayId == null) return null;
|
||||
const arrDayId = last?.arr_day_id ?? booking.end_day_id ?? depDayId;
|
||||
// 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 arrClock = carrierClock(last?.arr_time ?? booking.reservation_end_time);
|
||||
const bookingPosition = (dayId: number): number | null =>
|
||||
@@ -168,6 +199,7 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
|
||||
reservationId: booking.id,
|
||||
type: booking.type,
|
||||
title: booking.title,
|
||||
kind,
|
||||
departure: {
|
||||
dayId: depDayId,
|
||||
position: positionOn(first?.day_positions, depDayId) ?? bookingPosition(depDayId),
|
||||
@@ -177,15 +209,18 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
|
||||
lat: from.lat!,
|
||||
lng: from.lng!,
|
||||
},
|
||||
arrival: {
|
||||
dayId: arrDayId,
|
||||
position: positionOn(last?.day_positions, arrDayId) ?? bookingPosition(arrDayId),
|
||||
clock: arrClock,
|
||||
name: to.name,
|
||||
code: to.code ?? null,
|
||||
lat: to.lat!,
|
||||
lng: to.lng!,
|
||||
},
|
||||
arrival:
|
||||
to && arrDayId != null
|
||||
? {
|
||||
dayId: arrDayId,
|
||||
position: positionOn(last?.day_positions, arrDayId) ?? bookingPosition(arrDayId),
|
||||
clock: arrClock,
|
||||
name: to.name,
|
||||
code: to.code ?? null,
|
||||
lat: to.lat!,
|
||||
lng: to.lng!,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,7 +234,7 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
|
||||
*/
|
||||
export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number | null {
|
||||
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;
|
||||
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;
|
||||
|
||||
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. */
|
||||
@@ -222,12 +262,14 @@ function terminalStop(
|
||||
dayId: number,
|
||||
ownerIndex: number,
|
||||
): 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 depart = parseClock(end.clock);
|
||||
// The departure terminal is pinned a check-in ahead of the timetable and left at the
|
||||
// timetable's minute; the arrival one is pinned at the timetable's minute and left at
|
||||
// once. Neither has a stay of its own.
|
||||
// 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;
|
||||
return {
|
||||
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
|
||||
* 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
|
||||
* 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
|
||||
* measured against; `clockOf` its time in minutes, or null.
|
||||
@@ -283,7 +334,12 @@ export function seatCarrierStops(
|
||||
seams: CarrierSeam[],
|
||||
): RoadtripStop[] {
|
||||
const departing = seams.filter((s) => s.departure.dayId === dayId);
|
||||
const arriving = seams.filter((s) => s.arrival.dayId === dayId && s.departure.dayId !== dayId);
|
||||
// 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;
|
||||
|
||||
type Item =
|
||||
@@ -296,8 +352,8 @@ export function seatCarrierStops(
|
||||
minutes: parseClock(stop.time ?? stop.checkInTime ?? null),
|
||||
}));
|
||||
const ends: { seam: CarrierSeam; role: CarrierTerminal['role']; end: CarrierEnd }[] = [
|
||||
...departing.map((seam) => ({ seam, role: 'departure' as const, end: seam.departure })),
|
||||
...arriving.map((seam) => ({ seam, role: 'arrival' as const, end: seam.arrival })),
|
||||
...departing.map((seam) => ({ seam, role: rolesOf(seam).start, end: seam.departure })),
|
||||
...arriving.map((seam) => ({ seam, role: rolesOf(seam).end, end: seam.arrival! })),
|
||||
];
|
||||
const items: Item[] = [...base];
|
||||
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
|
||||
// plan happens to file it last there too, but read as a drive that puts the
|
||||
// morning's stops on the road before the traveller has landed.
|
||||
const opens = e.role === 'arrival';
|
||||
const opens = opensTheDay(e.role);
|
||||
key =
|
||||
after === -Infinity
|
||||
? opens
|
||||
@@ -344,7 +400,7 @@ export function seatCarrierStops(
|
||||
const emit = (slot: (typeof slots)[number]): void => {
|
||||
const insertAt = out.filter((s) => !s.carrier).length;
|
||||
out.push(terminalStop(slot.seam, slot.role, dayId, insertAt));
|
||||
if (slot.role === 'departure' && slot.seam.arrival.dayId === dayId) {
|
||||
if (slot.role === 'departure' && slot.seam.arrival?.dayId === dayId) {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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[] {
|
||||
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];
|
||||
}
|
||||
|
||||
/** 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';
|
||||
}
|
||||
|
||||
@@ -5,18 +5,21 @@ import type { SpillMark } from './nightSpill';
|
||||
import type { Schedule, ScheduleWarning, DayWarning, DryPoint } from './roadtripModel';
|
||||
|
||||
/**
|
||||
* A terminal of a booking the traveller rides rather than drives: the airport, station or
|
||||
* port a flight, train, ferry, cruise or bus leaves from or lands at.
|
||||
* A point a booking puts on the drive: the airport, station or port a flight, train,
|
||||
* 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
|
||||
* happens in between is the booking's business, not the road's: the leg between the
|
||||
* two carries the ride's minutes and nothing else, and no road is ever asked for it.
|
||||
* For a ride the drive ends at the departure terminal and starts again at the arrival
|
||||
* one; what happens in between is the booking's business, not the road's, and the leg
|
||||
* 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 {
|
||||
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;
|
||||
role: 'departure' | 'arrival';
|
||||
role: 'departure' | 'arrival' | 'pickup' | 'return';
|
||||
/** The booking's title, which is what the rail prints on the ride. */
|
||||
title: string;
|
||||
/** IATA code or station code, when the booking carries one. */
|
||||
|
||||
+10
-2
@@ -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.
|
||||
|
||||
- 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 map draws the ride as the booking's own line, the same arc the day plan draws, beside the roads.
|
||||
- The ride's hours and kilometres are the booking's, not the drive's: the day's distance and driving time count the roads only, the range budget spends nothing on a flight, and after a ride the car cannot take (a flight, a cruise, a bus) the range starts afresh, because the next road is driven in another car. A car ferry or a motorail keeps the budget.
|
||||
- A booking that lands on a later day seats its arrival at the start of that day. With **Connect the days** on, the join between the two days is the ride. With daily travel times set, the ride runs on the booking's clock: no day ends at the airport and no morning resumes on the plane.
|
||||
- The terminals sit where the day plan lists the booking: a position you dragged it to under Days, otherwise behind the last stop whose time is before the departure, otherwise at the end of the day. Move the booking under Days to move the seam.
|
||||
|
||||
What the seam does not do: it never plans the ride itself, and a booking without located terminals (an airport TREK could not place, a train with no station coordinates) makes no seam and is left to the day plan. Via points and other ways of driving are offered for the road into a departure terminal, not for the ride or for the road out of an arrival, and the search along the route skips the ride. A hire car, a taxi or a transit hop is not a seam: you stay on the road.
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user