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 = ( <> - -