From 2552b37e907c2b6324e25d4a6fc5abacd1fc28c3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 01:29:47 +0000 Subject: [PATCH] refactor(client): the drive band and the night drive block move to RoadtripSidebarDriveBands DriveBand and SpillBlock leave RoadtripSidebar.tsx for their own module and are imported back by name. MODE_ICON goes with them, unexported, since only the drive band reads it, and the imports only these two used leave the sidebar. The code moves as it was, with the dashes in its comments rewritten as colons, commas and full stops. Nothing renders differently. New tests render both on their own: every travel mode's sign, the alternatives button and its pressed state, a hop, a ride with and without its booking and timetable, and a night drive that is automatic, has no leg yet or misses its figures. --- .../components/Roadtrip/RoadtripSidebar.tsx | 254 +---------------- .../RoadtripSidebarDriveBands.test.tsx | 141 ++++++++++ .../Roadtrip/RoadtripSidebarDriveBands.tsx | 258 ++++++++++++++++++ 3 files changed, 405 insertions(+), 248 deletions(-) create mode 100644 client/src/components/Roadtrip/RoadtripSidebarDriveBands.test.tsx create mode 100644 client/src/components/Roadtrip/RoadtripSidebarDriveBands.tsx diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 8eaf8bb81..ecd00f8d9 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -3,11 +3,10 @@ import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import { useElementSize } from '../../hooks/useElementSize' import React, { useMemo, useState } from 'react' import { - CarFront, Footprints, Bike, Zap, AlertTriangle, - ParkingSquare, Shuffle, Clock, Spline, Ban, Moon, + Zap, AlertTriangle, + ParkingSquare, Clock, Spline, Ban, type LucideIcon, } from 'lucide-react' -import MDancingTrek from '../../mobile/components/MDancingTrek' import type { RefuelSearch } from './useRefuelSearch' import type { RefuelCandidate } from './refuelSuggestion' import type { DryPoint } from './roadtripModel' @@ -18,14 +17,14 @@ import { formatDistance } from '../../utils/units' import { formatDate, formatClockTime } 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, isHop, legReroutable, movableWithin, resumeFoldsIntoBookend, type BookendReading } from './roadtripRowModel' +import { arrivingReroutable, bookendReading, destinationCount, legReroutable, movableWithin, resumeFoldsIntoBookend, type BookendReading } from './roadtripRowModel' import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge, bookendBooking } from './nightBookend' import { spurWorthLabelling } from './accessSpur' import StopKindPicker from './StopKindPicker' import StopFillPicker from './StopFillPicker' import { useVehicleRange } from './useVehicleRange' import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared' -import { isCarrierMode, isStoredStop, undatedRides, type CarrierTerminal } from '@trek/shared/roadtrip' +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' @@ -35,7 +34,7 @@ import { FS } from './typeScale' import type { RouteSegment } from '../../types' import EmptyState from '../shared/EmptyState' import AutomaticDayStop from './AutomaticDayStop' -import { bookingOpens, carrierIcon, missedRide, rideDuration, rideReading, terminalReading, type CheckInReading, type RideEnd } from './carrierRide' +import { bookingOpens, carrierIcon, missedRide, rideReading, terminalReading, type CheckInReading, type RideEnd } from './carrierRide' import FigureBadge from './FigureBadge' import { bookingClock, bookingIcon, dayBookings } from './stopBookings' import type { Reservation } from '../../types' @@ -44,6 +43,7 @@ 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 { RefuelBand } from './RoadtripSidebarRefuelBand' +import { DriveBand, SpillBlock } from './RoadtripSidebarDriveBands' interface RoadtripSidebarProps { onFocusPoint?: (lat: number, lng: number) => void @@ -135,12 +135,6 @@ interface RoadtripSidebarProps { canEditBookings?: boolean } -const MODE_ICON: Record = { - driving: CarFront, - walking: Footprints, - cycling: Bike, -} - // Icon and name both come from the one stop-kind table now. This file used to keep its // own copy of each, and its rest_area icon had drifted away from the popup's. @@ -250,122 +244,6 @@ function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow: boole ) } -/** - * The drive between two stops — or between a stop and the charger halfway along it. - * - * Both numbers are rebuilt from the raw metres and seconds instead of the router's - * pre-formatted strings: those spell a full hour "1 h 0 min", and they carry whichever - * unit the leg was fetched with, so a km/mi switch showed stale text until the refetch - * landed. One sentence rather than two values — "152 km in 1 h 38 min" is how the - * distance and the time belong together, and it leaves the row's right edge for the - * button instead of a second number. - */ -function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: { - leg: RouteSegment | undefined - /** The booking, when the leg is a ride and not a road: the join into the day it lands on. */ - carrier?: CarrierTerminal - /** Asks for other ways of driving this leg. Absent means the route is not editable. */ - onAskAlternatives?: () => void - alternativesOpen?: boolean -}): React.ReactElement { - const { t } = useTranslation() - const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - const mode = leg?.mode ?? 'driving' - // A ride reads as the ride block's head does: the booking and its minutes under the - // booking's icon. Through the road branches it was a car driving no distance for nine - // hours, and a ride without a timetable was short enough to pass for a hop. - const ride = isCarrierMode(mode) - const duration = ride ? rideDuration(leg) : null - const Icon = ride ? carrierIcon(mode) : mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront - // A hop (the hire desk beside the terminal) keeps the line and drops the pill: there is - // nothing to say about it and no other way to drive it. - if (!ride && isHop(leg)) { - return ( -
- - - - -
- ) - } - // The band's contents, shared by the clickable and the read-only shape so the two can - // never drift apart in what they say. - // A ride's minutes stand apart from its title and never truncate: a long booking title - // would otherwise cut off the one figure the band exists for. - const band = ( - <> - - {leg && ride ? ( - <> - {carrier ? {carrier.title} : null} - {duration ? {duration} : null} - - ) : ( - - {!leg - ? t('roadtrip.leg.pending') - : t('roadtrip.leg.driveText', { - distance: formatDistance(leg.distance / 1000, distanceUnit), - time: formatDurationShort(leg.duration), - })} - - )} - - ) - return ( -
- - {/* Dashed between stops, solid at them: the eye reads the gap as travel. */} - - -
- {/* The whole band is the target, not the 18px square at its end. Asking for other - ways of driving a leg is what the band is for, and a click anywhere on it is - the gesture people try first — the shuffle mark stays as the sign that it can - be clicked, and as where the open state shows. */} - {onAskAlternatives && leg ? ( - - - - ) : ( -
- {band} -
- )} - {/* What a plugin route attached to this leg ("25 min charge"): free text, so it - takes a line of its own rather than being forced into the row above. */} - {leg?.noteText ? ( -

- {leg.noteText} -

- ) : null} -
-
- ) -} - /** * 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. @@ -958,126 +836,6 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s ) } -/** - * What a night drive leaves on the next morning's card. - * - * The one thing in the rail that is not the day it sits under. A drive leaving at 21:00 - * and arriving at 01:23 arrives tomorrow, and the rail draws it there — under tomorrow's - * date, with the night it came through kept in front of it so the kilometres reach the - * stops rather than being left on a day nobody drives them on. - * - * Nothing has been written to say so. The stops still belong to the day they were planned - * on and the day plan still shows them there; this is the arrangement the arrival times - * imply, worked out again every time they change. Shorten the stay before the night drive - * and the first stop crosses back on its own. - * - * The ground fades from night at the head to the card's own surface at the foot, because - * that is what the block is: the end of one day handed to the beginning of the next. - */ -function SpillBlock({ spill, children }: { - spill: SpillMark - children: React.ReactNode -}): React.ReactElement { - const { t } = useTranslation() - const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const leg = spill.leg - const departure = spill.departure - // Mixed against the card rather than laid over it with alpha: the mascot cuts its eyes - // out in the colour behind it, and a translucent ground would show its body through - // them. The same reason the old overnight band mixed instead of tinting. - const night = 'color-mix(in srgb, var(--info) 11%, var(--bg-card))' - const dawn = 'color-mix(in srgb, var(--info) 4%, var(--bg-card))' - const edge = 'color-mix(in srgb, var(--info) 22%, transparent)' - return ( -
  • -
    -
    - - - {t('roadtrip.spill.title', { number: spill.fromDayNumber })} - - {/* Three z's on one baseline, each starting a third of the loop after the last, - so one is always on its way up. Decorative: the line beside it already says - what the block is. */} - - z - z - z - -
    - {/* The drive itself, in the band the rail uses everywhere else — this is the - reason the block exists, and the kilometres on it are the ones the card's - header now counts. Not clickable: alternatives are asked for on the day the - leg is stored on, and offering the same leg twice would be two answers. */} -
    - - - -
    - {/* Shaped like every other drive in the rail: what it cost on the left, the - clock on the right, in the column every arrival in this card already - reads down. The time used to sit inside the sentence, which put a - reading in the one place the eye does not look for one — and gave the - row two competing figures with no order between them. */} -
    - - - {leg - ? t('roadtrip.leg.driveText', { - distance: formatDistance((leg.distance ?? 0) / 1000, distanceUnit), - time: formatDurationShort(leg.duration ?? 0), - }) - : t('roadtrip.leg.pending')} - - {/* Departure, not arrival, so it says so in words: everything else in this - column is a time of arrival, and a bare 23:57 there would read as one. - The stop it leaves from is not named — it is drawn on yesterday's card - directly above, and a second row for it would read as a stop made - twice. */} - {departure ? ( - - {t('roadtrip.spill.departs', { time: formatClockTime(departure, is12h) })} - - ) : null} -
    -
    -
    -
    - {/* Seven, not eight: the block is pulled out by 8 and draws a 1px border, so the - inside edge lands one pixel in unless the padding gives that pixel back. It is - the difference between the numbers in here standing on the same line as the ones - below and standing almost on it. */} -
      {children}
    -
  • - ) -} - /** * One day, one card: its own numbers in its own head, its stops chained below. * diff --git a/client/src/components/Roadtrip/RoadtripSidebarDriveBands.test.tsx b/client/src/components/Roadtrip/RoadtripSidebarDriveBands.test.tsx new file mode 100644 index 000000000..783e582cc --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripSidebarDriveBands.test.tsx @@ -0,0 +1,141 @@ +import React from 'react' +import { 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 { SpillMark } from './nightSpill' +import type { RouteSegment } from '../../types' +import { DriveBand, SpillBlock } from './RoadtripSidebarDriveBands' + +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()) + +const leg = (over: Partial = {}): RouteSegment => + ({ distance: 152000, duration: 5880, distanceText: '152 km', durationText: '1 h 38 min', mode: 'driving', ...over }) as RouteSegment + +const flight: CarrierTerminal = { reservationId: 9, type: 'flight', role: 'departure', title: 'LH 2078', code: 'HAM', at: '15:15' } + +const spill = (over: Partial = {}): SpillMark => ({ + at: 0, count: 1, fromDayNumber: 3, departure: '21:40', leg: leg({ distance: 248000, duration: 13500 }), + fromStop: undefined, line: [], + ...over, +}) + +/** The lucide icon a band leads with, by its class name. */ +const iconOf = (container: HTMLElement): string => + [...(container.querySelector('svg.lucide')?.classList ?? [])].find(c => c.startsWith('lucide-')) ?? '' + +describe('DriveBand', () => { + it('FE-ROADTRIP-DRIVEBAND-001: a road leg reads distance and time as one sentence, under the sign of how it is travelled', () => { + const cases: [string, string][] = [ + ['driving', 'lucide-car-front'], + ['walking', 'lucide-footprints'], + ['cycling', 'lucide-bike'], + ['plugin:ev-router', 'lucide-zap'], + ['hovercraft', 'lucide-car-front'], + ] + for (const [mode, icon] of cases) { + const { container, unmount } = wrap() + expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument() + expect(iconOf(container)).toBe(icon) + unmount() + } + + // No mode at all is a drive. + const { container } = wrap() + expect(iconOf(container)).toBe('lucide-car-front') + expect(screen.queryByRole('button')).toBeNull() + }) + + it('FE-ROADTRIP-DRIVEBAND-002: the band is the button that asks for other ways, and shows when they are open', () => { + const onAsk = vi.fn() + const { rerender } = wrap() + const band = screen.getByRole('button') + expect(band).toHaveAttribute('aria-pressed', 'false') + expect(band).toHaveClass('bg-surface-tertiary') + expect(screen.getByLabelText('Other ways')).toBeInTheDocument() + fireEvent.click(band) + expect(onAsk).toHaveBeenCalledTimes(1) + + rerender() + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button')).toHaveClass('bg-surface-selected') + }) + + it('FE-ROADTRIP-DRIVEBAND-003: a leg still coming says so, and offers nothing to change', () => { + wrap() + expect(screen.getByText('No route')).toBeInTheDocument() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('FE-ROADTRIP-DRIVEBAND-004: a hop keeps the line and drops the pill, unless a plugin wrote something on it', () => { + const hop = wrap() + expect(hop.container.querySelector('svg')).toBeNull() + expect(hop.container.textContent).toBe('') + expect(hop.container.querySelector('span.flex-1')).not.toBeNull() + hop.unmount() + + wrap() + expect(screen.getByText('Ferry ramp')).toBeInTheDocument() + expect(screen.getByText('80 m in 1 min')).toBeInTheDocument() + }) + + it('FE-ROADTRIP-DRIVEBAND-005: a ride reads as its booking and its minutes, however short it is', () => { + const ride = wrap() + expect(iconOf(ride.container)).toBe('lucide-plane') + expect(screen.getByText('LH 2078')).toHaveClass('truncate') + expect(screen.getByText('1 h 20 min')).toHaveClass('shrink-0') + expect(screen.queryByText(/ in /)).toBeNull() + ride.unmount() + + // Without a booking to name and without a timetable there is only the sign. + const bare = wrap() + expect(iconOf(bare.container)).toBe('lucide-tram-front') + expect(bare.container.textContent).toBe('') + }) +}) + +describe('SpillBlock', () => { + /** The rail row that holds the night's drive: the moon's band, its column, then the row. */ + const driveRow = (container: HTMLElement): HTMLElement => + container.querySelector('svg.lucide-moon')!.parentElement!.parentElement!.parentElement! + + it('FE-ROADTRIP-DRIVEBAND-006: the night drive carries yesterday\'s number, its own drive and when it leaves', () => { + settings({ time_format: '12h' }) + const { container } = wrap( +
      + +
    1. Ghent
    2. +
      +
    , + ) + expect(screen.getByText('From day 3')).toBeInTheDocument() + expect(screen.getByText('248 km in 3 h 45 min')).toBeInTheDocument() + expect(screen.getByText('leaves 9:40 PM')).toHaveAttribute('dir', 'ltr') + expect(driveRow(container)).toHaveClass('grid') + expect(driveRow(container)).not.toHaveClass('hidden') + // The stops it brings along sit in a list of their own inside the block. + expect(screen.getByText('Ghent').closest('ol')).not.toBe(container.querySelector('ol')) + }) + + it('FE-ROADTRIP-DRIVEBAND-007: an automatic night hides its drive, and missing figures fall back instead of breaking', () => { + const automatic = wrap(
      {null}
    ) + expect(driveRow(automatic.container)).toHaveClass('hidden') + expect(driveRow(automatic.container)).not.toHaveClass('grid') + expect(screen.queryByText(/^leaves /)).toBeNull() + automatic.unmount() + + const pending = wrap(
      {null}
    ) + expect(screen.getByText('No route')).toBeInTheDocument() + pending.unmount() + + wrap(
      {null}
    ) + expect(screen.getByText('0 m in 0 min')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripSidebarDriveBands.tsx b/client/src/components/Roadtrip/RoadtripSidebarDriveBands.tsx new file mode 100644 index 000000000..e7cb06bd3 --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripSidebarDriveBands.tsx @@ -0,0 +1,258 @@ +import React from 'react' +import { Bike, CarFront, Footprints, Moon, Shuffle, Zap, type LucideIcon } from 'lucide-react' +import MDancingTrek from '../../mobile/components/MDancingTrek' +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 } from './roadtripModel' +import { isHop } from './roadtripRowModel' +import { isCarrierMode, type CarrierTerminal } from '@trek/shared/roadtrip' +import type { SpillMark } from './nightSpill' +import type { RouteSegment } from '../../types' +import { carrierIcon, rideDuration } from './carrierRide' +import { FS } from './typeScale' +import { RAIL_DASH, RAIL_GRID } from './RoadtripSidebar.constants' + +const MODE_ICON: Record = { + driving: CarFront, + walking: Footprints, + cycling: Bike, +} + +/** + * The drive between two stops, or between a stop and the charger halfway along it. + * + * Both numbers are rebuilt from the raw metres and seconds instead of the router's + * pre-formatted strings: those spell a full hour "1 h 0 min", and they carry whichever + * unit the leg was fetched with, so a km/mi switch showed stale text until the refetch + * landed. One sentence rather than two values: "152 km in 1 h 38 min" is how the + * distance and the time belong together, and it leaves the row's right edge for the + * button instead of a second number. + */ +export function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: { + leg: RouteSegment | undefined + /** The booking, when the leg is a ride and not a road: the join into the day it lands on. */ + carrier?: CarrierTerminal + /** Asks for other ways of driving this leg. Absent means the route is not editable. */ + onAskAlternatives?: () => void + alternativesOpen?: boolean +}): React.ReactElement { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const mode = leg?.mode ?? 'driving' + // A ride reads as the ride block's head does: the booking and its minutes under the + // booking's icon. Through the road branches it was a car driving no distance for nine + // hours, and a ride without a timetable was short enough to pass for a hop. + const ride = isCarrierMode(mode) + const duration = ride ? rideDuration(leg) : null + const Icon = ride ? carrierIcon(mode) : mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront + // A hop (the hire desk beside the terminal) keeps the line and drops the pill: there is + // nothing to say about it and no other way to drive it. + if (!ride && isHop(leg)) { + return ( +
    + + + + +
    + ) + } + // The band's contents, shared by the clickable and the read-only shape so the two can + // never drift apart in what they say. + // A ride's minutes stand apart from its title and never truncate: a long booking title + // would otherwise cut off the one figure the band exists for. + const band = ( + <> + + {leg && ride ? ( + <> + {carrier ? {carrier.title} : null} + {duration ? {duration} : null} + + ) : ( + + {!leg + ? t('roadtrip.leg.pending') + : t('roadtrip.leg.driveText', { + distance: formatDistance(leg.distance / 1000, distanceUnit), + time: formatDurationShort(leg.duration), + })} + + )} + + ) + return ( +
    + + {/* Dashed between stops, solid at them: the eye reads the gap as travel. */} + + +
    + {/* The whole band is the target, not the 18px square at its end. Asking for other + ways of driving a leg is what the band is for, and a click anywhere on it is + the gesture people try first. The shuffle mark stays as the sign that it can + be clicked, and as where the open state shows. */} + {onAskAlternatives && leg ? ( + + + + ) : ( +
    + {band} +
    + )} + {/* What a plugin route attached to this leg ("25 min charge"): free text, so it + takes a line of its own rather than being forced into the row above. */} + {leg?.noteText ? ( +

    + {leg.noteText} +

    + ) : null} +
    +
    + ) +} + +/** + * What a night drive leaves on the next morning's card. + * + * The one thing in the rail that is not the day it sits under. A drive leaving at 21:00 + * and arriving at 01:23 arrives tomorrow, and the rail draws it there: under tomorrow's + * date, with the night it came through kept in front of it so the kilometres reach the + * stops rather than being left on a day nobody drives them on. + * + * Nothing has been written to say so. The stops still belong to the day they were planned + * on and the day plan still shows them there; this is the arrangement the arrival times + * imply, worked out again every time they change. Shorten the stay before the night drive + * and the first stop crosses back on its own. + * + * The ground fades from night at the head to the card's own surface at the foot, because + * that is what the block is: the end of one day handed to the beginning of the next. + */ +export function SpillBlock({ spill, children }: { + spill: SpillMark + children: React.ReactNode +}): React.ReactElement { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const leg = spill.leg + const departure = spill.departure + // Mixed against the card rather than laid over it with alpha: the mascot cuts its eyes + // out in the colour behind it, and a translucent ground would show its body through + // them. The same reason the old overnight band mixed instead of tinting. + const night = 'color-mix(in srgb, var(--info) 11%, var(--bg-card))' + const dawn = 'color-mix(in srgb, var(--info) 4%, var(--bg-card))' + const edge = 'color-mix(in srgb, var(--info) 22%, transparent)' + return ( +
  • +
    +
    + + + {t('roadtrip.spill.title', { number: spill.fromDayNumber })} + + {/* Three z's on one baseline, each starting a third of the loop after the last, + so one is always on its way up. Decorative: the line beside it already says + what the block is. */} + + z + z + z + +
    + {/* The drive itself, in the band the rail uses everywhere else: this is the + reason the block exists, and the kilometres on it are the ones the card's + header now counts. Not clickable: alternatives are asked for on the day the + leg is stored on, and offering the same leg twice would be two answers. */} +
    + + + +
    + {/* Shaped like every other drive in the rail: what it cost on the left, the + clock on the right, in the column every arrival in this card already + reads down. The time used to sit inside the sentence, which put a + reading in the one place the eye does not look for one, and gave the + row two competing figures with no order between them. */} +
    + + + {leg + ? t('roadtrip.leg.driveText', { + distance: formatDistance((leg.distance ?? 0) / 1000, distanceUnit), + time: formatDurationShort(leg.duration ?? 0), + }) + : t('roadtrip.leg.pending')} + + {/* Departure, not arrival, so it says so in words: everything else in this + column is a time of arrival, and a bare 23:57 there would read as one. + The stop it leaves from is not named: it is drawn on yesterday's card + directly above, and a second row for it would read as a stop made + twice. */} + {departure ? ( + + {t('roadtrip.spill.departs', { time: formatClockTime(departure, is12h) })} + + ) : null} +
    +
    +
    +
    + {/* Seven, not eight: the block is pulled out by 8 and draws a 1px border, so the + inside edge lands one pixel in unless the padding gives that pixel back. It is + the difference between the numbers in here standing on the same line as the ones + below and standing almost on it. */} +
      {children}
    +
  • + ) +}