diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx index 56feadc93..432498d43 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx @@ -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({ui}) @@ -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() 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() + wrap() + // 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() + 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 => + ({ 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() + 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() + 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() + }) }) diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index f92b22bbf..3448450ec 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -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 = { @@ -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 +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 ( + + + + {stop.name} + {stop.carrier!.code ? ( + {stop.carrier!.code} + ) : null} + + + {line ? {line} : null} + {late.map(w => )} + + + {entry?.arrival ? : null} + + ) +} + +/** + * 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 = ( - <> - - - {rideText(carrier, seg)} + return ( + ) +} + +/** + * 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 (
- - + + {continues ? : null} -
- {onOpen ? ( - - - - ) : ( -
- {band} -
- )} +
+ {bookings.map(r => { + const Icon = bookingIcon(r.type) + const clock = bookingClock(r) + const body = ( + <> + + {r.title} + {clock ? {formatClockTime(clock, is12h)} : 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 ? ( + + + + ) : ( + {body} + ) + })}
) @@ -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 (
  • @@ -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 (
  • - {ride ? ( - - ) : i < last && (!next?.automaticNight || day.legs[i]?.distance !== 0) ? ( - + + ) : ( + + )} + {tail < last && (!after?.automaticNight || day.legs[tail]?.distance !== 0) ? ( + ) : null} - {i < last && !ride ? (day.legVias[i] ?? []).map((via, vi) => ( + {tail < last ? (day.legVias[tail] ?? []).map((via, vi) => ( )) : null}
  • @@ -1578,6 +1695,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} /> )} + {bookings.atStop.has(i) ? ( + + ) : null} {i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? ( ) ))} + {/* 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 ? ( +
  • +
    + + {t('roadtrip.bookings.loose')} +
    + +
  • + ) : null} {picking ? ( = { 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 }) } /** diff --git a/client/src/components/Roadtrip/roadtripRowModel.test.ts b/client/src/components/Roadtrip/roadtripRowModel.test.ts index 070800139..26c3f3555 100644 --- a/client/src/components/Roadtrip/roadtripRowModel.test.ts +++ b/client/src/components/Roadtrip/roadtripRowModel.test.ts @@ -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', () => { diff --git a/client/src/components/Roadtrip/roadtripRowModel.ts b/client/src/components/Roadtrip/roadtripRowModel.ts index 9778b114c..5f55f0684 100644 --- a/client/src/components/Roadtrip/roadtripRowModel.ts +++ b/client/src/components/Roadtrip/roadtripRowModel.ts @@ -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 } diff --git a/client/src/components/Roadtrip/stopBookings.test.ts b/client/src/components/Roadtrip/stopBookings.test.ts new file mode 100644 index 000000000..938f30338 --- /dev/null +++ b/client/src/components/Roadtrip/stopBookings.test.ts @@ -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 => + ({ 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() + }) +}) diff --git a/client/src/components/Roadtrip/stopBookings.ts b/client/src/components/Roadtrip/stopBookings.ts new file mode 100644 index 000000000..86f7e4ed7 --- /dev/null +++ b/client/src/components/Roadtrip/stopBookings.ts @@ -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 + /** 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[] }, + reservations: readonly Reservation[], +): DayBookings { + if (!reservations.length) return EMPTY + const atStop = new Map() + 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)[type] ?? Ticket +} + +/** The clock the booking starts at, or null when it names none. */ +export function bookingClock(r: Pick): string | null { + return splitReservationDateTime(r.reservation_time).time || null +} diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts index de0bed182..08e033f98 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts @@ -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) + }) }) diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx index 724b85b78..96d9f0b85 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx @@ -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 = { 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 ( + + + + {row.stop.name} + {carrier.code && {carrier.code}} + + {line && {line}} + + {row.time && ( + + {formatClockTime(row.time, chrome.is12h)} + + )} + + ) +} + /** - * 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 + chrome: RowChrome onOpen?: () => void }) { - const Icon = carrierIcon(carrier.type) + const Icon = carrierIcon(row.carrier.type) const body = ( <> -