diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index 1cf950c49..5898cfbb9 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -4,7 +4,7 @@ import { useElementSize } from '../../hooks/useElementSize'
import React, { useMemo, useState } from 'react'
import {
CarFront, Footprints, Bike, Zap, AlertTriangle,
- ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone,
+ ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, Moon, Milestone,
type LucideIcon,
} from 'lucide-react'
import MDancingTrek from '../../mobile/components/MDancingTrek'
@@ -40,8 +40,9 @@ import FigureBadge from './FigureBadge'
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
import type { Reservation } from '../../types'
import type { StayDraft } from './RoadtripStayModal'
-import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading'
+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'
interface RoadtripSidebarProps {
onFocusPoint?: (lat: number, lng: number) => void
@@ -155,50 +156,6 @@ const RIDE_DASH: React.CSSProperties = {
*/
const RAIL_NARROW_PX = 330
-/**
- * How long the traveller stays here, and the way to change it.
- *
- * On every stop, not only the ones that already carry a time: the value has never been
- * editable anywhere in TREK, so a stop that has none needs somewhere to say so before it
- * can get one. Unset it reads as a plus in the slot the number will occupy, which keeps
- * the two states the same shape and the same width — a row does not jump when a stay is
- * added to it.
- *
- * Read-only for someone who cannot edit the trip: then it is a label, and a stop without
- * a stay shows nothing at all rather than an invitation that leads nowhere.
- */
-/**
- * How far the road stops short of the place, in the same two-part shell the stay wears.
- *
- * Beside the stay rather than under it: both answer "what does this stop cost you", one
- * in time and one in a walk, and two badges on one line read as one fact about the stop
- * instead of two unrelated notes stacked up.
- */
-function OffRoadBadge({ meters }: { meters: number }): React.ReactElement {
- const { t } = useTranslation()
- const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
- return (
- }
- value={formatDistance(meters / 1000, distanceUnit)}
- tooltip={t('roadtrip.stop.offRoad', { distance: formatDistance(meters / 1000, distanceUnit) })}
- />
- )
-}
-
-/**
- * What a fill-up here actually puts in, beside the stay it takes.
- *
- * Only on a stop that refuels, because it is the only stop where the answer changes
- * anything. It reads as the second half of the stay badge because that is what it is —
- * how long you stand here, and what you get for it.
- *
- * The figure is the stop's own when it has one and the traveller's default otherwise, so
- * what the badge says is always what the range budget will actually use. With neither it
- * shows a "+", the same invitation the stay badge makes when nothing is planned yet: this
- * badge is the only way into the per-stop figure, and a badge that hid itself until a
- * value existed could never be used to create one.
- */
/**
* What a stop will actually fill to: its own figure, else the traveller's default.
*
@@ -212,70 +169,6 @@ function effectiveFill(own: number | null | undefined, setting: number | undefin
return setting && setting > 0 && setting < 100 ? setting : null
}
-function FillBadge({ percent, own, onEdit }: {
- /** What this stop will actually fill to, inherited or not. Null when nothing says. */
- percent: number | null
- /** Whether that figure is the stop's own rather than the traveller's default. */
- own: boolean
- onEdit?: (anchor: HTMLElement) => void
-}): React.ReactElement | null {
- const { t } = useTranslation()
- if (percent === null && !onEdit) return null
- return (
- }
- value={percent === null ? '+' : `${percent} %`}
- faint={percent === null || !own}
- valueSize="micro"
- tooltip={onEdit ? (percent === null ? t('roadtrip.stop.fillSet') : t('roadtrip.limit.fillBadge', { percent })) : undefined}
- onActivate={onEdit}
- />
- )
-}
-
-/**
- * How long the stop takes. A stop the traveller leaves at a set time is stood at until
- * then, so it reads the stay the schedule made of that and says until when.
- */
-/**
- * The check-in of the night a stay's own stop begins, on the day it does: the hour the
- * room is ready, which is also what holds the arrival clock beside it when the drive gets
- * there first.
- */
-function NightCheckIn({ stop }: { stop: RoadtripStop }): React.ReactElement | null {
- const { t } = useTranslation()
- const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
- if (!stop.night || !stop.checkInTime) return null
- return
-}
-
-function StayBadge({ stay, onEdit }: { stay: StayReading; onEdit?: () => void }): React.ReactElement | null {
- const { t } = useTranslation()
- const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
- const shown = shownStay(stay)
- const text = shown === null ? null : formatDurationShort(shown * 60)
- const until = stay.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null
- if (!text && !until && !onEdit) return null
- return (
-
- {text ?? (until ? null : '+')}
- {until ? {until} : null}
- >
- )}
- faint={!text}
- tooltip={onEdit ? t('roadtrip.stop.stay') : undefined}
- onActivate={onEdit}
- ariaLabel={onEdit
- ? text || until ? `${t('roadtrip.stop.stay')}: ${[text, until].filter(Boolean).join(' ')}` : t('roadtrip.stay.add')
- : undefined}
- />
- )
-}
-
/** The quiet half of a measurement — the unit, and anything after the decimal point. */
function Unit({ children }: { children: React.ReactNode }): React.ReactElement {
return {children}
@@ -1159,84 +1052,6 @@ function RouteViaStop({ via }: { via: RouteVia }): React.ReactElement {
)
}
-/**
- * A finding about the drive leaving this stop: too long at the wheel in one go, or the
- * tank running out before anywhere to fill it.
- *
- * Wears the same two-part shell as the stay and the walk, in warning colours, and sits in
- * the same row: all of them say what this stop costs, and one row of badges reads as one
- * answer rather than as notes stacked under each other. The index is the stop the leg
- * LEAVES, so the badge means "the drive from here".
- *
- * Neither finding names a time of day. With no stop pinned to a clock the cascade
- * produces no times at all, so both are durations and distances, which exist as soon as
- * the leg has routed.
- */
-function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.ReactElement | null {
- const { t } = useTranslation()
- const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
- const text = warning.code === 'leg'
- ? t('roadtrip.limit.legOver', { time: formatDurationShort((warning.overMinutes ?? 0) * 60) })
- : warning.code === 'range'
- ? t('roadtrip.limit.range', { distance: formatDistance(warning.sinceKm ?? 0, distanceUnit) })
- : null
- if (!text) return null
- const Icon = warning.code === 'range' ? Fuel : Clock
- return (
- }
- 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')}`}
-
- )
-}
-
-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)
- 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
- // look like it had a button in it. What is left of the distinction is weight and ink —
- // enough to see which time somebody chose, without a second shape in the rail.
- return (
-
-
- {text}
- {carry}
-
-
- )
-}
-
function Stop({ stop, number, entry, late, driveFindings, selected, continues, starts, onSelect, onMove, canMove, onEditStay, onPickKind, onPickFill }: {
stop: RoadtripStop
/** Position within the day — the same count the map badges its markers with. */
@@ -1367,32 +1182,6 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
)
}
-/**
- * How far past the time you set this stop is reached. Its own component because a pause
- * runs late exactly like a numbered stop does: the schedule computes the finding for both
- * (roadtripModel restarts the chain at any anchor, whatever kind of stop carries it), and
- * drawing it in only one of them threw the other one's away.
- *
- * It sits in the badge row beside the stay and the drive findings rather than on a line of
- * its own: they are all answers to "what does this stop cost", and a warning on its own row
- * pushed every following stop down for a finding that fits in a pill.
- */
-function LateBadge({ late }: { late: ScheduleWarning }): React.ReactElement {
- const { t } = useTranslation()
- const label = t(late.code === 'missedLeave' ? 'roadtrip.warn.missedLeave' : 'roadtrip.warn.late', { minutes: late.minutes ?? 0 })
- // The same two-part shell the stay and the drive findings wear, so a row of badges reads
- // as one set instead of a pill among boxes.
- return (
- }
- value={`+${formatDurationShort((late.minutes ?? 0) * 60)}`}
- tooltip={label}
- />
- )
-}
-
/**
* What a night drive leaves on the next morning's card.
*
diff --git a/client/src/components/Roadtrip/RoadtripSidebarBadges.test.tsx b/client/src/components/Roadtrip/RoadtripSidebarBadges.test.tsx
new file mode 100644
index 000000000..c751c2292
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripSidebarBadges.test.tsx
@@ -0,0 +1,171 @@
+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 { RoadtripStop } from './useRoadtripRoutes'
+import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
+import { Arrival, DayCarry, DriveFindingBadge, FillBadge, LateBadge, NightCheckIn, OffRoadBadge, StayBadge } from './RoadtripSidebarBadges'
+import { tooltipOf } from '../../../tests/helpers/roadtripRail'
+
+const wrap = (ui: React.ReactElement) => render({ui})
+
+function settings(over: { distance_unit?: 'metric' | 'imperial'; time_format?: '12h' | '24h' } = {}): void {
+ useSettingsStore.setState({ settings: { distance_unit: 'metric', time_format: '24h', ...over } as never })
+}
+
+beforeEach(() => settings())
+afterEach(() => { vi.useRealTimers() })
+
+/** The badge shell a figure sits in: the element its value text lives two levels inside. */
+const shellOf = (text: string): Element => screen.getByText(text).parentElement!
+
+function stop(over: Partial = {}): RoadtripStop {
+ return {
+ assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'Lake', lat: 47, lng: 11,
+ time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
+ ...over,
+ }
+}
+
+const entry = (over: Partial = {}): ScheduleEntry =>
+ ({ arrival: '18:30', departure: null, anchored: false, dayOffset: 0, ...over })
+
+const warning = (over: Partial & Pick): ScheduleWarning => ({ index: 0, ...over })
+
+describe('Road trip rail badges', () => {
+ it('FE-ROADTRIP-BADGES-001: the walk from the road reads in the traveller\'s own unit, in the figure and in the tooltip', () => {
+ const metric = wrap()
+ expect(screen.getByText('240 m')).toBeInTheDocument()
+ expect(metric.container.querySelector('svg.lucide-footprints')).not.toBeNull()
+ expect(tooltipOf(shellOf('240 m'))).toBe('240 m from the road')
+ metric.unmount()
+
+ settings({ distance_unit: 'imperial' })
+ wrap()
+ expect(screen.getByText('1 mi')).toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-BADGES-002: a too long drive and a tank running dry each wear their own sign and figure', () => {
+ const leg = wrap()
+ expect(screen.getByText('+30 min')).toBeInTheDocument()
+ expect(leg.container.querySelector('svg.lucide-clock')).not.toBeNull()
+ expect(tooltipOf(shellOf('+30 min'))).toBe('30 min over your longest drive')
+ leg.unmount()
+
+ // The rail never draws this one, it hands range findings to the refuel band, so only a
+ // direct render reaches it.
+ const range = wrap()
+ expect(screen.getByText('612 km')).toBeInTheDocument()
+ expect(range.container.querySelector('svg.lucide-fuel')).not.toBeNull()
+ expect(range.container.querySelector('svg.lucide-clock')).toBeNull()
+ expect(tooltipOf(shellOf('612 km'))).toBe('612 km since the last fill-up')
+ })
+
+ it('FE-ROADTRIP-BADGES-003: a finding that is not about the drive draws nothing, and missing figures count as zero', () => {
+ const late = wrap()
+ expect(late.container).toBeEmptyDOMElement()
+ late.unmount()
+
+ wrap()
+ expect(screen.getByText('0 m')).toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-BADGES-004: the fill-up badge invites a figure, says whose it is, and hides when it can say nothing', () => {
+ const silent = wrap()
+ expect(silent.container).toBeEmptyDOMElement()
+ silent.unmount()
+
+ const onEdit = vi.fn()
+ const invite = wrap()
+ fireEvent.click(screen.getByRole('button'))
+ expect(onEdit).toHaveBeenCalledWith(screen.getByRole('button'))
+ expect(screen.getByText('+')).toHaveClass('text-content-faint')
+ expect(tooltipOf(screen.getByRole('button'))).toBe('Set how full this stop fills')
+ invite.unmount()
+
+ const inherited = wrap()
+ expect(screen.getByText('80 %')).toHaveClass('text-content-faint')
+ expect(screen.queryByRole('button')).toBeNull()
+ inherited.unmount()
+
+ wrap()
+ expect(screen.getByText('90 %')).not.toHaveClass('text-content-faint')
+ expect(tooltipOf(screen.getByRole('button'))).toBe('fills to 90 %')
+ })
+
+ it('FE-ROADTRIP-BADGES-005: the check-in shows only on the stop a night begins at, in the traveller\'s clock', () => {
+ const day = wrap()
+ expect(day.container).toBeEmptyDOMElement()
+ day.unmount()
+
+ const noTime = wrap()
+ expect(noTime.container).toBeEmptyDOMElement()
+ noTime.unmount()
+
+ settings({ time_format: '12h' })
+ wrap()
+ expect(screen.getByText('Check-in')).toBeInTheDocument()
+ expect(screen.getByText('3:00 PM')).toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-BADGES-006: the stay reads its length and the time it ends, and only an editor sees the plus', () => {
+ const readOnly = wrap()
+ expect(readOnly.container).toBeEmptyDOMElement()
+ readOnly.unmount()
+
+ const onEdit = vi.fn()
+ const add = wrap()
+ fireEvent.click(screen.getByRole('button', { name: 'Add a stay' }))
+ expect(onEdit).toHaveBeenCalledTimes(1)
+ expect(screen.getByText('+')).toBeInTheDocument()
+ add.unmount()
+
+ const both = wrap()
+ expect(screen.getByRole('button', { name: 'Time at this stop: 1 h 30 min until 14:00' })).toBeInTheDocument()
+ expect(screen.getByText('until 14:00')).toHaveClass('ms-1')
+ both.unmount()
+
+ // Left at a set time with no length worked out yet: the end alone, with no gap before it.
+ wrap()
+ expect(screen.getByText('until 14:00')).not.toHaveClass('ms-1')
+ expect(screen.queryByText('+')).toBeNull()
+ expect(screen.queryByRole('button')).toBeNull()
+ })
+
+ it('FE-ROADTRIP-BADGES-007: an arrival you pinned stands out from a computed one, and past midnight it carries the day', () => {
+ const computed = wrap()
+ expect(screen.getByText('18:30')).toHaveClass('font-medium', 'text-content-faint')
+ expect(screen.getByText('18:30')).toHaveAttribute('dir', 'ltr')
+ expect(tooltipOf(screen.getByText('18:30'))).toBe('Calculated from the drive')
+ computed.unmount()
+
+ settings({ time_format: '12h' })
+ wrap()
+ const clock = screen.getByText('1:15 AM')
+ expect(clock).toHaveClass('font-semibold', 'text-content-secondary')
+ expect(clock).toHaveTextContent('1:15 AM+1 Next day')
+ expect(screen.getByText('Next day')).toHaveClass('sr-only')
+ expect(tooltipOf(clock)).toBe('Time you set')
+ })
+
+ it('FE-ROADTRIP-BADGES-008: the day carry stays silent on the same day', () => {
+ const same = wrap()
+ expect(same.container).toBeEmptyDOMElement()
+ same.unmount()
+
+ wrap()
+ expect(screen.getByText('+2')).toHaveClass('ms-0.5')
+ })
+
+ it('FE-ROADTRIP-BADGES-009: arriving late and missing the time to leave are told apart in words', () => {
+ const late = wrap()
+ expect(screen.getByLabelText('Arrives 25 min after the time you set')).toBeInTheDocument()
+ expect(screen.getByText('+25 min').parentElement).toHaveAttribute('dir', 'ltr')
+ late.unmount()
+
+ wrap()
+ expect(screen.getByLabelText('Arrives 0 min after the time you set to leave')).toBeInTheDocument()
+ expect(screen.getByText('+0 min')).toBeInTheDocument()
+ })
+})
diff --git a/client/src/components/Roadtrip/RoadtripSidebarBadges.tsx b/client/src/components/Roadtrip/RoadtripSidebarBadges.tsx
new file mode 100644
index 000000000..b4610c8fe
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripSidebarBadges.tsx
@@ -0,0 +1,223 @@
+import React from 'react'
+import { AlertTriangle, BatteryCharging, Clock, Footprints, Fuel } from 'lucide-react'
+import { useTranslation } from '../../i18n/TranslationContext'
+import { Tooltip } from '../shared/Tooltip'
+import { useSettingsStore } from '../../store/settingsStore'
+import { formatDistance } from '../../utils/units'
+import { formatClockTime } from '../../utils/formatters'
+import { formatDurationShort, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
+import type { RoadtripStop } from './useRoadtripRoutes'
+import { FS } from './typeScale'
+import FigureBadge from './FigureBadge'
+import { shownStay, type StayReading } from './stayReading'
+
+/**
+ * How far the road stops short of the place, in the same two-part shell the stay wears.
+ *
+ * Beside the stay rather than under it: both answer "what does this stop cost you", one
+ * in time and one in a walk, and two badges on one line read as one fact about the stop
+ * instead of two unrelated notes stacked up.
+ */
+export function OffRoadBadge({ meters }: { meters: number }): React.ReactElement {
+ const { t } = useTranslation()
+ const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
+ return (
+ }
+ value={formatDistance(meters / 1000, distanceUnit)}
+ tooltip={t('roadtrip.stop.offRoad', { distance: formatDistance(meters / 1000, distanceUnit) })}
+ />
+ )
+}
+
+/**
+ * What a fill-up here actually puts in, beside the stay it takes.
+ *
+ * Only on a stop that refuels, because it is the only stop where the answer changes
+ * anything. It reads as the second half of the stay badge because that is what it is:
+ * how long you stand here, and what you get for it.
+ *
+ * The figure is the stop's own when it has one and the traveller's default otherwise, so
+ * what the badge says is always what the range budget will actually use. With neither it
+ * shows a "+", the same invitation the stay badge makes when nothing is planned yet: this
+ * badge is the only way into the per-stop figure, and a badge that hid itself until a
+ * value existed could never be used to create one.
+ */
+export function FillBadge({ percent, own, onEdit }: {
+ /** What this stop will actually fill to, inherited or not. Null when nothing says. */
+ percent: number | null
+ /** Whether that figure is the stop's own rather than the traveller's default. */
+ own: boolean
+ onEdit?: (anchor: HTMLElement) => void
+}): React.ReactElement | null {
+ const { t } = useTranslation()
+ if (percent === null && !onEdit) return null
+ return (
+ }
+ value={percent === null ? '+' : `${percent} %`}
+ faint={percent === null || !own}
+ valueSize="micro"
+ tooltip={onEdit ? (percent === null ? t('roadtrip.stop.fillSet') : t('roadtrip.limit.fillBadge', { percent })) : undefined}
+ onActivate={onEdit}
+ />
+ )
+}
+
+/**
+ * The check-in of the night a stay's own stop begins, on the day it does: the hour the
+ * room is ready, which is also what holds the arrival clock beside it when the drive gets
+ * there first.
+ */
+export function NightCheckIn({ stop }: { stop: RoadtripStop }): React.ReactElement | null {
+ const { t } = useTranslation()
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+ if (!stop.night || !stop.checkInTime) return null
+ return
+}
+
+/**
+ * How long the traveller stays here, and the way to change it.
+ *
+ * On every stop, not only the ones that already carry a time: the value has never been
+ * editable anywhere in TREK, so a stop that has none needs somewhere to say so before it
+ * can get one. Unset it reads as a plus in the slot the number will occupy, which keeps
+ * the two states the same shape and the same width: a row does not jump when a stay is
+ * added to it.
+ *
+ * Read-only for someone who cannot edit the trip: then it is a label, and a stop without
+ * a stay shows nothing at all rather than an invitation that leads nowhere.
+ *
+ * A stop the traveller leaves at a set time is stood at until then, so the badge reads the
+ * stay the schedule made of that and says until when.
+ */
+export function StayBadge({ stay, onEdit }: { stay: StayReading; onEdit?: () => void }): React.ReactElement | null {
+ const { t } = useTranslation()
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+ const shown = shownStay(stay)
+ const text = shown === null ? null : formatDurationShort(shown * 60)
+ const until = stay.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null
+ if (!text && !until && !onEdit) return null
+ return (
+
+ {text ?? (until ? null : '+')}
+ {until ? {until} : null}
+ >
+ )}
+ faint={!text}
+ tooltip={onEdit ? t('roadtrip.stop.stay') : undefined}
+ onActivate={onEdit}
+ ariaLabel={onEdit
+ ? text || until ? `${t('roadtrip.stop.stay')}: ${[text, until].filter(Boolean).join(' ')}` : t('roadtrip.stay.add')
+ : undefined}
+ />
+ )
+}
+
+/**
+ * A finding about the drive leaving this stop: too long at the wheel in one go, or the
+ * tank running out before anywhere to fill it.
+ *
+ * Wears the same two-part shell as the stay and the walk, in warning colours, and sits in
+ * the same row: all of them say what this stop costs, and one row of badges reads as one
+ * answer rather than as notes stacked under each other. The index is the stop the leg
+ * LEAVES, so the badge means "the drive from here".
+ *
+ * Neither finding names a time of day. With no stop pinned to a clock the cascade
+ * produces no times at all, so both are durations and distances, which exist as soon as
+ * the leg has routed.
+ */
+export function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.ReactElement | null {
+ const { t } = useTranslation()
+ const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
+ const text = warning.code === 'leg'
+ ? t('roadtrip.limit.legOver', { time: formatDurationShort((warning.overMinutes ?? 0) * 60) })
+ : warning.code === 'range'
+ ? t('roadtrip.limit.range', { distance: formatDistance(warning.sinceKm ?? 0, distanceUnit) })
+ : null
+ if (!text) return null
+ const Icon = warning.code === 'range' ? Fuel : Clock
+ return (
+ }
+ 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.
+ */
+export function DayCarry({ days }: { days: number }): React.ReactElement | null {
+ const { t } = useTranslation()
+ if (days <= 0) return null
+ return (
+
+ {`+${days}`}
+ {` ${t('roadtrip.warn.overnight')}`}
+
+ )
+}
+
+export 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)
+ 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
+ // look like it had a button in it. What is left of the distinction is weight and ink:
+ // enough to see which time somebody chose, without a second shape in the rail.
+ return (
+
+
+ {text}
+ {carry}
+
+
+ )
+}
+
+/**
+ * How far past the time you set this stop is reached. Its own component because a pause
+ * runs late exactly like a numbered stop does: the schedule computes the finding for both
+ * (roadtripModel restarts the chain at any anchor, whatever kind of stop carries it), and
+ * drawing it in only one of them threw the other one's away.
+ *
+ * It sits in the badge row beside the stay and the drive findings rather than on a line of
+ * its own: they are all answers to "what does this stop cost", and a warning on its own row
+ * pushed every following stop down for a finding that fits in a pill.
+ */
+export function LateBadge({ late }: { late: ScheduleWarning }): React.ReactElement {
+ const { t } = useTranslation()
+ const label = t(late.code === 'missedLeave' ? 'roadtrip.warn.missedLeave' : 'roadtrip.warn.late', { minutes: late.minutes ?? 0 })
+ // The same two-part shell the stay and the drive findings wear, so a row of badges reads
+ // as one set instead of a pill among boxes.
+ return (
+ }
+ value={`+${formatDurationShort((late.minutes ?? 0) * 60)}`}
+ tooltip={label}
+ />
+ )
+}
diff --git a/client/tests/helpers/roadtripRail.ts b/client/tests/helpers/roadtripRail.ts
new file mode 100644
index 000000000..2b394e653
--- /dev/null
+++ b/client/tests/helpers/roadtripRail.ts
@@ -0,0 +1,20 @@
+import { act, fireEvent, screen } from '@testing-library/react'
+import { vi } from 'vitest'
+
+/** Scaffolding for the tests of the road trip rail's parts, each rendered on its own. */
+
+/**
+ * What the tooltip says once the pointer has rested on the element long enough.
+ *
+ * The pointer leaves again and the real clock is back before it returns, so a case can
+ * read one tooltip after another and render on with real timers.
+ */
+export function tooltipOf(el: Element): string {
+ vi.useFakeTimers()
+ fireEvent.mouseEnter(el)
+ act(() => { vi.advanceTimersByTime(300) })
+ const text = screen.getByRole('tooltip').textContent ?? ''
+ fireEvent.mouseLeave(el)
+ vi.useRealTimers()
+ return text
+}