mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(client): the rows a booking stands for move to RoadtripSidebarBookingRows
TerminalStop, BookendStop, RideBlock, BookingChips and UndatedRides leave RoadtripSidebar.tsx for their own module and are imported back by name. RIDE_DASH, DiscRow, CodeChip, TimetableClock and CheckInBadge go with them unexported, since only these rows use them, and the imports only they used leave the sidebar. The code moves as it was; the one change is that UndatedRides spells out the type of its booking callback instead of reading it off the sidebar's props, so the new module never imports from the sidebar. Nothing renders differently. New tests render each row on its own: a departure with its clock and check-in, a hire desk, a booked night that is left after check-out, a ride that is caught, missed or has nothing on its timetable, the chips a reader may and may not open, and the rides on no day.
This commit is contained in:
@@ -4,8 +4,7 @@ import { useElementSize } from '../../hooks/useElementSize'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
Zap, AlertTriangle,
|
||||
ParkingSquare, Clock, Spline, Ban,
|
||||
type LucideIcon,
|
||||
ParkingSquare, Spline, Ban,
|
||||
} from 'lucide-react'
|
||||
import type { RefuelSearch } from './useRefuelSearch'
|
||||
import type { RefuelCandidate } from './refuelSuggestion'
|
||||
@@ -14,11 +13,11 @@ import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { formatDate, formatClockTime } from '../../utils/formatters'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel'
|
||||
import { STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import { arrivingReroutable, bookendReading, destinationCount, legReroutable, movableWithin, resumeFoldsIntoBookend, type BookendReading } from './roadtripRowModel'
|
||||
import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge, bookendBooking } from './nightBookend'
|
||||
import { arrivingReroutable, bookendReading, destinationCount, legReroutable, movableWithin, resumeFoldsIntoBookend } from './roadtripRowModel'
|
||||
import { bookendBooking } from './nightBookend'
|
||||
import { spurWorthLabelling } from './accessSpur'
|
||||
import StopKindPicker from './StopKindPicker'
|
||||
import StopFillPicker from './StopFillPicker'
|
||||
@@ -31,19 +30,18 @@ import { ARRIVING_DRIVE, openOn, type LegAlternatives, type RailDrive } from './
|
||||
import { dayColor } from './dayColors'
|
||||
import type { RouteVia } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
import type { RouteSegment } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import AutomaticDayStop from './AutomaticDayStop'
|
||||
import { bookingOpens, carrierIcon, missedRide, rideReading, terminalReading, type CheckInReading, type RideEnd } from './carrierRide'
|
||||
import FigureBadge from './FigureBadge'
|
||||
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
|
||||
import { missedRide } from './carrierRide'
|
||||
import { dayBookings } from './stopBookings'
|
||||
import type { Reservation } from '../../types'
|
||||
import type { StayDraft } from './RoadtripStayModal'
|
||||
import { missedLeaveOf, readStay, stayDraftOf } from './stayReading'
|
||||
import { DAY_BADGE, DISC, RAIL_DASH, RAIL_GRID, STAT_LABEL } from './RoadtripSidebar.constants'
|
||||
import { Arrival, DayCarry, DriveFindingBadge, FillBadge, LateBadge, NightCheckIn, OffRoadBadge, StayBadge } from './RoadtripSidebarBadges'
|
||||
import { Arrival, DriveFindingBadge, FillBadge, LateBadge, NightCheckIn, OffRoadBadge, StayBadge } from './RoadtripSidebarBadges'
|
||||
import { RefuelBand } from './RoadtripSidebarRefuelBand'
|
||||
import { DriveBand, SpillBlock } from './RoadtripSidebarDriveBands'
|
||||
import { BookendStop, BookingChips, RideBlock, TerminalStop, UndatedRides } from './RoadtripSidebarBookingRows'
|
||||
|
||||
interface RoadtripSidebarProps {
|
||||
onFocusPoint?: (lat: number, lng: number) => void
|
||||
@@ -139,12 +137,6 @@ interface RoadtripSidebarProps {
|
||||
// own copy of each, and its rest_area icon had drifted away from the popup's.
|
||||
|
||||
|
||||
/** The rail's dash laid on its side: the ride between two terminals, drawn like a leg. */
|
||||
const RIDE_DASH: React.CSSProperties = {
|
||||
height: 1.5,
|
||||
backgroundImage: 'repeating-linear-gradient(90deg, var(--border-primary) 0 4px, transparent 4px 8px)',
|
||||
}
|
||||
|
||||
/**
|
||||
* Below this width the rail stops counting stops: the trip head drops its third figure and
|
||||
* the day headers their count, rather than squeezing every badge onto a line too short.
|
||||
@@ -244,305 +236,6 @@ function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow: boole
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One end of a ride on a row of its own: a terminal whose ride lands on another day, or a
|
||||
* hire car's desk.
|
||||
*
|
||||
* No number, no stay, no kind picker and no drag handle: it is not a stop anybody chose
|
||||
* and cannot be moved or turned into anything, it is where the booking puts the
|
||||
* traveller. Built from the ride block's pieces: the code, the place, the timetable's clock
|
||||
* on the right, and on a departure the check-in with whatever the drive makes of it. A desk
|
||||
* has no other end beside it, so it says which one it is. The whole row opens the booking.
|
||||
*/
|
||||
function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
|
||||
stop: RoadtripStop
|
||||
entry: ScheduleEntry | undefined
|
||||
late: ScheduleWarning[]
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const { end, desk, checkIn } = terminalReading(stop, entry, late, t, is12h)
|
||||
const place = <span className="min-w-0 truncate font-semibold text-content" style={{ fontSize: FS.name }}>{end.place}</span>
|
||||
return (
|
||||
<DiscRow Icon={carrierIcon(stop.carrier!.type)} starts={starts} continues={continues} onOpen={onOpen} opens={onOpen ? t('roadtrip.ride.open') : undefined}>
|
||||
<span className="flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex min-w-0 items-center gap-1.5 leading-6">
|
||||
{end.code ? <CodeChip text={end.code} /> : null}
|
||||
{onOpen ? <Tooltip label={t('roadtrip.ride.open')}>{place}</Tooltip> : place}
|
||||
</span>
|
||||
{desk || checkIn ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{desk ? <span className="text-content-muted" style={{ fontSize: FS.meta }}>{desk}</span> : null}
|
||||
{checkIn ? <CheckInBadge checkIn={checkIn} /> : null}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{end.clock ? <TimetableClock end={end} className="leading-6" /> : entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||
</span>
|
||||
</DiscRow>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the rail that is one button on a disc: a terminal, a ride, a booked night.
|
||||
* The line runs in from above unless the chain starts here, and on below while it goes on.
|
||||
*
|
||||
* What pressing it does is said after the row's own content rather than instead of it: an
|
||||
* aria-label on the button would be all a screen reader heard, and a ride block holds the
|
||||
* clocks and the warning somebody needs to hear first.
|
||||
*/
|
||||
function DiscRow({ Icon, face, starts, continues, onOpen, opens, children }: {
|
||||
Icon: LucideIcon
|
||||
/** The disc's own colours, for a row that stands for something with a face of its own. */
|
||||
face?: React.CSSProperties
|
||||
starts?: boolean
|
||||
continues: boolean
|
||||
onOpen?: () => void
|
||||
opens?: string
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
disabled={!onOpen}
|
||||
className="group grid w-full rounded-lg text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent disabled:cursor-default"
|
||||
style={RAIL_GRID}
|
||||
>
|
||||
<span className="flex flex-col items-center">
|
||||
{starts ? null : <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden />}
|
||||
<span className={`${DISC} my-1 ${face ? '' : 'bg-surface-tertiary text-content-secondary'}`} style={face}>
|
||||
<Icon size={13} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
|
||||
</span>
|
||||
{children}
|
||||
{opens ? <span className="sr-only">{opens}</span> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A booked night at the edge of the day: the hotel the day sets out from, or the one it
|
||||
* ends at (`seatNightBookends`).
|
||||
*
|
||||
* On the hotel stop's own disc, so the same hotel wears one face whether the day checks
|
||||
* in, sets out from it or comes back to it. Otherwise flat, with no number, stay, kind
|
||||
* picker or drag handle: it is the stay's place and no stop of the day, so nothing about it
|
||||
* is changed here. Under its line the latest hour the room is handed back, on the morning
|
||||
* it is, and whatever the drive into it runs over; on the right the time the chain has the
|
||||
* traveller there. The whole row opens the booking behind the night, or the hotel's place
|
||||
* when there is none to open.
|
||||
*/
|
||||
function BookendStop({ reading, entry, late, driveFindings, continues, starts, onOpen }: {
|
||||
reading: BookendReading
|
||||
entry: ScheduleEntry | undefined
|
||||
late: ScheduleWarning[]
|
||||
driveFindings: ScheduleWarning[]
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const badge = bookendBadge(reading, entry, t, is12h)
|
||||
return (
|
||||
<DiscRow Icon={BOOKEND_ICON} face={BOOKEND_DISC} starts={starts} continues={continues} onOpen={onOpen}>
|
||||
<span className="flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="min-w-0 break-words font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
{reading.name}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<FigureBadge
|
||||
tone={badge.warning ? 'warning' : 'neutral'}
|
||||
caption
|
||||
lead={badge.warning ? <><AlertTriangle size={9} aria-hidden />{badge.lead}</> : badge.lead}
|
||||
value={badge.value ?? undefined}
|
||||
tooltip={badge.hint ?? undefined}
|
||||
/>
|
||||
{driveFindings.map(w => <DriveFindingBadge key={w.code} warning={w} />)}
|
||||
{late.map(w => <LateBadge key={w.code} late={w} />)}
|
||||
</span>
|
||||
</span>
|
||||
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||
</span>
|
||||
</DiscRow>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span
|
||||
className="block h-[16px] min-w-0 max-w-[9rem] shrink-0 truncate rounded border border-edge bg-surface-card px-1 font-geist font-semibold leading-[14px] text-content"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Tooltip label={end.label ?? ''}>
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`shrink-0 whitespace-nowrap font-semibold tabular-nums text-content-secondary ${className}`}
|
||||
style={{ fontSize: FS.time }}
|
||||
>
|
||||
<span aria-hidden>{end.clock}</span>
|
||||
<DayCarry days={end.dayOffset} />
|
||||
<span className="sr-only">{end.label}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<FigureBadge
|
||||
tone={warning ? 'warning' : 'neutral'}
|
||||
caption
|
||||
lead={warning ? <><AlertTriangle size={9} aria-hidden />{checkIn.lead}</> : checkIn.lead}
|
||||
value={checkIn.value}
|
||||
tooltip={checkIn.hint}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A ride that leaves and lands on the same day, as one thing in the chain. One block on
|
||||
* one disc rather than three rows, because it IS one thing, a flight, and three rows read
|
||||
* as three places the day went to.
|
||||
*
|
||||
* Four lines, each fact once: the booking; the two codes with their timetable clocks and
|
||||
* the ride drawn between them as a leg; the places; and the check-in, which carries the
|
||||
* finding when the drive gets there late, beside the minutes the ride takes. A ride the
|
||||
* drive cannot catch edges the block in the warning colour. The whole block opens the
|
||||
* booking; the road resumes below it as usual.
|
||||
*/
|
||||
function RideBlock({ departure, arrival, entries, late, seg, continues, starts, onOpen }: {
|
||||
departure: RoadtripStop
|
||||
arrival: RoadtripStop
|
||||
entries: [ScheduleEntry | undefined, ScheduleEntry | undefined]
|
||||
late: ScheduleWarning[]
|
||||
seg: RouteSegment | undefined
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const ride = rideReading({ departure, arrival, entries, warnings: late, seg }, t, is12h)
|
||||
const title = <span className="min-w-0 flex-1 truncate font-medium text-content-secondary" style={{ fontSize: FS.meta }}>{ride.title}</span>
|
||||
return (
|
||||
<DiscRow Icon={carrierIcon(departure.carrier!.type)} starts={starts} continues={continues} onOpen={onOpen} opens={onOpen ? t('roadtrip.ride.open') : undefined}>
|
||||
<span
|
||||
className={`my-0.5 flex min-w-0 flex-col gap-1 rounded-lg border bg-surface-tertiary px-2 pb-1.5 pt-1 transition-colors group-hover:bg-surface-selected ${
|
||||
ride.checkIn?.state === 'missed' ? 'border-warning' : 'border-edge-faint'
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center">
|
||||
{onOpen ? <Tooltip label={t('roadtrip.ride.open')}>{title}</Tooltip> : title}
|
||||
</span>
|
||||
<span className="grid min-w-0 items-center gap-1.5" style={{ gridTemplateColumns: 'auto auto minmax(12px, 1fr) auto auto' }}>
|
||||
<CodeChip text={ride.from.chip} />
|
||||
{ride.from.clock ? <TimetableClock end={ride.from} /> : <span />}
|
||||
<span style={RIDE_DASH} aria-hidden />
|
||||
{ride.to.clock ? <TimetableClock end={ride.to} /> : <span />}
|
||||
<CodeChip text={ride.to.chip} />
|
||||
</span>
|
||||
{ride.from.placeLine || ride.to.placeLine ? (
|
||||
<span className="flex min-w-0 justify-between gap-2 text-content-muted" style={{ fontSize: FS.meta }}>
|
||||
<span className="min-w-0 truncate">{ride.from.placeLine}</span>
|
||||
<span className="min-w-0 truncate text-end">{ride.to.placeLine}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{ride.checkIn || ride.duration ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{ride.checkIn ? <CheckInBadge checkIn={ride.checkIn} /> : null}
|
||||
{ride.duration ? (
|
||||
<FigureBadge lead={<Clock size={9} aria-hidden />} value={ride.duration} tooltip={t('roadtrip.ride.duration')} />
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</DiscRow>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bookings a stop carries, under its row: the table, the tickets, the tour, each a
|
||||
* chip with the booking panel's icon, its name and the clock it starts at. Chips because
|
||||
* a booking is a fact about the stop, not a stop of its own: the count, the numbering
|
||||
* and the drive are none the different for it. Each opens its booking.
|
||||
*
|
||||
* Under the stop's button rather than inside it, because a button cannot hold buttons.
|
||||
* The rail keeps its line through the row so the chain reads unbroken.
|
||||
*/
|
||||
function BookingChips({ bookings, continues, canEdit, onOpen }: {
|
||||
bookings: Reservation[]
|
||||
continues: boolean
|
||||
/** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
|
||||
canEdit: boolean
|
||||
onOpen?: (reservationId: number) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
return (
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<span className="flex flex-col items-center" aria-hidden>
|
||||
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" /> : null}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-wrap gap-1 px-1.5 pb-1.5">
|
||||
{bookings.map(r => {
|
||||
const Icon = bookingIcon(r.type)
|
||||
const clock = bookingClock(r)
|
||||
const body = (
|
||||
<>
|
||||
<Icon size={10} strokeWidth={2} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate">{r.title}</span>
|
||||
{clock ? <span className="shrink-0 font-medium tabular-nums text-content-faint">{formatClockTime(clock, is12h)}</span> : null}
|
||||
</>
|
||||
)
|
||||
const chip = 'inline-flex h-[20px] max-w-full items-center gap-1 rounded-md border border-edge bg-surface-card px-1.5 text-content-secondary'
|
||||
return onOpen && bookingOpens(r, canEdit) ? (
|
||||
<Tooltip key={r.id} label={t('roadtrip.ride.open')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(r.id)}
|
||||
className={`${chip} transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span key={r.id} className={chip} style={{ fontSize: FS.meta }}>{body}</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A charger, a filling station or a rest stop — a stop that interrupts the drive.
|
||||
*
|
||||
@@ -1428,42 +1121,6 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rides the drive leaves out because they are on no day (#2461).
|
||||
*
|
||||
* The map draws such a booking's arc between its terminals all the same, so without this
|
||||
* the ferry looked planned while the rail drove round it by road. One line each, with the
|
||||
* booking a click away, since the fix is a date in the booking itself.
|
||||
*/
|
||||
function UndatedRides({ rides, onOpenBooking }: {
|
||||
rides: readonly Reservation[]
|
||||
onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
if (!rides.length) return null
|
||||
return (
|
||||
<div role="status" className="mx-3.5 mt-2 rounded-xl bg-warning-soft p-3 text-caption text-content">
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{rides.map(ride => (
|
||||
<li key={ride.id} className="flex items-start gap-2">
|
||||
<AlertTriangle size={13} strokeWidth={2} aria-hidden="true" className="mt-0.5 shrink-0 text-warning" />
|
||||
<span className="min-w-0 flex-1">{t('roadtrip.ride.undated', { title: ride.title })}</span>
|
||||
{onOpenBooking ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenBooking(ride.id)}
|
||||
className="shrink-0 font-semibold text-content underline underline-offset-2 hover:text-content-secondary"
|
||||
>
|
||||
{t('roadtrip.ride.open')}
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The road trip rail: the whole trip as one chain of stops with the driving distance
|
||||
* and time between them.
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
|
||||
import type { BookendReading } from './roadtripRowModel'
|
||||
import type { Reservation, RouteSegment } from '../../types'
|
||||
import { BookendStop, BookingChips, RideBlock, TerminalStop, UndatedRides } from './RoadtripSidebarBookingRows'
|
||||
import { tooltipOf } from '../../../tests/helpers/roadtripRail'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
function settings(over: { time_format?: '12h' | '24h' } = {}): void {
|
||||
useSettingsStore.setState({ settings: { distance_unit: 'metric', time_format: '24h', ...over } as never })
|
||||
}
|
||||
|
||||
beforeEach(() => settings())
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
const carrier = (over: Partial<CarrierTerminal> = {}): CarrierTerminal =>
|
||||
({ reservationId: 7, type: 'flight', role: 'departure', title: 'LH 2078', code: 'HAM', at: '15:15', ...over })
|
||||
|
||||
function stop(over: Partial<RoadtripStop> = {}): RoadtripStop {
|
||||
return {
|
||||
assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'Hamburg Airport', lat: 53.6, lng: 10,
|
||||
time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const entry = (over: Partial<ScheduleEntry> = {}): ScheduleEntry =>
|
||||
({ arrival: null, departure: null, anchored: false, dayOffset: 0, ...over })
|
||||
|
||||
const warning = (over: Partial<ScheduleWarning> & Pick<ScheduleWarning, 'code'>): ScheduleWarning => ({ index: 0, ...over })
|
||||
|
||||
const reading = (over: Partial<BookendReading> = {}): BookendReading => ({
|
||||
phase: 'morning', variant: 'checkOut', name: 'Hotel Alster', until: '10:00', from: null,
|
||||
reservationId: 31, accommodationId: 4, placeId: 40,
|
||||
...over,
|
||||
})
|
||||
|
||||
const booking = (over: Partial<Reservation> & Pick<Reservation, 'id' | 'type' | 'title'>): Reservation =>
|
||||
({ reservation_time: null, ...over }) as Reservation
|
||||
|
||||
/** The rail column of a disc row: the line above, the disc, the line below. */
|
||||
const railOf = (row: Element): Element => row.firstElementChild!
|
||||
const lines = (row: Element): number => railOf(row).querySelectorAll(':scope > .w-\\[1\\.5px\\]').length
|
||||
|
||||
describe('TerminalStop', () => {
|
||||
it('FE-ROADTRIP-BOOKINGROWS-001: a departure on a row of its own reads its code, its timetable clock and the check-in, and opens the booking', () => {
|
||||
const onOpen = vi.fn()
|
||||
const { container } = wrap(
|
||||
<TerminalStop
|
||||
stop={stop({ time: '13:15', carrier: carrier() })}
|
||||
entry={entry({ dayOffset: 1 })}
|
||||
late={[]}
|
||||
continues
|
||||
onOpen={onOpen}
|
||||
/>,
|
||||
)
|
||||
const row = screen.getByRole('button')
|
||||
expect(row).toBeEnabled()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
|
||||
// The timetable's clock, its day carry and its words for a screen reader.
|
||||
expect(screen.getByText('15:15')).toHaveAttribute('aria-hidden')
|
||||
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Departure 15:15')).toHaveClass('sr-only')
|
||||
expect(screen.getByText('Check-in')).toBeInTheDocument()
|
||||
expect(screen.getByText('13:15')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg.lucide-plane')).not.toBeNull()
|
||||
// What pressing it does comes after the row's own content.
|
||||
expect(row.lastElementChild).toHaveTextContent('Open booking')
|
||||
expect(row.lastElementChild).toHaveClass('sr-only')
|
||||
expect(lines(row)).toBe(2)
|
||||
expect(tooltipOf(screen.getByText('Hamburg Airport'))).toBe('Open booking')
|
||||
fireEvent.click(row)
|
||||
expect(onOpen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-BOOKINGROWS-002: a hire desk says which one it is, falls back to the arrival clock and stays inert without a booking to open', () => {
|
||||
const desk = carrier({ type: 'car', role: 'pickup', code: null, at: null, title: 'Sixt' })
|
||||
const first = wrap(
|
||||
<TerminalStop stop={stop({ name: 'Sixt Hamburg', carrier: desk })} entry={entry({ arrival: '09:40' })} late={[]} continues={false} starts />,
|
||||
)
|
||||
const row = screen.getByRole('button')
|
||||
expect(row).toBeDisabled()
|
||||
expect(screen.getByText('Pickup')).toBeInTheDocument()
|
||||
expect(screen.getByText('09:40')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Open booking')).toBeNull()
|
||||
expect(first.container.querySelector('svg.lucide-car')).not.toBeNull()
|
||||
// The first row of the day on the last stop of it: no line either way.
|
||||
expect(lines(row)).toBe(0)
|
||||
first.unmount()
|
||||
|
||||
// Neither a timetable nor a drive arrival: the clock column stays empty.
|
||||
wrap(<TerminalStop stop={stop({ name: 'Sixt Hamburg', carrier: desk })} entry={undefined} late={[]} continues />)
|
||||
expect(screen.getByRole('button')).not.toHaveTextContent(/\d/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BookendStop', () => {
|
||||
it('FE-ROADTRIP-BOOKINGROWS-003: leaving after the room is handed back turns the badge into the warning, with the drive findings and lateness beside it', () => {
|
||||
const onOpen = vi.fn()
|
||||
const { container } = wrap(
|
||||
<BookendStop
|
||||
reading={reading()}
|
||||
entry={entry({ arrival: '12:30' })}
|
||||
late={[warning({ code: 'late', minutes: 20 })]}
|
||||
driveFindings={[warning({ code: 'leg', overMinutes: 45 })]}
|
||||
continues
|
||||
starts
|
||||
onOpen={onOpen}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Hotel Alster')).toBeInTheDocument()
|
||||
expect(screen.getByText('Check-out')).toBeInTheDocument()
|
||||
expect(screen.getByText('10:00')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg.lucide-triangle-alert, svg.lucide-alert-triangle')).not.toBeNull()
|
||||
expect(tooltipOf(screen.getByText('10:00').parentElement!)).toBe('Leaves after check-out')
|
||||
expect(screen.getByText('+45 min')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Arrives 20 min after the time you set')).toBeInTheDocument()
|
||||
expect(screen.getByText('12:30')).toBeInTheDocument()
|
||||
// The bed on the hotel's own disc, starting the chain.
|
||||
const row = screen.getByRole('button')
|
||||
expect(container.querySelector('svg.lucide-bed-double')).not.toBeNull()
|
||||
expect(lines(row)).toBe(1)
|
||||
expect(screen.queryByText('Open booking')).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(onOpen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-BOOKINGROWS-004: a check-in evening reads the hour the room is ready, plainly, with no clock of its own', () => {
|
||||
const { container } = wrap(
|
||||
<BookendStop
|
||||
reading={reading({ phase: 'evening', variant: 'checkIn', until: null, from: '15:00' })}
|
||||
entry={undefined}
|
||||
late={[]}
|
||||
driveFindings={[]}
|
||||
continues={false}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Check-in')).toBeInTheDocument()
|
||||
expect(screen.getByText('15:00')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg.lucide-triangle-alert, svg.lucide-alert-triangle')).toBeNull()
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
expect(lines(screen.getByRole('button'))).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RideBlock', () => {
|
||||
const departure = stop({ name: 'Hamburg Airport (HAM)', time: '13:15', carrier: carrier() })
|
||||
const arrival = stop({
|
||||
assignmentId: 2, name: 'Munich Airport', carrier: carrier({ role: 'arrival', code: 'MUC', at: '16:30' }),
|
||||
})
|
||||
const seg = { distance: 600000, duration: 4500, durationText: '1 h 15 min', mode: 'flight' } as RouteSegment
|
||||
|
||||
it('FE-ROADTRIP-BOOKINGROWS-005: a ride on one day is one block: its title, both ends with their clocks, the places, the check-in and the minutes', () => {
|
||||
const onOpen = vi.fn()
|
||||
wrap(
|
||||
<RideBlock
|
||||
departure={departure}
|
||||
arrival={arrival}
|
||||
entries={[entry(), entry()]}
|
||||
late={[]}
|
||||
seg={seg}
|
||||
continues
|
||||
onOpen={onOpen}
|
||||
/>,
|
||||
)
|
||||
const row = screen.getByRole('button')
|
||||
const block = row.children[1]
|
||||
expect(block).toHaveClass('border-edge-faint')
|
||||
expect(screen.getByText('LH 2078')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
expect(screen.getByText('MUC')).toBeInTheDocument()
|
||||
expect(screen.getByText('Departure 15:15')).toHaveClass('sr-only')
|
||||
expect(screen.getByText('Arrival 16:30')).toHaveClass('sr-only')
|
||||
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
|
||||
expect(screen.getByText('Munich Airport')).toHaveClass('text-end')
|
||||
expect(screen.getByText('Check-in')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
|
||||
expect(tooltipOf(screen.getByText('1 h 15 min').parentElement!)).toBe('Duration per the booking')
|
||||
expect(tooltipOf(screen.getByText('LH 2078'))).toBe('Open booking')
|
||||
fireEvent.click(row)
|
||||
expect(onOpen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-BOOKINGROWS-006: a ride the drive cannot catch edges the block in the warning colour, its check-in standing alone without minutes', () => {
|
||||
wrap(
|
||||
<RideBlock
|
||||
departure={departure}
|
||||
arrival={arrival}
|
||||
entries={[entry(), entry()]}
|
||||
late={[warning({ code: 'late', minutes: 180 })]}
|
||||
seg={undefined}
|
||||
continues={false}
|
||||
starts
|
||||
/>,
|
||||
)
|
||||
const row = screen.getByRole('button')
|
||||
expect(row).toBeDisabled()
|
||||
expect(row.children[1]).toHaveClass('border-warning')
|
||||
expect(screen.getByText('Missed')).toBeInTheDocument()
|
||||
expect(screen.getByText('there at 16:15')).toBeInTheDocument()
|
||||
// No minutes on the timetable: the check-in stands alone.
|
||||
expect(screen.getByText('Missed').closest('.flex-wrap')!.children).toHaveLength(1)
|
||||
expect(lines(row)).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-BOOKINGROWS-007: a booking with no codes, no clocks and no minutes keeps its strip and drops the lines it has nothing for', () => {
|
||||
wrap(
|
||||
<RideBlock
|
||||
departure={stop({ name: 'Kiel', carrier: carrier({ type: 'ferry', code: null, at: null, title: 'Color Line' }) })}
|
||||
arrival={stop({ assignmentId: 2, name: 'Oslo', carrier: carrier({ type: 'ferry', role: 'arrival', code: null, at: null }) })}
|
||||
entries={[undefined, undefined]}
|
||||
late={[]}
|
||||
seg={undefined}
|
||||
continues
|
||||
/>,
|
||||
)
|
||||
const block = screen.getByRole('button').children[1]
|
||||
// Title and strip only: no place line under chips that already are the names, and no
|
||||
// check-in or minutes row.
|
||||
expect(block.children).toHaveLength(2)
|
||||
const strip = block.children[1]
|
||||
expect(strip.children).toHaveLength(5)
|
||||
expect(strip.children[0]).toHaveTextContent('Kiel')
|
||||
expect(strip.children[1]).toBeEmptyDOMElement()
|
||||
expect(strip.children[3]).toBeEmptyDOMElement()
|
||||
expect(strip.children[4]).toHaveTextContent('Oslo')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-BOOKINGROWS-008: the minutes stand alone when the timetable gives no check-in', () => {
|
||||
wrap(
|
||||
<RideBlock
|
||||
departure={stop({ name: 'Kiel', carrier: carrier({ type: 'ferry', code: null, at: '14:00' }) })}
|
||||
arrival={stop({ assignmentId: 2, name: 'Oslo', carrier: carrier({ type: 'ferry', role: 'arrival', code: null, at: null }) })}
|
||||
entries={[entry(), entry()]}
|
||||
late={[]}
|
||||
seg={{ ...seg, durationText: '' }}
|
||||
continues
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('Check-in')).toBeNull()
|
||||
expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('14:00')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BookingChips', () => {
|
||||
const table = booking({ id: 1, type: 'restaurant', title: 'Fischereihafen', reservation_time: '2026-06-01T19:30' })
|
||||
const train = booking({ id: 2, type: 'train', title: 'ICE 1007' })
|
||||
|
||||
it('FE-ROADTRIP-BOOKINGROWS-009: a chip opens when this reader may open it, and the rest stay plain chips', () => {
|
||||
const onOpen = vi.fn()
|
||||
const { container } = wrap(<BookingChips bookings={[table, train]} continues canEdit={false} onOpen={onOpen} />)
|
||||
expect(screen.getByText('19:30')).toBeInTheDocument()
|
||||
// The table has only its editor, which this reader may not open; the train has a view.
|
||||
expect(screen.getByText('Fischereihafen').closest('button')).toBeNull()
|
||||
const ride = screen.getByRole('button')
|
||||
expect(ride).toHaveTextContent('ICE 1007')
|
||||
fireEvent.click(ride)
|
||||
expect(onOpen).toHaveBeenCalledWith(2)
|
||||
expect(container.querySelector('.w-\\[1\\.5px\\]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-BOOKINGROWS-010: an editor opens every chip, and without a way to open any they are all plain', () => {
|
||||
const onOpen = vi.fn()
|
||||
const editor = wrap(<BookingChips bookings={[table, train]} continues={false} canEdit onOpen={onOpen} />)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2)
|
||||
fireEvent.click(screen.getByText('Fischereihafen'))
|
||||
expect(onOpen).toHaveBeenCalledWith(1)
|
||||
expect(editor.container.querySelector('.w-\\[1\\.5px\\]')).toBeNull()
|
||||
editor.unmount()
|
||||
|
||||
wrap(<BookingChips bookings={[table, train]} continues={false} canEdit />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByText('ICE 1007')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-BOOKINGROWS-011: the clock follows the traveller\'s format', () => {
|
||||
settings({ time_format: '12h' })
|
||||
wrap(<BookingChips bookings={[table]} continues={false} canEdit={false} />)
|
||||
expect(screen.getByText('7:30 PM')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UndatedRides', () => {
|
||||
const ferry = booking({ id: 9, type: 'ferry', title: 'Stena Line' })
|
||||
|
||||
it('FE-ROADTRIP-BOOKINGROWS-012: nothing on no day draws nothing', () => {
|
||||
const { container } = wrap(<UndatedRides rides={[]} onOpenBooking={vi.fn()} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-BOOKINGROWS-013: each ride on no day is a line, with its booking a click away when it can be opened', () => {
|
||||
const onOpenBooking = vi.fn()
|
||||
const open = wrap(<UndatedRides rides={[ferry]} onOpenBooking={onOpenBooking} />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Stena Line is on none of this trip’s days, so the drive does not use it.')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open booking' }))
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(9)
|
||||
open.unmount()
|
||||
|
||||
wrap(<UndatedRides rides={[ferry]} />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,358 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, Clock, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
|
||||
import type { BookendReading } from './roadtripRowModel'
|
||||
import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge } from './nightBookend'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { Reservation, RouteSegment } from '../../types'
|
||||
import { bookingOpens, carrierIcon, rideReading, terminalReading, type CheckInReading, type RideEnd } from './carrierRide'
|
||||
import FigureBadge from './FigureBadge'
|
||||
import { bookingClock, bookingIcon } from './stopBookings'
|
||||
import { FS } from './typeScale'
|
||||
import { DISC, RAIL_GRID } from './RoadtripSidebar.constants'
|
||||
import { Arrival, DayCarry, DriveFindingBadge, LateBadge } from './RoadtripSidebarBadges'
|
||||
|
||||
/** The rail's dash laid on its side: the ride between two terminals, drawn like a leg. */
|
||||
const RIDE_DASH: React.CSSProperties = {
|
||||
height: 1.5,
|
||||
backgroundImage: 'repeating-linear-gradient(90deg, var(--border-primary) 0 4px, transparent 4px 8px)',
|
||||
}
|
||||
|
||||
/**
|
||||
* One end of a ride on a row of its own: a terminal whose ride lands on another day, or a
|
||||
* hire car's desk.
|
||||
*
|
||||
* No number, no stay, no kind picker and no drag handle: it is not a stop anybody chose
|
||||
* and cannot be moved or turned into anything, it is where the booking puts the
|
||||
* traveller. Built from the ride block's pieces: the code, the place, the timetable's clock
|
||||
* on the right, and on a departure the check-in with whatever the drive makes of it. A desk
|
||||
* has no other end beside it, so it says which one it is. The whole row opens the booking.
|
||||
*/
|
||||
export function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
|
||||
stop: RoadtripStop
|
||||
entry: ScheduleEntry | undefined
|
||||
late: ScheduleWarning[]
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const { end, desk, checkIn } = terminalReading(stop, entry, late, t, is12h)
|
||||
const place = <span className="min-w-0 truncate font-semibold text-content" style={{ fontSize: FS.name }}>{end.place}</span>
|
||||
return (
|
||||
<DiscRow Icon={carrierIcon(stop.carrier!.type)} starts={starts} continues={continues} onOpen={onOpen} opens={onOpen ? t('roadtrip.ride.open') : undefined}>
|
||||
<span className="flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex min-w-0 items-center gap-1.5 leading-6">
|
||||
{end.code ? <CodeChip text={end.code} /> : null}
|
||||
{onOpen ? <Tooltip label={t('roadtrip.ride.open')}>{place}</Tooltip> : place}
|
||||
</span>
|
||||
{desk || checkIn ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{desk ? <span className="text-content-muted" style={{ fontSize: FS.meta }}>{desk}</span> : null}
|
||||
{checkIn ? <CheckInBadge checkIn={checkIn} /> : null}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{end.clock ? <TimetableClock end={end} className="leading-6" /> : entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||
</span>
|
||||
</DiscRow>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the rail that is one button on a disc: a terminal, a ride, a booked night.
|
||||
* The line runs in from above unless the chain starts here, and on below while it goes on.
|
||||
*
|
||||
* What pressing it does is said after the row's own content rather than instead of it: an
|
||||
* aria-label on the button would be all a screen reader heard, and a ride block holds the
|
||||
* clocks and the warning somebody needs to hear first.
|
||||
*/
|
||||
function DiscRow({ Icon, face, starts, continues, onOpen, opens, children }: {
|
||||
Icon: LucideIcon
|
||||
/** The disc's own colours, for a row that stands for something with a face of its own. */
|
||||
face?: React.CSSProperties
|
||||
starts?: boolean
|
||||
continues: boolean
|
||||
onOpen?: () => void
|
||||
opens?: string
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
disabled={!onOpen}
|
||||
className="group grid w-full rounded-lg text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent disabled:cursor-default"
|
||||
style={RAIL_GRID}
|
||||
>
|
||||
<span className="flex flex-col items-center">
|
||||
{starts ? null : <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden />}
|
||||
<span className={`${DISC} my-1 ${face ? '' : 'bg-surface-tertiary text-content-secondary'}`} style={face}>
|
||||
<Icon size={13} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
|
||||
</span>
|
||||
{children}
|
||||
{opens ? <span className="sr-only">{opens}</span> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A booked night at the edge of the day: the hotel the day sets out from, or the one it
|
||||
* ends at (`seatNightBookends`).
|
||||
*
|
||||
* On the hotel stop's own disc, so the same hotel wears one face whether the day checks
|
||||
* in, sets out from it or comes back to it. Otherwise flat, with no number, stay, kind
|
||||
* picker or drag handle: it is the stay's place and no stop of the day, so nothing about it
|
||||
* is changed here. Under its line the latest hour the room is handed back, on the morning
|
||||
* it is, and whatever the drive into it runs over; on the right the time the chain has the
|
||||
* traveller there. The whole row opens the booking behind the night, or the hotel's place
|
||||
* when there is none to open.
|
||||
*/
|
||||
export function BookendStop({ reading, entry, late, driveFindings, continues, starts, onOpen }: {
|
||||
reading: BookendReading
|
||||
entry: ScheduleEntry | undefined
|
||||
late: ScheduleWarning[]
|
||||
driveFindings: ScheduleWarning[]
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const badge = bookendBadge(reading, entry, t, is12h)
|
||||
return (
|
||||
<DiscRow Icon={BOOKEND_ICON} face={BOOKEND_DISC} starts={starts} continues={continues} onOpen={onOpen}>
|
||||
<span className="flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="min-w-0 break-words font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
{reading.name}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<FigureBadge
|
||||
tone={badge.warning ? 'warning' : 'neutral'}
|
||||
caption
|
||||
lead={badge.warning ? <><AlertTriangle size={9} aria-hidden />{badge.lead}</> : badge.lead}
|
||||
value={badge.value ?? undefined}
|
||||
tooltip={badge.hint ?? undefined}
|
||||
/>
|
||||
{driveFindings.map(w => <DriveFindingBadge key={w.code} warning={w} />)}
|
||||
{late.map(w => <LateBadge key={w.code} late={w} />)}
|
||||
</span>
|
||||
</span>
|
||||
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||
</span>
|
||||
</DiscRow>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span
|
||||
className="block h-[16px] min-w-0 max-w-[9rem] shrink-0 truncate rounded border border-edge bg-surface-card px-1 font-geist font-semibold leading-[14px] text-content"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Tooltip label={end.label ?? ''}>
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`shrink-0 whitespace-nowrap font-semibold tabular-nums text-content-secondary ${className}`}
|
||||
style={{ fontSize: FS.time }}
|
||||
>
|
||||
<span aria-hidden>{end.clock}</span>
|
||||
<DayCarry days={end.dayOffset} />
|
||||
<span className="sr-only">{end.label}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<FigureBadge
|
||||
tone={warning ? 'warning' : 'neutral'}
|
||||
caption
|
||||
lead={warning ? <><AlertTriangle size={9} aria-hidden />{checkIn.lead}</> : checkIn.lead}
|
||||
value={checkIn.value}
|
||||
tooltip={checkIn.hint}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A ride that leaves and lands on the same day, as one thing in the chain. One block on
|
||||
* one disc rather than three rows, because it IS one thing, a flight, and three rows read
|
||||
* as three places the day went to.
|
||||
*
|
||||
* Four lines, each fact once: the booking; the two codes with their timetable clocks and
|
||||
* the ride drawn between them as a leg; the places; and the check-in, which carries the
|
||||
* finding when the drive gets there late, beside the minutes the ride takes. A ride the
|
||||
* drive cannot catch edges the block in the warning colour. The whole block opens the
|
||||
* booking; the road resumes below it as usual.
|
||||
*/
|
||||
export function RideBlock({ departure, arrival, entries, late, seg, continues, starts, onOpen }: {
|
||||
departure: RoadtripStop
|
||||
arrival: RoadtripStop
|
||||
entries: [ScheduleEntry | undefined, ScheduleEntry | undefined]
|
||||
late: ScheduleWarning[]
|
||||
seg: RouteSegment | undefined
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const ride = rideReading({ departure, arrival, entries, warnings: late, seg }, t, is12h)
|
||||
const title = <span className="min-w-0 flex-1 truncate font-medium text-content-secondary" style={{ fontSize: FS.meta }}>{ride.title}</span>
|
||||
return (
|
||||
<DiscRow Icon={carrierIcon(departure.carrier!.type)} starts={starts} continues={continues} onOpen={onOpen} opens={onOpen ? t('roadtrip.ride.open') : undefined}>
|
||||
<span
|
||||
className={`my-0.5 flex min-w-0 flex-col gap-1 rounded-lg border bg-surface-tertiary px-2 pb-1.5 pt-1 transition-colors group-hover:bg-surface-selected ${
|
||||
ride.checkIn?.state === 'missed' ? 'border-warning' : 'border-edge-faint'
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center">
|
||||
{onOpen ? <Tooltip label={t('roadtrip.ride.open')}>{title}</Tooltip> : title}
|
||||
</span>
|
||||
<span className="grid min-w-0 items-center gap-1.5" style={{ gridTemplateColumns: 'auto auto minmax(12px, 1fr) auto auto' }}>
|
||||
<CodeChip text={ride.from.chip} />
|
||||
{ride.from.clock ? <TimetableClock end={ride.from} /> : <span />}
|
||||
<span style={RIDE_DASH} aria-hidden />
|
||||
{ride.to.clock ? <TimetableClock end={ride.to} /> : <span />}
|
||||
<CodeChip text={ride.to.chip} />
|
||||
</span>
|
||||
{ride.from.placeLine || ride.to.placeLine ? (
|
||||
<span className="flex min-w-0 justify-between gap-2 text-content-muted" style={{ fontSize: FS.meta }}>
|
||||
<span className="min-w-0 truncate">{ride.from.placeLine}</span>
|
||||
<span className="min-w-0 truncate text-end">{ride.to.placeLine}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{ride.checkIn || ride.duration ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{ride.checkIn ? <CheckInBadge checkIn={ride.checkIn} /> : null}
|
||||
{ride.duration ? (
|
||||
<FigureBadge lead={<Clock size={9} aria-hidden />} value={ride.duration} tooltip={t('roadtrip.ride.duration')} />
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</DiscRow>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bookings a stop carries, under its row: the table, the tickets, the tour, each a
|
||||
* chip with the booking panel's icon, its name and the clock it starts at. Chips because
|
||||
* a booking is a fact about the stop, not a stop of its own: the count, the numbering
|
||||
* and the drive are none the different for it. Each opens its booking.
|
||||
*
|
||||
* Under the stop's button rather than inside it, because a button cannot hold buttons.
|
||||
* The rail keeps its line through the row so the chain reads unbroken.
|
||||
*/
|
||||
export function BookingChips({ bookings, continues, canEdit, onOpen }: {
|
||||
bookings: Reservation[]
|
||||
continues: boolean
|
||||
/** Whether a table's or a ticket's editor opens for this reader; see `bookingOpens`. */
|
||||
canEdit: boolean
|
||||
onOpen?: (reservationId: number) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
return (
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<span className="flex flex-col items-center" aria-hidden>
|
||||
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" /> : null}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-wrap gap-1 px-1.5 pb-1.5">
|
||||
{bookings.map(r => {
|
||||
const Icon = bookingIcon(r.type)
|
||||
const clock = bookingClock(r)
|
||||
const body = (
|
||||
<>
|
||||
<Icon size={10} strokeWidth={2} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate">{r.title}</span>
|
||||
{clock ? <span className="shrink-0 font-medium tabular-nums text-content-faint">{formatClockTime(clock, is12h)}</span> : null}
|
||||
</>
|
||||
)
|
||||
const chip = 'inline-flex h-[20px] max-w-full items-center gap-1 rounded-md border border-edge bg-surface-card px-1.5 text-content-secondary'
|
||||
return onOpen && bookingOpens(r, canEdit) ? (
|
||||
<Tooltip key={r.id} label={t('roadtrip.ride.open')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(r.id)}
|
||||
className={`${chip} transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span key={r.id} className={chip} style={{ fontSize: FS.meta }}>{body}</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rides the drive leaves out because they are on no day (#2461).
|
||||
*
|
||||
* The map draws such a booking's arc between its terminals all the same, so without this
|
||||
* the ferry looked planned while the rail drove round it by road. One line each, with the
|
||||
* booking a click away, since the fix is a date in the booking itself.
|
||||
*/
|
||||
export function UndatedRides({ rides, onOpenBooking }: {
|
||||
rides: readonly Reservation[]
|
||||
onOpenBooking?: (reservationId: number) => void
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
if (!rides.length) return null
|
||||
return (
|
||||
<div role="status" className="mx-3.5 mt-2 rounded-xl bg-warning-soft p-3 text-caption text-content">
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{rides.map(ride => (
|
||||
<li key={ride.id} className="flex items-start gap-2">
|
||||
<AlertTriangle size={13} strokeWidth={2} aria-hidden="true" className="mt-0.5 shrink-0 text-warning" />
|
||||
<span className="min-w-0 flex-1">{t('roadtrip.ride.undated', { title: ride.title })}</span>
|
||||
{onOpenBooking ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenBooking(ride.id)}
|
||||
className="shrink-0 font-semibold text-content underline underline-offset-2 hover:text-content-secondary"
|
||||
>
|
||||
{t('roadtrip.ride.open')}
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user