feat(roadtrip): move a stop reached after midnight onto the day it is reached

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.
This commit is contained in:
Maurice
2026-09-10 19:33:23 +02:00
committed by Maurice
parent bd6e6312e1
commit 3f0eb210d8
53 changed files with 3980 additions and 626 deletions
+5 -2
View File
@@ -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({
<Polyline
key={`${i}-casing`}
positions={seg}
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={!!onRouteClick}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
@@ -1046,7 +1049,7 @@ export const MapView = memo(function MapView({
<Polyline
key={`${i}-core`}
positions={seg}
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [])}
+56 -8
View File
@@ -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(() => {
@@ -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 (
<>
@@ -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(
<AnchoredPopover anchor={anchor} label="Stop kind" onClose={onClose}>
<button>Inside</button>
</AnchoredPopover>,
)
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(
<AnchoredPopover anchor={null} label="Stop kind" onClose={onClose}>
<button>Inside</button>
</AnchoredPopover>,
)
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(
<AnchoredPopover anchor={second} label="Stop kind" onClose={onClose}>
<button>Inside</button>
</AnchoredPopover>,
)
// 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()
})
})
@@ -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<React.ComponentProps<typeof RangeStrip>> = {}) => (
<RangeStrip
rangeKm={500}
fillPercent={null}
wearPercent={null}
unit={'metric' as DistanceUnit}
electric={false}
{...over}
/>
)
/**
* 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<HTMLElement>('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()
})
})
@@ -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 (
<div className="flex flex-col gap-2 rounded-xl border border-edge-faint bg-surface-card px-3 py-2.5">
{/* 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. */}
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
<span className="flex items-baseline gap-1">
<span className="font-semibold tabular-nums leading-none text-content" style={{ fontSize: FS.total }}>
{shown ?? '—'}
</span>
{shown !== null ? (
<span className="font-semibold leading-none text-content-muted" style={{ fontSize: FS.totalUnit }}>
{getDistanceUnitLabel(unit)}
</span>
) : null}
</span>
<span className="text-caption text-content-faint">
{shown === null
? t('roadtrip.limit.rangeEmpty')
: t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')}
</span>
</div>
<div
className="h-2.5 w-full overflow-hidden rounded-full bg-surface"
role="img"
aria-label={shown === null ? note : `${shown} ${getDistanceUnitLabel(unit)} — ${note}`}
>
<div className="flex h-full w-full" style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined} aria-hidden>
<div
className="h-full transition-[width] duration-500 ease-out"
style={{ width: `${filled}%`, background: 'linear-gradient(90deg, var(--accent-hover), var(--accent))' }}
/>
<div className="h-full transition-[width] duration-500 ease-out" style={{ width: `${rest}%` }} />
<div
className="h-full transition-[width] duration-500 ease-out"
style={{ width: `${wear}%`, backgroundImage: stripes }}
/>
</div>
</div>
{/* 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. */}
<div className="flex flex-wrap items-center gap-1.5">
<span className={NOTE_CHIP}>{note}</span>
{mask ? (
<span className={NOTE_CHIP}>{t('roadtrip.limit.blockNote', { distance: formatDistance(BLOCK_KM, unit) })}</span>
) : 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 ? (
<span className={`${NOTE_CHIP} gap-1.5`}>
<span className="h-2 w-3.5 shrink-0 rounded-sm" style={{ backgroundImage: stripes }} aria-hidden />
{t('roadtrip.limit.wearNote', { percent: wear })}
</span>
) : null}
</div>
</div>
)
}
@@ -11,6 +11,8 @@ const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</Trans
const stop = (id: number, name: string): RoadtripStop => ({
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(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
// "Fuel" also names an option in the kind picker; the group header is the one
// inside a <header>.
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(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
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', () => {
@@ -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
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
{poi.name}
</div>
{/* 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. */}
<div
className="flex flex-wrap items-baseline gap-x-2 tabular-nums text-content-faint"
style={{ fontSize: FS.meta }}
>
<span>{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}</span>
<span>
{/* 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. */}
<div className="flex flex-wrap items-center gap-1" style={{ fontSize: FS.meta }}>
<span className={POI_CHIP}>
{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
</span>
<span className={POI_CHIP}>
{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 (
<section className="pb-1">
<header className="flex items-center gap-2.5 px-2 pb-1.5 pt-3">
// 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.
<section className="pb-1 pt-1.5 first:pt-0">
{/* 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. */}
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
className="group/cat flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
>
<span
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
// theme-lint-disable — see ResultBadge.
@@ -178,13 +207,21 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
<Icon size={12} strokeWidth={2} aria-hidden />
</span>
<span className="font-semibold tracking-[-0.01em] text-content" style={{ fontSize: FS.name }}>
{meta ? t(meta.labelKey) : category}
{label}
</span>
<span className={`ms-auto ${EYEBROW} tabular-nums`} style={{ fontSize: FS.micro }}>
{t('roadtrip.poi.found', { count: pois.length })}
</span>
</header>
<ul>
{/* 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. */}
<ChevronDown
size={13}
className={`shrink-0 text-content-faint transition-all group-hover/cat:text-content ${open ? '' : '-rotate-90'}`}
aria-hidden
/>
</button>
<ul hidden={!open}>
{pois.map(poi => (
<ResultRow
key={poi.osm_id}
@@ -267,9 +304,11 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
// Some boxes answered and some did not. Saying so is the difference between "there is
// no fuel on this stretch" and "nobody looked at this stretch".
else if (search.failedAreas > 0) warnings.push(['partial', t('roadtrip.poi.partial', { count: search.failedAreas })])
// The stretch was searched and the answer came back short. Without this line a filter
// finding nothing looks like proof there is nothing.
if (search.truncatedAreas > 0) warnings.push(['truncated', t('roadtrip.poi.truncated', { count: search.truncatedAreas })])
// A stretch that answered short is deliberately NOT reported. It fired on almost every
// search of a long day — the ceiling is per box and a busy corridor reaches it easily —
// so it read as a permanent complaint about a search that had in fact worked, and the
// advice it gave ("narrow the corridor") makes the answer smaller rather than better.
// The two warnings that remain are about a search that did not happen.
return (
<div className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
@@ -328,12 +367,16 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
{/* One word, so it can be read at the size a primary action deserves. The panel
it sits in is headed "along the route" and every control above it narrows the
same search; repeating that on the button only made it small. */}
same search; repeating that on the button only made it small.
Its height is the controls' height, not a size of its own. At 38px it stood a
head above the segmented rows it follows and read as a second panel rather
than as the end of this one. */}
<button
type="button"
onClick={search.search}
disabled={!canSearch}
className="flex h-[38px] w-full items-center justify-center gap-2 rounded-xl bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
className="flex h-[32px] w-full items-center justify-center gap-2 rounded-lg bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
>
{search.loading
? <RotateCw size={15} className="animate-spin" aria-hidden />
@@ -13,7 +13,7 @@ import RoadtripLimitsCard from './RoadtripLimitsCard'
* tenth of what was typed and only say so after the next reload.
*/
function open(onSave?: (key: string, value: number) => void) {
function open(onSave?: (key: string, value: number | string | boolean) => void) {
render(<RoadtripLimitsCard onSave={onSave} />)
fireEvent.click(screen.getByRole('button'))
}
@@ -21,6 +21,18 @@ function open(onSave?: (key: string, value: number) => void) {
/** Every number field, in the order the dialog lists them. */
const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
/**
* Unfolds the car's own figures.
*
* They sit behind a disclosure now, open from the start only when one of them is already
* stored — so a test that arrives with an empty vehicle has to ask for them, and one that
* arrives with values does not.
*/
function openSpec() {
const toggle = screen.queryByRole('button', { name: /Work it out from the car/i })
if (toggle && toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle)
}
beforeEach(() => {
useSettingsStore.setState({
settings: {
@@ -134,8 +146,13 @@ describe('RoadtripLimitsCard', () => {
})
open(vi.fn())
// Leg, day, battery, consumption, wear, range, fill. The three in the middle
// do not exist until there is a kind of car to have them.
// Four are always out: leg, day, range, fill. The car's own three are folded away
// until asked for, because nothing is stored on this vehicle yet.
expect(inputs()).toHaveLength(4)
openSpec()
// Leg, day, range, fill, battery, consumption, wear. The three at the end do not
// exist until there is a kind of car to have them.
expect(inputs()).toHaveLength(7)
expect(screen.getByText('kWh')).toBeInTheDocument()
expect(screen.getByText('kWh/100 km')).toBeInTheDocument()
@@ -168,7 +185,8 @@ describe('RoadtripLimitsCard', () => {
open(onSave)
// Leg, day, tank, consumption, range, fill.
const tank = inputs()[2]
openSpec()
const tank = screen.getByTestId('limit-tankLitres') as HTMLInputElement
fireEvent.change(tank, { target: { value: '15' } })
fireEvent.blur(tank)
@@ -188,8 +206,10 @@ describe('RoadtripLimitsCard', () => {
const onSave = vi.fn()
open(onSave)
// Leg, day, battery, consumption, wear, fill — the range row is read-only here.
const consumption = inputs()[3]
openSpec()
// By name rather than by position: the rows are no longer at fixed indices now that
// the car's figures live behind a disclosure.
const consumption = screen.getByTestId('limit-kwhPer100') as HTMLInputElement
fireEvent.change(consumption, { target: { value: '20' } })
// 58 kWh at 20 per hundred is 290 km, shown before anything is written: the gauge
@@ -198,4 +218,46 @@ describe('RoadtripLimitsCard', () => {
expect(screen.getAllByText('290').length).toBeGreaterThan(0)
expect(onSave).not.toHaveBeenCalled()
})
/**
* The two switches over the route line itself.
*
* Both are off by default and both change what the map draws, so what is asserted is
* that each reports its own key and that neither is written until it is pressed.
*/
describe('the route line', () => {
it('FE-ROADTRIP-LIMITS-013: joining the days up is off until it is asked for', () => {
const onSave = vi.fn()
open(onSave)
const toggle = screen.getByRole('button', { name: 'Connect the days' })
expect(toggle).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(toggle)
// Costs a routing request per join and changes every day's kilometres, so it is a
// decision rather than a default.
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', true)
})
it('FE-ROADTRIP-LIMITS-014: a colour per day is its own switch', () => {
const onSave = vi.fn()
open(onSave)
fireEvent.click(screen.getByRole('button', { name: 'A colour per day' }))
expect(onSave).toHaveBeenCalledWith('roadtrip_day_colors', true)
expect(onSave).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-LIMITS-015: a switch that is on reports the way back off', () => {
useSettingsStore.setState({
settings: { roadtrip_connect_days: true, distance_unit: 'metric' } as never,
})
const onSave = vi.fn()
open(onSave)
const toggle = screen.getByRole('button', { name: 'Connect the days' })
expect(toggle).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(toggle)
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', false)
})
})
})
@@ -1,15 +1,15 @@
import React, { useState } from 'react'
import {
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship,
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles,
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
} from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../utils/units'
import { convertDistance, formatDistance } from '../../utils/units'
import { formatDurationShort, parseAvoid, serializeAvoid, type VehicleKind } from './roadtripModel'
import { effectiveRangeKm, rangeFromSpec, showSpec, storeSpec, specUnit, type SpecKey, type VehicleSpec } from './vehicleRange'
import RangeStrip from './RangeStrip'
import { valhallaAvailable } from '../Map/valhallaRoute'
import ToggleSwitch from '../Settings/ToggleSwitch'
import type { DistanceUnit, RouteAvoidClass } from '../../types'
@@ -48,7 +48,7 @@ function parseLimit(raw: string): number {
* its shape either way, because a range that switched between an input and a line of text
* would move every row under it the moment a consumption was typed.
*/
function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived, wide, onDraft, onChange }: {
function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived, testId, onDraft, onChange }: {
icon: typeof Clock
label: string
suffix: string
@@ -57,15 +57,12 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
step?: number
derived?: string
/**
* Room for a unit longer than a symbol, set per section rather than per row.
* A stable handle for the tests, because the rows are no longer at fixed indices.
*
* "kWh/100 km" and "L/100 km" are units people actually quote, and a column sized for
* "min" wrapped them onto a second line, which made one row of the vehicle section
* taller than the rest. Sizing it from the string would have done the same thing
* sideways: only the consumption row would widen, and the section's right edge would
* come out ragged.
* The vehicle figures now live behind a disclosure, so "the fourth number field" means
* different things depending on whether it is open.
*/
wide?: boolean
testId?: string
/**
* What is in the field right now, on every keystroke, saved or not.
*
@@ -93,40 +90,57 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
}
return (
<label className="flex items-center gap-3">
<label className="flex min-h-9 items-center gap-4">
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
{derived ? (
// A mark rather than a word: "computed" is eight characters in English and
// fourteen in half the locales, and the column is as wide as the input it
// replaces. The sentence lives in the title and in the screen-reader text.
<span
className={`flex items-center justify-end gap-1.5 ${wide ? 'w-16' : 'w-20'}`}
title={derived}
data-testid="limit-derived"
>
<Sparkles size={12} className="shrink-0 text-accent-on" aria-hidden />
<span className="text-body font-semibold tabular-nums text-content">{value ?? 0}</span>
<span className="sr-only">{derived}</span>
{/* Label and unit in one wrapping flow, not in two columns. The unit used to have a
column of its own, sized per section, because "kWh/100 km" does not fit where
"min" does — and that column was the reason every section had a right edge of
its own. In the flow it simply wraps under a long label and costs nothing. */}
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-body text-content-secondary">{label}</span>
{/* The unit as a chip rather than as more grey words after the label. "kWh/100 km"
set as prose reads as part of the question; on a chip it reads as what the
number beside it will be measured in, which is what it is. */}
<span className="inline-flex items-center rounded-md bg-surface-tertiary px-1.5 py-0.5 text-caption leading-none text-content-muted">
{suffix}
</span>
) : (
<input
type="number"
inputMode="decimal"
min={0}
step={step ?? 1}
value={shown}
placeholder={placeholder}
onChange={e => { setDraft(e.target.value); onDraft?.(parseLimit(e.target.value)) }}
onBlur={commit}
// Committed here rather than by blurring and letting onBlur do it: a
// second commit is a no-op anyway, and going through blur made Enter
// depend on focus handling instead of on the key.
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
className={`${wide ? 'w-16' : 'w-20'} rounded-lg border border-edge bg-surface px-2 py-1 text-end text-body tabular-nums text-content focus:border-accent focus:outline-none`}
/>
)}
<span className={`${wide ? 'w-[86px]' : 'w-7'} whitespace-nowrap text-caption text-content-faint`}>{suffix}</span>
</span>
{/* The one right edge of the whole dialog. Sized from the type rather than in fixed
pixels, so it grows with the reader's text-size setting instead of clipping: six
digits of tabular figures plus the field's own padding, and never narrower than
a switch. Geometry, which the styleguide allows inline. */}
<span className="flex shrink-0 justify-end" style={{ width: 'max(5.5rem, calc(6ch + 1.75rem))' }}>
{derived ? (
// A mark rather than a word: "computed" is eight characters in English and
// fourteen in half the locales. The sentence lives in the title and in the
// screen-reader text.
<span className="flex items-center gap-1.5" title={derived} data-testid="limit-derived">
<Sparkles size={12} className="shrink-0 text-accent-on" aria-hidden />
<span className="text-body font-semibold tabular-nums text-content">{value ?? 0}</span>
<span className="sr-only">{derived}</span>
</span>
) : (
<input
type="number"
inputMode="decimal"
min={0}
step={step ?? 1}
value={shown}
placeholder={placeholder}
aria-label={`${label}, ${suffix}`}
data-testid={testId}
onChange={e => { setDraft(e.target.value); onDraft?.(parseLimit(e.target.value)) }}
onBlur={commit}
// Committed here rather than by blurring and letting onBlur do it: a
// second commit is a no-op anyway, and going through blur made Enter
// depend on focus handling instead of on the key.
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
// The spinners are the other half of the width problem: they eat a third of a
// narrow field and nobody sets a driving limit by clicking an arrow.
className="w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-end text-body tabular-nums text-content [appearance:textfield] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
)}
</span>
</label>
)
}
@@ -156,7 +170,35 @@ function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
)
}
/** The name of one half of the form. */
/**
* One section of the dialog, as a panel of its own.
*
* The complaint the redesign started from was "untidy", and the cause was that four
* sections sat in two columns with nothing between them but a heading: no edges, so the
* eye had to work out where one ended, and two right edges, so nothing lined up across
* the middle. A panel draws that boundary once and the rows inside inherit one rhythm.
*
* The closing note is part of the panel rather than a paragraph somebody remembers to add:
* every one of these sections has a sentence that has to be said (driving time is driving
* time only; avoidance is a weighting, not a ban), and left to each caller they drifted
* apart in spacing and in colour.
*/
function Panel({ icon: Icon, title, note, children }: {
icon: typeof Clock
title: string
note?: string
children: React.ReactNode
}): React.ReactElement {
return (
<section className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-4 py-3.5">
<SectionTitle icon={Icon}>{title}</SectionTitle>
{children}
{note ? <p className="text-caption leading-snug text-content-faint">{note}</p> : null}
</section>
)
}
/** The name of one section, inside its panel. */
function SectionTitle({ icon: Icon, children }: {
icon: typeof Clock
children: React.ReactNode
@@ -169,118 +211,6 @@ function SectionTitle({ icon: Icon, children }: {
)
}
/**
* The range, drawn as the thing it describes.
*
* The bar is the tank the day it left the factory, left to right, and it carries the two
* figures 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.
*/
function RangeGauge({ rangeKm, fillPercent, wearPercent, unit, electric, t }: {
rangeKm: number | null
fillPercent: number | null
wearPercent: number | null
unit: DistanceUnit
electric: boolean
t: (key: string, params?: Record<string, string | number>) => string
}): React.ReactElement {
// 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)'
return (
<div className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-tertiary px-4 py-3.5">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent-on">
{electric ? <Zap size={18} aria-hidden /> : <Fuel size={18} aria-hidden />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-baseline gap-1">
<span className="font-semibold tabular-nums leading-none text-content" style={{ fontSize: FS.total }}>
{shown ?? '—'}
</span>
{shown !== null ? (
<span className="font-semibold leading-none text-content-muted" style={{ fontSize: FS.totalUnit }}>
{getDistanceUnitLabel(unit)}
</span>
) : null}
</span>
<span className="truncate text-caption text-content-muted">
{shown === null
? t('roadtrip.limit.rangeEmpty')
: t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')}
</span>
</span>
</div>
{/* The quarter marks are painted over the whole bar rather than drawn into each
segment, so they stay put while the segments under them move. They are the
card's own colour, which makes them read as cut-outs: visible across the filled
part, invisible over the empty track, where there is nothing to divide. */}
<div className="relative h-3.5 w-full overflow-hidden rounded-full bg-surface">
<div className="flex h-full w-full">
<div
className="h-full transition-[width] duration-500 ease-out"
style={{ width: `${filled}%`, background: 'linear-gradient(90deg, var(--accent-hover), var(--accent))' }}
/>
<div className="h-full transition-[width] duration-500 ease-out" style={{ width: `${rest}%` }} />
<div
className="h-full transition-[width] duration-500 ease-out"
style={{ width: `${wear}%`, backgroundImage: stripes }}
/>
</div>
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage:
'repeating-linear-gradient(90deg, transparent 0, transparent calc(25% - 1px), var(--bg-tertiary) calc(25% - 1px), var(--bg-tertiary) 25%)',
}}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-caption leading-snug text-content-faint">
{shown === null ? (
<span>{t('roadtrip.limit.rangeEmptyHint')}</span>
) : (
<>
<span>
{fill < 100
? t('roadtrip.limit.afterFill', { percent: fill, distance: formatDistance((rangeKm! * fill) / 100, unit) })
: t('roadtrip.limit.fullNote')}
</span>
{/* The swatch is what ties the sentence to the tail of the bar. Without it the
hatching is an unexplained stripe and the sentence an unexplained loss. */}
{wear > 0 ? (
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-3.5 shrink-0 rounded-sm" style={{ backgroundImage: stripes }} aria-hidden />
{t('roadtrip.limit.wearNote', { percent: wear })}
</span>
) : null}
</>
)}
</div>
</div>
)
}
/** Not said, petrol, electric. "Not said" first, because it is what everybody starts on. */
const VEHICLES: { key: VehicleKind | null; labelKey: string; Icon: typeof Car }[] = [
{ key: null, labelKey: 'roadtrip.limit.vehicleAny', Icon: Car },
@@ -310,6 +240,14 @@ const SPEC_ROWS: Record<VehicleKind, { key: SpecKey; setting: string; Icon: type
],
}
/**
* The settings every one of the car's own figures is stored under.
*
* Read once, to decide whether the disclosure below the range starts open: a stored
* figure means the range row is derived, and its cause has to be in sight.
*/
const SPEC_SETTINGS = ['roadtrip_tank_litres', 'roadtrip_litres_per_100', 'roadtrip_battery_kwh', 'roadtrip_kwh_per_100', 'roadtrip_battery_degradation'] as const
export default function RoadtripLimitsCard({ onSave }: {
/**
* Persists one setting. Absent leaves the dialog read-only.
@@ -317,7 +255,7 @@ export default function RoadtripLimitsCard({ onSave }: {
* A string as well as a number since the avoidance is stored as a comma list: one
* decision with three parts, which goes to the router as one request either way.
*/
onSave?: (key: string, value: number | string) => void
onSave?: (key: string, value: number | string | boolean) => void
}): React.ReactElement {
const { t } = useTranslation()
const settings = useSettingsStore(s => s.settings)
@@ -354,6 +292,16 @@ export default function RoadtripLimitsCard({ onSave }: {
* through the same conversion the save would have used.
*/
const [preview, setPreview] = useState<{ key: SpecKey | 'range' | 'fill'; value: number } | null>(null)
/**
* Whether the car's own figures are unfolded.
*
* Open from the start the moment any of them is stored, because then the range row is
* read-only and folding its cause away would leave a field nobody can edit with no
* visible reason. Read once at mount: re-deriving it would slam the panel shut under
* the hand of somebody clearing the last field.
*/
const [specOpen, setSpecOpen] = useState(() =>
SPEC_SETTINGS.some(k => typeof (settings as unknown as Record<string, unknown>)[k] === 'number'))
const spec: VehicleSpec = preview && preview.key !== 'range' && preview.key !== 'fill'
? { ...saved, [preview.key]: storeSpec(preview.key, preview.value, imperial) }
: saved
@@ -464,147 +412,213 @@ export default function RoadtripLimitsCard({ onSave }: {
</button>
{open ? (
<Modal isOpen onClose={() => { setOpen(false); setPreview(null) }} size="2xl" title={
<Modal isOpen onClose={() => { setOpen(false); setPreview(null) }} size="4xl" title={
<span className="flex items-center gap-2">
<SlidersHorizontal size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.limit.title')}
</span>
}>
{/* Two columns from the small breakpoint up. One narrow column stopped reading
as a form and started reading as a wall, and the halves are different in
kind anyway: the left is the trip, the right is the car. */}
<div className="grid gap-x-8 gap-y-7 sm:grid-cols-2">
<div className="flex flex-col gap-7">
<section className="flex flex-col gap-4">
<SectionTitle icon={Clock}>{t('roadtrip.limit.sectionDriving')}</SectionTitle>
<LimitRow
icon={Clock}
label={t('roadtrip.limit.legLabel')}
suffix={t('roadtrip.limit.minutes')}
value={legMinutes}
placeholder={t('roadtrip.limit.off')}
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
/>
<LimitRow
icon={CalendarClock}
label={t('roadtrip.limit.dayLabel')}
suffix={t('roadtrip.limit.minutes')}
value={dayMinutes}
placeholder={t('roadtrip.limit.off')}
onChange={v => onSave?.('roadtrip_day_minutes', v)}
/>
{/* Said out loud rather than left to be discovered: the day figure this is
measured against is driving time only. It does not include how long
anyone stands still, and it does not include the leg to the hotel,
because the rail does not model one. A limit called "travel time per
day" over that number would be a lie that looks tidy. */}
<p className="text-caption leading-snug text-content-faint">{t('roadtrip.limit.hint')}</p>
</section>
<div className="flex flex-col gap-4">
{/* Two columns, kept. One column reads more tidily but turns the dialog into
something that has to be scrolled, and a setting you cannot see while you
change another is worse than a seam down the middle.
<section className="flex flex-col gap-3">
<SectionTitle icon={Signpost}>{t('roadtrip.avoid.section')}</SectionTitle>
{AVOID_ROWS.map(({ cls, icon, label }) => (
What answers the seam is not the count of columns but what is in them: each
section is now a panel of its own with a visible edge, every row inside it
ends on the same right edge, and the panels are stacked to one rhythm. The
left column is the trip, the right one is the car. */}
<div className="grid items-start gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-4">
<Panel icon={Clock} title={t('roadtrip.limit.sectionDriving')} note={t('roadtrip.limit.hint')}>
<LimitRow
icon={Clock}
label={t('roadtrip.limit.legLabel')}
suffix={t('roadtrip.limit.minutes')}
value={legMinutes}
placeholder={t('roadtrip.limit.off')}
testId="limit-legMinutes"
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
/>
<LimitRow
icon={CalendarClock}
label={t('roadtrip.limit.dayLabel')}
suffix={t('roadtrip.limit.minutes')}
value={dayMinutes}
placeholder={t('roadtrip.limit.off')}
testId="limit-dayMinutes"
onChange={v => onSave?.('roadtrip_day_minutes', v)}
/>
</Panel>
<Panel
icon={Signpost}
title={t('roadtrip.avoid.section')}
note={canAvoid ? t('roadtrip.avoid.hint') : t('roadtrip.avoid.unavailable')}
>
{AVOID_ROWS.map(({ cls, icon, label }) => (
<AvoidRow
key={cls}
icon={icon}
label={label}
on={avoiding.includes(cls)}
disabled={!canAvoid}
onToggle={() => toggleAvoid(cls)}
/>
))}
</Panel>
<Panel icon={Route} title={t('roadtrip.line.section')} note={t('roadtrip.line.hint')}>
{/* The road between one day's last stop and the next day's first is real
driving that the rail has never drawn: days are routed one at a time,
so that gap was never asked for. Off by default because it costs a
routing request per join and changes every day's kilometres. */}
<AvoidRow
key={cls}
icon={icon}
label={label}
on={avoiding.includes(cls)}
disabled={!canAvoid}
onToggle={() => toggleAvoid(cls)}
icon={Link2}
label={t('roadtrip.line.connect')}
on={!!settings.roadtrip_connect_days}
disabled={false}
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
/>
))}
{/* "Where possible" is doing real work in the label, so it is spelled out
here: the router weights a class down rather than banning it, and on a
drive with no way round one the road still uses it. The day says so
itself when that happens, which is the only honest way to offer this. */}
<p className="text-caption leading-snug text-content-faint">
{canAvoid ? t('roadtrip.avoid.hint') : t('roadtrip.avoid.unavailable')}
</p>
</section>
</div>
<section className="flex flex-col gap-4">
<SectionTitle icon={Car}>{t('roadtrip.limit.sectionVehicle')}</SectionTitle>
{/* First, because it decides what everything under it means: with no vehicle
named, a petrol station and a charger both fill the tank, which is
arithmetic that is wrong for everybody who drives just one of them. */}
<label className="flex items-center gap-3">
<Car size={16} className="shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1 text-body text-content-secondary">{t('roadtrip.limit.vehicleLabel')}</span>
<span className="w-[132px] shrink-0">
<CustomSelect
value={vehicle}
onChange={next => onSave?.('roadtrip_vehicle', String(next))}
size="sm"
options={VEHICLES.map(({ key, labelKey, Icon }) => ({
value: key ?? '',
label: t(labelKey),
icon: <Icon size={14} aria-hidden />,
}))}
{/* Which matters most once the line IS continuous: end to end it is one
stroke, and a colour per day is what puts the days back into it. */}
<AvoidRow
icon={Palette}
label={t('roadtrip.line.dayColors')}
on={!!settings.roadtrip_day_colors}
disabled={false}
onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)}
/>
</span>
</label>
</Panel>
</div>
<RangeGauge
rangeKm={planningKm}
fillPercent={shownFill}
wearPercent={vehicleKind === 'electric' ? spec.degradationPercent ?? null : null}
unit={distanceUnit}
electric={vehicleKind === 'electric'}
t={t}
/>
<Panel
icon={Car}
title={t('roadtrip.limit.sectionVehicle')}
note={vehicleKind ? t('roadtrip.limit.specHint') : t('roadtrip.limit.vehicleHint')}
>
{/* First, because it decides what everything under it means: with no
vehicle named, a petrol station and a charger both fill the tank, which
is arithmetic that is wrong for everybody who drives just one of them.
{/* The parts, when there is a kind of car to have parts of. Nothing here is
required: a traveller who knows their range types it below and never
looks at these. */}
{vehicleKind ? SPEC_ROWS[vehicleKind].map(({ key, setting, Icon, labelKey, step }) => (
<LimitRow
key={key}
icon={Icon}
label={t(labelKey)}
suffix={specUnit(key, imperial)}
step={step}
wide
value={showSpec(key, saved[key], imperial)}
placeholder={t('roadtrip.limit.off')}
onDraft={v => setPreview({ key, value: v })}
onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
Radios in a segmented shell rather than a dropdown: three options that
change the meaning of everything below them should be readable without
being opened, and the arrow keys, the names and the roles then come
from the browser instead of from a rebuilt listbox. */}
<fieldset className="min-w-0">
<legend className="sr-only">{t('roadtrip.limit.vehicleLabel')}</legend>
<div className="grid grid-cols-3 gap-1 rounded-xl border border-edge bg-surface-tertiary p-1">
{VEHICLES.map(({ key, labelKey, Icon }) => (
<label key={key ?? 'unset'} className="min-w-0">
<input
type="radio"
name="roadtrip-vehicle"
className="peer sr-only"
checked={(settings.roadtrip_vehicle || '') === (key ?? '')}
onChange={() => {
// Never writes the car's figures away: switching kind leaves
// both sets stored, `rangeFromSpec` simply returns null for
// the other one, and the typed range comes back as an editable
// field. Deleting the other kind's values here would lose data
// on a mis-click.
setPreview(null)
onSave?.('roadtrip_vehicle', key ?? '')
}}
/>
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent">
<Icon size={14} className="shrink-0" aria-hidden />
{t(labelKey)}
</span>
</label>
))}
</div>
</fieldset>
<RangeStrip
rangeKm={planningKm}
fillPercent={shownFill}
wearPercent={vehicleKind === 'electric' ? spec.degradationPercent ?? null : null}
unit={distanceUnit}
electric={vehicleKind === 'electric'}
/>
)) : null}
{/* 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. */}
<LimitRow
icon={Route}
// "on one tank" is the wrong noun for half the travellers who set this.
label={t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel')}
suffix={imperial ? 'mi' : 'km'}
value={rangeShown}
placeholder={t('roadtrip.limit.off')}
derived={computedKm ? t('roadtrip.limit.computed') : undefined}
wide
onDraft={v => 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. */}
<LimitRow
icon={BatteryCharging}
label={t('roadtrip.limit.fillLabel')}
suffix="%"
value={settings.roadtrip_fill_percent}
placeholder={t('roadtrip.limit.fillFull')}
wide
onDraft={v => setPreview({ key: 'fill', value: v > 100 ? 100 : v })}
onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)}
/>
<p className="text-caption leading-snug text-content-faint">
{vehicleKind ? t('roadtrip.limit.specHint') : t('roadtrip.limit.vehicleHint')}
</p>
</section>
{/* 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. */}
<LimitRow
icon={Route}
// "on one tank" is the wrong noun for half the travellers who set this.
label={t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel')}
suffix={imperial ? 'mi' : 'km'}
value={rangeShown}
placeholder={t('roadtrip.limit.off')}
derived={computedKm ? t('roadtrip.limit.computed') : undefined}
testId="limit-range"
onDraft={v => 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. */}
<LimitRow
icon={BatteryCharging}
label={t('roadtrip.limit.fillLabel')}
suffix="%"
value={settings.roadtrip_fill_percent}
placeholder={t('roadtrip.limit.fillFull')}
testId="limit-fill"
onDraft={v => 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 ? (
<>
<button
type="button"
onClick={() => { setSpecOpen(o => !o); setPreview(null) }}
aria-expanded={specOpen}
aria-controls="roadtrip-spec"
className="-mx-1 flex items-center gap-1.5 self-start rounded-lg px-1 py-0.5 text-caption font-semibold text-accent-on transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<ChevronRight
size={13}
className={`shrink-0 transition-transform ${specOpen ? 'rotate-90' : ''}`}
aria-hidden
/>
{t('roadtrip.limit.specToggle')}
</button>
{/* `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. */}
<div id="roadtrip-spec" hidden={!specOpen} className={specOpen ? 'flex flex-col gap-3' : ''}>
{SPEC_ROWS[vehicleKind].map(({ key, setting, Icon, labelKey, step }) => (
<LimitRow
key={key}
icon={Icon}
label={t(labelKey)}
suffix={specUnit(key, imperial)}
step={step}
value={showSpec(key, saved[key], imperial)}
placeholder={t('roadtrip.limit.off')}
testId={`limit-${key}`}
onDraft={v => setPreview({ key, value: v })}
onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
/>
))}
</div>
</>
) : null}
</Panel>
</div>
</div>
</Modal>
) : null}
@@ -12,6 +12,11 @@ const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</Trans
function stop(over: Partial<RoadtripStop> & { 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> = {}): 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> = {}): 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> = {}): RoadtripRoutes {
return {
days: [day()],
lines: [],
lineDays: [],
segments: [],
accessLines: [],
vias: [],
@@ -201,11 +214,15 @@ describe('RoadtripSidebar', () => {
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
// 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(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
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(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
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(<RoadtripSidebar routes={routes({ days: [spilled()] })} />)
expect(screen.getByText('From day 1')).toBeTruthy()
})
it('FE-ROADTRIP-SIDEBAR-032: draws the night drive, so the kilometres reach the stops', () => {
wrap(<RoadtripSidebar routes={routes({ days: [spilled()] })} />)
// 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(<RoadtripSidebar
routes={routes({ days: [spilled()] })}
onReorderStop={onReorderStop}
onMoveStopToDay={onMoveStopToDay}
/>)
// 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(<RoadtripSidebar routes={routes()} />)
expect(screen.queryByText(/From day/)).toBeNull()
})
})
})
describe('RoadtripModeSwitch', () => {
+528 -219
View File
@@ -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<number, number>
/** The name of the track each day follows, so the badge can say which road it is. */
trackNames?: Record<number, string>
/**
* 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<number>
/** Absent leaves every card open and its header unclickable. */
onToggleDay?: (dayId: number) => void
}
const MODE_ICON: Record<string, LucideIcon> = {
@@ -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 (
<div className="grid" style={RAIL_GRID}>
<span className="relative z-[1] flex flex-col items-center" aria-hidden>
<span className="flex-1" style={RAIL_DASH} />
</span>
<div className="min-w-0">
{/* 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. */}
<div className="my-1.5 flex flex-col gap-1 rounded-lg bg-warning-soft px-2 py-1">
<div className="flex items-center gap-1.5">
<Fuel size={12} strokeWidth={1.7} className="shrink-0 text-warning" aria-hidden />
<span className="min-w-0 truncate font-medium text-content" style={{ fontSize: FS.meta }}>
{t('roadtrip.refuel.dry')}
</span>
{/* 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. */}
<span className="ms-auto shrink-0 tabular-nums text-content-muted" style={{ fontSize: FS.meta }}>
{t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })}
</span>
{/* 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. */}
<div className="min-w-0">
{/* 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. */}
<div
className="my-1.5 flex flex-col gap-1 overflow-hidden rounded-xl border px-2 py-1.5"
style={{
borderColor: 'color-mix(in srgb, var(--danger) 22%, transparent)',
// Danger, not warning. The rail's amber says "this is more than you asked
// for" — a day over its driving budget, a leg longer than allowed, all of
// which are still plans that work. An empty tank is the point the drive stops
// being possible, which is the one thing in the rail that is not a matter of
// degree.
//
// The make-up is the night block's, a step quieter. That one is a break in the
// day and can afford to lift off the card; this sits between two stops in a
// running chain, and lit as hard it pulled the eye off everything around it.
// Shallow tint, no drop shadow, the top edge only just catching the light.
backgroundImage: 'linear-gradient(180deg, color-mix(in srgb, var(--danger) 11%, var(--bg-card)) 0%, color-mix(in srgb, var(--danger) 4%, var(--bg-card)) 70%, var(--bg-card) 100%)',
boxShadow: 'inset 0 1px 0 color-mix(in srgb, var(--danger) 20%, transparent)',
// The mascot draws itself in `--m-ink` and cuts its eyes out in `--m-bg`, so
// both have to be named here: the ink is the band's warning colour, and the
// ground has to be OPAQUE or the eyes show the body through them.
'--m-ink': 'var(--danger)',
'--m-bg': 'color-mix(in srgb, var(--danger) 11%, var(--bg-card))',
} as React.CSSProperties}
>
<div className="flex items-center gap-2">
{/* 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. */}
<MDancingTrek scene="transport" mood="sad" size={26} />
<div className="min-w-0 flex-1">
<div
className="truncate font-geist font-semibold uppercase tracking-[0.16em] text-danger"
style={{ fontSize: FS.label }}
>
{t(electric ? 'roadtrip.refuel.dryElectric' : 'roadtrip.refuel.dry')}
</div>
{/* 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. */}
<div className="truncate tabular-nums text-content-muted" style={{ fontSize: FS.meta }}>
{t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })}
</div>
</div>
{/* 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) ? (
<Tooltip label={t('common.close')}>
<button
type="button"
onClick={refuel.close}
aria-label={t('common.close')}
className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md text-content-muted transition-colors hover:bg-surface-card hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-lg text-content-muted transition-colors hover:bg-surface-card hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<X size={12} aria-hidden />
<X size={13} aria-hidden />
</button>
</Tooltip>
) : (
/* 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. */
<Tooltip label={settled && refuel.outcome !== 'found' ? t('roadtrip.refuel.again') : t('roadtrip.refuel.find')}>
<Tooltip label={settled && refuel.outcome !== 'found'
? t('roadtrip.refuel.again')
: t(electric ? 'roadtrip.refuel.findElectric' : 'roadtrip.refuel.find')}>
<button
type="button"
onClick={onAsk}
aria-label={settled && refuel.outcome !== 'found' ? t('roadtrip.refuel.again') : t('roadtrip.refuel.find')}
className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md bg-surface-card text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
aria-label={settled && refuel.outcome !== 'found'
? t('roadtrip.refuel.again')
: t(electric ? 'roadtrip.refuel.findElectric' : 'roadtrip.refuel.find')}
className="group/fuel grid h-[22px] w-[22px] shrink-0 place-items-center rounded-lg text-danger transition-colors hover:bg-danger-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{settled && refuel.outcome !== 'found'
? <RotateCcw size={11} aria-hidden />
: <Search size={11} aria-hidden />}
? <RotateCcw size={13} strokeWidth={2} aria-hidden />
: (
/* The glow stops under the pointer: a lamp that keeps blinking
while it is being aimed at reads as unresponsive. */
<DryIcon
size={14}
strokeWidth={2}
className="trek-lowfuel group-hover/fuel:[animation-play-state:paused]"
aria-hidden
/>
)}
</button>
</Tooltip>
)}
@@ -479,31 +555,94 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
{settled ? (
refuel.results.length ? (
<ul className="flex flex-col gap-0.5">
<ul className="flex flex-col gap-1">
{/* 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 => (
<li key={poi.osm_id} className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-content" style={{ fontSize: FS.meta }}>
{poi.name}
</span>
<span className="shrink-0 tabular-nums text-content-muted" style={{ fontSize: FS.meta }}>
{/* 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) })}
</span>
{onAccept ? (
<button
type="button"
onClick={() => onAccept(poi)}
aria-label={t('roadtrip.refuel.add', { name: poi.name })}
className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md bg-surface-card text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<Plus size={11} aria-hidden />
</button>
) : null}
</li>
))}
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 (
<li key={poi.osm_id}>
{/* 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. */}
<div className="flex items-center gap-2 rounded-lg bg-surface-card py-1 pe-1.5 ps-2.5">
<span
className={`${DISC} h-[22px] w-[22px] shrink-0`}
style={{ background: `color-mix(in srgb, ${kind?.color ?? 'var(--danger)'} 16%, transparent)`, color: kind?.color }}
aria-hidden
>
<KindIcon size={12} strokeWidth={2} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-content" style={{ fontSize: FS.name }}>
{poi.name}
</span>
{/* 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. */}
<span className="mt-0.5 flex items-center gap-1">
<Tooltip label={t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}>
<span className={FIGURE_BADGE}>
<span className="flex items-center bg-surface-tertiary px-1 text-content-faint">
<Milestone size={9} aria-hidden />
</span>
<span
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
style={{ fontSize: FS.label }}
>
{formatDistance(poi.offRouteKm, distanceUnit)}
</span>
</span>
</Tooltip>
<Tooltip label={t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })}>
<span className={FIGURE_BADGE}>
<span className="flex items-center bg-surface-tertiary px-1 text-content-faint">
<DryIcon size={9} aria-hidden />
</span>
<span
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
style={{ fontSize: FS.label }}
>
{formatDistance(Math.round(poi.spareKm), distanceUnit)}
</span>
</span>
</Tooltip>
</span>
</span>
{onAccept ? (
<Tooltip label={t(electric ? 'roadtrip.refuel.addElectric' : 'roadtrip.refuel.add', { name: poi.name })}>
<button
type="button"
onClick={() => onAccept(poi)}
aria-label={t(electric ? 'roadtrip.refuel.addElectric' : 'roadtrip.refuel.add', { name: poi.name })}
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-lg bg-surface-secondary text-content-secondary transition-colors hover:bg-accent hover:text-accent-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<Plus size={13} strokeWidth={2.2} aria-hidden />
</button>
</Tooltip>
) : null}
</div>
</li>
)
})}
</ul>
) : (
/* Three different sentences for three different facts. "Nothing on this
@@ -518,7 +657,6 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
</span>
)
) : null}
</div>
</div>
</div>
)
@@ -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 (
<div
className="my-1 flex w-full items-center gap-2 rounded-xl py-1 pe-2.5 ps-1.5 text-info"
style={{ background: night, '--m-bg': night, '--m-ink': 'var(--info)' } as React.CSSProperties}
>
<MDancingTrek scene="idle" mood="sleepy" size={26} />
<span
className="font-geist font-semibold uppercase tracking-[0.16em]"
style={{ fontSize: FS.label }}
>
{t('roadtrip.warn.overnight')}
</span>
{/* 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. */}
<span className="trek-doze ms-auto flex items-end gap-[3px]" aria-hidden>
<span style={{ fontSize: FS.micro }}>z</span>
<span style={{ fontSize: FS.label }}>z</span>
<span style={{ fontSize: FS.meta }}>z</span>
</span>
</div>
)
}
/**
* 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 (
<li
// Pulled out by exactly the padding it puts back inside, so the stops in here sit
// on the SAME left edge as the day's own stops below. Otherwise the block indents
// them by its own padding and their numbers and dashed line stand a few pixels
// right of every other number and line in the card — which reads as two lists
// rather than as one chain with a night in it.
className="trek-spill-in -mx-2 mb-1.5 mt-0.5 overflow-hidden rounded-xl border"
style={{
borderColor: edge,
backgroundImage: `linear-gradient(180deg, ${night} 0%, ${dawn} 58%, var(--bg-card) 100%)`,
// A lit pixel along the top edge, and nothing under it. The shadow that used to
// lift the block off the card made it the loudest thing on a screen where it is
// only ever context: what matters here are the stops inside it, not the frame.
boxShadow: `inset 0 1px 0 color-mix(in srgb, var(--info) 20%, transparent)`,
}}
>
{/* 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. */}
<div
className="px-2 pb-1 pt-2"
style={{ backgroundImage: `radial-gradient(140px 64px at 22px 26px, color-mix(in srgb, var(--info) 12%, transparent), transparent 72%)` }}
>
<div className="flex items-center gap-2 text-info">
<MDancingTrek scene="idle" mood="sleepy" size={26} />
<span
className="min-w-0 flex-1 truncate font-geist font-semibold uppercase tracking-[0.16em]"
style={{ fontSize: FS.label }}
>
{t('roadtrip.spill.title', { number: spill.fromDayNumber })}
</span>
{/* 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. */}
<span className="trek-doze flex items-end gap-[3px] self-start" aria-hidden>
<span style={{ fontSize: FS.micro }}>z</span>
<span style={{ fontSize: FS.label }}>z</span>
<span style={{ fontSize: FS.meta }}>z</span>
</span>
</div>
{/* 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. */}
<div className="grid" style={RAIL_GRID}>
<span className="relative z-[1] flex flex-col items-center" aria-hidden>
<span className="flex-1" style={RAIL_DASH} />
</span>
<div className="min-w-0">
{/* 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. */}
<div
className="my-1.5 flex w-full items-center gap-2 rounded-lg py-1 pe-2 ps-2"
style={{ background: 'color-mix(in srgb, var(--info) 12%, transparent)', color: 'var(--info)' }}
>
<Moon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
{leg
? t('roadtrip.leg.driveText', {
distance: formatDistance((leg.distance ?? 0) / 1000, distanceUnit),
time: formatDurationShort(leg.duration ?? 0),
})
: t('roadtrip.leg.pending')}
</span>
{/* 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 ? (
<span
dir="ltr"
className="ms-auto shrink-0 whitespace-nowrap font-semibold leading-6 tabular-nums"
style={{ fontSize: FS.time }}
>
{t('roadtrip.spill.departs', { time: formatClockTime(departure, is12h) })}
</span>
) : null}
</div>
</div>
</div>
</div>
{/* 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. */}
<ol className="px-[7px] pb-2">{children}</ol>
</li>
)
}
/**
* 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 (
<li
key={stop.assignmentId}
draggable={!!onReorderStop}
onDragStart={onReorderStop ? e => {
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 ? (
<ServiceStop
stop={stop}
entry={day.schedule.entries[i]}
late={marks.find(w => 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}
/>
) : (
<Stop
stop={stop}
number={counted}
entry={day.schedule.entries[i]}
late={marks.find(w => 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 ? (
<DriveBand
leg={day.legs[i]}
onAskAlternatives={onAskAlternatives ? () => 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 => (
<RefuelBand
key={`dry-${dry.legIndex}`}
dry={dry}
// The card's day, not the stop's stored one. This is the key the open
// search is filed under, and the hook is asked with the same number —
// a band inside a borrowed stretch had them differ, so the results
// arrived, drew on the map, and the band they belonged to never opened.
dayId={day.dayId}
refuel={refuel}
onAsk={() => 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) => (
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
)) : null}
</li>
)
}
/**
* 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 (
<section className="mx-3.5 shrink-0 overflow-hidden rounded-2xl border border-edge-faint bg-surface-card">
{/* 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. */}
<header className="mb-1 border-b border-edge-faint bg-surface-secondary px-3.5 pb-2.5 pt-3">
<header
// A button when there is something to fold, a plain header when there is not, so
// a read-only rail never offers a control that does nothing. The whole header is
// the target and the hover is the only sign of it: a chevron or a "fold" label
// beside the day's own facts read as a fourth fact about the day rather than as a
// control, and the header is what somebody is already looking at when they decide
// to put a day away.
{...(onToggle ? {
role: 'button' as const,
tabIndex: 0,
'aria-expanded': !collapsed,
onClick: onToggle,
onKeyDown: (e: React.KeyboardEvent) => {
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}
>
<h3
className="text-center font-semibold tracking-[-0.015em] text-content"
style={{ fontSize: FS.dayTitle }}
@@ -1140,11 +1535,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
) : null}
<div className="mt-1.5 flex flex-wrap justify-center gap-1">
{day.date ? (
<time dateTime={day.date} className={`${DAY_BADGE} border border-edge`} style={{ fontSize: FS.label }}>
<time dateTime={day.date} className={`${DAY_BADGE} border border-edge bg-surface-card`} style={{ fontSize: FS.label }}>
{formatDate(day.date, language)}
</time>
) : null}
<span className={`${DAY_BADGE} bg-surface-tertiary`} style={{ fontSize: FS.label }}>
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.leg.driveText', {
distance: formatDistance(day.distance / 1000, distanceUnit),
time: formatDurationShort(day.duration),
@@ -1160,7 +1555,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
</span>
</Tooltip>
) : null}
<span className={`${DAY_BADGE} bg-surface-tertiary`} style={{ fontSize: FS.label }}>
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })}
</span>
{/* The other half of "where possible". The setting is a weighting, so a day
@@ -1170,7 +1565,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
fact about this day's roads, not a warning about the plan. */}
{day.avoidMissed?.length ? (
<Tooltip label={t('roadtrip.avoid.missedHint')}>
<span className={`${DAY_BADGE} gap-1 bg-surface-tertiary text-content-secondary`} style={{ fontSize: FS.label }}>
<span className={`${DAY_BADGE} gap-1 bg-surface-card text-content-secondary`} style={{ fontSize: FS.label }}>
<Ban size={10} className="shrink-0" aria-hidden />
{t('roadtrip.avoid.missed', {
classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '),
@@ -1188,7 +1583,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
type="button"
onClick={() => onFollowTrack(day.dayId)}
className={`${DAY_BADGE} gap-1 transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
viaCount ? 'bg-accent-subtle text-content' : 'bg-surface-tertiary hover:bg-surface-hover'
viaCount ? 'bg-accent-subtle text-content' : 'bg-surface-card hover:bg-surface-hover'
}`}
style={{ fontSize: FS.label }}
>
@@ -1203,109 +1598,20 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
list of places, not of alternating places and connectors. A service stop owns
the drive leaving it in exactly the same way, which is what splits its leg into
band, marker, band without the list needing a second shape. */}
<ol className="px-3.5 pb-3 pt-3">
{day.stops.map((stop, i) => {
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)
return (
<li
key={stop.assignmentId}
draggable={!!onReorderStop}
onDragStart={onReorderStop ? e => {
setFrom({ dayId: day.dayId, index: i, 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 !== day.dayId || dropAt.index !== i) setDropAt({ dayId: day.dayId, index: i })
} : undefined}
onDrop={onReorderStop && from ? e => {
e.preventDefault()
const src = from
setFrom(null)
setDropAt(null)
if (src.dayId === day.dayId) {
if (src.index !== i) onReorderStop(day.dayId, src.assignmentId, i)
} else {
onMoveStopToDay?.(src.dayId, src.assignmentId, day.dayId, i)
}
} : undefined}
className={`rounded-lg transition-opacity ${dragging === i ? 'opacity-40' : ''} ${
dropAt?.dayId === day.dayId && dropAt.index === i && from && !(from.dayId === day.dayId && from.index === i)
? 'ring-2 ring-inset ring-accent'
: ''
}`}
>
{marks.some(w => w.code === 'overnight') ? <OvernightBreak /> : null}
{service ? (
<ServiceStop
stop={stop}
entry={day.schedule.entries[i]}
late={marks.find(w => 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}
/>
) : (
<Stop
stop={stop}
number={counted}
entry={day.schedule.entries[i]}
late={marks.find(w => 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(day.dayId, stop.assignmentId, i + 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 ? (
<DriveBand
leg={day.legs[i]}
onAskAlternatives={onAskAlternatives ? () => 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 => (
<RefuelBand
key={`dry-${dry.legIndex}`}
dry={dry}
dayId={day.dayId}
refuel={refuel}
onAsk={() => 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) => (
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
)) : null}
</li>
{/* Folded to the header, and with it off the map: what is not listed here is not
drawn there either, which is the whole point of putting a day away. */}
<ol className="px-3.5 pb-3 pt-3" hidden={collapsed}>
{runs.map(run => (
run.spill ? (
<SpillBlock key={`spill-${run.from}`} spill={run.spill}>
{day.stops.slice(run.from, run.to).map((stop, n) => renderStop(stop, run.from + n))}
</SpillBlock>
) : (
<React.Fragment key={`run-${run.from}`}>
{day.stops.slice(run.from, run.to).map((stop, n) => renderStop(stop, run.from + n))}
</React.Fragment>
)
})}
))}
</ol>
{picking ? (
<StopKindPicker
@@ -1408,6 +1714,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
export default function RoadtripSidebar({
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
collapsedDayIds, onToggleDay,
}: RoadtripSidebarProps): React.ReactElement {
const { t } = useTranslation()
// One drag state for the whole rail rather than one per day: a stop that cannot leave
@@ -1467,6 +1774,8 @@ export default function RoadtripSidebar({
onAskRefuel={onAskRefuel}
onAcceptRefuel={onAcceptRefuel}
loading={routes.loading}
collapsed={collapsedDayIds?.has(day.dayId)}
onToggle={onToggleDay ? () => onToggleDay(day.dayId) : undefined}
/>
))}
{routes.quietDays.map(day => (
@@ -6,6 +6,7 @@ import {
haversineKm,
pointAtMeters,
projectOntoRoute,
sliceAtMeters,
simplifyLine,
type LatLng,
} from './corridor'
@@ -214,3 +215,49 @@ describe('boxAround', () => {
expect(tromso.east - tromso.west).toBeGreaterThan(berlin.east - berlin.west)
})
})
describe('sliceAtMeters', () => {
// A degree of latitude is about 111 km, so this line is roughly 111 km long and every
// figure below can be read off it without a calculator.
const line: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 53, lng: 13 }]
it('cuts a stretch out of the middle and interpolates both ends', () => {
const out = sliceAtMeters(line, 30_000, 60_000)
expect(out).toHaveLength(2)
expect(haversineKm(out[0], out[1]) * 1000).toBeGreaterThan(29_000)
expect(haversineKm(out[0], out[1]) * 1000).toBeLessThan(31_000)
})
it('two adjacent slices meet exactly, so the parts rebuild the whole', () => {
// The reason this exists: a day's line is its legs end to end, and a night drive
// takes its own leg to the next card. A gap at the join would draw as a break in
// the road.
const first = sliceAtMeters(line, 0, 40_000)
const second = sliceAtMeters(line, 40_000, 80_000)
const end = first[first.length - 1]
expect(haversineKm(end, second[0])).toBeLessThan(0.001)
})
it('clamps to the line rather than running past its end', () => {
const out = sliceAtMeters(line, 0, 999_000_000)
expect(out[out.length - 1]).toEqual(line[line.length - 1])
})
it('is empty for a zero-length or backwards range', () => {
expect(sliceAtMeters(line, 50_000, 50_000)).toEqual([])
expect(sliceAtMeters(line, 60_000, 30_000)).toEqual([])
})
it('is empty for a line that is not one', () => {
expect(sliceAtMeters([], 0, 100)).toEqual([])
expect(sliceAtMeters([{ lat: 52, lng: 13 }], 0, 100)).toEqual([])
})
it('survives a repeated vertex without producing NaN', () => {
// A concatenated route line really does contain these, and dividing by a zero-length
// segment is how a route full of NaN reaches the map.
const doubled: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 52, lng: 13 }, { lat: 53, lng: 13 }]
const out = sliceAtMeters(doubled, 10_000, 20_000)
expect(out.every(p => Number.isFinite(p.lat) && Number.isFinite(p.lng))).toBe(true)
})
})
@@ -267,6 +267,61 @@ export function pointAtMeters(line: LatLng[], metres: number): LatLng | null {
return line[line.length - 1]
}
/**
* How long a line is, in metres, measured the way `sliceAtMeters` walks it.
*
* Not the same figure the router reports for the same road: that comes off its own graph
* while this is great-circle hops between the vertices it sent back. Anything cutting a
* line at a distance has to measure it with this, or the last cut falls short of the end
* by the difference.
*/
export function lineMetres(line: LatLng[]): number {
let total = 0
for (let i = 1; i < line.length; i++) total += haversineKm(line[i - 1], line[i]) * 1000
return total
}
/**
* The stretch of a line between two distances along it, ends included.
*
* The router answers a whole run with one polyline and a leg list beside it, so the road
* belonging to any single leg has to be cut out afterwards. Which matters because a road
* trip draws its stops by the day they are REACHED: a drive across midnight hands its
* stops to the next card, and the road under them has to go with them — otherwise the
* day they set off from keeps a line nobody drives on it, and the corridor search along
* "day 2" looks at road that was covered yesterday.
*
* Both ends are interpolated inside the segment they fall in rather than snapped to the
* nearest vertex, so two adjacent slices meet exactly and concatenating them gives the
* original line back.
*/
export function sliceAtMeters(line: LatLng[], fromMetres: number, toMetres: number): LatLng[] {
if (line.length < 2 || toMetres <= fromMetres) return []
const from = Math.max(0, fromMetres)
const out: LatLng[] = []
let covered = 0
for (let i = 1; i < line.length; i++) {
const step = haversineKm(line[i - 1], line[i]) * 1000
const segStart = covered
const segEnd = covered + step
covered = segEnd
if (segEnd < from) continue
if (segStart > toMetres) break
// A zero-length segment divides by zero; both its ends are the same point, so the
// fraction does not matter and zero is the one that never produces NaN.
const at = (m: number): LatLng => {
const t = step > 0 ? (m - segStart) / step : 0
return {
lat: line[i - 1].lat + (line[i].lat - line[i - 1].lat) * t,
lng: line[i - 1].lng + (line[i].lng - line[i - 1].lng) * t,
}
}
if (!out.length) out.push(segStart >= from ? line[i - 1] : at(from))
out.push(segEnd <= toMetres ? line[i] : at(toMetres))
}
return out.length > 1 ? out : []
}
/**
* A box around a point, sized so its inscribed circle reaches `radiusKm`.
*
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { dayColor } from './dayColors'
describe('dayColor', () => {
it('FE-DAYCOLORS-001: day one is the blue the route has always been', () => {
// So a one-day trip looks exactly as it did before colouring by day existed.
expect(dayColor(1).line).toBe('#0a84ff')
})
it('FE-DAYCOLORS-002: consecutive days differ', () => {
const seen = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(n => dayColor(n).line))
expect(seen.size).toBe(8)
})
it('FE-DAYCOLORS-003: the ninth day starts the palette again', () => {
expect(dayColor(9)).toEqual(dayColor(1))
expect(dayColor(17)).toEqual(dayColor(1))
})
it('FE-DAYCOLORS-004: the casing is its own colour, not the core', () => {
// A single fixed casing went muddy under the warm colours, which is why each entry
// carries a darkened hue of its own.
for (const n of [1, 2, 3, 4, 5, 6, 7, 8]) {
expect(dayColor(n).casing).not.toBe(dayColor(n).line)
}
})
it('FE-DAYCOLORS-005: a missing or nonsensical day still draws something', () => {
// A day number is read off data, and a route with no colour at all draws nothing.
expect(dayColor(0)).toEqual(dayColor(1))
expect(dayColor(-3)).toEqual(dayColor(1))
expect(dayColor(Number.NaN)).toEqual(dayColor(1))
})
})
@@ -0,0 +1,43 @@
/**
* A colour per day, for a road trip drawn end to end.
*
* The map draws one blue line for a whole trip, which is right while the days are read as
* separate cards. Once the days are joined into one continuous drive there is nothing in
* the line to say where one ends, so the colour has to do it.
*
* Fixed hex rather than theme tokens, and deliberately so: these sit on map tiles, not on
* the app's own surfaces, and a token that follows the user's accent would put the route
* in the same colour as the terrain on some schemes. The same exception the rest of the
* map's paint takes.
*/
/**
* Eight, then it repeats.
*
* Picked for separation at a 5px stroke over a pale basemap rather than for a gradient: a
* ramp reads as "later" and days are not a quantity. Blue stays first because it is the
* colour the route has always been, so a one-day trip looks unchanged.
*
* The second value is the casing under the core — the same hue darkened, which is what
* keeps the line readable over water and forest. A single fixed casing would have gone
* muddy under the warm colours.
*/
const DAY_COLORS: readonly [string, string][] = [
['#0a84ff', '#0a5cc2'], // theme-lint-disable — map paint, see file comment
['#ff9f0a', '#c2740a'], // theme-lint-disable
['#30d158', '#1f9c40'], // theme-lint-disable
['#bf5af2', '#8e3fb8'], // theme-lint-disable
['#ff375f', '#c22546'], // theme-lint-disable
['#64d2ff', '#3d9dc2'], // theme-lint-disable
['#ffd60a', '#c2a30a'], // theme-lint-disable
['#ac8e68', '#7d654a'], // theme-lint-disable
]
/** The core and casing a given day of the trip is drawn in. Day numbers start at 1. */
export function dayColor(dayNumber: number): { line: string; casing: string } {
// A day number that is missing or nonsensical still has to draw something, and the
// first colour is the one the route had before any of this existed.
const at = Number.isFinite(dayNumber) && dayNumber > 0 ? (dayNumber - 1) % DAY_COLORS.length : 0
const [line, casing] = DAY_COLORS[at]
return { line, casing }
}
@@ -0,0 +1,258 @@
import { describe, it, expect } from 'vitest'
import { spillChains } from './nightSpill'
import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
import type { RouteSegment } from '../../types'
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string }): RoadtripStop {
return {
ownerDayId: 1,
ownerIndex: 0,
placeId: over.assignmentId * 10,
lat: 53.5,
lng: 9.9,
time: null,
dwellMinutes: null,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
}
}
/** A day of the plan, with every stop's stored position filled in for it. */
function day(dayId: number, dayNumber: number, stops: RoadtripStop[]): PlanDay {
return {
dayId,
dayNumber,
date: null,
title: null,
stops: stops.map((s, i) => ({ ...s, ownerDayId: dayId, ownerIndex: i })),
}
}
const seg = (minutes: number, km = 100): RouteSegment =>
({ distance: km * 1000, duration: minutes * 60, distanceText: `${km} km`, durationText: `${minutes} min`, mode: 'driving' }) as RouteSegment
/** Every leg the same length, which is all most of these cases need. */
const everyLeg = (minutes: number, km = 100) => () => ({ seg: seg(minutes, km), line: [[0, 0], [1, 1]] as [number, number][] })
/** No leg routed at all, which is what an unrouted or offline trip looks like. */
const noLegs = () => undefined
describe('spillChains', () => {
it('FE-NIGHTSPILL-001: leaves an ordinary day exactly where it is', () => {
const plan = [day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '09:00', dwellMinutes: 60 }),
stop({ assignmentId: 2, name: 'Berlin' }),
])]
const chains = spillChains(plan, [], everyLeg(180))
expect(chains).toHaveLength(1)
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Berlin'])
expect(chains[0].spills).toEqual([])
})
it('FE-NIGHTSPILL-002: hands the stops reached after midnight to the next day', () => {
// 21:00 + 1 h 30 stay = 22:30, then a three hour drive arrives at 01:30 tomorrow.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
]),
day(2, 2, [
stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }),
stop({ assignmentId: 4, name: 'Leipzig' }),
]),
]
const chains = spillChains(plan, [], everyLeg(180))
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg'])
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Wittenberg', 'Leipzig'])
})
it('FE-NIGHTSPILL-003: the spill carries the night drive and the time it set off', () => {
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
]),
day(2, 2, [
stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }),
stop({ assignmentId: 4, name: 'Leipzig' }),
]),
]
const chains = spillChains(plan, [], everyLeg(180, 240))
expect(chains[1].spills).toHaveLength(1)
const spill = chains[1].spills[0]
expect(spill).toMatchObject({ at: 0, count: 1, fromDayNumber: 1, departure: '22:30' })
// The leg is the drive that crossed midnight, not the one after it: without it the
// card would gain a stop and none of the distance that reaches it.
expect(spill.leg?.distance).toBe(240000)
expect(spill.line.length).toBeGreaterThan(1)
})
it('FE-NIGHTSPILL-004: a stop that moved keeps the day the server stores it on', () => {
const plan = [
day(7, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
]),
day(8, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
]
const chains = spillChains(plan, [], everyLeg(180))
// Drawn under day 2, stored on day 1 — which is what every callback still names.
const moved = chains[1].stops.find(s => s.name === 'Neuruppin')
expect(moved?.ownerDayId).toBe(7)
expect(moved?.ownerIndex).toBe(1)
})
it('FE-NIGHTSPILL-005: the last day of a trip keeps its own crossing, having nowhere to send it', () => {
const plan = [day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
])]
const chains = spillChains(plan, [], everyLeg(180))
expect(chains).toHaveLength(1)
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin'])
expect(chains[0].spills).toEqual([])
// And it still says so: the arrival is a day past the one the card is under.
expect(chains[0].schedule.entries[1].dayOffset).toBe(1)
})
it('FE-NIGHTSPILL-006: a pinned time resolved to yesterday does not tear the day apart', () => {
// The case that broke it: 07:00 then 23:00. `computeSchedule` resolves the second pin
// to the occurrence nearest the drive reaching it, so nine hours late reads as nearer
// than fifteen hours of waiting and 23:00 lands on the PREVIOUS day. The whole chain
// is then lifted to stay positive, which leaves the FIRST stop wearing a day offset
// and the one after it none. Placing stops on those offsets moved the early stop to
// tomorrow and left the late one behind, visiting them out of order.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Cobbelsdorf', time: '07:00', dwellMinutes: 45 }),
stop({ assignmentId: 2, name: 'Wittenberg', time: '23:00', dwellMinutes: 180 }),
stop({ assignmentId: 3, name: 'Leipzig' }),
]),
day(2, 2, [stop({ assignmentId: 4, name: 'Dresden', time: '12:00' })]),
]
const chains = spillChains(plan, [], everyLeg(50))
// Cobbelsdorf and Wittenberg both stay: nothing between them crosses midnight.
expect(chains[0].stops.map(s => s.name)).toEqual(['Cobbelsdorf', 'Wittenberg'])
// Leipzig is reached at 02:50, after the clock went back past 23:00, so it moves.
expect(chains[1].stops.map(s => s.name)).toEqual(['Leipzig', 'Dresden'])
})
it('FE-NIGHTSPILL-007: two nights land on two different days', () => {
// 20:00, then two twelve-hour drives: 08:00 the next day, 20:00 that evening, and
// 08:00 the day after — two backwards jumps of the clock, two days on.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'A', time: '20:00' }),
stop({ assignmentId: 2, name: 'B' }),
stop({ assignmentId: 3, name: 'C' }),
stop({ assignmentId: 4, name: 'D' }),
]),
day(2, 2, []),
day(3, 3, []),
]
const chains = spillChains(plan, [], everyLeg(12 * 60))
expect(chains[0].stops.map(s => s.name)).toEqual(['A'])
expect(chains[1].stops.map(s => s.name)).toEqual(['B', 'C'])
expect(chains[2].stops.map(s => s.name)).toEqual(['D'])
})
it('FE-NIGHTSPILL-008: a day whose legs never routed moves nothing', () => {
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00' }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
]),
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
]
const chains = spillChains(plan, [], noLegs)
// No drive, no arrival, no crossing — and a guess would be worse than nothing.
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin'])
expect(chains[1].spills).toEqual([])
})
it('FE-NIGHTSPILL-009: a day that carries no drive of its own still receives one', () => {
const plan = [day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
])]
const quiet: QuietDay[] = [{ dayId: 2, dayNumber: 2, date: null, title: null, stops: [] }]
const chains = spillChains(plan, quiet, everyLeg(180))
expect(chains).toHaveLength(2)
expect(chains[1].dayId).toBe(2)
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin'])
})
it('FE-NIGHTSPILL-010: the crossing is not marked twice on a stop that moved', () => {
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
]),
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
]
const chains = spillChains(plan, [], everyLeg(180))
// The block around it is what says the night happened; a band as well would say it
// twice. And it is not "the next day" once it is drawn under the next day's date.
expect(chains[1].schedule.warnings.some(w => w.code === 'overnight')).toBe(false)
expect(chains[1].schedule.entries[0].dayOffset).toBe(0)
})
it('FE-NIGHTSPILL-011: a finding about a stop travels with it, renumbered', () => {
// Wittenberg is pinned two hours before the drive can get there, which is a "late"
// finding against index 1 of day 1. It moves to day 2 as index 0.
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin', time: '00:30' }),
]),
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
]
const chains = spillChains(plan, [], everyLeg(180))
const late = chains[1].schedule.warnings.filter(w => w.code === 'late')
expect(late).toHaveLength(1)
expect(late[0].index).toBe(0)
})
it('FE-NIGHTSPILL-012: two stops crossing together are one block, not two', () => {
const plan = [
day(1, 1, [
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
stop({ assignmentId: 2, name: 'Neuruppin' }),
stop({ assignmentId: 3, name: 'Potsdam' }),
]),
day(2, 2, [stop({ assignmentId: 4, name: 'Wittenberg', time: '10:00' })]),
]
const chains = spillChains(plan, [], everyLeg(180))
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Potsdam', 'Wittenberg'])
expect(chains[1].spills).toHaveLength(1)
expect(chains[1].spills[0]).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 })
})
})
@@ -0,0 +1,212 @@
import { computeSchedule, parseClock, type Schedule } from './roadtripModel'
import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
import type { RouteSegment } from '../../types'
/**
* Which date each stop of a road trip is drawn on.
*
* A day plan stores a stop against a day, and that is the right model for planning: the
* traveller put it there. But a road trip is read as a drive, and a drive that leaves at
* 21:00 and arrives at 01:23 arrives TOMORROW. The rail used to say so with a band
* between the two stops and then carry on listing the arrival under yesterday's date —
* the one thing the band had just said was wrong.
*
* So a road trip lays its stops out by the day they are actually reached, and the drive
* across midnight travels with them, which is what makes the kilometres count on the day
* they are driven rather than on the day they were set off on.
*
* **Nothing is written.** The stop still belongs to the day it was planned on, the day
* plan shows it there, and every stop carries `ownerDayId`/`ownerIndex` so a reorder, a
* move, a stay edit or a refuel offer still names the day the server knows it by. Because
* this is worked out again from the arrival times every time they change, shortening the
* stay before a night drive brings the stop back on its own — there is no state to undo,
* and no way for the arrangement and the times to disagree.
*/
/** A stretch of one card's chain that was driven onto it from an earlier day. */
export interface SpillMark {
/** Where it starts in the card's `stops`. */
at: number
/** How many stops it covers. */
count: number
/** The day they are stored on, as the traveller numbers it. */
fromDayNumber: number
/** When the drive set off, read off that day's own schedule. */
departure: string | null
/** The drive across midnight itself, or undefined when that leg never routed. */
leg: RouteSegment | undefined
/**
* The stop the night drive LEAVES from, still on the earlier card.
*
* Needed because that drive is drawn on this card while belonging, leg-wise, to the
* stop before it: anything measured along this card's line that lands on the incoming
* stretch has to be anchored to this stop, not to the first one drawn here. Putting a
* via there and anchoring it to the wrong stop is what made a route double back.
*/
fromStop: RoadtripStop | undefined
/**
* The road it covers, so the card it lands on can draw it and count it.
*
* Without this the night is a hole: the day it set off from ends before it and the day
* it arrives on starts after it, so its kilometres belong to neither and the map draws
* two runs with a gap between them.
*/
line: [number, number][]
}
/** One date's worth of driving, before the legs and warnings are hung off it. */
export interface SpillChain {
dayId: number
dayNumber: number
date: string | null
title: string | null
/** In driving order: anything inherited first, then the day's own. */
stops: RoadtripStop[]
/** Arrivals as each stop's OWN day worked them out, renumbered onto this chain. */
schedule: Schedule
/** The inherited stretches, in the order they appear. Empty on an ordinary day. */
spills: SpillMark[]
}
/** A day as the plan holds it, before anything is routed onto it. */
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
/** What the caller can find out about the road between two stops. */
type LegLookup = (from: RoadtripStop, to: RoadtripStop) => { seg: RouteSegment; line: [number, number][] } | undefined
/** One stop on its way to a card, with the times its own day gave it. */
interface Placed {
stop: RoadtripStop
arrival: string | null
departure: string | null
/** Days past its own day's first stop. Renumbered against the card it lands on. */
dayOffset: number
/** Findings its own day filed against it, to be renumbered with it. */
codes: ('late' | 'overnight' | 'leg' | 'range')[]
lateMinutes: number | null
/** Which day it came from, or null when it is the card's own. */
fromDayNumber: number | null
departedAt: string | null
leg: RouteSegment | undefined
line: [number, number][]
from: RoadtripStop | undefined
}
/**
* Lays a trip out by the date each stop is reached.
*
* Every day of the trip gets a chain, in trip order, including the ones that carry no
* drive of their own — a day whose only content was driven onto it through the night is
* still a day, and the alternative is a date that silently disappears. The caller drops
* the chains that end up with fewer than two stops, exactly as it did before.
*/
export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegLookup): SpillChain[] {
const all: PlanDay[] = [...plan, ...quietDays.map(d => ({
dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops,
}))].sort((a, b) => a.dayNumber - b.dayNumber)
const numbers = new Set(all.map(d => d.dayNumber))
const landing = new Map<number, Placed[]>()
for (const d of all) landing.set(d.dayNumber, [])
for (const d of all) {
// A day's own schedule, walked over its own stops: this is what says where midnight
// falls, and it is the only reading of these times that is anchored to the times the
// traveller actually pinned.
const routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1]))
const legs = routed.map(l => l?.seg)
const schedule = computeSchedule(
d.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
legs.map(l => l?.duration),
)
/**
* Which day of this chain each stop is reached on, read off the clock itself.
*
* Not `dayOffset`. That is arithmetic on top of a pinned time resolved to the
* occurrence NEAREST the drive reaching it — so a stop pinned at 23:00 after one
* pinned at 07:00 resolves to 23:00 yesterday (nine hours late reads as nearer than
* fifteen hours of waiting), the chain drops below midnight, and the schedule lifts
* the whole day back up to keep it positive. The result is an early stop wearing a
* higher offset than the one after it, and placing stops on those offsets tears the
* drive into pieces that are visited out of order.
*
* A clock that goes backwards is the one signal none of that can fake. Arrivals only
* ever move forward along a drive, so 23:00 followed by 03:12 is one thing and one
* thing only: midnight happened in between. A stop with no arrival yet inherits the
* day of the one before it rather than resetting the count.
*/
let day = 0
let previous: number | null = null
d.stops.forEach((stop, i) => {
const entry = schedule.entries[i]
const clock = parseClock(entry?.arrival)
if (clock !== null) {
if (previous !== null && clock < previous) day += 1
previous = clock
}
const offset = day
const marks = schedule.warnings.filter(w => w.index === i)
// A day the trip does not have is not a day to move onto. Those stops stay where
// they are and keep the crossing marked the way the rail always marked it, because
// the only other answer would be to invent a date nobody planned. The ratchet stays
// where it was too, so the rest of the chain stays with them.
const reachable = offset > 0 && numbers.has(d.dayNumber + offset)
const target = reachable ? d.dayNumber + offset : d.dayNumber
const moved = target !== d.dayNumber
landing.get(target)?.push({
stop,
arrival: entry?.arrival ?? null,
departure: entry?.departure ?? null,
// Renumbered against the card it lands on: 01:23 is not "the next day" once it is
// drawn under the next day's date. A stop that could not move keeps its offset,
// which is what still makes the "+1" and the crossing band read correctly.
dayOffset: moved ? 0 : offset,
// The crossing itself is the spill block's own head wherever the stop moved, so
// it would be said twice.
codes: marks.map(w => w.code).filter(c => !(moved && c === 'overnight')),
lateMinutes: marks.find(w => w.code === 'late')?.minutes ?? null,
fromDayNumber: moved ? d.dayNumber : null,
departedAt: moved ? schedule.entries[i - 1]?.departure ?? null : null,
leg: moved ? legs[i - 1] : undefined,
line: moved ? routed[i - 1]?.line ?? [] : [],
from: moved ? d.stops[i - 1] : undefined,
})
})
}
const out: SpillChain[] = []
for (const d of all) {
const placed = landing.get(d.dayNumber) ?? []
if (!placed.length) continue
// Inherited stretches first, oldest day first, then the card's own stops: that is
// the order they are driven in.
placed.sort((a, b) => (a.fromDayNumber ?? Number.MAX_SAFE_INTEGER) - (b.fromDayNumber ?? Number.MAX_SAFE_INTEGER))
const spills: SpillMark[] = []
for (let i = 0; i < placed.length; i++) {
const p = placed[i]
if (p.fromDayNumber === null) continue
const last = spills[spills.length - 1]
if (last && last.fromDayNumber === p.fromDayNumber && last.at + last.count === i) last.count += 1
else spills.push({ at: i, count: 1, fromDayNumber: p.fromDayNumber, departure: p.departedAt, leg: p.leg, line: p.line, fromStop: p.from })
}
out.push({
dayId: d.dayId,
dayNumber: d.dayNumber,
date: d.date,
title: d.title,
stops: placed.map(p => p.stop),
schedule: {
entries: placed.map(p => ({
arrival: p.arrival,
departure: p.departure,
anchored: p.stop.time !== null && p.stop.time !== undefined,
dayOffset: p.dayOffset,
})),
warnings: placed.flatMap((p, i) => p.codes.map(code => (
code === 'late' ? { index: i, code, minutes: p.lateMinutes ?? 0 } : { index: i, code }
))),
},
spills,
})
}
return out
}
@@ -30,8 +30,8 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
return {
dayId: 7, dayNumber: 1, date: null, title: null,
stops: [
{ assignmentId: 1, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 2, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 2, ownerDayId: 1, ownerIndex: 1, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
],
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
geometry: [[START.lat, START.lng], [END.lat, END.lng]],
@@ -41,7 +41,7 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
}
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
days, lines: [], lineDays: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
})
function viasStub(over: Partial<RoadtripVias> = {}): RoadtripVias {
@@ -0,0 +1,282 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { haversineKm, pointAtMeters, type Bbox, type LatLng } from './corridor'
const { pois, offline, stored } = vi.hoisted(() => ({
pois: vi.fn(),
offline: vi.fn(),
stored: { settings: {} as { roadtrip_vehicle?: string } },
}))
vi.mock('../../api/client', () => ({ mapsApi: { pois } }))
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
vi.mock('../../sync/networkMode', () => ({ isEffectivelyOffline: offline }))
vi.mock('../../store/settingsStore', () => ({
useSettingsStore: (select: (state: typeof stored) => unknown) => select(stored),
}))
import { useRefuelSearch } from './useRefuelSearch'
/**
* A hundred kilometres due north. A straight north-south drive makes "before the dry
* point" a plain statement about latitude, so the direction the search looked in can be
* read off the box the request carried.
*/
const LINE: LatLng[] = [{ lat: 53, lng: 10 }, { lat: 53.9, lng: 10 }]
const DRY_KM = 60
const DRY = pointAtMeters(LINE, DRY_KM * 1000)!
const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
osm_id: id,
name: id,
lat,
lng,
category,
poi_type: 'amenity=fuel',
address: null,
website: null,
phone: null,
opening_hours: null,
cuisine: null,
source: 'openstreetmap',
})
/**
* A station `alongKm` into the day, `eastKm` to the side of the road.
*
* The side step is a rough degree conversion rather than the projection the hook uses:
* nothing here depends on the exact metre, only on a larger `eastKm` really sitting
* further from the line, which is what orders the offers.
*/
const station = (id: string, alongKm: number, eastKm = 0.5) => {
const on = pointAtMeters(LINE, alongKm * 1000)!
return hit(id, on.lat, on.lng + eastKm / 66)
}
const answer = (items: ReturnType<typeof hit>[], over: { truncated?: boolean; clamped?: boolean } = {}) =>
({ pois: items, source: 'openstreetmap', truncated: false, clamped: false, ...over })
const boxOf = (call: unknown[]) => call[1] as Bbox
const signalOf = (call: unknown[]) => call[3] as AbortSignal
/** A request left hanging, so a second one can overtake it. */
function held() {
let settle!: (value: ReturnType<typeof answer>) => void
const promise = new Promise<ReturnType<typeof answer>>(resolve => { settle = resolve })
return { promise, settle }
}
beforeEach(() => {
pois.mockReset().mockResolvedValue(answer([]))
offline.mockReset().mockReturnValue(false)
stored.settings = {}
})
describe('useRefuelSearch', () => {
it('FE-REFUELSEARCH-001: offline it says so rather than offering a stop that cannot be kept', async () => {
// Accepting an offer writes a place and assigns it to a day; only the first of those
// queues offline, so anything accepted here would end up an orphan. Saying "could not
// look" is the honest answer, and it must not cost a request either.
offline.mockReturnValue(true)
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(pois).not.toHaveBeenCalled()
expect(result.current.outcome).toBe('failed')
expect(result.current.loading).toBe(false)
// The band still opens on the point that was asked about, or there is nowhere to
// print the answer.
expect(result.current.openFor).toBe('4:1')
})
it('FE-REFUELSEARCH-002: somebody who drives a petrol car is not sent to a charger', async () => {
stored.settings = { roadtrip_vehicle: 'combustion' }
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(pois.mock.calls[0][0]).toBe('fuel')
// The names come back in the traveller's language, so the rail is not half German.
expect(pois.mock.calls[0][2]).toBe('en-US')
})
it('FE-REFUELSEARCH-003: an electric car is not sent to a petrol pump', async () => {
stored.settings = { roadtrip_vehicle: 'electric' }
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(pois.mock.calls[0][0]).toBe('charging')
})
it('FE-REFUELSEARCH-004: with nothing said about the car both kinds are looked for', async () => {
const { result, rerender } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(pois.mock.calls[0][0]).toBe('fuel,charging')
// And a value this hook has never heard of falls back to both rather than narrowing
// the search to nothing: an empty category list would read on screen as "there is no
// filling station on this road", which is a statement about the road.
stored.settings = { roadtrip_vehicle: 'hovercraft' }
rerender()
await act(async () => { await result.current.ask('4:2', DRY, LINE, DRY_KM, []) })
expect(pois.mock.calls[1][0]).toBe('fuel,charging')
})
it('FE-REFUELSEARCH-005: it looks at the road already driven, not around the dry point', async () => {
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
const box = boxOf(pois.mock.calls[0])
const centre = { lat: (box.south + box.north) / 2, lng: (box.west + box.east) / 2 }
// South is backwards along this drive. Centring on the dry point itself would spend
// half the circle on road that lies beyond an empty tank.
expect(centre.lat).toBeLessThan(DRY.lat)
expect(haversineKm(centre, DRY)).toBeGreaterThan(11)
expect(haversineKm(centre, DRY)).toBeLessThan(13)
// Pulled back, but not so far that the last kilometres before the tank empties fall
// outside the box: a pump two kilometres short of the dry point is the best answer
// there is.
expect(box.north).toBeGreaterThan(DRY.lat)
})
it('FE-REFUELSEARCH-006: a second question abandons the answer to the first', async () => {
const first = held()
pois.mockImplementationOnce(() => first.promise)
const { result } = renderHook(() => useRefuelSearch())
let pending!: Promise<void>
act(() => { pending = result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
pois.mockResolvedValueOnce(answer([station('second', 30)]))
await act(async () => { await result.current.ask('5:0', DRY, LINE, DRY_KM, []) })
// The abandoned request answers late, the way a slow one does. Landing it now would
// put day four's stations under day five's heading, with day five's accept button
// under them.
await act(async () => { first.settle(answer([station('first', 45)])); await pending })
expect(signalOf(pois.mock.calls[0]).aborted).toBe(true)
expect(result.current.results.map(p => p.osm_id)).toEqual(['second'])
expect(result.current.openFor).toBe('5:0')
expect(result.current.loading).toBe(false)
})
it('FE-REFUELSEARCH-007: asking again empties the rail before the new answer arrives', async () => {
pois.mockResolvedValueOnce(answer([station('old', 40)]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(result.current.results).toHaveLength(1)
pois.mockImplementationOnce(() => held().promise)
act(() => { void result.current.ask('5:0', DRY, LINE, DRY_KM, []) })
// Left standing, the first search's stations sit under the new heading for as long as
// the second one takes, and accepting one would file it against the wrong dry point.
expect(result.current.results).toEqual([])
expect(result.current.offered).toEqual([])
expect(result.current.outcome).toBeNull()
expect(result.current.loading).toBe(true)
})
it('FE-REFUELSEARCH-008: a request that fails is a failure, not an empty road', async () => {
pois.mockRejectedValue(new Error('502'))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(result.current.outcome).toBe('failed')
// Left spinning, the band offers no way to ask again.
expect(result.current.loading).toBe(false)
})
it('FE-REFUELSEARCH-009: stations the car cannot reach are not a find', async () => {
// Both come back from the server and both are real filling stations; both sit past
// the point the tank empties, so neither answers the question that was asked.
pois.mockResolvedValue(answer([station('just-past', 58), station('well-past', 70)]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(result.current.results).toEqual([])
expect(result.current.outcome).toBe('none')
})
it('FE-REFUELSEARCH-010: a search the server cut short is not reported as an empty road', async () => {
pois.mockResolvedValue(answer([], { truncated: true }))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
// "There is nothing here" and "we did not finish looking" send a traveller to two
// different places, and only the server knows which of them happened.
expect(result.current.outcome).toBe('incomplete')
})
it('FE-REFUELSEARCH-011: a pump already on the plan is not offered beside itself', async () => {
const planned = station('planned', 40)
pois.mockResolvedValue(answer([planned, station('new', 45, 1.5)]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => {
await result.current.ask('4:1', DRY, LINE, DRY_KM, [{ lat: planned.lat, lng: planned.lng }])
})
expect(result.current.results.map(p => p.osm_id)).toEqual(['new'])
})
it('FE-REFUELSEARCH-012: three go on the map, and they are the three the rail lists', async () => {
// Ordered by the detour, so the map has to draw the first three of that same list
// rather than any three: a pin the rail does not name is a stop nobody can accept.
pois.mockResolvedValue(answer([
station('far', 30, 2.5),
station('nearest', 44, 0.2),
station('third', 36, 1.5),
station('fourth', 32, 2),
station('second', 40, 0.8),
]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(result.current.results).toHaveLength(5)
expect(result.current.offered.map(p => p.osm_id)).toEqual(['nearest', 'second', 'third'])
expect(result.current.offered).toEqual(result.current.results.slice(0, 3))
})
it('FE-REFUELSEARCH-013: with nothing open the offer list is the same array every time', async () => {
// The map rebuilds every pin when this reference changes, and the planner re-renders
// on anything from a drag to an incoming websocket event. A fresh [] per render is a
// map that flickers all day for a search nobody started.
const { result, rerender } = renderHook(() => useRefuelSearch())
const idle = result.current.offered
rerender()
expect(result.current.offered).toBe(idle)
pois.mockResolvedValue(answer([station('a', 40)]))
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
const open = result.current.offered
rerender()
expect(result.current.offered).toBe(open)
act(() => { result.current.close() })
expect(result.current.offered).toBe(idle)
})
it('FE-REFUELSEARCH-014: closing the band drops the answer and the request behind it', async () => {
pois.mockImplementationOnce(() => held().promise)
const { result } = renderHook(() => useRefuelSearch())
act(() => { void result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
act(() => { result.current.close() })
expect(signalOf(pois.mock.calls[0]).aborted).toBe(true)
expect(result.current.openFor).toBeNull()
expect(result.current.results).toEqual([])
expect(result.current.outcome).toBeNull()
expect(result.current.loading).toBe(false)
})
})
@@ -21,8 +21,8 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
return {
dayId: 1, dayNumber: 1, date: null, title: null,
stops: [
{ assignmentId: 1, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 2, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
{ assignmentId: 2, ownerDayId: 1, ownerIndex: 1, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
],
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
geometry: [[53.5, 9.9], [53.0, 11.0], [52.5, 13.4]],
@@ -32,7 +32,7 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
}
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
days, lines: [], lineDays: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
})
/** The search state the mocked hook hands back, results included. */
@@ -352,6 +352,74 @@ describe('useRoadtripRoutes', () => {
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2))
})
/**
* Days read by the date each stop is REACHED, and the road between them.
*
* The arrangement itself is tested in `nightSpill.test.ts`; what is asserted here is
* what the hook does with it — the roads it has to ask for that a day-at-a-time plan
* never needed, and what the switch over them turns on.
*/
describe('driving that crosses a day boundary', () => {
/** Two days, the first of which sets off late enough to arrive after midnight. */
const overnight = () => ({
days: [day(1, 1), day(2, 2)],
assignments: {
...map(1, [{ id: 1, at: HAMBURG, time: '21:00', dwell: 90 }, { id: 2, at: LUENEBURG }]),
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
} as AssignmentsMap,
})
it('FE-ROADTRIP-ROUTES-017: routes the road onto the day a night drive lands on', async () => {
// The stop reached after midnight is drawn under day 2, next to day 2's own first
// stop. Those two were never neighbours before, so their road was never asked for —
// and a chain with a hole in it draws as two runs with a gap across the middle.
const { days, assignments } = overnight()
// Three hours per leg, which is what puts the second stop past midnight.
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), duration: 10800, legs: [{ distance: 100000, duration: 10800, text: '100 km' }] })
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
await waitFor(() => expect(result.current.loading).toBe(false))
await waitFor(() => expect(calculateRouteWithLegs.mock.calls.length).toBeGreaterThan(2))
// A two-point request, which is the seam rather than either day's own run.
const seam = calculateRouteWithLegs.mock.calls.find(c => c[0].length === 2
&& c[0][0].lat === LUENEBURG[0] && c[0][1].lat === BERLIN[0])
expect(seam).toBeTruthy()
})
it('FE-ROADTRIP-ROUTES-018: leaves the gap between two ordinary days alone', async () => {
// Nothing moved here, so the road from one day's last stop to the next day's first
// is a gap the plan simply has. Asking for it is what the switch is for.
const daysList = [day(1, 1), day(2, 2)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
} as AssignmentsMap
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(result.current.loading).toBe(false))
// One request per day and no more: the seam is not asked for.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
expect(result.current.days).toHaveLength(2)
})
it('FE-ROADTRIP-ROUTES-019: hands the map a line per leg, coloured by the day it is driven on', async () => {
const daysList = [day(1, 1), day(2, 2)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
} as AssignmentsMap
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(result.current.loading).toBe(false))
// Same length and same order as `lines`, which is the contract the map paints from.
expect(result.current.lineDays).toHaveLength(result.current.lines.length)
expect(result.current.lineDays).toEqual([1, 2])
})
})
it('FE-ROADTRIP-ROUTES-011: leaving the view aborts the request in flight', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
@@ -2,9 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
import { resolveLegMode } from '../Planner/legMode'
import {
computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint, type VehicleKind } from './roadtripModel'
import { projectOntoRoute, pointAtMeters} from './corridor'
deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint } from './roadtripModel'
import { projectOntoRoute, pointAtMeters, sliceAtMeters, lineMetres } from './corridor'
import { spillChains, type SpillMark } from './nightSpill'
import { useSettingsStore } from '../../store/settingsStore'
import { useVehicleRange } from './useVehicleRange'
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
@@ -14,6 +15,17 @@ import type { RoadtripVia } from '@trek/shared'
/** One stop on a day's drive — an assignment whose place actually has coordinates. */
export interface RoadtripStop {
assignmentId: number
/**
* The day this stop is STORED on, and its position there.
*
* A road trip draws its stops by the day they are reached, which after a drive across
* midnight is not the day they are stored on — see `nightSpill.ts`. Nothing is written
* to make that so, which means every callback that reorders, moves, re-anchors or adds
* beside a stop has to name the day the server knows it by. That is this pair, and it
* travels with the stop so no caller has to work it out.
*/
ownerDayId: number
ownerIndex: number
placeId: number
name: string
lat: number
@@ -103,8 +115,23 @@ export interface RoadtripDay {
driveWarnings: ScheduleWarning[]
/** More driving in this day than the traveller allows, or null. */
dayWarning: DayWarning | null
/**
* Stretches of this day's chain that were driven onto it from an earlier day.
*
* Empty on an ordinary day, which is most of them. See `nightSpill.ts`: a drive that
* crosses midnight is read on the date it ARRIVES, and the stops it reaches are drawn
* here even though they are stored on the day they set off from. Each stop still names
* that day in `ownerDayId`, so nothing that writes has to know about this.
*/
spills?: SpillMark[]
}
/**
* A day with its stops and nothing routed onto it yet — what the routing effect is handed
* and what the arrangement by date is worked out from.
*/
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
/** A place, the road it is driven to from, and how far apart the two are. */
export interface AccessSpur {
line: [[number, number], [number, number]]
@@ -136,6 +163,14 @@ export interface RoadtripRoutes {
quietDays: QuietDay[]
/** One polyline per routed leg, in trip order — what the map draws in road trip mode. */
lines: [number, number][][]
/**
* The day each entry of `lines` is driven on, same length and same order.
*
* So the map can colour the trip by day without knowing anything about how the days
* were worked out. A night drive belongs to the day it ARRIVES on, the same rule its
* kilometres follow.
*/
lineDays: number[]
/**
* The gap between a place and the road it was routed from, as a two-point line.
*
@@ -222,14 +257,30 @@ export interface SnappedPoint {
const stopKey = (s: RoadtripStop): string =>
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`
/**
* The via points that shape the drive leaving a stop, as one comparable string.
*
* Read in two places that must agree: when deciding whether a seam still matches the
* answer already in hand, and when recording what an answer was fetched for. One function
* so the two cannot drift and quietly stop refetching.
*/
const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>): string =>
(viasByDay[from.ownerDayId] ?? [])
.filter(v => v.after_order_index === from.ownerIndex)
.sort((a, b) => a.sequence - b.sequence)
.map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`)
.join('|')
/** The drive from one stop to the next, identified the same way `planKey` identifies them. */
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
const asStop = (a: Assignment): RoadtripStop | null => {
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): RoadtripStop | null => {
const p = a.place
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
return {
assignmentId: a.id,
ownerDayId,
ownerIndex,
placeId: a.place_id,
name: p.name,
lat: p.lat,
@@ -282,6 +333,17 @@ export function useRoadtripRoutes(
const [snapByDay, setSnapByDay] = useState<Record<number, Record<string, SnappedWaypoint>>>({})
/** Per day, the classes it was asked to avoid and did not get. See RoadtripDay.avoidMissed. */
const [missedByDay, setMissedByDay] = useState<Record<number, RouteAvoidClass[]>>({})
/**
* Drives that only exist because a night moved a stop onto the next day's card.
*
* The trip is routed a stored day at a time, so the road between the last stop of one
* day and the first of the next was never asked for — there was no chain that needed
* it. Once a drive across midnight hands its stops forward, the stop it ends at and the
* next day's own first stop become NEIGHBOURS in one chain, and a chain with a hole in
* it draws two disconnected runs on the map. Filed by leg key like every other leg, so
* everything downstream reads them without knowing where they came from.
*/
const [seamLegs, setSeamLegs] = useState<Record<string, RoutedLeg & { shape: string }>>({})
const [loading, setLoading] = useState(false)
const abortRef = useRef<AbortController | null>(null)
@@ -297,6 +359,15 @@ export function useRoadtripRoutes(
* not have.
*/
const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid)
/**
* Whether the gaps between days are driven too.
*
* A trip is stored as days and routed as days, so the road from one day's last stop to
* the next day's first was never asked for: no chain needed it. Turning this on makes
* the trip one continuous drive — every one of those gaps is routed, drawn, and counted
* towards the day it ARRIVES on, which is the same rule a night drive already follows.
*/
const connectDays = useSettingsStore(s => !!s.settings.roadtrip_connect_days)
const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting])
// What the car is and how far it goes on one fill, assembled in one place because the
// rail needs the same answer to say what a given fill buys at a given stop.
@@ -316,29 +387,25 @@ export function useRoadtripRoutes(
[legMinutes, dayMinutes, planningRangeKm, fillPercent],
)
const plan = useMemo<RoadtripDay[]>(() => {
const plan = useMemo<PlanDay[]>(() => {
return [...days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(d => {
const stops = (assignments[String(d.id)] ?? [])
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map(asStop)
.map(a => asStop(a, d.id, 0))
.filter((s): s is RoadtripStop => s !== null)
// The index is filled in after the drop, because it is the index into THIS
// list: an assignment whose place has no coordinates never becomes a stop, and
// counting before the filter would name a row the rail does not draw.
.map((s, i) => ({ ...s, ownerIndex: i }))
return {
dayId: d.id,
dayNumber: d.day_number ?? 0,
date: d.date ?? null,
title: d.title ?? null,
stops,
legs: [],
legVias: [],
driveWarnings: [],
dayWarning: null,
schedule: { entries: [], warnings: [] },
geometry: [],
distance: 0,
duration: 0,
}
})
.filter(d => d.stops.length > 1)
@@ -355,8 +422,9 @@ export function useRoadtripRoutes(
stops: (assignments[String(d.id)] ?? [])
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map(asStop)
.filter((s): s is RoadtripStop => s !== null),
.map(a => asStop(a, d.id, 0))
.filter((s): s is RoadtripStop => s !== null)
.map((s, i) => ({ ...s, ownerIndex: i })),
}))
.filter(d => d.stops.length < 2)
}, [days, assignments])
@@ -411,7 +479,6 @@ export function useRoadtripRoutes(
dfMode,
))
const dayVias = viasByDay[day.dayId] ?? []
for (const { stops: run, mode } of runs) {
tasks.push(async () => {
@@ -425,9 +492,14 @@ export function useRoadtripRoutes(
stopAt.push(waypoints.length)
waypoints.push({ lat: stop.lat, lng: stop.lng })
if (i === run.length - 1) return
const dayIndex = day.stops.indexOf(stop)
dayVias
.filter(v => v.after_order_index === dayIndex)
// A via is filed against the day it was dropped on and the position it sits
// after IN THAT DAY. Since a chain can hold stops from an earlier day (see
// `nightSpill.ts`), the lookup has to be the stop's own day and its own
// index — this used to be the position within the chain, so on any day that
// received a night drive every via matched nothing and quietly stopped
// shaping the road.
;(viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
.filter(v => v.after_order_index === (stop.ownerIndex ?? i))
.sort((a, b) => a.sequence - b.sequence)
.forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
})
@@ -464,6 +536,18 @@ export function useRoadtripRoutes(
// together would get wrong.
const legEndMeters: number[] = []
const legKeys: string[] = []
/**
* Where each leg starts and ends along the run, so its own stretch of road
* can be cut out of the run's one polyline.
*
* Collected rather than cut on the spot because the cut is proportional:
* the router reports a leg's length off its own graph while the polyline is
* great-circle hops between the vertices it sent back, and the two never
* agree exactly. Cutting at absolute metres left the last leg of every run
* short by the difference. Cutting at the same FRACTION of each makes the
* last cut land on the last vertex, so the pieces still rebuild the run.
*/
const cuts: { key: string; from: number; to: number; seg: RouteSegment }[] = []
let travelled = 0
for (let i = 0; i < run.length - 1; i++) {
const from = run[i]
@@ -476,11 +560,26 @@ export function useRoadtripRoutes(
distance: parts.reduce((sum, l) => sum + (l.distance ?? 0), 0),
duration: parts.reduce((sum, l) => sum + (l.duration ?? 0), 0),
}
const startedAt = travelled
travelled += merged.distance ?? 0
legEndMeters.push(travelled)
const key = legKey(from, to)
legKeys.push(key)
dayLegs[key] = { seg: { ...merged, mode }, line: i === 0 ? r.coordinates : [], vias: [] }
cuts.push({ key, from: startedAt, to: travelled, seg: { ...merged, mode } })
}
// The road under each leg, in the polyline's own measure. It used to be
// "the whole run hangs off leg 0", which was enough while a day was always
// drawn whole — but a drive across midnight hands its stops to the next
// card and the road has to travel with them.
const drawnMetres = lineMetres(spine)
const scale = travelled > 0 ? drawnMetres / travelled : 0
for (const cut of cuts) {
dayLegs[cut.key] = {
seg: cut.seg,
line: sliceAtMeters(spine, cut.from * scale, cut.to * scale)
.map(pt => [pt.lat, pt.lng] as [number, number]),
vias: [],
}
}
for (const { via, alongMeters } of viaAlong) {
const idx = legIndexForAlong(legEndMeters, alongMeters)
@@ -525,53 +624,240 @@ export function useRoadtripRoutes(
})()
return () => controller.abort()
// planKey stands in for `plan`: same geometry, same legs.
// planKey stands in for `plan`: same geometry, same legs. avoidKey, not `avoid`:
// a fresh array every render would re-route on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
// avoidKey, not `avoid`: a fresh array every render would re-route on every render.
}, [planKey, routeProfile, distanceUnit, tripId, avoidKey])
/**
* Every leg known so far, by the two stops it connects.
*
* Filed under a key built from the stops' own coordinates, so which day it was fetched
* under stops mattering the moment a drive across midnight moves its stops to the next
* card — the road between two places is the road between them whichever date it is
* driven on.
*/
const allLegs = useMemo(() => {
const out: Record<string, RoutedLeg> = {}
for (const day of plan) Object.assign(out, legsByDay[day.dayId] ?? {})
return { ...out, ...seamLegs }
}, [plan, legsByDay, seamLegs])
// Which date each stop is actually reached on. Nothing is written to make it so; see
// `nightSpill.ts`.
const chains = useMemo(
() => spillChains(plan, quietDays, (a, b) => allLegs[legKey(a, b)]),
[plan, quietDays, allLegs],
)
/**
* Pairs a chain needs a road for and does not have.
*
* Only ever a seam between two stored days: everything inside one day was routed as a
* run. Left unrouted the rail shows a chain that stops halfway and the map draws the
* day in two pieces with a gap across the middle.
*/
const seams = useMemo(() => {
const out: { from: RoadtripStop; to: RoadtripStop; dayId: number }[] = []
const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => {
// Routed as part of a day's own run — that request is rebuilt whenever its vias
// change, so there is nothing to catch up here.
if (legsByDay[from.ownerDayId]?.[legKey(from, to)]) return
// A seam already fetched is skipped only while it was fetched for the SHAPE it has
// now. Skipping it whenever any answer existed is what made a via on a seam do
// nothing at all: the first answer was cached under the pair, and dragging the
// point changed the request nobody was going to send again.
const have = seamLegs[legKey(from, to)]
if (have && have.shape === seamShape(from, viasByDay)) return
out.push({ from, to, dayId })
}
for (const chain of chains) {
for (let i = 0; i < chain.stops.length - 1; i++) {
const from = chain.stops[i]
const to = chain.stops[i + 1]
// Inside one stored day it was routed as part of that day's run.
if (from.ownerDayId === to.ownerDayId) continue
want(from, to, chain.dayId)
}
}
// And, when the traveller asks for one continuous drive, the road from each card's
// last stop to the next card's first — the one gap a day-at-a-time routing leaves.
if (connectDays) {
for (let d = 0; d < chains.length - 1; d++) {
const from = chains[d].stops[chains[d].stops.length - 1]
const to = chains[d + 1].stops[0]
if (from && to) want(from, to, chains[d + 1].dayId)
}
}
return out
}, [chains, legsByDay, seamLegs, viasByDay, connectDays])
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';')
useEffect(() => {
if (!seams.length) return
const controller = new AbortController()
void (async () => {
for (const seam of seams) {
if (controller.signal.aborted) return
const mode = resolveLegMode(
{ isPlace: true, leg_transport_mode: seam.from.legMode },
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
)
try {
// The via points on this seam, threaded in the same way the day runs thread
// theirs. Without them a seam is the one stretch of the trip a via cannot
// shape: it is asked for on its own, so the points the traveller dropped on it
// never reached the router and dragging one did visibly nothing.
const shaping = (viasByDay[seam.from.ownerDayId] ?? [])
.filter(v => v.after_order_index === seam.from.ownerIndex)
.sort((a, b) => a.sequence - b.sequence)
const r = await calculateRouteWithLegs(
[
{ lat: seam.from.lat, lng: seam.from.lng },
...shaping.map(v => ({ lat: v.lat, lng: v.lng })),
{ lat: seam.to.lat, lng: seam.to.lng },
],
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid },
)
if (controller.signal.aborted) return
// One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail
// wants the whole drive: summed here, exactly as a day run folds its via legs
// back onto the stop pair they belong to.
if (!r.legs.length) continue
const merged = r.legs.length === 1 ? r.legs[0] : {
...r.legs[0],
distance: r.legs.reduce((sum, l) => sum + (l.distance ?? 0), 0),
duration: r.legs.reduce((sum, l) => sum + (l.duration ?? 0), 0),
}
setSeamLegs(prev => ({
...prev,
[legKey(seam.from, seam.to)]: {
seg: { ...merged, mode },
line: r.coordinates,
vias: [],
shape: seamShape(seam.from, viasByDay),
},
}))
} catch {
// A seam that will not route stays missing, exactly like any other leg that
// will not route: the chain shows a gap and the totals are partial rather than
// invented. Retried on the next run of this effect.
if (controller.signal.aborted) return
}
await sleep(REQUEST_SPACING_MS, controller.signal)
}
})()
return () => controller.abort()
// seamKey stands in for `seams`: same pairs, same requests. avoidKey, not `avoid`:
// a fresh array every render would re-ask for every seam on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seamKey, routeProfile, tripId, avoidKey])
return useMemo(() => {
// Legs and snaps, flat. They are filed under a key built from the two stops' own
// coordinates, so which day they were fetched under stops mattering the moment a
// drive across midnight moves its stops to the next card — the road between two
// places is the road between them whichever date it is driven on.
const allSnaps: Record<string, SnappedWaypoint> = {}
for (const day of plan) Object.assign(allSnaps, snapByDay[day.dayId] ?? {})
const legFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)]
const lines: [number, number][][] = []
const lineDays: number[] = []
const segments: RouteSegment[] = []
const accessLines: RoadtripRoutes['accessLines'] = []
// A loop rather than a map, because the range budget outlives a single day: a tank
// does not empty overnight, so what is left at the end of one day is what the next
// one starts with. `plan` is already in day order.
// one starts with. `chains` is already in day order, and the legs inside them are in
// driving order, which is the order the tank is emptied in.
const out: RoadtripDay[] = []
let carryKm: number | null = 0
for (const day of plan) {
const dayLegs = legsByDay[day.dayId] ?? {}
const daySnaps = snapByDay[day.dayId] ?? {}
const routed = day.stops.slice(0, -1).map((s, i) => dayLegs[legKey(s, day.stops[i + 1])])
for (const leg of routed) {
/** The stop the previous card ended at, so the next one can be driven to from it. */
let previousStop: RoadtripStop | undefined
/** And which day that was, so the road out of it is drawn as that day's. */
let previousDayNumber: number | undefined
for (const chain of chains) {
const routed = chain.stops.slice(0, -1).map((s, i) => legFor(s, chain.stops[i + 1]))
/**
* The drives that reach this card rather than leave from it, and only when the
* traveller asked for them.
*
* Every one of these crosses from one card to the next: a night drive that handed
* its stops forward, or plainly the road from yesterday's last stop to today's
* first. Whether a road trip is one continuous line or a day at a time is exactly
* what the switch asks, so with it off a card holds only the driving between its
* own stops — no stroke across the join, and none of those kilometres in its total.
*
* A night drive is no exception. It is stored inside one day and it is real
* driving, but on screen it runs from a stop on one card to a stop on the next, and
* a line between two cards is the thing the switch is about.
*
* `drawnAs` is the day it LEAVES rather than the one it arrives on. The kilometres
* count towards the arrival, because that is when they are driven, but the stroke
* starts at a stop on yesterday's card and colouring it as tomorrow makes tomorrow
* look like it begins at a stop that is not on it.
*/
const inboundAt = new Map<number, { seg: RouteSegment | undefined; line: [number, number][]; drawnAs: number }>()
if (connectDays) {
for (const spill of chain.spills) {
inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber })
}
// And the road out of the previous card where no night drive already opens this
// one. Nothing moved for that one: it is the gap a day-at-a-time plan leaves.
const joined = inboundAt.has(0) ? undefined : previousStop && legFor(previousStop, chain.stops[0])
if (joined) inboundAt.set(0, { seg: joined.seg, line: joined.line, drawnAs: previousDayNumber ?? chain.dayNumber })
}
previousStop = chain.stops[chain.stops.length - 1] ?? previousStop
previousDayNumber = chain.stops.length ? chain.dayNumber : previousDayNumber
for (let i = 0; i < chain.stops.length; i++) {
const inbound = inboundAt.get(i)
if (inbound) {
if (inbound.line.length > 1) { lines.push(inbound.line); lineDays.push(inbound.drawnAs) }
if (inbound.seg) segments.push(inbound.seg)
}
const leg = routed[i]
if (!leg) continue
if (leg.line.length > 1) lines.push(leg.line)
if (leg.line.length > 1) { lines.push(leg.line); lineDays.push(chain.dayNumber) }
segments.push(leg.seg)
}
// The run polylines end to end: leg 0 of a run carries its whole geometry, the
// rest carry none, so concatenating what is there gives the day as driven.
const geometry = routed.flatMap(l => l?.line ?? [])
// Each leg carries its own stretch of road now, so the day's line is simply its
// legs end to end — with the night that reached it threaded in at the stop it
// arrives at, so the line is drawn in the order it is driven.
const geometry: [number, number][] = []
for (let i = 0; i < chain.stops.length; i++) {
const inbound = inboundAt.get(i)
if (inbound) geometry.push(...inbound.line)
geometry.push(...(routed[i]?.line ?? []))
}
const legs = routed.map(l => l?.seg)
const inbound = [...inboundAt.values()].map(l => l.seg)
const distance = legs.reduce((sum, l) => sum + (l?.distance ?? 0), 0)
+ inbound.reduce((sum, l) => sum + (l?.distance ?? 0), 0)
const duration = legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0)
const schedule = computeSchedule(
day.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
legs.map(l => l?.duration),
)
+ inbound.reduce((sum, l) => sum + (l?.duration ?? 0), 0)
const schedule = chain.schedule
const legVias = routed.map(l => l?.vias ?? [])
const stops = day.stops.map(s => {
const snap = daySnaps[stopKey(s)]
const stops = chain.stops.map(s => {
const snap = allSnaps[stopKey(s)]
const line = spurFor(snap)
if (line) accessLines.push({ line, meters: snap.meters, stopKey: stopKey(s) })
return { ...s, offRoadMeters: line ? snap.meters : null }
})
// The night drive is on the tank too, and it is in no leg list: it ends at this
// chain's first stop rather than leaving from it. Added to the carry so the budget
// has spent it before the first stop of the day gets a chance to fill up again.
const inboundKm = inbound
.filter(l => l && (l.mode === undefined || l.mode === 'driving'))
.reduce((sum, l) => sum + (l?.distance ?? 0), 0) / 1000
const startKm = carryKm === null ? null : carryKm + inboundKm
const drive = deriveDriveWarnings(
legs,
// One longer than the legs: the last stop of the day counts too, because filling
// up on arrival is what makes the next morning start full.
stops.map(s => refuelsRange(s.stopType, vehicleKind)),
limits,
carryKm,
startKm,
// Same length and same order, so a stop that says how far it fills is read
// against itself rather than against the traveller's default.
stops.map(s => s.fillPercent),
@@ -579,7 +865,7 @@ export function useRoadtripRoutes(
carryKm = drive.carryKm
// Where each tank runs dry, as a place rather than a distance.
//
// Walked along the DRIVING legs only. `geometry` above is every run end to end,
// Walked along the DRIVING legs only. `geometry` above is every leg end to end,
// ferries and walks included, while the budget counts none of those — so walking
// the drawn line to the same figure overshoots by the whole length of any crossing
// the day happens to contain, and the marker lands a ferry's width off the road.
@@ -598,24 +884,38 @@ export function useRoadtripRoutes(
// ferry or a walk pays for a second copy.
const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine
out.push({
...day, stops, legs, legVias, schedule, geometry, distance, duration, dryPoints, drivingGeometry,
dayId: chain.dayId,
dayNumber: chain.dayNumber,
date: chain.date,
title: chain.title,
avoidMissed: missedByDay[chain.dayId],
spills: chain.spills,
stops, legs, legVias, schedule, geometry, distance, duration, dryPoints, drivingGeometry,
driveWarnings: drive.warnings,
dayWarning: drive.day,
})
}
const drives = out.filter(d => d.stops.length > 1)
return {
days: out,
days: drives,
lines,
lineDays,
segments,
accessLines,
vias: out.flatMap(d => d.legVias.flat()),
totalDistance: out.reduce((s, d) => s + d.distance, 0),
totalDuration: out.reduce((s, d) => s + d.duration, 0),
totalDistance: drives.reduce((s, d) => s + d.distance, 0),
totalDuration: drives.reduce((s, d) => s + d.duration, 0),
// Over the days the head is a summary OF, which is the ones with a drive: a day
// holding a single stop draws no card and contributes nothing else here either.
// Service stops are not stops in this sense — a charger on the way is part of the
// drive, and counting it here would make the head disagree with the day cards.
totalStops: out.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0),
quietDays,
totalStops: drives.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0),
// A day left with one stop or none draws no drive, so it stays what it was: an
// outline that appears while a stop is in flight and is not worth a row otherwise.
quietDays: out
.filter(d => d.stops.length < 2)
.map(d => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })),
loading,
}
}, [plan, legsByDay, snapByDay, loading, quietDays, limits])
}
}, [plan, chains, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays])
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { addListener, removeListener } from '../../api/websocket'
import { roadtripApi } from '../../api/client'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { isEmptyReanchoring, type Reanchoring } from './roadtripModel'
@@ -92,6 +93,14 @@ const EMPTY_TRACKS: Record<number, RoadtripDayTrack> = {}
* patched by hand — a via has a server-assigned id and sequence, and guessing them would
* be a second source of truth for the sake of one round trip.
*/
/** A copy of the map without one day, so an emptied day does not linger as an empty list. */
function omit<T>(map: Record<number, T>, dayId: number): Record<number, T> {
if (!(dayId in map)) return map
const next = { ...map }
delete next[dayId]
return next
}
export function useRoadtripVias(tripId: number | string | null, active: boolean): RoadtripVias {
const [byDay, setByDay] = useState<Record<number, RoadtripVia[]>>(EMPTY)
const [trackByDay, setTrackByDay] = useState<Record<number, RoadtripDayTrack>>(EMPTY_TRACKS)
@@ -146,6 +155,36 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean)
useEffect(() => { void reload() }, [reload])
/**
* What somebody else did to the drive, applied as it happens.
*
* Its own listener rather than a slice in the store, the way the collab tabs do it:
* these points live in this hook and nowhere else, and giving them a store slice would
* be a second copy of the same list to keep in step.
*
* The server sends the whole day's list, so applying it is a replace rather than a
* merge — and it excludes the socket that wrote, so a drag never gets its own point
* handed back mid-gesture.
*/
useEffect(() => {
if (!tripId || !active) return
const handler = (event: Record<string, unknown>) => {
if (String(event.tripId) !== String(tripId)) return
const dayId = Number(event.dayId)
if (!Number.isFinite(dayId)) return
if (event.type === 'roadtripVia:changed') {
const vias = (event.vias ?? []) as RoadtripVia[]
setByDay(prev => (vias.length ? { ...prev, [dayId]: vias } : omit(prev, dayId)))
}
if (event.type === 'roadtripTrack:changed') {
const track = event.track as RoadtripDayTrack | null
setTrackByDay(prev => (track ? { ...prev, [dayId]: track } : omit(prev, dayId)))
}
}
addListener(handler)
return () => removeListener(handler)
}, [tripId, active])
const add = useCallback(async (dayId: number, afterOrderIndex: number, lat: number, lng: number) => {
if (!tripId) return
await roadtripApi.addVia(tripId, dayId, { after_order_index: afterOrderIndex, lat, lng })
@@ -0,0 +1,199 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, beforeEach } from 'vitest'
import { useSettingsStore } from '../../store/settingsStore'
import type { Settings } from '../../types'
import { useVehicleRange } from './useVehicleRange'
/**
* Seven settings assembled into one answer.
*
* The arithmetic is pinned next door in vehicleRange.test.ts; what is worth guarding
* here is the assembly: that an unrecognised word is no vehicle rather than a vehicle
* nobody can refuel, that every one of the parts really reaches the division, and that
* a range which rounds away arrives as no limit rather than as a zero one.
*
* The identity cases are worth less than they look, and are kept small for that reason.
* Every reader today destructures the object on the spot, so its reference reaches
* nothing; what they guard is the day a reader stops doing that.
*/
// setState replaces the settings object wholesale, which is what the hook has to cope
// with anyway: the settings load hands it a new one on every fetch.
function setSettings(over: Partial<Settings>): void {
act(() => { useSettingsStore.setState({ settings: over as never }) })
}
/** Changes one field and leaves the rest, the way the settings dialog does. */
function changeSetting(over: Partial<Settings>): void {
act(() => {
useSettingsStore.setState(s => ({ settings: { ...s.settings, ...over } as never }))
})
}
beforeEach(() => {
// Wholesale, so nothing a previous case stored can answer for this one.
useSettingsStore.setState({ settings: {} as never })
})
describe('useVehicleRange', () => {
it('FE-VEHRANGEHOOK-001: a word the picker never wrote is no vehicle at all', () => {
// The setting is a free string on the wire and nothing on the server checks it, so a
// hand-edited value or a leftover from an older picker arrives here as-is. Anything
// but the two known words has to fall back to "not said", which is the state where a
// stop of either kind still counts as a fill-up.
setSettings({ roadtrip_vehicle: 'lorry' })
expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBeNull()
setSettings({ roadtrip_vehicle: 'Electric' })
expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBeNull()
setSettings({})
expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBeNull()
})
it('FE-VEHRANGEHOOK-002: the two words it does know come back untouched', () => {
setSettings({ roadtrip_vehicle: 'combustion' })
expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBe('combustion')
setSettings({ roadtrip_vehicle: 'electric' })
expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBe('electric')
})
it('FE-VEHRANGEHOOK-003: a complete pair of parts beats the round number that was typed', () => {
// 55 litres at 7 per hundred is 786 km, and the traveller who filled both fields said
// something more exact than "about 500".
setSettings({
roadtrip_vehicle: 'combustion',
roadtrip_tank_litres: 55,
roadtrip_litres_per_100: 7,
roadtrip_range_km: 500,
})
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(786)
})
it('FE-VEHRANGEHOOK-004: half a specification leaves the typed number standing', () => {
// Half a pair is not a range, and the typed figure is still a real answer — dropping
// it here would take the range budget away from somebody who had set one.
setSettings({
roadtrip_vehicle: 'combustion',
roadtrip_tank_litres: 55,
roadtrip_range_km: 500,
})
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(500)
})
it('FE-VEHRANGEHOOK-005: figures left over from the other kind of car are not used', () => {
// Someone who filled in a tank and then switched the picker to electric still has
// both petrol fields in their settings. Feeding them to an electric car would hand
// back a range that has nothing to do with the battery.
setSettings({
roadtrip_vehicle: 'electric',
roadtrip_tank_litres: 55,
roadtrip_litres_per_100: 7,
roadtrip_range_km: 500,
})
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(500)
})
it('FE-VEHRANGEHOOK-006: the battery figures arrive complete, age included', () => {
// 58 kWh less a tenth is 52.2, and 52.2 at 16 per hundred is 326 km. Forgetting to
// forward the degradation would give 363 here, which is a warning that fires 37 km
// after the car has stopped.
setSettings({
roadtrip_vehicle: 'electric',
roadtrip_battery_kwh: 58,
roadtrip_kwh_per_100: 16,
roadtrip_battery_degradation: 10,
})
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(326)
})
it('FE-VEHRANGEHOOK-007: an unset or zeroed range is no limit, never a range of nothing', () => {
// Zero is a legal thing to type into the field and means the same as leaving it
// empty. Handing a 0 downstream would put the dry point at the first metre and light
// up every stop on the trip.
setSettings({ roadtrip_range_km: 0 })
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBeNull()
setSettings({})
expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBeNull()
})
it('FE-VEHRANGEHOOK-008: a re-render with nothing changed hands back the same object', () => {
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500 })
const { result, rerender } = renderHook(() => useVehicleRange())
const first = result.current
rerender()
// Identity, not equality: the readers put this straight into an effect dependency
// list, so a new object per render means the whole trip is re-routed per render.
expect(result.current).toBe(first)
})
it('FE-VEHRANGEHOOK-009: a setting this hook does not read causes no churn', () => {
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500, dark_mode: false })
const { result } = renderHook(() => useVehicleRange())
const first = result.current
// A fresh settings object arrives on every settings load and after every unrelated
// toggle, and the seven fields are read as primitives so none of that reaches here.
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500, dark_mode: true })
expect(result.current).toBe(first)
})
it('FE-VEHRANGEHOOK-011: every part of the car reaches the arithmetic while mounted', () => {
// Each field changed on a hook that is already mounted, which is the only way a
// missing memo dependency shows itself: mounting afresh recomputes regardless, so a
// suite built out of fresh mounts passes with half the dependency list deleted.
setSettings({ roadtrip_vehicle: 'electric', roadtrip_battery_kwh: 58, roadtrip_kwh_per_100: 16 })
const { result } = renderHook(() => useVehicleRange())
expect(result.current.rangeKm).toBe(363)
changeSetting({ roadtrip_battery_kwh: 77 })
expect(result.current.rangeKm).toBe(481)
changeSetting({ roadtrip_kwh_per_100: 22 })
expect(result.current.rangeKm).toBe(350)
changeSetting({ roadtrip_battery_degradation: 10 })
expect(result.current.rangeKm).toBe(315)
// And the kind itself: the same figures mean nothing to a petrol car, which has no
// tank size stored here at all.
changeSetting({ roadtrip_vehicle: 'combustion' })
expect(result.current.vehicleKind).toBe('combustion')
expect(result.current.rangeKm).toBeNull()
changeSetting({ roadtrip_tank_litres: 55, roadtrip_litres_per_100: 7 })
expect(result.current.rangeKm).toBe(786)
})
it('FE-VEHRANGEHOOK-012: a range that rounds away leaves no limit rather than a zero one', () => {
// The branch this hook owns, as opposed to the one its neighbour owns: parts that
// round to nothing must not arrive as the number 0, which every reader downstream
// would take for a car that cannot leave the drive.
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_tank_litres: 1, roadtrip_litres_per_100: 500 })
const { result } = renderHook(() => useVehicleRange())
expect(result.current.rangeKm).toBeNull()
})
it('FE-VEHRANGEHOOK-010: a range the traveller actually changed does come through', () => {
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500 })
const { result } = renderHook(() => useVehicleRange())
const first = result.current
setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 640 })
// The other half of case 009: memoised is not frozen. A stale object here would keep
// the old budget on screen until something else happened to re-render.
expect(result.current.rangeKm).toBe(640)
expect(result.current).not.toBe(first)
})
})
+30 -1
View File
@@ -537,6 +537,30 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
.trek-doze > *:nth-child(2) { animation-delay: 1s; }
.trek-doze > *:nth-child(3) { animation-delay: 2s; }
/* Roadtrip: der Block, den eine Nachtfahrt auf dem Folgetag hinterlaesst. Er faellt
von oben in die Karte, weil er von der Karte darueber kommt — kurz genug, dass ein
Neuberechnen der Ankunftszeiten nicht als Zappeln liest. */
@keyframes trek-spill-in {
from { opacity: 0; transform: translateY(-6px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.trek-spill-in {
animation: trek-spill-in 320ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
/* Roadtrip: die Reserveleuchte am Band, an dem der Tank leerlaeuft. Das Gegenstueck
zu den Zzz am Tageswechsel — dort schlaeft der Tag ein, hier geht die Lampe im
Armaturenbrett an. Langsam an, kurz aus: eine Warnleuchte blinkt nicht hektisch,
sie glimmt. */
@keyframes trek-lowfuel {
0%, 62%, 100% { opacity: 1; }
74% { opacity: 0.28; }
86% { opacity: 1; }
}
.trek-lowfuel {
animation: trek-lowfuel 2.6s ease-in-out infinite;
}
/* Stagger-Helpers für Listen — Enter-Animation mit Offset */
@keyframes trek-fade-up {
from { opacity: 0; transform: translateY(6px); }
@@ -558,9 +582,14 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
/* Reduced motion — Emil's Accessibility-Regel: fewer and gentler, not zero */
@media (prefers-reduced-motion: reduce) {
.trek-menu-enter, .trek-menu-enter-left, .trek-popover-enter,
.trek-modal-enter, .trek-toast-enter, .trek-stagger > * {
.trek-modal-enter, .trek-toast-enter, .trek-stagger > *, .trek-spill-in {
animation: trek-backdrop-enter 120ms ease-out;
}
/* Die Reserveleuchte steht still statt zu glimmen — sie bleibt an, was die Aussage
ohnehin ist. */
.trek-lowfuel {
animation: none;
}
.trek-skeleton {
animation: none;
background: var(--bg-tertiary);
+6 -3
View File
@@ -252,7 +252,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
@@ -403,9 +403,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<div style={{ position: 'absolute', inset: 0 }}>
<MapView
tripId={tripId}
places={mapPlaces}
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
dayPlaces={dayPlaces}
route={roadtripActive ? roadtripRoutes.lines : route}
route={roadtripActive ? roadtripMapLines : route}
routeColors={roadtripActive ? roadtripLineColors : undefined}
routeVias={roadtripActive ? roadtripRoutes.vias : routeVias}
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
showTransitRoutes={transitRoutesShown}
@@ -557,6 +558,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
refuel={refuel}
onAskRefuel={askRefuel}
onAcceptRefuel={can('day_edit', trip) ? acceptRefuel : undefined}
collapsedDayIds={collapsedRoadtripDays}
onToggleDay={toggleRoadtripDay}
/>
</LazyPanel>
) : (
+126 -8
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate, useSearchParams } from 'react-router'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { dayColor } from '../../components/Roadtrip/dayColors'
import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
@@ -774,7 +775,82 @@ export function useTripPlanner() {
* per-user preferences on the settings table, the same path the map provider and the
* distance unit take, and they are read locally the moment they change.
*/
const saveRoadtripLimit = useCallback(async (key: string, value: number | string) => {
/**
* A colour per drawn line, or none at all.
*
* Off is not "all the same colour" but an absent array: the map then paints the blue it
* has always painted, so a trip that never turns this on renders byte for byte the way
* it did.
*/
/**
* Days folded down to their header in the road trip rail.
*
* Its own state rather than the day plan's `expandedDayIds`: that one is owned by
* `DayPlanSidebar`, which republishes it from its own state every time it mounts — so a
* day folded in the rail would spring back open the moment the other view was visited.
*/
const [collapsedRoadtripDays, setCollapsedRoadtripDays] = useState<Set<number>>(new Set())
const toggleRoadtripDay = useCallback((dayId: number) => {
setCollapsedRoadtripDays(prev => {
const next = new Set(prev)
if (!next.delete(dayId)) next.add(dayId)
return next
})
}, [])
/**
* The road trip's lines, minus the days that are folded away.
*
* Folding a card takes that day off the map, which is most of what folding is for: a
* rail entry can be scrolled past, a line across the map cannot be looked away from.
* `lineDays` runs parallel to `lines`, so both are filtered in one pass and the colours
* stay lined up with what is left.
*/
const roadtripMapLines = useMemo(() => {
if (!collapsedRoadtripDays.size) return roadtripRoutes.lines
const hidden = new Set(
roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber),
)
return roadtripRoutes.lines.filter((_, i) => !hidden.has(roadtripRoutes.lineDays[i]))
}, [roadtripRoutes.lines, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays])
/**
* The map's places with the folded cards' stops taken out.
*
* A second pass rather than a branch inside `mapPlaces`: that memo runs long before the
* road trip is routed, and the answer here needs the CHAINS — a card is a date, and
* after a night drive it holds stops stored on the day before (`nightSpill.ts`), so
* what a folded card hides is what is drawn on it, not what is filed under its day.
*
* A place still drawn on some other card stays, which is the rule the day plan's own
* declutter follows.
*/
const roadtripMapPlaces = useMemo(() => {
if (!collapsedRoadtripDays.size) return mapPlaces
const hidden = new Set<number>()
for (const day of roadtripRoutes.days) {
if (!collapsedRoadtripDays.has(day.dayId)) continue
for (const stop of day.stops) hidden.add(stop.placeId)
}
for (const day of roadtripRoutes.days) {
if (collapsedRoadtripDays.has(day.dayId)) continue
for (const stop of day.stops) hidden.delete(stop.placeId)
}
return mapPlaces.filter(p => !hidden.has(p.id))
}, [mapPlaces, roadtripRoutes.days, collapsedRoadtripDays])
const roadtripLineColors = useMemo(
() => {
if (!settings.roadtrip_day_colors) return undefined
const hidden = new Set(
roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber),
)
return roadtripRoutes.lineDays.filter(n => !hidden.has(n)).map(n => dayColor(n))
},
[settings.roadtrip_day_colors, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays],
)
const saveRoadtripLimit = useCallback(async (key: string, value: number | string | boolean) => {
try {
await updateSettings({ [key]: value } as Partial<Settings>)
} catch {
@@ -1166,7 +1242,7 @@ export function useTripPlanner() {
const stop = day?.stops[open.index]
if (!day || !stop) return
try {
await roadtripVias.add(open.dayId, day.stops.indexOf(stop), alt.divergence.lat, alt.divergence.lng)
await roadtripVias.add(stop.ownerDayId ?? day.dayId, stop.ownerIndex ?? open.index, alt.divergence.lat, alt.divergence.lng)
routeAlternatives.close()
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
@@ -1193,7 +1269,16 @@ export function useTripPlanner() {
const anchorFor = useCallback((lat: number, lng: number, onlyDayId?: number) => {
let best: { dayId: number; afterIndex: number; offRouteKm: number } | null = null
for (const day of roadtripRoutes.days) {
if (onlyDayId !== undefined && day.dayId !== onlyDayId) continue
// NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored
// on, but that day's stops are no longer all on the card of the same name: after a
// night drive they are drawn on the next one (`nightSpill.ts`). Filtering by card
// measured the new position against a line that no longer covers those stops — a
// point dragged near Brandenburg was projected onto the short remainder of card 1
// and came back anchored to its last stop, which put the via on the night drive
// itself and pushed the stop before it over midnight.
//
// So every card is measured, and the answer is filtered by the day the ANCHOR is
// stored on. Same promise, kept against the stops rather than against the card.
if (day.geometry.length < 2) continue
const spine = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln }))
const hit = projectOntoRoute({ lat, lng }, spine)
@@ -1201,8 +1286,34 @@ export function useTripPlanner() {
if (best && hit.offRouteKm >= best.offRouteKm) continue
// Which stop the via follows: the last one the car passes before reaching it.
const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0)
const afterIndex = Math.max(0, insertIndexForAlong(stopsAlong, hit.alongKm) - 1)
best = { dayId: day.dayId, afterIndex, offRouteKm: hit.offRouteKm }
const before = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
// Before the card's first stop means the incoming night drive, which is drawn here
// but leaves from a stop on the card BEFORE this one (`nightSpill.ts`). Anchoring
// it to this card's first stop would file the via on the leg AFTER that stop, and
// the route would run forward, double back to the point, and carry on.
if (before < 0) {
const from = day.spills?.find(sp => sp.at === 0)?.fromStop
if (!from) continue
const owner = from.ownerDayId ?? day.dayId
if (onlyDayId !== undefined && owner !== onlyDayId) continue
best = { dayId: owner, afterIndex: from.ownerIndex ?? 0, offRouteKm: hit.offRouteKm }
continue
}
const at = before
// Named by the day the anchor stop is STORED on and its position there, not by the
// card and the position within it. A card is a date and can hold stops from the day
// before (`nightSpill.ts`), so those two numbers differ on any day that received a
// night drive — and a via filed under the card's numbers matches no stop when the
// route is next built, which reads as a drag that did nothing at all.
const anchor = day.stops[at]
if (!anchor) continue
// Falling back to the card's own numbers is not a guard against a bug, it is the
// meaning: a stop that names no other day IS stored on the card it is drawn on,
// which is every stop on a trip that never drives past midnight.
const owner = anchor.ownerDayId ?? day.dayId
// A drag stays on its own day; a fresh click may land wherever it landed.
if (onlyDayId !== undefined && owner !== onlyDayId) continue
best = { dayId: owner, afterIndex: anchor.ownerIndex ?? at, offRouteKm: hit.offRouteKm }
}
return best
}, [roadtripRoutes.days])
@@ -1275,9 +1386,16 @@ export function useTripPlanner() {
// Before the stop the tank would have run out on, which is the leg the dry point
// names. A station reached after the day's last stop is tomorrow's problem, and
// clamping it onto the final leg would re-route the arrival through it.
const position = Math.min(dry.legIndex + 1, day.stops.length - 1)
const at = Math.min(dry.legIndex + 1, day.stops.length - 1)
// That index counts along the CARD, and after a night drive a card is not one stored
// day: its first stops belong to yesterday. The new stop goes in front of the one it
// was measured against, so it is that stop's own day and position that place it —
// written against the card's day it would land in the wrong list, at an index that
// means something else there.
const anchor = day.stops[at]
if (!anchor) return
refuel.close()
setStopDraft({ poi, dayId, position, dayNumber: day.dayNumber })
setStopDraft({ poi, dayId: anchor.ownerDayId, position: anchor.ownerIndex, dayNumber: day.dayNumber })
}, [roadtripRoutes.days, refuel, can, trip])
/** Removing a via lets the drive take the direct road again. */
@@ -1884,7 +2002,7 @@ export function useTripPlanner() {
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
+7
View File
@@ -66,6 +66,13 @@ export const DEFAULT_SETTINGS: Settings = {
valhalla_base_url: '',
// Empty = not said, and then a stop of either kind fills up. See refuelsRange().
roadtrip_vehicle: '',
// Off = the rail routes a day at a time, the way the day plan stores it, and the road
// between one day's last stop and the next day's first is simply not drawn. On = the
// trip is one continuous drive and every one of those gaps is routed too.
roadtrip_connect_days: false,
// Off = one blue line for the whole trip. On = a colour per day, so a route drawn end
// to end can still be read as days.
roadtrip_day_colors: false,
dark_mode: false,
// Empty = no personal display currency, so Costs falls back to the trip's own.
default_currency: '',
+4
View File
@@ -167,6 +167,10 @@ export interface Settings {
* opened the dialog.
*/
roadtrip_vehicle?: string
/** Route the gaps between days too, so the trip is one continuous drive. */
roadtrip_connect_days?: boolean
/** Draw each day of the trip in its own colour. */
roadtrip_day_colors?: boolean
/**
* How full a fill-up goes, 1 to 100. Absent or 100 means all the way.
*