From 5cfc546630999194678814d1a741b18bc97582c0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 01:49:14 +0000 Subject: [PATCH] refactor(client): the rows a booking stands for move to RoadtripSidebarBookingRows TerminalStop, BookendStop, RideBlock, BookingChips and UndatedRides leave RoadtripSidebar.tsx for their own module and are imported back by name. RIDE_DASH, DiscRow, CodeChip, TimetableClock and CheckInBadge go with them unexported, since only these rows use them, and the imports only they used leave the sidebar. The code moves as it was; the one change is that UndatedRides spells out the type of its booking callback instead of reading it off the sidebar's props, so the new module never imports from the sidebar. Nothing renders differently. New tests render each row on its own: a departure with its clock and check-in, a hire desk, a booked night that is left after check-out, a ride that is caught, missed or has nothing on its timetable, the chips a reader may and may not open, and the rides on no day. --- .../components/Roadtrip/RoadtripSidebar.tsx | 359 +----------------- .../RoadtripSidebarBookingRows.test.tsx | 312 +++++++++++++++ .../Roadtrip/RoadtripSidebarBookingRows.tsx | 358 +++++++++++++++++ 3 files changed, 678 insertions(+), 351 deletions(-) create mode 100644 client/src/components/Roadtrip/RoadtripSidebarBookingRows.test.tsx create mode 100644 client/src/components/Roadtrip/RoadtripSidebarBookingRows.tsx 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 ( -
- - {continues ? : null} - -
- {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 && bookingOpens(r, canEdit) ? ( - - - - ) : ( - {body} - ) - })} -
-
- ) -} - /** * 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 ( -
-
    - {rides.map(ride => ( -
  • -
  • - ))} -
-
- ) -} - /** * 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 ( +
+ + {continues ? : null} + +
+ {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 && bookingOpens(r, canEdit) ? ( + + + + ) : ( + {body} + ) + })} +
+
+ ) +} + +/** + * 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 ( +
+
    + {rides.map(ride => ( +
  • +
  • + ))} +
+
+ ) +}