diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index ecd00f8d9..57354d1d5 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -4,8 +4,7 @@ import { useElementSize } from '../../hooks/useElementSize'
import React, { useMemo, useState } from 'react'
import {
Zap, AlertTriangle,
- ParkingSquare, Clock, Spline, Ban,
- type LucideIcon,
+ ParkingSquare, Spline, Ban,
} from 'lucide-react'
import type { RefuelSearch } from './useRefuelSearch'
import type { RefuelCandidate } from './refuelSuggestion'
@@ -14,11 +13,11 @@ import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
-import { formatDate, formatClockTime } from '../../utils/formatters'
+import { formatDate } from '../../utils/formatters'
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel'
import { STOP_KIND_BY_KEY } from './stopKinds'
-import { arrivingReroutable, bookendReading, destinationCount, legReroutable, movableWithin, resumeFoldsIntoBookend, type BookendReading } from './roadtripRowModel'
-import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge, bookendBooking } from './nightBookend'
+import { arrivingReroutable, bookendReading, destinationCount, legReroutable, movableWithin, resumeFoldsIntoBookend } from './roadtripRowModel'
+import { bookendBooking } from './nightBookend'
import { spurWorthLabelling } from './accessSpur'
import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker'
@@ -31,19 +30,18 @@ import { ARRIVING_DRIVE, openOn, type LegAlternatives, type RailDrive } from './
import { dayColor } from './dayColors'
import type { RouteVia } from '../../types'
import { FS } from './typeScale'
-import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop'
-import { bookingOpens, carrierIcon, missedRide, rideReading, terminalReading, type CheckInReading, type RideEnd } from './carrierRide'
-import FigureBadge from './FigureBadge'
-import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
+import { missedRide } from './carrierRide'
+import { dayBookings } from './stopBookings'
import type { Reservation } from '../../types'
import type { StayDraft } from './RoadtripStayModal'
import { missedLeaveOf, readStay, stayDraftOf } from './stayReading'
import { DAY_BADGE, DISC, RAIL_DASH, RAIL_GRID, STAT_LABEL } from './RoadtripSidebar.constants'
-import { Arrival, DayCarry, DriveFindingBadge, FillBadge, LateBadge, NightCheckIn, OffRoadBadge, StayBadge } from './RoadtripSidebarBadges'
+import { Arrival, DriveFindingBadge, FillBadge, LateBadge, NightCheckIn, OffRoadBadge, StayBadge } from './RoadtripSidebarBadges'
import { RefuelBand } from './RoadtripSidebarRefuelBand'
import { DriveBand, SpillBlock } from './RoadtripSidebarDriveBands'
+import { BookendStop, BookingChips, RideBlock, TerminalStop, UndatedRides } from './RoadtripSidebarBookingRows'
interface RoadtripSidebarProps {
onFocusPoint?: (lat: number, lng: number) => void
@@ -139,12 +137,6 @@ interface RoadtripSidebarProps {
// own copy of each, and its rest_area icon had drifted away from the popup's.
-/** The rail's dash laid on its side: the ride between two terminals, drawn like a leg. */
-const RIDE_DASH: React.CSSProperties = {
- height: 1.5,
- backgroundImage: 'repeating-linear-gradient(90deg, var(--border-primary) 0 4px, transparent 4px 8px)',
-}
-
/**
* Below this width the rail stops counting stops: the trip head drops its third figure and
* the day headers their count, rather than squeezing every badge onto a line too short.
@@ -244,305 +236,6 @@ function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow: boole
)
}
-/**
- * One end of a ride on a row of its own: a terminal whose ride lands on another day, or a
- * hire car's desk.
- *
- * No number, no stay, no kind picker and no drag handle: it is not a stop anybody chose
- * and cannot be moved or turned into anything, it is where the booking puts the
- * traveller. Built from the ride block's pieces: the code, the place, the timetable's clock
- * on the right, and on a departure the check-in with whatever the drive makes of it. A desk
- * has no other end beside it, so it says which one it is. The whole row opens the booking.
- */
-function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
- stop: RoadtripStop
- entry: ScheduleEntry | undefined
- late: ScheduleWarning[]
- continues: boolean
- starts?: boolean
- onOpen?: () => void
-}): React.ReactElement {
- const { t } = useTranslation()
- const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
- const { end, desk, checkIn } = terminalReading(stop, entry, late, t, is12h)
- const place = {end.place}
- return (
-
-
-
-
- {end.code ? : null}
- {onOpen ? {place} : place}
-
- {desk || checkIn ? (
-
- {desk ? {desk} : null}
- {checkIn ? : null}
-
- ) : null}
-
- {end.clock ? : entry?.arrival ? : null}
-
-
- )
-}
-
-/**
- * A row of the rail that is one button on a disc: a terminal, a ride, a booked night.
- * The line runs in from above unless the chain starts here, and on below while it goes on.
- *
- * What pressing it does is said after the row's own content rather than instead of it: an
- * aria-label on the button would be all a screen reader heard, and a ride block holds the
- * clocks and the warning somebody needs to hear first.
- */
-function DiscRow({ Icon, face, starts, continues, onOpen, opens, children }: {
- Icon: LucideIcon
- /** The disc's own colours, for a row that stands for something with a face of its own. */
- face?: React.CSSProperties
- starts?: boolean
- continues: boolean
- onOpen?: () => void
- opens?: string
- children: React.ReactNode
-}): React.ReactElement {
- return (
-
- )
-}
-
-/**
- * A booked night at the edge of the day: the hotel the day sets out from, or the one it
- * ends at (`seatNightBookends`).
- *
- * On the hotel stop's own disc, so the same hotel wears one face whether the day checks
- * in, sets out from it or comes back to it. Otherwise flat, with no number, stay, kind
- * picker or drag handle: it is the stay's place and no stop of the day, so nothing about it
- * is changed here. Under its line the latest hour the room is handed back, on the morning
- * it is, and whatever the drive into it runs over; on the right the time the chain has the
- * traveller there. The whole row opens the booking behind the night, or the hotel's place
- * when there is none to open.
- */
-function BookendStop({ reading, entry, late, driveFindings, continues, starts, onOpen }: {
- reading: BookendReading
- entry: ScheduleEntry | undefined
- late: ScheduleWarning[]
- driveFindings: ScheduleWarning[]
- continues: boolean
- starts?: boolean
- onOpen?: () => void
-}): React.ReactElement {
- const { t } = useTranslation()
- const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
- const badge = bookendBadge(reading, entry, t, is12h)
- return (
-
-
-
-
- {reading.name}
-
-
- {badge.lead}> : badge.lead}
- value={badge.value ?? undefined}
- tooltip={badge.hint ?? undefined}
- />
- {driveFindings.map(w => )}
- {late.map(w => )}
-
-
- {entry?.arrival ? : null}
-
-
- )
-}
-
-/** A terminal's code, or the name of one without it, in the chip the ride's ends stand in. */
-function CodeChip({ text }: { text: string }): React.ReactElement {
- return (
-
- {text}
-
- )
-}
-
-/**
- * The timetable's clock at one end of a ride. Weighted like a pinned arrival because the
- * booking fixes it, and named for what it is: the time a clock in this column otherwise
- * means is when the drive arrives.
- */
-function TimetableClock({ end, className = '' }: { end: RideEnd; className?: string }): React.ReactElement {
- return (
-
-
- {end.clock}
-
- {end.label}
-
-
- )
-}
-
-/**
- * The check-in, as one badge that carries its own finding: on time it reads the hour, late
- * it reads by how much, and too late to catch the ride it says so and when the drive gets
- * there. Measured against the pin and set beside the departure's timetable clock, the old
- * "+9 h 39 min" read as a delay to that clock and never said the ride was gone.
- */
-function CheckInBadge({ checkIn }: { checkIn: CheckInReading }): React.ReactElement {
- const warning = checkIn.state !== 'ok'
- return (
- {checkIn.lead}> : checkIn.lead}
- value={checkIn.value}
- tooltip={checkIn.hint}
- />
- )
-}
-
-/**
- * A ride that leaves and lands on the same day, as one thing in the chain. 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.
- *
- * Four lines, each fact once: the booking; the two codes with their timetable clocks and
- * the ride drawn between them as a leg; the places; and the check-in, which carries the
- * finding when the drive gets there late, beside the minutes the ride takes. A ride the
- * drive cannot catch edges the block in the warning colour. 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[]
- seg: RouteSegment | undefined
- continues: boolean
- starts?: boolean
- onOpen?: () => void
-}): React.ReactElement {
- const { t } = useTranslation()
- const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
- const ride = rideReading({ departure, arrival, entries, warnings: late, seg }, t, is12h)
- const title = {ride.title}
- return (
-
-
-
- {onOpen ? {title} : title}
-
-
-
- {ride.from.clock ? : }
-
- {ride.to.clock ? : }
-
-
- {ride.from.placeLine || ride.to.placeLine ? (
-
- {ride.from.placeLine}
- {ride.to.placeLine}
-
- ) : null}
- {ride.checkIn || ride.duration ? (
-
- {ride.checkIn ? : null}
- {ride.duration ? (
- } value={ride.duration} tooltip={t('roadtrip.ride.duration')} />
- ) : null}
-
- ) : null}
-
-
- )
-}
-
-/**
- * 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, canEdit, onOpen }: {
- bookings: Reservation[]
- continues: boolean
- /** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
- canEdit: boolean
- onOpen?: (reservationId: number) => void
-}): React.ReactElement {
- const { t } = useTranslation()
- const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
- return (
-
- )
-}
-
/**
* A charger, a filling station or a rest stop — a stop that interrupts the drive.
*
@@ -1428,42 +1121,6 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
)
}
-/**
- * The rides the drive leaves out because they are on no day (#2461).
- *
- * The map draws such a booking's arc between its terminals all the same, so without this
- * the ferry looked planned while the rail drove round it by road. One line each, with the
- * booking a click away, since the fix is a date in the booking itself.
- */
-function UndatedRides({ rides, onOpenBooking }: {
- rides: readonly Reservation[]
- onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
-}): React.ReactElement | null {
- const { t } = useTranslation()
- if (!rides.length) return null
- return (
-
- )
-}
-
/**
* The road trip rail: the whole trip as one chain of stops with the driving distance
* and time between them.
diff --git a/client/src/components/Roadtrip/RoadtripSidebarBookingRows.test.tsx b/client/src/components/Roadtrip/RoadtripSidebarBookingRows.test.tsx
new file mode 100644
index 000000000..cbc6d9dee
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripSidebarBookingRows.test.tsx
@@ -0,0 +1,312 @@
+import React from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { TranslationProvider } from '../../i18n'
+import { useSettingsStore } from '../../store/settingsStore'
+import type { CarrierTerminal } from '@trek/shared/roadtrip'
+import type { RoadtripStop } from './useRoadtripRoutes'
+import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
+import type { BookendReading } from './roadtripRowModel'
+import type { Reservation, RouteSegment } from '../../types'
+import { BookendStop, BookingChips, RideBlock, TerminalStop, UndatedRides } from './RoadtripSidebarBookingRows'
+import { tooltipOf } from '../../../tests/helpers/roadtripRail'
+
+const wrap = (ui: React.ReactElement) => render({ui})
+
+function settings(over: { time_format?: '12h' | '24h' } = {}): void {
+ useSettingsStore.setState({ settings: { distance_unit: 'metric', time_format: '24h', ...over } as never })
+}
+
+beforeEach(() => settings())
+afterEach(() => { vi.useRealTimers() })
+
+const carrier = (over: Partial = {}): CarrierTerminal =>
+ ({ reservationId: 7, type: 'flight', role: 'departure', title: 'LH 2078', code: 'HAM', at: '15:15', ...over })
+
+function stop(over: Partial = {}): RoadtripStop {
+ return {
+ assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'Hamburg Airport', lat: 53.6, lng: 10,
+ time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
+ ...over,
+ }
+}
+
+const entry = (over: Partial = {}): ScheduleEntry =>
+ ({ arrival: null, departure: null, anchored: false, dayOffset: 0, ...over })
+
+const warning = (over: Partial & Pick): ScheduleWarning => ({ index: 0, ...over })
+
+const reading = (over: Partial = {}): BookendReading => ({
+ phase: 'morning', variant: 'checkOut', name: 'Hotel Alster', until: '10:00', from: null,
+ reservationId: 31, accommodationId: 4, placeId: 40,
+ ...over,
+})
+
+const booking = (over: Partial & Pick): Reservation =>
+ ({ reservation_time: null, ...over }) as Reservation
+
+/** The rail column of a disc row: the line above, the disc, the line below. */
+const railOf = (row: Element): Element => row.firstElementChild!
+const lines = (row: Element): number => railOf(row).querySelectorAll(':scope > .w-\\[1\\.5px\\]').length
+
+describe('TerminalStop', () => {
+ it('FE-ROADTRIP-BOOKINGROWS-001: a departure on a row of its own reads its code, its timetable clock and the check-in, and opens the booking', () => {
+ const onOpen = vi.fn()
+ const { container } = wrap(
+ ,
+ )
+ const row = screen.getByRole('button')
+ expect(row).toBeEnabled()
+ expect(screen.getByText('HAM')).toBeInTheDocument()
+ expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
+ // The timetable's clock, its day carry and its words for a screen reader.
+ expect(screen.getByText('15:15')).toHaveAttribute('aria-hidden')
+ expect(screen.getByText('+1')).toBeInTheDocument()
+ expect(screen.getByText('Departure 15:15')).toHaveClass('sr-only')
+ expect(screen.getByText('Check-in')).toBeInTheDocument()
+ expect(screen.getByText('13:15')).toBeInTheDocument()
+ expect(container.querySelector('svg.lucide-plane')).not.toBeNull()
+ // What pressing it does comes after the row's own content.
+ expect(row.lastElementChild).toHaveTextContent('Open booking')
+ expect(row.lastElementChild).toHaveClass('sr-only')
+ expect(lines(row)).toBe(2)
+ expect(tooltipOf(screen.getByText('Hamburg Airport'))).toBe('Open booking')
+ fireEvent.click(row)
+ expect(onOpen).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-ROADTRIP-BOOKINGROWS-002: a hire desk says which one it is, falls back to the arrival clock and stays inert without a booking to open', () => {
+ const desk = carrier({ type: 'car', role: 'pickup', code: null, at: null, title: 'Sixt' })
+ const first = wrap(
+ ,
+ )
+ const row = screen.getByRole('button')
+ expect(row).toBeDisabled()
+ expect(screen.getByText('Pickup')).toBeInTheDocument()
+ expect(screen.getByText('09:40')).toBeInTheDocument()
+ expect(screen.queryByText('Open booking')).toBeNull()
+ expect(first.container.querySelector('svg.lucide-car')).not.toBeNull()
+ // The first row of the day on the last stop of it: no line either way.
+ expect(lines(row)).toBe(0)
+ first.unmount()
+
+ // Neither a timetable nor a drive arrival: the clock column stays empty.
+ wrap()
+ expect(screen.getByRole('button')).not.toHaveTextContent(/\d/)
+ })
+})
+
+describe('BookendStop', () => {
+ it('FE-ROADTRIP-BOOKINGROWS-003: leaving after the room is handed back turns the badge into the warning, with the drive findings and lateness beside it', () => {
+ const onOpen = vi.fn()
+ const { container } = wrap(
+ ,
+ )
+ expect(screen.getByText('Hotel Alster')).toBeInTheDocument()
+ expect(screen.getByText('Check-out')).toBeInTheDocument()
+ expect(screen.getByText('10:00')).toBeInTheDocument()
+ expect(container.querySelector('svg.lucide-triangle-alert, svg.lucide-alert-triangle')).not.toBeNull()
+ expect(tooltipOf(screen.getByText('10:00').parentElement!)).toBe('Leaves after check-out')
+ expect(screen.getByText('+45 min')).toBeInTheDocument()
+ expect(screen.getByLabelText('Arrives 20 min after the time you set')).toBeInTheDocument()
+ expect(screen.getByText('12:30')).toBeInTheDocument()
+ // The bed on the hotel's own disc, starting the chain.
+ const row = screen.getByRole('button')
+ expect(container.querySelector('svg.lucide-bed-double')).not.toBeNull()
+ expect(lines(row)).toBe(1)
+ expect(screen.queryByText('Open booking')).toBeNull()
+ fireEvent.click(row)
+ expect(onOpen).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-ROADTRIP-BOOKINGROWS-004: a check-in evening reads the hour the room is ready, plainly, with no clock of its own', () => {
+ const { container } = wrap(
+ ,
+ )
+ expect(screen.getByText('Check-in')).toBeInTheDocument()
+ expect(screen.getByText('15:00')).toBeInTheDocument()
+ expect(container.querySelector('svg.lucide-triangle-alert, svg.lucide-alert-triangle')).toBeNull()
+ expect(screen.getByRole('button')).toBeDisabled()
+ expect(lines(screen.getByRole('button'))).toBe(1)
+ })
+})
+
+describe('RideBlock', () => {
+ const departure = stop({ name: 'Hamburg Airport (HAM)', time: '13:15', carrier: carrier() })
+ const arrival = stop({
+ assignmentId: 2, name: 'Munich Airport', carrier: carrier({ role: 'arrival', code: 'MUC', at: '16:30' }),
+ })
+ const seg = { distance: 600000, duration: 4500, durationText: '1 h 15 min', mode: 'flight' } as RouteSegment
+
+ it('FE-ROADTRIP-BOOKINGROWS-005: a ride on one day is one block: its title, both ends with their clocks, the places, the check-in and the minutes', () => {
+ const onOpen = vi.fn()
+ wrap(
+ ,
+ )
+ const row = screen.getByRole('button')
+ const block = row.children[1]
+ expect(block).toHaveClass('border-edge-faint')
+ expect(screen.getByText('LH 2078')).toBeInTheDocument()
+ expect(screen.getByText('HAM')).toBeInTheDocument()
+ expect(screen.getByText('MUC')).toBeInTheDocument()
+ expect(screen.getByText('Departure 15:15')).toHaveClass('sr-only')
+ expect(screen.getByText('Arrival 16:30')).toHaveClass('sr-only')
+ expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
+ expect(screen.getByText('Munich Airport')).toHaveClass('text-end')
+ expect(screen.getByText('Check-in')).toBeInTheDocument()
+ expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
+ expect(tooltipOf(screen.getByText('1 h 15 min').parentElement!)).toBe('Duration per the booking')
+ expect(tooltipOf(screen.getByText('LH 2078'))).toBe('Open booking')
+ fireEvent.click(row)
+ expect(onOpen).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-ROADTRIP-BOOKINGROWS-006: a ride the drive cannot catch edges the block in the warning colour, its check-in standing alone without minutes', () => {
+ wrap(
+ ,
+ )
+ const row = screen.getByRole('button')
+ expect(row).toBeDisabled()
+ expect(row.children[1]).toHaveClass('border-warning')
+ expect(screen.getByText('Missed')).toBeInTheDocument()
+ expect(screen.getByText('there at 16:15')).toBeInTheDocument()
+ // No minutes on the timetable: the check-in stands alone.
+ expect(screen.getByText('Missed').closest('.flex-wrap')!.children).toHaveLength(1)
+ expect(lines(row)).toBe(0)
+ })
+
+ it('FE-ROADTRIP-BOOKINGROWS-007: a booking with no codes, no clocks and no minutes keeps its strip and drops the lines it has nothing for', () => {
+ wrap(
+ ,
+ )
+ const block = screen.getByRole('button').children[1]
+ // Title and strip only: no place line under chips that already are the names, and no
+ // check-in or minutes row.
+ expect(block.children).toHaveLength(2)
+ const strip = block.children[1]
+ expect(strip.children).toHaveLength(5)
+ expect(strip.children[0]).toHaveTextContent('Kiel')
+ expect(strip.children[1]).toBeEmptyDOMElement()
+ expect(strip.children[3]).toBeEmptyDOMElement()
+ expect(strip.children[4]).toHaveTextContent('Oslo')
+ })
+
+ it('FE-ROADTRIP-BOOKINGROWS-008: the minutes stand alone when the timetable gives no check-in', () => {
+ wrap(
+ ,
+ )
+ expect(screen.queryByText('Check-in')).toBeNull()
+ expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
+ expect(screen.getByText('14:00')).toBeInTheDocument()
+ })
+})
+
+describe('BookingChips', () => {
+ const table = booking({ id: 1, type: 'restaurant', title: 'Fischereihafen', reservation_time: '2026-06-01T19:30' })
+ const train = booking({ id: 2, type: 'train', title: 'ICE 1007' })
+
+ it('FE-ROADTRIP-BOOKINGROWS-009: a chip opens when this reader may open it, and the rest stay plain chips', () => {
+ const onOpen = vi.fn()
+ const { container } = wrap()
+ expect(screen.getByText('19:30')).toBeInTheDocument()
+ // The table has only its editor, which this reader may not open; the train has a view.
+ expect(screen.getByText('Fischereihafen').closest('button')).toBeNull()
+ const ride = screen.getByRole('button')
+ expect(ride).toHaveTextContent('ICE 1007')
+ fireEvent.click(ride)
+ expect(onOpen).toHaveBeenCalledWith(2)
+ expect(container.querySelector('.w-\\[1\\.5px\\]')).not.toBeNull()
+ })
+
+ it('FE-ROADTRIP-BOOKINGROWS-010: an editor opens every chip, and without a way to open any they are all plain', () => {
+ const onOpen = vi.fn()
+ const editor = wrap()
+ expect(screen.getAllByRole('button')).toHaveLength(2)
+ fireEvent.click(screen.getByText('Fischereihafen'))
+ expect(onOpen).toHaveBeenCalledWith(1)
+ expect(editor.container.querySelector('.w-\\[1\\.5px\\]')).toBeNull()
+ editor.unmount()
+
+ wrap()
+ expect(screen.queryByRole('button')).toBeNull()
+ expect(screen.getByText('ICE 1007')).toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-BOOKINGROWS-011: the clock follows the traveller\'s format', () => {
+ settings({ time_format: '12h' })
+ wrap()
+ expect(screen.getByText('7:30 PM')).toBeInTheDocument()
+ })
+})
+
+describe('UndatedRides', () => {
+ const ferry = booking({ id: 9, type: 'ferry', title: 'Stena Line' })
+
+ it('FE-ROADTRIP-BOOKINGROWS-012: nothing on no day draws nothing', () => {
+ const { container } = wrap()
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('FE-ROADTRIP-BOOKINGROWS-013: each ride on no day is a line, with its booking a click away when it can be opened', () => {
+ const onOpenBooking = vi.fn()
+ const open = wrap()
+ expect(screen.getByRole('status')).toHaveTextContent('Stena Line is on none of this trip’s days, so the drive does not use it.')
+ fireEvent.click(screen.getByRole('button', { name: 'Open booking' }))
+ expect(onOpenBooking).toHaveBeenCalledWith(9)
+ open.unmount()
+
+ wrap()
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ expect(screen.queryByRole('button')).toBeNull()
+ })
+})
diff --git a/client/src/components/Roadtrip/RoadtripSidebarBookingRows.tsx b/client/src/components/Roadtrip/RoadtripSidebarBookingRows.tsx
new file mode 100644
index 000000000..1d4dbc3ee
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripSidebarBookingRows.tsx
@@ -0,0 +1,358 @@
+import React from 'react'
+import { AlertTriangle, Clock, type LucideIcon } from 'lucide-react'
+import { useTranslation } from '../../i18n/TranslationContext'
+import { Tooltip } from '../shared/Tooltip'
+import { useSettingsStore } from '../../store/settingsStore'
+import { formatClockTime } from '../../utils/formatters'
+import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
+import type { BookendReading } from './roadtripRowModel'
+import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge } from './nightBookend'
+import type { RoadtripStop } from './useRoadtripRoutes'
+import type { Reservation, RouteSegment } from '../../types'
+import { bookingOpens, carrierIcon, rideReading, terminalReading, type CheckInReading, type RideEnd } from './carrierRide'
+import FigureBadge from './FigureBadge'
+import { bookingClock, bookingIcon } from './stopBookings'
+import { FS } from './typeScale'
+import { DISC, RAIL_GRID } from './RoadtripSidebar.constants'
+import { Arrival, DayCarry, DriveFindingBadge, LateBadge } from './RoadtripSidebarBadges'
+
+/** The rail's dash laid on its side: the ride between two terminals, drawn like a leg. */
+const RIDE_DASH: React.CSSProperties = {
+ height: 1.5,
+ backgroundImage: 'repeating-linear-gradient(90deg, var(--border-primary) 0 4px, transparent 4px 8px)',
+}
+
+/**
+ * One end of a ride on a row of its own: a terminal whose ride lands on another day, or a
+ * hire car's desk.
+ *
+ * No number, no stay, no kind picker and no drag handle: it is not a stop anybody chose
+ * and cannot be moved or turned into anything, it is where the booking puts the
+ * traveller. Built from the ride block's pieces: the code, the place, the timetable's clock
+ * on the right, and on a departure the check-in with whatever the drive makes of it. A desk
+ * has no other end beside it, so it says which one it is. The whole row opens the booking.
+ */
+export function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
+ stop: RoadtripStop
+ entry: ScheduleEntry | undefined
+ late: ScheduleWarning[]
+ continues: boolean
+ starts?: boolean
+ onOpen?: () => void
+}): React.ReactElement {
+ const { t } = useTranslation()
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+ const { end, desk, checkIn } = terminalReading(stop, entry, late, t, is12h)
+ const place = {end.place}
+ return (
+
+
+
+
+ {end.code ? : null}
+ {onOpen ? {place} : place}
+
+ {desk || checkIn ? (
+
+ {desk ? {desk} : null}
+ {checkIn ? : null}
+
+ ) : null}
+
+ {end.clock ? : entry?.arrival ? : null}
+
+
+ )
+}
+
+/**
+ * A row of the rail that is one button on a disc: a terminal, a ride, a booked night.
+ * The line runs in from above unless the chain starts here, and on below while it goes on.
+ *
+ * What pressing it does is said after the row's own content rather than instead of it: an
+ * aria-label on the button would be all a screen reader heard, and a ride block holds the
+ * clocks and the warning somebody needs to hear first.
+ */
+function DiscRow({ Icon, face, starts, continues, onOpen, opens, children }: {
+ Icon: LucideIcon
+ /** The disc's own colours, for a row that stands for something with a face of its own. */
+ face?: React.CSSProperties
+ starts?: boolean
+ continues: boolean
+ onOpen?: () => void
+ opens?: string
+ children: React.ReactNode
+}): React.ReactElement {
+ return (
+
+ )
+}
+
+/**
+ * A booked night at the edge of the day: the hotel the day sets out from, or the one it
+ * ends at (`seatNightBookends`).
+ *
+ * On the hotel stop's own disc, so the same hotel wears one face whether the day checks
+ * in, sets out from it or comes back to it. Otherwise flat, with no number, stay, kind
+ * picker or drag handle: it is the stay's place and no stop of the day, so nothing about it
+ * is changed here. Under its line the latest hour the room is handed back, on the morning
+ * it is, and whatever the drive into it runs over; on the right the time the chain has the
+ * traveller there. The whole row opens the booking behind the night, or the hotel's place
+ * when there is none to open.
+ */
+export function BookendStop({ reading, entry, late, driveFindings, continues, starts, onOpen }: {
+ reading: BookendReading
+ entry: ScheduleEntry | undefined
+ late: ScheduleWarning[]
+ driveFindings: ScheduleWarning[]
+ continues: boolean
+ starts?: boolean
+ onOpen?: () => void
+}): React.ReactElement {
+ const { t } = useTranslation()
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+ const badge = bookendBadge(reading, entry, t, is12h)
+ return (
+
+
+
+
+ {reading.name}
+
+
+ {badge.lead}> : badge.lead}
+ value={badge.value ?? undefined}
+ tooltip={badge.hint ?? undefined}
+ />
+ {driveFindings.map(w => )}
+ {late.map(w => )}
+
+
+ {entry?.arrival ? : null}
+
+
+ )
+}
+
+/** A terminal's code, or the name of one without it, in the chip the ride's ends stand in. */
+function CodeChip({ text }: { text: string }): React.ReactElement {
+ return (
+
+ {text}
+
+ )
+}
+
+/**
+ * The timetable's clock at one end of a ride. Weighted like a pinned arrival because the
+ * booking fixes it, and named for what it is: the time a clock in this column otherwise
+ * means is when the drive arrives.
+ */
+function TimetableClock({ end, className = '' }: { end: RideEnd; className?: string }): React.ReactElement {
+ return (
+
+
+ {end.clock}
+
+ {end.label}
+
+
+ )
+}
+
+/**
+ * The check-in, as one badge that carries its own finding: on time it reads the hour, late
+ * it reads by how much, and too late to catch the ride it says so and when the drive gets
+ * there. Measured against the pin and set beside the departure's timetable clock, the old
+ * "+9 h 39 min" read as a delay to that clock and never said the ride was gone.
+ */
+function CheckInBadge({ checkIn }: { checkIn: CheckInReading }): React.ReactElement {
+ const warning = checkIn.state !== 'ok'
+ return (
+ {checkIn.lead}> : checkIn.lead}
+ value={checkIn.value}
+ tooltip={checkIn.hint}
+ />
+ )
+}
+
+/**
+ * A ride that leaves and lands on the same day, as one thing in the chain. 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.
+ *
+ * Four lines, each fact once: the booking; the two codes with their timetable clocks and
+ * the ride drawn between them as a leg; the places; and the check-in, which carries the
+ * finding when the drive gets there late, beside the minutes the ride takes. A ride the
+ * drive cannot catch edges the block in the warning colour. The whole block opens the
+ * booking; the road resumes below it as usual.
+ */
+export function RideBlock({ departure, arrival, entries, late, seg, continues, starts, onOpen }: {
+ departure: RoadtripStop
+ arrival: RoadtripStop
+ entries: [ScheduleEntry | undefined, ScheduleEntry | undefined]
+ late: ScheduleWarning[]
+ seg: RouteSegment | undefined
+ continues: boolean
+ starts?: boolean
+ onOpen?: () => void
+}): React.ReactElement {
+ const { t } = useTranslation()
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+ const ride = rideReading({ departure, arrival, entries, warnings: late, seg }, t, is12h)
+ const title = {ride.title}
+ return (
+
+
+
+ {onOpen ? {title} : title}
+
+
+
+ {ride.from.clock ? : }
+
+ {ride.to.clock ? : }
+
+
+ {ride.from.placeLine || ride.to.placeLine ? (
+
+ {ride.from.placeLine}
+ {ride.to.placeLine}
+
+ ) : null}
+ {ride.checkIn || ride.duration ? (
+
+ {ride.checkIn ? : null}
+ {ride.duration ? (
+ } value={ride.duration} tooltip={t('roadtrip.ride.duration')} />
+ ) : null}
+
+ ) : null}
+
+
+ )
+}
+
+/**
+ * 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.
+ */
+export function BookingChips({ bookings, continues, canEdit, onOpen }: {
+ bookings: Reservation[]
+ continues: boolean
+ /** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
+ canEdit: boolean
+ onOpen?: (reservationId: number) => void
+}): React.ReactElement {
+ const { t } = useTranslation()
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+ return (
+
+ )
+}
+
+/**
+ * The rides the drive leaves out because they are on no day (#2461).
+ *
+ * The map draws such a booking's arc between its terminals all the same, so without this
+ * the ferry looked planned while the rail drove round it by road. One line each, with the
+ * booking a click away, since the fix is a date in the booking itself.
+ */
+export function UndatedRides({ rides, onOpenBooking }: {
+ rides: readonly Reservation[]
+ onOpenBooking?: (reservationId: number) => void
+}): React.ReactElement | null {
+ const { t } = useTranslation()
+ if (!rides.length) return null
+ return (
+