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 => ( +
  • +
  • + ))} +
+
+ ) +}