From 3f0eb210d83d555c5d7335c8352f5afcd6e66947 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 10 Sep 2026 19:33:23 +0200 Subject: [PATCH] feat(roadtrip): move a stop reached after midnight onto the day it is reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A day that runs past midnight used to keep every one of its stops on its own card, so day one ended at 03:12 with three places nobody reaches on day one and day two began with a drive that had already happened. The rail now lays the stops out by the date they are actually reached: when the clock goes backwards between two arrivals the ones after it move to the following day, into a block that says where they came from, and the night drive and its kilometres go with them. It is an arrangement, not a write — the day planner beside it is untouched, nothing reaches the database, and a shorter stay the evening before puts everything back on its own. The map follows the same arrangement, because a boundary drawn in the rail and not on the map is two answers to one question. Day one now ends where its last reachable stop is and the line, the legs and the totals are cut to match. Two switches in the driving settings, off by default. "Connect the days" routes the gap between one day's last stop and the next day's first, which is real driving the rail has never drawn, and counts it. "A colour per day" then puts the days back into a line that has become continuous, and tints each day header to match. Collapsing a day from its header takes its route and its places off the map as well as out of the rail, and the categories in the corridor panel fold the same way. The refuel band is one full-width red band with the pump as its button, the results are chips, and the driving dialog is two columns of panels with the figures on one right edge. Also fixes a via that would not adjust the route once dragged. Five separate causes, all measured rather than guessed: leg threading used the card index instead of the owning day's, the anchor lookup returned chain indices, it filtered by the card rather than by the anchor's owner, seams were routed without their vias, and a seam once routed was never re-fetched. --- client/src/components/Map/MapView.tsx | 7 +- client/src/components/Map/MapViewGL.tsx | 64 +- .../src/components/Map/RoadtripViaMarkers.tsx | 25 +- .../Roadtrip/AnchoredPopover.test.tsx | 261 ++++++ .../components/Roadtrip/RangeStrip.test.tsx | 228 ++++++ client/src/components/Roadtrip/RangeStrip.tsx | 161 ++++ .../Roadtrip/RoadtripCorridorPanel.test.tsx | 20 +- .../Roadtrip/RoadtripCorridorPanel.tsx | 87 +- .../Roadtrip/RoadtripLimitsCard.test.tsx | 74 +- .../Roadtrip/RoadtripLimitsCard.tsx | 584 +++++++------- .../Roadtrip/RoadtripSidebar.test.tsx | 102 ++- .../components/Roadtrip/RoadtripSidebar.tsx | 747 +++++++++++++----- .../src/components/Roadtrip/corridor.test.ts | 47 ++ client/src/components/Roadtrip/corridor.ts | 55 ++ .../src/components/Roadtrip/dayColors.test.ts | 34 + client/src/components/Roadtrip/dayColors.ts | 43 + .../components/Roadtrip/nightSpill.test.ts | 258 ++++++ client/src/components/Roadtrip/nightSpill.ts | 212 +++++ .../Roadtrip/useFollowTrack.test.ts | 6 +- .../Roadtrip/useRefuelSearch.test.ts | 282 +++++++ .../Roadtrip/useRoadtripCorridor.test.ts | 6 +- .../Roadtrip/useRoadtripRoutes.test.ts | 68 ++ .../components/Roadtrip/useRoadtripRoutes.ts | 398 ++++++++-- .../components/Roadtrip/useRoadtripVias.ts | 39 + .../Roadtrip/useVehicleRange.test.ts | 199 +++++ client/src/index.css | 31 +- client/src/pages/TripPlannerPage.tsx | 9 +- .../src/pages/tripPlanner/useTripPlanner.ts | 134 +++- client/src/store/settingsStore.ts | 7 + client/src/types.ts | 4 + shared/src/i18n/ar/roadtrip.ts | 18 + shared/src/i18n/br/roadtrip.ts | 18 + shared/src/i18n/ca/roadtrip.ts | 18 + shared/src/i18n/cs/roadtrip.ts | 18 + shared/src/i18n/de/roadtrip.ts | 18 + shared/src/i18n/en/roadtrip.ts | 18 + shared/src/i18n/es/roadtrip.ts | 18 + shared/src/i18n/fr/roadtrip.ts | 18 + shared/src/i18n/gr/roadtrip.ts | 18 + shared/src/i18n/hu/roadtrip.ts | 18 + shared/src/i18n/id/roadtrip.ts | 18 + shared/src/i18n/it/roadtrip.ts | 18 + shared/src/i18n/ja/roadtrip.ts | 18 + shared/src/i18n/ko/roadtrip.ts | 18 + shared/src/i18n/nl/roadtrip.ts | 18 + shared/src/i18n/pl/roadtrip.ts | 18 + shared/src/i18n/ru/roadtrip.ts | 18 + shared/src/i18n/sv/roadtrip.ts | 18 + shared/src/i18n/tr/roadtrip.ts | 18 + shared/src/i18n/uk/roadtrip.ts | 18 + shared/src/i18n/vi/roadtrip.ts | 18 + shared/src/i18n/zh-TW/roadtrip.ts | 18 + shared/src/i18n/zh/roadtrip.ts | 18 + 53 files changed, 3980 insertions(+), 626 deletions(-) create mode 100644 client/src/components/Roadtrip/AnchoredPopover.test.tsx create mode 100644 client/src/components/Roadtrip/RangeStrip.test.tsx create mode 100644 client/src/components/Roadtrip/RangeStrip.tsx create mode 100644 client/src/components/Roadtrip/dayColors.test.ts create mode 100644 client/src/components/Roadtrip/dayColors.ts create mode 100644 client/src/components/Roadtrip/nightSpill.test.ts create mode 100644 client/src/components/Roadtrip/nightSpill.ts create mode 100644 client/src/components/Roadtrip/useRefuelSearch.test.ts create mode 100644 client/src/components/Roadtrip/useVehicleRange.test.ts diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 0af947b93..5d17af5f6 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -606,6 +606,9 @@ export const MapView = memo(function MapView({ places = [], dayPlaces = [], route = null, + // One colour pair per entry of `route`, or absent for the blue the route has always + // been. Only the road trip passes these, and only while colouring by day is on. + routeColors = null, routeSegments = [], selectedPlaceId = null, hoverDisabled = false, @@ -1032,7 +1035,7 @@ export const MapView = memo(function MapView({ { @@ -1046,7 +1049,7 @@ export const MapView = memo(function MapView({ , ] : [])} diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 015e9d604..0e830a51f 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -123,6 +123,11 @@ interface Props { /** The dashed last bit to a place the road network does not reach. */ accessLines?: { line: [[number, number], [number, number]]; meters: number }[] route?: [number, number][][] | null + /** + * One colour pair per entry of `route`, or absent for the blue the route has always + * been. Only the road trip passes these, and only while colouring by day is on. + */ + routeColors?: ({ line: string; casing: string } | undefined)[] | null routeSegments?: RouteSegment[] selectedPlaceId?: number | null onMarkerClick?: (id: number) => void @@ -606,6 +611,7 @@ export function MapViewGL({ routeVias = NO_ROUTE_VIAS, accessLines = NO_ACCESS_LINES, route = null, + routeColors = null, routeSegments = NO_ROUTE_SEGMENTS, selectedPlaceId = null, hoverDisabled = false, @@ -762,6 +768,23 @@ export function MapViewGL({ viaCleanupRef.current = [] viaPinsRef.current.forEach(p => p.remove()) viaPinsRef.current = [] + /** + * Zoomed out, the handles go away. + * + * A via is a handle for a few hundred metres of road, and at a continental zoom a + * whole day's worth of them collapses into a cluster of dots over one town — not + * something anybody can aim at, and dragging one there moves the route by kilometres + * per pixel. Below this the drive is read, not shaped. + */ + const VIA_MIN_ZOOM = 9 + const applyViaZoom = () => { + const on = map.getZoom() >= VIA_MIN_ZOOM + for (const pin of viaPinsRef.current) { + const node = (pin as { getElement?: () => HTMLElement | null }).getElement?.() + if (node) node.style.display = on ? '' : 'none' + } + } + for (const via of vias) { const el = document.createElement('span') el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none' @@ -850,8 +873,26 @@ export function MapViewGL({ el.removeEventListener('dblclick', swallow) }) } + + // Applied now and on every zoom that settles, so a handle that should not be there is + // gone before the first frame rather than after the first gesture. + applyViaZoom() + map.on('zoomend', applyViaZoom) + viaCleanupRef.current.push(() => map.off('zoomend', applyViaZoom)) }, [vias, viasDraggable, mapReady, glProvider]) + /** + * The other people's pointers, and this person's own going the other way. + * + * Built as plain elements on the same pin layer the vias use, so they ride the map + * during a pan instead of being re-placed a frame later — the same reason the via + * handles are not library markers. + * + * No interpolation between frames, unlike the studio's book: a map pans and zooms under + * the arrow, so a position eased towards over several frames is a position that was + * never true at any of them. Ten frames a second placed exactly reads as a hand; the + * same frames chasing a moving target read as a drift. + */ /** * The offered routes, drawn under the current one. * @@ -1178,7 +1219,9 @@ export function MapViewGL({ id: 'trip-route-casing', type: 'line', source: 'trip-route', - paint: { 'line-color': '#0a5cc2', 'line-width': 8 }, + // Per feature where the caller gave one, else the blue the route has always + // been. `coalesce` rather than a second layer: one source, one stroke. + paint: { 'line-color': ['coalesce', ['get', 'casing'], '#0a5cc2'], 'line-width': 8 }, layout: { 'line-cap': 'round', 'line-join': 'round' }, }) // An invisible band over the route, purely to be clicked. The drawn line is 8px @@ -1195,7 +1238,7 @@ export function MapViewGL({ id: 'trip-route-line', type: 'line', source: 'trip-route', - paint: { 'line-color': '#0a84ff', 'line-width': 5 }, + paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 5 }, layout: { 'line-cap': 'round', 'line-join': 'round' }, }) } @@ -1885,13 +1928,18 @@ export function MapViewGL({ if (!map) return const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined if (!src) return - const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({ - type: 'Feature' as const, - properties: {}, - geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) }, - })) + const features = (route || []) + .map((seg, i) => ({ seg, colors: routeColors?.[i] })) + .filter(({ seg }) => seg && seg.length > 1) + .map(({ seg, colors }) => ({ + type: 'Feature' as const, + // Null rather than absent: `coalesce` in the paint expression falls through on + // null, and an absent property would make every line the default colour. + properties: { color: colors?.line ?? null, casing: colors?.casing ?? null }, + geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) }, + })) src.setData({ type: 'FeatureCollection', features }) - }, [route, mapReady]) + }, [route, routeColors, mapReady]) // Update access-spur geojson useEffect(() => { diff --git a/client/src/components/Map/RoadtripViaMarkers.tsx b/client/src/components/Map/RoadtripViaMarkers.tsx index fb6cab5ff..484258cbb 100644 --- a/client/src/components/Map/RoadtripViaMarkers.tsx +++ b/client/src/components/Map/RoadtripViaMarkers.tsx @@ -1,5 +1,5 @@ -import React, { useMemo, useRef } from 'react' -import { Marker, Tooltip } from 'react-leaflet' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { Marker, Tooltip, useMap } from 'react-leaflet' import L from 'leaflet' import { useTranslation } from '../../i18n/TranslationContext' import { useStableVias } from './viaMarkerState' @@ -31,6 +31,9 @@ interface RoadtripViaMarkersProps { } /** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */ +/** Below this the handles are not drawn. Shared with the GL renderer's own copy. */ +const VIA_MIN_ZOOM = 9 + export default function RoadtripViaMarkers({ viasByDay, onMoveVia, onRemoveVia, }: RoadtripViaMarkersProps): React.ReactElement | null { @@ -44,6 +47,22 @@ export default function RoadtripViaMarkers({ const handlersRef = useRef({ onMoveVia, onRemoveVia }) handlersRef.current = { onMoveVia, onRemoveVia } const draggable = !!onMoveVia + /** + * Zoomed out, the handles go away. + * + * A via is a handle for a few hundred metres of road, and at a continental zoom a whole + * day's worth collapses into a cluster of dots over one town — not something anybody can + * aim at, and a drag there moves the route by kilometres per pixel. Below this the drive + * is read, not shaped. + */ + const map = useMap() + const [zoomedIn, setZoomedIn] = useState(() => map.getZoom() >= VIA_MIN_ZOOM) + useEffect(() => { + const update = () => setZoomedIn(map.getZoom() >= VIA_MIN_ZOOM) + update() + map.on('zoomend', update) + return () => { map.off('zoomend', update) } + }, [map]) const markers = useMemo(() => vias.map(via => ({ via, @@ -62,7 +81,7 @@ export default function RoadtripViaMarkers({ }, })), [vias]) - if (!markers.length) return null + if (!markers.length || !zoomedIn) return null return ( <> diff --git a/client/src/components/Roadtrip/AnchoredPopover.test.tsx b/client/src/components/Roadtrip/AnchoredPopover.test.tsx new file mode 100644 index 000000000..0507a1272 --- /dev/null +++ b/client/src/components/Roadtrip/AnchoredPopover.test.tsx @@ -0,0 +1,261 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fireEvent, render, screen } from '../../../tests/helpers/render' +import AnchoredPopover from './AnchoredPopover' + +/** + * FE-ANCHOREDPOP-001..014 — where the panel lands and what makes it go away. + * + * Everything here is geometry read off the page and arithmetic nobody sees until it is + * wrong: a panel a gap below the badge, above it on a short window, pulled back inside + * the viewport, centred on the rail rather than on the badge. The numbers are worked out + * in the comments so a changed rule fails with a readable diff instead of a shrug. + * + * jsdom lays nothing out — every getBoundingClientRect comes back as zeroes, so badge, + * rail and panel all measure the same and every branch of the placement collapses onto + * 0,0. Sizes are therefore fed in per element, and window.innerHeight / innerWidth are + * stated rather than left to the jsdom default, since the flip and the clamp are the + * whole point of half these cases. + */ + +const rect = (top: number, left: number, width: number, height: number): DOMRect => ({ + top, left, width, height, + bottom: top + height, + right: left + width, + x: left, + y: top, + toJSON: () => ({}), +}) as DOMRect + +let section: HTMLElement +let anchor: HTMLElement +let panel: DOMRect + +function viewport(width: number, height: number) { + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width }) + Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height }) +} + +beforeEach(() => { + section = document.createElement('section') + anchor = document.createElement('button') + section.appendChild(anchor) + document.body.appendChild(section) + // The rail as it stands on a desktop: a tall column at the left edge with the badge + // hard against its far side. That offset is what the centring rule exists for. + section.getBoundingClientRect = () => rect(100, 0, 400, 600) + anchor.getBoundingClientRect = () => rect(120, 344, 48, 20) + panel = rect(0, 0, 200, 150) + viewport(1200, 800) + // The panel is the one element that cannot be primed by hand: React makes it inside the + // portal and the layout effect measures it in the tick it is created. So it answers off + // the prototype, which the two own assignments above shadow. Only its size is read — + // handing back a top and a left it does not have yet would make the test circular. + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => panel) +}) + +afterEach(() => { + // Both, because case 006 moves the anchor out of the section to test the fallback, so + // it would outlive section.remove() and sit in the document for the rest of the file. + anchor.remove() + section.remove() + vi.restoreAllMocks() +}) + +function open() { + const onClose = vi.fn() + const view = render( + + + , + ) + return { onClose, unmount: view.unmount, rerender: view.rerender } +} + +const dialog = () => screen.getByRole('dialog') + +describe('AnchoredPopover', () => { + it('FE-ANCHOREDPOP-001: without an anchor there is nothing to hang under, and nothing listening', () => { + const onClose = vi.fn() + render( + + + , + ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + // A closed panel that keeps its key handler takes Escape away from whatever is + // genuinely open behind it. + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('FE-ANCHOREDPOP-002: it hangs a gap under the badge, and is shown only once it has been placed', () => { + open() + + // Badge bottom 140 plus the gap. Fixed is asserted as the class rather than as a + // computed style, because jsdom resolves no stylesheet: it is what makes every + // number in this file a viewport coordinate instead of an offset inside a scroller. + expect(dialog()).toHaveStyle({ top: '148px' }) + expect(dialog().className).toContain('fixed') + expect(screen.getByRole('dialog', { name: 'Stop kind' })).toBeInTheDocument() + }) + + it('FE-ANCHOREDPOP-015: it is portalled to the body, out of the rail that would clip it', () => { + open() + + // The reason this component exists at all. The rail scrolls and carries transforms, + // and both of those trap a fixed child, so a panel rendered where it is declared + // would be clipped by the sidebar it hangs in. Every other case here passes without + // the portal, which is why this one asserts the parent rather than the geometry. + expect(dialog().parentElement).toBe(document.body) + expect(section.contains(dialog())).toBe(false) + }) + + it('FE-ANCHOREDPOP-016: moving to another badge re-measures against that one', () => { + const { onClose, rerender } = open() + + const second = document.createElement('button') + section.appendChild(second) + second.getBoundingClientRect = () => rect(500, 344, 48, 20) + rerender( + + + , + ) + + // 500 plus 20 plus the gap. The rail has more than one badge worth opening and the + // same panel serves all of them, so the anchor really does change under a mounted + // popover. Measuring in a layout effect is what keeps that from ever being painted + // at the previous badge: the position is committed before the browser draws. + expect(dialog()).toHaveStyle({ top: '528px' }) + second.remove() + }) + + it('FE-ANCHOREDPOP-003: on a short window it flips above the badge instead of off the bottom', () => { + anchor.getBoundingClientRect = () => rect(200, 344, 48, 20) + viewport(1200, 360) + + open() + + // Below would start at 228 and run to 378, past the 352 the window leaves. Above is + // badge top 200 less the 150 it needs less the gap. + expect(dialog()).toHaveStyle({ top: '42px' }) + }) + + it('FE-ANCHOREDPOP-004: a panel too tall for either side sits at the top edge rather than above it', () => { + anchor.getBoundingClientRect = () => rect(40, 344, 48, 20) + panel = rect(0, 0, 200, 250) + viewport(1200, 320) + + open() + + // Flipping honestly would put it at -218, scrolling the half that holds the controls + // out of reach with nothing to scroll back. Covering the badge is the lesser evil. + expect(dialog()).toHaveStyle({ top: '8px' }) + }) + + it('FE-ANCHOREDPOP-005: it centres on the rail, not on the badge sitting at the edge of it', () => { + open() + + // Rail centre 200 less half the panel. Centred on the badge it would be 268, hanging + // over the map and pointing at nothing. + expect(dialog()).toHaveStyle({ left: '100px' }) + }) + + it('FE-ANCHOREDPOP-006: with no column around it, it falls back to the badge', () => { + // Same badge, no rail: the panel is not owned by the sidebar and has to place itself + // sanely wherever it is hung. + document.body.appendChild(anchor) + + open() + + // Badge centre 368 less half the panel. + expect(dialog()).toHaveStyle({ left: '268px' }) + }) + + it('FE-ANCHOREDPOP-007: a rail near the right edge does not push the panel off the screen', () => { + section.getBoundingClientRect = () => rect(100, 380, 200, 600) + viewport(500, 800) + + open() + + // Centred it would want 380 and end at 580, well past a window 500 wide. Clamped it + // ends at 492, the same margin the rest of the placement keeps. + expect(dialog()).toHaveStyle({ left: '292px' }) + }) + + it('FE-ANCHOREDPOP-008: a rail scrolled off the left does not push the panel off it either', () => { + section.getBoundingClientRect = () => rect(100, -60, 100, 600) + + open() + + // Centred on that sliver the panel would start at -110 and lose its first half. + expect(dialog()).toHaveStyle({ left: '8px' }) + }) + + it('FE-ANCHOREDPOP-009: Escape closes it, and only Escape', () => { + // The handler sits on the document, so it sees every key pressed anywhere on the + // page, typing in a field included. + const { onClose } = open() + + fireEvent.keyDown(document, { key: 'Enter' }) + fireEvent.keyDown(document, { key: 'e' }) + expect(onClose).not.toHaveBeenCalled() + + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-ANCHOREDPOP-010: a press anywhere else closes it', () => { + const { onClose } = open() + + fireEvent.mouseDown(document.body) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-ANCHOREDPOP-011: a press on what the panel contains leaves it open', () => { + // It closes on mousedown, not on click. Without the guard a button in the panel would + // be torn out from under the pointer before its own click ever landed. + const { onClose } = open() + + fireEvent.mouseDown(screen.getByText('Inside')) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('FE-ANCHOREDPOP-012: a press on the badge that opened it leaves it open too', () => { + // The badge toggles. Closing here as well means one press closes the panel twice, and + // the next press reads as the badge refusing to open. + const { onClose } = open() + + fireEvent.mouseDown(anchor) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('FE-ANCHOREDPOP-013: a scroll closes it, including one that never bubbles up', () => { + // Scroll events do not bubble and the rail is its own scroller. A listener without + // capture would hear nothing while the badge slides out from under a panel that stays + // where it was measured. + const { onClose } = open() + + fireEvent.scroll(section) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-ANCHOREDPOP-014: once it is gone it has stopped listening', () => { + // Three handlers on the document and the window, on a component that is mounted and + // thrown away again for every badge in the rail. + const { onClose, unmount } = open() + + unmount() + fireEvent.keyDown(document, { key: 'Escape' }) + fireEvent.mouseDown(document.body) + fireEvent.scroll(section) + + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/components/Roadtrip/RangeStrip.test.tsx b/client/src/components/Roadtrip/RangeStrip.test.tsx new file mode 100644 index 000000000..25138fc77 --- /dev/null +++ b/client/src/components/Roadtrip/RangeStrip.test.tsx @@ -0,0 +1,228 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, screen } from '../../../tests/helpers/render' +import type { DistanceUnit } from '../../types' +import RangeStrip, { blockMask } from './RangeStrip' + +/** + * FE-RANGESTRIP-001..015 — the picture under the range field. + * + * Nothing here is interactive, so what is worth pinning is the arithmetic the drawing + * rests on: the empty state has to stay empty rather than settle on a plausible zero, + * the block mask has a band it lives in and looks broken outside it, and the three + * segments have to cover the bar exactly or the tail runs off the end. The chips are the + * only readable proof that any of that happened, and the aria-label is the only route a + * screen reader has to the figure at all. + */ + +const strip = (over: Partial> = {}) => ( + +) + +/** + * The three widths of the bar, in the order they are laid: what a stop puts in, what is + * left of the tank, what age has taken. Read off the track rather than off the wrapper, + * because the wrapper is the thing carrying the label. + */ +function segments(): number[] { + const track = screen.getByRole('img').firstElementChild as HTMLElement + return Array.from(track.children).map(c => parseFloat((c as HTMLElement).style.width)) +} + +describe('RangeStrip', () => { + it('FE-RANGESTRIP-001: with no range the figure is a dash and no unit hangs off it', () => { + // A lone "km" beside a dash reads as a unit waiting for a number, which invites the + // guess that the number is zero. There is no number, so there is nothing to label. + render(strip({ rangeKm: null })) + + expect(screen.getByText('—')).toBeInTheDocument() + expect(screen.queryByText('km')).not.toBeInTheDocument() + expect(screen.getByText('No range set')).toBeInTheDocument() + expect(screen.getByText(/Type a range/)).toBeInTheDocument() + }) + + it('FE-RANGESTRIP-002: with no range the bar reads out the hint alone', () => { + // The bar is drawn empty here, so its label is the only thing that can say why. + render(strip({ rangeKm: null })) + + expect(screen.getByRole('img')).toHaveAccessibleName( + 'Type a range, or fill in the figures below and TREK works it out.', + ) + }) + + it('FE-RANGESTRIP-003: the figure and its unit are split but say the same thing', () => { + // Split so the unit can sit smaller beside the number; the split is only safe while + // the two halves still read as one reading of one distance. + render(strip({ rangeKm: 500 })) + + expect(screen.getByText('500')).toBeInTheDocument() + expect(screen.getByText('km')).toBeInTheDocument() + }) + + it('FE-RANGESTRIP-004: an electric car charges where a petrol one fills', () => { + const { rerender } = render(strip({ electric: true })) + expect(screen.getByText('per charge')).toBeInTheDocument() + + rerender(strip({ electric: false })) + expect(screen.getByText('per tank')).toBeInTheDocument() + }) + + it('FE-RANGESTRIP-005: a countable range is cut into blocks and says what one is worth', () => { + // Ten blocks. The chip is the only thing that turns the gaps from texture into a + // measure, so it has to appear with the mask rather than merely near it. + render(strip({ rangeKm: 500 })) + + expect(screen.getByText('One block = 50 km')).toBeInTheDocument() + }) + + it('FE-RANGESTRIP-006: a range too short or too long to count is left solid', () => { + // Two blocks read as damage; forty eat the bar. Either way there is nothing to + // count, so claiming a block is worth fifty kilometres would be claiming a mask + // that was never cut. + const { rerender } = render(strip({ rangeKm: 100 })) + expect(screen.queryByText(/One block/)).not.toBeInTheDocument() + + rerender(strip({ rangeKm: 2000 })) + expect(screen.queryByText(/One block/)).not.toBeInTheDocument() + }) + + it('FE-RANGESTRIP-007: the countable band has hard edges at three and thirty blocks', () => { + // Rounded, not floored: 130 km is three blocks and 1520 km is thirty. The pairs + // either side of each edge are what a change to that rounding would move. + const { rerender } = render(strip({ rangeKm: 120 })) + expect(screen.queryByText(/One block/)).not.toBeInTheDocument() + + rerender(strip({ rangeKm: 130 })) + expect(screen.getByText('One block = 50 km')).toBeInTheDocument() + + rerender(strip({ rangeKm: 1520 })) + expect(screen.getByText('One block = 50 km')).toBeInTheDocument() + + rerender(strip({ rangeKm: 1560 })) + expect(screen.queryByText(/One block/)).not.toBeInTheDocument() + }) + + it('FE-RANGESTRIP-008: a partial fill says what that stop actually buys', () => { + // Four fifths of a 500 km tank. The whole point of the panel is that nobody charges + // to the top, so the note has to move off the full-tank sentence. + render(strip({ rangeKm: 500, fillPercent: 80 })) + + expect(screen.getByText('A 80 % stop gives 400 km')).toBeInTheDocument() + expect(screen.queryByText('A stop fills right up')).not.toBeInTheDocument() + }) + + it('FE-RANGESTRIP-009: a full, an absent and an impossible fill all read as filling right up', () => { + // Anything outside nought to a hundred is not a fraction of a tank, and a note + // saying a 130 % stop gives 650 km would be the panel inventing range. + const { rerender } = render(strip({ fillPercent: 100 })) + expect(screen.getByText('A stop fills right up')).toBeInTheDocument() + + rerender(strip({ fillPercent: null })) + expect(screen.getByText('A stop fills right up')).toBeInTheDocument() + + rerender(strip({ fillPercent: 130 })) + expect(screen.getByText('A stop fills right up')).toBeInTheDocument() + + // The other end of the same guard, and the one a cleared or mistyped field really + // produces: a stop that puts nothing in would leave the bar empty and the note + // claiming a range of nought. + rerender(strip({ fillPercent: 0 })) + expect(screen.getByText('A stop fills right up')).toBeInTheDocument() + + rerender(strip({ fillPercent: -20 })) + expect(screen.getByText('A stop fills right up')).toBeInTheDocument() + }) + + it('FE-RANGESTRIP-010: wear is clamped to 90 rather than drawn past the bar', () => { + // A battery reported as 150 % gone would leave the middle segment at a negative + // width, which the browser drops: the tail would then be the whole bar and the chip + // would be telling the traveller the car cannot move. + render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 150 })) + + expect(screen.getByText('90 % lost to age')).toBeInTheDocument() + expect(segments()).toEqual([8, 2, 90]) + }) + + it('FE-RANGESTRIP-011: with nothing lost to age there is no tail to explain', () => { + // Zero is the ordinary case for a new car and for every petrol one. A chip reading + // "0 % lost to age" is an answer to a question nobody asked. + const { rerender } = render(strip({ wearPercent: 0 })) + expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument() + + rerender(strip({ wearPercent: -20 })) + expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument() + expect(segments()).toEqual([100, 0, 0]) + }) + + it('FE-RANGESTRIP-012: without a range there is no wear to take off it', () => { + // Wear is a share of a range. With no range the share is of nothing, and a tail on + // an empty bar would be the one filled thing on it. + render(strip({ rangeKm: null, wearPercent: 30 })) + + expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument() + expect(segments()).toEqual([0, 100, 0]) + }) + + it('FE-RANGESTRIP-013: the three segments cover the bar exactly', () => { + // A fifth gone and a four-fifths stop: the fill is four fifths of what is LEFT, not + // of the factory bar, or the filled part would reach into the hatched tail. + render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 20 })) + + expect(segments()).toEqual([64, 16, 20]) + }) + + it('FE-RANGESTRIP-016: the stencil itself cuts one block per fifty kilometres', () => { + // Asserted on the function, because jsdom implements no mask property: React writes + // neither maskImage nor WebkitMaskImage and the element arrives with no style at + // all, so through the DOM every one of these cases would pass on an empty bar. + expect(blockMask(500)).toContain('repeating-linear-gradient') + // Ten blocks of fifty, so one keep-then-cut step is a tenth of the bar. + expect(blockMask(500)).toContain('10%') + // Five blocks, a fifth each: the step really follows the distance rather than being + // a fixed number of divisions dressed up as one. + expect(blockMask(250)).toContain('20%') + + // Below three the cuts read as damage and above thirty the gaps eat the bar, so + // outside that the bar is honestly solid. + expect(blockMask(100)).toBeUndefined() + expect(blockMask(2000)).toBeUndefined() + expect(blockMask(null)).toBeUndefined() + }) + + it('FE-RANGESTRIP-017: the wear chip carries the same hatching as the tail of the bar', () => { + // The swatch is what ties the sentence to the stripe. Without it the hatching is an + // unexplained texture at the end of the bar and the chip an unexplained subtraction. + const { container } = render(strip({ rangeKm: 400, wearPercent: 12, electric: true })) + + const hatched = Array.from(container.querySelectorAll('span[style*="repeating-linear-gradient"]')) + expect(hatched).toHaveLength(1) + expect(hatched[0].style.backgroundImage).toBe( + (screen.getByRole('img').firstElementChild?.lastElementChild as HTMLElement).style.backgroundImage, + ) + }) + + it('FE-RANGESTRIP-014: the bar reads out the figure and the note together', () => { + // Everything else in here is colour and width. A screen reader gets the range from + // this label or not at all, so it has to carry both halves of the answer. + render(strip({ rangeKm: 500, fillPercent: 80 })) + + expect(screen.getByRole('img')).toHaveAccessibleName('500 km — A 80 % stop gives 400 km') + }) + + it('FE-RANGESTRIP-015: imperial converts the figure, the unit and the block chip together', () => { + // The blocks are cut in kilometres whatever the setting, so the chip is the one + // place the two systems can drift apart: 50 km of bar has to be stated as 31.1 mi. + render(strip({ rangeKm: 500, unit: 'imperial' as DistanceUnit })) + + expect(screen.getByText('310.7')).toBeInTheDocument() + expect(screen.getByText('mi')).toBeInTheDocument() + expect(screen.getByText('One block = 31.1 mi')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Roadtrip/RangeStrip.tsx b/client/src/components/Roadtrip/RangeStrip.tsx new file mode 100644 index 000000000..b7c143e28 --- /dev/null +++ b/client/src/components/Roadtrip/RangeStrip.tsx @@ -0,0 +1,161 @@ +import React from 'react' +import { useTranslation } from '../../i18n/TranslationContext' +import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../utils/units' +import type { DistanceUnit } from '../../types' +import { FS } from './typeScale' + +/** + * How far the car goes on one fill, drawn as the tank it leaves the factory with. + * + * The figure above it is the answer; this is the argument that the figure is right. It + * carries the two things a bare number cannot say on its own: + * + * - The filled part is what a stop actually puts back in. Nobody charges to 100 % on the + * road, and a traveller who set 80 % is looking at four fifths of a bar, which is the + * honest picture of what the next leg has. + * - The hatched tail is what age has taken off an electric car's battery. It is already + * out of the number above, so without the tail that figure looks like an unexplained + * disagreement with the car's own data sheet. + * + * Widths animate because this sits directly under the fields that feed it: typing a + * consumption and watching the bar move is what makes the two ways of stating a range + * visibly the same thing. + * + * No box, no border, no tinted panel. It used to sit in a card of its own, which made it + * a second surface inside a dialog whose whole point is one plain column — and the box + * was doing nothing the bar was not already doing better. + */ + +/** + * One reading of the figure above it. + * + * `bg-surface-secondary` rather than the card's own colour: these sit ON the card, so the + * chip has to be a step away from it in whichever scheme is on, and a white literal would + * be a step the wrong way in the dark one. + */ +const NOTE_CHIP = 'inline-flex items-center rounded-lg bg-surface-secondary px-2 py-1 text-caption leading-snug text-content-secondary' + +/** How many kilometres one block of the bar stands for. */ +const BLOCK_KM = 50 + +/** + * The bar, cut into blocks of a fixed distance rather than into quarters. + * + * Quarters divide a bar; blocks measure a road. With one block per fifty kilometres the + * bar for a 240 km car is visibly shorter than the bar for a 600 km one, which is the + * comparison somebody typing a consumption is actually making — and it turns the bar into + * something countable rather than something to eyeball. + * + * Cut as a MASK rather than painted over: the old quarter marks were drawn in the panel's + * own colour to look like gaps, which tied them to that panel and left them visible as + * stripes anywhere else. A mask makes real holes, so whatever is behind the bar shows + * through and nothing has to know the background. + * + * Exported for its own test rather than checked through the bar: jsdom supports no mask + * property at all, so React writes neither of the two and the element reaches a test + * carrying no style whatsoever. Through the DOM the rule is unobservable. + */ +export function blockMask(rangeKm: number | null): string | undefined { + if (!rangeKm) return undefined + const blocks = Math.round(rangeKm / BLOCK_KM) + // Below three the cuts read as damage rather than as measure, and above about thirty + // the gaps eat the bar. Outside that the bar is simply solid, which is honest: at that + // range the blocks were never going to be counted. + if (blocks < 3 || blocks > 30) return undefined + const step = 100 / blocks + // Straight, not slanted. The slant was tried and read as a decorative texture rather + // than as a measure: on a bar this thin the lean is legible only as fuzz on the seams. + // + // The colour is opacity here, not paint: black is "keep", transparent is "cut". + return `repeating-linear-gradient(90deg, #000 0 calc(${step}% - 2px), transparent calc(${step}% - 2px) ${step}%)` // theme-lint-disable — mask stencil, not a colour +} + +export default function RangeStrip({ rangeKm, fillPercent, wearPercent, unit, electric }: { + rangeKm: number | null + fillPercent: number | null + wearPercent: number | null + unit: DistanceUnit + electric: boolean +}): React.ReactElement { + const { t } = useTranslation() + // Capped where the arithmetic caps it, so the picture cannot claim a battery is gone. + const wear = rangeKm ? Math.min(90, Math.max(0, wearPercent ?? 0)) : 0 + const fill = fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : 100 + const usable = 100 - wear + const filled = rangeKm ? (usable * fill) / 100 : 0 + const rest = 100 - wear - filled + // Split rather than formatted whole, so the unit can sit smaller beside the figure. The + // rounding is formatDistance's own, because the badge on the trigger uses that and the + // two must not disagree by a tenth. + const shown = rangeKm ? Math.round(convertDistance(rangeKm, unit) * 10) / 10 : null + const stripes = 'repeating-linear-gradient(135deg, var(--border-primary) 0 3px, transparent 3px 6px)' + const mask = blockMask(rangeKm) + + const note = shown === null + ? t('roadtrip.limit.rangeEmptyHint') + : fill < 100 + ? t('roadtrip.limit.afterFill', { percent: fill, distance: formatDistance((rangeKm! * fill) / 100, unit) }) + : t('roadtrip.limit.fullNote') + + return ( +
+ {/* The figure and what it is a figure OF, on one baseline. Wrapping rather than + truncating: "per charge" is three words in English and six in Hungarian. */} +
+ + + {shown ?? '—'} + + {shown !== null ? ( + + {getDistanceUnitLabel(unit)} + + ) : null} + + + {shown === null + ? t('roadtrip.limit.rangeEmpty') + : t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')} + +
+ +
+
+
+
+
+
+
+ + {/* Three readings of this one figure — what a stop actually puts in, what one block + on the bar stands for, what age has taken off — each on its own chip inside the + card the number and the bar already share. As prose on one line they ran into + each other; given the card to themselves they looked like four separate answers. + Chips inside the card are both: three facts, one answer. */} +
+ {note} + {mask ? ( + {t('roadtrip.limit.blockNote', { distance: formatDistance(BLOCK_KM, unit) })} + ) : null} + {/* The swatch is what ties the chip to the tail of the bar. Without it the + hatching is an unexplained stripe and the sentence an unexplained loss. */} + {wear > 0 ? ( + + + {t('roadtrip.limit.wearNote', { percent: wear })} + + ) : null} +
+
+ ) +} diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx index be56236b4..59dcc33b6 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx @@ -11,6 +11,8 @@ const wrap = (ui: React.ReactElement) => render({ui} ({ assignmentId: id, + ownerDayId: 1, + ownerIndex: id - 1, placeId: id * 10, name, lat: 53.5, @@ -41,7 +43,7 @@ const day = (dayId: number, dayNumber: number): RoadtripDay => ({ const routes = (days: RoadtripDay[]): RoadtripRoutes => ({ days, - lines: [], + lines: [], lineDays: [], accessLines: [], vias: [], segments: [], @@ -202,9 +204,9 @@ describe('RoadtripCorridorPanel', () => { }) wrap() - // "Fuel" also names an option in the kind picker; the group header is the one - // inside a
. - const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('header')).find(Boolean)! + // "Fuel" also names an option in the kind picker; the group header is the one that + // folds its list, so it is the button. + const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('button')).find(Boolean)! expect(within(fuelGroup).getByText('2 on the way')).toBeInTheDocument() expect(screen.getByText('Camping Elbe')).toBeInTheDocument() }) @@ -291,11 +293,17 @@ describe('RoadtripCorridorPanel', () => { expect(screen.getByText('The route is long — only the first stretch was searched.')).toBeInTheDocument() }) - it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is admitted, not passed off as empty', () => { + it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is not reported as a fault', () => { + // It used to say so, and it said so on nearly every search of a long day: the ceiling + // is per box and a busy corridor reaches it as a matter of course. That made a + // permanent complaint about a search that had worked, and its advice — narrow the + // corridor — makes the answer smaller rather than better. The warnings that remain + // are the ones about a search that did not happen. const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })], truncatedAreas: 2 }) wrap() - expect(screen.getByText(/2 stretches had more than fits in one answer/)).toBeInTheDocument() + expect(screen.queryByText(/had more than fits in one answer/)).not.toBeInTheDocument() + expect(screen.getByText('Aral')).toBeInTheDocument() }) it('FE-ROADTRIP-PANEL-018: the filter appears only once there is something to narrow', () => { diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index b4dd2ac26..c5b69f5b6 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -1,6 +1,6 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useState } from 'react' import { - Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, + Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, ChevronDown, } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' import { Tooltip } from '../shared/Tooltip' @@ -85,6 +85,14 @@ function ResultBadge({ category }: { category: string }): React.ReactElement { ) } +/** + * One fact about a hit, on its own surface. + * + * `bg-surface-secondary` rather than a literal: the row lifts to `surface-hover` under the + * pointer, and a chip painted white would stop lifting with it in the dark scheme. + */ +const POI_CHIP = 'inline-flex items-center rounded-md bg-surface-secondary px-1.5 py-0.5 leading-none tabular-nums text-content-muted' + function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) @@ -95,16 +103,16 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
{poi.name}
- {/* Two facts, two elements. Both are translated phrases rather than short fixed - values, so they stay text and the gap does the separating a middot used to — - which also lets them wrap onto their own lines instead of breaking around a - dot left stranded at the end of a line. */} -
- {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} - + {/* Two facts, two chips. Where it is off the road and how far into the drive it + comes are separate answers, and as two phrases sharing a line they read as one + run-on sentence about the same thing. A chip each gives them an edge, and the + row still wraps cleanly at a narrow width because nothing has to break around + a separator. */} +
+ + {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} + + {poi.alongKm < 0.5 ? t('roadtrip.poi.atStart') : t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })} @@ -167,9 +175,30 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: { const meta = CATEGORY_META[category] const Icon = meta?.Icon ?? MapPin const color = serviceColor(category) + /** + * Folded away, per category. + * + * A search for fuel AND food comes back as two lists that push each other off the + * screen, and the one being read is never the one at the top. Local state on purpose: + * which group somebody has open right now is a way of looking, not a setting — it + * should not outlive the search that produced these groups. + */ + const [open, setOpen] = useState(true) + const label = meta ? t(meta.labelKey) : category return ( -
-
+ // The gap between two groups belongs to the section, not to the strip: as padding on + // the strip it became part of the hover surface, which then hung well above the row + // it highlights. +
+ {/* The whole strip is the control, not a chevron at its end: it is what the eye is + on when it decides this is not the list it wants, and at this width a 12px + target in the corner is a miss waiting to happen. */} +
-
    + {/* Quiet until the strip is under the pointer, then it says which way this goes. + A chevron that is always at full contrast reads as a fourth piece of data in a + header that already carries three. */} + + +
+ {/* Read-only once the parts add up, because then it IS the parts. Leaving + it editable would offer a second answer to a question that already has + one, and the traveller would have no way of telling which the warnings + were using. Clearing a field above hands the row straight back. */} + setPreview({ key: 'range', value: v })} + onChange={setRange} + /> + {/* Under the range, because it is a fraction OF it. Nobody charges to + 100 % on the road: the last fifth takes as long as the first four, so a + stop counted as a full tank overstates everything after it by that + fifth. */} + setPreview({ key: 'fill', value: v > 100 ? 100 : v })} + onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)} + /> + + {/* The car's own figures, folded away. Nothing here is required — somebody + who knows their range types it above and never opens this — but it + starts open the moment any of them is stored, because otherwise the + range row is read-only with its cause out of sight. */} + {vehicleKind ? ( + <> + + {/* `hidden` rather than unmounting: it really leaves the tab order, + and a field that kept a draft while folded away would be a draft + nobody can see. */} + {/* The display class only while it is open. `[hidden]` sets + `display: none` in the preflight, but a `flex` class beats it on + specificity — so the attribute flipped, the semantics were right, + and the panel stayed on screen. */} + + + ) : null} + +
) : null} diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx index aef384da9..dfd206cfe 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx @@ -12,6 +12,11 @@ const wrap = (ui: React.ReactElement) => render({ui} & { assignmentId: number; name: string }): RoadtripStop { return { placeId: over.assignmentId * 10, + // Filled in by `day()` from the card it is put on, unless the case under test says + // otherwise: a stop names the day it is STORED on, which after a night drive is not + // the card it is drawn on. -1 is "not stated", never a real id. + ownerDayId: -1, + ownerIndex: -1, lat: 53.5, lng: 9.9, time: null, @@ -31,12 +36,12 @@ function day(over: Partial = {}): RoadtripDay { stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' }), ] + const dayId = over.dayId ?? 1 return { - dayId: 1, + dayId, dayNumber: 1, date: null, title: null, - stops, legs: [leg()], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: stops.map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] }, @@ -44,6 +49,13 @@ function day(over: Partial = {}): RoadtripDay { distance: 100000, duration: 3600, ...over, + // After the spread, because `over` carries the raw stops this was built from: the + // same pair the hook fills in, for every stop that did not state its own. + stops: stops.map((s, i) => ({ + ...s, + ownerDayId: s.ownerDayId === -1 ? dayId : s.ownerDayId, + ownerIndex: s.ownerIndex === -1 ? i : s.ownerIndex, + })), } } @@ -51,6 +63,7 @@ function routes(over: Partial = {}): RoadtripRoutes { return { days: [day()], lines: [], + lineDays: [], segments: [], accessLines: [], vias: [], @@ -201,11 +214,15 @@ describe('RoadtripSidebar', () => { } wrap() - // Said twice on purpose: once as the break in the chain, once on the arrival after - // it, where "01:30" would otherwise read as tonight. - expect(screen.getAllByText('Next day')).toHaveLength(2) - // The day marker rides on the arrival that crossed midnight, where "01:30" would - // otherwise read as tonight. + // The crossing is no longer a band in the chain: a drive past midnight hands its + // stops to the next day's card, and the block around them there is what says so + // (`nightSpill.ts`). What is left on a day that could not hand them on — the last day + // of a trip, or one whose arrivals are supplied directly as they are here — is the + // marker on the arrival, where "01:30" would otherwise read as tonight. + // One mention, not two: the marker on the arrival, spelled out for a screen reader. + // The band that used to repeat it in the chain is gone — a drive past midnight hands + // its stops to the next day's card now, and the block around them there says it. + expect(screen.getAllByText('Next day')).toHaveLength(1) expect(screen.getByText('01:30').textContent).toContain('+1') }) @@ -224,7 +241,9 @@ describe('RoadtripSidebar', () => { } wrap() - expect(screen.getAllByText('Next day')).toHaveLength(2) + // Both findings still reach the stop; only the band that used to repeat the crossing + // is gone. Reading just the first warning used to drop the late flag entirely. + expect(screen.getByText('01:30').textContent).toContain('+1') expect(screen.getByLabelText(/45/)).toBeInTheDocument() }) @@ -557,6 +576,73 @@ describe('RoadtripSidebar', () => { wrap() expect(screen.queryByRole('button', { name: 'Track' })).toBeNull() }) + + /** + * The block a night drive leaves on the next morning's card. + * + * The stops are stored on the day they set off from and drawn on the day they are + * reached, so what is asserted here is that the block says where they came from and + * that the drive through the night is drawn with them. See `nightSpill.ts`. + */ + describe('a stretch driven onto this day through the night', () => { + const spilled = () => day({ + dayNumber: 2, + stops: [ + stop({ assignmentId: 1, name: 'Neuruppin', ownerDayId: 9, ownerIndex: 3 }), + stop({ assignmentId: 2, name: 'Wittenberg' }), + ], + spills: [{ + at: 0, + count: 1, + fromDayNumber: 1, + departure: '22:30', + leg: leg({ distance: 253000, duration: 10380 }), + line: [[53, 10], [52, 11]], + fromStop: stop({ assignmentId: 99, name: 'Circle K', ownerDayId: 9, ownerIndex: 2 }), + }], + }) + + it('FE-ROADTRIP-SIDEBAR-031: names the day the stretch came from', () => { + wrap() + expect(screen.getByText('From day 1')).toBeTruthy() + }) + + it('FE-ROADTRIP-SIDEBAR-032: draws the night drive, so the kilometres reach the stops', () => { + wrap() + // Without it the card would gain a stop and none of the driving that reaches it. + expect(screen.getByText('253 km in 2 h 53 min')).toBeTruthy() + expect(screen.getByText('leaves 22:30')).toBeTruthy() + }) + + it('FE-ROADTRIP-SIDEBAR-030: a drag names the day the stop is STORED on, not the card', () => { + const onReorderStop = vi.fn() + const onMoveStopToDay = vi.fn() + const { container } = wrap() + + // Dropping the borrowed stop onto its neighbour is a reorder inside day 9 — the day + // the server knows it by — even though both are drawn under day 2. + // The draggable rows only: the block around the borrowed stretch is a list item + // too, and it contains the text of every stop inside it. + const rows = container.querySelectorAll('li[draggable="true"]') + const dataTransfer = { effectAllowed: '', setData: vi.fn() } + fireEvent.dragStart(rows[0], { dataTransfer }) + fireEvent.dragOver(rows[1], { dataTransfer }) + fireEvent.drop(rows[1], { dataTransfer }) + // Dragged from day 9 index 3 — where the server has it — onto day 1 index 1, the + // stored position of the row it was dropped on. Neither number is the card's. + expect(onMoveStopToDay).toHaveBeenCalledWith(9, 1, 1, 1) + expect(onReorderStop).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-SIDEBAR-033: an ordinary day draws no block at all', () => { + wrap() + expect(screen.queryByText(/From day/)).toBeNull() + }) + }) }) describe('RoadtripModeSwitch', () => { diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 984398a33..22e9d271d 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import { CarFront, Footprints, Bike, Zap, AlertTriangle, - ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, Search, RotateCcw, X, BatteryCharging, + ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, type LucideIcon, } from 'lucide-react' import MDancingTrek from '../../mobile/components/MDancingTrek' @@ -13,7 +13,7 @@ import { Tooltip } from '../shared/Tooltip' import { useSettingsStore } from '../../store/settingsStore' import { formatDistance } from '../../utils/units' import { formatDate, formatClockTime } from '../../utils/formatters' -import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange, type VehicleKind} from './roadtripModel' +import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel' import { STOP_KIND_BY_KEY } from './stopKinds' import { spurWorthLabelling } from './accessSpur' import StopKindPicker from './StopKindPicker' @@ -21,6 +21,8 @@ import StopFillPicker from './StopFillPicker' import { useVehicleRange } from './useVehicleRange' import type { RoadtripStopType } from '@trek/shared' import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes' +import type { SpillMark } from './nightSpill' +import { dayColor } from './dayColors' import type { RouteVia } from '../../types' import { FS } from './typeScale' import type { RouteSegment } from '../../types' @@ -83,6 +85,16 @@ interface RoadtripSidebarProps { viaCounts?: Record /** The name of the track each day follows, so the badge can say which road it is. */ trackNames?: Record + /** + * Days folded down to their header, by id. + * + * A long trip is a long rail, and reading the shape of one day means scrolling past the + * four before it. Folding is also what takes that day OFF the map: a card with nothing + * under its header would otherwise still be drawing its road. + */ + collapsedDayIds?: Set + /** Absent leaves every card open and its header unclickable. */ + onToggleDay?: (dayId: number) => void } const MODE_ICON: Record = { @@ -109,6 +121,15 @@ const RAIL_DASH: React.CSSProperties = { backgroundImage: 'repeating-linear-gradient(var(--border-primary) 0 4px, transparent 4px 8px)', } +/** + * A figure in the two-part shell the rail uses everywhere: what it is on the left, the + * number on the right, one hairline between them. + * + * Named because more than one place builds it now — the walk from the road, and the two + * figures on a refuel offer — and a second copy is how the two drift apart. + */ +const FIGURE_BADGE = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge' + /** A 24px disc — a stop's number, or a service stop's icon. */ const DISC = 'grid h-6 w-6 shrink-0 place-items-center rounded-full' @@ -414,60 +435,115 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: { }): React.ReactElement { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + // What the traveller drives, so the band says the right word and offers the right + // thing. An electric car does not run out of TANK, and a lamp shaped like a pump on a + // band about a battery is the kind of detail that makes the rest look careless. + const { vehicleKind } = useVehicleRange() + const electric = vehicleKind === 'electric' + const DryIcon = electric ? Zap : Fuel const key = `${dayId}:${dry.legIndex}` const open = refuel.openFor === key const settled = open && !refuel.loading && refuel.outcome return ( -
- - - -
- {/* Built as a drive band, because that is what it belongs to: the leg the fuel - runs out on. Same height, same type, same rail. The warning ring is the only - difference, and it is the whole message — this is the band where the tank - ends. */} -
-
- - - {t('roadtrip.refuel.dry')} - - {/* How far INTO this leg, where a drive band keeps its figures. Not the range - that was crossed: that is the traveller's own setting, says nothing about - where, and made every band on a day read the same number. */} - - {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })} - - {/* An icon, the size of the shuffle mark a drive band carries, because the - band has to stay one line high beside its neighbours. What it does is in - the tooltip and in the label a screen reader gets. */} +
+ {/* Across the whole rail, marker column included, the way the day change used to + be drawn. It belongs to no stop: the tank runs out between two of them, and + hanging it off the one after would put it where somebody finds out rather than + where it happens. Full width is also what makes it the ONLY thing saying this — + the per-stop "so far on this tank" badge stands down wherever a search can be + run, because after the band has said where the fuel ends, every later stop + repeating it with a bigger number is the same message again. */} +
+
+ {/* Out of fuel is a thing that happens to the DRIVE, so the mascot is the one + with the vehicle, and it is not enjoying it. */} + +
+
+ {t(electric ? 'roadtrip.refuel.dryElectric' : 'roadtrip.refuel.dry')} +
+ {/* How far INTO this leg, where a drive band keeps its figures. Not the + range that was crossed: that is the traveller's own setting, says + nothing about where, and made every band on a day read the same + number. */} +
+ {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })} +
+
+ {/* The low-fuel lamp IS the button. + It was a lamp beside a magnifier, which is the same thing said twice: the + lamp says the tank is empty and the magnifier offers to do something + about it, and nobody reads a dashboard warning as decoration. So the lamp + glows, and pressing it goes looking. Larger than the 18px marks a drive + band carries, because unlike those it is the one thing in the band worth + pressing. What it does is in the tooltip and in the screen reader label. + + Once a search is open the lamp steps aside for the state that matters: + the way to close the answer, or the way to ask again. An answer that + found nothing leaves a button rather than a dead end — the place search + is a shared public service that does time out, and "it did not answer" + with no way to retry reads as broken rather than as busy. */} {open && (refuel.loading || refuel.results.length) ? ( ) : ( - /* An answer that found nothing leaves the button, not a dead end. The place - search is a shared public service that does time out, and "it did not - answer" with no way to ask again reads as broken rather than as busy. */ - + )} @@ -479,31 +555,94 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: { {settled ? ( refuel.results.length ? ( -
    +
      {/* Three at most. This is an offer beside a plan, not a list to browse; - the corridor panel is where somebody goes to see all of them. */} - {refuel.results.slice(0, 3).map(poi => ( -
    • - - {poi.name} - - - {/* What is left when the car draws level, the figure that decides - whether this one is any use. The detour is already in it. */} - {t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })} - - {onAccept ? ( - - ) : null} -
    • - ))} + the corridor panel is where somebody goes to see all of them. + + Each one on its own surface rather than as bare text in the band: the + band is the problem and these are the answers, and a row somebody is + meant to press has to look like it can be. Two lines, because one was + not enough to tell them apart — measured on a real day the top three + came back as "Vattenfall InCharge" three times over, identical but for + a number nobody could see the meaning of. */} + {refuel.results.slice(0, 3).map(poi => { + const kind = STOP_KIND_BY_KEY[poi.category] + const KindIcon = kind?.Icon ?? Fuel + return ( +
    • + {/* Logical padding, not left/right: the rail runs the other way in Arabic and + the disc has to keep its distance from the reading edge either way. */} +
      + + + + + + {poi.name} + + {/* The two figures that decide it, in the order they decide it, + and as two badges rather than one line with a middot in it. + No value in this rail is ever joined to another that way: + they are separate facts, and a separator invites them to be + read as one number about one thing. + + The detour is what the list is SORTED by — everything here + is reachable already, so what separates them is what the + stop costs — and it is also the only thing telling three + branches of one chain apart. What is left in the tank when + the car draws level comes second, with the detour already + counted into it. The badge carries the figure and the + tooltip says which one it is, which is how two numbers stay + legible in a column this narrow. */} + + + + + + + + {formatDistance(poi.offRouteKm, distanceUnit)} + + + + + + + + + + {formatDistance(Math.round(poi.spareKm), distanceUnit)} + + + + + + {onAccept ? ( + + + + ) : null} +
      +
    • + ) + })}
    ) : ( /* Three different sentences for three different facts. "Nothing on this @@ -518,7 +657,6 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: { ) ) : null} -
) @@ -809,53 +947,6 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea ) } -/** - * Midnight, marked where it happens — in the chain, between the two stops the drive runs - * between, rather than as a fourth badge hanging off one of them. - * - * The schedule has carried this since it was written and nothing ever drew it: a day - * running past midnight showed "02:30" as if it were tonight. - */ -/** - * Where the chain crosses midnight. - * - * The one band in the rail that breaks the line instead of hanging off it, and the only - * thing here that moves: a night is not a place and not a drive, it is the day running - * out, and the rail should look like it stops. So it spans the marker column too, the - * dashes end above it and start again below, and the mascot dozes in the gap with three - * z's drifting off it. - * - * The ground is mixed rather than the soft info token: that token is translucent in the - * dark theme, and the mascot cuts its eyes out in the colour behind it, which would show - * the body through them. Mixing against the card keeps it opaque in both themes. - */ -function OvernightBreak(): React.ReactElement { - const { t } = useTranslation() - const night = 'color-mix(in srgb, var(--info) 13%, var(--bg-card))' - return ( -
- - - {t('roadtrip.warn.overnight')} - - {/* Three z's on one baseline, each starting a third of the loop after the last, so - one is always on its way up. Purely decorative: the word beside it already says - what this band is. */} - - z - z - z - -
- ) -} - /** * The arrival — the one value in the rail the user may have decided themselves. * @@ -1064,15 +1155,150 @@ function LateBadge({ late }: { late: ScheduleWarning | undefined }): React.React ) } +/** + * 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 ( +
  • + {/* Moonlight from the left, falling on the mascot. A radial wash rather than a + second background colour: it has no edge of its own, so the head reads as lit + rather than as another box stacked on the first. */} +
    +
    + + + {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. * * Stops count from one inside the day because that is what the map badges on its markers * (`dayOrderMap` numbers the selected day's assignments from 1). A rail counting across * the trip would put "17" beside a pin the map calls "3". + * + * A card is no longer one stored day. It is one DATE, and it draws whatever is reached on + * it — which on the morning after a night drive means a stretch that is stored on + * yesterday. Every stop keeps the day it is stored on in `block.source`, so a reorder, a + * move, a stay edit or a refuel offer still names the day the server knows it by. See + * `nightSpill.ts`. */ -function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading }: { +function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle }: { day: RoadtripDay + /** Folded down to the header, and off the map with it. */ + collapsed?: boolean + onToggle?: () => void selectedAssignmentId?: number | null onSelectStop?: (placeId: number, assignmentId: number) => void onReorderStop?: (dayId: number, assignmentId: number, toIndex: number) => void @@ -1094,13 +1320,16 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on loading?: boolean }): React.ReactElement { const { from, setFrom, dropAt, setDropAt } = drag - const dragging = from?.dayId === day.dayId ? from.index : null // Which disc the picker hangs under, and for which stop. One at a time: two open // popovers over the same rail is two answers to one question. const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const { t, language } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + // 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 = useSettingsStore(s => !!s.settings.roadtrip_day_colors) + const tint = dayColorsOn ? dayColor(day.dayNumber).line : null const last = day.stops.length - 1 /** * The findings a stop wears, minus the one the refuel band already says better. @@ -1110,12 +1339,147 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on * at once says the same thing twice, in the wrong order — the answer above the problem * — so the warning stands down wherever the band is showing. */ - const showingBand = !!refuel && !loading && !!day.dryPoints?.length + const showingBand = !!refuel && !loading const findingsFor = (i: number) => day.driveWarnings.filter(w => w.index === i && !(showingBand && w.code === 'range')) // The running number a stop wears, with the service stops passed over — so a day with a // charger halfway through still counts one, two, three the way its map pins do. let counted = 0 + + /** + * One row of the chain: the stop, the drive leaving it, whatever hangs off that drive. + * + * The day's own index `i` addresses everything that belongs to this DATE — the leg, the + * schedule entry, the dry points. Everything that WRITES uses `stop.ownerDayId` and + * `stop.ownerIndex` instead, because a stop driven onto this date through the night is + * still stored on the day it set off from (`nightSpill.ts`). + */ + const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => { + const service = isServiceStopType(stop.stopType) + if (!service) counted += 1 + // Every finding at this index is read on its own. Taking the first match let an + // overnight crossing swallow the "you arrive late" flag without a trace. + const marks = day.schedule.warnings.filter(w => w.index === i) + const ownDay = stop.ownerDayId + const ownIndex = stop.ownerIndex + return ( +
  • { + setFrom({ dayId: ownDay, index: ownIndex, assignmentId: stop.assignmentId }) + e.dataTransfer.effectAllowed = 'move' + // Firefox refuses to start a drag without payload, even an unused one. + e.dataTransfer.setData('text/plain', String(stop.assignmentId)) + } : undefined} + onDragEnd={() => { setFrom(null); setDropAt(null) }} + onDragOver={onReorderStop && from ? e => { + e.preventDefault() + if (dropAt?.dayId !== ownDay || dropAt.index !== ownIndex) setDropAt({ dayId: ownDay, index: ownIndex }) + } : undefined} + onDrop={onReorderStop && from ? e => { + e.preventDefault() + const src = from + setFrom(null) + setDropAt(null) + if (src.dayId === ownDay) { + if (src.index !== ownIndex) onReorderStop(ownDay, src.assignmentId, ownIndex) + } else { + onMoveStopToDay?.(src.dayId, src.assignmentId, ownDay, ownIndex) + } + } : undefined} + className={`rounded-lg transition-opacity ${from?.dayId === ownDay && from.index === ownIndex ? 'opacity-40' : ''} ${ + dropAt?.dayId === ownDay && dropAt.index === ownIndex && from && !(from.dayId === ownDay && from.index === ownIndex) + ? 'ring-2 ring-inset ring-accent' + : '' + }`} + > + {service ? ( + w.code === 'late')} + driveFindings={findingsFor(i)} + selected={selectedAssignmentId === stop.assignmentId} + onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} + onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined} + onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined} + onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} + /> + ) : ( + w.code === 'late')} + driveFindings={findingsFor(i)} + selected={selectedAssignmentId === stop.assignmentId} + continues={i < last} + starts={i === 0} + onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} + onMove={onReorderStop ? delta => onReorderStop(ownDay, stop.assignmentId, ownIndex + delta) : undefined} + canMove={{ up: i > 0, down: i < last }} + onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined} + onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined} + onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} + /> + )} + {i < last ? ( + onAskAlternatives(day.dayId, i) : undefined} + alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i} + /> + ) : null} + {/* Only on the leg the fuel actually runs out on, and only once the day has + finished routing: the warnings are republished after every routing task + and the early ones are wrong, so an offer that appears and moves while + the trip loads reads as a fault. */} + {refuel && !loading + ? (day.dryPoints ?? []) + .filter(dry => dry.legIndex === i) + .map(dry => ( + onAskRefuel?.(day.dayId, dry)} + onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} + /> + )) + : null} + {/* After the band, because a plugin halt happens on the drive it describes + rather than before setting off. */} + {i < last ? (day.legVias[i] ?? []).map((via, vi) => ( + + )) : null} +
  • + ) + } + + /** + * The chain in runs: the stretches driven onto this date through the night, and the + * day's own stops between and after them. + * + * Built rather than mapped straight through, because a spill is drawn inside a block of + * its own and a block cannot be opened halfway down a `map`. + */ + const runs: { spill: SpillMark | null; from: number; to: number }[] = [] + { + let at = 0 + for (const spill of day.spills ?? []) { + if (spill.at > at) runs.push({ spill: null, from: at, to: spill.at }) + runs.push({ spill, from: spill.at, to: spill.at + spill.count }) + at = spill.at + spill.count + } + if (at < day.stops.length) runs.push({ spill: null, from: at, to: day.stops.length }) + } + return (
    {/* Centred, with the day's facts as badges beneath its name: at this width a row @@ -1125,7 +1489,38 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on 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} + >

    {day.date ? ( -