From e1315806c27c8ff3b97bfc1b4a32419c748f7101 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 24 Sep 2026 10:12:10 +0200 Subject: [PATCH] feat(roadtrip): the ride block and the stay rows read as badges The flight block in the road trip rail printed each fact two or three times: the booking title in capitals with its duration cut off, the airport code three times per side, an unlabelled 14:15 on the right with "Departure 15:15" under it, 17:20 twice, and a warning "+9 h 39 min" measured against a check-in nobody could see, for a flight that was missed. It now reads in four lines: the booking as typed; the two codes with their timetable clocks and the ride between them; the places without the code in brackets; and the check-in as a badge that carries its own state, the hour when the drive is on time, by how much when it is late for the check-in, and "Missed, there at 23:54" when it gets there after the departure, with the duration beside it. A missed ride edges the block and says so in the day header, also when the day is folded. Trains and buses say boarding. The phone shows the same, and its rows now read the lateness and missed-leave findings at all. The stays at the edge of a day are named by the hotel, with the edge in a badge: Check-out with the latest hour, Day start, Overnight, or Check-in with the hour the room is ready. A plan that leaves after the check-out turns that badge instead of adding a line. They wear the hotel stop's own disc, the stay's own stop shows its check-in the same way, and the driving settings button shows when the stays are switched on. The two-part badge the rail used in six hand-typed copies is one FigureBadge now, and the airport code is stripped by one shared helper instead of two private copies. --- .../src/components/Planner/TransportModal.tsx | 10 +- .../components/Roadtrip/FigureBadge.test.tsx | 70 +++ .../src/components/Roadtrip/FigureBadge.tsx | 95 ++++ .../Roadtrip/RoadtripLimitsCard.test.tsx | 18 + .../Roadtrip/RoadtripLimitsCard.tsx | 26 +- .../Roadtrip/RoadtripSidebar.test.tsx | 234 ++++++-- .../components/Roadtrip/RoadtripSidebar.tsx | 534 +++++++++--------- .../components/Roadtrip/carrierRide.test.ts | 191 +++++++ client/src/components/Roadtrip/carrierRide.ts | 203 ++++++- .../components/Roadtrip/nightBookend.test.ts | 41 +- .../src/components/Roadtrip/nightBookend.ts | 46 +- .../Roadtrip/roadtripRowModel.test.ts | 39 +- .../components/Roadtrip/roadtripRowModel.ts | 11 +- .../screens/trip/roadtrip/MRoadtripRows.tsx | 209 +++++-- .../trip/sheets/MTransportFormSheet.tsx | 9 +- client/src/utils/flightLegs.test.ts | 13 +- client/src/utils/flightLegs.ts | 11 + .../unit/mobile/trip/MRoadtripRows.test.tsx | 166 ++++-- .../unit/mobile/trip/MRoadtripTab.test.tsx | 44 +- shared/src/i18n/ar/roadtrip.ts | 29 +- shared/src/i18n/br/roadtrip.ts | 29 +- shared/src/i18n/ca/roadtrip.ts | 29 +- shared/src/i18n/cs/roadtrip.ts | 26 +- shared/src/i18n/de/roadtrip.ts | 29 +- shared/src/i18n/en/roadtrip.ts | 29 +- shared/src/i18n/es/roadtrip.ts | 29 +- shared/src/i18n/fr/roadtrip.ts | 29 +- shared/src/i18n/gr/roadtrip.ts | 29 +- shared/src/i18n/hu/roadtrip.ts | 26 +- shared/src/i18n/id/roadtrip.ts | 29 +- shared/src/i18n/it/roadtrip.ts | 29 +- shared/src/i18n/ja/roadtrip.ts | 28 +- shared/src/i18n/ko/roadtrip.ts | 26 +- shared/src/i18n/nl/roadtrip.ts | 29 +- shared/src/i18n/pl/roadtrip.ts | 27 +- shared/src/i18n/ru/roadtrip.ts | 29 +- shared/src/i18n/sv/roadtrip.ts | 29 +- shared/src/i18n/tr/roadtrip.ts | 26 +- shared/src/i18n/uk/roadtrip.ts | 27 +- shared/src/i18n/vi/roadtrip.ts | 29 +- shared/src/i18n/zh-TW/roadtrip.ts | 26 +- shared/src/i18n/zh/roadtrip.ts | 26 +- 42 files changed, 2043 insertions(+), 571 deletions(-) create mode 100644 client/src/components/Roadtrip/FigureBadge.test.tsx create mode 100644 client/src/components/Roadtrip/FigureBadge.tsx create mode 100644 client/src/components/Roadtrip/carrierRide.test.ts diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 07b471547..35815780c 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -16,7 +16,7 @@ import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/ import { openFile } from '../../utils/fileDownload' import apiClient from '../../api/client' import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types' -import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs' +import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '../../utils/flightLegs' import { BookingCostsSection } from './BookingCostsSection' import { TravelerPicker } from './TravelerPicker' import type { TripMember } from '../Budget/BudgetPanelMemberChips' @@ -57,14 +57,6 @@ function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', se } } -// "Paris Charles de Gaulle (CDG)" → "Paris Charles de Gaulle". Done as a trim -// plus an anchored test instead of /\s*\([A-Z]{3}\)\s*$/, because the leading -// \s* backtracks over every space in a long name for a quadratic worst case. -function stripAirportCode(name: string): string { - const trimmed = name.trimEnd() - return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name -} - function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null { if (!e || !e.code) return null return { diff --git a/client/src/components/Roadtrip/FigureBadge.test.tsx b/client/src/components/Roadtrip/FigureBadge.test.tsx new file mode 100644 index 000000000..7f82dc6bd --- /dev/null +++ b/client/src/components/Roadtrip/FigureBadge.test.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { Footprints } from 'lucide-react' +import FigureBadge from './FigureBadge' + +// FE-FIGURE-BADGE-001 to FE-FIGURE-BADGE-005 + +/** The classes a cell carries, as a set, so the order they are written in does not matter. */ +const classes = (el: Element | null): string[] => (el?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean).sort() +const of = (list: string): string[] => list.split(' ').sort() + +describe('FigureBadge', () => { + it('FE-FIGURE-BADGE-001: the neutral shell is the one the stay, the walk and the fill-up wore before', () => { + const { container } = render(} value="240 m" />) + const shell = container.firstElementChild! + expect(classes(shell)).toEqual(of('inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge')) + expect(classes(shell.children[0])).toEqual(of('flex items-center gap-0.5 px-1 bg-surface-tertiary text-content-faint')) + expect(classes(shell.children[1])).toEqual( + of('flex items-center border-s bg-surface-card px-1.5 font-semibold tabular-nums border-edge text-content-secondary'), + ) + expect(shell.children[1]).toHaveTextContent('240 m') + }) + + it('FE-FIGURE-BADGE-002: the warning tone edges both halves in the warning colour itself', () => { + const { container } = render() + const shell = container.firstElementChild! + expect(shell).toHaveAttribute('dir', 'ltr') + expect(classes(shell)).toContain('border-warning') + expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['bg-warning-soft', 'text-warning'])) + expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['border-warning', 'text-warning'])) + }) + + it('FE-FIGURE-BADGE-003: a word in the lead is a caption, a figure can be quiet, and without one the sign stands alone', () => { + const caption = render() + const shell = caption.container.firstElementChild! + expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['font-geist', 'font-semibold', 'uppercase', 'tracking-[0.12em]'])) + expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['text-content-faint'])) + expect((shell.children[1] as HTMLElement).style.fontSize).toContain('8px') + caption.unmount() + + const sign = render(} />) + expect(sign.container.firstElementChild!.children).toHaveLength(1) + expect(screen.getByLabelText('Walk')).toBeInTheDocument() + }) + + it('FE-FIGURE-BADGE-004: as a control it is a focusable button role that answers the keys meaning press and keeps them from the row', () => { + const onActivate = vi.fn() + const onRow = vi.fn() + render( +
+ +
, + ) + const badge = screen.getByRole('button', { name: 'Stay: 1 h' }) + fireEvent.click(badge) + fireEvent.keyDown(badge, { key: 'Enter' }) + fireEvent.keyDown(badge, { key: ' ' }) + fireEvent.keyDown(badge, { key: 'Tab' }) + expect(onActivate).toHaveBeenCalledTimes(3) + expect(onActivate).toHaveBeenCalledWith(badge) + // Only the Tab went on to the row. + expect(onRow).toHaveBeenCalledTimes(1) + }) + + it('FE-FIGURE-BADGE-005: without a handler it is no control at all', () => { + render() + expect(screen.queryByRole('button')).toBeNull() + }) +}) diff --git a/client/src/components/Roadtrip/FigureBadge.tsx b/client/src/components/Roadtrip/FigureBadge.tsx new file mode 100644 index 000000000..5d6080b3e --- /dev/null +++ b/client/src/components/Roadtrip/FigureBadge.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import { Tooltip } from '../shared/Tooltip' +import { FS } from './typeScale' + +/** + * A figure in the two-part shell the rail uses everywhere: what it is on the left, the + * number on the right, one hairline between them. + * + * One component because the shell had been typed out six times, the stay, the walk, the + * fill-up, the two findings and the limits button each carrying their own copy, and the + * copies had started to drift. The warning tone keeps the border in the warning colour + * itself, not its soft tint: at this size a tinted edge disappears into the card. + */ +const TONES = { + neutral: { + shell: 'border-edge', + lead: 'bg-surface-tertiary text-content-faint', + value: 'border-edge text-content-secondary', + }, + warning: { + shell: 'border-warning', + lead: 'bg-warning-soft text-warning', + value: 'border-warning text-warning', + }, +} as const + +interface FigureBadgeProps { + /** An icon, or a word set as a caption (`caption`). */ + lead: React.ReactNode + /** The figure. Absent draws the lead alone, a badge that is only a sign. */ + value?: React.ReactNode + tone?: 'neutral' | 'warning' + tooltip?: string + /** The lead is a word, set small, wide and in capitals like the stay's. */ + caption?: boolean + /** The figure in the quiet ink: an invitation, or a value inherited rather than set. */ + faint?: boolean + valueSize?: 'label' | 'micro' + dir?: 'ltr' + /** + * Makes the badge the control that changes its figure. A span with the button role, not + * a ) } @@ -858,11 +807,13 @@ function DiscRow({ Icon, starts, continues, onOpen, label, children }: { * A booked night at the edge of the day: the hotel the day sets out from, or the one it * ends at (`seatNightBookends`). * - * Laid out like a terminal, 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. + * 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 @@ -875,29 +826,25 @@ function BookendStop({ reading, entry, late, driveFindings, continues, starts, o }): React.ReactElement { const { t } = useTranslation() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const meta = bookendMeta(reading, t, is12h) - const afterCheckOut = leavesAfterCheckOut(reading, entry) - const badges = afterCheckOut || late.length + driveFindings.length > 0 + const badge = bookendBadge(reading, entry, t, is12h) return ( - + - {bookendTitle(reading, t)} + {reading.name} + + + {badge.lead} : badge.lead} + value={badge.value ?? undefined} + tooltip={badge.hint ?? undefined} + /> + {driveFindings.map(w => )} + {late.map(w => )} - {meta || badges ? ( - - {meta ? {meta} : null} - {afterCheckOut ? ( - - - ) : null} - {driveFindings.map(w => )} - {late.map(w => )} - - ) : null} {entry?.arrival ? : null} @@ -905,69 +852,114 @@ function BookendStop({ reading, entry, late, driveFindings, continues, starts, o ) } -/** - * One end of a ride: the terminal's name and code, the timetable's clock under it, and - * on the right the time the chain has the traveller there. Inside the ride's block for - * a same-day ride, and the whole of a lone terminal's row. - */ -function RideEnd({ stop, entry, late }: { - stop: RoadtripStop - entry: ScheduleEntry | undefined - late: ScheduleWarning[] -}): React.ReactElement { - const { t } = useTranslation() - const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const line = terminalLine(stop.carrier!, t, is12h) +/** 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 ( - - - - {stop.name} - {stop.carrier!.code ? ( - {stop.carrier!.code} - ) : null} - - - {line ? {line} : null} - {late.map(w => )} - - - {entry?.arrival ? : null} + + {text} ) } /** - * A ride that leaves and lands on the same day, as one thing in the chain: the booking - * at the top, the terminal it leaves from, the minutes in the air, the terminal it lands - * at. One block on one disc rather than three rows, because it IS one thing, a flight, - * and three rows read as three places the day went to. The whole block opens the + * 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[], ScheduleWarning[]] + late: ScheduleWarning[] seg: RouteSegment | undefined continues: boolean starts?: boolean onOpen?: () => void }): React.ReactElement { const { t } = useTranslation() - const carrier = departure.carrier! - const Icon = carrierIcon(carrier.type) + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const ride = rideReading({ departure, arrival, entries, warnings: late, seg }, t, is12h) + const title = {ride.title} return ( - - - - - - {rideText(carrier, seg)} - + + + + {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} ) @@ -1125,6 +1117,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE {stop.name}{stop.stopType === 'charging' && } + {refuelsRange(stop.stopType, vehicleKind) ? ( - - - - - - {warning.code === 'range' - ? formatDistance(warning.sinceKm ?? 0, distanceUnit) - : `+${formatDurationShort((warning.overMinutes ?? 0) * 60)}`} - + } + value={warning.code === 'range' + ? formatDistance(warning.sinceKm ?? 0, distanceUnit) + : `+${formatDurationShort((warning.overMinutes ?? 0) * 60)}`} + tooltip={text} + /> + ) +} + +/** + * The timetable convention: past midnight the clock keeps reading small numbers, so the + * day it belongs to travels with it instead of sitting a line away as a separate note. + */ +function DayCarry({ days }: { days: number }): React.ReactElement | null { + const { t } = useTranslation() + if (days <= 0) return null + return ( + + {`+${days}`} + {` ${t('roadtrip.warn.overnight')}`} - ) } @@ -1240,14 +1237,7 @@ function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement { const { t } = useTranslation() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const text = formatClockTime(entry.arrival, is12h) - // The timetable convention: past midnight the clock keeps reading small numbers, so the - // day it belongs to travels with it instead of sitting a line away as a separate note. - const carry = entry.dayOffset > 0 ? ( - - {`+${entry.dayOffset}`} - {` ${t('roadtrip.warn.overnight')}`} - - ) : null + const carry = // Every arrival is plain text at the row's right edge, pinned or not. The pinned one // used to be a filled pill with a clock, which made a column of quiet clock readings @@ -1381,6 +1371,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s enough to change the plan. The dashed line on the map already says there is one; the number is for luggage, a gate, a track a hire car should not be on. */} + {refuelsRange(stop.stopType, vehicleKind) ? ( - - - - - - {`+${formatDurationShort((late.minutes ?? 0) * 60)}`} - - - + } + value={`+${formatDurationShort((late.minutes ?? 0) * 60)}`} + tooltip={label} + /> ) } @@ -1608,6 +1589,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const { t, language } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + // A ride the drive gets to too late is said in the header too, where a folded day still shows it. + const missed = missedRide(day, t, is12h) // The colour the map draws this day in, or none at all while the map is drawing one // blue line for the whole trip. const dayColorsOn = useRoadtripSettings(s => !!s.roadtrip_day_colors) @@ -1738,7 +1722,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca departure={stop} arrival={next!} entries={[day.schedule.entries[i], day.schedule.entries[i + 1]]} - late={[lateness, latenessAt(i + 1)]} + late={lateness} seg={day.legs[i]} continues={tail < last} starts={i === 0} @@ -1961,6 +1945,14 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca ) : null} + {missed ? ( + + + + {missed.label} + + + ) : null} {/* Nor does a day whose drive runs from one hotel to the next with no stop of its own: "0 stops" beside the drive reads as the stops having gone missing. */} {day.legs.length > 0 && destinationCount(day) > 0 ? ( diff --git a/client/src/components/Roadtrip/carrierRide.test.ts b/client/src/components/Roadtrip/carrierRide.test.ts new file mode 100644 index 000000000..e96ece189 --- /dev/null +++ b/client/src/components/Roadtrip/carrierRide.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { missedRide, rideDuration, rideReading, terminalReading } from './carrierRide' +import type { CarrierTerminal, RoadtripStop, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip' + +// FE-CARRIER-RIDE-001 to FE-CARRIER-RIDE-009 + +/** A translator that shows the key and what went into it, so a test reads which sentence was asked for. */ +const t = (key: string, params?: Record) => + params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key + +/** + * One end of LH 2078 as the routing round seats it: the departure pinned an hour ahead of + * the timetable for the check-in and left at the timetable's minute, the arrival pinned at + * its own. Named the way every airport writer names an endpoint. + */ +function terminal(role: CarrierTerminal['role'], over: Partial = {}, stop: Partial = {}): RoadtripStop { + const departs = role === 'departure' + const carrier: CarrierTerminal = { + reservationId: 78, + type: 'flight', + role, + title: 'LH 2078 HAM-MUC (ohne Endpunkte)', + code: departs ? 'HAM' : 'MUC', + at: departs ? '15:15' : '17:20', + ...over, + } + return { + assignmentId: departs ? -780 : -781, + ownerDayId: 3, + ownerIndex: 1, + placeId: -78, + name: departs ? 'Hamburg (HAM)' : 'Munich (MUC)', + lat: departs ? 53.63 : 48.35, + lng: departs ? 9.99 : 11.78, + time: departs ? '14:15' : '17:20', + dwellMinutes: departs ? 60 : 0, + legMode: departs ? carrier.type : null, + incomingLegMode: departs ? null : carrier.type, + stopType: null, + carrier, + ...stop, + } +} + +const FLIGHT: RouteSegment = { + mid: [50, 10], from: [53.63, 9.99], to: [48.35, 11.78], + distance: 0, duration: 7500, walkingText: '', drivingText: '', distanceText: '', durationText: '2 h 5 min', + mode: 'flight', +} + +const late = (minutes: number): ScheduleWarning => ({ index: 1, code: 'late', minutes }) + +const read = (warnings: ScheduleWarning[] = [], is12h = false, over: Partial = {}) => + rideReading({ + departure: terminal('departure', over), + arrival: terminal('arrival', over), + entries: [ + { arrival: '14:15', departure: '15:15', anchored: true, dayOffset: 0 }, + { arrival: '17:20', departure: '17:20', anchored: true, dayOffset: 0 }, + ], + warnings, + seg: FLIGHT, + }, t, is12h) + +describe('a ride as its block reads it', () => { + it('FE-CARRIER-RIDE-001: every fact once, the title as typed, the code off the place, and the check-in with its hour', () => { + const ride = read() + expect(ride.title).toBe('LH 2078 HAM-MUC (ohne Endpunkte)') + expect(ride.duration).toBe('2 h 5 min') + expect(ride.from).toEqual({ + code: 'HAM', + place: 'Hamburg', + chip: 'HAM', + placeLine: 'Hamburg', + clock: '15:15', + dayOffset: 0, + label: 'roadtrip.ride.departureFlight(time=15:15)', + }) + expect(ride.to).toMatchObject({ chip: 'MUC', placeLine: 'Munich', clock: '17:20', label: 'roadtrip.ride.arrival(time=17:20)' }) + expect(ride.checkIn).toEqual({ + state: 'ok', + lead: 'roadtrip.ride.checkIn', + value: '14:15', + hint: 'roadtrip.ride.hintFlight(title=LH 2078 HAM-MUC (ohne Endpunkte),departs=15:15,pin=14:15)', + }) + }) + + it('FE-CARRIER-RIDE-002: reached after the check-in but before take-off is tight, and says by how much', () => { + const checkIn = read([late(25)]).checkIn! + expect(checkIn.state).toBe('tight') + expect(checkIn.lead).toBe('roadtrip.ride.checkIn') + expect(checkIn.value).toBe('roadtrip.ride.lateBy(time=25 min)') + expect(checkIn.hint).toBe( + 'roadtrip.ride.lateHintFlight(title=LH 2078 HAM-MUC (ohne Endpunkte),departs=15:15,pin=14:15,place=Hamburg,time=14:40)', + ) + // Reaching the gate as the plane leaves is still a catch. + expect(read([late(60)]).checkIn!.state).toBe('tight') + }) + + it('FE-CARRIER-RIDE-003: reached after take-off is missed, and says when the drive gets there instead of a delta', () => { + const checkIn = read([late(579)]).checkIn! + expect(checkIn.state).toBe('missed') + expect(checkIn.lead).toBe('roadtrip.ride.missed') + expect(checkIn.value).toBe('roadtrip.ride.reachedAt(time=23:54)') + expect(checkIn.hint).toContain('place=Hamburg,time=23:54') + // Past midnight the clock carries its day the way the rail's arrivals do, in the reader's clock. + expect(read([late(12 * 60 + 15)], true).checkIn!.value).toBe('roadtrip.ride.reachedAt(time=2:30 AM+1)') + // Findings that are not about being late leave the check-in alone. + expect(read([{ index: 1, code: 'leg', overMinutes: 30 }]).checkIn!.state).toBe('ok') + }) + + it('FE-CARRIER-RIDE-004: a train or a bus is boarded, a ferry or a cruise sails, and only a flight has its own departure word', () => { + const train = read([], false, { type: 'train', code: null }) + expect(train.checkIn!.lead).toBe('roadtrip.ride.boarding') + expect(train.checkIn!.hint).toMatch(/^roadtrip\.ride\.hintBoarding\(/) + expect(train.from.label).toBe('roadtrip.ride.departure(time=15:15)') + expect(read([late(25)], false, { type: 'bus' }).checkIn!.hint).toMatch(/^roadtrip\.ride\.lateHintBoarding\(/) + const ferry = read([], false, { type: 'ferry' }) + expect(ferry.checkIn!.lead).toBe('roadtrip.ride.checkIn') + expect(ferry.checkIn!.hint).toMatch(/^roadtrip\.ride\.hintShip\(/) + expect(read([], false, { type: 'cruise' }).checkIn!.hint).toMatch(/^roadtrip\.ride\.hintShip\(/) + }) + + it('FE-CARRIER-RIDE-005: a terminal without a code puts its name in the chip, and one without a timetable names no clock and no check-in', () => { + const station = rideReading({ + departure: terminal('departure', { type: 'train', code: null, at: null }, { name: 'Hamburg Hbf', time: null }), + arrival: terminal('arrival', { type: 'train', code: null, at: null }, { name: 'München Hbf', time: null }), + entries: [undefined, { arrival: '07:20', departure: '07:20', anchored: false, dayOffset: 1 }], + warnings: [], + seg: undefined, + }, t, false) + expect(station.from).toMatchObject({ code: null, chip: 'Hamburg Hbf', placeLine: null, clock: null, label: null, dayOffset: 0 }) + expect(station.to).toMatchObject({ chip: 'München Hbf', dayOffset: 1 }) + expect(station.duration).toBeNull() + expect(station.checkIn).toBeNull() + }) +}) + +describe('a terminal on a row of its own', () => { + it('FE-CARRIER-RIDE-006: a desk says which one it is and has no check-in; a lone departure has one, a lone arrival has none', () => { + const pickup = terminalReading( + terminal('pickup', { type: 'car', title: 'Sixt Hamburg', code: null, at: '09:00' }, { name: 'Sixt Hauptbahnhof', time: '09:00' }), + undefined, + [], + t, + false, + ) + expect(pickup.desk).toBe('reservations.span.pickup') + expect(pickup.checkIn).toBeNull() + expect(pickup.end).toMatchObject({ place: 'Sixt Hauptbahnhof', clock: '09:00', label: 'roadtrip.ride.pickup(time=09:00)' }) + + const leaving = terminalReading(terminal('departure'), undefined, [late(579)], t, false) + expect(leaving.desk).toBeNull() + expect(leaving.checkIn!.state).toBe('missed') + expect(terminalReading(terminal('arrival'), undefined, [late(30)], t, false).checkIn).toBeNull() + }) +}) + +describe('the day a ride is missed on', () => { + const day = (warnings: ScheduleWarning[], type = 'flight') => ({ + stops: [ + { ...terminal('pickup', { type: 'car', code: null }), carrier: undefined, name: 'Hotel Atlantic' }, + terminal('departure', { type }), + terminal('arrival', { type }), + ], + schedule: { entries: [], warnings }, + }) + + it('FE-CARRIER-RIDE-007: names the kind of ride in the header and keeps the sentence for its tooltip', () => { + expect(missedRide(day([late(579)]), t, false)).toEqual({ + label: 'roadtrip.day.rideMissed.flight', + hint: expect.stringContaining('time=23:54'), + }) + expect(missedRide(day([late(579)], 'ferry'), t, false)!.label).toBe('roadtrip.day.rideMissed.ferry') + }) + + it('FE-CARRIER-RIDE-008: a ride caught, late or not, and a finding at another stop say nothing in the header', () => { + expect(missedRide(day([]), t, false)).toBeNull() + expect(missedRide(day([late(25)]), t, false)).toBeNull() + expect(missedRide(day([{ index: 2, code: 'late', minutes: 600 }]), t, false)).toBeNull() + }) +}) + +describe('rideDuration', () => { + it('FE-CARRIER-RIDE-009: the leg\'s own text, else its seconds, else nothing', () => { + expect(rideDuration(FLIGHT)).toBe('2 h 5 min') + expect(rideDuration({ ...FLIGHT, durationText: '' })).toBe('2 h 5 min') + expect(rideDuration({ ...FLIGHT, durationText: '', duration: 0 })).toBeNull() + expect(rideDuration(undefined)).toBeNull() + }) +}) diff --git a/client/src/components/Roadtrip/carrierRide.ts b/client/src/components/Roadtrip/carrierRide.ts index a56d687d2..80e28dc9d 100644 --- a/client/src/components/Roadtrip/carrierRide.ts +++ b/client/src/components/Roadtrip/carrierRide.ts @@ -1,16 +1,18 @@ import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react' -import { formatDurationShort } from './roadtripModel' +import { formatClock, formatDurationShort, parseClock, type ScheduleEntry, type ScheduleWarning } from './roadtripModel' import { formatClockTime } from '../../utils/formatters' +import { stripAirportCode } from '../../utils/flightLegs' import { TRANSPORT_TYPES } from '../../utils/dayMerge' -import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip' +import { CARRIER_TYPES, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip' import type { Reservation, TranslationFn } from '../../types' /** * How a ride and its two terminals, and a hire car's two desks, read on the rail and in * the phone chain (#2428), and which of the bookings under a stop open there. * - * One module for both shells, because the two would otherwise carry the same icon table - * and the same three sentences each, and the duplication budget does not stretch to that. + * One module for both shells, because the two would otherwise carry the same icon table, + * the same reading of the timetable against the drive and the same sentences each, and the + * duplication budget does not stretch to that. The shells only draw what comes out of here. * The icons are the booking panel's for the same types, so a flight looks like a flight * wherever the trip shows it. */ @@ -28,25 +30,190 @@ export function carrierIcon(type: string): LucideIcon { } /** - * The line under a terminal's name: the timetable's clock at it, in the reader's own - * clock format. Nothing when the booking names no time. + * How long the ride takes on the timetable: the leg's own text, or its seconds when the + * text is not in yet. Null for a ride the timetable gives no minutes for. */ -export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h: boolean): string | null { - if (!carrier.at) return null - const time = formatClockTime(carrier.at, is12h) - return t(`roadtrip.ride.${carrier.role}`, { time }) +export function rideDuration(seg: RouteSegment | undefined): string | null { + const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0 + return seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : null) +} + +/** One end of a ride, or a hire car's desk, as a row or the route strip of a block reads it. */ +export interface RideEnd { + /** IATA or station code, when the booking carries one. */ + code: string | null + /** The terminal's name without the code the airport forms append to it. */ + place: string + /** What the route strip's chip holds: the code, or the name of a terminal without one. */ + chip: string + /** The name under the chip, or null when the chip already is the name. */ + placeLine: string | null + /** The timetable's clock here, in the reader's format. Null when the booking names none. */ + clock: string | null + /** Days past the card's own that the clock falls on, for the timetable's "+1". */ + dayOffset: number + /** The clock in words, "Departure 15:15", for its tooltip and a screen reader. */ + label: string | null } /** - * What the ride's pill says: the booking, then how long it takes when the timetable gives - * both ends. A ride with no minutes is still the booking, and says so without a duration. - * A ride leg met without its booking is just the minutes. + * The departure measured against the drive that gets there. + * + * `ok` is the drive arriving by the check-in, `tight` after it but still before the ride + * leaves, `missed` after it has left. The ride keeps its day either way (`scheduleStopOf` + * pins it `dated`), so a drive a day late reads as missed rather than as tomorrow's ride. */ -export function rideText(carrier: Pick | undefined, seg: RouteSegment | undefined): string { - const seconds = seg && Number.isFinite(seg.duration) ? seg.duration : 0 - const duration = seg?.durationText || (seconds > 0 ? formatDurationShort(seconds) : '') - if (!carrier) return duration - return duration ? `${carrier.title} · ${duration}` : carrier.title +export type RideState = 'ok' | 'tight' | 'missed' + +export interface CheckInReading { + state: RideState + /** The badge's word: check-in, boarding for a train or a bus, or that the ride is missed. */ + lead: string + /** Its figure: the check-in clock, how late the drive gets there, or when it does. */ + value: string + /** The sentence behind the badge, with the timetable in it. */ + hint: string +} + +export interface RideReading { + title: string + duration: string | null + from: RideEnd + to: RideEnd + checkIn: CheckInReading | null +} + +export interface TerminalReading { + end: RideEnd + /** A hire car's desk says which one it is, having no other end beside it to say it. */ + desk: string | null + checkIn: CheckInReading | null +} + +const DAY_MINUTES = 24 * 60 + +/** Trains and buses are boarded, the rest is checked in for (`CHECK_IN_MINUTES`). */ +const BOARDED = new Set(['train', 'bus']) +const SAILS = new Set(['ferry', 'cruise']) + +/** Which of the three timetable sentences a ride takes: they differ in the verb and the word for the check-in. */ +function sentenceOf(type: string): 'Flight' | 'Ship' | 'Boarding' { + if (BOARDED.has(type)) return 'Boarding' + return SAILS.has(type) ? 'Ship' : 'Flight' +} + +/** Minutes from the card's midnight as a clock, in the timetable's "+1" form past it. */ +function clockOf(minutes: number, is12h: boolean): string { + const days = Math.floor(minutes / DAY_MINUTES) + const clock = formatClockTime(formatClock(minutes), is12h) + return days > 0 ? `${clock}+${days}` : clock +} + +function rideEnd(stop: RoadtripStop, entry: ScheduleEntry | undefined, t: TranslationFn, is12h: boolean): RideEnd { + const carrier = stop.carrier! + const place = stripAirportCode(stop.name) + const clock = carrier.at ? formatClockTime(carrier.at, is12h) : null + // German, among others, has a word of its own for a plane leaving. + const key = carrier.role === 'departure' && carrier.type === 'flight' ? 'roadtrip.ride.departureFlight' : `roadtrip.ride.${carrier.role}` + return { + code: carrier.code, + place, + chip: carrier.code ?? place, + placeLine: carrier.code && carrier.code !== place ? place : null, + clock, + dayOffset: entry?.dayOffset ?? 0, + label: clock ? t(key, { time: clock }) : null, + } +} + +/** + * The check-in against the drive, read off the departure's pin and the schedule's `late` + * finding at it: the pin is the timetable minus the check-in (`terminalStop`), and the + * finding is how far past the pin the chain gets there. + */ +function checkInOf( + departure: RoadtripStop, + warnings: readonly ScheduleWarning[], + t: TranslationFn, + is12h: boolean, +): CheckInReading | null { + const carrier = departure.carrier + if (carrier?.role !== 'departure') return null + const departs = parseClock(carrier.at) + const pinned = parseClock(departure.time) + if (departs === null || pinned === null) return null + const late = warnings.find(w => w.code === 'late') + const sentence = sentenceOf(carrier.type) + const word = t(sentence === 'Boarding' ? 'roadtrip.ride.boarding' : 'roadtrip.ride.checkIn') + const pin = clockOf(pinned, is12h) + const facts = { title: carrier.title, departs: clockOf(departs, is12h), pin } + if (!late) return { state: 'ok', lead: word, value: pin, hint: t(`roadtrip.ride.hint${sentence}`, facts) } + + const reach = pinned + (late.minutes ?? 0) + const time = clockOf(reach, is12h) + const hint = t(`roadtrip.ride.lateHint${sentence}`, { ...facts, place: stripAirportCode(departure.name), time }) + if (reach > departs) return { state: 'missed', lead: t('roadtrip.ride.missed'), value: t('roadtrip.ride.reachedAt', { time }), hint } + return { state: 'tight', lead: word, value: t('roadtrip.ride.lateBy', { time: formatDurationShort((reach - pinned) * 60) }), hint } +} + +/** + * A ride that leaves and lands on the same day, as its block reads it: the booking, its + * minutes, both ends and the check-in with whatever the drive makes of it. + */ +export function rideReading( + ride: { + departure: RoadtripStop + arrival: RoadtripStop + entries: readonly [ScheduleEntry | undefined, ScheduleEntry | undefined] + /** The schedule's findings at the departure. */ + warnings: readonly ScheduleWarning[] + seg: RouteSegment | undefined + }, + t: TranslationFn, + is12h: boolean, +): RideReading { + return { + title: ride.departure.carrier!.title, + duration: rideDuration(ride.seg), + from: rideEnd(ride.departure, ride.entries[0], t, is12h), + to: rideEnd(ride.arrival, ride.entries[1], t, is12h), + checkIn: checkInOf(ride.departure, ride.warnings, t, is12h), + } +} + +/** A terminal on a row of its own, its ride landing on another day, or a hire car's desk. */ +export function terminalReading( + stop: RoadtripStop, + entry: ScheduleEntry | undefined, + warnings: readonly ScheduleWarning[], + t: TranslationFn, + is12h: boolean, +): TerminalReading { + const role = stop.carrier!.role + return { + end: rideEnd(stop, entry, t, is12h), + desk: role === 'pickup' || role === 'return' ? t(`reservations.span.${role}`) : null, + checkIn: checkInOf(stop, warnings, t, is12h), + } +} + +/** + * The first ride of the day the drive reaches too late to catch, as the day's header names + * it, so a folded day still says so. Null while every ride is caught. + */ +export function missedRide( + day: Pick, + t: TranslationFn, + is12h: boolean, +): { label: string; hint: string } | null { + for (let i = 0; i < day.stops.length; i++) { + const stop = day.stops[i]! + const checkIn = checkInOf(stop, day.schedule.warnings.filter(w => w.index === i), t, is12h) + if (checkIn?.state !== 'missed') continue + const type = (CARRIER_TYPES as readonly string[]).includes(stop.carrier!.type) ? stop.carrier!.type : 'flight' + return { label: t(`roadtrip.day.rideMissed.${type}`), hint: checkIn.hint } + } + return null } /** diff --git a/client/src/components/Roadtrip/nightBookend.test.ts b/client/src/components/Roadtrip/nightBookend.test.ts index 8f3c65ef3..c5a3e278d 100644 --- a/client/src/components/Roadtrip/nightBookend.test.ts +++ b/client/src/components/Roadtrip/nightBookend.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it } from 'vitest' import { BedDouble } from 'lucide-react' -import { BOOKEND_ICON, bookendBooking, bookendMeta, bookendTitle, leavesAfterCheckOut, staysAtTheirPlaces } from './nightBookend' +import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge, bookendBooking, leavesAfterCheckOut, staysAtTheirPlaces } from './nightBookend' import type { BookendReading } from './roadtripRowModel' +import { serviceColor } from './roadtripModel' -// FE-BOOKEND-001 to FE-BOOKEND-008 +// FE-BOOKEND-001 to FE-BOOKEND-010 const reading = (over: Partial = {}): BookendReading => ({ phase: 'morning', variant: 'from', name: 'Hotel Alpenblick', until: null, + from: null, reservationId: 41, accommodationId: 5, placeId: 900, @@ -21,21 +23,29 @@ const t = (key: string, params?: Record) => params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key describe('a booked night as the rail and the phone say it', () => { - it('FE-BOOKEND-001: each of the four readings is its own sentence, with the stay named in it', () => { - expect(bookendTitle(reading({ variant: 'checkOut' }), t)).toBe('roadtrip.bookend.checkOut(name=Hotel Alpenblick)') - expect(bookendTitle(reading({ variant: 'from' }), t)).toBe('roadtrip.bookend.from(name=Hotel Alpenblick)') - expect(bookendTitle(reading({ phase: 'evening', variant: 'back' }), t)).toBe('roadtrip.bookend.back(name=Hotel Alpenblick)') - expect(bookendTitle(reading({ phase: 'evening', variant: 'checkIn' }), t)).toBe('roadtrip.bookend.checkIn(name=Hotel Alpenblick)') + it('FE-BOOKEND-001: the badge names the edge of the stay the day stands at, the row names the hotel', () => { + expect(bookendBadge(reading({ variant: 'checkOut' }), undefined, t, false).lead).toBe('roadtrip.bookend.checkOut') + expect(bookendBadge(reading({ variant: 'from' }), undefined, t, false).lead).toBe('roadtrip.bookend.from') + expect(bookendBadge(reading({ phase: 'evening', variant: 'back' }), undefined, t, false).lead).toBe('roadtrip.bookend.back') + expect(bookendBadge(reading({ phase: 'evening', variant: 'checkIn' }), undefined, t, false).lead).toBe('roadtrip.bookend.checkIn') }) - it('FE-BOOKEND-002: the check-out hour is a label in the reader\'s clock', () => { - expect(bookendMeta(reading({ variant: 'checkOut', until: '10:00' }), t, false)).toBe('roadtrip.stay.until(time=10:00)') - expect(bookendMeta(reading({ variant: 'checkOut', until: '14:30' }), t, true)).toBe('roadtrip.stay.until(time=2:30 PM)') + it('FE-BOOKEND-002: the check-out hour is the figure of the badge, in the clock of the reader', () => { + expect(bookendBadge(reading({ variant: 'checkOut', until: '10:00' }), undefined, t, false)) + .toEqual({ lead: 'roadtrip.bookend.checkOut', value: '10:00', warning: false, hint: 'roadtrip.stay.until(time=10:00)' }) + expect(bookendBadge(reading({ variant: 'checkOut', until: '14:30' }), undefined, t, true).value).toBe('2:30 PM') }) - it('FE-BOOKEND-003: nothing under the line when the morning hands no room back', () => { - expect(bookendMeta(reading(), t, false)).toBeNull() - expect(bookendMeta(reading({ phase: 'evening', variant: 'back' }), t, false)).toBeNull() + it('FE-BOOKEND-003: a morning that hands no room back, or an evening back at the stay, is the word alone', () => { + expect(bookendBadge(reading(), undefined, t, false)).toEqual({ lead: 'roadtrip.bookend.from', value: null, warning: false, hint: null }) + expect(bookendBadge(reading({ phase: 'evening', variant: 'back' }), undefined, t, false).value).toBeNull() + }) + + it('FE-BOOKEND-010: a check-in evening carries the hour the room is ready, and leaving late turns the check-out badge', () => { + expect(bookendBadge(reading({ phase: 'evening', variant: 'checkIn', from: '15:00' }), undefined, t, false)) + .toEqual({ lead: 'roadtrip.bookend.checkIn', value: '15:00', warning: false, hint: null }) + expect(bookendBadge(reading({ variant: 'checkOut', until: '10:00' }), { arrival: '12:27', dayOffset: 0 }, t, false)) + .toEqual({ lead: 'roadtrip.bookend.checkOut', value: '10:00', warning: true, hint: 'roadtrip.bookend.afterCheckOut' }) }) it('FE-BOOKEND-007: a day that sets out after the room is handed back says so', () => { @@ -65,6 +75,11 @@ describe('a booked night as the rail and the phone say it', () => { it('FE-BOOKEND-005: the hotel wears the bed the stay chips wear', () => { expect(BOOKEND_ICON).toBe(BedDouble) }) + + it('FE-BOOKEND-009: on the disc the hotel stop wears, in its signage colour with the bed in white', () => { + expect(BOOKEND_DISC.background).toBe(serviceColor('hotel')) + expect(BOOKEND_DISC.color).toBe('#fff') + }) }) describe('the hotel a booked night stands for', () => { diff --git a/client/src/components/Roadtrip/nightBookend.ts b/client/src/components/Roadtrip/nightBookend.ts index 0e483a286..883bb5075 100644 --- a/client/src/components/Roadtrip/nightBookend.ts +++ b/client/src/components/Roadtrip/nightBookend.ts @@ -1,6 +1,7 @@ +import type { CSSProperties } from 'react' import { BedDouble, type LucideIcon } from 'lucide-react' import { formatClockTime } from '../../utils/formatters' -import { parseClock, type ScheduleEntry } from './roadtripModel' +import { parseClock, serviceColor, type ScheduleEntry } from './roadtripModel' import type { TranslationFn } from '../../types' import type { BookendReading } from './roadtripRowModel' @@ -17,17 +18,44 @@ import type { BookendReading } from './roadtripRowModel' /** The bed the day plan's stay chips wear, so the hotel looks like itself here too. */ export const BOOKEND_ICON: LucideIcon = BedDouble -/** "Check-out · the stay", "From …", "Back to …" or "Check-in · …". */ -export function bookendTitle(reading: BookendReading, t: TranslationFn): string { - return t(`roadtrip.bookend.${reading.variant}`, { name: reading.name }) -} +/** + * The disc the bed sits on: the hotel stop's own, in the road-signage colour its map pin + * wears. On a plain grey disc the night at the day's edge read as a terminal, and the same + * hotel wore two faces on one card, one where the day checks in and another where it + * comes back to it. + */ +export const BOOKEND_DISC: CSSProperties = { background: serviceColor('hotel'), color: '#fff' } // theme-lint-disable: road-signage palette /** - * The line under it: on a check-out morning the latest the room is handed back, in the - * reader's own clock format. A label, never the time the drive leaves at (#2357). + * The badge under the hotel's name: which edge of the stay the day stands at, and the hour + * that edge names, the latest the room is handed back or the earliest it is ready. Both are + * labels, never the time the drive leaves or arrives at (#2357). A plan that sets out after + * the check-out turns the badge itself to a warning rather than adding a line to the row. */ -export function bookendMeta(reading: BookendReading, t: TranslationFn, is12h: boolean): string | null { - return reading.until ? t('roadtrip.stay.until', { time: formatClockTime(reading.until, is12h) }) : null +export interface BookendBadge { + lead: string + value: string | null + warning: boolean + hint: string | null +} + +export function bookendBadge( + reading: BookendReading, + entry: Pick | undefined, + t: TranslationFn, + is12h: boolean, +): BookendBadge { + const clock = reading.until ?? reading.from + const value = clock ? formatClockTime(clock, is12h) : null + const warning = leavesAfterCheckOut(reading, entry) + return { + lead: t(`roadtrip.bookend.${reading.variant}`), + value, + warning, + hint: warning + ? t('roadtrip.bookend.afterCheckOut') + : reading.until && value ? t('roadtrip.stay.until', { time: value }) : null, + } } /** diff --git a/client/src/components/Roadtrip/roadtripRowModel.test.ts b/client/src/components/Roadtrip/roadtripRowModel.test.ts index e880f5c95..622361c33 100644 --- a/client/src/components/Roadtrip/roadtripRowModel.test.ts +++ b/client/src/components/Roadtrip/roadtripRowModel.test.ts @@ -19,7 +19,7 @@ import { import type { ScheduleEntry, ScheduleWarning } from './roadtripModel' import type { BookendPhase, NightBookend, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip' -// FE-RTROW-001 to FE-RTROW-066 +// FE-RTROW-001 to FE-RTROW-067 function stop(name: string, over: Partial = {}): RoadtripStop { return { @@ -231,14 +231,16 @@ describe('roadtripRows stop detail', () => { }) it('FE-RTROW-011: a stop shows only its own findings, and only the strongest of them', () => { + // Where each finding really comes from: being late from the schedule, the drive's + // limits from `driveWarnings`. const rows = stopRows( roadtripRows( day([stop('A'), stop('B')], { driveWarnings: [ { index: 0, code: 'overnight' }, { index: 1, code: 'leg', overMinutes: 20 }, - { index: 1, code: 'late', minutes: 15 }, ], + schedule: { entries: [entry(null), entry(null)], warnings: [{ index: 1, code: 'late', minutes: 15 }] }, }), ), ) @@ -246,6 +248,32 @@ describe('roadtripRows stop detail', () => { expect(rows[1].warning).toEqual({ index: 1, code: 'late', minutes: 15 }) }) + it('FE-RTROW-067: being late, or leaving after the time set, reaches the row from the schedule, a terminal\'s included', () => { + const terminal = stop('HAM', { + carrier: { reservationId: 78, type: 'flight', role: 'departure', title: 'LH 2078', code: 'HAM', at: '15:15' }, + }) + const rows = stopRows( + roadtripRows( + day([stop('A'), stop('B'), terminal], { + schedule: { + entries: [entry('09:00'), entry('10:00'), entry('14:15')], + warnings: [ + { index: 1, code: 'missedLeave', minutes: 20 }, + { index: 2, code: 'late', minutes: 579 }, + // Only the lateness is read from the schedule, not the day it changes on. + { index: 0, code: 'overnight' }, + ], + }, + }), + ), + ) + expect(rows.map(r => r.warning)).toEqual([ + null, + { index: 1, code: 'missedLeave', minutes: 20 }, + { index: 2, code: 'late', minutes: 579 }, + ]) + }) + it('FE-RTROW-012: dwell and the walk in from the road pass through, absent means null', () => { const rows = stopRows(roadtripRows(day([stop('A', { dwellMinutes: 90, offRoadMeters: 240 }), stop('B')]))) expect([rows[0].dwellMinutes, rows[0].offRoadMeters]).toEqual([90, 240]) @@ -746,6 +774,7 @@ describe('a booked night at the edge of the day', () => { variant: 'checkOut', name: 'Hotel Alpenblick', until: '10:00', + from: null, reservationId: 41, accommodationId: 5, placeId: 900, @@ -756,7 +785,11 @@ describe('a booked night at the edge of the day', () => { const stayed = day([stop('Hotel Alpenblick', { lat: 45, lng: 7, night: true }), stop('Lookout'), hotel('evening', { checkingIn: true })]) expect(bookendReading(stayed, 2)?.variant).toBe('back') const transfer = day([hotel('morning', { checkingOut: true }), hotel('evening', { checkingIn: true }, { name: 'Wallinga', lat: 46 })]) - expect(bookendReading(transfer, 1)).toMatchObject({ variant: 'checkIn', name: 'Wallinga' }) + expect(bookendReading(transfer, 1)).toMatchObject({ variant: 'checkIn', name: 'Wallinga', from: null }) + // The hour the room is ready rides along on a check-in, as a label and nowhere else. + const ready = day([hotel('morning', { checkingOut: true }), hotel('evening', { checkingIn: true, checkIn: '15:00' }, { name: 'Wallinga', lat: 46 })]) + expect(bookendReading(ready, 1)).toMatchObject({ variant: 'checkIn', from: '15:00' }) + expect(bookendReading(ready, 0)?.from).toBeNull() // Nothing to read on an ordinary stop, or past the end. expect(bookendReading(loop(), 1)).toBeNull() expect(bookendReading(loop(), 9)).toBeNull() diff --git a/client/src/components/Roadtrip/roadtripRowModel.ts b/client/src/components/Roadtrip/roadtripRowModel.ts index 5bd0bd82f..46489efb6 100644 --- a/client/src/components/Roadtrip/roadtripRowModel.ts +++ b/client/src/components/Roadtrip/roadtripRowModel.ts @@ -72,6 +72,8 @@ export interface BookendReading { name: string /** The latest the room is handed back, on the check-out morning. A label, never a departure. */ until: string | null + /** From when the room is ready, on a check-in evening. A label, never an arrival. */ + from: string | null /** The booking a tap opens, or null for a stay entered without one. */ reservationId: number | null accommodationId: number @@ -92,6 +94,7 @@ export function bookendReading(day: Pick, index: number): variant: morning ? (bookend.checkingOut ? 'checkOut' : 'from') : evening, name: stop.name, until: morning && bookend.checkingOut ? bookend.checkOut : null, + from: evening === 'checkIn' ? (bookend.checkIn ?? null) : null, reservationId: bookend.reservationId, accommodationId: bookend.accommodationId, placeId: stop.placeId, @@ -158,7 +161,13 @@ function stopRow(day: RoadtripDay, index: number, number: number | null): StopRo // for "the day ended here" and `roadtripRows` turns it into an 'auto' row instead. const stop = day.stops[index] const entry = day.schedule.entries[index] - const mine = day.driveWarnings.filter(w => w.index === index) + // Being late comes from the schedule and the drive's own findings from the limits: two + // lists, and a row reading only the second never showed a stop reached late, or a ride + // the drive gets to after it has left. + const mine = [ + ...day.driveWarnings, + ...day.schedule.warnings.filter(w => w.code === 'late' || w.code === 'missedLeave'), + ].filter(w => w.index === index) return { kind: 'stop', stop, diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx index 3120bde47..03ee2e1f9 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx @@ -1,10 +1,10 @@ -import { AlertTriangle, Bike, CarFront, Footprints, Fuel, Hourglass, Moon, Pin, Plus, RotateCcw, Shuffle, Sunrise, X, Zap } from 'lucide-react' +import { AlertTriangle, Bike, CarFront, Clock, Footprints, Fuel, Hourglass, Moon, Pin, Plus, RotateCcw, Shuffle, Sunrise, X, Zap } from 'lucide-react' import type { CSSProperties, ReactNode } from 'react' import MDancingTrek from '../../../components/MDancingTrek' import MIconBtn from '../../../components/MIconBtn' import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel' -import { bookingOpens, carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide' -import { BOOKEND_ICON, bookendMeta, bookendTitle, leavesAfterCheckOut } from '../../../../components/Roadtrip/nightBookend' +import { bookingOpens, carrierIcon, rideReading, terminalReading, type CheckInReading, type RideEnd } from '../../../../components/Roadtrip/carrierRide' +import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge } from '../../../../components/Roadtrip/nightBookend' import { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings' import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds' import { formatDistance } from '../../../../utils/units' @@ -98,9 +98,23 @@ function Disc({ row, t, onPickKind }: { row: StopRow; t: TranslationFn; onPickKi ) } -function Mark({ icon, children, tone }: { icon: ReactNode; children: ReactNode; tone?: 'warn' }) { +/** + * A small pill: an icon and a word or figure. With `value` the figure sits in a pill of its + * own inside it, the phone's badge in a badge, and `label` is the sentence a screen reader + * hears in place of the two, since a row that opens a booking sheet has no other place to + * say it. + */ +function Mark({ icon, children, tone, value, label }: { + icon: ReactNode + children: ReactNode + tone?: 'warn' + value?: ReactNode + label?: string +}) { return ( {icon} {children} + {value !== undefined && {value}} ) } @@ -115,8 +130,8 @@ function Mark({ icon, children, tone }: { icon: ReactNode; children: ReactNode; /** The short form of a finding: the number only, the sentence waits in the sheet. */ function warningMark(warning: ScheduleWarning, chrome: RowChrome): ReactNode { const { t, unit } = chrome - if (warning.code === 'late') { - return }>{`+${warning.minutes ?? 0} min`} + if (warning.code === 'late' || warning.code === 'missedLeave') { + return }>{`+${formatDurationShort((warning.minutes ?? 0) * 60)}`} } if (warning.code === 'range') { return }>{formatDistance(warning.sinceKm ?? 0, unit)} @@ -155,15 +170,19 @@ export function RtStopRow({ row, chrome, onOpen, onPickKind }: { onPickKind?: () => void }) { const { t, unit } = chrome + if (row.stop.carrier) return const marks: ReactNode[] = [] + if (row.stop.night && row.stop.checkInTime) { + marks.push( + {t('roadtrip.bookend.checkIn')}, + ) + } if (row.dwellMinutes) { marks.push( }>{formatDurationShort(row.dwellMinutes * 60)}, ) } if (row.warning) marks.push({warningMark(row.warning, chrome)}) - const terminal = row.stop.carrier ? terminalLine(row.stop.carrier, t, chrome.is12h) : null - if (terminal) marks.push({terminal}) if (!marks.length && row.offRoadMeters) { marks.push( }> @@ -194,10 +213,10 @@ export function RtStopRow({ row, chrome, onOpen, onPickKind }: { /** * A booked night at the edge of the stage: the hotel the day sets out from, or the one it - * ends at. Laid out like a stop, on the bed a stay wears, with no number and no kind to - * pick: it is the stay's place, not a stop of the day. Under it the latest hour the room - * is handed back, on the morning it is, and the one finding a row has room for. The whole - * row opens the booking behind the night, or the stay. + * ends at. Laid out like a stop, on the hotel stop's own disc with the bed a stay wears, + * with no number and no kind to pick: it is the stay's place, not a stop of the day. Under + * it the latest hour the room is handed back, on the morning it is, and the one finding a + * row has room for. The whole row opens the booking behind the night, or the stay. */ export function RtBookendRow({ row, bookend, chrome, onOpen }: { row: StopRow @@ -205,25 +224,27 @@ export function RtBookendRow({ row, bookend, chrome, onOpen }: { chrome: RowChrome onOpen: () => void }) { - const { t } = chrome - const meta = bookendMeta(bookend, t, chrome.is12h) - const afterCheckOut = leavesAfterCheckOut(bookend, row.entry) + const badge = bookendBadge(bookend, row.entry, chrome.t, chrome.is12h) return ( - + - {bookendTitle(bookend, t)} - {(meta || afterCheckOut || row.warning) && ( - - {meta && {meta}} - {afterCheckOut && }>{t('roadtrip.bookend.afterCheckOut')}} - {row.warning && warningMark(row.warning, chrome)} - - )} + {bookend.name} + + : null} + value={badge.value ?? undefined} + label={badge.warning ? [badge.lead, badge.value, badge.hint].filter(Boolean).join(' ') : undefined} + > + {badge.lead} + + {row.warning && warningMark(row.warning, chrome)} + {row.time && ( @@ -239,34 +260,95 @@ const LEG_ICONS: Record = { cycling: Bike, } -/** One end of a ride inside its block: name, code, the timetable line, the clock. */ -function RideEnd({ row, chrome }: { row: StopRow; chrome: RowChrome }) { - const carrier = row.stop.carrier! - const line = terminalLine(carrier, chrome.t, chrome.is12h) +/** The ride between two terminals, drawn in the dash the chain's legs wear, laid on its side. */ +const RIDE_CONN: CSSProperties = { + backgroundImage: 'repeating-linear-gradient(90deg, var(--m-conn) 0 4px, transparent 4px 8px)', +} + +/** A terminal's code, or the name of one without it. On the card it takes the chain's own ground. */ +function RtChip({ text, onCard }: { text: string; onCard?: boolean }) { return ( - - - - {row.stop.name} - {carrier.code && {carrier.code}} - - {line && {line}} - - {row.time && ( - - {formatClockTime(row.time, chrome.is12h)} - - )} + + {text} ) } +/** The timetable's clock at one end, with the day it falls on and its role said to a screen reader. */ +function RtClock({ end, t }: { end: RideEnd; t: TranslationFn }) { + if (!end.clock) return null + return ( + + + {end.dayOffset > 0 ? `${end.label} ${t('roadtrip.warn.overnight')}` : end.label} + + ) +} + +/** The check-in as one mark with the hour, or the lateness, in a pill inside it; see `carrierRide`. */ +function CheckInMark({ checkIn }: { checkIn: CheckInReading }) { + const warning = checkIn.state !== 'ok' + return ( + : null} + value={checkIn.value} + label={checkIn.hint} + > + {checkIn.lead} + + ) +} + /** - * A ride that leaves and lands on the day, as one block in the chain (#2428): the - * booking and its minutes at the top, the terminal it leaves from, the terminal it lands - * at. One block on one disc, because it is one thing, and three rows read as three - * places the day went to. The block is the tap target and opens the booking, the same - * sheet the map's endpoint badge opens. + * A terminal whose ride lands on another day, or a hire car's desk: the code and the place, + * the timetable's clock, and on a departure the check-in, which carries the lateness the + * row's own mark would otherwise repeat. A desk says which one it is. The tap opens the + * booking, as the rail's row does. + */ +function RtTerminalRow({ row, chrome, onOpen }: { row: StopRow; chrome: RowChrome; onOpen: () => void }) { + const { t, is12h } = chrome + const { end, desk, checkIn } = terminalReading(row.stop, row.entry, row.warning ? [row.warning] : [], t, is12h) + const warning = checkIn && row.warning?.code === 'late' ? null : row.warning + return ( + + + + + {end.code && } + {end.place} + + {(desk || checkIn || warning) && ( + + {desk && {desk}} + {checkIn && } + {warning && warningMark(warning, chrome)} + + )} + + {end.clock ? : row.time && ( + + {formatClockTime(row.time, is12h)} + + )} + + ) +} + +/** + * A ride that leaves and lands on the day, as one block in the chain (#2428), in the same + * four lines the rail draws: the booking with its minutes, the two codes with their + * timetable clocks and the ride between them, the places, and the check-in carrying + * whatever the drive makes of it. One block on one disc, because it is one thing, and + * three rows read as three places the day went to. A ride the drive cannot catch is + * edged in the warning colour. The block is the tap target and opens the booking, the + * same sheet the map's endpoint badge opens. */ export function RtRideRow({ row, chrome, onOpen }: { row: Extract @@ -274,17 +356,40 @@ export function RtRideRow({ row, chrome, onOpen }: { onOpen?: () => void }) { const Icon = carrierIcon(row.carrier.type) + const ride = rideReading({ + departure: row.departure.stop, + arrival: row.arrival.stop, + entries: [row.departure.entry, row.arrival.entry], + warnings: row.departure.warning ? [row.departure.warning] : [], + seg: row.seg, + }, chrome.t, chrome.is12h) const body = ( <> - -