Merge remote-tracking branch 'origin/dev' into feat/trip-route-overview

# Conflicts:
#	client/src/components/Map/MapView.tsx
#	client/src/components/Map/MapViewGL.tsx
#	client/src/pages/TripPlannerPage.tsx
#	client/src/pages/tripPlanner/useTripPlanner.ts
This commit is contained in:
Konstantinos Thermos
2026-09-11 16:22:21 +03:00
270 changed files with 14787 additions and 826 deletions
+27
View File
@@ -387,6 +387,33 @@ describe('client > dev-only contract drift checks', () => {
)
})
it('FE-APIWIRE-037: search-provider hits are appended to the core results and name their index', async () => {
server.use(
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
http.get('/api/plugin-search', () =>
HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })),
)
// Appended, not interleaved: the core list keeps the order it earned.
await expect(mapsApi.search('Rome')).resolves.toEqual({
places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }],
source: 'openstreetmap+plugin:demo',
})
})
it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => {
server.use(
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
)
// The optional index being unwell must never cost the search that worked.
await expect(mapsApi.search('Rome')).resolves.toEqual({
places: [{ name: 'Core hit' }],
source: 'openstreetmap',
})
})
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
+14 -1
View File
@@ -413,9 +413,22 @@ describe('client > endpoint wiring', () => {
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
})
/**
* The one call in this file that is deliberately TWO requests, which is why it is not
* in the list above: a search asks TREK's own indexes and any installed search-provider
* plugin at the same time, and the caller gets one list back (#2221).
*/
it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => {
log = []
await mapsApi.search('Rome')
expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([
'GET /api/plugin-search',
'POST /api/maps/search',
])
})
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
await assertCalls([
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
+61 -2
View File
@@ -1143,6 +1143,50 @@ async function withCachedPlaces<T>(
}
}
/**
* What plugins implementing `searchProvider` found for the same query (#2221).
*
* Its own request beside the core one rather than a branch inside it: the core search
* is TREK's own indexes and must not wait on, or fail with, somebody's plugin. This
* answers with an empty list for every failure there is — no provider installed, one
* that timed out, a 404 on an older server, the network gone — because a search that
* breaks when an optional index is unwell is worse than one without it.
*/
/**
* How long a plugin index may keep the search list waiting, in milliseconds.
*
* The host gives a provider two seconds to answer and this gives the round trip a
* little more. It is a ceiling on the WAIT, not on the provider: with no search
* plugin installed the route answers immediately without reaching any of them, so
* the normal case costs one local round trip and nothing else.
*
* A deadline rather than patience, because the alternative is a search that feels
* broken. A list that arrives without an optional index is a smaller answer; a list
* that arrives four seconds late is no answer at all.
*/
const PLUGIN_SEARCH_DEADLINE_MS = 2500
async function pluginSearchPlaces(
query: string,
lang?: string,
near?: { lat: number; lng: number },
): Promise<Record<string, unknown>[]> {
const stop = new AbortController()
const timer = setTimeout(() => stop.abort(), PLUGIN_SEARCH_DEADLINE_MS)
try {
const r = await apiClient.get('/plugin-search', {
params: { q: query, lang: lang || 'en', lat: near?.lat, lng: near?.lng },
signal: stop.signal,
})
const places = (r.data as { places?: unknown })?.places
return Array.isArray(places) ? (places as Record<string, unknown>[]) : []
} catch {
return []
} finally {
clearTimeout(timer)
}
}
export const mapsApi = {
/**
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
@@ -1152,10 +1196,25 @@ export const mapsApi = {
* It also decides whether the index answers at all: a common single word
* without coordinates is refused upstream as too expensive, and the search
* then falls back to Nominatim alone.
*
* Plugin indexes are asked at the same time and their hits are appended, so every
* caller of this one function gets them without knowing they exist. Appended rather
* than interleaved: the core list is ordered by relevance and has earned that order,
* and a plugin's row carries its own `source` for a caller that wants to mark it.
*/
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () =>
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))),
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
// Side by side, so the wait is the slower of the two rather than their sum. Only
// the core call may reject: that rejection is what hands withCachedPlaces the
// offline path, and a plugin failure must never trigger it.
const [core, extra] = await Promise.all([
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
pluginSearchPlaces(query, lang, locationBias),
])
if (extra.length === 0) return core
const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+')
return { places: [...core.places, ...extra], source: `${core.source}+${from}` }
}),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
withCachedPlaces(
input,
+24
View File
@@ -0,0 +1,24 @@
import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared'
import { apiClient } from './client'
/**
* Reports the routing counters this browser has tallied up.
*
* Fire and forget: it never throws and never blocks the route it is describing. A
* failed report is a lost count, which is a far smaller problem than a map that waits
* on a measurement.
*
* No client-side switch to check, unlike the shadow log. Counting is on unless an
* operator turned it off, and that is a server setting the server enforces — a report
* to a switched-off instance is answered with `recorded: false` and costs one request.
*/
export const routeUsageApi = {
report: (entries: RouteUsageEntry[]): Promise<void> =>
entries.length
? apiClient.post('/route-usage/report', { entries }).then(() => undefined)
: Promise.resolve(),
/** Admin only. The figures behind "could we host a router ourselves". */
summary: (): Promise<RouteUsageSummaryResult> =>
apiClient.get('/route-usage/summary').then(r => r.data),
}
+5
View File
@@ -30,6 +30,11 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'collab:message:created',
'collab:message:reacted',
'collab:message:deleted',
// Road trip shaping — components/Roadtrip/useRoadtripVias. The points a drive is
// routed through live in that hook and nowhere else; a store slice for them would be a
// second copy of the same list to keep in step.
'roadtripVia:changed',
'roadtripTrack:changed',
// In-app notifications — hooks/useInAppNotificationListener
'notification:new',
'notification:updated',
@@ -262,6 +262,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') })
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
@@ -44,6 +44,7 @@ type Defaults = {
map_tile_url?: string
carto_api_key?: string
routing_base_url?: string
valhalla_base_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
@@ -121,6 +122,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [routingBase, setRoutingBase] = useState('')
const [valhallaBase, setValhallaBase] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
@@ -131,6 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setRoutingBase(data.routing_base_url || '')
setValhallaBase(data.valhalla_base_url || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
@@ -154,6 +157,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'routing_base_url') setRoutingBase('')
if (key === 'valhalla_base_url') setValhallaBase('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
@@ -409,6 +413,28 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
</div>
)}
{/* The second engine, next to the first because it is the same kind of decision
and carries the same restart caveat. Only ever asked to leave a road class
out of one leg, which is the one question the OSRM above refuses. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.valhallaBase')}
<ResetButton field="valhalla_base_url" />
</label>
<input
type="text"
value={valhallaBase}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValhallaBase(e.target.value)}
onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })}
placeholder="https://valhalla.example.org"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.valhallaBaseHint')}</p>
</div>
)}
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
@@ -0,0 +1,101 @@
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-006
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { DatePicker } from './JourneyDetailPageDatePicker'
function open(value = '2026-09-11') {
const onChange = vi.fn()
render(<DatePicker value={value} onChange={onChange} />)
return { onChange }
}
const header = () => screen.getByRole('button', { name: /select month|select year|\d{4} – \d{4}/i })
const prev = (name: RegExp) => screen.getByRole('button', { name })
describe('DatePicker year navigation (#2318)', () => {
it('FE-COMP-JOURNEYDATEPICKER-001: opens on the month of the value with weekday headers', async () => {
const user = userEvent.setup()
open()
await user.click(screen.getByText(/Sep 11, 2026/))
expect(screen.getByText('Mo')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('September 2026')
})
it('FE-COMP-JOURNEYDATEPICKER-002: the header climbs to the months, then to the years', async () => {
const user = userEvent.setup()
open()
await user.click(screen.getByText(/Sep 11, 2026/))
await user.click(screen.getByRole('button', { name: 'Select month' }))
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Jan' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2026')
await user.click(screen.getByRole('button', { name: 'Select year' }))
expect(screen.getByRole('button', { name: '2016' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '2027' })).toBeInTheDocument()
expect(header()).toHaveTextContent('2016 – 2027')
})
it('FE-COMP-JOURNEYDATEPICKER-003: picking a year three back lands on that year in one move', async () => {
const user = userEvent.setup()
open()
await user.click(screen.getByText(/Sep 11, 2026/))
await user.click(screen.getByRole('button', { name: 'Select month' }))
await user.click(screen.getByRole('button', { name: 'Select year' }))
await user.click(screen.getByRole('button', { name: '2023' }))
// Back on the months, of the chosen year.
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2023')
await user.click(screen.getByRole('button', { name: 'Mar' }))
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('March 2023')
expect(screen.getByText('Mo')).toBeInTheDocument()
})
it('FE-COMP-JOURNEYDATEPICKER-004: the arrows step the level they are on', async () => {
const user = userEvent.setup()
open()
await user.click(screen.getByText(/Sep 11, 2026/))
await user.click(prev(/previous month/i))
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('August 2026')
await user.click(screen.getByRole('button', { name: 'Select month' }))
await user.click(prev(/previous year/i))
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2025')
await user.click(screen.getByRole('button', { name: 'Select year' }))
await user.click(prev(/previous years/i))
expect(header()).toHaveTextContent('2004 – 2015')
await user.click(screen.getByRole('button', { name: /next years/i }))
expect(header()).toHaveTextContent('2016 – 2027')
})
it('FE-COMP-JOURNEYDATEPICKER-005: a day picked after the jump is handed back as ISO', async () => {
const user = userEvent.setup()
const { onChange } = open()
await user.click(screen.getByText(/Sep 11, 2026/))
await user.click(screen.getByRole('button', { name: 'Select month' }))
await user.click(screen.getByRole('button', { name: 'Select year' }))
await user.click(screen.getByRole('button', { name: '2023' }))
await user.click(screen.getByRole('button', { name: 'Mar' }))
await user.click(screen.getByRole('button', { name: '15' }))
expect(onChange).toHaveBeenCalledWith('2023-03-15')
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
})
it('FE-COMP-JOURNEYDATEPICKER-006: reopening starts on the days again', async () => {
const user = userEvent.setup()
open()
const trigger = screen.getByText(/Sep 11, 2026/)
await user.click(trigger)
await user.click(screen.getByRole('button', { name: 'Select month' }))
await user.click(screen.getByRole('button', { name: 'Select year' }))
await user.click(trigger)
expect(screen.queryByRole('button', { name: '2023' })).not.toBeInTheDocument()
await user.click(trigger)
expect(screen.getByText('Mo')).toBeInTheDocument()
})
})
@@ -3,6 +3,16 @@ import { localIsoDate } from '../../utils/localDate'
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
import { useTranslation } from '../../i18n'
/**
* The header steps up a level on each click, days → months → years, and a
* pick steps back down. Mirrors CustomDateTimePicker, so a person who has
* learnt one calendar in TREK knows the other. Without it the only way to a
* different year was one month at a time, which on a photo picker asked to
* cover a trip three years back meant thirty-six clicks (#2318).
*/
type CalendarView = 'days' | 'months' | 'years'
const YEAR_PAGE_SIZE = 12
export function DatePicker({ value, onChange, tripDates }: {
value: string
onChange: (date: string) => void
@@ -10,6 +20,8 @@ export function DatePicker({ value, onChange, tripDates }: {
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [view, setView] = useState<CalendarView>('days')
const [yearPageStart, setYearPageStart] = useState(0)
const [viewMonth, setViewMonth] = useState(() => {
const d = value ? new Date(value + 'T00:00:00') : new Date()
return { year: d.getFullYear(), month: d.getMonth() }
@@ -27,19 +39,73 @@ export function DatePicker({ value, onChange, tripDates }: {
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
}
const handlePrev = () => {
if (view === 'days') prevMonth()
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year - 1 }))
else setYearPageStart(s => s - YEAR_PAGE_SIZE)
}
const handleNext = () => {
if (view === 'days') nextMonth()
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year + 1 }))
else setYearPageStart(s => s + YEAR_PAGE_SIZE)
}
const handleHeaderClick = () => {
if (view === 'days') {
setView('months')
} else if (view === 'months') {
setYearPageStart(Math.floor(viewMonth.year / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE)
setView('years')
}
}
const selectMonth = (month: number) => {
setViewMonth(p => ({ ...p, month }))
setView('days')
}
const selectYear = (year: number) => {
setViewMonth(p => ({ ...p, year }))
setView('months')
}
const toggleOpen = () => {
// Reopening lands on the days again: a picker left on the year grid
// would otherwise open there next time, one level away from a date.
setView('days')
setOpen(!open)
}
const prevLabel = view === 'days' ? t('common.datepicker.prevMonth')
: view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears')
const nextLabel = view === 'days' ? t('common.datepicker.nextMonth')
: view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears')
const headerLabel = view === 'days' ? monthName
: view === 'months' ? String(viewMonth.year)
: `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`
const headerAria = view === 'days' ? t('common.datepicker.selectMonth')
: view === 'months' ? t('common.datepicker.selectYear') : undefined
const pad = (n: number) => String(n).padStart(2, '0')
const cells: (number | null)[] = []
for (let i = 0; i < firstDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
const monthNames = Array.from({ length: 12 }, (_, i) =>
new Date(viewMonth.year, i).toLocaleDateString(undefined, { month: 'short' }))
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i)
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
const navButton = 'w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500'
const gridCell = (selected: boolean) => `h-9 rounded-lg text-[12px] font-medium flex items-center justify-center transition-colors ${
selected
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
}`
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
onClick={toggleOpen}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
>
{formatted ? (
@@ -59,54 +125,86 @@ export function DatePicker({ value, onChange, tripDates }: {
above closes the popover again from the keyboard. */}
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
{/* Month nav */}
{/* Header: arrows step the current view, the label climbs to the next one up */}
<div className="flex items-center justify-between mb-2">
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
<ArrowLeft size={14} />
</button>
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
<button
type="button"
onClick={handleHeaderClick}
aria-label={headerAria}
disabled={view === 'years'}
className="px-2 py-1 rounded-lg text-[13px] font-semibold text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-700 disabled:hover:bg-transparent disabled:cursor-default"
>
{headerLabel}
</button>
<button type="button" onClick={handleNext} aria-label={nextLabel} className={navButton}>
<ChevronRight size={14} />
</button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 mb-1">
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{cells.map((day, i) => {
if (day === null) return <div key={`e${i}`} />
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
const isSelected = dateStr === value
const isTrip = tripDates?.has(dateStr)
const isToday = dateStr === localIsoDate()
return (
<button
key={dateStr}
type="button"
onClick={() => { onChange(dateStr); setOpen(false) }}
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
isSelected
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: isToday
? 'text-zinc-900 dark:text-white font-bold'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
}`}
>
{day}
{isTrip && !isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
)}
{view === 'months' && (
<div className="grid grid-cols-3 gap-1">
{monthNames.map((name, i) => (
<button key={name} type="button" onClick={() => selectMonth(i)} className={gridCell(i === viewMonth.month)}>
{name}
</button>
)
})}
</div>
))}
</div>
)}
{view === 'years' && (
<div className="grid grid-cols-3 gap-1">
{years.map(y => (
<button key={y} type="button" onClick={() => selectYear(y)} className={gridCell(y === viewMonth.year)}>
{y}
</button>
))}
</div>
)}
{view === 'days' && (
<>
{/* Weekday headers */}
<div className="grid grid-cols-7 mb-1">
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{cells.map((day, i) => {
if (day === null) return <div key={`e${i}`} />
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
const isSelected = dateStr === value
const isTrip = tripDates?.has(dateStr)
const isToday = dateStr === localIsoDate()
return (
<button
key={dateStr}
type="button"
onClick={() => { onChange(dateStr); setOpen(false) }}
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
isSelected
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: isToday
? 'text-zinc-900 dark:text-white font-bold'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
}`}
>
{day}
{isTrip && !isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
)}
</button>
)
})}
</div>
</>
)}
</div>
</>
)}
+20 -7
View File
@@ -18,7 +18,13 @@ const mapMock = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })),
// A flat projection, 1000 px per degree: far enough apart that every
// booking line clears its declutter floor, which is measured along the
// projected line (#2275) rather than read off a canned distanceTo.
latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => ({
x: lng * 1000, y: lat * 1000,
distanceTo(other: { x: number; y: number }) { return Math.hypot(lng * 1000 - other.x, lat * 1000 - other.y) },
})),
// Panes: jsdom has none, so keep them in a map the pane tests can read back.
panes: new Map<string, HTMLElement>(),
getPane: vi.fn(function (this: void, name: string) { return mapMock.panes.get(name) }),
@@ -221,15 +227,22 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-006b: the whole-trip overview colours each line, on a white casing', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={['#ea580c']} />)
it('FE-COMP-MAPVIEW-006b: a caller-coloured route takes its own core and casing', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }]} />)
const [casing, core] = screen.getAllByTestId('polyline')
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
// White under the colour, the same trick the coloured GPX tracks use to stay
// readable on satellite and dark basemaps.
expect(casing.color).toBe('#ffffff')
expect(core.color).toBe('#ea580c')
expect(casing.color).toBe('#c2740a')
expect(core.color).toBe('#ff9f0a')
})
it('FE-COMP-MAPVIEW-006c: a line with no colour of its own keeps the route blue', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[undefined]} />)
const [casing, core] = screen.getAllByTestId('polyline')
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
expect(casing.color).toBe('#0a5cc2')
expect(core.color).toBe('#0a84ff')
})
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
+6 -7
View File
@@ -606,8 +606,10 @@ 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 = [],
routeColors,
selectedPlaceId = null,
hoverDisabled = false,
onMarkerClick,
@@ -1028,15 +1030,12 @@ export const MapView = memo(function MapView({
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of
the two, so it is the one a pointer actually lands on.
In the whole-trip overview each day is drawn in its own colour (#1736), and the
casing goes white — the same trick the coloured GPX tracks use to stay readable
on satellite and dark basemaps, where a per-colour dark casing would not. */}
the two, so it is the one a pointer actually lands on. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
<Polyline
key={`${i}-casing`}
positions={seg}
pathOptions={{ color: routeColors?.[i] ? '#ffffff' : '#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 }) => {
@@ -1050,7 +1049,7 @@ export const MapView = memo(function MapView({
<Polyline
key={`${i}-core`}
positions={seg}
pathOptions={{ color: routeColors?.[i] ?? '#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}
/>,
] : [])}
+70 -4
View File
@@ -1540,17 +1540,19 @@ describe('MapViewGL', () => {
expect(features[0].geometry.coordinates).toEqual([[2, 48], [3, 49]])
})
it('FE-COMP-MAPVIEWGL-048b: the whole-trip overview colours each line on the feature', async () => {
it('FE-COMP-MAPVIEWGL-048b: a caller-coloured route rides its colours on the feature', async () => {
const routeSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null))
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]} routeColors={['#1d4ed8', null]} />)
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]}
routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }, undefined]} />)
await act(async () => {})
const { features } = lastData(routeSource)
expect(features[0].properties.color).toBe('#1d4ed8')
expect(features[0].properties.color).toBe('#ff9f0a')
expect(features[0].properties.casing).toBe('#c2740a')
// No colour means the day route's own blue, which the layer paint falls back to.
expect(features[1].properties.color).toBeUndefined()
expect(features[1].properties.color).toBeNull()
})
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
@@ -1968,4 +1970,68 @@ describe('MapViewGL', () => {
// in the wrong place.
expect(layer.firstElementChild).not.toBe(handle)
})
// ── Satellite ───────────────────────────────────────────────────────────────
//
// Leaflet has had the imagery for a while and swaps its whole tile layer for it.
// A GL map cannot do that, because its basemap is a style with dozens of layers,
// so the imagery goes on as a raster layer under everything TREK draws.
it('FE-COMP-MAPVIEWGL-074: satellite adds the imagery under the first TREK layer', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({
layers: [{ id: 'background' }, { id: 'road' }, { id: 'trip-route' }, { id: 'trip-gpx-hit' }],
})
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({
type: 'raster',
tiles: [expect.stringContaining('arcgisonline.com')],
}))
// Anchored before the route, or the imagery would be painted over it.
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }),
'trip-route',
)
})
it('FE-COMP-MAPVIEWGL-075: with the default basemap no imagery is fetched at all', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
} as never)
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
})
it('FE-COMP-MAPVIEWGL-076: switching back hides the layer instead of tearing it down', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
// Present from the first pass on, the way the real map reports it afterwards.
glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null))
glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null))
const { rerender } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
act(() => {
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
} as never)
})
rerender(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none')
})
})
+142 -12
View File
@@ -8,6 +8,7 @@ import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
@@ -27,7 +28,7 @@ import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
@@ -48,6 +49,21 @@ const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
const GPX_HIT_LAYER_ID = 'trip-gpx-hit'
/**
* The satellite base layer, the GL twin of the Leaflet one.
*
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
* layer of its own, drawn over the style and under everything TREK adds — the route,
* the pins and the tracks stay on top and stay legible, which is the same order the
* Leaflet map ends up with.
*
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
*/
const SATELLITE_SOURCE_ID = 'trip-satellite'
const SATELLITE_LAYER_ID = 'trip-satellite-raster'
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
const OWN_LAYER_PREFIXES = ['trip-', 'trek-']
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -107,9 +123,12 @@ 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[]
/** Line colour per entry of `route`, same index — the whole-trip overview (#1736). */
routeColors?: (string | null)[]
selectedPlaceId?: number | null
onMarkerClick?: (id: number) => void
hoverDisabled?: boolean
@@ -235,6 +254,44 @@ function addPlaceClusterLayers(map: any): void {
})
}
/**
* Puts the imagery on the map, or takes it off again.
*
* Built on demand rather than once at load: a style change drops every source the map
* had, and a hot reload does the same. Guarded on its own absence, so the usual pass is
* a no-op and only the visibility flips.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applySatellite(map: any, on: boolean): void {
try {
if (!map.isStyleLoaded?.()) return
if (!map.getSource(SATELLITE_SOURCE_ID)) {
if (!on) return // Nothing to build while it is switched off.
map.addSource(SATELLITE_SOURCE_ID, {
type: 'raster',
tiles: [SATELLITE_TILE_URL],
tileSize: 256,
maxzoom: SATELLITE_TILE_MAXZOOM,
attribution: SATELLITE_TILE_ATTRIBUTION,
})
// Under the first thing TREK draws, over everything the basemap style draws.
// Without the anchor the imagery lands on top and buries the route.
const layers = map.getStyle?.()?.layers ?? []
const firstOwn = layers.find((l: { id: string }) =>
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
map.addLayer({
id: SATELLITE_LAYER_ID,
type: 'raster',
source: SATELLITE_SOURCE_ID,
paint: { 'raster-opacity': 1 },
}, firstOwn?.id)
}
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none')
}
} catch { /* a style that refuses the layer keeps the plain basemap */ }
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
@@ -554,8 +611,8 @@ export function MapViewGL({
routeVias = NO_ROUTE_VIAS,
accessLines = NO_ACCESS_LINES,
route = null,
routeColors = null,
routeSegments = NO_ROUTE_SEGMENTS,
routeColors,
selectedPlaceId = null,
hoverDisabled = false,
onMarkerClick,
@@ -597,6 +654,15 @@ export function MapViewGL({
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
// The same stored choice the Leaflet map reads, so the two renderers agree.
const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
const isSatellite = baseLayer === 'satellite'
const toggleBaseLayer = useCallback(() => {
// The store flips synchronously, so the map switches even offline; a failed save
// is logged there rather than blocking the switch.
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
}, [isSatellite, updateSetting])
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
@@ -702,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'
@@ -790,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.
*
@@ -1118,7 +1219,9 @@ export function MapViewGL({
id: 'trip-route-casing',
type: 'line',
source: 'trip-route',
paint: { 'line-color': ['case', ['has', 'color'], '#ffffff', '#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
@@ -1825,13 +1928,16 @@ export function MapViewGL({
if (!map) return
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
if (!src) return
// The colour rides on the feature rather than the layer so one source can hold a
// whole trip of differently-coloured days (#1736), the way trip-gpx already does.
const features = (route || []).flatMap((seg, i) => seg && seg.length > 1 ? [{
type: 'Feature' as const,
properties: routeColors?.[i] ? { color: routeColors[i] } : {},
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, routeColors, mapReady])
@@ -2063,6 +2169,18 @@ export function MapViewGL({
else map.once('load', apply)
}, [userPosition, trackingMode, glProvider])
// Satellite, the same setting the Leaflet map reads, so switching it on one renderer
// and reloading into the other keeps the choice. `styledata` is subscribed because a
// basemap change rebuilds the style from scratch and takes the imagery with it.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
const apply = () => applySatellite(map, isSatellite)
apply()
map.on('styledata', apply)
return () => { map.off('styledata', apply) }
}, [isSatellite, mapReady, glProvider])
if (!isMapLibre && !mapboxToken) {
return (
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
@@ -2098,6 +2216,18 @@ export function MapViewGL({
bottomOffset={buttonBottom as unknown as number}
/>
)}
{/* Same pill, same corner, same offsets as the Leaflet map: the switch should not
move when an instance changes renderer. The day panel is a centred card on the
desktop map and never reaches the pill, but on a phone it is full width and
does, which is why the lift is only there. */}
<div style={{
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
bottom: isMobile && hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
}}>
<MapLayerSwitcher active={baseLayer as BaseLayer} onToggle={toggleBaseLayer} />
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
the Leaflet map's overlay (no anchored popup, no photo). */}
{!hoverDisabled && hoverPlace && hoverPos && !isMobile && (
@@ -1,4 +1,4 @@
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-024
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-045
import React from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
@@ -389,6 +389,43 @@ describe('ReservationOverlay declutter floors and road routes', () => {
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual([[48, 16], [51.5, 16]])
})
it('FE-COMP-RESOVERLAY-043: a routed drive whose ends project close together still draws (#2275)', () => {
// Endpoints 50 px apart, under the 80 px car floor, so the straight hop would
// hide. The road between them loops out 0.3° (300 px) and back, which is what
// is actually drawn — and what the person was missing on their map.
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
const { unmount } = renderOverlay({ reservations: [hop('car', 7, 0.05)] })
expect(lines()).toHaveLength(0)
unmount()
renderOverlay({ reservations: [hop('car', 7, 0.05)], roadRoutes: new Map([[7, road]]) })
expect(lines()).toHaveLength(1)
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual(road)
})
it('FE-COMP-RESOVERLAY-044: a drive with a stop on the way is measured along its legs, not its ends', () => {
// From and to sit 20 px apart; the stop is 400 px away, so the two legs
// together draw 800 px of line.
renderOverlay({
reservations: [booking({
id: 8, type: 'car',
endpoints: [
endpoint({ role: 'from', sequence: 0, name: 'Home', lat: 48, lng: 16 }),
endpoint({ role: 'stop', sequence: 1, name: 'Lake', lat: 48.4, lng: 16 }),
endpoint({ role: 'to', sequence: 2, name: 'Back', lat: 48.02, lng: 16 }),
],
})],
})
expect(lines()).toHaveLength(2)
})
it('FE-COMP-RESOVERLAY-045: a routed drive that really is tiny on screen stays decluttered', () => {
// Every point of the road projects within 30 px: nothing worth drawing yet.
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
renderOverlay({ reservations: [hop('car', 7, 0.03)], roadRoutes: new Map([[7, road]]) })
expect(lines()).toHaveLength(0)
})
it('FE-COMP-RESOVERLAY-036: a booking without an endpoints array is skipped', () => {
renderOverlay({ reservations: [booking({ endpoints: undefined })] })
expect(lines()).toHaveLength(0)
@@ -8,6 +8,7 @@ import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry
import { geodesicArcs } from './flightGeodesy'
import { cleanEndpointName } from './reservationName'
import { useSettingsStore } from '../../store/settingsStore'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import type { Reservation, ReservationEndpoint } from '../../types'
const ENDPOINT_PANE = 'reservation-endpoints'
@@ -122,6 +123,12 @@ function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fal
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
const road = roadRoutes?.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
}
interface TransportItem {
res: Reservation
from: ReservationEndpoint
@@ -200,26 +207,25 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
}, [reservations])
const visibleItems = useMemo(() => {
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
return items.filter(item => {
// A transit journey draws its real rail/bus alignment, not a straight from->to
// line, so the endpoint-proximity declutter (which exists to hide tiny no-op
// straight connectors) must not suppress it. Otherwise a zoomed-out day — e.g. one
// with no other places to tighten the map onto — hides the whole route (#1570).
if (item.transitSegs.length > 0) return true
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return fromPx.distanceTo(toPx) >= minPx
// Measured along what is drawn, not between the ends: a routed drive can
// cover half the screen while its endpoints sit close together (#2275).
return hopIsVisible(item.type, linesFor(item, roadRoutes), project)
})
}, [items, zoom, map])
}, [items, zoom, map, roadRoutes])
const labelVisibleIds = useMemo(() => {
const set = new Set<number>()
for (const item of visibleItems) {
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
if (fromPx.distanceTo(toPx) >= labelFloorPx(item.type)) set.add(item.res.id)
}
return set
}, [visibleItems, zoom, map])
@@ -248,9 +254,7 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
))
}
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
const road = roadRoutes?.get(item.res.id)
const lines = road && road.length >= 2 ? [road] : item.arcs
return lines.map((seg, segIdx) => (
return linesFor(item, roadRoutes).map((seg, segIdx) => (
<Polyline
key={`line-${item.res.id}-${segIdx}`}
positions={seg}
@@ -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 (
<>
@@ -797,6 +797,119 @@ describe('calculateAlternatives', () => {
expect(asked).toHaveLength(1)
})
// The second engine, which is what made this whole branch produce anything on a
// default install. Every case here overrides the 503 the shared handlers answer with,
// so the OSRM path above stays the one the cases before this measure.
const VALHALLA = 'https://valhalla1.openstreetmap.de/route'
const valhallaAnswer = (summary: Record<string, unknown>) => ({
trip: {
// The detour twoRoutes describes, re-encoded as the polyline6 Valhalla sends.
legs: [{ shape: '_szadB_gjaR~po]_yqwC~po]~tpzA' }],
summary: { length: 130, time: 4500, has_toll: false, has_highway: false, has_ferry: false, ...summary },
},
})
it('FE-COMP-ROUTECALCULATOR-050: one answer from OSRM is offered a second way by Valhalla', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: false })))
)
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(routes).toHaveLength(2)
expect(routes[1].avoids).toBe('motorway')
expect(routes[1].distance).toBeCloseTo(130000, 0)
expect(routes[1].duration).toBe(4500)
})
it('FE-COMP-ROUTECALCULATOR-051: a road that still has the class is not offered as avoiding it', async () => {
// use_highways: 0 is a weighting, so a leg with no way round the motorway comes back
// on the motorway and says so. Offering that as "No motorway" would put a lie on the
// map, so it is dropped and the leg keeps the single way OSRM found.
//
// Asserted on the result rather than on which excludes OSRM was asked: by this point
// `excludeUnsupported` has already recorded the public host from the case above, and
// that Set is module state for the lifetime of the file.
server.use(
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
return new HttpResponse(null, { status: 400 })
}),
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true, has_ferry: true })))
)
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52.5, lng: 11.5 })
expect(routes).toHaveLength(1)
expect(routes[0].avoids).toBeUndefined()
})
it('FE-COMP-ROUTECALCULATOR-052: a ferry-free way is offered, which OSRM alone never managed', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
return new HttpResponse(null, { status: 400 })
}),
http.post(VALHALLA, async ({ request }) => {
const body = await request.json() as { costing_options: { auto: Record<string, number> } }
// Only the ferry question gets a usable answer, so the first two fall through.
if (!('use_ferry' in body.costing_options.auto)) {
return HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true }))
}
return HttpResponse.json(valhallaAnswer({ has_ferry: false }))
})
)
const routes = await calculateAlternatives({ lat: 54, lng: 10 }, { lat: 55, lng: 12 })
expect(routes).toHaveLength(2)
expect(routes[1].avoids).toBe('ferry')
})
it('FE-COMP-ROUTECALCULATOR-054: the same road from the other engine is not offered as a second one', async () => {
// The reason the geometry decides rather than the numbers. Valhalla prices roads
// differently, so on a long leg the SAME road comes back kilometres apart in both
// length and time — here 13 km and 5 minutes, which is the measured spread between
// the two engines and comfortably past the 200 m the numbers used to allow. Judged
// on those it reads as a discovery and puts a second blue line on top of the first.
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
http.post(VALHALLA, () => HttpResponse.json({
trip: {
// twoRoutes.routes[0]'s own line, re-encoded: the identical road.
legs: [{ shape: '_szadB_gjaR~po]_qo]~po]_qo]' }],
summary: { length: 113, time: 3900, has_toll: false, has_highway: false, has_ferry: false },
},
}))
)
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
expect(routes).toHaveLength(1)
})
it('FE-COMP-ROUTECALCULATOR-053: an instance with its own OSRM is never sent to the public Valhalla', async () => {
let valhallaCalls = 0
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://osrm.example.org' } }))
server.use(
http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
}),
http.post(VALHALLA, () => { valhallaCalls++; return HttpResponse.json(valhallaAnswer({})) })
)
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 12 })
expect(valhallaCalls).toBe(0)
expect(routes[1].avoids).toBe('motorway')
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
})
it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => {
// OSRM reports these in every answer and TREK threw them away since the first route
// was drawn. Without them a place set back from the road looks like it is on the
+268 -26
View File
@@ -3,6 +3,9 @@ import { pluginsApi } from '../../api/client'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, legAvoids, type AvoidClass } from './valhallaRoute'
import type { RouteUsageSurface } from '@trek/shared'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
@@ -50,6 +53,44 @@ function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
}
/**
* `fetch`, with the request counted.
*
* Every route TREK draws goes out from here, and nowhere else, which makes this the one
* place that can answer how much routing an instance really does — the number behind
* "could we host an engine ourselves". Counted: how many requests, of what kind, how
* many waypoints, roughly how far, and whether the host refused. Not counted, because it
* is never sent: where any of it was.
*
* Distance is the straight line along the waypoint chain rather than the routed length,
* which is only in the answer and differs per response shape. It is a floor on the real
* figure, and a floor is enough to tell a 1500 km per-request limit from a 150 km one.
*
* An aborted request is not a failure: the map cancels constantly while someone drags.
*/
async function routedFetch(
url: string,
signal: AbortSignal | undefined,
kind: RouteUsageSurface,
profile: 'driving' | 'walking' | 'cycling',
waypoints: readonly Waypoint[],
): Promise<Response> {
const selfHosted = !!useSettingsStore.getState().settings.routing_base_url?.trim()
let km = 0
for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i])
const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km }
try {
const response = await fetch(url, { signal })
countRoute({ ...sample, failed: !response.ok })
return response
} catch (err) {
if (!(err instanceof DOMException && err.name === 'AbortError')) {
countRoute({ ...sample, failed: true })
}
throw err
}
}
/**
* A routing request the host refused, as opposed to one it answered with "no route".
*
@@ -114,7 +155,7 @@ export async function calculateRoute(
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'route', profile, waypoints)
if (!response.ok) {
throw new RoutingRefusedError(response.status, retryAfterMs(response))
}
@@ -321,7 +362,7 @@ export async function calculateSegments(
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'segments', 'driving', waypoints)
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -352,23 +393,55 @@ export async function calculateSegments(
*/
export async function calculateRouteWithLegs(
waypoints: Waypoint[],
{ signal, profile = 'driving', tripId, dayId }: { signal?: AbortSignal; profile?: RouteProfileKey; tripId?: number | string | null; dayId?: number | null } = {}
{ signal, profile = 'driving', tripId, dayId, avoid = [] }: {
signal?: AbortSignal
profile?: RouteProfileKey
tripId?: number | string | null
dayId?: number | null
/** Road classes to weight away. Only the driving profile has any, and only Valhalla can. */
avoid?: readonly AvoidClass[]
} = {}
): Promise<RouteWithLegs> {
if (!waypoints || waypoints.length < 2) {
return { coordinates: [], distance: 0, duration: 0, legs: [] }
}
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable()
? [...avoid].sort()
: []
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
// The cached result carries formatted leg distances, so the active distance unit is
// part of the key — otherwise switching km↔mi would return stale text (#1300).
// A plugin route is trip-/day-specific (it may return different charging stops for
// the same coordinates on a different day), so its key includes tripId/dayId;
// the built-in OSRM profiles are context-free and leave those out.
//
// The avoidance belongs in the key for two reasons pointing opposite ways. Without it
// turning the switch on returns the tolled route out of cache in under a millisecond,
// so nothing on screen changes and it reads as a broken toggle. And the leak the other
// way is worse: this Map is shared with the ordinary day planner, the studio and the
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
// under the plain key would hand them a road nobody drives and a second engine's
// times. `avoiding` is empty whenever the request goes to OSRM after all, so the key
// also separates the two engines.
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
const cached = routeCache.get(cacheKey)
if (cached) return cached
if (avoiding.length) {
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
// Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a
// day drawn rather than blank, and `avoidance` stays absent on that result so the
// rail can say the avoidance did not happen instead of implying it did.
if (routed) {
cacheRoute(cacheKey, routed)
return routed
}
}
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
// and normalizes its answer; null means the provider failed or refused, and the
// throw makes callers fall back to straight lines exactly like an OSRM outage.
@@ -416,7 +489,7 @@ export async function calculateRouteWithLegs(
const osrmProfile: 'driving' | 'walking' | 'cycling' =
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'legs', osrmProfile, waypoints)
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -446,12 +519,73 @@ export async function calculateRouteWithLegs(
const snapped = readSnapped(data, waypoints)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
routeCache.set(cacheKey, result)
cacheRoute(cacheKey, result)
return result
}
/** Store a route and drop the oldest once the cache is over its cap. */
function cacheRoute(key: string, route: RouteWithLegs): void {
routeCache.set(key, route)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
if (oldest !== undefined) routeCache.delete(oldest)
}
return result
}
/**
* A whole chain from the second engine, weighted away from some road classes.
*
* Shaped exactly like the OSRM answer above so the callers never branch on which engine
* spoke — same leg list, same formatted text, same snap positions. What it adds is
* `avoidance`: what was asked for against what the road actually turned out to be, which
* is the only honest way to label a weighting that can fail.
*
* Null rather than a throw when it cannot answer, so the caller falls through to OSRM
* and the day stays drawn.
*/
async function routeAvoidingWithLegs(
waypoints: Waypoint[],
avoid: AvoidClass[],
signal?: AbortSignal,
): Promise<RouteWithLegs | null> {
const sample = {
profile: 'driving' as const,
surface: 'legs' as const,
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
waypoints: waypoints.length,
km: waypoints.slice(1).reduce((sum, point, i) => sum + haversineKm(waypoints[i], point), 0),
}
const run = await valhallaRun(waypoints, 'driving', avoid, signal)
// Counted by hand because the adapter speaks to a different engine with a different
// verb, and the counter exists to answer "how much routing does this instance do" —
// an engine it cannot see is exactly the traffic that would go missing from it.
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
if (!run) return null
const legs: RouteSegment[] = run.legs.map((leg, i): RouteSegment => {
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
return {
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
from,
to,
distance: leg.distance,
duration: leg.duration,
walkingText: formatDuration(leg.distance / (5000 / 3600)),
drivingText: formatDuration(leg.duration),
distanceText: formatRouteDistance(leg.distance),
durationText: formatDuration(leg.duration),
}
})
return {
coordinates: run.total.coordinates,
distance: run.total.distance,
duration: run.total.duration,
legs,
snapped: run.snapped,
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
}
}
/**
@@ -514,7 +648,7 @@ export interface RouteAlternative {
/** Where this route differs most from the first one — the point that would pin it. */
divergence: { lat: number; lng: number } | null
/** Set when this way exists because a road class was left out of it. */
avoids?: 'motorway' | 'toll'
avoids?: AvoidClass
}
/**
@@ -528,11 +662,39 @@ export interface RouteAlternative {
*
* Only for driving: the foot and bike profiles have no excludable classes, and asking
* anyway earns an `InvalidOptions` for nothing.
*
* Ferry is on the list for Valhalla's sake and skipped on the OSRM path below. OSRM
* does carry a ferry class, but on a leg where a ferry is the only crossing it answers
* NoRoute rather than the long way round, which is not an alternative anybody can use.
* Valhalla weights it instead and drives around, which is the whole point.
*/
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const
/** Two lines are the same road if their length and their duration agree closely enough. */
/**
* How far two lines may stray from each other and still be the same road, in kilometres.
*
* Parallel carriageways, a different slip road onto the same motorway and the noise
* between two engines' idea of where a road is all sit well under this. A way somebody
* would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to
* Copenhagen, which is a genuinely different road, peaks 2.3 km off the default.
*/
const SAME_ROAD_KM = 0.5
/**
* Two lines are the same road.
*
* Length and duration used to decide this, and stopped being enough when a second engine
* started answering. Valhalla prices roads differently — measured over ten European legs
* its distances ran 2.8 % above OSRM's and its times 5.4 % — so on a 470 km leg the same
* road comes back thirteen kilometres apart and a 200 m tolerance calls it a discovery.
* Two identical blue lines on the map is exactly what that looks like.
*
* The geometry does not care which engine drew it, so it decides whenever there is one.
* The numbers stay as the fallback for a line with no shape to compare.
*/
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
const stray = furthestFrom(b.coordinates, a.coordinates)
if (stray) return stray.km < SAME_ROAD_KM
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
}
@@ -563,16 +725,76 @@ const excludeUnsupported = new Set<string>()
* particular question had no answer.
*/
async function routeExcluding(
coords: string,
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
exclude: string,
exclude: AvoidClass,
signal?: AbortSignal,
): Promise<RouteAlternative | null> {
// Valhalla first, because on a default install it is the only one of the two that
// can answer at all. It weights the class away instead of banning it, so it also
// answers on a leg where the class cannot be avoided — and says so, which is why the
// result is thrown away below unless the road really came back without it.
const weighted = await valhallaExcluding(from, to, profile, exclude, signal)
if (weighted) return weighted
// OSRM has a ferry class too, but on a leg where the ferry IS the crossing it answers
// NoRoute rather than driving round, so there is nothing to offer and no point paying
// a request to be told so.
if (exclude === 'ferry') return null
return osrmExcluding(from, to, profile, exclude, signal)
}
/**
* The same leg from Valhalla, weighted away from one class, or null.
*
* The check that makes this honest is `legAvoids`: `use_tolls: 0` is a preference, so
* between two points with no untolled connection the answer is still a tolled road.
* Offering that as "No tolls" would put a lie on the map, so it is dropped and the
* OSRM attempt gets its turn.
*/
async function valhallaExcluding(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
exclude: AvoidClass,
signal?: AbortSignal,
): Promise<RouteAlternative | null> {
if (!valhallaAvailable()) return null
const sample = {
profile,
surface: 'alternatives' as const,
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
waypoints: 2,
km: haversineKm(from, to),
}
const leg = await valhallaRouteAvoiding(from, to, profile, exclude, signal)
// Counted here rather than in the adapter, so the adapter stays a format translator
// and every routing request an instance makes still lands in the one counter.
if (!signal?.aborted) countRoute({ ...sample, failed: !leg })
if (!leg || !legAvoids(leg, exclude)) return null
return {
coordinates: leg.coordinates,
distance: leg.distance,
duration: leg.duration,
divergence: null,
}
}
/** The same leg from OSRM with the class excluded outright, or null. */
async function osrmExcluding(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
exclude: AvoidClass,
signal?: AbortSignal,
): Promise<RouteAlternative | null> {
const base = routeBaseFor(profile)
if (excludeUnsupported.has(base)) return null
try {
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'alternatives', profile, [from, to])
if (!response.ok) {
// 400 is the router saying the parameter itself is not available here, which is
// true of every leg from now on. Anything else is about this request alone.
@@ -614,7 +836,7 @@ export async function calculateAlternatives(
): Promise<RouteAlternative[]> {
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'alternatives', profile, [from, to])
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -629,19 +851,18 @@ export async function calculateAlternatives(
// Nothing but the router's own preference came back, which is what it answers on most
// long legs. Rather than reporting "only one sensible way", ask a different question:
// the same drive without the motorway, and failing that without the tolls. Tried one at
// a time and stopped as soon as one lands, so a leg that does have a second road costs
// one extra request rather than three.
// the same drive without the motorway, then without the tolls, then without a ferry.
// Tried one at a time and stopped as soon as one lands, so a leg that does have a
// second road costs one extra request rather than three.
//
// Worth knowing before reading further: on the two public hosts TREK ships with, this
// never produces anything. Their car profile is built without excludable classes and
// the parameter comes back as HTTP 400, which is why long legs keep reporting a single
// way to drive them. It works against a self-hosted OSRM built through the MLD
// pipeline, and `excludeUnsupported` makes sure a host that refuses is only asked once.
// This used to produce nothing at all on a default install: OSRM's `exclude` is HTTP
// 400 on both public hosts, whose car profile is built without excludable classes. It
// now asks Valhalla first, where avoidance is a runtime weighting, and only falls back
// to OSRM's `exclude` for an instance running its own MLD-built router.
if (routes.length < 2 && profile === 'driving') {
for (const exclude of EXCLUDABLE_CLASSES) {
if (signal?.aborted) break
const detour = await routeExcluding(coords, profile, exclude, signal)
const detour = await routeExcluding(from, to, profile, exclude, signal)
if (detour && !routes.some(r => sameRoad(r, detour))) {
detour.avoids = exclude
routes.push(detour)
@@ -659,22 +880,43 @@ export async function calculateAlternatives(
/** The point of `line` that lies furthest from `reference`, in plain degrees. */
function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null {
const furthest = furthestFrom(line, reference)
return furthest ? { lat: furthest.lat, lng: furthest.lng } : null
}
/**
* The same point, plus how far off it actually is.
*
* The search itself stays in squared degrees with a cosine on the longitude: it only has
* to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
* real distance rather than a rank, and half a degree is a different number of metres in
* Andalusia than in Lapland.
*/
function furthestFrom(
line: [number, number][],
reference: [number, number][],
): { lat: number; lng: number; km: number } | null {
if (!line.length || !reference.length) return null
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
// full cross product of two thousand-point lines is not worth the milliseconds.
const step = Math.max(1, Math.floor(reference.length / 200))
let best: { lat: number; lng: number } | null = null
let bestNeighbour: [number, number] | null = null
let bestDist = -1
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
const [lat, lng] = line[i]
let nearest = Infinity
let nearestPoint: [number, number] | null = null
for (let j = 0; j < reference.length; j += step) {
const dLat = lat - reference[j][0]
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
const d = dLat * dLat + dLng * dLng
if (d < nearest) nearest = d
if (d < nearest) { nearest = d; nearestPoint = reference[j] }
}
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
}
return best
if (!best) return null
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
return { ...best, km }
}
@@ -5,8 +5,8 @@ import type { TripRouteOverview } from './useTripRouteOverview'
const overview = (over: Partial<TripRouteOverview> = {}): TripRouteOverview => ({
days: [
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: '#1d4ed8', lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: '#ea580c', lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: { line: '#0a84ff', casing: '#0a5cc2' }, lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: { line: '#ff9f0a', casing: '#c2740a' }, lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
],
lines: [], lineColors: [], segments: [], focusPoints: [],
totalDistance: 222500, totalDuration: 10800, loading: false,
@@ -94,7 +94,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
const row = (
<>
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color, flexShrink: 0 }} aria-hidden />
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color.line, flexShrink: 0 }} aria-hidden />
<span className="text-content" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</span>
@@ -613,6 +613,26 @@ describe('ReservationMapboxOverlay declutter floors', () => {
attach(wide, labelled).update([hop(2, 'cruise', 0.4)], labelled)
expect(glMarkers.map(m => m.el.textContent)).toEqual(['GOA', 'PMI'])
})
it('FE-COMP-RESGL-043: a routed drive whose ends project close together still draws (#2275)', () => {
// 50 px between the ends, under the 80 px car floor; the road loops out 300 px.
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
const bare = freshMap()
attach(bare).update([hop(7, 'car', 0.05)], opts)
expect(features(bare)).toHaveLength(0)
const routed = freshMap()
attach(routed).update([hop(7, 'car', 0.05)], opts, new Map([[7, road]]))
expect(features(routed)).toHaveLength(1)
expect(features(routed)[0].geometry.coordinates).toEqual(road.map(([lat, lng]) => [lng, lat]))
})
it('FE-COMP-RESGL-044: a routed drive that really is tiny on screen stays decluttered', () => {
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
const map = freshMap()
attach(map).update([hop(7, 'car', 0.03)], opts, new Map([[7, road]]))
expect(features(map)).toHaveLength(0)
})
})
describe('ReservationMapboxOverlay waypoint edge cases', () => {
+16 -14
View File
@@ -13,6 +13,7 @@ import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, Tram
import { getTransitMapSegments } from './transitGeometry'
import { geodesicArcs } from './flightGeodesy'
import { cleanEndpointName } from './reservationName'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types'
@@ -259,27 +260,31 @@ export class ReservationMapboxOverlay {
})
}
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
private linesFor(item: TransportItem): [number, number][][] {
const road = this.roadRoutes.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
}
private render() {
const map = this.map
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
const show = this.opts.showConnections
// Visible filter: require the on-screen pixel distance between
// endpoints to exceed a type-specific minimum, same as the Leaflet
// overlay, so tiny no-op transport lines don't clutter the map.
// Visible filter: a hop draws once what it will draw is long enough on
// screen to be worth it, same as the Leaflet overlay, so tiny no-op
// transport lines don't clutter the map. Measured along the drawn line,
// not between the ends: a routed drive can cover half the screen while
// its endpoints sit close together (#2275).
const project = (p: readonly [number, number]) => map.project([p[1], p[0]])
const visibleItems = show ? this.items.filter(item => {
try {
// A transit journey draws its real alignment, not a straight from->to line, so
// don't let the endpoint-proximity declutter hide it when the map is zoomed out
// (#1570). Mirrors the Leaflet overlay.
if (item.type === 'transit' && getTransitMapSegments(item.res).length > 0) return true
const fromPx = map.project([item.from.lng, item.from.lat])
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return dist >= minPx
return hopIsVisible(item.type, this.linesFor(item), project)
} catch { return true }
}) : []
@@ -293,8 +298,7 @@ export class ReservationMapboxOverlay {
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
if (dist >= minPx) labelVisibleIds.add(item.res.id)
if (dist >= labelFloorPx(item.type)) labelVisibleIds.add(item.res.id)
} catch { /* ignore */ }
}
}
@@ -320,9 +324,7 @@ export class ReservationMapboxOverlay {
}))
}
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
const road = this.roadRoutes.get(item.res.id)
const lines = road && road.length >= 2 ? [road] : item.arcs
return lines.map(seg => ({
return this.linesFor(item).map(seg => ({
type: 'Feature' as const,
properties: {
resId: item.res.id,
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { countRoute, flushRouteUsage } from './routeUsageCounter'
import { routeUsageApi } from '../../api/routeUsage'
/**
* FE-MAP-ROUTECOUNT-001..006 — the tally behind "how much routing does an instance do".
*
* Two things are worth pinning: that it batches rather than posting per route, because a
* counter that costs a request of its own measures the load by adding to it, and that it
* never throws — it sits on the hot path of every route the map draws.
*/
const sample = {
profile: 'driving' as const,
surface: 'legs' as const,
selfHosted: false,
waypoints: 3,
km: 100,
failed: false,
}
describe('routeUsageCounter', () => {
beforeEach(() => {
vi.spyOn(routeUsageApi, 'report').mockResolvedValue(undefined)
vi.useFakeTimers()
})
afterEach(() => {
flushRouteUsage()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('FE-MAP-ROUTECOUNT-001: a single route posts nothing on its own', () => {
countRoute(sample)
expect(routeUsageApi.report).not.toHaveBeenCalled()
})
it('FE-MAP-ROUTECOUNT-002: the tally goes out once the timer runs out', () => {
countRoute(sample)
vi.advanceTimersByTime(60_000)
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries).toEqual([{ ...sample, requests: 1, failed: 0 }])
})
it('FE-MAP-ROUTECOUNT-003: routes of one kind add onto one entry', () => {
countRoute(sample)
countRoute({ ...sample, waypoints: 5, km: 200 })
countRoute({ ...sample, failed: true })
flushRouteUsage()
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({ requests: 3, waypoints: 11, km: 400, failed: 1 })
})
it('FE-MAP-ROUTECOUNT-004: a different kind or engine is its own entry', () => {
countRoute(sample)
countRoute({ ...sample, surface: 'alternatives' })
countRoute({ ...sample, selfHosted: true })
countRoute({ ...sample, profile: 'walking' })
flushRouteUsage()
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries).toHaveLength(4)
})
it('FE-MAP-ROUTECOUNT-005: a busy stretch flushes on its own, without waiting', () => {
for (let i = 0; i < 25; i++) countRoute(sample)
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries[0].requests).toBe(25)
})
it('FE-MAP-ROUTECOUNT-006: a refused report is swallowed, and the next tally starts clean', async () => {
vi.mocked(routeUsageApi.report).mockRejectedValueOnce(new Error('offline'))
countRoute(sample)
expect(() => flushRouteUsage()).not.toThrow()
await Promise.resolve()
countRoute(sample)
flushRouteUsage()
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[1]
expect(entries[0].requests).toBe(1)
})
})
@@ -0,0 +1,102 @@
import type { RouteUsageEntry, RouteUsageProfile, RouteUsageSurface } from '@trek/shared'
import { routeUsageApi } from '../../api/routeUsage'
/**
* Counts the routing this browser asks for, and posts the totals in batches.
*
* All routing happens client-side against an external engine, so the server never
* sees a route request and cannot count one. Without a count nobody knows what a
* self-hosted router would have to carry — how many requests a real trip costs, how
* big they are, how much already goes to an operator's own engine.
*
* Counters only. What goes over the wire is four integers per bucket: how many
* requests, how many waypoints, how many kilometres, how many failed. No coordinate,
* no route, no trip, nothing that says where anybody went.
*
* Batched on purpose. A counter that cost a request of its own would measure the load
* by adding to it, so the tally is held in memory and flushed on a timer, when it
* grows past a threshold, and when the page goes away.
*/
/** Flush when the tally has this many requests in it, whichever comes first. */
const FLUSH_AFTER_REQUESTS = 25
/** …or after this long, so a quiet session still reports what it did. */
const FLUSH_AFTER_MS = 60_000
/** Guard against a pathological session: drop the tally rather than grow forever. */
const MAX_BUCKETS = 40
type Bucket = Omit<RouteUsageEntry, 'profile' | 'surface' | 'selfHosted'> & {
profile: RouteUsageProfile
surface: RouteUsageSurface
selfHosted: boolean
}
const buckets = new Map<string, Bucket>()
let pending = 0
let timer: ReturnType<typeof setTimeout> | null = null
let unloadWired = false
function flush(): void {
if (timer !== null) { clearTimeout(timer); timer = null }
if (!buckets.size) return
const entries = [...buckets.values()]
buckets.clear()
pending = 0
// Fire and forget: a failed report is a lost count, not a problem worth telling
// anybody about. The next flush carries on from an empty tally either way.
void routeUsageApi.report(entries).catch(() => {})
}
function wireUnload(): void {
if (unloadWired || typeof document === 'undefined') return
unloadWired = true
// `visibilitychange`, not `unload`: the latter no longer fires reliably on mobile,
// where a session ends by switching apps rather than by closing a tab.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush()
})
}
/**
* Records one routing request. Never throws and never awaits: it sits on the hot path
* of every route the map draws.
*/
export function countRoute(sample: {
profile: RouteUsageProfile
surface: RouteUsageSurface
selfHosted: boolean
waypoints: number
km: number
failed: boolean
}): void {
try {
wireUnload()
const key = `${sample.profile}|${sample.surface}|${sample.selfHosted}`
const bucket = buckets.get(key)
if (bucket) {
bucket.requests += 1
bucket.waypoints += sample.waypoints
bucket.km += sample.km
if (sample.failed) bucket.failed += 1
} else {
if (buckets.size >= MAX_BUCKETS) return
buckets.set(key, {
profile: sample.profile,
surface: sample.surface,
selfHosted: sample.selfHosted,
requests: 1,
waypoints: sample.waypoints,
km: sample.km,
failed: sample.failed ? 1 : 0,
})
}
pending += 1
if (pending >= FLUSH_AFTER_REQUESTS) { flush(); return }
if (timer === null) timer = setTimeout(flush, FLUSH_AFTER_MS)
} catch { /* counting must never break routing */ }
}
/** Flushes now. Exported for tests and for a deliberate "report before you go". */
export function flushRouteUsage(): void {
flush()
}
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useMergedMapPois } from './useMergedMapPois'
import type { Poi } from './poiCategories'
/**
* FE-MAP-MERGEPOI-001..006 — the two searches that can run at once in road trip mode.
*
* The corridor answers "what is along the drive", the category pill answers "what is in
* view". Both are drawn, the corridor's copy of a shared hit wins, and the reference is
* only new when the content really is — the map rebuilds every pin off it.
*/
const poi = (osm_id: string, name = osm_id): Poi => ({
osm_id, name, lat: 48, lng: 2, category: 'hotel', source: 'openstreetmap',
} as Poi)
describe('useMergedMapPois', () => {
it('FE-MAP-MERGEPOI-001: outside road trip mode only the pill hits are drawn', () => {
const explore = [poi('n1')]
const { result } = renderHook(() => useMergedMapPois(null, explore))
expect(result.current).toBe(explore)
})
it('FE-MAP-MERGEPOI-002: with nothing found in view the corridor list passes through as-is', () => {
const corridor = [poi('n1')]
const { result } = renderHook(() => useMergedMapPois(corridor, []))
expect(result.current).toBe(corridor)
})
it('FE-MAP-MERGEPOI-003: before a corridor search the pill hits pass through as-is', () => {
const explore = [poi('n2')]
const { result } = renderHook(() => useMergedMapPois([], explore))
expect(result.current).toBe(explore)
})
it('FE-MAP-MERGEPOI-004: both searches draw together, corridor first', () => {
const { result } = renderHook(() => useMergedMapPois([poi('n1')], [poi('n2')]))
expect(result.current.map(p => p.osm_id)).toEqual(['n1', 'n2'])
})
it('FE-MAP-MERGEPOI-005: a place both searches found is drawn once, as the corridor knows it', () => {
// The corridor copy carries how far along the drive it sits, which the pill's does not.
const corridor = [poi('n1', 'Hotel am Weg')]
const { result } = renderHook(() => useMergedMapPois(corridor, [poi('n1', 'Hotel am Weg (Sicht)')]))
expect(result.current).toBe(corridor)
expect(result.current).toHaveLength(1)
expect(result.current[0].name).toBe('Hotel am Weg')
})
it('FE-MAP-MERGEPOI-006: an unchanged pair keeps its reference, so no pin is rebuilt', () => {
const corridor = [poi('n1')]
const explore = [poi('n2')]
const { result, rerender } = renderHook(() => useMergedMapPois(corridor, explore))
const first = result.current
rerender()
expect(result.current).toBe(first)
})
})
describe('useMergedMapPois — the refuel offers', () => {
const poi = (id: string): Poi => ({ osm_id: id, name: id, lat: 0, lng: 0 } as Poi)
it('FE-MAP-MERGEPOI-007: an offered station is drawn even with no search running', () => {
// In road trip mode this is the only channel a POI reaches the map through, so
// without it somebody is asked to accept a stop they cannot see.
const { result } = renderHook(() => useMergedMapPois([], [], [poi('offer')]))
expect(result.current.map(p => p.osm_id)).toEqual(['offer'])
})
it('FE-MAP-MERGEPOI-008: an offer already on the route is one pin, not two', () => {
const { result } = renderHook(() => useMergedMapPois([poi('a')], [], [poi('a'), poi('b')]))
expect(result.current.map(p => p.osm_id)).toEqual(['a', 'b'])
})
it('FE-MAP-MERGEPOI-009: nothing offered keeps the same array, so no pin is rebuilt', () => {
const corridor = [poi('a')]
const { result, rerender } = renderHook(({ o }: { o: Poi[] }) => useMergedMapPois(corridor, [], o), {
initialProps: { o: [] as Poi[] },
})
const first = result.current
rerender({ o: [] })
expect(result.current).toBe(first)
expect(result.current).toBe(corridor)
})
})
@@ -0,0 +1,42 @@
import { useMemo } from 'react'
import type { Poi } from './poiCategories'
/**
* The findings the map should draw, when two searches can be running at once.
*
* In road trip mode the corridor search answers "what is along this drive". The category
* pill answers a different question — "what is in view right now" — and a hotel at tonight's
* stop is exactly the case where someone wants the second one without giving up the first,
* so the two lists are drawn together rather than one replacing the other.
*
* Identity is `osm_id`, the same key the corridor search dedupes its own overlapping tiles
* on: a hotel found both along the route and in the viewport is one pin, and the corridor's
* copy wins because it carries how far along the drive it sits.
*
* `offered` is the third case: the handful of filling stations proposed where a tank runs
* out. They belong on the map for a plain reason — somebody is being asked to accept a
* stop, and a stop you cannot see is not one you can judge — but they are not a search
* result and must not survive the panel being closed, which is why they arrive separately
* rather than being folded into the corridor's own list.
*
* Returns the input array itself whenever there is nothing to merge. The map redraws off
* this reference, so handing it a fresh array on every render would rebuild every pin.
*/
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[], offered: Poi[] = NONE): Poi[] {
return useMemo(() => {
const base = corridor === null ? explore : merge(corridor, explore)
return merge(base, offered)
}, [corridor, explore, offered])
}
/** Nothing offered, as one stable array, so the default never changes identity. */
const NONE: Poi[] = []
/** `a` with everything from `b` that it does not already carry, by `osm_id`. */
function merge(a: Poi[], b: Poi[]): Poi[] {
if (!b.length) return a
if (!a.length) return b
const seen = new Set(a.map(p => p.osm_id))
const extra = b.filter(p => !seen.has(p.osm_id))
return extra.length ? [...a, ...extra] : a
}
@@ -1,6 +1,6 @@
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { TRACK_COLORS } from '@trek/shared'
import { dayColor } from '../Roadtrip/dayColors'
import { useTripRouteOverview } from './useTripRouteOverview'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { AssignmentsMap, RouteSegment } from '../../types'
@@ -64,16 +64,18 @@ describe('useTripRouteOverview', () => {
expect(result.current.totalDuration).toBe(1800)
})
it('FE-MAP-TRO-003: gives each day its own colour from the map palette', async () => {
it('FE-MAP-TRO-003: gives each day the colour the road trip draws it in', async () => {
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
const colors = result.current.days.map(d => d.color)
expect(colors[0]).toBe(TRACK_COLORS[1])
expect(colors[1]).toBe(TRACK_COLORS[2])
// The same palette either way, so a day does not change colour when the trip is
// read as a road trip instead of an overview.
expect(colors[0]).toEqual(dayColor(1))
expect(colors[1]).toEqual(dayColor(2))
// Every drawn line is labelled with the colour of the day it belongs to.
expect(result.current.lineColors).toHaveLength(result.current.lines.length)
expect(new Set(result.current.lineColors).size).toBe(2)
expect(new Set(result.current.lineColors.map(c => c.line)).size).toBe(2)
})
it('FE-MAP-TRO-004: reports the modes each day is travelled in', async () => {
@@ -3,7 +3,7 @@ import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan'
import { resolveLegMode } from '../Planner/legMode'
import { useSettingsStore } from '../../store/settingsStore'
import { TRACK_COLORS } from '@trek/shared'
import { dayColor } from '../Roadtrip/dayColors'
import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types'
/** One travel day of the overview: the roads it covers, in its own colour. */
@@ -12,7 +12,9 @@ export interface TripOverviewDay {
dayNumber: number
date: string | null
title: string | null
color: string
/** The core and casing this day is drawn in — the road trip's palette, so a day is
* the same colour whichever way the trip is being read. */
color: { line: string; casing: string }
/** One polyline per run of the day, `[lat, lng]`. */
lines: [number, number][][]
segments: RouteSegment[]
@@ -28,7 +30,7 @@ export interface TripRouteOverview {
/** Every day's polylines flattened in trip order — what the map draws. */
lines: [number, number][][]
/** The colour of each entry of `lines`, same index. */
lineColors: string[]
lineColors: { line: string; casing: string }[]
segments: RouteSegment[]
/** Every drawn coordinate, so the map can frame the whole trip at once. */
focusPoints: [number, number][]
@@ -116,7 +118,7 @@ export function useTripRouteOverview(
const controller = new AbortController()
abortRef.current = controller
const colorFor = (day: Day) => TRACK_COLORS[Math.abs(day.day_number ?? 0) % TRACK_COLORS.length]
const colorFor = (day: Day) => dayColor(day.day_number ?? 0)
// Chunk every day up front, so the routed answers can be filed by position and
// reassembled in order however they come back.
@@ -0,0 +1,367 @@
import { describe, it, expect, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useSettingsStore } from '../../store/settingsStore'
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, runFrom, legAvoids } from './valhallaRoute'
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
/**
* A real polyline6, encoded from three known points and verified by decoding it back.
* Precision matters here more than anywhere else in the file: read as polyline5 the
* first vertex would be 535.511, which is not a latitude, and a wrong guess draws a
* line into the sea rather than failing.
*/
const SHAPE = 'w~nceBg}}`RgkPwjr@~fn~@oirmE'
const SHAPE_POINTS: [number, number][] = [[53.5511, 9.9937], [53.56, 10.02], [52.52, 13.405]]
const answer = (summary: Record<string, unknown> = {}) => ({
trip: {
legs: [{ shape: SHAPE }],
summary: {
has_time_restrictions: false,
has_toll: false,
has_highway: true,
has_ferry: false,
// Asked in kilometres, so this is 295.7 km and 191 minutes — the real
// Hamburg-to-Berlin answer, which the adapter has to hand on in metres and seconds.
length: 295.7,
time: 11460,
...summary,
},
},
})
const setSettings = (patch: Record<string, string>) =>
useSettingsStore.setState(st => ({ settings: { ...st.settings, ...patch } }))
afterEach(() => {
setSettings({ routing_base_url: '', valhalla_base_url: '' })
})
const HAMBURG = { lat: 53.5511, lng: 9.9937 }
const BERLIN = { lat: 52.52, lng: 13.405 }
describe('valhallaBase', () => {
it('VALHALLA-BASE-001: falls back to the public instance when nothing is configured', () => {
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
expect(valhallaAvailable()).toBe(true)
})
it('VALHALLA-BASE-002: uses the configured instance and drops its trailing slashes', () => {
setSettings({ valhalla_base_url: 'https://valhalla.example.org//' })
expect(valhallaBase()).toBe('https://valhalla.example.org')
})
it('VALHALLA-BASE-003: asks nothing when the instance runs its own OSRM', () => {
// An operator who filled in their own router chose against the public hosts, usually
// because the instance is not supposed to reach third parties at all. Adding one they
// never asked for would undo that, and their MLD-built OSRM answers exclude itself.
setSettings({ routing_base_url: 'https://osrm.example.org' })
expect(valhallaBase()).toBeNull()
expect(valhallaAvailable()).toBe(false)
})
it('VALHALLA-BASE-004: an own Valhalla wins over an own OSRM', () => {
setSettings({ routing_base_url: 'https://osrm.example.org', valhalla_base_url: 'https://v.example.org' })
expect(valhallaBase()).toBe('https://v.example.org')
})
})
describe('runFrom', () => {
const total = (data: unknown) => runFrom(data)?.total ?? null
it('VALHALLA-PARSE-001: decodes the shape as polyline6 and converts km to metres', () => {
const leg = total(answer())
expect(leg).not.toBeNull()
expect(leg!.coordinates).toEqual(SHAPE_POINTS)
expect(leg!.distance).toBeCloseTo(295700, 0)
expect(leg!.duration).toBe(11460)
})
it('VALHALLA-PARSE-002: carries the flags through as booleans', () => {
const leg = total(answer({ has_toll: true, has_ferry: true, has_highway: false }))
expect(leg).toMatchObject({ hasToll: true, hasFerry: true, hasHighway: false })
})
it('VALHALLA-PARSE-003: a missing flag reads as absent rather than unknown', () => {
// The public instance omits a flag it has nothing to say about, and the caller turns
// this into a label. Undefined has to become false, not a truthy object.
const leg = total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 1, time: 60 } } })
expect(leg).toMatchObject({ hasToll: false, hasHighway: false, hasFerry: false })
})
it('VALHALLA-PARSE-004: keeps a chain divided leg by leg, and adds it up as well', () => {
// What a request with a via really answers: one summary per pair of stops. The
// schedule is built from those, so flattening them into a total would lose the
// arrival time at every stop but the last.
const chain = {
trip: {
legs: [
{ shape: SHAPE, summary: { length: 100, time: 3600, has_toll: false, has_highway: true, has_ferry: false } },
{ shape: SHAPE, summary: { length: 50, time: 1800, has_toll: true, has_highway: false, has_ferry: false } },
],
summary: { length: 150, time: 5400, has_toll: true, has_highway: true, has_ferry: false },
},
}
const run = runFrom(chain)!
expect(run.legs.map(l => l.distance)).toEqual([100000, 50000])
expect(run.legs.map(l => l.hasToll)).toEqual([false, true])
expect(run.total.distance).toBe(150000)
expect(run.total.duration).toBe(5400)
// One tolled leg makes the drive tolled. `every` here would call a day with a
// single toll booth in it toll-free.
expect(run.total.hasToll).toBe(true)
expect(run.total.coordinates).toHaveLength(SHAPE_POINTS.length * 2)
})
it('VALHALLA-PARSE-007: several legs with no summaries of their own are refused', () => {
// There is no honest way to divide one total between them, and a guess would reach
// the user as a wrong arrival time rather than as a missing one.
const two = { trip: { legs: [{ shape: SHAPE }, { shape: SHAPE }], summary: { length: 2, time: 120 } } }
expect(runFrom(two)).toBeNull()
})
it('VALHALLA-PARSE-005: an error body is not a leg', () => {
// What a 400 carries: no trip at all. Parsing it as one would produce a route of
// NaN kilometres rather than the null every caller reads as "no second way".
expect(total({ error_code: 442, error: 'No path could be found for input' })).toBeNull()
expect(total({ trip: { legs: [], summary: { length: 1, time: 1 } } })).toBeNull()
expect(total({ trip: { legs: [{ shape: SHAPE }] } })).toBeNull()
expect(total(null)).toBeNull()
})
it('VALHALLA-PARSE-006: a summary without numbers is not a leg', () => {
expect(total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 'far', time: 60 } } })).toBeNull()
})
})
describe('legAvoids', () => {
const leg = (flags: Partial<{ hasToll: boolean; hasHighway: boolean; hasFerry: boolean }>) => ({
coordinates: SHAPE_POINTS, distance: 1, duration: 1,
hasToll: false, hasHighway: false, hasFerry: false, ...flags,
})
it('VALHALLA-AVOID-001: reads the answer, not the request', () => {
// use_tolls: 0 is a weighting. Between two points with no untolled connection the
// answer is still a tolled road, honestly flagged, and labelling that "No tolls"
// would put a lie on the map.
expect(legAvoids(leg({ hasToll: false }), 'toll')).toBe(true)
expect(legAvoids(leg({ hasToll: true }), 'toll')).toBe(false)
})
it('VALHALLA-AVOID-002: each class reads its own flag', () => {
expect(legAvoids(leg({ hasHighway: true }), 'motorway')).toBe(false)
expect(legAvoids(leg({ hasHighway: true }), 'toll')).toBe(true)
expect(legAvoids(leg({ hasFerry: true }), 'ferry')).toBe(false)
expect(legAvoids(leg({ hasFerry: false }), 'ferry')).toBe(true)
})
})
describe('valhallaRouteAvoiding', () => {
it('VALHALLA-REQ-001: posts the avoidance as a costing option under the right profile', async () => {
let body: Record<string, unknown> | null = null
server.use(
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
body = await request.json() as Record<string, unknown>
return HttpResponse.json(answer())
})
)
const leg = await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
expect(leg!.distance).toBeCloseTo(295700, 0)
expect(body).toMatchObject({
costing: 'auto',
costing_options: { auto: { use_tolls: 0 } },
directions_options: { units: 'kilometers' },
})
// The radius is what makes a place in a pedestrian zone routable at all: without it
// Vienna's Stephansdom answers error 442 and the whole leg is lost.
expect(body!.locations).toEqual([
{ lat: 53.5511, lon: 9.9937, radius: 50 },
{ lat: 52.52, lon: 13.405, radius: 50 },
])
})
it('VALHALLA-REQ-002: each class maps to its own costing option', async () => {
const seen: Record<string, unknown>[] = []
server.use(
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
seen.push(await request.json() as Record<string, unknown>)
return HttpResponse.json(answer())
})
)
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'ferry')
expect(seen.map(b => b.costing_options)).toEqual([
{ auto: { use_highways: 0 } },
{ auto: { use_ferry: 0 } },
])
})
it('VALHALLA-REQ-003: walking and cycling get their own costing models', async () => {
const seen: string[] = []
server.use(
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { costing: string }
seen.push(body.costing)
return HttpResponse.json(answer())
})
)
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'walking', 'ferry')
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'cycling', 'ferry')
expect(seen).toEqual(['pedestrian', 'bicycle'])
})
it('VALHALLA-REQ-004: a refusal is a null, not a throw', async () => {
// Every failure means the same thing to the caller — there is no second way to
// show — so a 400 for an unroutable point and a 429 for the rate limit both end
// here rather than as an error over a map that is drawing fine.
server.use(
http.post(FOSSGIS_VALHALLA, () =>
HttpResponse.json({ error_code: 442, error: 'No path could be found for input' }, { status: 400 })
)
)
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
})
it('VALHALLA-REQ-005: a host that is not a Valhalla is only asked once', async () => {
let calls = 0
setSettings({ valhalla_base_url: 'https://not-valhalla.example.org' })
server.use(
http.post('https://not-valhalla.example.org/route', () => {
calls++
return new HttpResponse(null, { status: 404 })
})
)
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
// A 404 is about the URL rather than about this leg, so remembering it saves every
// later leg a request against a host that will never answer.
expect(calls).toBe(1)
})
it('VALHALLA-REQ-006: asks nothing at all when the instance has its own OSRM', async () => {
let calls = 0
setSettings({ routing_base_url: 'https://osrm.example.org' })
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(answer()) }))
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
expect(calls).toBe(0)
})
})
describe('valhallaRun over a whole day', () => {
/** A chain answer with one summary per leg, which is what a real request returns. */
const chain = (legCount: number) => ({
trip: {
legs: Array.from({ length: legCount }, () => ({
shape: SHAPE,
summary: { length: 10, time: 600, has_toll: false, has_highway: true, has_ferry: false },
})),
summary: { length: 10 * legCount, time: 600 * legCount, has_toll: false, has_highway: true, has_ferry: false },
},
})
const stops = (n: number) =>
Array.from({ length: n }, (_, i) => ({ lat: 53 - i * 0.1, lng: 10 + i * 0.1 }))
it('VALHALLA-RUN-001: a day inside the cap goes out as one request, one leg per pair', async () => {
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
calls++
const body = await request.json() as { locations: unknown[] }
return HttpResponse.json(chain(body.locations.length - 1))
}))
const run = await valhallaRun(stops(6), 'driving', [])
expect(calls).toBe(1)
expect(run!.legs).toHaveLength(5)
expect(run!.total.distance).toBe(50000)
})
it('VALHALLA-RUN-002: a day over the cap is split on a shared point, and the legs still line up', async () => {
// Ten locations is hard: the eleventh answers error 150. Split runs overlap by one
// point, which is exact rather than approximate — measured over a six-stop day,
// [0..3] plus [3..5] came back within two metres of the same day asked whole.
const sizes: number[] = []
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
sizes.push(body.locations.length)
return HttpResponse.json(chain(body.locations.length - 1))
}))
const run = await valhallaRun(stops(14), 'driving', ['toll'])
// Fourteen stops: the first run takes ten, the second starts on the tenth again and
// takes the remaining five. Nine pairs plus four.
expect(sizes).toEqual([10, 5])
// Thirteen pairs across two requests, none lost and none counted twice.
expect(run!.legs).toHaveLength(13)
expect(run!.total.distance).toBe(130000)
})
it('VALHALLA-RUN-003: never sends more than ten locations, however long the day', async () => {
const sizes: number[] = []
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
sizes.push(body.locations.length)
return HttpResponse.json(chain(body.locations.length - 1))
}))
await valhallaRun(stops(28), 'driving', [])
expect(Math.max(...sizes)).toBeLessThanOrEqual(10)
expect(sizes.reduce((sum, n) => sum + n - 1, 0)).toBe(27)
})
it('VALHALLA-RUN-004: a piece that will not answer loses the whole day rather than half of it', async () => {
// Half a day of legs is worse than none. The schedule chains leg times blindly and
// cannot tell that some are missing, so it would print a complete, plausible,
// silently wrong timetable instead of leaving a visible gap.
//
// The SECOND CHUNK fails, not the second request: one attempt is retried, so failing
// by call number would just be answered on the retry.
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
if (body.locations.length < 10) {
return HttpResponse.json({ error_code: 442, error: 'No path' }, { status: 400 })
}
return HttpResponse.json(chain(body.locations.length - 1))
}))
await expect(valhallaRun(stops(14), 'driving', ['toll'])).resolves.toBeNull()
})
it('VALHALLA-RUN-007: a piece that fails once is asked again rather than losing the day', async () => {
// The caller retries the whole task, so without this a rate limit on the last piece
// of a split day throws away the pieces that already answered and asks for all of
// them again — three times over, against a host that allows one request a second.
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
const body = await request.json() as { locations: unknown[] }
calls++
if (calls === 2) return new HttpResponse(null, { status: 429 })
return HttpResponse.json(chain(body.locations.length - 1))
}))
const run = await valhallaRun(stops(14), 'driving', ['toll'])
expect(run!.legs).toHaveLength(13)
// First piece, second piece refused, second piece again.
expect(calls).toBe(3)
})
it('VALHALLA-RUN-005: fewer than two points is nothing to route', async () => {
let calls = 0
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(chain(1)) }))
await expect(valhallaRun(stops(1), 'driving', [])).resolves.toBeNull()
expect(calls).toBe(0)
})
it('VALHALLA-RUN-006: an answer with the wrong number of legs is refused', async () => {
// A day whose legs do not match its stops cannot be assigned to the rail, and
// guessing which stop is missing would put the wrong time against the right place.
server.use(http.post(FOSSGIS_VALHALLA, () => HttpResponse.json(chain(2))))
await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull()
})
})
+411
View File
@@ -0,0 +1,411 @@
import { useSettingsStore } from '../../store/settingsStore'
import { decodePolyline } from './transitGeometry'
import { haversineKm } from '../../utils/geo'
import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
/**
* Valhalla, the second routing engine, asked only the questions OSRM cannot answer.
*
* The shipped OSRM hosts are built without excludable road classes: their car profile
* says so in its own Lua ("no memory for classes on demand server"), and `exclude=`
* comes back as HTTP 400 on every leg. So the one thing a road tripper keeps asking
* for — drive this without the tolls — has never worked on a default install.
*
* FOSSGIS runs a public Valhalla on the whole planet next to it, keyless, and there
* avoidance is a runtime weighting rather than a profile that has to be rebuilt. This
* module is the adapter: same question, different engine, answer shaped like the OSRM
* one so the callers do not have to care which engine spoke.
*
* Deliberately NOT a replacement for the OSRM path. Three measured reasons:
* - it caps at ten locations, and following a track sends a day's stops plus up to
* nine anchors, so the bundled requests would start failing;
* - it refuses a point it cannot bind to a road, where OSRM still snaps one. The
* Stephansdom, in a pedestrian zone, answers `No path could be found for input`
* until a search radius is allowed;
* - it prices roads differently. Over ten European legs its drive times sat 5.4 %
* above OSRM's on average and 36 % above on one, and those times feed the arrival
* clocks of a road trip day. Mixing engines inside one chain would move times
* nobody asked to move.
*
* Which leaves exactly the shape this module serves: two points, one avoidance, asked
* on demand, drawn as an offer next to the route rather than replacing it.
*/
/** The public instance, matching how the OSRM defaults are shipped rather than configured. */
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de'
/**
* How far Valhalla may look for a road under a point, in metres.
*
* Zero is its default and it is the wrong default for TREK's data, which is full of
* museums, viewpoints and restaurants that sit inside a pedestrian zone. Measured on
* Vienna's Stephansdom: without a radius the whole route answers error 442, with 50 it
* routes, and 200 changed nothing further. So 50 buys the fix and nothing beyond it.
*/
const SNAP_RADIUS_M = 50
/** TREK's profile names against Valhalla's costing models. */
const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
driving: 'auto',
walking: 'pedestrian',
cycling: 'bicycle',
}
/**
* What a caller can ask to be left out, against the costing option that asks for it.
*
* Every one of these is a weighting from 0 to 1, not a ban — which is the whole reason
* this engine is worth having. Asked to avoid tolls between New York and Boston it
* still returns a tolled road, because there is no untolled way, where OSRM's `exclude`
* would refuse to answer at all. The caller has to read the answer to find out what it
* got, which is what `hasToll` and friends are for.
*/
const AVOID_OPTION: Record<AvoidClass, string> = {
motorway: 'use_highways',
toll: 'use_tolls',
ferry: 'use_ferry',
}
/** The same three classes the rest of the app names; aliased so this file reads on its own. */
export type AvoidClass = RouteAvoidClass
/**
* Locations one request may carry. The eleventh answers
* `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400.
*/
const MAX_LOCATIONS = 10
/**
* Pause between the pieces of a split day, in milliseconds.
*
* The public instance allows about one request a second per client, and a day long
* enough to be split is exactly the case that would fire two back to back. Matches the
* 1100 ms the road trip rail already leaves between its own days.
*/
const CHUNK_PAUSE_MS = 1100
/**
* Extra attempts for one piece of a split day.
*
* One, not a ladder: the common failure here is the public instance's rate limit, which
* a single pause clears, and anything a second attempt cannot fix is not going to be
* fixed by a fourth while somebody waits for their map.
*/
const CHUNK_RETRIES = 1
/** One way of driving a leg, in the units OSRM answers in so callers need no branch. */
export interface ValhallaLeg {
coordinates: [number, number][]
/** Metres. Valhalla is asked in kilometres and converted here. */
distance: number
/** Seconds. */
duration: number
/** What the route ACTUALLY has, as opposed to what was asked for. */
hasToll: boolean
hasHighway: boolean
hasFerry: boolean
}
/** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */
export interface ValhallaRun {
legs: ValhallaLeg[]
total: ValhallaLeg
/**
* Where each waypoint actually met the road.
*
* Reconstructed from the shape rather than read from a field, because Valhalla echoes
* the coordinates it was GIVEN in `trip.locations` and never says where it bound them.
* The ends of a leg's line are those points: measured against Füssen, its first vertex
* came back at 47.571617 / 10.70153, which is OSRM's snapped location for the same
* request to the digit. Without this the dashed kerb-to-door spur from #2273 would
* quietly disappear from every day routed by this engine.
*/
snapped: SnappedWaypoint[]
}
/**
* The instance's Valhalla, or null when it should not ask one.
*
* Blank falls back to the public instance, the same reading `routing_base_url` gets —
* with one condition. An operator who filled in their OWN router chose deliberately
* against the public hosts, usually because their instance is not supposed to talk to
* third parties at all, and quietly adding a second public host they never asked for
* would undo that choice. They lose nothing: a self-hosted OSRM built through the MLD
* pipeline answers `exclude` on its own, which is exactly the fallback below.
*/
export function valhallaBase(): string | null {
const settings = useSettingsStore.getState().settings
const configured = settings.valhalla_base_url?.trim()
if (configured) return configured.replace(/\/+$/, '')
return settings.routing_base_url?.trim() ? null : FOSSGIS_VALHALLA
}
/** True when this engine can be asked at all, so callers can skip building a request. */
export function valhallaAvailable(): boolean {
return valhallaBase() !== null
}
/** Hosts that have already proved they are not a Valhalla, by base URL. */
const notValhalla = new Set<string>()
/**
* One leg with a road class weighted away, or null when it cannot be had.
*
* Null for every failure, deliberately: this is an offer next to a route that already
* exists, so a host that is down, a rate limit, a point in a field and a genuinely
* connected-only-by-motorway leg all mean the same thing to the caller — there is no
* second way to show. The one failure worth remembering is a host that is not a
* Valhalla at all, because that is true of every leg from now on rather than this one.
*/
export async function valhallaRouteAvoiding(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
avoid: AvoidClass,
signal?: AbortSignal,
): Promise<ValhallaLeg | null> {
const run = await valhallaRun([from, to], profile, [avoid], signal)
return run ? run.total : null
}
/**
* A whole chain of waypoints, one leg per pair, or null when it cannot be had.
*
* Split when it has to be. Ten locations is a hard cap, and a road trip day is bundled
* into one request precisely so it appears at once rather than trickling in — so a day
* over the cap is cut into runs that overlap by one point and the legs are concatenated.
* That is exact, not an approximation: measured over a six-stop day, [0..3] plus [3..5]
* came back within two metres and under a second of the same day asked whole, because
* the shared point is routed to and from identically.
*
* The pieces go out one after another with a pause, since a day long enough to need
* splitting is exactly the one that would otherwise fire two requests inside a second
* at a host that allows one.
*/
export async function valhallaRun(
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
): Promise<ValhallaRun | null> {
const base = valhallaBase()
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
const legs: ValhallaLeg[] = []
const chunks = chunkWaypoints(waypoints)
for (const [index, chunk] of chunks.entries()) {
if (signal?.aborted) return null
if (index > 0) await pause(CHUNK_PAUSE_MS, signal)
// Retried per piece rather than per day. The caller retries the whole task, so a
// rate limit on the last piece of a split day would otherwise throw away the pieces
// that already answered and ask for all of them again — three times over, against a
// host that allows one request a second.
let run = await requestRun(base, chunk, profile, avoid, signal)
for (let attempt = 0; !run && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
// Not when the host just proved it is not a Valhalla at all: that answer will not
// change on a second ask, and asking anyway is the request the memo exists to save.
if (notValhalla.has(base)) break
await pause(CHUNK_PAUSE_MS, signal)
run = await requestRun(base, chunk, profile, avoid, signal)
}
// A day is one answer or none. Half a day of legs would be worse than no answer:
// the schedule chains leg times blindly and cannot tell that it is missing some, so
// it would print a complete, plausible, wrong timetable rather than a gap.
if (!run) return null
legs.push(...run.legs)
}
if (legs.length !== waypoints.length - 1) return null
return { legs, total: joinLegs(legs), snapped: snappedFrom(legs, waypoints) }
}
/**
* Where each waypoint met the road, taken off the ends of the legs that touch it.
*
* A leg's line starts at the bound position of its first stop and ends at the bound
* position of its second, so stop i is the head of leg i, and the last stop is the tail
* of the last leg. The distance is measured here rather than reported, the same recovery
* `readSnapped` already applies when OSRM leaves the field out.
*/
function snappedFrom(legs: ValhallaLeg[], waypoints: Waypoint[]): SnappedWaypoint[] {
return waypoints.map((waypoint, i) => {
const line = i < legs.length ? legs[i].coordinates : legs[legs.length - 1].coordinates
const at = i < legs.length ? line[0] : line[line.length - 1]
const asked: [number, number] = [waypoint.lat, waypoint.lng]
return {
asked,
at,
meters: haversineKm(waypoint, { lat: at[0], lng: at[1] }) * 1000,
}
})
}
/** Runs of at most ten points, each starting on the one the previous ended at. */
function chunkWaypoints(waypoints: Waypoint[]): Waypoint[][] {
if (waypoints.length <= MAX_LOCATIONS) return [waypoints]
const chunks: Waypoint[][] = []
for (let start = 0; start < waypoints.length - 1; start += MAX_LOCATIONS - 1) {
chunks.push(waypoints.slice(start, start + MAX_LOCATIONS))
}
return chunks
}
function pause(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise(resolve => {
const timer = setTimeout(resolve, ms)
signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
})
}
/** One request, at most ten locations. */
async function requestRun(
base: string,
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
const costing = COSTING[profile]
// All of them in one request: the options are independent weightings, so asking to
// leave out tolls and ferries together is one question rather than two routes to
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
// away together answered 656.9 km against 339.2 km for the plain route.
const options: Record<string, number> = {}
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
const body = {
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
costing,
costing_options: { [costing]: options },
directions_options: { units: 'kilometers' },
}
try {
const response = await fetch(`${base}/route`, {
method: 'POST',
// A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a
// client to identify itself with. It costs a preflight, which is answered.
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
body: JSON.stringify(body),
signal,
})
if (!response.ok) {
// 404 and 405 mean this URL is not a Valhalla — an OSRM pointed at by mistake,
// or a proxy that swallowed the path. Every other status is about this request:
// 400 carries the real refusals (unroutable point, over 1500 km apart) and 429
// is the public instance's one-per-second.
if (response.status === 404 || response.status === 405) notValhalla.add(base)
return null
}
return runFrom(await response.json())
} catch {
return null
}
}
/**
* Several legs read as one, the way a caller that only wants the whole drive sees it.
*
* The seam matters more than it looks. Every leg ends where the next begins, so a plain
* concatenation repeats that vertex — and this line is not only drawn: a via dropped on
* the route is projected onto it to work out which leg it belongs after, and that index
* is written to the database. A repeated point can put the via on the wrong leg, which
* is a stored wrong answer rather than an invisible one.
*/
function joinLegs(legs: ValhallaLeg[]): ValhallaLeg {
const coordinates: [number, number][] = []
for (const leg of legs) {
const last = coordinates[coordinates.length - 1]
const first = leg.coordinates[0]
const continues = last && first && last[0] === first[0] && last[1] === first[1]
coordinates.push(...(continues ? leg.coordinates.slice(1) : leg.coordinates))
}
return {
coordinates,
distance: legs.reduce((sum, l) => sum + l.distance, 0),
duration: legs.reduce((sum, l) => sum + l.duration, 0),
// One tolled leg makes the drive a tolled drive: `some`, never `every`, or a day
// with a single toll booth in it would be labelled toll-free.
hasToll: legs.some(l => l.hasToll),
hasHighway: legs.some(l => l.hasHighway),
hasFerry: legs.some(l => l.hasFerry),
}
}
/**
* The Valhalla answer as a leg, or null when it is not one.
*
* Split out because it is the whole of the format knowledge and the only part worth
* testing without a network: the native shape, not `format=osrm`. That flag exists and
* would have kept the response side unchanged, but it drops `has_toll` — the one field
* this is all for — and carries every intersection instead, which measured 5.7 times
* larger on Paris to Lyon. Translating a smaller answer beats parsing a familiar one.
*/
/**
* The answer read leg by leg, which is what a whole day needs.
*
* Valhalla gives every leg its own summary, so a chain comes back already divided the
* way a road trip day wants it: this stop to the next, with its own distance, its own
* drive time, and its own honest `has_toll`. Reading only the trip summary would flatten
* a day into one number and lose the per-leg times the schedule is built from.
*
* The fallback to the trip summary is for a single leg only. Where several legs carry no
* summary of their own there is no honest way to divide one total between them, and a
* guess here would arrive as a wrong arrival time rather than as a missing one.
*/
export function runFrom(data: unknown): Omit<ValhallaRun, 'snapped'> | null {
const trip = tripOf(data)
if (!trip || !Array.isArray(trip.legs) || !trip.legs.length) return null
const legs: ValhallaLeg[] = []
for (const leg of trip.legs) {
if (typeof leg.shape !== 'string') return null
const summary = leg.summary ?? (trip.legs.length === 1 ? trip.summary : undefined)
if (!summary) return null
// Precision 6, verified against a known point: decoded as 5 the first vertex of a
// Paris route reads 488.56, which is not a latitude; as 6 it reads 48.856193.
const measure = measured(summary, decodePolyline(leg.shape, 6))
if (!measure) return null
legs.push(measure)
}
return { legs, total: joinLegs(legs) }
}
interface ValhallaSummary { [key: string]: number | boolean | undefined }
function tripOf(data: unknown) {
return (data as {
trip?: { legs?: { shape?: string; summary?: ValhallaSummary }[]; summary?: ValhallaSummary }
})?.trip
}
/** A summary and a shape as a leg, or null when the summary carries no numbers. */
function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null {
if (!coordinates.length) return null
const length = Number(summary.length)
const time = Number(summary.time)
if (!Number.isFinite(length) || !Number.isFinite(time)) return null
return {
coordinates,
// Asked in kilometres, answered in kilometres, handed on in metres because that is
// what every caller already reads off an OSRM route.
distance: length * 1000,
duration: time,
hasToll: summary.has_toll === true,
hasHighway: summary.has_highway === true,
hasFerry: summary.has_ferry === true,
}
}
/**
* Whether a route actually avoids what it was asked to avoid.
*
* The reason this function exists rather than a boolean on the request: `use_tolls: 0`
* is a weighting, so between two points with no untolled connection Valhalla answers a
* tolled route and reports it honestly. Labelling that "No tolls" would be a lie on
* screen, and the flags are in the answer precisely so nobody has to guess.
*/
export function legAvoids(leg: ValhallaLeg, avoid: AvoidClass): boolean {
if (avoid === 'toll') return !leg.hasToll
if (avoid === 'motorway') return !leg.hasHighway
return !leg.hasFerry
}
@@ -299,6 +299,43 @@ describe('PlaceFormModal', () => {
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-021e: an OpenStreetMap suggestion keeps its own coordinates instead of searching for its label', async () => {
// The layer's second line is the name written on the building, not an
// address. Joining the two and searching for it asks a question nobody
// typed, and whatever came back first was taken as the place the user had
// already picked. The row carries coordinates, so the fallback uses those.
const user = userEvent.setup();
let searched = 0;
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{
placeId: 'node:9712313',
mainText: 'Tokio Hauptbahnhof',
secondaryText: '東京駅丸の内駅舎',
source: 'openstreetmap',
lat: 35.6811816,
lng: 139.76598265,
}],
source: 'trek-places',
}),
),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
http.post('/api/maps/search', () => {
searched += 1;
return HttpResponse.json({ places: [{ name: 'Etwas ganz anderes', lat: '1', lng: '1' }], source: 'trek-places' });
}),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokio');
await user.click(await screen.findByText('Tokio Hauptbahnhof'));
expect(await screen.findByDisplayValue('35.6811816')).toBeInTheDocument();
expect(screen.getByDisplayValue('139.76598265')).toBeInTheDocument();
expect(searched).toBe(0);
});
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
const user = userEvent.setup();
server.use(
@@ -375,6 +412,31 @@ describe('PlaceFormModal', () => {
expect(await screen.findByText('TREK')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022d: the typed-ahead list marks the OpenStreetMap rows as such', async () => {
// The keystroke path asks both indexes at once, so the name the response
// carries for the whole list ('trek-places') is true of the call and wrong
// for half the rows. Marking every suggestion TREK is how a list that did
// contain OpenStreetMap places read as if it never had.
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [
{ placeId: 'gers:abc', mainText: 'Tokyo Station Beer Stand', secondaryText: 'Chiyoda', source: 'trek-places' },
{ placeId: 'node:9712313', mainText: 'Tokio Hauptbahnhof', secondaryText: '東京駅', source: 'openstreetmap' },
],
source: 'trek-places',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokyo Station');
expect(await screen.findByText('TREK')).toBeInTheDocument();
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022c: an interleaved result list marks each row with its own index', async () => {
// The whole point of putting it on the row: with both indexes answering,
// a label above the list could only name one of them. The index marks its
@@ -18,6 +18,7 @@ import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError'
import { sourceLabelFor } from '../../utils/placeSource'
import { useLocationBias } from '../../hooks/useLocationBias'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -56,36 +57,19 @@ interface PlaceFormModalProps {
/**
* Which index a search result came from, as a short mark beside it.
* One row of the typed-ahead list, as the server sends it.
*
* A place list can be two indexes interleaved, so the source belongs on the row
* rather than above the list: with the TREK index and OpenStreetMap answering
* together, "one of these came from somewhere" is not an answer anyone can use.
*
* The three names are proper nouns, so they are not translated, and that is also
* why there is no fourth: a source without a name people already know would need
* a string in 23 languages to say less than nothing.
* `source`, `lat` and `lng` are optional because not every index fills them:
* Google answers with neither, and the mark falls back to the name the whole
* list carries.
*/
const SOURCE_LABELS: Record<string, string> = {
'trek-places': 'TREK',
openstreetmap: 'OpenStreetMap',
nominatim: 'OpenStreetMap',
google: 'Google',
}
/**
* The label for one row.
*
* A place carries its own source when the index that produced it says so, which
* is what makes an interleaved list readable. Everything else falls back to what
* answered the call: Google never marks its places, and a merged list marks only
* the index side, so an unmarked row in one is OpenStreetMap by elimination.
*/
function sourceLabelFor(place: unknown, listSource: string): string | null {
const own = (place as { source?: unknown } | null)?.source
if (typeof own === 'string' && own) return SOURCE_LABELS[own] ?? null
if (listSource.includes('openstreetmap')) return SOURCE_LABELS.openstreetmap
return SOURCE_LABELS[listSource] ?? null
type Suggestion = {
placeId: string
mainText: string
secondaryText: string
source?: string
lat?: number
lng?: number
}
/** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */
@@ -161,10 +145,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const [pendingFiles, setPendingFiles] = useState([])
const fileRef = useRef(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
// Which index answered the last keystroke. One call is served by one source,
// so the whole list carries it; the search below is per place, because that
// list can be two indexes interleaved.
const [acSuggestions, setAcSuggestions] = useState<Suggestion[]>([])
// Which index answered the last keystroke, for the rows that do not say so
// themselves. Google and the OpenStreetMap fallback each answer from one
// place; the index path answers from two at once and marks every row.
const [acSource, setAcSource] = useState<string>('')
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -420,7 +404,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
setMapsSearch('')
}
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
const handleSelectSuggestion = async (suggestion: Suggestion) => {
// Read before the list is cleared: this is the rank the user saw.
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
const acCount = acSuggestions.length
@@ -448,6 +432,21 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
} catch (err) {
console.error('Failed to fetch place details:', err)
}
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
// The layer's rows carry no address; their second line is the name
// written on the building. Searching for "Tokio Hauptbahnhof, 東京駅"
// is not a question anybody asked, and its first answer would be
// whatever the index made of it — a different place, chosen silently.
// The suggestion already knows where it is, so use that.
place = {
name: suggestion.mainText,
address: '',
lat: suggestion.lat,
lng: suggestion.lng,
osm_id: suggestion.placeId,
source: 'openstreetmap',
}
}
if (!place) {
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(query, language, locationBiasPoint)
@@ -288,6 +288,113 @@ describe('Filter tabs', () => {
expect(screen.getByText('Planned Place')).toBeInTheDocument();
expect(screen.queryByText('Unplanned Place')).not.toBeInTheDocument();
});
// ── The open day narrows the pool, and says so ──────────────────────────────
//
// The map has followed the selected day on this filter since #2024 while the list
// did not, so a trip with everything planned read "55" in the pool beside five pins
// on the map. Reported twice in the same Discord thread as the map being broken.
it('FE-PLANNER-SIDEBAR-052: with a day open, "Planned" shows that day, not the whole trip', () => {
const today = buildPlace({ id: 91, name: 'On This Day' });
const otherDay = buildPlace({ id: 92, name: 'On Another Day' });
const assignments = {
'1': [buildAssignment({ place: today, day_id: 1 })],
'2': [buildAssignment({ place: otherDay, day_id: 2 })],
};
seedStore(useTripStore, { placesFilter: 'planned' });
render(
<PlacesSidebar
{...defaultProps}
places={[today, otherDay]}
assignments={assignments}
days={[{ id: 1 }, { id: 2 }] as never}
selectedDayId={1}
/>,
);
expect(screen.getByText('On This Day')).toBeInTheDocument();
expect(screen.queryByText('On Another Day')).not.toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-053: without a day open it is the whole trip again', () => {
const dayOne = buildPlace({ id: 91, name: 'On This Day' });
const dayTwo = buildPlace({ id: 92, name: 'On Another Day' });
const assignments = {
'1': [buildAssignment({ place: dayOne, day_id: 1 })],
'2': [buildAssignment({ place: dayTwo, day_id: 2 })],
};
seedStore(useTripStore, { placesFilter: 'planned' });
render(
<PlacesSidebar
{...defaultProps}
places={[dayOne, dayTwo]}
assignments={assignments}
days={[{ id: 1 }, { id: 2 }] as never}
selectedDayId={null}
/>,
);
expect(screen.getByText('On This Day')).toBeInTheDocument();
expect(screen.getByText('On Another Day')).toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-054: the note only appears while the day is actually narrowing', () => {
const today = buildPlace({ id: 91, name: 'On This Day' });
const assignments = { '1': [buildAssignment({ place: today, day_id: 1 })] };
const onClearSelectedDay = vi.fn();
seedStore(useTripStore, { placesFilter: 'planned' });
const { rerender } = render(
<PlacesSidebar
{...defaultProps}
places={[today]}
assignments={assignments}
days={[{ id: 1 }] as never}
selectedDayId={1}
onClearSelectedDay={onClearSelectedDay}
/>,
);
expect(screen.getByText('Showing the open day only')).toBeInTheDocument();
// Dismissing it asks for the day to be closed rather than changing the filter.
fireEvent.click(screen.getByLabelText('Show the whole trip'));
expect(onClearSelectedDay).toHaveBeenCalledTimes(1);
// No day open, nothing narrowed, no note.
rerender(
<PlacesSidebar
{...defaultProps}
places={[today]}
assignments={assignments}
days={[{ id: 1 }] as never}
selectedDayId={null}
onClearSelectedDay={onClearSelectedDay}
/>,
);
expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-055: "Unplanned" stays trip-wide while a day is open', () => {
// A place assigned to some other day is planned, whichever day happens to be open,
// so it must not reappear in the unplanned pool.
const otherDay = buildPlace({ id: 92, name: 'On Another Day' });
const loose = buildPlace({ id: 93, name: 'Not Planned At All' });
const assignments = { '2': [buildAssignment({ place: otherDay, day_id: 2 })] };
seedStore(useTripStore, { placesFilter: 'unplanned' });
render(
<PlacesSidebar
{...defaultProps}
places={[otherDay, loose]}
assignments={assignments}
days={[{ id: 1 }, { id: 2 }] as never}
selectedDayId={1}
/>,
);
expect(screen.getByText('Not Planned At All')).toBeInTheDocument();
expect(screen.queryByText('On Another Day')).not.toBeInTheDocument();
expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument();
});
});
// ── Search ────────────────────────────────────────────────────────────────────
@@ -1,4 +1,4 @@
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus } from 'lucide-react'
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus, CalendarDays } from 'lucide-react'
import { getCategoryIcon } from '../shared/categoryIcons'
import Tooltip from '../shared/Tooltip'
import { useElementSize } from '../../hooks/useElementSize'
@@ -32,7 +32,7 @@ export function PlacesDropOverlay({ t }: SidebarState) {
export function PlacesHeader(S: SidebarState) {
const {
canEditPlaces, onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks,
places, categories, categoryFilters, search, setSearch, plannedIds, plannedFilterIds, dayScoped, onClearSelectedDay, hasTracks,
filter, setFilter, setSelectedIds, selectMode, setSelectMode,
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters,
ratingFilter, setRatingFilter,
@@ -163,7 +163,11 @@ export function PlacesHeader(S: SidebarState) {
const counts = {
all: baseFiltered.length,
unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
planned: baseFiltered.filter(p => plannedIds.has(p.id)).length,
// While a day is open this counts that day's plan, the same set the list and
// the map show. Counting the whole trip here is what made the tab read 55
// beside five pins, with nothing to say the two were answering different
// questions.
planned: baseFiltered.filter(p => plannedFilterIds.has(p.id)).length,
tracks: baseFiltered.filter(p => p.route_geometry).length,
}
const tabs = ([
@@ -211,6 +215,42 @@ export function PlacesHeader(S: SidebarState) {
)
})()}
{/* Says out loud what the count above already narrowed to.
The map has followed the open day on this filter since #2024, and until now
nothing anywhere said so: the pool read 55, the map drew five, and the honest
conclusion was that the map was broken. It sits under the tabs rather than on
the map because a chip over the canvas is unreachable on a phone, which is
where this was reported from. */}
{dayScoped && (
<div
className="border border-edge-faint bg-surface-tertiary text-content-secondary"
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px 4px 9px', borderRadius: 8, marginBottom: 8,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
}}
>
<CalendarDays size={11} strokeWidth={2} style={{ flexShrink: 0 }} />
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t('places.dayScoped')}
</span>
{onClearSelectedDay && (
<button type="button"
onClick={onClearSelectedDay}
aria-label={t('places.dayScopedClear')}
title={t('places.dayScopedClear')}
className="text-content-faint hover:text-content"
style={{
marginInlineStart: 'auto', display: 'flex', alignItems: 'center',
background: 'none', border: 'none', padding: 3, cursor: 'pointer', flexShrink: 0,
}}
>
<X size={12} strokeWidth={2.2} />
</button>
)}
</div>
)}
{/* Suchfeld */}
<div style={{ position: 'relative' }}>
<Search size={13} strokeWidth={1.8} color="var(--text-faint)" style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }} />
@@ -15,7 +15,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { safeHttpUrl } from '../../utils/safeUrl'
import { plannedPlaceIds, type PlannedAccommodation } from '../../utils/plannedPlaces'
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
/** Stable identity — a fresh [] default would invalidate the planned memo on every render. */
const NO_ACCOMMODATIONS: PlannedAccommodation[] = []
@@ -40,6 +40,12 @@ export interface PlacesSidebarProps {
* through the day picker.
*/
onAddPlaceToSelectedDay?: () => void
/**
* Close the open day, from the note that says the pool is showing only that day.
* Absent leaves the note without its dismiss, which is what the mobile day picker
* wants: there the day is closed by the picker itself.
*/
onClearSelectedDay?: () => void
onAssignToDay: (placeId: number, dayId: number) => void
onEditPlace: (place: Place) => void
onDeletePlace: (placeId: number) => void
@@ -60,7 +66,7 @@ export interface PlacesSidebarProps {
*/
export function usePlacesSidebar(props: PlacesSidebarProps) {
const {
tripId, places, assignments, selectedDayId, accommodations = NO_ACCOMMODATIONS,
tripId, places, assignments, selectedDayId, days, accommodations = NO_ACCOMMODATIONS,
pushUndo, initialScrollTop, onScrollTopChange,
} = props
const { t } = useTranslation()
@@ -238,10 +244,30 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
[assignments, accommodations, reservations],
)
/**
* What "planned" means while a day is open: that day's plan, not the whole trip's.
*
* The map has narrowed to the selected day since #2024, and the list did not, which is
* how a trip with 55 planned places showed 55 in the pool and five pins on the map with
* nothing to explain the gap — read, reasonably, as the map being broken. The list now
* follows the map, and `dayScoped` tells the header to say so.
*
* Only "planned" narrows. "Unplanned" stays trip-wide on purpose: a place assigned to
* some other day is planned, whichever day happens to be open.
*/
const plannedInDayIds = useMemo(
() => (selectedDayId
? plannedPlaceIdsForDay(selectedDayId, days, { assignments, accommodations, reservations })
: null),
[selectedDayId, days, assignments, accommodations, reservations],
)
const plannedFilterIds = plannedInDayIds ?? plannedIds
const dayScoped = filter === 'planned' && plannedInDayIds !== null
const filtered = useMemo(() => {
const list = places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (filter === 'planned' && !plannedIds.has(p.id)) return false
if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false
if (filter === 'tracks' && !p.route_geometry) return false
if (categoryFilters.size > 0) {
if (p.category_id == null) {
@@ -254,7 +280,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
return true
})
return list
}, [places, filter, categoryFilters, search, plannedIds, ratingFilter])
}, [places, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter])
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
if (element) {
@@ -322,7 +348,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
markSelectionVisited, markVisitedBusy,
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
hasTracks, plannedIds, plannedFilterIds, dayScoped, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
}
}
@@ -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,84 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
/**
* A small panel hung under something in the rail.
*
* The rail has more than one badge worth opening — what kind of stop this is, how full it
* fills — and every one of them needs the same four things: measure before showing,
* flip above when the window is short, close on an outside click or Escape, and close on
* a scroll rather than chase the anchor. Each is one line to get wrong and none of them
* is about what the panel contains, which is why they live here once instead of beside
* every panel that needs them.
*
* Deliberately not the `CustomSelect` machinery: that one is a select, with a value, a
* search and a keyboard model. This is a surface.
*/
export default function AnchoredPopover({ anchor, label, children, onClose }: {
/** The element the panel hangs under. Null renders nothing, which is how it stays closed. */
anchor: HTMLElement | null
/** What the dialog is called, for anyone who cannot see where it is hanging. */
label: string
children: React.ReactNode
onClose: () => void
}): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
// Measured after paint, before the browser shows it: reading the size first and then
// placing it is what stops the panel appearing at 0,0 for one frame.
useLayoutEffect(() => {
if (!anchor || !ref.current) return
const a = anchor.getBoundingClientRect()
const p = ref.current.getBoundingClientRect()
const gap = 8
let top = a.bottom + gap
// Flips above when there is no room below, which is most of the rail on a short
// window: a panel clipped by the viewport is one that cannot be used at all.
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
// Centred on the column, not on the anchor. A badge sits hard against one edge of the
// rail, so a panel centred on it hangs half off the sidebar and points at the map.
// Vertically it still follows the row it belongs to.
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
const left = Math.min(
Math.max(8, column.left + column.width / 2 - p.width / 2),
window.innerWidth - p.width - 8,
)
setPos({ top, left })
}, [anchor])
useEffect(() => {
if (!anchor) return
const onDown = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (anchor.contains(e.target as Node)) return
onClose()
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
// Any scroll moves the anchor out from under the panel, and following it would mean
// measuring on every frame for a menu that is open for two seconds.
window.addEventListener('scroll', onClose, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onClose, true)
}
}, [anchor, onClose])
if (!anchor) return null
return createPortal(
<div
ref={ref}
role="dialog"
aria-label={label}
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
>
{children}
</div>,
document.body,
)
}
@@ -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 />
@@ -5,7 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import RoadtripLimitsCard from './RoadtripLimitsCard'
/**
* FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning.
* FE-ROADTRIP-LIMITS-001..012 — the numbers that decide every warning.
*
* The one that matters is when a number is written. These fields had no local
* state, so every keystroke was a settings PUT: typing "180" sent three, and
@@ -13,14 +13,26 @@ 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'))
}
/** The three inputs, in the order the dialog lists them. */
/** 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: {
@@ -127,4 +139,125 @@ describe('RoadtripLimitsCard', () => {
// handler to call, and must not assume one.
expect(() => fireEvent.blur(leg)).not.toThrow()
})
it('FE-ROADTRIP-LIMITS-009: naming the kind of car brings out its own figures', () => {
useSettingsStore.setState({
settings: { roadtrip_vehicle: 'electric', distance_unit: 'metric' } as never,
})
open(vi.fn())
// 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()
})
it('FE-ROADTRIP-LIMITS-010: a complete pair takes the range field over', () => {
useSettingsStore.setState({
settings: {
roadtrip_vehicle: 'electric',
roadtrip_battery_kwh: 58,
roadtrip_kwh_per_100: 16,
// Deliberately different from what the figures work out to: the point of
// the row going read-only is that there is no longer a second answer.
roadtrip_range_km: 500,
distance_unit: 'metric',
} as never,
})
open(vi.fn())
expect(screen.getByTestId('limit-derived')).toHaveTextContent('363')
// Leg, day, battery, consumption, wear, fill — the range input is gone.
expect(inputs()).toHaveLength(6)
})
it('FE-ROADTRIP-LIMITS-011: gallons typed by an imperial reader are stored as litres', () => {
useSettingsStore.setState({
settings: { roadtrip_vehicle: 'combustion', distance_unit: 'imperial' } as never,
})
const onSave = vi.fn()
open(onSave)
// Leg, day, tank, consumption, range, fill.
openSpec()
const tank = screen.getByTestId('limit-tankLitres') as HTMLInputElement
fireEvent.change(tank, { target: { value: '15' } })
fireEvent.blur(tank)
// 15 US gallons, not 15 litres. Storing the number as typed would have made
// every fuel warning fire at a quarter of the right distance.
expect(onSave).toHaveBeenCalledWith('roadtrip_tank_litres', 56.78)
})
it('FE-ROADTRIP-LIMITS-012: the range answers while the figure is still being typed', () => {
useSettingsStore.setState({
settings: {
roadtrip_vehicle: 'electric',
roadtrip_battery_kwh: 58,
roadtrip_kwh_per_100: 16,
distance_unit: 'metric',
} as never,
})
const onSave = vi.fn()
open(onSave)
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
// is the argument that these figures ARE the range, and it only makes it if it
// answers now rather than after the field is left.
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,28 +1,38 @@
import React, { useState } from 'react'
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react'
import {
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship,
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
} from 'lucide-react'
import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { convertDistance, formatDistance } from '../../utils/units'
import { formatDurationShort } from './roadtripModel'
import type { DistanceUnit } from '../../types'
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'
import { FS } from './typeScale'
/**
* The three numbers that decide when the rail speaks up about the driving.
* Everything that decides when the rail speaks up about the driving.
*
* Behind a button rather than laid out in the column, because this is set once and then
* read never: a permanent card costs three rows of a narrow sidebar for something most
* read never: a permanent card costs rows of a narrow sidebar for something most
* travellers touch on the first day of planning and leave alone after. The button carries
* the current answer, so what is set stays visible without the form being.
* the current answers, so what is set stays visible without the form being.
*
* All three are personal rather than instance configuration: how long somebody is willing
* to sit behind a wheel, and how far their car goes on a tank, belong to the traveller.
* They are plain per-user settings, which is why this needs no migration.
* All of it is personal rather than instance configuration: how long somebody is willing
* to sit behind a wheel, and what their car is, belong to the traveller. They are plain
* per-user settings, which is why this needs no migration.
*
* Nothing here suggests a stop, adds one, or knows anything about a vehicle. OSM has no
* idea what car this is, and neither has TREK, so the range is a number the traveller
* types. Furkot does exactly the same, for the same reason.
* The dialog has two halves because the settings are two kinds. The left is the trip: how
* long a day may be and what the road may not be. The right is the car, and it is the
* half that earns the width — a range can be typed as one number or worked out from what
* the vehicle is made of, and the gauge shows the second turning into the first as it is
* typed. Nothing here asks the router about a vehicle; OSM has no idea what car this is,
* and neither has TREK.
*/
/** Empty means no limit, and so does zero — both are stored as 0 and read as off. */
@@ -31,12 +41,38 @@ function parseLimit(raw: string): number {
return Number.isFinite(n) && n > 0 ? n : 0
}
function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
/**
* One number in the form, or one number the form worked out.
*
* `derived` turns the row read-only and states where the figure came from. The row keeps
* 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, testId, onDraft, onChange }: {
icon: typeof Clock
label: string
suffix: string
value: number | undefined
placeholder: string
step?: number
derived?: string
/**
* A stable handle for the tests, because the rows are no longer at fixed indices.
*
* The vehicle figures now live behind a disclosure, so "the fourth number field" means
* different things depending on whether it is open.
*/
testId?: string
/**
* What is in the field right now, on every keystroke, saved or not.
*
* Separate from `onChange` because the two answer different questions. `onChange` is
* "this is the new setting", which must stay rare — it is an HTTP write. This one is
* "this is what the traveller is looking at", which the gauge needs on every keystroke
* or it would sit still until the field is left and the promise it makes, that these
* figures are the range, would only pay out after the fact.
*/
onDraft?: (next: number) => void
onChange: (next: number) => void
}): React.ReactElement {
// Typed here, saved on blur or Enter. Writing on every keystroke sent one
@@ -54,59 +90,279 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
}
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>
<input
type="number"
inputMode="numeric"
min={0}
value={shown}
placeholder={placeholder}
onChange={e => setDraft(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="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="w-7 text-caption text-content-faint">{suffix}</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>
</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>
)
}
/**
* One class the drive should leave out, with the switch that asks for it.
*
* Disabled rather than hidden when there is no engine that can answer: an operator who
* pointed the instance at their own OSRM has no second engine, and a switch that flips
* and changes nothing is worse than one that says why it cannot.
*/
function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
icon: typeof Coins
label: string
on: boolean
disabled: boolean
onToggle: () => void
}): React.ReactElement {
return (
<div className={`flex items-center gap-3 ${disabled ? 'opacity-50' : ''}`}>
<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>
{disabled
? <span className="text-caption text-content-faint">{'—'}</span>
: <ToggleSwitch on={on} onToggle={onToggle} label={label} />}
</div>
)
}
/**
* 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
}): React.ReactElement {
return (
<p className="flex items-center gap-2 text-caption font-semibold uppercase tracking-wide text-content-faint">
<Icon size={13} aria-hidden />
{children}
</p>
)
}
/** 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 },
{ key: 'combustion', labelKey: 'roadtrip.limit.vehicleCombustion', Icon: Fuel },
{ key: 'electric', labelKey: 'roadtrip.limit.vehicleElectric', Icon: Zap },
]
/**
* What each kind of car is made of, and where each figure is kept.
*
* Two rows for petrol and three for electric, because that is the difference that
* matters: a tank does not lose capacity to the years and a battery does. Everything else
* ABRP asks for — plug type, reference speed, relative speed, drive style — shapes a
* consumption PREDICTION. This dialog does not predict one; it divides a capacity by a
* consumption the traveller states, so those fields would add typing without moving a
* single kilometre of the answer.
*/
const SPEC_ROWS: Record<VehicleKind, { key: SpecKey; setting: string; Icon: typeof Fuel; labelKey: string; step: number }[]> = {
combustion: [
{ key: 'tankLitres', setting: 'roadtrip_tank_litres', Icon: Fuel, labelKey: 'roadtrip.limit.tankLabel', step: 1 },
{ key: 'litresPer100', setting: 'roadtrip_litres_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 },
],
electric: [
{ key: 'batteryKwh', setting: 'roadtrip_battery_kwh', Icon: BatteryFull, labelKey: 'roadtrip.limit.batteryLabel', step: 1 },
{ key: 'kwhPer100', setting: 'roadtrip_kwh_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 },
{ key: 'degradationPercent', setting: 'roadtrip_battery_degradation', Icon: BatteryWarning, labelKey: 'roadtrip.limit.wearLabel', step: 1 },
],
}
/**
* 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. */
onSave?: (key: string, value: number) => void
/**
* Persists one setting. Absent leaves the dialog read-only.
*
* 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 | boolean) => void
}): React.ReactElement {
const { t } = useTranslation()
const settings = useSettingsStore(s => s.settings)
const [open, setOpen] = useState(false)
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
const imperial = distanceUnit === 'imperial'
const legMinutes = settings.roadtrip_leg_minutes
const dayMinutes = settings.roadtrip_day_minutes
const rangeKm = settings.roadtrip_range_km
// Parsed rather than trusted: a per-user setting gets no server-side validation, and
// an unknown word here would become a costing option the router does not have.
const vehicle = settings.roadtrip_vehicle ?? ''
const vehicleKind: VehicleKind | null =
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
const saved: VehicleSpec = {
tankLitres: settings.roadtrip_tank_litres,
litresPer100: settings.roadtrip_litres_per_100,
batteryKwh: settings.roadtrip_battery_kwh,
kwhPer100: settings.roadtrip_kwh_per_100,
degradationPercent: settings.roadtrip_battery_degradation,
}
/**
* The one field being typed in, ahead of the write that will save it.
*
* One slot rather than a copy of the form, because only one field can be typed in at a
* time, and a second copy of the settings is exactly the hand-mirrored state this
* codebase refuses everywhere else. It exists so the gauge answers while the traveller
* is still typing: the figures below it are only convincingly "the range" if changing
* one moves the bar, and a settings PUT per keystroke is not on offer.
*
* The value arrives in whatever unit the field is labelled with, so it goes back
* 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
const typedKm = preview?.key === 'range'
? (imperial ? preview.value / 0.621371 : preview.value)
: rangeKm
const shownFill = preview?.key === 'fill' ? preview.value : settings.roadtrip_fill_percent ?? null
// What the parts say, and what the trip will actually plan with. The second is the
// first when there is one, which is the rule effectiveRangeKm states once for
// everybody who needs it.
const computedKm = rangeFromSpec(vehicleKind, spec)
const planningKm = effectiveRangeKm(vehicleKind, spec, typedKm)
const avoiding = parseAvoid(settings.roadtrip_avoid)
// No second engine, no avoidance. An instance pointed at its own OSRM has one, and its
// car profile is built without excludable classes on every public host.
const canAvoid = valhallaAvailable()
const toggleAvoid = (cls: RouteAvoidClass) => {
const next = avoiding.includes(cls) ? avoiding.filter(c => c !== cls) : [...avoiding, cls]
onSave?.('roadtrip_avoid', serializeAvoid(next))
}
const AVOID_ROWS: { cls: RouteAvoidClass; icon: typeof Coins; label: string }[] = [
{ cls: 'toll', icon: Coins, label: t('roadtrip.avoid.toll') },
{ cls: 'motorway', icon: Signpost, label: t('roadtrip.avoid.motorway') },
{ cls: 'ferry', icon: Ship, label: t('roadtrip.avoid.ferry') },
]
// Kilometres in storage, the traveller's own unit on screen. Without the round trip an
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
// third too early for ever after.
const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, distanceUnit)) : undefined
// third too early for ever after. The same rule runs through the vehicle's own figures,
// where showSpec and storeSpec do it per field.
const rangeShown = planningKm ? Math.round(convertDistance(planningKm, distanceUnit)) : undefined
const setRange = (shown: number) => {
const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown
const km = imperial ? shown / 0.621371 : shown
onSave?.('roadtrip_range_km', Math.round(km))
}
// What is set, on the button itself, so the form does not have to be open to read it.
// Each one keeps its own icon, because three numbers in a row say nothing about which
// is which: a clock, a calendar and a pump do. Distances go through formatDistance, so
// an imperial traveller reads miles here and types miles in the dialog, while what is
// Each one keeps its own icon, because numbers in a row say nothing about which is
// which: a clock, a calendar and a pump do. Distances go through formatDistance, so an
// imperial traveller reads miles here and types miles in the dialog, while what is
// stored stays kilometres either way.
const badges = [
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
// The planning range, not the typed one: when the vehicle's figures win, the badge
// has to say what the warnings will actually use.
planningKm ? { key: 'range', Icon: vehicleKind === 'electric' ? Zap : Fuel, text: formatDistance(planningKm, distanceUnit) } : null,
// Only worth a badge when it is NOT a full tank, which is the case that changes the
// arithmetic. "100 %" on the trigger would be a badge for the default.
settings.roadtrip_fill_percent && settings.roadtrip_fill_percent < 100
? { key: 'fill', Icon: BatteryCharging, text: `${settings.roadtrip_fill_percent} %` }
: null,
// The avoidance rides here too, and as ONE badge rather than three: it is a single
// decision, the three icons already say which parts of it are on, and a fourth,
// fifth and sixth badge would wrap the row onto a second line in a narrow rail.
avoiding.length && canAvoid
? { key: 'avoid', Icon: AVOID_ROWS.find(r => r.cls === avoiding[0])!.icon, text: t('roadtrip.avoid.badge', { count: avoiding.length }) }
: null,
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
return (
@@ -120,7 +376,7 @@ export default function RoadtripLimitsCard({ onSave }: {
onClick={() => setOpen(true)}
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{/* No icon tile on the trigger: the three badges below already carry a clock, a
{/* No icon tile on the trigger: the badges below already carry a clock, a
calendar and a pump, and a fourth mark in front of the title only ate the width
they need to sit on one line. */}
<span className="flex min-w-0 flex-1 flex-col gap-1">
@@ -156,43 +412,213 @@ export default function RoadtripLimitsCard({ onSave }: {
</button>
{open ? (
<Modal isOpen onClose={() => setOpen(false)} size="sm" 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>
}>
<div className="flex flex-col gap-4">
<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)}
/>
<LimitRow
icon={Fuel}
label={t('roadtrip.limit.rangeLabel')}
suffix={distanceUnit === 'imperial' ? 'mi' : 'km'}
value={rangeShown}
placeholder={t('roadtrip.limit.off')}
onChange={setRange}
/>
{/* 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 text-content-faint">{t('roadtrip.limit.hint')}</p>
{/* 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.
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
icon={Link2}
label={t('roadtrip.line.connect')}
on={!!settings.roadtrip_connect_days}
disabled={false}
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
/>
{/* 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)}
/>
</Panel>
</div>
<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.
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'}
/>
{/* 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}
@@ -5,6 +5,9 @@ import { TranslationProvider } from '../../i18n'
import RoadtripSidebar from './RoadtripSidebar'
import RoadtripModeSwitch from './RoadtripModeSwitch'
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { DryPoint } from './roadtripModel'
import type { RefuelSearch } from './useRefuelSearch'
import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion'
import type { RouteSegment } from '../../types'
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
@@ -12,6 +15,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 +39,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 +52,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 +66,7 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
return {
days: [day()],
lines: [],
lineDays: [],
segments: [],
accessLines: [],
vias: [],
@@ -201,11 +217,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 +244,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()
})
@@ -302,6 +324,23 @@ describe('RoadtripSidebar', () => {
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
})
it('FE-ROADTRIP-SIDEBAR-034: a pause reached late says so, the same as a numbered stop', () => {
// The schedule restarts its chain at any pinned time, whatever kind of stop carries
// it, so it computes the finding for a fuel halt too. The rail used to hand `late`
// only to the numbered branch and throw the pause's own away.
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', dwellMinutes: 15 }),
]
const schedule = {
entries: stops.map(() => ({ arrival: '09:00', departure: '09:15', anchored: true, dayOffset: 0 })),
warnings: [{ index: 1, code: 'late' as const, minutes: 45 }],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule, legs: [leg()] })] })} />)
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
@@ -540,6 +579,443 @@ 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()
})
})
/**
* The band where the fuel actually ends, and the one search offered from it.
*
* It hangs off the leg rather than off a stop, because those are two different places:
* the range warning marks where somebody finds out, and that can be a long way past the
* point the tank ran dry. A station offered at the warning is one the car cannot reach.
*/
describe('the leg the tank runs out on', () => {
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
legIndex: 0,
intoLegKm: 182,
drivenMeters: 182000,
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
// traveller's own setting, so a band printing it would read the same figure on
// every leg of the day.
sinceKm: 600,
lat: 52.4,
lng: 10.2,
...over,
})
/** Idle unless a case says which part of the search it is standing in. */
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
openFor: null,
loading: false,
outcome: null,
results: [],
offered: [],
ask: vi.fn(),
close: vi.fn(),
...over,
})
const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
osm_id: `osm-${name}`,
name,
lat: 52.4,
lng: 10.1,
category: 'fuel',
poi_type: 'fuel',
address: null,
website: null,
phone: null,
opening_hours: null,
cuisine: null,
source: 'openstreetmap',
alongKm: 170,
offRouteKm,
spareKm,
})
const empties = dry({ legIndex: 1 })
// Three stops, so the leg that empties has one it is not on to be absent from.
const thirsty = (): RoadtripDay => day({
stops: [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Hannover' }),
stop({ assignmentId: 3, name: 'Kassel' }),
],
legs: [leg(), leg()],
dryPoints: [empties],
})
it('FE-ROADTRIP-SIDEBAR-035: the band sits on the leg the fuel ends on, and says how far into it', () => {
const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [thirsty()], totalStops: 3 })} refuel={search()} />,
)
// The second row owns the second leg. Drawn on every row instead, the rail would
// offer a fill-up on a stretch the car drives with a full tank.
const rows = container.querySelectorAll('li')
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
expect(within(rows[1] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
// Distance into the leg, which is where a drive band keeps its figures. The 600 is
// the setting that was crossed and belongs to no place on the map.
expect(screen.getByText('after 182 km')).toBeInTheDocument()
expect(screen.queryByText(/600 km/)).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-036: the low-fuel lamp is itself the button that goes looking', () => {
const onAskRefuel = vi.fn()
wrap(
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search()}
onAskRefuel={onAskRefuel}
/>,
)
// Not a lamp with a magnifier beside it, which says the same thing twice: the lamp
// reports the empty tank and pressing it is what does something about it, so the
// lamp has to be inside the button rather than next to one.
const ask = screen.getByRole('button', { name: 'Find fuel' })
expect(ask.querySelector('svg.lucide-fuel')).toBeInTheDocument()
expect(within(ask).queryByRole('button')).toBeNull()
fireEvent.click(ask)
// The dry point travels with the ask: the search is a circle pulled back from that
// coordinate, and the day alone does not say where on it to look.
expect(onAskRefuel).toHaveBeenCalledWith(1, empties)
})
it('FE-ROADTRIP-SIDEBAR-037: a band inside a borrowed stretch is filed under the card it is drawn on', () => {
const onAskRefuel = vi.fn()
const overnight = dry({ legIndex: 0 })
const card = day({
dayNumber: 2,
stops: [
stop({ assignmentId: 1, name: 'Neuruppin', ownerDayId: 9, ownerIndex: 3 }),
stop({ assignmentId: 2, name: 'Wittenberg' }),
],
dryPoints: [overnight],
spills: [{
at: 0,
count: 1,
fromDayNumber: 1,
departure: '22:30',
leg: leg(),
line: [[53, 10], [52, 11]],
fromStop: stop({ assignmentId: 99, name: 'Circle K', ownerDayId: 9, ownerIndex: 2 }),
}],
})
const { unmount } = wrap(
<RoadtripSidebar
routes={routes({ days: [card] })}
refuel={search({ openFor: '1:0', loading: true })}
onAskRefuel={onAskRefuel}
/>,
)
// Everything that WRITES names the day a stop is stored on, day 9 here. This is the
// exception and it has to be: the open search is filed under the card day and the
// leg, so a band asking as day 9 would have its answers arrive under a key no band
// is watching and would never open. That it opened at all is the assertion.
expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
unmount()
wrap(
<RoadtripSidebar
routes={routes({ days: [card] })}
refuel={search()}
onAskRefuel={onAskRefuel}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Find fuel' }))
expect(onAskRefuel).toHaveBeenCalledWith(1, overnight)
})
it('FE-ROADTRIP-SIDEBAR-038: while the request is in flight the lamp steps aside for the way out', () => {
const close = vi.fn()
wrap(
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search({ openFor: '1:1', loading: true, close })}
/>,
)
expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
// Gone, not merely quieter. Every press is a real request against a shared service,
// and a lamp still standing there invites a second one nobody asked for.
expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(close).toHaveBeenCalled()
})
it('FE-ROADTRIP-SIDEBAR-039: an answer carries the detour and what is left in the tank as two figures', () => {
const onAcceptRefuel = vi.fn()
const shell = pump('Shell Hannover', 1.4, 48.6)
wrap(
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search({ openFor: '1:1', outcome: 'found', results: [shell] })}
onAcceptRefuel={onAcceptRefuel}
/>,
)
// Two facts about two different things: the detour is what the stop costs and what
// the list is sorted by, the spare is what is left when the car draws level. Joined
// into one line with a separator, neither exact lookup finds anything.
expect(screen.getByText('1.4 km')).toBeInTheDocument()
expect(screen.getByText('49 km')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add Shell Hannover as a fuel stop' }))
// With the dry point, because the caller has no other way of knowing which leg the
// accepted stop belongs on.
expect(onAcceptRefuel).toHaveBeenCalledWith(1, shell, empties)
})
it('FE-ROADTRIP-SIDEBAR-040: a search that came back with nothing says which nothing it was', () => {
const answered = (outcome: RefuelOutcome) => (
<TranslationProvider>
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search({ openFor: '1:1', outcome })}
/>
</TranslationProvider>
)
const { rerender } = render(answered('none'))
// Only the first of these is a statement about the road. Telling somebody there is
// nothing on a stretch that was never fully checked, or never checked at all, is
// worse than saying nothing.
expect(screen.getByText(/Nothing found/)).toBeInTheDocument()
rerender(answered('incomplete'))
expect(screen.getByText(/cut short/)).toBeInTheDocument()
rerender(answered('failed'))
expect(screen.getByText(/did not answer/)).toBeInTheDocument()
// And a way to ask again rather than a dead end: the place search is a shared
// service that does time out, and no answer with no retry reads as broken.
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-041: the stop stops repeating what the band already says better', () => {
// The warning marks the stop somebody finds out at and counts the whole tank; the
// band sits where the fuel ends and offers a way out. Both at once prints the answer
// above the problem.
const warned = (): RoadtripDay => ({
...thirsty(),
driveWarnings: [{ index: 2, code: 'range', sinceKm: 640 }],
})
const { unmount } = wrap(
<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} refuel={search()} />,
)
expect(screen.queryByText('640 km')).toBeNull()
unmount()
// With no search to run there is nothing better, so the finding is all there is and
// has to stay.
wrap(<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} />)
expect(screen.getByText('640 km')).toBeInTheDocument()
})
})
/**
* Folding a day down to its header.
*
* The whole header is the control and the hover is the only sign of it: a chevron or a
* fold label sitting among the day's own facts reads as a fourth fact about the day
* rather than as something to press. Nothing on screen says which way it stands, so the
* header has to say it to a screen reader.
*/
describe('a day folded down to its header', () => {
it('FE-ROADTRIP-SIDEBAR-042: the header is the control, and it folds by id rather than by number', () => {
const onToggleDay = vi.fn()
// Id and number deliberately apart. The card is titled by its number and folded by
// its id, and on a trip whose days were not created in order those two differ —
// mixing them up folds a card the reader did not click.
wrap(<RoadtripSidebar routes={routes({ days: [day({ dayId: 42 })] })} onToggleDay={onToggleDay} />)
const header = screen.getByRole('button', { name: /Day 1/ })
expect(header).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(header)
expect(onToggleDay).toHaveBeenCalledWith(42)
})
it('FE-ROADTRIP-SIDEBAR-043: folded, the stops are gone and the header is what is left', () => {
// Folded by id, again with a card whose id is not its number: a set read as numbers
// would fold the card titled "Day 42" here, or nothing at all.
wrap(<RoadtripSidebar
routes={routes({ days: [day({ dayId: 42 }), day({ dayId: 2, dayNumber: 2 })], totalStops: 4 })}
onToggleDay={vi.fn()}
collapsedDayIds={new Set([42])}
/>)
const folded = screen.getByRole('button', { name: /Day 1/ })
expect(folded).toHaveAttribute('aria-expanded', 'false')
// The card keeps its head: a day put away is still one the reader scrolls past, and
// its own figures are how it is found again.
expect(within(folded).getByText('2 stops')).toBeInTheDocument()
expect(screen.getAllByText('Hamburg')[0]).not.toBeVisible()
// One card at a time. Folding is per day, and the id is what decides which.
expect(screen.getByRole('button', { name: /Day 2/ })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getAllByText('Berlin')[1]).toBeVisible()
})
it('FE-ROADTRIP-SIDEBAR-044: the header answers the keys that mean press, and no others', () => {
const onToggleDay = vi.fn()
wrap(<RoadtripSidebar routes={routes()} onToggleDay={onToggleDay} />)
const header = screen.getByRole('button', { name: /Day 1/ })
// A header wearing a button role gets none of a real button's keyboard behaviour, so
// it answers both keys itself. Space has to be swallowed as well as answered, or the
// rail scrolls a page down behind the day that was just put away. fireEvent reports
// a cancelled event as false.
fireEvent.keyDown(header, { key: 'Enter' })
expect(fireEvent.keyDown(header, { key: ' ' })).toBe(false)
expect(onToggleDay).toHaveBeenCalledTimes(2)
fireEvent.keyDown(header, { key: 'ArrowDown' })
expect(onToggleDay).toHaveBeenCalledTimes(2)
})
it('FE-ROADTRIP-SIDEBAR-045: a rail with nothing to fold offers no control at all', () => {
wrap(<RoadtripSidebar routes={routes()} />)
// A viewer gets a heading. A control that does nothing when pressed is worse than
// one that is not there.
expect(screen.queryByRole('button', { name: /Day 1/ })).toBeNull()
expect(screen.getByRole('heading', { level: 3 })).toBeVisible()
expect(screen.getByText('Hamburg')).toBeVisible()
})
})
/**
* How full one stop fills the tank.
*
* A property of the stop rather than of the traveller: the motorway rapid charger is
* worth 80 % because the last fifth costs as long again, the one at the hotel is worth
* all of it. The badge is the only way into that figure anywhere in TREK.
*/
describe('the fill badge on a stop that puts fuel back', () => {
const refuelling = (fillPercent?: number | null): RoadtripDay => day({
stops: [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', fillPercent }),
],
})
it('FE-ROADTRIP-SIDEBAR-046: the badge is the way into a figure that is not there yet', () => {
const onSelectStop = vi.fn()
const onSetStopFill = vi.fn()
wrap(<RoadtripSidebar
routes={routes({ days: [refuelling()] })}
onSelectStop={onSelectStop}
onSetStopFill={onSetStopFill}
/>)
// The plus stands in the slot the number will occupy: a badge that hid itself until
// a figure existed could never be used to make one.
fireEvent.click(screen.getByRole('button', { name: '+' }))
expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
// The whole row is a button too. A click that reached it would select the stop and
// move the map out from under the panel that just opened.
expect(onSelectStop).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '80' }))
expect(onSetStopFill).toHaveBeenCalledWith(20, 80)
expect(screen.queryByRole('dialog')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-047: the badge opens from the keyboard and swallows the key', () => {
wrap(<RoadtripSidebar routes={routes({ days: [refuelling()] })} onSetStopFill={vi.fn()} />)
const badge = screen.getByRole('button', { name: '+' })
// The same span-with-a-role bargain as the header, one row further in: unanswered,
// the only way to the figure is a mouse.
fireEvent.keyDown(badge, { key: 'ArrowDown' })
expect(screen.queryByRole('dialog')).toBeNull()
expect(fireEvent.keyDown(badge, { key: 'Enter' })).toBe(false)
expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-048: only a stop that fills up carries the badge, and its own figure reads as one', () => {
wrap(<RoadtripSidebar routes={routes({ days: [refuelling(100)] })} onSetStopFill={vi.fn()} />)
// An explicit 100 is a decision, not an absent figure: on a trip whose default is 80
// it says this one goes right up, and folding it into the invitation would leave the
// traveller reading 80 on a stop that fills to 100.
expect(screen.getAllByRole('button', { name: '100 %' })).toHaveLength(1)
// Hamburg has none at all. A place the trip is for fills no tank, so a fill figure
// there is a control for a decision nobody makes.
expect(screen.queryByText('+')).toBeNull()
})
})
})
describe('RoadtripModeSwitch', () => {
File diff suppressed because it is too large Load Diff
@@ -140,4 +140,21 @@ describe('RoadtripStayModal', () => {
expect(onSave).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
})
it('FE-STAYMODAL-013: a stay running past midnight says which day it leaves on', () => {
// The clock wraps modulo 24 h, so without the carry this reads "01:00" for a
// departure the chain places on the next day — the rail says +1 beside it and
// the dialog used to say nothing.
open({ minutes: 180, arrival: '22:00' })
expect(screen.getByText('01:00')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('FE-STAYMODAL-014: a stay inside one day carries no day marker', () => {
open({ minutes: 60, arrival: '12:00' })
expect(screen.getByText('13:00')).toBeInTheDocument()
expect(screen.queryByText('+1')).not.toBeInTheDocument()
})
})
@@ -81,6 +81,11 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
return {
arrive: formatClockTime(formatClock(arrival), is12h),
leave: formatClockTime(formatClock(arrival + minutes), is12h),
// `formatClock` wraps modulo 24 h, so a stay that runs past midnight reads as a
// small number again. The rail carries the day it belongs to along with the time;
// without the same carry here the dialog would quietly say "leave 01:00" for a
// stop the chain places on the next day.
leaveCarry: Math.floor((arrival + minutes) / (24 * 60)) - Math.floor(arrival / (24 * 60)),
}
}, [stop?.arrival, minutes, is12h])
@@ -164,7 +169,15 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.leave')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">{times.leave}</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">
{times.leave}
{times.leaveCarry > 0 ? (
<span className="ms-0.5">
{`+${times.leaveCarry}`}
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
</span>
) : null}
</span>
</span>
</div>
) : null}
@@ -0,0 +1,112 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import StopFillPicker from './StopFillPicker'
/**
* FE-STOPFILL-001..010 — how full one stop fills, as opposed to how full they all do.
*
* The figure is two-sided: a stop either has its own or borrows the traveller's, and the
* panel has to open showing which. What is worth pinning is the seam between them —
* that an inherited figure reads as chosen, that picking it again hands the stop back to
* the setting rather than writing the same number, and that the kilometre line under the
* pills is absent rather than invented when no range is set.
*/
let anchor: HTMLElement
beforeEach(() => {
anchor = document.createElement('button')
document.body.appendChild(anchor)
useSettingsStore.setState({
settings: { roadtrip_fill_percent: 80, roadtrip_range_km: 500, distance_unit: 'metric' } as never,
})
})
afterEach(() => { anchor.remove() })
function open(over: { current?: number | null; onPick?: (p: number | null) => void } = {}) {
const onPick = over.onPick ?? vi.fn()
const onClose = vi.fn()
render(
<StopFillPicker
anchor={anchor}
current={over.current ?? null}
onPick={onPick}
onClose={onClose}
/>,
)
return { onPick, onClose }
}
const pill = (percent: number) => screen.getByRole('button', { name: String(percent) })
describe('StopFillPicker', () => {
it('FE-STOPFILL-001: without an anchor there is nothing to hang under', () => {
render(<StopFillPicker anchor={null} current={null} onPick={vi.fn()} onClose={vi.fn()} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-STOPFILL-002: the traveller own figure is the one shown as chosen', () => {
// Nothing said about this stop, so it fills the way every other one does. Opening
// with nothing pressed would suggest the stop has no answer, when it has one.
open({ current: null })
expect(pill(80)).toHaveAttribute('aria-pressed', 'true')
expect(pill(100)).toHaveAttribute('aria-pressed', 'false')
})
it('FE-STOPFILL-003: the stop own figure wins over the traveller own', () => {
open({ current: 60 })
expect(pill(60)).toHaveAttribute('aria-pressed', 'true')
expect(pill(80)).toHaveAttribute('aria-pressed', 'false')
})
it('FE-STOPFILL-004: picking a percentage reports it', () => {
const { onPick } = open({ current: null })
fireEvent.click(pill(60))
expect(onPick).toHaveBeenCalledWith(60)
})
it('FE-STOPFILL-005: picking the figure the stop already has hands it back to the setting', () => {
// The same gesture the kind picker uses to un-pick a kind. Writing 60 again would
// leave the stop pinned to a number it only ever had by inheritance.
const { onPick } = open({ current: 60 })
fireEvent.click(pill(60))
expect(onPick).toHaveBeenCalledWith(null)
})
it('FE-STOPFILL-006: an inherited figure can be pinned by picking it', () => {
// Pressed because it is what the stop uses, but not its own — so clicking it means
// "make this one mine", not "take it off".
const { onPick } = open({ current: null })
fireEvent.click(pill(80))
expect(onPick).toHaveBeenCalledWith(80)
})
it('FE-STOPFILL-007: what the choice buys is stated, and only when it is known', () => {
open({ current: 60 })
// 60 % of a 500 km range.
expect(screen.getByText(/300 km/)).toBeInTheDocument()
})
it('FE-STOPFILL-008: with no range set the kilometres are absent rather than invented', () => {
useSettingsStore.setState({
settings: { roadtrip_fill_percent: 80, distance_unit: 'metric' } as never,
})
open({ current: 60 })
expect(screen.queryByText(/km/)).not.toBeInTheDocument()
})
it('FE-STOPFILL-009: the way back to the default appears only once there is one to undo', () => {
// On a stop with no figure of its own it would be a button that says "leave
// everything as it is".
open({ current: null })
expect(screen.queryByText('Use my default')).not.toBeInTheDocument()
})
it('FE-STOPFILL-010: a stop with its own figure can be handed back to the setting', () => {
const { onPick } = open({ current: 60 })
fireEvent.click(screen.getByText('Use my default'))
expect(onPick).toHaveBeenCalledWith(null)
})
})
@@ -0,0 +1,111 @@
import React from 'react'
import { RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import AnchoredPopover from './AnchoredPopover'
import { useVehicleRange } from './useVehicleRange'
import { FS } from './typeScale'
import type { DistanceUnit } from '../../types'
/**
* How full THIS stop fills up.
*
* A property of the stop rather than of the traveller, which is the whole reason it is
* here and not only in the settings dialog: a motorway rapid charger is worth about 80 %
* because the last fifth costs as long again, while the one at the hotel is worth all of
* it because the car stands there all night. One figure for the whole trip cannot say
* both, and the difference between them is a leg.
*
* Six pills rather than a slider or a field. A tank is not filled to 63 %, and the number
* being chosen is a decision — "top up" or "fill right up" — not a measurement, so a
* control that offers the six answers people actually give is one tap where a slider is a
* drag and a field is a keyboard.
*
* Under them, what the choice is worth in kilometres, because 80 % is an abstraction and
* "about 290 km from here" is the thing the traveller is actually deciding.
*/
/** The answers people actually give. Below half a tank nobody stops on purpose. */
const PRESETS = [50, 60, 70, 80, 90, 100] as const
export default function StopFillPicker({ anchor, current, onPick, onClose }: {
/** The element the popover hangs under: the stop's own fill badge. */
anchor: HTMLElement | null
/** What this stop says, or null when it follows the traveller's own setting. */
current: number | null
/** A percentage for this stop, or null to hand it back to the setting. */
onPick: (percent: number | null) => void
onClose: () => void
}): React.ReactElement | null {
const { t } = useTranslation()
const unit: DistanceUnit = useSettingsStore(s => s.settings.distance_unit) === 'imperial' ? 'imperial' : 'metric'
const fallback = useSettingsStore(s => s.settings.roadtrip_fill_percent)
const { rangeKm } = useVehicleRange()
// Zero, absent and 100 all mean the same thing in the settings, so they read as full
// here too rather than as three different defaults.
const settingPercent = fallback && fallback > 0 && fallback < 100 ? fallback : 100
const shown = current ?? settingPercent
return (
<AnchoredPopover anchor={anchor} label={t('roadtrip.stop.fillTitle')} onClose={onClose}>
<div className="flex flex-col gap-2 p-0.5">
<p
className="px-1 font-geist font-semibold uppercase tracking-[0.12em] text-content-faint"
style={{ fontSize: FS.label }}
>
{t('roadtrip.stop.fillTitle')}
</p>
<div className="flex gap-1">
{PRESETS.map(percent => {
// Pressed against what the stop will actually use, inherited or not, so the
// panel opens showing where the stop stands rather than showing nothing.
const on = shown === percent
return (
<button
key={percent}
type="button"
aria-pressed={on}
// Picking what it already is hands it back to the setting, the same way
// the kind picker un-picks a kind it already has.
onClick={() => onPick(current === percent ? null : percent)}
className={`h-9 w-9 rounded-xl border text-caption font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
on
? 'border-accent bg-accent text-accent-text'
: 'border-edge bg-surface-card text-content-secondary hover:bg-surface-hover'
}`}
>
{percent}
</button>
)
})}
</div>
{/* What the choice buys. Absent rather than guessed when no range is set: a
kilometre figure worked out from nothing would be the most convincing wrong
number on the screen. */}
<p className="px-1 text-caption text-content-muted">
{rangeKm
? t('roadtrip.stop.fillGives', { distance: formatDistance((rangeKm * shown) / 100, unit) })
: t('roadtrip.stop.fillNoRange')}
</p>
{/* Only once there is something to undo. Without an override it would be a button
that says "leave everything as it is". */}
{current !== null ? (
<button
type="button"
onClick={() => onPick(null)}
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<RotateCcw size={14} className="shrink-0 text-content-faint" aria-hidden />
<span className="flex-1 text-start">{t('roadtrip.stop.fillDefault')}</span>
<span className="tabular-nums text-content-faint">{`${settingPercent} %`}</span>
</button>
) : null}
</div>
</AnchoredPopover>
)
}
@@ -1,8 +1,8 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import React from 'react'
import { MapPin } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import AnchoredPopover from './AnchoredPopover'
import { STOP_KINDS } from './stopKinds'
import type { RoadtripStopType } from '@trek/shared'
@@ -33,61 +33,9 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: {
onClose: () => void
}): React.ReactElement | null {
const { t } = useTranslation()
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
// Measured after paint, before the browser shows it: reading the size first and then
// placing it is what stops the panel appearing at 0,0 for one frame.
useLayoutEffect(() => {
if (!anchor || !ref.current) return
const a = anchor.getBoundingClientRect()
const p = ref.current.getBoundingClientRect()
const gap = 8
let top = a.bottom + gap
// Flips above when there is no room below, which is most of the rail on a short
// window: a panel clipped by the viewport is one that cannot be used at all.
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
// Centred on the column, not on the number. The number sits hard against the left
// edge of the rail, so a panel centred on it hangs half off the sidebar and points at
// the map. Vertically it still follows the row it belongs to.
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
const left = Math.min(
Math.max(8, column.left + column.width / 2 - p.width / 2),
window.innerWidth - p.width - 8,
)
setPos({ top, left })
}, [anchor])
useEffect(() => {
if (!anchor) return
const onDown = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (anchor.contains(e.target as Node)) return
onClose()
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
// Any scroll moves the anchor out from under the panel, and following it would mean
// measuring on every frame for a menu that is open for two seconds.
window.addEventListener('scroll', onClose, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onClose, true)
}
}, [anchor, onClose])
if (!anchor) return null
return createPortal(
<div
ref={ref}
role="dialog"
aria-label={t('roadtrip.stop.kind')}
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
>
return (
<AnchoredPopover anchor={anchor} label={t('roadtrip.stop.kind')} onClose={onClose}>
<div className="flex gap-1">
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => {
const on = current === key
@@ -127,7 +75,6 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: {
{t('roadtrip.stop.backToDestination')}
</button>
) : null}
</div>,
document.body,
</AnchoredPopover>
)
}
@@ -12,7 +12,7 @@ import type { RouteAlternative } from '../Map/RouteCalculator'
* reason this is not simply the midpoint.
*/
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' }
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry' }
/** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */
function line(n: number, lat: number): [number, number][] {
@@ -83,7 +83,7 @@ function mostDistinctPoint(
*/
export function buildAlternativeOverlays(
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
labels: { fastest: string; current: string; noMotorway: string; noToll: string; noFerry: string },
): AlternativeOverlay[] {
if (!routes?.length) return []
// A single answer is not a choice; drawing it would just double the route already there.
@@ -114,7 +114,9 @@ export function buildAlternativeOverlays(
? labels.noMotorway
: route.avoids === 'toll'
? labels.noToll
: index === quickest ? labels.fastest : '',
: route.avoids === 'ferry'
? labels.noFerry
: index === quickest ? labels.fastest : '',
duration: route.duration,
distance: route.distance,
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
@@ -1,9 +1,12 @@
import { describe, it, expect } from 'vitest'
import {
boxAround,
corridorTiles,
distanceToSegmentKm,
haversineKm,
pointAtMeters,
projectOntoRoute,
sliceAtMeters,
simplifyLine,
type LatLng,
} from './corridor'
@@ -155,3 +158,106 @@ describe('simplifyLine', () => {
expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN])
})
})
describe('pointAtMeters', () => {
// A straight run due east along one parallel, so the arithmetic is checkable by hand:
// at 52° north a degree of longitude is about 68.5 km.
const WEST: LatLng = { lat: 52, lng: 13 }
const EAST: LatLng = { lat: 52, lng: 14 }
const LEG = haversineKm(WEST, EAST) * 1000
it('lands halfway along a single segment', () => {
const mid = pointAtMeters([WEST, EAST], LEG / 2)!
expect(mid.lat).toBeCloseTo(52, 5)
expect(mid.lng).toBeCloseTo(13.5, 3)
})
it('walks past the first segment into the second', () => {
const line = [WEST, EAST, { lat: 52, lng: 15 }]
const at = pointAtMeters(line, LEG * 1.5)!
// One and a half segments in, so halfway through the second.
expect(at.lng).toBeGreaterThan(14)
expect(at.lng).toBeLessThan(15)
})
it('returns the last point past the end rather than nothing', () => {
// A tank that runs out after the day's final stop still ran out somewhere; the
// caller decides what to do about that, so this must not answer null.
expect(pointAtMeters([WEST, EAST], LEG * 10)).toEqual(EAST)
})
it('answers the first point for zero, and nothing for an empty line', () => {
expect(pointAtMeters([WEST, EAST], 0)).toEqual(WEST)
expect(pointAtMeters([WEST, EAST], -5)).toEqual(WEST)
expect(pointAtMeters([], 100)).toBeNull()
expect(pointAtMeters([WEST], 100)).toEqual(WEST)
})
it('survives a repeated vertex without dividing by zero', () => {
// A route line concatenated from several runs does contain these.
const at = pointAtMeters([WEST, WEST, EAST], LEG / 2)!
expect(at.lng).toBeCloseTo(13.5, 3)
})
})
describe('boxAround', () => {
it('reaches the asked radius on all four sides', () => {
const box = boxAround(BERLIN, 10)
// North and south are the easy ones: a degree of latitude is constant.
expect(haversineKm(BERLIN, { lat: box.north, lng: BERLIN.lng })).toBeCloseTo(10, 0)
expect(haversineKm(BERLIN, { lat: BERLIN.lat, lng: box.east })).toBeCloseTo(10, 0)
})
it('widens the longitude span the further north it sits', () => {
// The same radius is more degrees of longitude in Tromsø than in Berlin.
const berlin = boxAround(BERLIN, 10)
const tromso = boxAround({ lat: 69.65, lng: 18.96 }, 10)
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)
})
})
+108
View File
@@ -232,3 +232,111 @@ export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
const right = simplifyLine(line.slice(index), toleranceKm)
return [...left.slice(0, -1), ...right]
}
/**
* The point that lies `metres` along a line, measured the way a car drives it.
*
* Every other helper in this file goes the other way: given a point, where on the line
* is it. This is the inverse, and it exists because the range budget produces a distance
* ("the tank is empty 340 km in") with no idea where that is on the map.
*
* Walked segment by segment rather than approximated, and interpolated inside the
* segment it lands in, because the answer becomes a marker somebody drives to. Past the
* end it returns the last point rather than null: a tank that runs out after the day's
* final stop still ran out somewhere, and the caller decides what to do about that.
*/
export function pointAtMeters(line: LatLng[], metres: number): LatLng | null {
if (!line.length) return null
if (line.length === 1 || metres <= 0) return line[0]
let covered = 0
for (let i = 1; i < line.length; i++) {
const step = haversineKm(line[i - 1], line[i]) * 1000
if (covered + step >= metres) {
// How far into this segment, as a fraction. A zero-length segment (two identical
// vertices, which a concatenated route line does produce) would divide by zero,
// and its start point is the right answer anyway.
const t = step > 0 ? (metres - covered) / 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,
}
}
covered += step
}
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`.
*
* The server turns a box into centre plus half the diagonal, so a box of ±r asks for
* r·√2 of reach and finds things in the corners that are further away than they look.
* Sized from the radius rather than guessed, and the caller still has to read the
* answer's own `clamped` flag: above 20 km the index narrows the search silently.
*/
export function boxAround(point: LatLng, radiusKm: number): Bbox {
const dLat = radiusKm / KM_PER_DEG_LAT
const dLng = radiusKm / (KM_PER_DEG_LAT * Math.max(0.01, Math.cos(toRad(point.lat))))
return {
south: point.lat - dLat,
west: point.lng - dLng,
north: point.lat + dLat,
east: point.lng + dLng,
}
}
@@ -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
}
@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest'
import { reachableRefuels, outcomeOf } from './refuelSuggestion'
import type { LatLng } from './corridor'
import type { Poi } from '../Map/poiCategories'
// A straight run due east at 52° north, where a degree of longitude is about 68.5 km.
const LINE: LatLng[] = [
{ lat: 52, lng: 13 },
{ lat: 52, lng: 14 },
{ lat: 52, lng: 15 },
]
const poi = (name: string, lat: number, lng: number): Poi =>
({ osm_id: `node:${name}`, name, lat, lng } as Poi)
describe('reachableRefuels', () => {
it('FE-ROADTRIP-REFUEL-001: offers only what lies before the tank runs out', () => {
// Dry at 100 km in. A pump at 50 km is reachable; one at 130 km is on the far side
// of an empty tank, and offering it is offering a walk.
const early = poi('early', 52, 13.73) // ~50 km
const late = poi('late', 52, 14.9) // ~130 km
const out = reachableRefuels([early, late], LINE, 100)
expect(out.map(p => p.name)).toEqual(['early'])
})
it('FE-ROADTRIP-REFUEL-002: the detour counts twice, because it is driven out and back', () => {
// Both sit at the same point along the road, 60 km in, with 100 km of range. One is
// on the road; the other is 20 km off it, so 40 km of driving, which no longer fits
// in the 40 km that are left once the reserve is kept back.
const onRoad = poi('on-road', 52, 13.876)
const offRoad = poi('off-road', 52.18, 13.876) // ~20 km north
const out = reachableRefuels([onRoad, offRoad], LINE, 100)
expect(out.map(p => p.name)).toEqual(['on-road'])
})
it('FE-ROADTRIP-REFUEL-003: a reserve is kept back, so nobody is sent to the last drop', () => {
// 8 km before the dry point is inside the ten kilometre reserve.
const onTheLimit = poi('on-the-limit', 52, 14.34) // ~93 km
expect(reachableRefuels([onTheLimit], LINE, 100)).toEqual([])
})
it('FE-ROADTRIP-REFUEL-004: the smallest detour comes first, however late the others sit', () => {
// Measured on a real day: the station that got furthest was 9.2 km off the road for
// one kilometre more of range. Everything in this list is already reachable, so what
// separates them is what the stop costs.
const nearRoad = poi('near-road', 52, 13.15) // on the line, early
const bigDetour = poi('big-detour', 52.05, 13.9) // ~5.5 km off, later
const out = reachableRefuels([bigDetour, nearRoad], LINE, 100)
expect(out.map(p => p.name)).toEqual(['near-road', 'big-detour'])
})
it('FE-ROADTRIP-REFUEL-009: with the same detour, the later one wins', () => {
// Filling up early wastes the tank that was already paid for.
const early = poi('early', 52, 13.15)
const later = poi('later', 52, 14.16)
const out = reachableRefuels([early, later], LINE, 100)
expect(out.map(p => p.name)).toEqual(['later', 'early'])
})
it('FE-ROADTRIP-REFUEL-005: a pump already on the plan is not suggested next to itself', () => {
// Matched on position, not on id: the same station added by hand or from another
// source carries a different osm_id.
const pump = poi('pump', 52, 13.73)
const out = reachableRefuels([pump], LINE, 100, { existing: [{ lat: 52.0005, lng: 13.7302 }] })
expect(out).toEqual([])
})
it('FE-ROADTRIP-REFUEL-006: something nowhere near the road is not on the way', () => {
const wayOff = poi('way-off', 49, 13.5)
expect(reachableRefuels([wayOff], LINE, 100)).toEqual([])
})
})
describe('outcomeOf', () => {
it('FE-ROADTRIP-REFUEL-007: no answer is not the same as no filling station', () => {
// The distinction the whole band hangs on. Saying "none on this stretch" after a
// failed request states a fact that was never checked.
expect(outcomeOf([], null)).toBe('failed')
expect(outcomeOf([], { truncated: true })).toBe('incomplete')
expect(outcomeOf([], {})).toBe('none')
// `clamped` is not the same thing: it means the server searched a smaller circle
// than the box described, and the caller sizes the box so that never happens.
// Treating it as "could not check" turned every empty answer into a shrug.
expect(outcomeOf([], { clamped: true })).toBe('none')
})
it('FE-ROADTRIP-REFUEL-008: a hit is a hit even when the answer was cut short', () => {
const found = [{ name: 'x' }] as never
expect(outcomeOf(found, { truncated: true })).toBe('found')
})
})
describe('reachableRefuels — one entry per station', () => {
const at = (name: string, lat: number, lng: number): Poi =>
({ osm_id: `node:${name}-${lat}-${lng}`, name, lat, lng } as Poi)
it('FE-ROADTRIP-REFUEL-010: a charging site mapped as one node per socket is offered once', () => {
// Measured on a real day: the top three offers were the same "autostrom plus GmbH"
// three times over, because OSM maps every socket as its own node.
// The real shape of it: four nodes in two pairs, 6 m and 7 m apart, with 450 m
// between the pairs.
const sockets = [
at('autostrom plus GmbH', 52, 13.3),
at('autostrom plus GmbH', 52.00005, 13.30005),
at('autostrom plus GmbH', 52.004, 13.3005),
at('autostrom plus GmbH', 52.00405, 13.30045),
]
const out = reachableRefuels(sockets, LINE, 100)
expect(out).toHaveLength(1)
})
it('FE-ROADTRIP-REFUEL-011: the same chain in two towns stays two entries', () => {
// Name alone is not identity: a chain has a branch everywhere.
// Measured: genuinely different branches were 18 to 36 km apart.
const out = reachableRefuels([at('Aral', 52, 13.2), at('Aral', 52, 13.6)], LINE, 100)
expect(out).toHaveLength(2)
})
it('FE-ROADTRIP-REFUEL-012: two operators sharing one services are both offered', () => {
// Position alone is not identity either.
const out = reachableRefuels([at('Aral', 52, 13.3), at('Shell', 52.0003, 13.3)], LINE, 100)
expect(out.map(p => p.name).sort()).toEqual(['Aral', 'Shell'])
})
})
@@ -0,0 +1,123 @@
import { haversineKm, projectOntoRoute, type LatLng } from './corridor'
import type { Poi } from '../Map/poiCategories'
/**
* Picking a filling station for a tank that is about to run out.
*
* Pure: no React, no network. The caller does the one request and hands the answers
* here, which keeps the judgement — which of these can the car actually reach — testable
* without a server.
*/
/** A candidate, with the two figures that decide whether it is any use. */
export interface RefuelCandidate extends Poi {
/** Kilometres along the day's driving line, in the same space as the dry point. */
alongKm: number
/** How far off the road it sits. */
offRouteKm: number
/** Kilometres of range still in the tank when the car draws level with it. */
spareKm: number
}
/**
* How much of the last drop to keep back, in kilometres.
*
* Arriving at a pump with nothing left is not a plan, and the range figure a traveller
* types is a round number from a manual rather than a measurement. Ten kilometres is
* small enough not to move the suggestion and large enough not to promise the
* impossible.
*/
const RESERVE_KM = 10
/**
* The stations that can actually be reached, nearest to the dry point first.
*
* Three filters, and each one exists because its absence produces a wrong answer rather
* than an untidy one:
*
* - Only what lies BEFORE the point the tank empties. A station 20 km past it is on the
* far side of an empty tank, and offering it is offering a walk.
* - The detour counts double. A pump 8 km off the road is 16 km of driving, and on a
* tank this close to empty that is the difference between arriving and not.
* - What is left has to fit in the range that remains, minus a reserve.
*
* Sorted by the detour, not by how far along the road it sits. Measured against a real
* day: the station that got furthest was 9.2 km off the road for one kilometre more of
* range, while one 0.9 km off sat almost as late. Everything in this list is already
* reachable, so what separates them is what the stop costs, and that is the detour. Ties
* go to the later one, because filling up early wastes the tank you already paid for.
*/
export function reachableRefuels(
pois: Poi[],
line: LatLng[],
dryAlongKm: number,
{ existing = [] }: { existing?: { lat: number; lng: number }[] } = {},
): RefuelCandidate[] {
const out: RefuelCandidate[] = []
for (const poi of pois) {
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, line)
if (!hit) continue
// Reached before the tank empties, once the detour out and back is paid for.
const spareKm = dryAlongKm - hit.alongKm - hit.offRouteKm * 2 - RESERVE_KM
if (spareKm < 0) continue
// Already planned. Matched on position rather than on id: the same pump added by
// hand, or from another source, carries a different osm_id and would otherwise be
// suggested next to itself.
if (existing.some(e => haversineKm(e, { lat: poi.lat, lng: poi.lng }) < 0.15)) continue
out.push({ ...poi, alongKm: hit.alongKm, offRouteKm: hit.offRouteKm, spareKm })
}
return oneEach(out.sort((a, b) => a.offRouteKm - b.offRouteKm || a.spareKm - b.spareKm))
}
/**
* What a search actually established, as opposed to what it returned.
*
* An empty list means one of two very different things, and saying the wrong one is
* worse than saying nothing: either there is no filling station on this stretch, or we
* never managed to look. The server flags the second case itself (`truncated` when it
* hit its own ceiling, `clamped` when it narrowed the area), and a failed request is the
* third. Only `none` is a statement about the road.
*/
export type RefuelOutcome = 'found' | 'none' | 'incomplete' | 'failed'
export function outcomeOf(
candidates: RefuelCandidate[],
answer: { truncated?: boolean; clamped?: boolean } | null,
): RefuelOutcome {
if (!answer) return 'failed'
if (candidates.length) return 'found'
// `truncated` alone. `clamped` only means the server searched a smaller circle than
// the box described, which the caller now sizes so it never happens — and reporting it
// as "we could not check" turned every empty answer into a shrug.
return answer.truncated ? 'incomplete' : 'none'
}
/**
* One entry per filling station, not one per pump.
*
* A charging site is mapped in OSM as a node per socket and a forecourt often as one per
* pump, all with the same name a few metres apart. Deduplicating by id keeps every one of
* them, which is right on a map and wrong in a list of three offers: measured on a real
* day, the top three were the same "autostrom plus GmbH" three times over.
*
* Same name and within half a kilometre counts as the same place. Both halves matter:
* not position alone, because two operators do share a motorway services, and not name
* alone, because a chain has a branch in every town.
*
* Half a kilometre comes from the data rather than from taste. In one 25 km search the
* four nodes of one charging site sat in two pairs, 6 m and 7 m apart, with 450 m between
* the pairs — while genuinely different branches of the same chain were 18 to 36 km
* apart. Anything from 500 m to a few kilometres separates those two cases; the lower end
* is the safer place to stand.
*/
const SAME_PLACE_KM = 0.5
function oneEach(sorted: RefuelCandidate[]): RefuelCandidate[] {
const kept: RefuelCandidate[] = []
for (const poi of sorted) {
const twin = kept.some(k =>
k.name === poi.name && haversineKm(k, { lat: poi.lat, lng: poi.lng }) < SAME_PLACE_KM)
if (!twin) kept.push(poi)
}
return kept
}
@@ -11,6 +11,7 @@ import {
parseClock,
splitIntoRuns,
sumLegSeconds,
refuelStopTypeFor,
} from './roadtripModel'
describe('formatDurationShort', () => {
@@ -89,7 +90,10 @@ describe('computeSchedule', () => {
expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false })
})
it('leaves stops before the first pinned time blank rather than inventing one', () => {
it('works back from the first pinned time to say when to set off', () => {
// The museum opens at ten and we want an hour at the stop before it, an hour of
// driving in between: be there at eight. Answering that is the reason to pin a time
// on the second stop at all.
const { entries } = computeSchedule(
[
{ anchor: null, dwellMinutes: 60 },
@@ -97,10 +101,41 @@ describe('computeSchedule', () => {
],
[hours(1)],
)
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '09:00', anchored: false })
expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
})
it('stops working back at a leg that never routed rather than inventing one', () => {
const { entries } = computeSchedule(
[
{ anchor: null, dwellMinutes: 60 },
{ anchor: null, dwellMinutes: 30 },
{ anchor: '10:00', dwellMinutes: 30 },
],
[undefined, hours(1)],
)
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
// Leaves at 09:00 to arrive at 10:00, and its own half hour puts it there at 08:30.
expect(entries[1]).toMatchObject({ arrival: '08:30', departure: '09:00' })
expect(entries[2]).toMatchObject({ arrival: '10:00', anchored: true })
})
it('keeps the earliest stop on day zero when working back crosses midnight', () => {
// Pinned at one in the morning with three hours of driving before it: the stop before
// is the evening before. The chain shifts up a day rather than printing a day below
// zero, so the boundary shows between the two stops instead of under the first one.
const { entries, warnings } = computeSchedule(
[
{ anchor: null, dwellMinutes: 0 },
{ anchor: '01:00', dwellMinutes: 0 },
],
[hours(3)],
)
expect(entries[0]).toMatchObject({ arrival: '22:00', dayOffset: 0 })
expect(entries[1]).toMatchObject({ arrival: '01:00', dayOffset: 1, anchored: true })
expect(warnings).toContainEqual({ index: 1, code: 'overnight' })
})
it('restarts the cascade at a pinned stop instead of pushing it', () => {
// The drive would arrive at 09:00, but the museum ticket says 11:00: everything
// after it counts from 11:00, not from the drive.
@@ -545,3 +580,234 @@ describe('deriveDriveWarnings — what counts as driving', () => {
expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }])
})
})
describe('deriveDriveWarnings — where the tank actually runs dry', () => {
const drive = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
const ferry = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'ferry' })
it('FE-ROADTRIP-MODEL-089: the dry point sits where the fuel ends, not where somebody notices', () => {
// 300 km of range, two legs of 200. The warning lands on stop 2, which is 400 km in;
// the tank was empty 100 km earlier, halfway through the second leg. Suggesting a
// filling station at the warning would suggest one the car cannot reach.
const out = deriveDriveWarnings(
[drive(120, 200), drive(120, 200)],
[false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(out.warnings).toEqual([{ index: 2, code: 'range', sinceKm: 400 }])
expect(out.emptyAt).toEqual([
{ legIndex: 1, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 },
])
})
it('FE-ROADTRIP-MODEL-090: one dry point per tank, however many warnings the stretch produces', () => {
// A long run with nothing on it warns at every stop on purpose, because a warning is
// not a fill-up. One refuel offer per warning would stack three identical offers for
// a single tank down one day.
const out = deriveDriveWarnings(
[drive(60, 400), drive(60, 400), drive(60, 400)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 600 },
0,
)
expect(out.warnings).toHaveLength(2)
expect(out.emptyAt).toHaveLength(1)
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, drivenMeters: 600000 })
})
it('FE-ROADTRIP-MODEL-091: filling up starts a new tank, and the next one runs dry again', () => {
const out = deriveDriveWarnings(
[drive(60, 400), drive(60, 400), drive(60, 400)],
[false, false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 600 },
0,
)
// Empty once before the fuel stop, then once more after it.
expect(out.emptyAt).toHaveLength(1)
expect(out.emptyAt[0].drivenMeters).toBe(600000)
})
it('FE-ROADTRIP-MODEL-092: a ferry carries the car without burning a drop', () => {
// The trap this field exists for. The budget skips a ferry, so the dry point must be
// counted in DRIVING metres only; measuring along the day's drawn line instead would
// overshoot by the whole crossing and put the marker out at sea.
const out = deriveDriveWarnings(
[drive(60, 200), ferry(120, 50), drive(60, 200)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(out.emptyAt).toEqual([
{ legIndex: 2, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 },
])
})
it('FE-ROADTRIP-MODEL-093: a tank carried over midnight runs dry earlier the next day', () => {
const out = deriveDriveWarnings(
[drive(60, 200), drive(60, 200)],
[false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
250,
)
// Only 50 km left on arrival, so it empties a quarter into the first leg.
expect(out.emptyAt).toEqual([
{ legIndex: 0, intoLegKm: 50, drivenMeters: 50000, sinceKm: 300 },
])
})
it('FE-ROADTRIP-MODEL-094: no range limit and no crossing produce no dry point at all', () => {
const noLimit = deriveDriveWarnings(
[drive(60, 900)],
[false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: null },
0,
)
expect(noLimit.emptyAt).toEqual([])
const withinRange = deriveDriveWarnings(
[drive(60, 100)],
[false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(withinRange.emptyAt).toEqual([])
})
it('FE-ROADTRIP-MODEL-095: an unrouted leg gives the tank up rather than guessing where it ends', () => {
// The budget goes null and stays null, so there is no honest dry point to offer.
const out = deriveDriveWarnings(
[drive(60, 200), undefined, drive(60, 400)],
[false, false, false, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
0,
)
expect(out.emptyAt).toEqual([])
expect(out.carryKm).toBeNull()
})
})
describe('refuelsRange — what fills which tank', () => {
it('FE-ROADTRIP-MODEL-096: with no vehicle named, either kind fills up', () => {
// What TREK did before the setting existed, and the right answer for somebody who
// never opened the dialog. Anything else would quietly change their warnings.
expect(refuelsRange('fuel')).toBe(true)
expect(refuelsRange('charging')).toBe(true)
expect(refuelsRange('fuel', null)).toBe(true)
expect(refuelsRange('charging', null)).toBe(true)
})
it('FE-ROADTRIP-MODEL-097: a petrol station does not charge a battery', () => {
// The bug this exists for: an electric car pausing at a petrol station had its
// battery refilled on paper, the warnings went quiet for the rest of the day, and
// the driver was told nothing.
expect(refuelsRange('charging', 'electric')).toBe(true)
expect(refuelsRange('fuel', 'electric')).toBe(false)
})
it('FE-ROADTRIP-MODEL-098: and a charger does not fill a tank', () => {
expect(refuelsRange('fuel', 'combustion')).toBe(true)
expect(refuelsRange('charging', 'combustion')).toBe(false)
})
it('FE-ROADTRIP-MODEL-099: nothing else refuels anything, whatever is driven', () => {
for (const vehicle of [null, 'combustion', 'electric'] as const) {
expect(refuelsRange('rest_area', vehicle)).toBe(false)
expect(refuelsRange('restaurant', vehicle)).toBe(false)
expect(refuelsRange(null, vehicle)).toBe(false)
}
})
it('FE-ROADTRIP-MODEL-100: the search looks for what the vehicle actually takes', () => {
expect(refuelStopTypeFor('combustion')).toEqual(['fuel'])
expect(refuelStopTypeFor('electric')).toEqual(['charging'])
expect(refuelStopTypeFor(null)).toEqual(['fuel', 'charging'])
})
})
describe('deriveDriveWarnings — filling only part way', () => {
const drive = (minutes: number, km: number) =>
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
it('FE-ROADTRIP-MODEL-101: a stop that fills to 80 % leaves a fifth already used', () => {
// Nobody charges to 100 % on the road: the last fifth takes as long as the first
// four. Counting a stop as a full tank overstates what comes after it by that fifth.
const out = deriveDriveWarnings(
[drive(60, 100), drive(60, 450)],
[false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
0,
)
// After the stop the budget restarts at 100 km rather than 0, so the tank is dry
// 400 km into the second leg. Filled all the way it would have gone the whole 450.
expect(out.emptyAt).toHaveLength(1)
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 })
})
it('FE-ROADTRIP-MODEL-102: filling all the way is what absent, zero and 100 all mean', () => {
const legs = [drive(60, 100), drive(60, 450)]
const refuels = [false, true, false]
const full = { legMinutes: null, dayMinutes: null, rangeKm: 500 }
for (const fillPercent of [undefined, null, 0, 100]) {
const out = deriveDriveWarnings(legs, refuels, { ...full, fillPercent }, 0)
// A full tank after the stop covers the remaining 450 km without a finding.
expect(out.emptyAt).toEqual([])
expect(out.warnings).toEqual([])
}
})
it('FE-ROADTRIP-MODEL-103: with no range set, the fill level changes nothing', () => {
const out = deriveDriveWarnings(
[drive(60, 900)],
[true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: null, fillPercent: 50 },
0,
)
expect(out.warnings).toEqual([])
expect(out.emptyAt).toEqual([])
})
it('FE-ROADTRIP-MODEL-104: a stop that says how full it fills is read against itself', () => {
// The motorway charger tops up to 60 %; the traveller's own default is 80. Reading
// the default here would promise 100 km the car does not have.
const out = deriveDriveWarnings(
[drive(60, 100), drive(60, 450)],
[false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
0,
[null, 60, null],
)
// 60 % of 500 km is 300, so the tank is dry 300 km into the second leg rather than
// the 400 the traveller's own figure would have given.
expect(out.emptyAt).toHaveLength(1)
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 300 })
})
it('FE-ROADTRIP-MODEL-105: a stop with no opinion still follows the traveller', () => {
// Same day, same stop, nothing said about it: the default has to keep applying, or
// adding the field would have quietly changed every trip that never used it.
const out = deriveDriveWarnings(
[drive(60, 100), drive(60, 450)],
[false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
0,
[null, null, null],
)
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 })
})
it('FE-ROADTRIP-MODEL-106: a stop can also say it fills right up', () => {
// The charger at the hotel: the car stands there all night, so this one goes to 100
// even on a trip whose default stops at 80.
const out = deriveDriveWarnings(
[drive(60, 100), drive(60, 450)],
[false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
0,
[null, 100, null],
)
expect(out.emptyAt).toEqual([])
})
})
+185 -23
View File
@@ -4,6 +4,8 @@
* on its own and reused by the map, the rail and (later) the MCP tool.
*/
import type { RouteAvoidClass } from '../../types'
/**
* The stop kinds that interrupt a drive rather than end it.
*
@@ -121,9 +123,32 @@ export interface ScheduleWarning {
*/
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
/** Whether stopping here starts the range budget over. */
export function refuelsRange(stopType: string | null | undefined): boolean {
return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '')
/**
* What the traveller drives, which decides what actually fills the tank.
*
* Absent means both count, which is what TREK did before the setting existed and is the
* right answer for somebody who never said. It is only wrong once a vehicle IS named:
* a petrol station does not charge a battery, and a charger does not fill a tank.
*/
export type VehicleKind = 'combustion' | 'electric'
/** The stop kind that refills the named vehicle, for filtering a search. */
export function refuelStopTypeFor(vehicle: VehicleKind | null | undefined): readonly string[] {
if (vehicle === 'combustion') return ['fuel']
if (vehicle === 'electric') return ['charging']
return REFUELLING_STOP_TYPES
}
/**
* Whether stopping here starts the range budget over.
*
* With no vehicle named, either kind does — a plain reading of "I filled up". Once one is
* named the other stops counting, because the alternative is arithmetic that is simply
* wrong: an electric car pausing at a petrol station had its battery refilled on paper,
* the warnings went quiet for the rest of the day, and the driver was told nothing.
*/
export function refuelsRange(stopType: string | null | undefined, vehicle?: VehicleKind | null): boolean {
return refuelStopTypeFor(vehicle).includes(stopType ?? '')
}
/**
@@ -131,6 +156,15 @@ export function refuelsRange(stopType: string | null | undefined): boolean {
* rather than a sentinel: zero is a legal thing to type and means the same as unset.
*/
export interface DriveLimits {
/**
* How full a fill-up actually goes, 1 to 100, or null for "all the way".
*
* Nobody charges an electric car to 100 % on the road: the last fifth takes as long as
* the first four and the manual says not to. Treating every stop as a full tank
* overstates the range after it by exactly that fifth, which is a whole leg on a long
* day.
*/
fillPercent?: number | null
/** Longest single drive between two stops, in minutes. */
legMinutes: number | null
/** Longest total driving in one day, in minutes. */
@@ -212,8 +246,10 @@ function resolveArrival(
}
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
const entries: ScheduleEntry[] = []
const warnings: ScheduleWarning[] = []
// Arrivals in minutes, so the chain can be walked twice before anything is formatted.
const arrivals: (number | null)[] = new Array(stops.length).fill(null)
const anchored: boolean[] = new Array(stops.length).fill(false)
// Minutes since the first stop's midnight, so a chain crossing midnight keeps counting.
let cursor: number | null = null
let dayOffset = 0
@@ -224,28 +260,57 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset)
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy })
if (arrival === null) {
if (arrival === null) continue
const offset = Math.floor(arrival / DAY_MINUTES)
if (offset > dayOffset) dayOffset = offset
arrivals[i] = arrival
anchored[i] = anchor !== null
const leg = legSeconds[i]
cursor = leg === undefined ? null : arrival + (stop.dwellMinutes ?? 0) + Math.round(leg / 60)
}
// Then backwards, for the stops the forward walk left blank because nobody had pinned
// a time yet. Pinning a time on the second stop is the ordinary way to plan: the museum
// opens at ten, so when do we have to leave? Working back from the first known arrival
// answers it — departure = the next arrival minus the drive, arrival = that minus the
// stay — and it stops at the first leg that never routed rather than inventing one.
const firstKnown = arrivals.findIndex(a => a !== null)
for (let i = firstKnown - 1; i >= 0; i--) {
const leg = legSeconds[i]
const next = arrivals[i + 1]
if (leg === undefined || next === null) break
arrivals[i] = next - Math.round(leg / 60) - (stops[i].dwellMinutes ?? 0)
}
// Working back can land before the anchor day's midnight, which would print as a
// negative day. `dayOffset` counts days past the FIRST stop, so the whole chain shifts
// up instead until the earliest stop sits on day zero again.
const earliest = arrivals.reduce<number | null>((m, a) => (a === null ? m : m === null || a < m ? a : m), null)
const shift = earliest === null || earliest >= 0 ? 0 : -Math.floor(earliest / DAY_MINUTES) * DAY_MINUTES
const entries: ScheduleEntry[] = []
let lastOffset = 0
for (let i = 0; i < stops.length; i++) {
const raw = arrivals[i]
if (raw === null) {
entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 })
continue
}
const offset = Math.floor(arrival / (24 * 60))
if (offset > dayOffset) {
dayOffset = offset
warnings.push({ index: i, code: 'overnight' })
}
const dwell = stop.dwellMinutes ?? 0
const departure = arrival + dwell
const arrival = raw + shift
const offset = Math.floor(arrival / DAY_MINUTES)
// Read off the finished chain rather than during the forward walk, so a midnight the
// backward pass introduced is marked too.
if (offset > lastOffset) warnings.push({ index: i, code: 'overnight' })
lastOffset = offset
entries.push({
arrival: formatClock(arrival),
departure: formatClock(departure),
anchored: anchor !== null,
departure: formatClock(arrival + (stops[i].dwellMinutes ?? 0)),
anchored: anchored[i],
dayOffset: offset,
})
const leg = legSeconds[i]
cursor = leg === undefined ? null : departure + Math.round(leg / 60)
}
return { entries, warnings }
@@ -308,6 +373,29 @@ export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
* town. A point past the end lands in the last leg rather than nowhere, because that is
* a rounding edge and not a missing leg.
*/
/**
* Where a tank runs dry, in the day's own driving coordinates.
*
* A separate thing from the range WARNING, and the difference is the whole point. The
* warning is filed against the stop the car arrives at, which is where somebody finds
* out; this is where the fuel actually runs out, which can be most of a leg earlier and
* is the only place worth suggesting a filling station.
*
* `drivenMeters` counts DRIVING legs only, so it is not an offset into the day's drawn
* line: that line also carries ferry and walking runs. Converting it to a coordinate
* means walking the driving legs, which `dryPointOn` does.
*/
export interface DryPoint {
/** Index into the day's legs, i.e. the leg the car is on when the tank empties. */
legIndex: number
/** How far into that leg, in kilometres. */
intoLegKm: number
/** Metres of driving from the start of the day to that point. */
drivenMeters: number
/** The range limit that was crossed, which is what the traveller set. */
sinceKm: number
}
/**
* The findings about the driving itself: too long at the wheel, too long in one day, and
* the tank running out before anywhere to fill it.
@@ -344,13 +432,45 @@ export function deriveDriveWarnings(
limits: DriveLimits,
/** Kilometres already on the tank when the day starts; null when that is unknown. */
carryKm: number | null,
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null } {
/**
* Per stop, how full THAT stop fills up, 1-100. Absent follows `limits.fillPercent`.
*
* A property of the stop rather than of the traveller: a motorway rapid charger is
* worth about 80 % because the last fifth costs as long again, while the one at the
* hotel is worth all of it because the car stands there all night. One figure for the
* whole trip cannot say both, and the difference between them is a leg.
*
* Last and optional so the four-argument call, which is every caller that has no
* per-stop opinion, keeps meaning exactly what it did.
*/
fillAt: readonly (number | null | undefined)[] = [],
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null; emptyAt: DryPoint[] } {
const warnings: ScheduleWarning[] = []
const emptyAt: DryPoint[] = []
let budget = carryKm
let totalSeconds = 0
// Metres of DRIVING covered so far. Not the same as metres along the day's line: the
// line carries every run, and a ferry or a walk in the middle of a day adds to it
// without adding to this. Whatever converts a dry point back into a coordinate has to
// walk the driving legs only, which is why this is counted here rather than derived.
let drivenMeters = 0
/**
* What is already on the clock the moment the car pulls away from stop `i`.
*
* A tank filled to 80 % has used a fifth of its range before it moves, which is how a
* budget that counts upwards says "not full". Zero, absent and 100 all mean it filled
* right up, so all three land on nothing used.
*/
const usedAfterFilling = (i: number): number => {
const percent = fillAt[i] ?? limits.fillPercent
return limits.rangeKm && percent && percent > 0 && percent < 100
? limits.rangeKm * (1 - percent / 100)
: 0
}
for (let i = 0; i < legs.length; i++) {
if (refuelsAt[i]) budget = 0
if (refuelsAt[i]) budget = usedAfterFilling(i)
const leg = legs[i]
// The stop this leg arrives at. Both findings are about what is true on arrival.
const at = i + 1
@@ -370,7 +490,25 @@ export function deriveDriveWarnings(
if (typeof metres !== 'number') {
budget = null
} else if (budget !== null) {
const before = budget
budget += metres / 1000
// Where the tank actually runs dry, as opposed to where somebody notices. The
// warning below sits on the arriving stop, which can be a hundred kilometres past
// the point the fuel ran out; a suggestion has to be offered at the point, or it
// suggests filling up somewhere the car cannot reach.
//
// Once per tank, not once per warning. A long stretch with nothing on it produces
// a run of warnings on purpose (see the note above), and one refuel offer per
// warning would stack three identical offers down one day for a single fill-up.
if (limits.rangeKm && before <= limits.rangeKm && budget > limits.rangeKm) {
const intoLegKm = limits.rangeKm - before
emptyAt.push({
legIndex: i,
intoLegKm,
drivenMeters: drivenMeters + intoLegKm * 1000,
sinceKm: Math.round(limits.rangeKm),
})
}
// Not on a stop that fills up: arriving at a petrol station with an empty tank is
// the plan working, not a problem, and a warning there would sit on the one stop
// that answers it. No reset either — a warning is not a fill-up, and the figure has
@@ -378,18 +516,19 @@ export function deriveDriveWarnings(
if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]) {
warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) })
}
drivenMeters += metres
}
}
// The last stop of the day counts too: filling up on arrival is what makes the next
// morning start with a full tank.
if (refuelsAt[legs.length]) budget = 0
if (refuelsAt[legs.length]) budget = usedAfterFilling(legs.length)
const minutes = Math.round(totalSeconds / 60)
const day = limits.dayMinutes && minutes > limits.dayMinutes
? { code: 'dayDriving' as const, minutes, limitMinutes: limits.dayMinutes }
: null
return { warnings, day, carryKm: budget }
return { warnings, day, carryKm: budget, emptyAt }
}
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
@@ -639,3 +778,26 @@ export function reanchorAfterReorder(
export function isEmptyReanchoring(r: Reanchoring): boolean {
return r.vias.length === 0 && r.remove.length === 0
}
/**
* The road classes a stored setting asks to leave out, validated.
*
* Parsed rather than trusted. A per-user setting has no server-side validation at all —
* the write route stores any key with any value and says so — so an unknown word here
* would travel straight into a costing option the router does not have. Unknown entries
* are dropped, the order is fixed so two equal settings produce one cache key, and
* anything that is not a non-empty list reads as "route normally".
*/
export function parseAvoid(raw: unknown): RouteAvoidClass[] {
if (typeof raw !== 'string' || !raw.trim()) return []
const asked = new Set(raw.split(',').map(part => part.trim().toLowerCase()))
return AVOIDABLE.filter(cls => asked.has(cls))
}
/** Every class that can be avoided, in the order they are offered and stored. */
export const AVOIDABLE: readonly RouteAvoidClass[] = ['toll', 'motorway', 'ferry']
/** The setting value for a set of classes, in the fixed order. */
export function serializeAvoid(classes: readonly RouteAvoidClass[]): string {
return AVOIDABLE.filter(cls => classes.includes(cls)).join(',')
}
@@ -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)
})
})
@@ -0,0 +1,134 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { useSettingsStore } from '../../store/settingsStore'
import { refuelStopTypeFor, type VehicleKind } from './roadtripModel'
import { boxAround, pointAtMeters, type LatLng } from './corridor'
import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } from './refuelSuggestion'
/**
* Somewhere to fill up before the tank runs out, asked for once, when asked for.
*
* Deliberately NOT automatic, for the same reason the corridor search next door is not:
* every run is a real request against a shared service. It is also not the corridor
* search itself, although it looks like a smaller version of it. Three reasons, all
* about behaviour rather than tidiness:
*
* - that search is bound to the day the panel has selected, so it cannot answer a
* warning on day five while the panel shows day one;
* - starting it clears the panel's results and the traveller's own name filter;
* - it asks up to sixteen boxes along a whole day. This question needs one small circle.
*
* One request, one day, no shared state touched.
*/
/**
* How far around the search point to look, in kilometres.
*
* Fourteen and not more, because the server turns a box into a circle of half its
* diagonal and silently narrows anything past 20 km. A box of ±14 asks for 19.8, which
* is the largest question it answers in full — ±25 asked for 35 and came back flagged
* as narrowed every single time, which then read on screen as "we could not check".
*/
const LOOK_KM = 14
/**
* How far BEFORE the dry point to centre that circle, in kilometres.
*
* Only the road already driven is any use: a station past the point is on the far side
* of an empty tank. Centring on the dry point spends half the circle on road nobody can
* reach, so it is pulled back far enough that the useful half becomes the whole of it.
*/
const LOOK_BACK_KM = 12
/** Nothing on offer, as one stable array, so an idle search never redraws the map. */
const NONE: RefuelCandidate[] = []
export interface RefuelSearch {
/** Which dry point is being answered, as `<dayId>:<legIndex>`, or null when idle. */
openFor: string | null
loading: boolean
outcome: RefuelOutcome | null
results: RefuelCandidate[]
/**
* The ones on offer, for the map to draw.
*
* The same three the rail lists and no more: somebody is being asked to accept a stop,
* and a stop that cannot be seen is not one that can be judged. Empty while nothing is
* open, and the same array each time, because the map rebuilds every pin when this
* reference changes.
*/
offered: RefuelCandidate[]
/** Runs the one request. `key` identifies the dry point, so only one is open at a time. */
ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[]) => Promise<void>
close: () => void
}
export function useRefuelSearch(): RefuelSearch {
const { locale } = useTranslation()
// What to look for. Somebody who said they drive an electric car has no use for a
// petrol station in the list, and the other way round; with nothing said, both.
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
const [openFor, setOpenFor] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [outcome, setOutcome] = useState<RefuelOutcome | null>(null)
const [results, setResults] = useState<RefuelCandidate[]>([])
const abortRef = useRef<AbortController | null>(null)
const close = useCallback(() => {
abortRef.current?.abort()
abortRef.current = null
setOpenFor(null)
setResults([])
setOutcome(null)
setLoading(false)
}, [])
const ask = useCallback(async (
key: string,
at: LatLng,
line: LatLng[],
dryAlongKm: number,
existing: LatLng[],
) => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setOpenFor(key)
setResults([])
setOutcome(null)
// Offline the answer would be a list of places that cannot be saved: the place write
// queues offline but the day assignment does not, so accepting one would leave an
// orphan. Say so instead of offering it.
if (isEffectivelyOffline()) {
setOutcome('failed')
return
}
setLoading(true)
try {
const kind: VehicleKind | null =
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
const wanted = refuelStopTypeFor(kind).join(',')
// Pulled back along the road, because only what lies before the dry point can be
// reached. `at` stays the dry point itself: it is what the band names and what the
// map is asked to show.
const centre = pointAtMeters(line, Math.max(0, (dryAlongKm - LOOK_BACK_KM) * 1000)) ?? at
const answer = await mapsApi.pois(wanted, boxAround(centre, LOOK_KM), locale, controller.signal)
if (controller.signal.aborted) return
const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing })
setResults(candidates)
setOutcome(outcomeOf(candidates, answer))
} catch {
if (!controller.signal.aborted) setOutcome('failed')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}, [locale, vehicle])
const offered = useMemo(() => (openFor ? results.slice(0, 3) : NONE), [openFor, results])
return { openFor, loading, outcome, results, offered, ask, close }
}
@@ -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. */
@@ -1,6 +1,6 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { Assignment, AssignmentsMap, Day } from '../../types'
import type { Assignment, AssignmentsMap, Day, Settings } from '../../types'
// Hoisted together with the mock: the module factory runs before the file body, so a
// class declared down there would not exist yet when the hook does its `instanceof`.
@@ -17,6 +17,8 @@ const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs, RoutingRefusedError }))
import { useRoadtripRoutes } from './useRoadtripRoutes'
import { DEFAULT_SETTINGS, useSettingsStore } from '../../store/settingsStore'
import { lineMetres } from './corridor'
const HAMBURG: [number, number] = [53.5511, 9.9937]
const LUENEBURG: [number, number] = [53.2464, 10.4115]
@@ -352,6 +354,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 }]
@@ -368,4 +438,231 @@ describe('useRoadtripRoutes', () => {
unmount()
expect(seen!.aborted).toBe(true)
})
it('FE-ROADTRIP-ROUTES-020: gives each leg its share of the drawn line, not its share of the routed metres', async () => {
// The router reports 50 km a leg off its own graph while the polyline it sends back
// measures 260 km as great-circle hops between the vertices. Cutting at the raw
// metres left every run short by the difference: the second leg ended a third of the
// way into the drive and the day never reached Berlin at all.
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }, { id: 3, at: BERLIN }]
calculateRouteWithLegs.mockResolvedValue(routed(2, [HAMBURG, LUENEBURG, BERLIN]))
const metres = (line: [number, number][]): number => lineMetres(line.map(([lat, lng]) => ({ lat, lng })))
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
await waitFor(() => expect(result.current.loading).toBe(false))
const [first, second] = result.current.lines
expect(first[0]).toEqual(HAMBURG)
// Both ends are interpolated inside the segment they fall in, so the two pieces meet
// on one coordinate and put back together give the run back.
expect(first[first.length - 1]).toEqual(second[0])
// And the last cut lands on the last vertex rather than somewhere short of it.
expect(second[second.length - 1][0]).toBeCloseTo(BERLIN[0], 6)
expect(second[second.length - 1][1]).toBeCloseTo(BERLIN[1], 6)
// Two legs the router called equal take equal shares of the road that was drawn, and
// between them the whole 260 km of it rather than the 100 km the router reported.
const drawn = metres(first) + metres(second)
expect(metres(first) / drawn).toBeCloseTo(0.5, 2)
expect(drawn).toBeCloseTo(metres([HAMBURG, LUENEBURG, BERLIN]), -2)
})
/**
* The switch that turns a trip stored as days into one continuous drive.
*
* Off, a card holds only the driving between its own stops: the road across the join is
* not asked for, not drawn and not counted. On, every one of those gaps becomes a leg
* like any other — and it is the one stretch of a trip that no day run ever covers, so
* a hole left in the chain here is left there by nothing else.
*/
describe('connecting the days', () => {
beforeEach(() => {
// Wholesale, so nothing a previous case set can answer for this one.
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })
})
/** The hook reads the switch off the settings store, the way the limits card writes it. */
const setting = (over: Partial<Settings>): void => {
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, ...over } })) })
}
/** Two ordinary days, with the road from Lueneburg to Berlin left over between them. */
const twoDays = () => ({
days: [day(1, 1), day(2, 2)],
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,
})
/** Every request for one particular road, found by the two ends it was asked for. */
const askedFor = (from: [number, number], to: [number, number]) =>
calculateRouteWithLegs.mock.calls.filter(c =>
c[0][0].lat === from[0] && c[0][c[0].length - 1].lat === to[0])
/** A run whose legs are a round 100 km each, so the range budget reads off the page. */
const hundreds = (legs: number, coordinates: [number, number][]) => ({
coordinates,
distance: legs * 100000,
duration: legs * 3600,
legs: Array.from({ length: legs }, () => ({ distance: 100000, duration: 3600, text: '100 km' })),
})
it('FE-ROADTRIP-ROUTES-021: off, the join is neither driven nor counted; on, it is both', async () => {
const { days: daysList, assignments } = twoDays()
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(result.current.loading).toBe(false))
// A gap the day-at-a-time plan simply has, and the totals say so by leaving it out.
expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(0)
expect(result.current.lines).toHaveLength(2)
expect(result.current.totalDistance).toBe(200000)
setting({ roadtrip_connect_days: true })
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
await waitFor(() => expect(result.current.lines).toHaveLength(3))
expect(result.current.totalDistance).toBe(300000)
})
it('FE-ROADTRIP-ROUTES-022: the joining stroke wears the day it leaves, its kilometres the day it reaches', async () => {
setting({ roadtrip_connect_days: true })
const { days: daysList, assignments } = twoDays()
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(result.current.lines).toHaveLength(3))
// Same length and same order as `lines`, or a colour lands on the wrong day.
expect(result.current.lineDays).toHaveLength(result.current.lines.length)
// The stroke starts at a stop on day 1, so drawing it as day 2 would make day 2
// look like it begins somewhere it has no stop.
expect(result.current.lineDays).toEqual([1, 1, 2])
// The kilometres go the other way: they are driven on the day they arrive.
expect(result.current.days.map(d => d.distance)).toEqual([100000, 200000])
})
it('FE-ROADTRIP-ROUTES-023: a night drive is drawn on the card it reaches only once the switch is on', async () => {
// Stored inside one day and 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 what the switch asks
// about. Its kilometres belong to the day it lands on either way.
const daysList = [day(1, 1), day(2, 2)]
const 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
// Three hours a 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, daysList, assignments))
await waitFor(() => expect(result.current.lines).toHaveLength(2))
expect(result.current.days[0].distance).toBe(200000)
setting({ roadtrip_connect_days: true })
expect(result.current.lines).toHaveLength(3)
expect(result.current.lineDays).toEqual([1, 2, 2])
expect(result.current.days[0].distance).toBe(300000)
})
it('FE-ROADTRIP-ROUTES-024: vias on the join reach the router in sequence order, and come back as one drive', async () => {
setting({ roadtrip_connect_days: true })
const { days: daysList, assignments } = twoDays()
// Filed after the last stop of day 1, which is the join itself. Handed over in the
// wrong order on purpose: driven through its points backwards the seam doubles back.
const vias = {
1: [
{ id: 2, day_id: 1, after_order_index: 1, sequence: 1, lat: 53.0, lng: 11.5 },
{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 },
],
}
// A shaped seam comes back as one leg per waypoint pair.
const inPieces = {
...routed(1),
legs: [
{ distance: 30000, duration: 1000, text: '30 km' },
{ distance: 30000, duration: 1000, text: '30 km' },
{ distance: 40000, duration: 1600, text: '40 km' },
],
}
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) =>
Promise.resolve(wp.length === 4 ? inPieces : routed(1)))
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments, 'driving', vias))
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
const [waypoints] = askedFor(LUENEBURG, BERLIN)[0]
expect(waypoints.map((w: { lat: number }) => w.lat)).toEqual([LUENEBURG[0], 53.2, 53.0, BERLIN[0]])
// Three pieces, one drive: the rail carries the whole road across the join rather
// than the first stretch of it.
await waitFor(() => expect(result.current.days[1].distance).toBe(200000))
})
it('FE-ROADTRIP-ROUTES-025: dragging a via on the join asks for that road again', async () => {
// The first answer is filed under the pair of stops. Skipping the seam whenever any
// answer existed is what made a via on the join do visibly nothing at all.
setting({ roadtrip_connect_days: true })
const { days: daysList, assignments } = twoDays()
const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 }] }
const { rerender } = renderHook(
({ v }: { v: Record<number, typeof vias[1]> }) => useRoadtripRoutes(7, daysList, assignments, 'driving', v),
{ initialProps: { v: vias } },
)
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
rerender({ v: { 1: [{ ...vias[1][0], lat: 53.9, lng: 10.4 }] } })
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(2))
const [waypoints] = askedFor(LUENEBURG, BERLIN)[1]
expect(waypoints[1]).toEqual({ lat: 53.9, lng: 10.4 })
})
it('FE-ROADTRIP-ROUTES-026: a join that will not route leaves both cards their own driving', async () => {
setting({ roadtrip_connect_days: true })
const { days: daysList, assignments } = twoDays()
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) =>
wp[0].lat === LUENEBURG[0] ? Promise.reject(new Error('503')) : Promise.resolve(routed(1)))
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
await waitFor(() => expect(result.current.loading).toBe(false))
// Let the refusal settle: what it must not do is file a leg anyway.
await act(async () => { await Promise.resolve() })
expect(result.current.lines).toHaveLength(2)
expect(result.current.lineDays).toEqual([1, 2])
// Partial rather than invented, exactly like any other leg that will not route.
expect(result.current.totalDistance).toBe(200000)
})
it('FE-ROADTRIP-ROUTES-027: the road across the join is spent from the tank before the next card fills up', async () => {
// A tank does not empty overnight and the join is real driving, so the day it
// reaches starts with those kilometres already on the clock — which moves the point
// the fuel runs out a whole leg earlier.
const SOUTH: [number, number] = [51.5, 13.5]
const FURTHER_SOUTH: [number, number] = [51.05, 13.74]
setting({ roadtrip_range_km: 250 })
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: SOUTH }, { id: 5, at: FURTHER_SOUTH }]),
} as AssignmentsMap
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) => Promise.resolve(
wp[0].lat === BERLIN[0]
? hundreds(2, [BERLIN, SOUTH, FURTHER_SOUTH])
: hundreds(1, wp[0].lat === LUENEBURG[0] ? [LUENEBURG, BERLIN] : [HAMBURG, LUENEBURG])))
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(result.current.days).toHaveLength(2))
await waitFor(() => expect(result.current.days[1].dryPoints).toHaveLength(1))
// 100 km carried over from day 1, so the 250th is reached halfway down the second leg.
expect(result.current.days[1].dryPoints![0]).toMatchObject({ legIndex: 1, drivenMeters: 150000 })
setting({ roadtrip_connect_days: true })
await waitFor(() => expect(result.current.days[1].distance).toBe(300000))
const [dry] = result.current.days[1].dryPoints!
expect(dry).toMatchObject({ legIndex: 0, drivenMeters: 50000 })
// Walked along the roads this day drives, not along the drawn line: 50 km down that
// one is still on the join, north of Berlin, a whole leg from where the tank empties.
expect(dry.lat).toBeLessThan(BERLIN[0])
})
})
})
@@ -2,18 +2,30 @@ 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,
} from './roadtripModel'
import { projectOntoRoute } 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 type { Assignment, AssignmentsMap, Day, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
import { useVehicleRange } from './useVehicleRange'
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
import { spurFor } from './accessSpur'
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
@@ -27,6 +39,17 @@ export interface RoadtripStop {
incomingLegMode: string | null
/** fuel / charging / rest_area / campsite, or null for an ordinary place (#1797). */
stopType: string | null
/**
* How full THIS stop fills up, 1-100, or null to follow the traveller's own setting.
*
* A property of the stop: the motorway rapid charger is worth 80 % because the last
* fifth costs as long again, the one at the hotel is worth all of it because the car
* stands there all night.
*
* Optional, like `offRoadMeters`: absent and null say the same thing, and a stop built
* anywhere that has no opinion about filling up should not have to say so.
*/
fillPercent?: number | null
/**
* How far the router had to go to find a road, in metres, once this stop has routed.
*
@@ -53,6 +76,27 @@ export interface RoadtripDay {
* stop. Kept out of `RouteSegment` because that type is read by half the map.
*/
legVias: RouteVia[][]
/**
* Classes this day was asked to leave out and could not.
*
* Empty when the drive got what it asked for, and empty when nothing was asked. The
* switch says "where possible", and this is what makes that honest rather than a
* hedge: a drive with no untolled crossing comes back on the toll road, and the day
* has to be able to say so instead of wearing a label the road disproves.
*/
avoidMissed?: RouteAvoidClass[]
/**
* Where the tank runs dry on this day, once per fill-up, with a coordinate.
*
* Empty when no range limit is set, when the day never crosses it, or when a leg did
* not route — the budget gives up rather than guessing, and so does this.
*/
dryPoints?: (DryPoint & { lat: number; lng: number })[]
/**
* `geometry` with the non-driving runs left out, which is what `dryPoints` are measured
* along. The same array as `geometry` on a day that drives the whole way.
*/
drivingGeometry?: [number, number][]
/**
* The roads actually driven that day, as [lat, lng] — not the straight lines between
* stops. Anything asking "what is along this day" has to use this: between Hamburg and
@@ -71,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]]
@@ -104,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.
*
@@ -190,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,
@@ -207,6 +290,7 @@ const asStop = (a: Assignment): RoadtripStop | null => {
legMode: a.leg_transport_mode ?? null,
incomingLegMode: a.incoming_leg_transport_mode ?? null,
stopType: p.stop_type ?? null,
fillPercent: typeof p.fill_percent === 'number' ? p.fill_percent : null,
}
}
@@ -247,42 +331,81 @@ export function useRoadtripRoutes(
* filing it twice would draw the spur twice.
*/
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)
const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes)
const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes)
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
const fillPercent = useSettingsStore(s => s.settings.roadtrip_fill_percent)
/**
* Road classes to weight away, as the settings row stores them: a comma list.
*
* Parsed against the known classes rather than trusted, because there is no
* server-side validation for a per-user setting — the write route stores any key with
* any value — and an unknown word here would become a costing option the router does
* 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.
const { vehicleKind, rangeKm: planningRangeKm } = useVehicleRange()
const avoidKey = avoid.join(',')
// Zero and absent both mean "no limit": zero is a legal thing to type and says the
// same thing, so it is folded here rather than guarded at every reading.
const limits = useMemo<DriveLimits>(
() => ({ legMinutes: legMinutes || null, dayMinutes: dayMinutes || null, rangeKm: rangeKm || null }),
[legMinutes, dayMinutes, rangeKm],
() => ({
legMinutes: legMinutes || null,
dayMinutes: dayMinutes || null,
rangeKm: planningRangeKm,
// Only a real fraction counts. Zero, absent and 100 all mean "fills right up",
// which is what the budget did before the setting existed.
fillPercent: fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : null,
}),
[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)
@@ -299,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])
@@ -327,6 +451,7 @@ export function useRoadtripRoutes(
if (!plan.length) {
setLegsByDay({})
setSnapByDay({})
setMissedByDay({})
setLoading(false)
return
}
@@ -339,6 +464,7 @@ export function useRoadtripRoutes(
const collected: Record<number, Record<string, RoutedLeg>> = {}
const collectedSnaps: Record<number, Record<string, SnappedWaypoint>> = {}
const collectedMisses: Record<number, RouteAvoidClass[]> = {}
const tasks: (() => Promise<void>)[] = []
for (const day of plan) {
@@ -353,7 +479,6 @@ export function useRoadtripRoutes(
dfMode,
))
const dayVias = viasByDay[day.dayId] ?? []
for (const { stops: run, mode } of runs) {
tasks.push(async () => {
@@ -367,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 }))
})
@@ -379,7 +509,7 @@ export function useRoadtripRoutes(
try {
const r = await calculateRouteWithLegs(
waypoints,
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId },
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId, avoid },
)
// Where each stop ended up. stopAt[i] is that stop's waypoint index, so the
// vias threaded in between are skipped: a via is a shape handle, not a
@@ -406,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]
@@ -418,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)
@@ -456,6 +613,7 @@ export function useRoadtripRoutes(
if (controller.signal.aborted) return
setLegsByDay({ ...collected })
setSnapByDay({ ...collectedSnaps })
setMissedByDay({ ...collectedMisses })
// Only pace what actually went out. RouteCalculator answers a repeat from its
// cache in well under a millisecond, and switching back into road trip mode is
// all repeats — waiting a second between those made a warm view feel broken.
@@ -466,73 +624,298 @@ 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
}, [planKey, routeProfile, distanceUnit, tripId])
}, [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)),
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),
)
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 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.
const drivingLine = routed
.filter(l => l && l.seg.mode !== undefined && l.seg.mode === 'driving')
.flatMap(l => l?.line ?? [])
const dryPoints = drive.emptyAt
.map(dry => {
const at = pointAtMeters(drivingLine.map(([lat, lng]) => ({ lat, lng })), dry.drivenMeters)
return at ? { ...dry, lat: at.lat, lng: at.lng } : null
})
.filter((d): d is DryPoint & { lat: number; lng: number } => d !== null)
// The same line the dry points are measured along, so anything offered against
// them is projected in the same space. Handed on as the SAME array when the day
// drives all the way through, which is nearly every day — only a day carrying a
// 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,
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])
}
@@ -11,13 +11,43 @@ const { roadtripApi } = vi.hoisted(() => ({
}))
vi.mock('../../api/client', () => ({ roadtripApi }))
// Offline is the hook's headline rule and it has no other way in: `editable` is read
// straight off useNetworkMode, so a test that never drives it can only ever see the
// online answer.
const { networkMode } = vi.hoisted(() => ({ networkMode: { offline: false } }))
vi.mock('../../hooks/useNetworkMode', () => ({ useNetworkMode: () => networkMode }))
// The remote path is driven by hand: the hook hands its handler to addListener, and the
// cases below call it the way a message from another screen would arrive.
const { addListener, removeListener } = vi.hoisted(() => ({
addListener: vi.fn(),
removeListener: vi.fn(),
}))
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener,
removeListener,
}))
import { useRoadtripVias } from './useRoadtripVias'
const via = (over: Partial<{ id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }> = {}) => ({
id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53, lng: 10, ...over,
})
/** Whatever the hook last gave the socket, which is how a change from another screen gets in. */
const socketHandler = (): ((event: Record<string, unknown>) => void) => {
const call = addListener.mock.calls[addListener.mock.calls.length - 1]
if (!call) throw new Error('the hook never subscribed to the socket')
return call[0]
}
beforeEach(() => {
networkMode.offline = false
roadtripApi.listVias.mockReset().mockResolvedValue({ vias: [] })
roadtripApi.addVia.mockReset().mockResolvedValue({ via: via() })
roadtripApi.moveVia.mockReset().mockResolvedValue({ via: via() })
@@ -107,4 +137,141 @@ describe('useRoadtripVias', () => {
expect(roadtripApi.addVia).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-VIAS-008: a via somebody else moved replaces that day and only that day', async () => {
// The screen holds the id the router handed it, a string, and the socket sends the
// number the server broadcasts. Compared as they arrive, every remote change is lost.
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
const { result } = renderHook(() => useRoadtripVias('7', true))
await waitFor(() => expect(result.current.byDay[2]).toHaveLength(1))
const untouched = result.current.byDay[1]
// dayId travels as the route param it came from, so it is a string on the wire too.
act(() => socketHandler()({
type: 'roadtripVia:changed',
tripId: 7,
dayId: '2',
vias: [via({ id: 8, day_id: 2, lat: 48 }), via({ id: 9, day_id: 2, lat: 49 })],
}))
// The server sends the day's whole list, so this replaces rather than merges: a merge
// keeps drawing the point the other screen has just deleted.
expect(result.current.byDay[2].map(v => v.id)).toEqual([8, 9])
expect(result.current.byDay[1]).toBe(untouched)
})
it('FE-ROADTRIP-VIAS-009: a change to another trip is not applied to this one', async () => {
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
const { result } = renderHook(() => useRoadtripVias(7, true))
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
const before = result.current.byDay
act(() => socketHandler()({
type: 'roadtripVia:changed', tripId: 8, dayId: '1', vias: [via({ id: 4, day_id: 1 })],
}))
// The same object, not merely an equal one: this list feeds the routing key, so even
// handing back a fresh copy re-routes the trip for an event meant for somebody else.
expect(result.current.byDay).toBe(before)
})
it('FE-ROADTRIP-VIAS-010: a day emptied elsewhere leaves the map instead of lingering empty', async () => {
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
const { result } = renderHook(() => useRoadtripVias(7, true))
await waitFor(() => expect(result.current.byDay[2]).toHaveLength(1))
act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, dayId: '2', vias: [] }))
// An empty list left behind still answers yes to a `dayId in byDay` check, which is
// how the surfaces ask whether a day is shaped at all.
expect(2 in result.current.byDay).toBe(false)
expect(result.current.byDay[1]).toHaveLength(1)
const before = result.current.byDay
act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, dayId: '2', vias: [] }))
// Nothing left to drop, so nothing new to hand out — a copy here would re-route the
// whole trip for an event that changed nothing.
expect(result.current.byDay).toBe(before)
})
it('FE-ROADTRIP-VIAS-011: only a track event moves the track, and it clears just its own day', async () => {
roadtripApi.listVias.mockResolvedValue({
vias: [via({ id: 1, day_id: 1 })],
tracks: [{ day_id: 1, place_id: 11, stray_km: 0.4 }, { day_id: 2, place_id: 22, stray_km: null }],
})
const { result } = renderHook(() => useRoadtripVias(7, true))
await waitFor(() => expect(result.current.trackByDay[1]?.place_id).toBe(11))
// A via event carries no track at all. Letting it reach the track map would drop the
// badge for the road the day follows every time anybody nudged a point on it.
act(() => socketHandler()({
type: 'roadtripVia:changed', tripId: 7, dayId: '1', vias: [via({ id: 3, day_id: 1 })],
}))
expect(result.current.trackByDay[1]?.place_id).toBe(11)
act(() => socketHandler()({ type: 'roadtripTrack:changed', tripId: 7, dayId: '1', track: null }))
expect(1 in result.current.trackByDay).toBe(false)
expect(result.current.trackByDay[2]?.place_id).toBe(22)
act(() => socketHandler()({
type: 'roadtripTrack:changed', tripId: 7, dayId: '2', track: { day_id: 2, place_id: 33, stray_km: 1 },
}))
expect(result.current.trackByDay[2].place_id).toBe(33)
})
it('FE-ROADTRIP-VIAS-012: the listener follows the trip and goes down with the screen', async () => {
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
const { result, rerender, unmount } = renderHook(({ id }: { id: number }) => useRoadtripVias(id, true), {
initialProps: { id: 7 },
})
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
const first = socketHandler()
rerender({ id: 8 })
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalledWith(8))
const second = socketHandler()
expect(second).not.toBe(first)
expect(removeListener).toHaveBeenCalledWith(first)
// The live handler is the one built for the trip on screen. One kept from the previous
// trip would keep writing that trip's days into this one.
act(() => second({
type: 'roadtripVia:changed',
tripId: 7,
dayId: '1',
vias: [via({ id: 4, day_id: 1 }), via({ id: 5, day_id: 1 })],
}))
expect(result.current.byDay[1]).toHaveLength(1)
// A handler that outlives its screen writes into a trip nobody is looking at.
unmount()
expect(removeListener).toHaveBeenCalledWith(second)
})
it('FE-ROADTRIP-VIAS-014: with no network the map is told not to offer a drag', async () => {
// A via anchors to a POSITION in the day stop list, not to an id, so a write queued
// offline and replayed after somebody else added a stop would re-pin the drive onto
// a leg nobody chose, silently and hours later. Until the anchor is an assignment id
// the honest answer is to stop offering the gesture rather than to lose the edit one
// write at a time.
roadtripApi.listVias.mockResolvedValue({ vias: [] })
networkMode.offline = true
const { result } = renderHook(() => useRoadtripVias(7, true))
expect(result.current.editable).toBe(false)
})
it('FE-ROADTRIP-VIAS-013: an event that names no day is dropped rather than filed under NaN', async () => {
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
const { result } = renderHook(() => useRoadtripVias(7, true))
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
const before = result.current.byDay
act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, vias: [via({ id: 4, day_id: 1 })] }))
// Unguarded this lands under the key NaN, and everything that walks the days then
// routes a day the trip does not have.
expect(result.current.byDay).toBe(before)
expect(Object.keys(result.current.byDay)).toEqual(['1'])
})
})
@@ -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'
@@ -66,7 +67,7 @@ export interface RoadtripVias {
/** Absent leaves the day's track alone, null clears it, an object records a new one. */
track?: { place_id: number; stray_km?: number | null } | null,
) => Promise<void>
move: (dayId: number, id: number, lat: number, lng: number) => Promise<void>
move: (dayId: number, id: number, lat: number, lng: number, afterOrderIndex?: number) => Promise<void>
remove: (dayId: number, id: number) => Promise<void>
/**
* Correct a day's anchors after its stops changed shape.
@@ -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 })
@@ -171,9 +210,16 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean)
await reload()
}, [tripId, reload])
const move = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
const move = useCallback(async (dayId: number, id: number, lat: number, lng: number, afterOrderIndex?: number) => {
if (!tripId) return
await roadtripApi.moveVia(tripId, dayId, id, { lat, lng })
// The anchor rides along when the caller worked out a new one: a via dragged past the
// stop it used to sit before belongs to the next leg now, and saying only where it is
// leaves it claiming the old one.
await roadtripApi.moveVia(tripId, dayId, id, {
lat,
lng,
...(afterOrderIndex === undefined ? {} : { after_order_index: afterOrderIndex }),
})
await reload()
}, [tripId, reload])
@@ -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)
})
})
@@ -0,0 +1,46 @@
import { useMemo } from 'react'
import { useSettingsStore } from '../../store/settingsStore'
import { effectiveRangeKm } from './vehicleRange'
import type { VehicleKind } from './roadtripModel'
/**
* How far the traveller's car goes on one fill, and what kind of car it is.
*
* One place rather than two, because the answer is assembled from seven settings and two
* readers need it: the routes hook, which turns it into the range budget every warning is
* measured against, and the rail, which tells a traveller what a given fill buys at a
* given stop. Assembling it twice is the hand-mirrored state this codebase refuses
* everywhere else, and the two copies would drift the first time a field is added.
*
* Each setting is read as its own primitive so the memo compares numbers. An object
* rebuilt on every render would give the result a new identity every time, and what
* depends on it is the effect that recalculates every day's driving.
*/
export function useVehicleRange(): { vehicleKind: VehicleKind | null; rangeKm: number | null } {
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
// Validated rather than trusted: a per-user setting gets no server-side check, and an
// unknown word here would silently stop both kinds of stop counting as a fill-up.
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
const tankLitres = useSettingsStore(s => s.settings.roadtrip_tank_litres)
const litresPer100 = useSettingsStore(s => s.settings.roadtrip_litres_per_100)
const batteryKwh = useSettingsStore(s => s.settings.roadtrip_battery_kwh)
const kwhPer100 = useSettingsStore(s => s.settings.roadtrip_kwh_per_100)
const degradationPercent = useSettingsStore(s => s.settings.roadtrip_battery_degradation)
const vehicleKind: VehicleKind | null =
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
// The parts win over the typed number when they add up, because they are the more
// specific answer; the settings dialog shows the result, so it is never a surprise.
// Zero and absent both mean "no limit", which is why the falsy fold happens here once.
const planningKm = useMemo(
() => effectiveRangeKm(
vehicleKind,
{ tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent },
rangeKm,
) || null,
[vehicleKind, tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent, rangeKm],
)
return useMemo(() => ({ vehicleKind, rangeKm: planningKm }), [vehicleKind, planningKm])
}
@@ -0,0 +1,129 @@
import { describe, it, expect } from 'vitest'
import { rangeFromSpec, effectiveRangeKm, showSpec, storeSpec, specUnit } from './vehicleRange'
/**
* FE-ROADTRIP-VEHRANGE-001..013 — a range stated as the parts it is made of.
*
* Two things are worth guarding here. The arithmetic itself, because it decides where
* every fuel warning lands, and the unit round trip, because an imperial traveller who
* types 15 into a field labelled gallons must not end up with 15 litres in storage.
*/
describe('rangeFromSpec', () => {
it('FE-ROADTRIP-VEHRANGE-001: a tank divided by a consumption is a range', () => {
expect(rangeFromSpec('combustion', { tankLitres: 55, litresPer100: 7 })).toBe(786)
})
it('FE-ROADTRIP-VEHRANGE-002: a battery divided by a consumption is a range', () => {
expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16 })).toBe(363)
})
it('FE-ROADTRIP-VEHRANGE-003: age comes off the battery before the division', () => {
// 58 kWh minus a tenth is 52.2, and 52.2 at 16 per hundred is 326.25 km.
expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 10 })).toBe(326)
})
it('FE-ROADTRIP-VEHRANGE-004: half a pair is not a range', () => {
// Guessing the missing half would put the warnings at the wrong place while
// looking exact, which is worse than saying nothing.
expect(rangeFromSpec('combustion', { tankLitres: 55 })).toBeNull()
expect(rangeFromSpec('combustion', { litresPer100: 7 })).toBeNull()
expect(rangeFromSpec('electric', { batteryKwh: 58 })).toBeNull()
})
it('FE-ROADTRIP-VEHRANGE-005: figures belong to the kind of car they came from', () => {
// A tank size on an electric car is a leftover from before the picker changed,
// not a battery.
expect(rangeFromSpec('electric', { tankLitres: 55, litresPer100: 7 })).toBeNull()
expect(rangeFromSpec('combustion', { batteryKwh: 58, kwhPer100: 16 })).toBeNull()
expect(rangeFromSpec(null, { tankLitres: 55, litresPer100: 7 })).toBeNull()
})
it('FE-ROADTRIP-VEHRANGE-006: a battery reported as entirely gone is a typo, not a car', () => {
// Capped at 90 %, because zero usable capacity would put the dry point at the
// first metre of the trip and divide by nothing.
expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 100 })).toBe(36)
})
it('FE-ROADTRIP-VEHRANGE-007: nonsense in a field is the same as an empty field', () => {
expect(rangeFromSpec('combustion', { tankLitres: 0, litresPer100: 7 })).toBeNull()
expect(rangeFromSpec('combustion', { tankLitres: -55, litresPer100: 7 })).toBeNull()
expect(rangeFromSpec('combustion', { tankLitres: Number.NaN, litresPer100: 7 })).toBeNull()
})
})
describe('effectiveRangeKm', () => {
it('FE-ROADTRIP-VEHRANGE-008: the parts win over a typed round number', () => {
// Somebody who gave a tank size and a consumption said something more exact
// than "about 500", and the dialog shows them the result.
expect(effectiveRangeKm('combustion', { tankLitres: 55, litresPer100: 7 }, 500)).toBe(786)
})
it('FE-ROADTRIP-VEHRANGE-009: an incomplete pair hands the typed number straight back', () => {
expect(effectiveRangeKm('combustion', { tankLitres: 55 }, 500)).toBe(500)
expect(effectiveRangeKm(null, {}, 500)).toBe(500)
})
it('FE-ROADTRIP-VEHRANGE-010: no figures anywhere is no limit', () => {
expect(effectiveRangeKm(null, {}, 0)).toBeNull()
expect(effectiveRangeKm('electric', {}, undefined)).toBeNull()
})
})
describe('unit round trip', () => {
it('FE-ROADTRIP-VEHRANGE-011: metric figures are stored exactly as typed', () => {
expect(storeSpec('tankLitres', 55, false)).toBe(55)
expect(showSpec('tankLitres', 55, false)).toBe(55)
expect(showSpec('litresPer100', 7.4, false)).toBe(7.4)
})
it('FE-ROADTRIP-VEHRANGE-012: gallons go in and come back out as the same gallons', () => {
// 15 US gallons is 56.78 litres, and storage is litres — the same rule the
// range itself follows, where storage is always kilometres.
const stored = storeSpec('tankLitres', 15, true)
expect(stored).toBe(56.78)
expect(showSpec('tankLitres', stored, true)).toBe(15)
})
it('FE-ROADTRIP-VEHRANGE-013: miles per gallon is the inverse, and survives the trip', () => {
// The one field that is not a scale factor: 30 mpg is 7.84 L/100 km, and a
// straight multiplication would have stored a car four times as thirsty.
const stored = storeSpec('litresPer100', 30, true)
expect(stored).toBe(7.84)
expect(showSpec('litresPer100', stored, true)).toBe(30)
expect(specUnit('litresPer100', true)).toBe('mpg')
expect(specUnit('litresPer100', false)).toBe('L/100 km')
})
it('FE-ROADTRIP-VEHRANGE-014: electric consumption is quoted per 100 miles', () => {
// What the EPA prints on the window sticker, so the figure is one a US
// traveller can copy rather than convert.
expect(showSpec('kwhPer100', 16, true)).toBe(25.7)
expect(specUnit('kwhPer100', true)).toBe('kWh/100 mi')
// A capacity is a capacity everywhere.
expect(specUnit('batteryKwh', true)).toBe('kWh')
expect(showSpec('batteryKwh', 58, true)).toBe(58)
})
it('FE-ROADTRIP-VEHRANGE-016: a consumption typed a hundred times too high does not silence the typed range', () => {
// A consumption of 500 rather than 5.0 makes the parts round to nothing. Zero used
// to count as an answer, so the `??` in effectiveRangeKm never reached the 500 km
// the traveller had typed by hand, and every range warning went quiet with nothing
// on screen to say which field did it.
expect(rangeFromSpec('combustion', { tankLitres: 1, litresPer100: 500 })).toBeNull()
expect(effectiveRangeKm('combustion', { tankLitres: 1, litresPer100: 500 }, 500)).toBe(500)
// Same shape on the other kind of car, where a decimal point is just as easy to lose.
expect(rangeFromSpec('electric', { batteryKwh: 0.5, kwhPer100: 200 })).toBeNull()
expect(effectiveRangeKm('electric', { batteryKwh: 0.5, kwhPer100: 200 }, 420)).toBe(420)
// One kilometre is still a figure, so the cut is below it and not at some round guess.
expect(rangeFromSpec('combustion', { tankLitres: 1, litresPer100: 100 })).toBe(1)
})
it('FE-ROADTRIP-VEHRANGE-015: an empty field stays empty rather than becoming a zero', () => {
expect(showSpec('tankLitres', undefined, false)).toBeUndefined()
expect(showSpec('tankLitres', 0, true)).toBeUndefined()
expect(storeSpec('tankLitres', 0, true)).toBe(0)
})
})
@@ -0,0 +1,170 @@
import type { VehicleKind } from './roadtripModel'
/**
* How far the vehicle goes on one fill, worked out from what it is made of.
*
* Two ways to the same number, and the point of having both is that people know their
* car in different ways. Some know "about 600 km"; some know the tank holds 55 litres
* and it does 7 per hundred, and would have to do the division themselves. Whoever types
* a range directly still gets exactly what they typed — this only fills that number in
* when it can and the field is empty.
*
* Deliberately not a consumption model. ABRP asks for reference speed, relative speed and
* a drive profile because it predicts a specific drive; this answers "how far on one
* fill", which is a division. Adding speed terms would make the number look precise
* without making it right, since the biggest factor by far is how the person drives.
*
* Pure: no React, no store, no network.
*/
export interface VehicleSpec {
/** Litres the tank holds. Petrol only. */
tankLitres?: number | null
/** Litres per 100 km. Petrol only. */
litresPer100?: number | null
/** Usable battery when new, in kWh. Electric only. */
batteryKwh?: number | null
/** kWh per 100 km. Electric only. */
kwhPer100?: number | null
/**
* How much of the battery the years have taken, as a percentage.
*
* A five-year-old car does not carry the capacity on its data sheet, and the gap is
* the difference between arriving and not on the last leg of a day.
*/
degradationPercent?: number | null
}
/** A positive, finite number, or null. Everything here comes from a text field. */
function positive(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
}
/**
* The range in kilometres that the parts add up to, or null when they do not add up.
*
* Null rather than a guess whenever a piece is missing: half a specification is not a
* range, and a made-up figure here becomes a fuel warning that fires at the wrong place.
*/
/**
* A rounded range that came out at nothing is a typo, not a car.
*
* A consumption typed as 500 rather than 5.0 rounds to a range of zero, and zero is a
* number: `effectiveRangeKm` would take it as the answer and throw away the range the
* traveller had typed by hand right next to it. What reached the rail was "no range at
* all", so every warning went quiet, with nothing on screen to say which field did it.
* Reported as nothing instead, so the typed figure gets its turn.
*/
function atLeastAKilometre(km: number): number | null {
return km >= 1 ? km : null
}
export function rangeFromSpec(vehicle: VehicleKind | null | undefined, spec: VehicleSpec): number | null {
if (vehicle === 'combustion') {
const tank = positive(spec.tankLitres)
const per100 = positive(spec.litresPer100)
if (!tank || !per100) return null
return atLeastAKilometre(Math.round((tank / per100) * 100))
}
if (vehicle === 'electric') {
const battery = positive(spec.batteryKwh)
const per100 = positive(spec.kwhPer100)
if (!battery || !per100) return null
// Degradation only ever takes away, and never all of it: a battery reported as 100 %
// gone is a typo, not a car, and dividing by zero range would put the dry point at
// the first metre of the trip.
const lost = Math.min(90, Math.max(0, positive(spec.degradationPercent) ?? 0))
const usable = battery * (1 - lost / 100)
return atLeastAKilometre(Math.round((usable / per100) * 100))
}
return null
}
/**
* The range to plan with: what the parts say, or what was typed, in that order.
*
* The computed one wins because it is the more specific answer — somebody who filled in
* a tank size and a consumption said something more exact than a round number, and the
* dialog shows them the result so it is never a surprise.
*/
export function effectiveRangeKm(
vehicle: VehicleKind | null | undefined,
spec: VehicleSpec,
typedKm: number | null | undefined,
): number | null {
return rangeFromSpec(vehicle, spec) ?? positive(typedKm)
}
/**
* The unit each field is typed in, per measurement system.
*
* Storage is always metric, exactly as the range itself is always kilometres: an imperial
* traveller who typed 15 into a field labelled "gallons" must not have 15 litres stored,
* or every warning after it fires at a quarter of the right distance.
*
* The imperial pair is not a straight translation of the metric one. Fuel economy is
* quoted as miles per gallon, which is the INVERSE of litres per hundred, while electric
* economy is quoted by the EPA as kWh per 100 miles, which is not. Forcing one shape onto
* both would leave one of the two reading as a figure nobody recognises.
*/
export type SpecKey = keyof VehicleSpec
const LITRES_PER_GALLON = 3.785411784
/** litres/100 km × mpg = this. The one constant that makes the inversion exact. */
const MPG_PRODUCT = 235.214583
const KM_PER_MILE = 1.609344
export function specUnit(key: SpecKey, imperial: boolean): string {
switch (key) {
case 'tankLitres': return imperial ? 'gal' : 'L'
case 'litresPer100': return imperial ? 'mpg' : 'L/100 km'
case 'batteryKwh': return 'kWh'
case 'kwhPer100': return imperial ? 'kWh/100 mi' : 'kWh/100 km'
default: return '%'
}
}
/** How many decimals the field is worth typing. A tank is not measured to a tenth. */
function places(key: SpecKey, imperial: boolean): number {
if (key === 'degradationPercent') return 0
if (key === 'tankLitres') return imperial ? 1 : 0
if (key === 'litresPer100') return imperial ? 0 : 1
return 1
}
function convert(key: SpecKey, value: number, imperial: boolean, toStorage: boolean): number {
if (!imperial) return value
switch (key) {
case 'tankLitres': return toStorage ? value * LITRES_PER_GALLON : value / LITRES_PER_GALLON
// Inverse in both directions, which is why one expression covers the pair.
case 'litresPer100': return MPG_PRODUCT / value
case 'kwhPer100': return toStorage ? value / KM_PER_MILE : value * KM_PER_MILE
default: return value
}
}
function round(value: number, decimals: number): number {
const f = 10 ** decimals
return Math.round(value * f) / f
}
/** The stored figure as it should appear in the field, or undefined when there is none. */
export function showSpec(key: SpecKey, stored: number | null | undefined, imperial: boolean): number | undefined {
const value = positive(stored)
if (value === null) return undefined
return round(convert(key, value, imperial, false), places(key, imperial))
}
/**
* A typed figure as it should be stored. Zero comes back as zero, meaning "not said".
*
* Two decimals rather than none: a gallon is 3.79 litres, so an imperial figure that
* round-tripped through a whole number would drift by up to half a litre and show the
* traveller a different number than the one they typed.
*/
export function storeSpec(key: SpecKey, shown: number, imperial: boolean): number {
if (!(shown > 0)) return 0
return round(convert(key, shown, imperial, true), 2)
}
@@ -1,6 +1,6 @@
import type { BookDocument } from '@trek/shared'
import { SpreadView } from './SpreadView'
import { MARK_LENGTH, MARK_WEIGHT, sheetBox, sheetsFor, type Sheet, type SheetMode } from './bookSheets'
import { MARK_LENGTH, MARK_WEIGHT, sheetBox, sheetsFor, type Sheet, type SheetMode, foliosOf } from './bookSheets'
/**
* The book laid out for a press.
@@ -107,7 +107,7 @@ function SheetView({
<SpreadView
spread={sheet.spread}
page={doc.page}
spreadIndex={sheet.spreadIndex}
folios={foliosOf(doc.spreads, sheet.spreadIndex, doc.page.pageNumbers.startAt)}
big
print
/>
+27 -13
View File
@@ -1,6 +1,7 @@
import type { BookPageSetup, BookSpread } from '@trek/shared'
import { fontStack } from './bookFonts'
import { folioInk } from './folioColour'
import { isNumbered } from './bookSheets'
/**
* The folios.
@@ -17,33 +18,46 @@ import { folioInk } from './folioColour'
* not the ones that get printed.
*
* The cover and the back cover carry none. A folio on a cover is a mistake in
* every book ever bound.
* every book ever bound. The single first and last pages carry one each, on
* the side of the book they are bound on (#2317).
*/
export function PageNumbers({
spread, page, spreadIndex,
spread, page, folios,
}: {
spread: BookSpread
page: BookPageSetup
/** Position in the document, cover included. */
spreadIndex: number
/**
* The numbers this spread carries, from foliosOf: two for a spread, one for
* a first or last page, none for a cover. Counted by the caller, which has
* the whole book — a spread on its own does not know how many pages sit
* before it.
*/
folios: readonly number[]
}) {
const cfg = page.pageNumbers
if (!cfg?.show || spread.role !== 'inner') return null
if (!cfg?.show || !isNumbered(spread.role) || folios.length === 0) return null
/*
* The cover is one sheet with a front and a back, so the first inner spread
* opens on `startAt` — 2 by default, the page you see when you open a book
* whose cover is page 1. `spreadIndex` counts the cover, hence the -1.
* Which page of the sheet each number sits on. A spread numbers both; the
* first page is a right-hand leaf and the last a left-hand one, and each
* carries its one number on the side it actually is.
*/
const left = cfg.startAt + (spreadIndex - 1) * 2
const right = left + 1
const sides: Array<['left' | 'right', number]> = spread.role === 'inner'
? [['left', folios[0]], ['right', folios[1]]]
: [[spread.role === 'first' ? 'right' : 'left', folios[0]]]
const size = cfg.size
const y = page.pageHeight - cfg.margin
/** Where the number sits on each page, given which edge it hangs from. */
/**
* Where the number sits on each page, given which edge it hangs from.
*
* A single page is one page wide, so its number sits at x 0 whichever
* side of the book it is bound on; the side only decides which edge is
* the outer one.
*/
const place = (side: 'left' | 'right') => {
const pageX = side === 'left' ? 0 : page.pageWidth
const pageX = side === 'left' || spread.role !== 'inner' ? 0 : page.pageWidth
if (cfg.position === 'centre') {
return { x: pageX, w: page.pageWidth, align: 'center' as const }
}
@@ -58,7 +72,7 @@ export function PageNumbers({
return (
<>
{([['left', left], ['right', right]] as const).map(([side, number]) => {
{sides.map(([side, number]) => {
const at = place(side)
/*
* Sampled at the middle of the number's own box, which is where the
+7 -6
View File
@@ -427,7 +427,7 @@ export function ElementView({
export function SpreadView({
spread,
page,
spreadIndex = 0,
folios = [],
big = false,
showGuides = false,
print = false,
@@ -436,11 +436,12 @@ export function SpreadView({
spread: BookSpread
page: BookPageSetup
/**
* Where this spread sits in the book. Only the folios need it — a page
* number is the one thing on the page that is a function of position rather
* than of the document.
* The page numbers this spread carries, from foliosOf. Only PageNumbers
* needs them — a page number is the one thing on the page that is a
* function of position in the book rather than of the spread itself, and
* the caller is the one holding the book.
*/
spreadIndex?: number
folios?: readonly number[]
big?: boolean
showGuides?: boolean
/** The print renderer passes this: no guides, no placeholders, no chrome. */
@@ -470,7 +471,7 @@ export function SpreadView({
<ElementView key={el.id} el={el} big={big} print={print} dropLabel={dropLabel} />
))}
<PageNumbers spread={spread} page={page} spreadIndex={spreadIndex} />
<PageNumbers spread={spread} page={page} folios={folios} />
{showGuides && (
<>
@@ -59,6 +59,7 @@ const HANDLE_POS: Record<HandleId, { left: string; top: string; cursor: string }
export function StudioCanvas({
spread,
spreadIndex,
folios = [],
page,
zoom,
pxPerMm,
@@ -71,6 +72,8 @@ export function StudioCanvas({
}: {
spread: BookSpread | null
spreadIndex: number
/** The page numbers this spread carries, from foliosOf. */
folios?: readonly number[]
page: BookPageSetup
zoom: number
pxPerMm: number
@@ -256,7 +259,7 @@ export function StudioCanvas({
}}
onPointerDown={() => select([])}
>
<SpreadView spread={spread} page={page} spreadIndex={spreadIndex} big={zoom > 0.34} showGuides dropLabel={dropLabel} />
<SpreadView spread={spread} page={page} folios={folios} big={zoom > 0.34} showGuides dropLabel={dropLabel} />
{cursors && cursors.length > 0 && (
<PeerCursors cursors={cursors} spreadIndex={spreadIndex} zoom={zoom} />
@@ -7,6 +7,7 @@ import {
} from 'lucide-react'
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
import { foliosOf } from './bookSheets'
import { StudioSidebar } from './StudioSidebar'
import { StudioCanvas } from './StudioCanvas'
import { StudioInspector } from './StudioInspector'
@@ -427,6 +428,7 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
<StudioCanvas
spread={s.spread}
spreadIndex={s.activeSpread}
folios={s.doc ? foliosOf(s.doc.spreads, s.activeSpread, s.page.pageNumbers.startAt) : []}
page={s.page}
zoom={s.zoom}
pxPerMm={s.pxPerMm}
@@ -9,6 +9,7 @@ import { useStudioStore } from '../../store/studioStore'
import { formatDate } from '../../utils/formatters'
import { useToast } from '../shared/Toast'
import { SpreadFold, SpreadView } from './SpreadView'
import { folioLabel, foliosOf } from './bookSheets'
import { photoSrc } from './bookRender'
import { elementId as uid } from './bookIds'
import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText'
@@ -200,14 +201,14 @@ function PagesPanel({
transform: `scale(${scale})`, transformOrigin: 'top left',
}}
>
<SpreadView spread={sp} page={page} spreadIndex={i} />
<SpreadView spread={sp} page={page} folios={foliosOf(spreads, i, page.pageNumbers.startAt)} />
</div>
{bookView && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
</div>
<span className="st-thumb-label">
{sp.role === 'cover' ? t('journey.studio.cover')
: sp.role === 'back' ? t('journey.studio.backCover')
: `${i * 2} – ${i * 2 + 1}`}
: folioLabel(foliosOf(spreads, i, page.pageNumbers.startAt))}
</span>
</button>
+33 -9
View File
@@ -1531,7 +1531,7 @@ function backSpread(input: AutoInput): BookSpread {
}
export function buildBook(input: AutoInput): BookDocument {
const spreads: BookSpread[] = [coverSpread(input)]
const spreads: BookSpread[] = [coverSpread(input), blankSpread('first')]
/*
* The summary and the countries open the book, before the entries.
@@ -1572,15 +1572,41 @@ export function buildBook(input: AutoInput): BookDocument {
}
}
flushRun()
spreads.push(backSpread(input))
spreads.push(blankSpread('last'), backSpread(input))
return {
version: 1,
title: input.title.slice(0, MAX_BOOK_TITLE),
page: input.page,
page: withFirstPage(input.page),
spreads: spreads.slice(0, MAX_SPREADS),
}
}
/**
* A page with nothing on it, of the given kind.
*
* The first and the last page of a book are laid down empty on purpose: the
* first is where a title page or a dedication goes, the last where a colophon
* does, and neither is something a layout should decide for a person.
*/
function blankSpread(role: BookSpread['role']): BookSpread {
return { id: uid('sp'), role, background: null, elements: [], parked: [], entryId: null }
}
/**
* The page setup for a book that opens on a single page.
*
* `startAt` numbers the first page inside the covers. A book from before the
* single first page existed opened straight onto a spread and started at 2,
* so that its left-hand page read as the second page of the object. With a
* first page in front of that spread the first page is page 1 — so a book laid
* out with one starts there, whatever the setup it inherited said (#2317).
* Nothing else of the setup is touched: the format and the folio styling are
* the person's, only the count moved with the page that moved.
*/
function withFirstPage(page: BookPageSetup): BookPageSetup {
return { ...page, pageNumbers: { ...page.pageNumbers, startAt: 1 } }
}
/**
* A book nobody has laid out yet.
*
@@ -1592,17 +1618,15 @@ export function buildBook(input: AutoInput): BookDocument {
*
* The covers are here rather than left out because a book has them and there is
* no other way to add one: the pages rail only inserts inner spreads, between
* the two. Empty, though, in the same way the page between them is.
* the two. The single first and last pages likewise (#2317). Empty, though, in
* the same way the spread between them is.
*/
export function emptyBook(input: AutoInput): BookDocument {
const blank = (role: BookSpread['role']): BookSpread => ({
id: uid('sp'), role, background: null, elements: [], parked: [], entryId: null,
})
return {
version: 1,
title: input.title.slice(0, MAX_BOOK_TITLE),
page: input.page,
spreads: [blank('cover'), blank('inner'), blank('back')],
page: withFirstPage(input.page),
spreads: [blankSpread('cover'), blankSpread('first'), blankSpread('inner'), blankSpread('last'), blankSpread('back')],
}
}
+37 -14
View File
@@ -110,6 +110,7 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] {
doc.spreads.forEach((spread, spreadIndex) => {
const single = spread.role !== 'inner'
const spreadWidth = single ? pageWidth : pageWidth * 2
const folios = foliosOf(doc.spreads, spreadIndex, pageNumbers.startAt)
if (single || mode === 'spreads') {
out.push({
@@ -120,7 +121,7 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] {
height: pageHeight,
spreadWidth,
single,
label: single ? '' : folioRange(spreadIndex, pageNumbers.startAt),
label: folioLabel(folios),
})
return;
}
@@ -134,7 +135,7 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] {
height: pageHeight,
spreadWidth,
single: true,
label: String(folio(spreadIndex, pageNumbers.startAt) + half),
label: String(folios[half]),
})
}
})
@@ -142,18 +143,40 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] {
return out
}
/**
* The number the left-hand page of a spread carries.
*
* Mirrors PageNumbers.tsx: the first inner spread opens at `startAt`, and the
* cover is a separate sheet, which is why the index is offset rather than used
* raw. It is only ever a label here — nothing is printed from it.
*/
function folio(spreadIndex: number, startAt: number): number {
return startAt + (spreadIndex - 1) * 2
/** Which of a book's leaves carry a page number: everything inside the covers. */
export function isNumbered(role: BookSpread['role']): boolean {
return role !== 'cover' && role !== 'back'
}
function folioRange(spreadIndex: number, startAt: number): string {
const left = folio(spreadIndex, startAt)
return `${left} – ${left + 1}`
/**
* The page numbers a spread carries, in reading order.
*
* Counted along the book rather than from the index: the covers carry none,
* a first or last page carries one, an inner spread two, and the first
* numbered page is `startAt`. A book from before the single first page
* existed has `startAt` 2 and no such page, so its first spread still reads
* 2 – 3; a book laid out since has a first page and starts at 1, so that page
* is 1 and the same spread behind it is 2 – 3 (#2317). Either way the number
* follows what is in the book, not where the spread sits in the array.
*
* The renderer and the print labels both come through here, so a number seen
* on screen is the one that is printed.
*
* Empty for a cover, and for an index past the end.
*/
export function foliosOf(spreads: readonly BookSpread[], spreadIndex: number, startAt: number): number[] {
const spread = spreads[spreadIndex]
if (!spread || !isNumbered(spread.role)) return []
let next = startAt
for (let i = 0; i < spreadIndex; i++) {
const role = spreads[i].role
if (!isNumbered(role)) continue
next += role === 'inner' ? 2 : 1
}
return spread.role === 'inner' ? [next, next + 1] : [next]
}
/** "3", or "2 – 3" for a spread; nothing for a cover. */
export function folioLabel(folios: readonly number[]): string {
return folios.length === 2 ? `${folios[0]} – ${folios[1]}` : folios.length === 1 ? String(folios[0]) : ''
}
+50 -1
View File
@@ -523,6 +523,44 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
will-change: transform, opacity;
}
/* Roadtrip: die Zzz am Tageswechsel im Streckenstrang. Drei Buchstaben auf einer
Grundlinie, jeder ein Drittel des Laufs nach dem vorigen, damit immer einer
unterwegs ist. Rein dekorativ — das Wort daneben sagt schon, was das Band ist. */
@keyframes trek-doze {
0% { opacity: 0; transform: translate(0, 3px) scale(0.7); }
22% { opacity: 0.9; }
100% { opacity: 0; transform: translate(6px, -10px) scale(1.15); }
}
.trek-doze > * {
animation: trek-doze 3s ease-out infinite;
}
.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); }
@@ -544,13 +582,24 @@ 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);
}
/* Die Zzz stehen still statt zu treiben — der Tageswechsel bleibt lesbar. */
.trek-doze > * {
animation: none;
opacity: 0.6;
transform: none;
}
button:not(:disabled):not([data-no-press]):active,
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):active,
[data-press]:active {
@@ -261,6 +261,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') })
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Bike, Bus, Car, CarTaxiFront, Check, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2 } from 'lucide-react'
import { Bike, Bus, Car, CarTaxiFront, Check, ChevronDown, ChevronUp, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2, X } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import { useAddonStore } from '../../../../store/addonStore'
import { useTranslation } from '../../../../i18n'
@@ -70,6 +70,16 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
}
// The places a driver planned between pick-up and return (#1797), the same shape the
// desktop TransportModal edits. Without an editor here the save below would rebuild the
// endpoint list from from/to alone and the server, which replaces every endpoint row of a
// booking, would drop the stops a phone never showed.
interface CarStopForm {
location: LocationPoint | null
time: string
}
const emptyCarStop = (): CarStopForm => ({ location: null, time: '' })
// A flight is an ordered list of airports; N waypoints = N-1 legs. The origin
// only departs, the destination only arrives, each stop does both.
interface WaypointForm {
@@ -154,6 +164,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
const [toPick, setToPick] = useState<EndpointPick>({})
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
const [carStops, setCarStops] = useState<CarStopForm[]>([])
const [pendingFiles, setPendingFiles] = useState<File[]>([])
// Travelers assigned to this booking (#1517) — seeded from the editing
// reservation on open, persisted separately after the save resolves.
@@ -296,6 +307,17 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
setToPick({ location: locationFromEndpoint(to) || undefined })
setWaypoints([emptyWaypoint(), emptyWaypoint()])
setTrainWaypoints([emptyStationWaypoint(), emptyStationWaypoint()])
// Stops persist for every type; only a car offers an editor for them, so only a
// car reads them back into one. The others keep passing theirs through untouched.
setCarStops(
src.type === 'car'
? (src.endpoints ?? [])
.filter(e => e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
: [],
)
}
} else {
setForm({ ...EMPTY, start_day_id: transportModalDayId ?? '', end_day_id: transportModalDayId ?? '' })
@@ -303,6 +325,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
setToPick({})
setWaypoints([emptyWaypoint(transportModalDayId ?? ''), emptyWaypoint(transportModalDayId ?? '')])
setTrainWaypoints([emptyStationWaypoint(transportModalDayId ?? ''), emptyStationWaypoint(transportModalDayId ?? '')])
setCarStops([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showTransportModal])
@@ -311,6 +334,16 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
const prefill = snap.prefill
const set = (field: keyof typeof EMPTY, value: string | number) => setForm(prev => ({ ...prev, [field]: value }))
const moveCarStop = (index: number, delta: number): void => {
setCarStops(prev => {
const to = index + delta
if (to < 0 || to >= prev.length) return prev
const next = [...prev]
;[next[index], next[to]] = [next[to], next[index]]
return next
})
}
const toggleTraveler = (id: number) => setTravelerIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
@@ -491,7 +524,14 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
})
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
const stops = keepTransit
// A car writes the stops the driver planned; every other type keeps passing the
// itinerary's transfer stops through while the route is unchanged (#1065).
const carEndpoints = form.type === 'car'
? carStops
.filter(s => s.location)
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
: []
const stops = keepTransit && form.type !== 'car'
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
: []
stops.forEach((s, i) => endpoints.push({
@@ -499,7 +539,9 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
}))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
carEndpoints.forEach(e => endpoints.push(e))
const stopCount = stops.length + carEndpoints.length
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null))
}
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
@@ -890,6 +932,72 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('reservations.meta.to')}</Eyebrow>
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
{/* Stops along the drive — cars only (#1797). The rental frame above stays the
pick-up and return; these are the places in between, in order. */}
{form.type === 'car' && (
<>
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('roadtrip.stops.label')}</Eyebrow>
<div className="flex flex-col gap-2">
{carStops.map((stop, i) => (
<div key={i} className="flex items-center gap-2">
{/* The order of these stops IS the route: `sequence` is the array
index at save time. Arrows rather than dragging, because the
row already carries a location picker and a time picker. */}
{carStops.length > 1 && (
<div className="flex shrink-0 flex-col">
<button
type="button"
onClick={() => moveCarStop(i, -1)}
disabled={i === 0}
aria-label={t('dayplan.moveUp')}
className="flex items-center px-1 py-[2px] text-m-muted disabled:opacity-30"
>
<ChevronUp size={14} strokeWidth={2.2} />
</button>
<button
type="button"
onClick={() => moveCarStop(i, 1)}
disabled={i === carStops.length - 1}
aria-label={t('dayplan.moveDown')}
className="flex items-center px-1 py-[2px] text-m-muted disabled:opacity-30"
>
<ChevronDown size={14} strokeWidth={2.2} />
</button>
</div>
)}
<div className="min-w-0 flex-1">
<LocationSelect
value={stop.location}
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
/>
</div>
<div className="w-[92px] shrink-0">
<CustomTimePicker
value={stop.time}
onChange={v => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))}
/>
</div>
<button
type="button"
onClick={() => setCarStops(prev => prev.filter((_, j) => j !== i))}
aria-label={t('roadtrip.stops.remove')}
className="flex shrink-0 items-center p-1 text-m-muted"
>
<X size={15} strokeWidth={2.2} />
</button>
</div>
))}
<button
type="button"
onClick={() => setCarStops(prev => [...prev, emptyCarStop()])}
className="flex w-full items-center justify-center gap-[5px] rounded-full border-[1.5px] border-dashed border-[color:var(--m-rowbr)] py-2 font-geist text-[0.6875rem] font-semibold text-m-muted"
>
<Plus size={12} strokeWidth={2.2} /> {t('reservations.layover.addStop')}
</button>
</div>
</>
)}
{/* Departure row */}
<div className="mt-3 flex gap-2">
<div className="min-w-0 flex-1">
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, Search } from 'lucide-react'
import { mapsApi } from '../../../../api/client'
import { sourceLabelFor } from '../../../../utils/placeSource'
import { recordPlacePick } from '../../../../api/placeShadow'
import { PlacesSession } from '../../../../utils/placesSession'
import { isGoogleMapsUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
@@ -26,10 +27,24 @@ interface Suggestion {
placeId: string
mainText: string
secondaryText: string
/** Which of the two indexes this row came from, when the list is both. */
source?: string
lat?: number
lng?: number
}
type MapsPlace = Record<string, unknown>
/** The same mark the desktop form shows, in the sheet's own tokens. */
function SourceMark({ label }: { label: string | null }) {
if (!label) return null
return (
<span className="shrink-0 rounded-md border border-[color:var(--m-rowbr)] px-1.5 py-0.5 font-geist text-[0.5625rem] font-medium text-m-muted">
{label}
</span>
)
}
interface PlPlaceSearchProps {
planner: TripPlanner
/** Search bias derived from the trip's existing places (trip centre). */
@@ -76,6 +91,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
// has to read the values that belonged to that list. Mirrors PlaceFormModal.
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
// The name the whole list carries, for rows that do not name their own index.
const [acSource, setAcSource] = useState('')
const setResolving = useCallback(
(v: boolean) => {
@@ -93,6 +110,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
try {
const result = await mapsApi.autocomplete(input, language, locationBias, controller.signal, placesSessionRef.current.current())
acMetaRef.current = { query: input, source: result.source || 'unknown' }
setAcSource(result.source || '')
setSuggestions(result.suggestions || [])
} catch (err: unknown) {
// Superseded request — axios rejects an aborted call with CanceledError.
@@ -211,6 +229,20 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
} catch {
// fall through to text search
}
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
// The layer's second line is the local name, not an address, so joining
// the two makes a query nobody typed — and its first answer would be
// silently taken as the place the user picked. The row already knows
// where it is.
place = {
name: suggestion.mainText,
address: '',
lat: suggestion.lat,
lng: suggestion.lng,
osm_id: suggestion.placeId,
source: 'openstreetmap',
}
}
if (!place) {
const fullQuery = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(fullQuery, language, pointFromBox(locationBias))
@@ -269,10 +301,15 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
onClick={() => handleSelectSuggestion(s)}
className="block w-full border-t border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left first:border-t-0"
>
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{s.mainText}</div>
{s.secondaryText && (
<div className="truncate font-geist text-[0.65625rem] text-m-muted">{s.secondaryText}</div>
)}
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{s.mainText}</div>
{s.secondaryText && (
<div className="truncate font-geist text-[0.65625rem] text-m-muted">{s.secondaryText}</div>
)}
</div>
<SourceMark label={sourceLabelFor(s, acSource)} />
</div>
</button>
))}
</div>
+98
View File
@@ -1352,4 +1352,102 @@ describe('SharedTripPage', () => {
await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument());
});
});
// ── #2320: the richer read-only detail ─────────────────────────────────
describe('FE-PAGE-SHARED-038: a stop shows its address, description, both notes, duration and contact (#2320)', () => {
const days = [{ id: 21, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One' }];
const assignments = {
21: [
{
id: 301, day_id: 21, order_index: 0, notes: 'go early, before the coaches',
place: {
id: 501, name: 'Louvre', description: 'The big one', address: 'Rue de Rivoli, Paris',
lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: null,
duration_minutes: 150, notes: 'Skip the pyramid queue', website: 'https://louvre.fr',
phone: '+33 1 40 20 50 50', image_url: null, transport_mode: 'walking', category: null, tags: [],
},
},
],
};
it('renders every field the owner filled, as separate lines', async () => {
await open('detail-token', payload({ days, assignments }));
fireEvent.click(screen.getByText('Day One'));
// The name is also on the map marker's tooltip, so the list row is the one with the address under it.
await waitFor(() => expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument());
expect(screen.getAllByText('Louvre').length).toBeGreaterThan(0);
expect(screen.getByText('The big one')).toBeInTheDocument();
expect(screen.getByText('go early, before the coaches')).toBeInTheDocument();
expect(screen.getByText('Skip the pyramid queue')).toBeInTheDocument();
expect(screen.getByText('2 h 30 min')).toBeInTheDocument();
const website = screen.getByRole('link', { name: /website/i });
expect(website).toHaveAttribute('href', 'https://louvre.fr');
expect(website).toHaveAttribute('target', '_blank');
expect(website).toHaveAttribute('rel', 'noopener noreferrer');
const maps = screen.getByRole('link', { name: /google maps/i });
expect(maps.getAttribute('href')).toContain('google.com/maps/search/');
expect(maps.getAttribute('href')).toContain(encodeURIComponent('Louvre, Rue de Rivoli, Paris'));
expect(maps).toHaveAttribute('rel', 'noopener noreferrer');
const phone = screen.getByRole('link', { name: '+33 1 40 20 50 50' });
expect(phone).toHaveAttribute('href', 'tel:+33140205050');
});
it('leaves out what is empty and never renders a link that is not http(s)', async () => {
const bare = {
21: [{
id: 302, day_id: 21, order_index: 0, notes: null,
place: {
id: 502, name: 'Somewhere', description: null, address: null, lat: null, lng: null,
duration_minutes: 0, notes: ' ', website: 'javascript:alert(1)', phone: null,
category_id: null, price: null, place_time: null, end_time: null, image_url: null,
transport_mode: 'walking', category: null, tags: [],
},
}],
};
await open('bare-token', payload({ days, assignments: bare }));
fireEvent.click(screen.getByText('Day One'));
await waitFor(() => expect(screen.getByText('Somewhere')).toBeInTheDocument());
expect(screen.queryByRole('link', { name: /website/i })).toBeNull();
expect(screen.queryByRole('link', { name: /google maps/i })).toBeNull();
expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
expect(screen.queryByText(/min$/)).toBeNull();
});
});
describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => {
it('renders the note and a link labelled by its host', async () => {
await open('booking-token', payload({
permissions: { share_bookings: true, share_packing: false, share_budget: false, share_collab: false },
reservations: [
{
id: 91, title: 'Night train', type: 'train', status: 'confirmed', day_id: null, end_day_id: null,
reservation_time: '2026-07-01T21:00:00', reservation_end_time: null, metadata: null,
notes: 'Meet at the north entrance', url: 'https://www.bahn.example/booking/abc',
},
{
id: 92, title: 'Sketchy', type: 'other', status: 'pending', day_id: null, end_day_id: null,
reservation_time: null, reservation_end_time: null, metadata: null,
notes: null, url: 'javascript:alert(1)',
},
],
}));
fireEvent.click(screen.getByText('Bookings'));
await waitFor(() => expect(screen.getByText('Night train')).toBeInTheDocument());
expect(screen.getByText('Meet at the north entrance')).toBeInTheDocument();
const link = screen.getByRole('link', { name: 'bahn.example' });
expect(link).toHaveAttribute('href', 'https://www.bahn.example/booking/abc');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(screen.getByText('Sketchy')).toBeInTheDocument();
expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
});
});
});
+6 -15
View File
@@ -32,6 +32,8 @@ import { splitReservationDateTime } from '../utils/formatters';
import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport';
import { resolveBasemap } from '../utils/tileUrl';
import { useSharedTrip } from './sharedTrip/useSharedTrip';
import { SharedPlaceDetails } from './sharedTrip/SharedPlaceDetails';
import { SharedBookingDetails } from './sharedTrip/SharedBookingDetails';
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship };
@@ -738,7 +740,7 @@ export default function SharedTripPage() {
key={`p-${item.data.id}`}
style={{
display: 'flex',
alignItems: 'center',
alignItems: 'flex-start',
gap: 10,
padding: '6px 8px',
borderRadius: 6,
@@ -773,19 +775,7 @@ export default function SharedTripPage() {
>
{place.name}
</div>
{(place.address || place.description) && (
<div
className="text-[#9ca3af]"
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{place.address || place.description}
</div>
)}
<SharedPlaceDetails place={place} assignmentNotes={item.data.notes} />
</div>
{place.place_time && (
<span
@@ -834,7 +824,7 @@ export default function SharedTripPage() {
<div
key={r.id}
className="border border-edge-faint bg-surface-card"
style={{ borderRadius: 10, padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}
style={{ borderRadius: 10, padding: '12px 16px', display: 'flex', alignItems: 'flex-start', gap: 12 }}
>
<div
className="bg-[#f3f4f6]"
@@ -900,6 +890,7 @@ export default function SharedTripPage() {
</span>
)}
</div>
<SharedBookingDetails notes={r.notes} url={r.url} />
</div>
<span
className={
+29 -14
View File
@@ -48,6 +48,7 @@ import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, T
import { ListTodo, Download, Plus, Trash2, FolderPlus } from 'lucide-react'
import { useTripPlanner } from './tripPlanner/useTripPlanner'
import { usePoiExplore } from '../components/Map/usePoiExplore'
import { useMergedMapPois } from '../components/Map/useMergedMapPois'
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
@@ -252,7 +253,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,
overviewActive, tripOverview, toggleOverview, overviewShown,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
@@ -266,9 +267,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
setRoadtripStopKind,
setRoadtripStopFill,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
stayDraft, setStayDraft, setRoadtripStay,
highlightedAlternative, setHighlightedAlternative,
moveRoadtripStopToDay,
@@ -322,7 +324,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
const [glMap, setGlMap] = useState<CompassMap | null>(null)
// The corridor search draws into the same map channel and answers the same question for
// a drive, so the explore pill stands down while road trip mode is on.
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false && !roadtripActive
// Also in road trip mode: searching the view is a different question from searching the
// drive ("is there a hotel at tonight's stop" versus "what is along the way"), and the
// two answers are drawn side by side rather than one hiding the other.
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
// The refuel offers ride the same channel: in road trip mode this is the only way a
// POI reaches the map, so without them somebody is asked to accept a stop they cannot
// see. They vanish with the offer rather than lingering as a search result.
const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois, refuel.offered)
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
// page level so it has tripMembers / base currency / current user available.
@@ -397,10 +406,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 : overviewActive ? tripOverview.lines : route}
routeColors={overviewActive ? tripOverview.lineColors : undefined}
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
routeColors={roadtripActive ? roadtripLineColors : overviewActive ? tripOverview.lineColors : undefined}
routeVias={roadtripActive ? roadtripRoutes.vias : routeVias}
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
showTransitRoutes={transitRoutesShown}
@@ -429,12 +438,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r)
}}
pois={roadtripActive
/* `visible`, not `search.results`: the map is the picture of this very
list, and filtering the list while seventy pins stay on the map no
longer answers "which of these". */
? roadtripCorridor.visible
: poi.pois}
/* In road trip mode the corridor's `visible` (not `search.results`: the map is
the picture of that very list, and filtering the list while seventy pins stay
on the map no longer answers "which of these") plus whatever the category
pill found in view. Outside it, only the pill's hits. */
pois={mapPois}
onPoiClick={handlePoiClick}
// Only while road trip mode is on: outside it there is no drive to drop onto.
onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined}
@@ -443,7 +451,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
alternativeRoutes={alternativeOverlays}
focusPoints={overviewActive ? tripOverview.focusPoints : alternativeFocusPoints}
focusPoints={overviewActive ? tripOverview.focusPoints : mapFocusPoints}
clusterLoosely={roadtripActive}
activeAlternative={highlightedAlternative}
onChooseAlternative={chooseRouteAlternative}
@@ -568,9 +576,15 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
openAlternatives={routeAlternatives.open}
onEditStay={can('place_edit', trip) ? setStayDraft : undefined}
onSetStopKind={can('place_edit', trip) ? setRoadtripStopKind : undefined}
onSetStopFill={can('place_edit', trip) ? setRoadtripStopFill : undefined}
onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined}
viaCounts={roadtripViaCounts}
trackNames={followTrack.namesByDay}
refuel={refuel}
onAskRefuel={askRefuel}
onAcceptRefuel={can('day_edit', trip) ? acceptRefuel : undefined}
collapsedDayIds={collapsedRoadtripDays}
onToggleDay={toggleRoadtripDay}
/>
</LazyPanel>
) : (
@@ -707,6 +721,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
assignments={assignments}
accommodations={tripAccommodations}
selectedDayId={selectedDayId}
onClearSelectedDay={() => handleSelectDay(null)}
selectedPlaceId={selectedPlaceId}
onPlaceClick={handlePlaceClick}
onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true) }}
@@ -874,7 +889,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
</div>
@@ -150,6 +150,9 @@ function baseState(): HookState {
roadtripActive: false,
roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 },
roadtripCorridor: { visible: [], day: null, dayId: '', setDayId: vi.fn(), categories: [], toggleCategory: vi.fn(), widthKm: 5, setWidthKm: vi.fn(), search: vi.fn(), nameFilter: '', setNameFilter: vi.fn(), insertIndexFor: vi.fn() },
refuel: { openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn() },
askRefuel: vi.fn(),
acceptRefuel: vi.fn(),
followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false },
roadtripViaCounts: {},
stopDraft: null,
@@ -0,0 +1,44 @@
import { ExternalLink, FileText } from 'lucide-react'
import { isHttpUrl, linkHost } from './sharedTripModel'
/**
* The note and the link on a shared booking (#2320).
*
* A booking on a public link used to be its title, its time and its status.
* The note the owner attached ("meet at the north entrance") and the booking
* page they linked are what a fellow traveller opens the link for, so both
* show now. What does not show is anything that would let a stranger touch
* the booking: the server keeps the confirmation number and the ticket data
* back, and this block only knows the two fields it is handed.
*
* The link opens in a new tab with no opener, and only when it is http(s) —
* checked here as well as on the server, so the page never trusts a payload
* to have done it.
*/
export function SharedBookingDetails({ notes, url }: { notes?: string | null; url?: string | null }) {
const note = notes?.trim() || null
const link = isHttpUrl(url) ? url.trim() : null
if (!note && !link) return null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginTop: 4 }}>
{note && (
<div className="text-[#374151]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}> {/* theme-lint-disable — public page, no user theme */}
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
<span>{note}</span>
</div>
)}
{link && (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="text-[#2563eb]" // theme-lint-disable — public page, no user theme
style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none', width: 'fit-content' }}
>
<ExternalLink size={10} />
{linkHost(link)}
</a>
)}
</div>
)
}
@@ -0,0 +1,103 @@
import { Clock, ExternalLink, FileText, Globe, MapPin, Phone } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps'
import { formatDurationMinutes, isHttpUrl } from './sharedTripModel'
/**
* The read-only detail of one stop on a shared plan (#2320).
*
* The page used to show a place as a name and, under it, the address *or* the
* description, one string on one line. The owner had written both, and had
* usually also left a note on the place and another on the day it was
* planned for, and a link would show none of that. This shows what the owner
* chose to share, laid out the way the planner lays it out: address, then
* description, then the two notes, then how long they meant to stay, then
* the ways to reach the place.
*
* Nothing here is editable and nothing here is a route into the app: every
* link leaves the page, in a new tab, with no opener. The server has already
* decided what a public viewer may see; this only renders what arrived.
*/
export interface SharedPlaceLike {
name: string
address?: string | null
description?: string | null
notes?: string | null
lat?: number | null
lng?: number | null
duration_minutes?: number | null
website?: string | null
phone?: string | null
place_time?: string | null
end_time?: string | null
}
export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPlaceLike; assignmentNotes?: string | null }) {
const { t } = useTranslation()
const website = isHttpUrl(place.website) ? place.website : null
const phone = place.phone?.trim() || null
const mapsUrl = getGoogleMapsUrlForPlace({
name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null,
google_place_id: null, google_ftid: null,
})
const duration = formatDurationMinutes(place.duration_minutes)
const dayNote = assignmentNotes?.trim() || null
const placeNote = place.notes?.trim() || null
const hasLinks = !!(website || phone || mapsUrl)
return (
<div className="shared-place-details" style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }}>
{place.address && (
<div className="text-[#6b7280]" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'flex', alignItems: 'flex-start', gap: 4 }}> {/* theme-lint-disable — public page, no user theme */}
<MapPin size={10} style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>{place.address}</span>
</div>
)}
{place.description && (
<div className="text-[#4b5563]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}> {/* theme-lint-disable — public page, no user theme */}
{place.description}
</div>
)}
{dayNote && (
<div className="text-[#374151]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }} title={t('places.assignmentNotes')}> {/* theme-lint-disable — public page, no user theme */}
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
<span>{dayNote}</span>
</div>
)}
{placeNote && (
<div className="text-[#6b7280]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }} title={t('places.formNotes')}> {/* theme-lint-disable — public page, no user theme */}
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
<span>{placeNote}</span>
</div>
)}
{(duration || hasLinks) && (
<div className="text-[#6b7280]" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '2px 10px', marginTop: 1 }}> {/* theme-lint-disable — public page, no user theme */}
{duration && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<Clock size={10} />
{duration}
</span>
)}
{mapsUrl && (
<a href={mapsUrl} target="_blank" rel="noopener noreferrer" className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
<ExternalLink size={10} />
{t('planner.openGoogleMaps')}
</a>
)}
{website && (
<a href={website} target="_blank" rel="noopener noreferrer" className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
<Globe size={10} />
{t('places.formWebsite')}
</a>
)}
{phone && (
<a href={`tel:${phone.replace(/[^+\d]/g, '')}`} className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
<Phone size={10} />
{phone}
</a>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { formatDurationMinutes, isHttpUrl, linkHost } from './sharedTripModel'
describe('sharedTripModel (#2320)', () => {
it('accepts http and https and nothing else', () => {
expect(isHttpUrl('https://a.example/x')).toBe(true)
expect(isHttpUrl(' http://a.example ')).toBe(true)
expect(isHttpUrl('javascript:alert(1)')).toBe(false)
expect(isHttpUrl('data:text/html,hi')).toBe(false)
expect(isHttpUrl('ftp://a.example')).toBe(false)
expect(isHttpUrl('not a url')).toBe(false)
expect(isHttpUrl('')).toBe(false)
expect(isHttpUrl(null)).toBe(false)
expect(isHttpUrl(42)).toBe(false)
})
it('writes a planned stay as minutes, hours, or both', () => {
expect(formatDurationMinutes(45)).toBe('45 min')
expect(formatDurationMinutes(60)).toBe('1 h')
expect(formatDurationMinutes(150)).toBe('2 h 30 min')
expect(formatDurationMinutes(89.6)).toBe('1 h 30 min')
})
it('writes nothing for a missing, zero or nonsense figure', () => {
expect(formatDurationMinutes(null)).toBeNull()
expect(formatDurationMinutes(undefined)).toBeNull()
expect(formatDurationMinutes(0)).toBeNull()
expect(formatDurationMinutes(-5)).toBeNull()
expect(formatDurationMinutes(Number.NaN)).toBeNull()
})
it('labels a link by its host, without the www', () => {
expect(linkHost('https://www.bahn.example/booking/abc')).toBe('bahn.example')
expect(linkHost('https://booking.example')).toBe('booking.example')
expect(linkHost('nonsense')).toBe('nonsense')
})
})
@@ -0,0 +1,48 @@
/**
* Pure helpers for the public shared-trip page (#2320). React-free, so the
* page and its detail blocks can share them and a test can drive them
* without rendering.
*/
/**
* Whether a string is a link the page may render as one.
*
* The server already drops anything that is not http(s) (share.service.ts),
* so this is the second gate rather than the first — the page must not become
* the place where a `javascript:` value turns into an anchor because a future
* payload forgot to filter it.
*/
export function isHttpUrl(value: unknown): value is string {
if (typeof value !== 'string' || !value.trim()) return false
try {
const url = new URL(value.trim())
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
/**
* "45 min", "2 h", "1 h 30 min" — the planned time at a place.
*
* Nothing for a missing, zero or negative figure: the planner's default of an
* hour is a default, not a plan, and the field stores what the owner typed.
*/
export function formatDurationMinutes(minutes: number | null | undefined): string | null {
if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) return null
const whole = Math.round(minutes)
const h = Math.floor(whole / 60)
const m = whole % 60
if (h === 0) return `${m} min`
if (m === 0) return `${h} h`
return `${h} h ${m} min`
}
/** The hostname a booking link points at, for a label that says where it goes. */
export function linkHost(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return url
}
}
@@ -1,4 +1,4 @@
// FE-TP-ROAD-001 to FE-TP-ROAD-056
// FE-TP-ROAD-001 to FE-TP-ROAD-074
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -15,6 +15,7 @@ import {
addonsApi, accommodationsApi, authApi, tripsApi, healthApi, airtrailApi, mapsApi,
} from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { dayColor } from '../../components/Roadtrip/dayColors'
/**
* The road trip half of the planner hook.
@@ -89,6 +90,7 @@ const rt = vi.hoisted(() => {
days: [] as Array<Record<string, unknown>>,
quietDays: [] as unknown[],
lines: [] as unknown[],
lineDays: [] as number[],
accessLines: [] as unknown[],
vias: [] as unknown[],
segments: [] as unknown[],
@@ -271,6 +273,8 @@ beforeEach(() => {
rt.vias.stale = false
rt.vias.editable = true
rt.routes.days = []
rt.routes.lines = []
rt.routes.lineDays = []
rt.corridor.day = undefined
rt.corridor.visible = []
rt.corridor.widthKm = 5
@@ -1060,13 +1064,65 @@ describe('useTripPlanner road trip: shaping the drive on the map', () => {
const { result } = await renderRoadtrip()
await act(async () => { await result.current.moveRoadtripVia(5, 3, 53.2, 11.7) })
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 53.2, 11.7)
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 53.2, 11.7, 0)
rt.vias.move.mockRejectedValue(new Error('stale'))
await act(async () => { await result.current.moveRoadtripVia(5, 3, 53.2, 11.7) })
expect(toasts.some(t => t.type === 'error' && t.message === 'stale')).toBe(true)
})
it('FE-TP-ROAD-053: a via dragged past a stop is re-pinned to the leg it landed on', async () => {
// The bug this exists for. A drag used to send only the new coordinates, so a via
// pulled beyond the stop it used to precede kept claiming the earlier leg: the route
// ran out to the point and back before carrying on, which reads as the drag doing
// nothing at all. Three stops so there are two legs to land between.
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
rt.corridor.day = { dayId: 5, dayNumber: 1 }
rt.routes.days = [{
dayId: 5,
stops: [{ lat: 53.55, lng: 9.99 }, { lat: 53.85, lng: 11.45 }, { lat: 52.52, lng: 13.4 }],
geometry: [[53.55, 9.99], [53.87, 10.7], [53.85, 11.45], [53.87, 11.53], [52.52, 13.4]],
}]
const { result } = await renderRoadtrip()
// Dropped on the first leg, then dragged past the middle stop onto the second.
await act(async () => { await result.current.addRoadtripVia(53.87, 10.7) })
expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.87, 10.7)
await act(async () => { await result.current.moveRoadtripVia(5, 9, 53.87, 11.53) })
expect(rt.vias.move).toHaveBeenCalledWith(5, 9, 53.87, 11.53, 1)
})
it('FE-TP-ROAD-054: a drag is measured against its own day, however close another one runs', async () => {
// Placing a via lets the nearest day win, which is right for a click on the map.
// A drag is not that: the via already belongs to a day, and handing it to a
// neighbouring day whose road happens to pass closer would make it vanish from the
// one it was dragged in.
twoRoutedDays()
const { result } = await renderRoadtrip()
// Right on day 6's line, but dragged within day 5.
await act(async () => { await result.current.moveRoadtripVia(5, 3, 47.9, 12.3) })
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 47.9, 12.3, expect.any(Number))
// Day 6 never saw it.
expect(rt.vias.move).not.toHaveBeenCalledWith(6, expect.anything(), expect.anything(), expect.anything(), expect.anything())
})
it('FE-TP-ROAD-055: a drag far off the road still moves, without an anchor to offer', async () => {
// No distance guard on a drag, unlike on a click: pulling a via well away from the
// current road is the whole point of the gesture. A day with no line to measure
// against simply sends no anchor, and the existing pin stays as it was.
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
rt.corridor.day = { dayId: 5, dayNumber: 1 }
rt.routes.days = [{ dayId: 5, stops: [], geometry: [] }]
const { result } = await renderRoadtrip()
await act(async () => { await result.current.moveRoadtripVia(5, 3, 41.9, 12.5) })
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 41.9, 12.5, undefined)
})
it('FE-TP-ROAD-052: removing a via lets the drive take the direct road again', async () => {
twoRoutedDays()
const { result } = await renderRoadtrip()
@@ -1137,3 +1193,346 @@ describe('useTripPlanner road trip: dropping a hit where it belongs', () => {
expect(result.current.stopDraft).toBeNull()
})
})
/** A stop as the rail DRAWS it: a point on a card, naming the day it is STORED on. */
const drawn = (placeId: number, lat: number, lng: number, ownerDayId: number, ownerIndex: number) => ({
placeId, name: `Stop ${placeId}`, lat, lng, ownerDayId, ownerIndex,
})
/** Four drawn legs: two on the first day, one each on the two after it. */
const LINES: [number, number][][] = [
[[53.55, 9.99], [53.2, 10.7]],
[[53.2, 10.7], [52.52, 13.4]],
[[52.52, 13.4], [50.1, 12.0]],
[[50.1, 12.0], [48.13, 11.58]],
]
/** Per-day colours on or off, without dropping the defaults the providers read. */
function setDayColors(on: boolean) {
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, roadtrip_day_colors: on } })
}
describe('useTripPlanner road trip: folding a day off the map', () => {
/**
* Three cards, and two of the places drawn on two cards each: the stop a day ends on is
* the stop the next one sets off from. That overlap is what the second pass below is
* about, and a fixture without it would let a one-pass answer through.
*/
const threeCards = () => {
seedTrip({
days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 }), buildDay({ id: 7, day_number: 3 })],
places: [
buildPlace({ id: 1101, lat: 53.55, lng: 9.99 }),
buildPlace({ id: 1102, lat: 52.52, lng: 13.4 }),
buildPlace({ id: 1103, lat: 50.1, lng: 12.0 }),
buildPlace({ id: 1104, lat: 48.13, lng: 11.58 }),
],
})
rt.corridor.day = { dayId: 5, dayNumber: 1 }
rt.routes.days = [
{ dayId: 5, dayNumber: 1, stops: [drawn(1101, 53.55, 9.99, 5, 0), drawn(1102, 52.52, 13.4, 5, 1)], geometry: LINES[0] },
{ dayId: 6, dayNumber: 2, stops: [drawn(1102, 52.52, 13.4, 6, 0), drawn(1103, 50.1, 12.0, 6, 1)], geometry: LINES[2] },
{ dayId: 7, dayNumber: 3, stops: [drawn(1103, 50.1, 12.0, 7, 0), drawn(1104, 48.13, 11.58, 7, 1)], geometry: LINES[3] },
]
rt.routes.lines = LINES
rt.routes.lineDays = [1, 1, 2, 3]
}
it('FE-TP-ROAD-060: folding a day folds that one alone, and folding it again brings it back', async () => {
// One set for the whole rail, so a header that wrote its own answer over it instead
// of editing a copy would fold one card and unfold every other in the same click.
threeCards()
const { result } = await renderRoadtrip()
act(() => { result.current.toggleRoadtripDay(6) })
act(() => { result.current.toggleRoadtripDay(7) })
expect([...result.current.collapsedRoadtripDays]).toEqual([6, 7])
act(() => { result.current.toggleRoadtripDay(6) })
expect([...result.current.collapsedRoadtripDays]).toEqual([7])
})
it('FE-TP-ROAD-061: a folded card takes ITS lines off the map and leaves the rest drawn', async () => {
// The fold is keyed by day id and the lines are labelled by day number. They are
// different numbers on this trip, as on any trip whose days were not created in
// order, so a filter built from the wrong one quietly hides nothing at all.
threeCards()
const { result } = await renderRoadtrip()
expect(result.current.roadtripMapLines).toHaveLength(4)
act(() => { result.current.toggleRoadtripDay(6) })
expect(result.current.roadtripMapLines).toEqual([LINES[0], LINES[1], LINES[3]])
})
it('FE-TP-ROAD-062: the colours drop with the lines, so what is left still lines up', async () => {
// The map reads the two lists side by side. Leaving a folded day colour in shifts
// every colour after it onto the wrong road, which reads as the days having moved.
threeCards()
const { result } = await renderRoadtrip()
// Off is an absent list rather than a list of blues: a trip that never turns this on
// is handed nothing and the map paints what it always painted.
expect(result.current.roadtripLineColors).toBeUndefined()
act(() => { setDayColors(true) })
act(() => { result.current.toggleRoadtripDay(6) })
expect(result.current.roadtripLineColors).toEqual([dayColor(1), dayColor(1), dayColor(3)])
expect(result.current.roadtripLineColors).toHaveLength(result.current.roadtripMapLines.length)
})
it('FE-TP-ROAD-063: a folded card takes its own stops off the map with it', async () => {
threeCards()
const { result } = await renderRoadtrip()
act(() => { result.current.toggleRoadtripDay(5) })
expect(result.current.roadtripMapPlaces.map(p => p.id)).not.toContain(1101)
// The fold and nothing else: the planner own map still holds it, so a filter that
// dropped the place for some unrelated reason would show up right here.
expect(result.current.mapPlaces.map(p => p.id)).toContain(1101)
})
it('FE-TP-ROAD-064: a stop another card still draws stays, and goes only when that card folds too', async () => {
// The second pass is the whole rule. The place a day ends on is the place the next
// day sets off from, so folding one card must not rub it off the other.
threeCards()
const { result } = await renderRoadtrip()
act(() => { result.current.toggleRoadtripDay(5) })
expect(result.current.roadtripMapPlaces.map(p => p.id)).toEqual([1102, 1103, 1104])
act(() => { result.current.toggleRoadtripDay(6) })
expect(result.current.roadtripMapPlaces.map(p => p.id)).toEqual([1103, 1104])
})
})
describe('useTripPlanner road trip: how full one stop fills up', () => {
it('FE-TP-ROAD-065: the fill is one field on one place, and handing it back sends null rather than nothing', async () => {
// null is not "leave it alone", it is "follow my own setting again" — the only way
// back from a figure typed on a single stop. Left out of the payload the update folds
// it into "unchanged" and the stop keeps the number for good.
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })], assignments: { '5': [stopAt(11, 5, 0)] } })
rt.corridor.day = { dayId: 5, dayNumber: 1 }
const { result } = await renderRoadtrip()
await act(async () => { await result.current.setRoadtripStopFill(1011, 80) })
expect(actions.updatePlace).toHaveBeenCalledWith(42, 1011, { fill_percent: 80 })
actions.updatePlace.mockRejectedValue(new Error('read only'))
await act(async () => { await result.current.setRoadtripStopFill(1011, null) })
expect(actions.updatePlace).toHaveBeenLastCalledWith(42, 1011, { fill_percent: null })
expect(toasts.some(t => t.type === 'error' && t.message === 'read only')).toBe(true)
})
})
describe('useTripPlanner road trip: a card that opens with yesterday stop', () => {
/**
* One card, day 6, whose drive begins with a stop stored on day 5: the night drive
* arrived after midnight, so the rail draws that stop here while the server still files
* it under the day it set off from.
*/
const spilledCard = () => {
seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
rt.corridor.day = { dayId: 6, dayNumber: 2 }
rt.routes.days = [{
dayId: 6,
dayNumber: 2,
stops: [drawn(1102, 53.0, 11.5, 5, 1), drawn(1103, 52.52, 13.4, 6, 0)],
geometry: [[53.55, 9.99], [53.0, 11.5], [52.8, 12.4], [52.52, 13.4]],
}]
}
it('FE-TP-ROAD-066: a via is filed under the day its anchor stop is STORED on, not the card it is drawn on', async () => {
// Written with the card own numbers the via matches no stop the next time the day is
// routed: the road springs back to where it was and the gesture reads as having done
// nothing at all.
spilledCard()
const { result } = await renderRoadtrip()
await act(async () => { await result.current.addRoadtripVia(52.8, 12.4) })
expect(rt.vias.add).toHaveBeenCalledWith(5, 1, 52.8, 12.4)
})
it('FE-TP-ROAD-072: a via dropped on the incoming night drive follows the stop it left from', async () => {
// The stretch between the card first drawn point and its first stop is last night
// driving: it is drawn here, but it leaves from a stop on the card before this one.
// Anchored to this card first stop instead, the via would be filed on the leg AFTER
// that stop, and the route would run forward, double back to the point and carry on.
//
// The rule used to be asked of the insert index, which cannot answer it:
// insertIndexForAlong clamps to at least 1 for any card with two stops or more, and
// the rail publishes no other kind. The guard read correctly and never ran.
spilledCard()
rt.routes.days[0].spills = [{
at: 0,
count: 1,
fromDayNumber: 1,
// The stop the night drive left from: FIRST on day 5, while the first stop drawn
// on this card is the second one, reached after midnight. Different numbers on
// purpose, or the two paths through the lookup would answer alike and this case
// would pass whichever one ran.
fromStop: drawn(1101, 53.55, 9.99, 5, 0),
}]
const { result } = await renderRoadtrip()
// Half way along the night stretch, well before the first stop drawn on this card.
await act(async () => { await result.current.addRoadtripVia(53.3, 10.7) })
expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.3, 10.7)
})
it('FE-TP-ROAD-073: a point before the first stop of an ordinary card still takes that stop', async () => {
// The other half of the same rule, and the reason it is asked of the spill rather
// than of the distance alone: a card that received no night drive has nothing before
// its first stop but its own first leg, so a point there belongs on that leg.
spilledCard()
rt.routes.days[0].spills = []
const { result } = await renderRoadtrip()
await act(async () => { await result.current.addRoadtripVia(53.3, 10.7) })
expect(rt.vias.add).toHaveBeenCalledWith(5, 1, 53.3, 10.7)
})
it('FE-TP-ROAD-067: a drag keeps to the day the via is stored on, wherever that day stops are drawn', async () => {
// Not "the card with that id". After a night drive the stops of day 5 are drawn on
// card 6, and measuring the drag against card 5 alone leaves it with no anchor to
// offer — which is exactly the anchor it needed.
spilledCard()
const { result } = await renderRoadtrip()
await act(async () => { await result.current.moveRoadtripVia(5, 9, 52.8, 12.4) })
expect(rt.vias.move).toHaveBeenCalledWith(5, 9, 52.8, 12.4, 1)
// The same rule the other way round: the stop nearest that point belongs to day 5, so
// a via of day 6 takes no anchor from it and the pin it already has stands.
await act(async () => { await result.current.moveRoadtripVia(6, 9, 52.8, 12.4) })
expect(rt.vias.move).toHaveBeenLastCalledWith(6, 9, 52.8, 12.4, undefined)
})
})
describe('useTripPlanner road trip: somewhere to fill up', () => {
/**
* A day driven straight down the tenth meridian, so a kilometre along the road is a
* kilometre anybody can check: a degree of latitude is 111 km and nothing here bends.
* Its first two stops are stored on the day before, the night drive having arrived
* after midnight, which is what the accepting case turns on.
*/
const drivenCard = () => {
seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
rt.corridor.day = { dayId: 6, dayNumber: 2 }
rt.routes.days = [{
dayId: 6,
dayNumber: 2,
stops: [drawn(1201, 53, 10, 5, 1), drawn(1202, 52, 10, 5, 2), drawn(1203, 51, 10, 6, 0)],
geometry: [[53, 10], [52, 10], [51, 10]],
drivingGeometry: [[53, 10], [52, 10], [51, 10]],
}]
}
/** Where the tank runs out: 190 km into a 222 km day, on the second leg. */
const dry = { legIndex: 1, intoLegKm: 79, drivenMeters: 190_000, sinceKm: 500, lat: 51.29, lng: 10 }
const pump = (name: string, lat: number) => ({
osm_id: `node/${name}`, name, lat, lng: 10, category: 'fuel', poi_type: 'fuel',
address: null, website: null, phone: null, opening_hours: null, cuisine: null,
})
it('FE-TP-ROAD-068: the day own stops go with the question, so a pump already on the plan is not offered again', async () => {
drivenCard()
const pois = vi.spyOn(mapsApi, 'pois').mockResolvedValue({
pois: [pump('Rasthof Dammer Berge', 52.5), pump('Schon geplant', 52)],
source: 'openstreetmap',
truncated: false,
} as never)
const { result } = await renderRoadtrip()
// A day the rail draws no drive for has no line to measure along, and asking anyway
// spends a request on a question that cannot be answered.
act(() => { result.current.askRefuel(999, dry) })
expect(pois).not.toHaveBeenCalled()
await act(async () => { result.current.askRefuel(6, dry) })
await waitFor(() => expect(result.current.refuel.outcome).toBe('found'))
expect(pois).toHaveBeenCalledTimes(1)
// The second one stands on the day own middle stop, and both are within range of the
// dry point — so only the day stops travelling with the question keep it out.
expect(result.current.refuel.results.map(r => r.name)).toEqual(['Rasthof Dammer Berge'])
// Which dry point is being answered, so a second warning cannot read the first list.
expect(result.current.refuel.openFor).toBe('6:1')
})
it('FE-TP-ROAD-069: while offers are on the table the map frames THEM, not the alternatives under them', async () => {
// Somebody is being asked to accept a stop, and a stop off the edge of the map cannot
// be judged. Averaging the two frames would have shown neither properly.
drivenCard()
rt.alt.open = {
dayId: 6, index: 0, loading: false, error: false,
// Two of them, because one road is not a choice and the picker draws nothing for it.
routes: [
{ coordinates: [[53, 10], [51, 10]], distance: 222_000, duration: 8_400, divergence: { lat: 52, lng: 10 } },
{ coordinates: [[53, 10], [52.4, 10.6], [51, 10]], distance: 240_000, duration: 9_000, divergence: { lat: 52.4, lng: 10.6 } },
],
}
vi.spyOn(mapsApi, 'pois').mockResolvedValue({
pois: [pump('Rasthof Dammer Berge', 52.5)], source: 'openstreetmap', truncated: false,
} as never)
const { result } = await renderRoadtrip()
expect(result.current.mapFocusPoints).toEqual(result.current.alternativeFocusPoints)
expect(result.current.mapFocusPoints.length).toBeGreaterThan(0)
await act(async () => { result.current.askRefuel(6, dry) })
await waitFor(() => expect(result.current.refuel.offered).toHaveLength(1))
expect(result.current.mapFocusPoints).toEqual([[52.5, 10]])
})
it('FE-TP-ROAD-070: accepting one closes the offers and opens the popup at the stop OWN day and position', async () => {
// The index the offer was measured at counts along the card, and this card begins
// with two stops stored on the day before. Written with the card numbers the new stop
// lands in the wrong day list, at a position that means something else there.
drivenCard()
vi.spyOn(mapsApi, 'pois').mockResolvedValue({
pois: [pump('Rasthof Dammer Berge', 52.5)], source: 'openstreetmap', truncated: false,
} as never)
const { result } = await renderRoadtrip()
await act(async () => { result.current.askRefuel(6, dry) })
await waitFor(() => expect(result.current.refuel.offered).toHaveLength(1))
// Dry on the first leg, so the stop goes in front of the one it was measured against:
// the card second, which day 5 holds at position 2.
act(() => { result.current.acceptRefuel(6, result.current.refuel.offered[0], { ...dry, legIndex: 0 }) })
expect(result.current.stopDraft).toMatchObject({ dayId: 5, position: 2, dayNumber: 2 })
expect(result.current.refuel.openFor).toBeNull()
})
it('FE-TP-ROAD-074: a reader changing a stop from the rail writes nothing at all', async () => {
// The two one-field writes the rail offers used to go straight to the API while their
// neighbour setRoadtripStay checked first. The server refused them, so nothing was
// ever saved, but the reader got an error toast for touching a control that should
// not have acted — and the rail redrew off a store the write never reached.
asReader('place_edit')
drivenCard()
const { result } = await renderRoadtrip()
await act(async () => { await result.current.setRoadtripStopKind(1102, 'fuel') })
await act(async () => { await result.current.setRoadtripStopFill(1102, 80) })
expect(actions.updatePlace).not.toHaveBeenCalled()
})
it('FE-TP-ROAD-071: a reader is not handed the stop to accept', async () => {
asReader('day_edit')
drivenCard()
const { result } = await renderRoadtrip()
act(() => { result.current.acceptRefuel(6, pump('Rasthof Dammer Berge', 52.5) as never, dry) })
expect(result.current.stopDraft).toBeNull()
})
})
+241 -14
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'
@@ -25,6 +26,8 @@ import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes'
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion'
import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack'
import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternatives'
import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
@@ -35,8 +38,7 @@ import {
reanchorAfterInsert,
reanchorAfterRemove,
reanchorByStopOrder,
reanchorAfterReorder,
} from '../../components/Roadtrip/roadtripModel'
reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel'
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
import type { RoadtripStopType } from '@trek/shared'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
@@ -555,6 +557,7 @@ export function useTripPlanner() {
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
const roadtripActive = !!enabledAddons.roadtrip && roadtripMode
const roadtripVias = useRoadtripVias(tripId, roadtripActive)
const refuel = useRefuelSearch()
const roadtripRoutes = useRoadtripRoutes(
tripId,
roadtripActive ? days : EMPTY_DAYS,
@@ -788,7 +791,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) => {
/**
* 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 {
@@ -895,12 +973,30 @@ export function useTripPlanner() {
* field on one place, and the rail redraws itself off the store the moment it lands.
*/
const setRoadtripStopKind = useCallback(async (placeId: number, kind: RoadtripStopType | null) => {
if (!can('place_edit', trip)) return
try {
await tripActions.updatePlace(tripId, placeId, { stop_type: kind })
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
}, [tripId, tripActions, toast, t])
}, [tripId, tripActions, toast, t, can, trip])
/**
* How full THIS stop fills the tank, from the road trip rail.
*
* The same shape as the kind above and for the same reason: one field on one place,
* with the rail redrawing off the store the moment it lands. Null hands the stop back
* to whatever the traveller set as their own default, which is what every stop does
* until somebody has an opinion about one.
*/
const setRoadtripStopFill = useCallback(async (placeId: number, percent: number | null) => {
if (!can('place_edit', trip)) return
try {
await tripActions.updatePlace(tripId, placeId, { fill_percent: percent })
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
}, [tripId, tripActions, toast, t, can, trip])
/**
* Moves a stop within its day, from the road trip rail.
@@ -1070,6 +1166,7 @@ export function useTripPlanner() {
current: t('roadtrip.alt.current'),
noMotorway: t('roadtrip.alt.noMotorway'),
noToll: t('roadtrip.alt.noToll'),
noFerry: t('roadtrip.alt.noFerry'),
}),
[routeAlternatives.open, t],
)
@@ -1088,6 +1185,21 @@ export function useTripPlanner() {
[alternativeOverlays],
)
/**
* What the map should bring into view.
*
* Refuel offers win while they are open, and for the reason they exist at all: somebody
* is being asked to accept a stop, and a stop off the edge of the map cannot be judged.
* They are the newer, smaller and more specific answer, so they take the view from the
* alternatives rather than being averaged with them into a frame that shows neither.
*/
const mapFocusPoints = useMemo<[number, number][]>(
() => (refuel.offered.length
? refuel.offered.map(p => [p.lat, p.lng] as [number, number])
: alternativeFocusPoints),
[refuel.offered, alternativeFocusPoints],
)
/** Asks the router for other ways of driving one leg of one day. */
const askRouteAlternatives = useCallback((dayId: number, legIndex: number) => {
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
@@ -1148,7 +1260,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'))
@@ -1163,10 +1275,28 @@ export function useTripPlanner() {
* third stop" means the same thing everywhere. The day is the one whose line was hit,
* found by trying each day's geometry and keeping the closest.
*/
const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
if (!can('day_edit', trip)) return
/**
* Which stop of which day a point belongs behind, measured along the drive.
*
* Shared by placing a via and by dragging one, because it is the same question both
* times and the answer has to be recomputed both times. A drag used to send only the
* new coordinates, so a via pulled past the stop it used to precede kept claiming the
* earlier leg: the route then ran out to the point and back before carrying on, which
* looks exactly like a drag that did nothing.
*/
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) {
// 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)
@@ -1174,9 +1304,46 @@ 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 }
// 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.
//
// Asked of the distance rather than of the index, because the index cannot answer
// it: `insertIndexForAlong` clamps to at least 1 for any list of two or more, and
// the rail only ever publishes cards with two stops or more. Written against the
// index this read as a guard and behaved as dead code, so a via dropped on the
// night stretch went to the first drawn stop after all, which is the exact failure
// the paragraph above describes.
const spilledIn = day.spills?.find(sp => sp.at === 0)?.fromStop
if (spilledIn && hit.alongKm < (stopsAlong[0] ?? 0)) {
const owner = spilledIn.ownerDayId ?? day.dayId
if (onlyDayId !== undefined && owner !== onlyDayId) continue
best = { dayId: owner, afterIndex: spilledIn.ownerIndex ?? 0, offRouteKm: hit.offRouteKm }
continue
}
const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
// 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])
const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
if (!can('day_edit', trip)) return
const best = anchorFor(lat, lng)
// A click that landed on some other line is not a via anywhere.
if (!best || best.offRouteKm > 2) return
try {
@@ -1184,17 +1351,75 @@ export function useTripPlanner() {
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
}, [roadtripRoutes.days, roadtripVias, can, trip, toast, t])
}, [anchorFor, roadtripVias, can, trip, toast, t])
/** Dragging a via redraws the route through its new position. */
const moveRoadtripVia = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
if (!can('day_edit', trip)) return
// Measured against this day only. A drag is a drag WITHIN a day: letting the nearest
// day win, the way placing one does, would hand the via to a neighbouring day whose
// road happens to pass closer, and it would vanish from the day it was dragged in.
//
// No distance guard either. Dragging a via well off the current road is the whole
// point of dragging it, and refusing that would be refusing the gesture; the anchor
// just says which leg gets bent, and the router answers the rest.
const anchor = anchorFor(lat, lng, dayId)
try {
await roadtripVias.move(dayId, id, lat, lng)
await roadtripVias.move(dayId, id, lat, lng, anchor?.afterIndex)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
}, [roadtripVias, can, trip, toast, t])
}, [anchorFor, roadtripVias, can, trip, toast, t])
/**
* Somewhere to fill up before this tank runs out.
*
* Measured against the day's DRIVING line, the same one the dry point was placed on, so
* a station's distance along the road is comparable with the distance the fuel lasts.
* The day's own places go in as well: a pump already on the plan should not be offered
* beside itself.
*/
const askRefuel = useCallback((dayId: number, dry: DryPoint & { lat: number; lng: number }) => {
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
if (!day) return
const line = (day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng }))
if (line.length < 2) return
void refuel.ask(
`${dayId}:${dry.legIndex}`,
{ lat: dry.lat, lng: dry.lng },
line,
dry.drivenMeters / 1000,
day.stops.map(stop => ({ lat: stop.lat, lng: stop.lng })),
)
}, [roadtripRoutes.days, refuel])
/**
* Accepting one hands it to the same popup a corridor hit goes through.
*
* Deliberately not a direct write: the popup is where the stop kind and the time spent
* are decided, it defaults both from the category, and every step after it — the place,
* the assignment at the right position, the via re-anchoring, the re-route — is already
* correct there and pinned by tests. A second path to the same end would be a second
* place for it to go wrong.
*/
const acceptRefuel = useCallback((dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => {
if (!can('day_edit', trip)) return
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
if (!day) return
// 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 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: anchor.ownerDayId, position: anchor.ownerIndex, dayNumber: day.dayNumber })
}, [roadtripRoutes.days, refuel, can, trip])
/** Removing a via lets the drive take the direct road again. */
const removeRoadtripVia = useCallback(async (dayId: number, id: number) => {
@@ -1800,7 +2025,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,
overviewShown, toggleOverview, overviewActive, tripOverview,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
@@ -1815,9 +2040,11 @@ export function useTripPlanner() {
placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId,
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
setRoadtripStopKind,
setRoadtripStopFill,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
refuel, askRefuel, acceptRefuel,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
stayDraft, setStayDraft, setRoadtripStay,
highlightedAlternative, setHighlightedAlternative,
moveRoadtripStopToDay,
+12
View File
@@ -61,6 +61,18 @@ export const DEFAULT_SETTINGS: Settings = {
map_tile_url: '',
// Empty = the public routing hosts TREK ships with.
routing_base_url: '',
// Empty = the public Valhalla, unless routing_base_url names an own router, in which
// case no second engine is asked at all. See valhallaBase().
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: '',
+38 -12
View File
@@ -77,6 +77,34 @@ interface StudioState {
canRedo: () => boolean
}
/**
* Where an inner spread may go: after the cover and the first page, before
* the last page and the back cover.
*
* Read from the document each time rather than assumed, because a book from
* before the single first page existed has none, and a book with no covers at
* all (the tests build those) has nothing to stay inside of. The lower bound
* is not decoration either: a book with no inner spreads yet reports its last
* inner one as -1, which asked for a page at 0 — in front of the cover, where
* it could not be moved back from, because a move only ever swaps with
* another inner spread.
*/
function innerBounds(spreads: readonly BookSpread[]): { first: number; limit: number } {
let first = 0
for (let i = 0; i < spreads.length; i++) {
const role = spreads[i].role
if (role === 'cover' || role === 'first') first = i + 1
else break
}
let limit = spreads.length
for (let i = spreads.length - 1; i >= 0; i--) {
const role = spreads[i].role
if (role === 'back' || role === 'last') limit = i
else break
}
return { first, limit: Math.max(first, limit) }
}
function replaceSpread(doc: BookDocument, index: number, fn: (s: BookSpread) => BookSpread): BookDocument {
const spreads = doc.spreads.slice()
if (!spreads[index]) return doc
@@ -197,10 +225,14 @@ export const useStudioStore = create<StudioState>((set, get) => ({
*
* The cover and the back cover are fixed points: a book has exactly one of
* each, they are single pages rather than spreads, and the layouts panel
* already refuses to touch them. So everything here operates on the inner
* spreads between them, and `canEditSpread` is the one place that decides
* what counts as inner — the rail, the menu and the store all ask it rather
* than each testing `role` for themselves.
* already refuses to touch them. The single first and last pages inside
* them are fixed the same way (#2317): a bound book opens onto one
* right-hand page and closes on one left-hand page, and moving either into
* the middle of the book would put a half-width page between two spreads.
* So everything here operates on the inner spreads between them, and
* `canEditSpread` is the one place that decides what counts as inner — the
* rail, the menu and the store all ask it rather than each testing `role`
* for themselves.
*/
canEditSpread: index => {
const sp = get().doc?.spreads[index]
@@ -219,10 +251,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
* move only ever swaps with another inner spread. The first page of an
* empty book is the one this catches.
*/
const cover = doc.spreads.findIndex(sp => sp.role === 'cover')
const back = doc.spreads.findIndex(sp => sp.role === 'back')
const first = cover === -1 ? 0 : cover + 1
const limit = back === -1 ? doc.spreads.length : back
const { first, limit } = innerBounds(doc.spreads)
const at = Math.min(Math.max(index + 1, first), limit)
get().commit(d => ({
...d,
@@ -246,10 +275,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
const doc = get().doc
if (!doc) return
// Same bounds as addSpread: inside the covers at both ends.
const cover = doc.spreads.findIndex(sp => sp.role === 'cover')
const back = doc.spreads.findIndex(sp => sp.role === 'back')
const first = cover === -1 ? 0 : cover + 1
const limit = back === -1 ? doc.spreads.length : back
const { first, limit } = innerBounds(doc.spreads)
const at = Math.min(Math.max(index + 1, first), limit)
get().commit(d => ({
...d,
+73
View File
@@ -115,6 +115,12 @@ export interface Settings {
* FOSSGIS hosts, which allow about one request a second.
*/
routing_base_url?: string
/**
* Base URL of the Valhalla asked for the avoidance questions OSRM cannot answer.
* Undefined means the public FOSSGIS instance TREK ships with; empty means an
* operator turned the second engine off and "other ways" goes back to OSRM alone.
*/
valhalla_base_url?: string
dark_mode: boolean | string
/** Display currency for Costs. Empty/null = follow each trip's own currency. */
default_currency: string | null
@@ -143,6 +149,61 @@ export interface Settings {
roadtrip_leg_minutes?: number
roadtrip_day_minutes?: number
roadtrip_range_km?: number
/**
* Road classes the drive should leave out where it can, as a comma list
* ("toll,ferry"). Absent means route normally, which is the right meaning for absent:
* the settings row does not exist until somebody turns one of them on, and the first
* paint of every session reads this before the settings have loaded.
*
* A list rather than three booleans because it is one decision with three parts, and
* because it goes to the router as one request either way.
*/
roadtrip_avoid?: string
/**
* What the traveller drives: 'combustion', 'electric', or absent for "did not say".
*
* Absent has to mean BOTH kinds refill, which is what the range budget did before this
* existed. Anything else would quietly change the warnings of every traveller who never
* 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.
*
* Nobody charges to 100 % on the road, so an electric traveller who leaves this at full
* gets a range figure a fifth too generous after every stop.
*/
roadtrip_fill_percent?: number
/**
* What the vehicle is made of, for travellers who would rather not do the division.
*
* Optional throughout, and stored METRIC throughout — litres and kilowatt-hours, per
* 100 kilometres — exactly as roadtrip_range_km is always kilometres. The dialog does
* the round trip into gallons, miles per gallon and kWh per 100 miles for an imperial
* traveller; storage stays one system so the arithmetic never has to ask.
*
* When a pair is complete it WINS over roadtrip_range_km, because it is the more
* specific answer. Half a pair computes nothing: a made-up range here would put the
* fuel warnings at the wrong place while looking exact.
*/
roadtrip_tank_litres?: number
roadtrip_litres_per_100?: number
roadtrip_battery_kwh?: number
roadtrip_kwh_per_100?: number
/**
* Percent of the battery the years have taken. Absent means none.
*
* The one figure of ABRP's list that changes the answer here. Plug type, reference
* speed and drive style shape a consumption PREDICTION; this addon does not predict
* one, it divides a capacity by a consumption the traveller states. Degradation is
* different in kind: it shrinks the capacity itself, and on a five-year-old car by
* enough to decide whether the last leg of a day arrives.
*/
roadtrip_battery_degradation?: number
/** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */
carto_api_key?: string
mapbox_access_token?: string
@@ -228,8 +289,20 @@ export interface RouteWithLegs {
vias?: RouteVia[]
/** One entry per REQUESTED waypoint, in request order. Absent on plugin routes. */
snapped?: SnappedWaypoint[]
/**
* What was asked to be left out, and what the road actually left out.
*
* The two differ, and that is the point. Valhalla weights a class away rather than
* banning it, so a drive with no untolled connection comes back on a toll road and
* says so. Present only on a route that was asked to avoid something, so `undefined`
* means the question was never put rather than "avoided nothing".
*/
avoidance?: { asked: RouteAvoidClass[]; achieved: RouteAvoidClass[] }
}
/** A road class a route can be asked to leave out. */
export type RouteAvoidClass = 'motorway' | 'toll' | 'ferry'
export interface RouteResult {
coordinates: [number, number][]
distance: number
+39
View File
@@ -0,0 +1,39 @@
/**
* Which index a place came from, as a short mark beside it.
*
* A result list can be two indexes interleaved, so the source belongs on the
* row rather than above the list: with the TREK index and OpenStreetMap
* answering together, "one of these came from somewhere" is not an answer
* anyone can use.
*
* Here rather than beside one screen because both the desktop form and the
* mobile sheet show the same lists, and a mark that says TREK on one and
* nothing on the other is worse than either alone.
*/
/**
* The three names are proper nouns, so they are not translated, and that is
* also why there is no fourth: a source without a name people already know
* would need a string in 23 languages to say less than nothing.
*/
export const SOURCE_LABELS: Record<string, string> = {
'trek-places': 'TREK',
openstreetmap: 'OpenStreetMap',
nominatim: 'OpenStreetMap',
google: 'Google',
}
/**
* The label for one row.
*
* A place carries its own source when the index that produced it says so, which
* is what makes an interleaved list readable. Everything else falls back to what
* answered the call: Google never marks its places, and a merged list marks only
* the index side, so an unmarked row in one is OpenStreetMap by elimination.
*/
export function sourceLabelFor(place: unknown, listSource: string): string | null {
const own = (place as { source?: unknown } | null)?.source
if (typeof own === 'string' && own) return SOURCE_LABELS[own] ?? null
if (listSource.includes('openstreetmap')) return SOURCE_LABELS.openstreetmap
return SOURCE_LABELS[listSource] ?? null
}

Some files were not shown because too many files have changed in this diff Show More