diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index b7d893733..bb7a1bc23 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -1,20 +1,12 @@
-import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { useElementSize } from '../../hooks/useElementSize'
import React, { useMemo, useState } from 'react'
-import {
- AlertTriangle,
- Spline, Ban,
-} from 'lucide-react'
import type { RefuelSearch } from './useRefuelSearch'
import type { RefuelCandidate } from './refuelSuggestion'
import type { DryPoint } from './roadtripModel'
import { useTranslation } from '../../i18n/TranslationContext'
-import { Tooltip } from '../shared/Tooltip'
-import { useSettingsStore } from '../../store/settingsStore'
-import { formatDistance } from '../../utils/units'
import { formatDate } from '../../utils/formatters'
-import { formatDurationShort, isServiceStopType, type ScheduleWarning } from './roadtripModel'
-import { arrivingReroutable, bookendReading, destinationCount, legReroutable, movableWithin, resumeFoldsIntoBookend } from './roadtripRowModel'
+import { isServiceStopType, type ScheduleWarning } from './roadtripModel'
+import { arrivingReroutable, bookendReading, legReroutable, movableWithin, resumeFoldsIntoBookend } from './roadtripRowModel'
import { bookendBooking } from './nightBookend'
import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker'
@@ -23,11 +15,9 @@ import { isStoredStop, undatedRides } from '@trek/shared/roadtrip'
import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { SpillMark } from './nightSpill'
import { ARRIVING_DRIVE, openOn, type LegAlternatives, type RailDrive } from './useRouteAlternatives'
-import { dayColor } from './dayColors'
import { FS } from './typeScale'
import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop'
-import { missedRide } from './carrierRide'
import { dayBookings } from './stopBookings'
import type { Reservation } from '../../types'
import type { StayDraft } from './RoadtripStayModal'
@@ -37,6 +27,7 @@ import { RefuelBand } from './RoadtripSidebarRefuelBand'
import { DriveBand, SpillBlock } from './RoadtripSidebarDriveBands'
import { BookendStop, BookingChips, RideBlock, TerminalStop, UndatedRides } from './RoadtripSidebarBookingRows'
import { RouteViaStop, ServiceStop, Stop } from './RoadtripSidebarStopRows'
+import { DayHeader, TripSummary } from './RoadtripSidebarHeaders'
interface RoadtripSidebarProps {
onFocusPoint?: (lat: number, lng: number) => void
@@ -134,86 +125,6 @@ interface RoadtripSidebarProps {
*/
const RAIL_NARROW_PX = 330
-/** The quiet half of a measurement — the unit, and anything after the decimal point. */
-function Unit({ children }: { children: React.ReactNode }): React.ReactElement {
- return {children}
-}
-
-/**
- * Sets the whole numbers of a measurement apart from everything else in it.
- *
- * "691.6 km" reads as six-hundred-and-ninety-one, roughly; "9 h 4 min" as nine and four.
- * Those are the digits worth the size, and the decimal tail belongs with the unit rather
- * than with them. Purely presentational and deliberately forgiving: a bare count comes
- * back as one big number, and a language that puts its unit first still splits correctly
- * because the split is driven by the digits, not by position.
- */
-function splitValue(value: string): React.ReactNode {
- const parts: React.ReactNode[] = []
- const re = /(\d+)([.,]\d+)?/g
- let last = 0
- let key = 0
- let m: RegExpExecArray | null
- while ((m = re.exec(value)) !== null) {
- if (m.index > last) parts.push({value.slice(last, m.index)})
- parts.push({m[1]})
- if (m[2]) parts.push({m[2]})
- last = m.index + m[0].length
- }
- if (last < value.length) parts.push({value.slice(last)})
- return parts.length ? parts : value
-}
-
-/**
- * Distance, driving time and stops for the whole trip — the head the left column never
- * had, above the day cards and reading as one card with them.
- *
- * Three equal centred columns with hairlines between them, rather than three labelled
- * rows: the labels are the quiet part and the numbers are what the head exists for, so
- * the numbers get the size and the labels get the letter-spacing.
- *
- * Pulled narrow, the stops go first, with their hairline: they are the figure the rail
- * itself shows best, one row per stop, while distance and time are only summed up here.
- */
-function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow: boolean }): React.ReactElement {
- const { t } = useTranslation()
- const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
- const cells: [string, string][] = [
- // `totalStops` already leaves the service stops out, so the head and the day badges
- // below count the same thing without either of them recounting the other's stops.
- [t('roadtrip.summary.distance'), formatDistance(routes.totalDistance / 1000, distanceUnit)],
- [t('roadtrip.summary.driving'), formatDurationShort(routes.totalDuration)],
- ...(narrow ? [] : [[t('roadtrip.summary.stops'), String(routes.totalStops)] as [string, string]]),
- ]
- return (
-
-
- {/* Legs land one request at a time, so until the last is in, every number above is
- a partial sum. A total that looks final and is not is worse than a slow one. */}
- {routes.loading ? (
-
- {t('roadtrip.summary.partial')}
-
- ) : null}
-
- )
-}
-
/**
* One day, one card: its own numbers in its own head, its stops chained below.
*
@@ -266,15 +177,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const bookings = useMemo(() => dayBookings(day, reservations ?? [], dayOrder), [day, reservations, dayOrder])
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)
- const tint = dayColorsOn ? dayColor(day.dayNumber).line : null
+ const { t } = useTranslation()
const last = day.stops.length - 1
const findingsFor = (i: number) =>
day.driveWarnings.filter(w => w.index === i && w.code !== 'range')
@@ -547,134 +450,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
return (
- {/* Centred, with the day's facts as badges beneath its name: at this width a row
- of label-and-value pairs breaks awkwardly, while three short badges wrap
- gracefully and stay readable in every language. */}
- {/* A tint of its own, not just a hairline: the header holds the day's summed facts
- and the list below holds its stops, and `--bg-hover` at 40% is 1% black in light
- mode — a separation nobody could see. `--bg-secondary` lifts off the card in
- both themes while staying a step under the badges sitting on it. */}
- {
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() }
- },
- } : {})}
- // The margin and the bottom rule belong to the header only while there is a list
- // under them. Folded, they left a white strip of card below the tinted header —
- // and once the day is tinted, that strip is the one thing on the card that is not.
- className={`border-edge-faint bg-surface-secondary px-3.5 pb-2.5 pt-3 ${collapsed ? '' : 'mb-1 border-b'} ${
- onToggle ? 'cursor-pointer transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent' : ''
- }`}
- // Washed with the colour this day is drawn in, and only while the map is drawing
- // days in colour: on the map the colour is what tells one day from the next, and
- // a rail that ignored it would leave the reader matching a green line to a card
- // that gives no sign of being the green one. A tenth of the hue is enough to
- // recognise and quiet enough that the day's own facts still read first.
- style={tint ? {
- backgroundImage: `linear-gradient(180deg, color-mix(in srgb, ${tint} 13%, var(--bg-secondary)), color-mix(in srgb, ${tint} 5%, var(--bg-secondary)))`,
- borderBottomColor: `color-mix(in srgb, ${tint} 30%, transparent)`,
- } : undefined}
- >
-
- {/* Read by the hook since it was written, never drawn until now. */}
- {day.title ? (
-
- {day.title}
-
- ) : null}
-
- {day.date ? (
-
- ) : null}
- {/* A day that is only the night booked for it has no drive to sum up and no
- stop to count; the hotel row below says everything it has to say. */}
- {day.legs.length > 0 ? (
-
- {t('roadtrip.leg.driveText', {
- distance: formatDistance(day.distance / 1000, distanceUnit),
- time: formatDurationShort(day.duration),
- })}
-
- ) : null}
- {day.dayWarning ? (
-
-
-
- {t('roadtrip.limit.dayOver', {
- time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60),
- })}
-
-
- ) : 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 && !narrow ? (
-
- {t('roadtrip.day.stopCount', { count: destinationCount(day) })}
-
- ) : null}
- {/* The other half of "where possible". The setting is a weighting, so a day
- with no untolled crossing comes back on the toll road — and the only thing
- worse than not avoiding it is not avoiding it silently, which reads as the
- switch being broken. Among the day's facts because that is what it is: a
- fact about this day's roads, not a warning about the plan. */}
- {day.avoidMissed?.length ? (
-
-
-
- {t('roadtrip.avoid.missed', {
- classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '),
- })}
-
-
- ) : null}
- {/* Among the day's facts rather than beside its title, because "which road this
- day takes" is one of them. Tinted once the day carries vias: the rail draws
- none of them, so this badge is the only place a drive shaped by hand differs
- from one the router picked on its own. */}
- {onFollowTrack ? (
-
-
-
- ) : null}
-
-
+
{/* One list item per stop, with the drive that follows it inside — the chain is a
list of places, not of alternating places and connectors. A service stop owns
the drive leaving it in exactly the same way, which is what splits its leg into
diff --git a/client/src/components/Roadtrip/RoadtripSidebarHeaders.test.tsx b/client/src/components/Roadtrip/RoadtripSidebarHeaders.test.tsx
new file mode 100644
index 000000000..045de6dd9
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripSidebarHeaders.test.tsx
@@ -0,0 +1,166 @@
+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 type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
+import type { RouteSegment } from '../../types'
+import { DayHeader, TripSummary } from './RoadtripSidebarHeaders'
+import { preferences, resetRailStores, seedRailSettings, tooltipOf } from '../../../tests/helpers/roadtripRail'
+
+const wrap = (ui: React.ReactElement) => render({ui})
+
+beforeEach(() => seedRailSettings())
+afterEach(() => resetRailStores())
+
+function stop(over: Partial & { assignmentId: number; name: string }): RoadtripStop {
+ return {
+ placeId: over.assignmentId * 10, ownerDayId: 1, ownerIndex: 0, lat: 53.5, lng: 9.9,
+ time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
+ ...over,
+ }
+}
+
+const leg = (): RouteSegment =>
+ ({ distance: 152000, duration: 5880, distanceText: '152 km', durationText: '1 h 38 min', mode: 'driving' }) as RouteSegment
+
+function day(over: Partial = {}): RoadtripDay {
+ const stops = over.stops ?? [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
+ return {
+ dayId: 1, dayNumber: 2, date: null, title: null,
+ legs: [leg()], legVias: [], driveWarnings: [], dayWarning: null,
+ schedule: { entries: stops.map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
+ geometry: [], distance: 152000, duration: 5880,
+ ...over,
+ stops,
+ }
+}
+
+function routes(over: Partial = {}): RoadtripRoutes {
+ return {
+ days: [], lines: [], lineDays: [], lineJoins: [], segments: [], accessLines: [], vias: [],
+ totalDistance: 691600, totalDuration: 32640, totalStops: 12, quietDays: [], loading: false,
+ ...over,
+ }
+}
+
+describe('TripSummary', () => {
+ it('FE-ROADTRIP-HEADERS-001: three figures under their captions, the whole numbers set apart from the units and decimals', () => {
+ const { container } = wrap()
+ const value = (caption: string): Element => screen.getByText(caption).parentElement!.lastElementChild!
+ expect(value('Distance')).toHaveTextContent('691.6 km')
+ expect(value('Driving time')).toHaveTextContent('9 h 4 min')
+ expect(value('Stops')).toHaveTextContent('12')
+ // "691" carries the size, ".6" and " km" step back into the unit's quiet type.
+ expect([...value('Distance').querySelectorAll('.text-content-muted')].map(u => u.textContent)).toEqual(['.6', ' km'])
+ expect([...value('Driving time').querySelectorAll('.text-content-muted')].map(u => u.textContent)).toEqual([' h ', ' min'])
+ expect(value('Stops').children).toHaveLength(0)
+ expect(container.querySelectorAll('span.w-px')).toHaveLength(2)
+ expect(screen.queryByText('Still working out the rest of the drive')).toBeNull()
+ })
+
+ it('FE-ROADTRIP-HEADERS-002: pulled narrow the stops give way, and while legs still land the figures say they are partial', () => {
+ const { container } = wrap()
+ expect(screen.queryByText('Stops')).toBeNull()
+ expect(container.querySelectorAll('span.w-px')).toHaveLength(1)
+ expect(container.querySelector('header')!.lastElementChild).toHaveTextContent('Still working out the rest of the drive')
+ })
+})
+
+describe('DayHeader', () => {
+ it('FE-ROADTRIP-HEADERS-003: the day reads its number, its title, its date, its drive and its stops', () => {
+ const { container } = wrap()
+ const header = container.querySelector('header')!
+ expect(container.firstElementChild).toBe(header)
+ expect(screen.getByRole('heading', { name: 'Day 2' }).parentElement).toBe(header)
+ expect(screen.getByText('Along the coast')).toBeInTheDocument()
+ expect(container.querySelector('time')).toHaveAttribute('dateTime', '2026-06-02')
+ expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument()
+ expect(screen.getByText('2 stops')).toBeInTheDocument()
+ // Read-only: no control, and the bottom rule over the list below.
+ expect(header).not.toHaveAttribute('role')
+ expect(header).toHaveClass('mb-1', 'border-b')
+ expect(header).not.toHaveAttribute('style')
+ })
+
+ it('FE-ROADTRIP-HEADERS-004: pulled narrow the count gives way, and a day of only its night has no drive and no count', () => {
+ const narrow = wrap()
+ expect(screen.queryByText('2 stops')).toBeNull()
+ expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument()
+ narrow.unmount()
+
+ wrap()
+ expect(screen.queryByText(/km in/)).toBeNull()
+ expect(screen.queryByText(/stop/)).toBeNull()
+ })
+
+ it('FE-ROADTRIP-HEADERS-005: the whole header folds the day on a click, Enter or Space, and says whether it is open', () => {
+ const onToggle = vi.fn()
+ const { container, rerender } = wrap()
+ const header = screen.getByRole('button', { expanded: true })
+ expect(header.tagName).toBe('HEADER')
+ fireEvent.click(header)
+ fireEvent.keyDown(header, { key: 'Enter' })
+ fireEvent.keyDown(header, { key: ' ' })
+ fireEvent.keyDown(header, { key: 'Tab' })
+ expect(onToggle).toHaveBeenCalledTimes(3)
+
+ rerender()
+ expect(container.querySelector('header')).toHaveAttribute('aria-expanded', 'false')
+ expect(container.querySelector('header')).not.toHaveClass('border-b')
+ })
+
+ it('FE-ROADTRIP-HEADERS-006: a day over the driving limit says by how much, and what the limit counts', () => {
+ wrap()
+ const badge = screen.getByText('1 h over')
+ expect(badge).toHaveClass('text-warning')
+ expect(tooltipOf(badge)).toBe('Driving only, excluding stays and overnight legs.')
+ })
+
+ it('FE-ROADTRIP-HEADERS-007: the roads a day could not avoid are among its facts', () => {
+ wrap()
+ const badge = screen.getByText('Toll roads, Ferries unavoidable')
+ expect(badge.querySelector('svg.lucide-ban')).not.toBeNull()
+ expect(tooltipOf(badge)).toBe('This day has no route around it, so the drive uses it. Every other day still avoids what it can.')
+ })
+
+ it('FE-ROADTRIP-HEADERS-008: a ride the drive reaches too late is named in the header', () => {
+ const departure = stop({
+ assignmentId: 3, name: 'Hamburg Airport', time: '13:15',
+ carrier: { reservationId: 9, type: 'flight', role: 'departure', title: 'LH 2078', code: 'HAM', at: '15:15' },
+ })
+ wrap(
+ ,
+ )
+ expect(screen.getByText('Flight missed')).toHaveClass('text-warning')
+ })
+
+ it('FE-ROADTRIP-HEADERS-009: the track badge follows a track, names the one it follows, and is tinted once the day carries vias', () => {
+ const onFollowTrack = vi.fn()
+ const plain = wrap()
+ const badge = screen.getByRole('button', { name: 'Track' })
+ expect(badge).toHaveClass('bg-surface-card')
+ expect(tooltipOf(badge)).toBe('Make this day follow an imported track')
+ fireEvent.click(badge)
+ expect(onFollowTrack).toHaveBeenCalledWith(1)
+ plain.unmount()
+
+ wrap()
+ const shaped = screen.getByRole('button', { name: 'Track' })
+ expect(shaped).toHaveClass('bg-accent-subtle')
+ expect(tooltipOf(shaped)).toBe('Currently following Küstenweg')
+ })
+
+ it('FE-ROADTRIP-HEADERS-010: while the map draws days in colour, the header is washed with its own', () => {
+ preferences({ roadtrip_day_colors: true })
+ const { container } = wrap()
+ const header = container.querySelector('header')!
+ expect(header.style.backgroundImage).toContain('linear-gradient(180deg, color-mix(in srgb, #ff9f0a 13%, var(--bg-secondary))')
+ // The DOM spells the hue its own way here, so only the mix is pinned.
+ expect(header.style.borderBottomColor).toMatch(/^color-mix\(in srgb, .+ 30%, transparent\)$/)
+ })
+})
diff --git a/client/src/components/Roadtrip/RoadtripSidebarHeaders.tsx b/client/src/components/Roadtrip/RoadtripSidebarHeaders.tsx
new file mode 100644
index 000000000..f89fd1cc6
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripSidebarHeaders.tsx
@@ -0,0 +1,255 @@
+import React from 'react'
+import { AlertTriangle, Ban, Spline } from 'lucide-react'
+import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
+import { useTranslation } from '../../i18n/TranslationContext'
+import { Tooltip } from '../shared/Tooltip'
+import { useSettingsStore } from '../../store/settingsStore'
+import { formatDistance } from '../../utils/units'
+import { formatDate } from '../../utils/formatters'
+import { formatDurationShort } from './roadtripModel'
+import { destinationCount } from './roadtripRowModel'
+import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
+import { missedRide } from './carrierRide'
+import { dayColor } from './dayColors'
+import { FS } from './typeScale'
+import { DAY_BADGE, STAT_LABEL } from './RoadtripSidebar.constants'
+
+/** The quiet half of a measurement: the unit, and anything after the decimal point. */
+function Unit({ children }: { children: React.ReactNode }): React.ReactElement {
+ return {children}
+}
+
+/**
+ * Sets the whole numbers of a measurement apart from everything else in it.
+ *
+ * "691.6 km" reads as six-hundred-and-ninety-one, roughly; "9 h 4 min" as nine and four.
+ * Those are the digits worth the size, and the decimal tail belongs with the unit rather
+ * than with them. Purely presentational and deliberately forgiving: a bare count comes
+ * back as one big number, and a language that puts its unit first still splits correctly
+ * because the split is driven by the digits, not by position.
+ */
+function splitValue(value: string): React.ReactNode {
+ const parts: React.ReactNode[] = []
+ const re = /(\d+)([.,]\d+)?/g
+ let last = 0
+ let key = 0
+ let m: RegExpExecArray | null
+ while ((m = re.exec(value)) !== null) {
+ if (m.index > last) parts.push({value.slice(last, m.index)})
+ parts.push({m[1]})
+ if (m[2]) parts.push({m[2]})
+ last = m.index + m[0].length
+ }
+ if (last < value.length) parts.push({value.slice(last)})
+ return parts.length ? parts : value
+}
+
+/**
+ * Distance, driving time and stops for the whole trip: the head the left column never
+ * had, above the day cards and reading as one card with them.
+ *
+ * Three equal centred columns with hairlines between them, rather than three labelled
+ * rows: the labels are the quiet part and the numbers are what the head exists for, so
+ * the numbers get the size and the labels get the letter-spacing.
+ *
+ * Pulled narrow, the stops go first, with their hairline: they are the figure the rail
+ * itself shows best, one row per stop, while distance and time are only summed up here.
+ */
+export function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow: boolean }): React.ReactElement {
+ const { t } = useTranslation()
+ const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
+ const cells: [string, string][] = [
+ // `totalStops` already leaves the service stops out, so the head and the day badges
+ // below count the same thing without either of them recounting the other's stops.
+ [t('roadtrip.summary.distance'), formatDistance(routes.totalDistance / 1000, distanceUnit)],
+ [t('roadtrip.summary.driving'), formatDurationShort(routes.totalDuration)],
+ ...(narrow ? [] : [[t('roadtrip.summary.stops'), String(routes.totalStops)] as [string, string]]),
+ ]
+ return (
+
+
+ {/* Legs land one request at a time, so until the last is in, every number above is
+ a partial sum. A total that looks final and is not is worse than a slow one. */}
+ {routes.loading ? (
+
+ {t('roadtrip.summary.partial')}
+
+ ) : null}
+
+ )
+}
+
+/**
+ * A day card's head: its name and title, its facts as badges, and the fold control.
+ *
+ * Rendered as the bare `` with nothing around it, so the card's markup is the same
+ * as when the card drew it inline. At module scope on purpose: declared inside the card it
+ * would remount on every render and drop the keyboard focus on the fold control.
+ */
+export function DayHeader({ day, collapsed, onToggle, narrow, onFollowTrack, viaCount, trackName }: Readonly<{
+ day: RoadtripDay
+ /** Folded down to the header, and off the map with it. */
+ collapsed?: boolean
+ onToggle?: () => void
+ /** The rail is pulled too narrow for every badge; the stop count gives way. */
+ narrow?: boolean
+ onFollowTrack?: (dayId: number) => void
+ viaCount?: number
+ trackName?: string
+}>): React.ReactElement {
+ 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)
+ const tint = dayColorsOn ? dayColor(day.dayNumber).line : null
+ return (
+ // Centred, with the day's facts as badges beneath its name: at this width a row
+ // of label-and-value pairs breaks awkwardly, while three short badges wrap
+ // gracefully and stay readable in every language.
+ //
+ // A tint of its own, not just a hairline: the header holds the day's summed facts
+ // and the list below holds its stops, and `--bg-hover` at 40% is 1% black in light
+ // mode, a separation nobody could see. `--bg-secondary` lifts off the card in
+ // both themes while staying a step under the badges sitting on it.
+ {
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() }
+ },
+ } : {})}
+ // The margin and the bottom rule belong to the header only while there is a list
+ // under them. Folded, they left a white strip of card below the tinted header,
+ // and once the day is tinted, that strip is the one thing on the card that is not.
+ className={`border-edge-faint bg-surface-secondary px-3.5 pb-2.5 pt-3 ${collapsed ? '' : 'mb-1 border-b'} ${
+ onToggle ? 'cursor-pointer transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent' : ''
+ }`}
+ // Washed with the colour this day is drawn in, and only while the map is drawing
+ // days in colour: on the map the colour is what tells one day from the next, and
+ // a rail that ignored it would leave the reader matching a green line to a card
+ // that gives no sign of being the green one. A tenth of the hue is enough to
+ // recognise and quiet enough that the day's own facts still read first.
+ style={tint ? {
+ backgroundImage: `linear-gradient(180deg, color-mix(in srgb, ${tint} 13%, var(--bg-secondary)), color-mix(in srgb, ${tint} 5%, var(--bg-secondary)))`,
+ borderBottomColor: `color-mix(in srgb, ${tint} 30%, transparent)`,
+ } : undefined}
+ >
+
+ {/* Read by the hook since it was written, never drawn until now. */}
+ {day.title ? (
+
+ {day.title}
+
+ ) : null}
+
+ {day.date ? (
+
+ ) : null}
+ {/* A day that is only the night booked for it has no drive to sum up and no
+ stop to count; the hotel row below says everything it has to say. */}
+ {day.legs.length > 0 ? (
+
+ {t('roadtrip.leg.driveText', {
+ distance: formatDistance(day.distance / 1000, distanceUnit),
+ time: formatDurationShort(day.duration),
+ })}
+
+ ) : null}
+ {day.dayWarning ? (
+
+
+
+ {t('roadtrip.limit.dayOver', {
+ time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60),
+ })}
+
+
+ ) : 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 && !narrow ? (
+
+ {t('roadtrip.day.stopCount', { count: destinationCount(day) })}
+
+ ) : null}
+ {/* The other half of "where possible". The setting is a weighting, so a day
+ with no untolled crossing comes back on the toll road, and the only thing
+ worse than not avoiding it is not avoiding it silently, which reads as the
+ switch being broken. Among the day's facts because that is what it is: a
+ fact about this day's roads, not a warning about the plan. */}
+ {day.avoidMissed?.length ? (
+
+
+
+ {t('roadtrip.avoid.missed', {
+ classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '),
+ })}
+
+
+ ) : null}
+ {/* Among the day's facts rather than beside its title, because "which road this
+ day takes" is one of them. Tinted once the day carries vias: the rail draws
+ none of them, so this badge is the only place a drive shaped by hand differs
+ from one the router picked on its own. */}
+ {onFollowTrack ? (
+
+
+
+ ) : null}
+