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 ( -
-
- {cells.map(([label, value], i) => ( - - {i > 0 ? : null} -
- {label} - - {splitValue(value)} - -
-
- ))} -
- {/* 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} - > -

- {t('roadtrip.day', { number: day.dayNumber })} -

- {/* 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 ( +
+
+ {cells.map(([label, value], i) => ( + + {i > 0 ? : null} +
+ {label} + + {splitValue(value)} + +
+
+ ))} +
+ {/* 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} + > +

+ {t('roadtrip.day', { number: day.dayNumber })} +

+ {/* 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} +
+
+ ) +}