Merge pull request #2334 from liketrek/codex/roadtrip-day-boundaries

Add configurable Roadtrip day endings and station clusters
This commit is contained in:
Maurice
2026-09-12 15:52:25 +02:00
committed by GitHub
252 changed files with 11402 additions and 3459 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+2
View File
@@ -24,6 +24,7 @@ import { tripRepo } from './repo/tripRepo'
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
@@ -364,6 +365,7 @@ export default function App() {
const { settings } = useSettingsStore()
useInAppNotificationListener()
useRoadtripPreferencesSync()
useEffect(() => {
if (isAuthenticated) {
+7
View File
@@ -0,0 +1,7 @@
import { assignmentSchema, type AssignmentEndDayRequest } from '@trek/shared'
import { apiClient } from './client'
export async function saveAssignmentEndDay(tripId: number | string, id: number, body: AssignmentEndDayRequest) {
const saved = await apiClient.put(`/trips/${tripId}/assignments/${id}/end-day`, body)
return assignmentSchema.parse(saved.data.assignment)
}
+1
View File
@@ -35,6 +35,7 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// second copy of the same list to keep in step.
'roadtripVia:changed',
'roadtripTrack:changed',
'roadtripPreferences:changed',
// In-app notifications — hooks/useInAppNotificationListener
'notification:new',
'notification:updated',
@@ -19,6 +19,7 @@ import {
type GlMapProvider,
} from '../Map/glProviders'
import { useAuthStore } from '../../store/authStore'
import RoutingInstanceFields, { type RoutingDefaults } from './RoutingInstanceFields'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -34,7 +35,7 @@ const MAP_PRESETS = [
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
type Defaults = {
type Defaults = RoutingDefaults & {
temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean
@@ -43,8 +44,6 @@ type Defaults = {
blur_booking_codes?: boolean
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,8 +120,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [routingBase, setRoutingBase] = useState('')
const [valhallaBase, setValhallaBase] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
@@ -132,8 +129,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
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))
@@ -156,8 +151,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
if (key === 'map_tile_url') setMapTileUrl('')
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))
@@ -387,53 +380,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
</div>
)}
{/* Instance configuration, not a taste, which is why it sits here rather
than in a user's own Map settings. The origin has to appear in the CSP
`connect-src` the server emits at boot, and that list is built from
this default — a value on a personal settings row is read by the route
calculator and then refused by the browser, so the whole app stops
routing with no error it could report. The write route refuses one
from a non-admin for the same reason. */}
{!managed && (
<div style={{ marginTop: 14 }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.routingBase')}
<ResetButton field="routing_base_url" />
</label>
<input
type="text"
value={routingBase}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
onBlur={() => save({ routing_base_url: routingBase.trim() })}
placeholder="https://osrm.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.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 className="mt-3.5">
<RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />
</div>
)}
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
@@ -0,0 +1,64 @@
import { useId, useState } from 'react'
import { useTranslation } from '../../i18n/TranslationContext'
export interface RoutingDefaults {
routing_base_url?: string
valhalla_base_url?: string
}
interface RoutingFieldsProps {
defaults: RoutingDefaults
onSave: (patch: RoutingDefaults) => Promise<void>
onReset: (key: keyof RoutingDefaults) => Promise<void>
hintClassName?: string
}
const ROUTERS = [
{ key: 'routing_base_url', label: 'settings.routingBase', hint: 'settings.routingBaseHint', placeholder: 'https://osrm.example.org' },
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
] as const
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 'text-caption leading-relaxed text-content-faint' }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
const { t } = useTranslation()
const id = useId()
const [draft, setDraft] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const saved = defaults[router.key] ?? ''
const commit = async () => {
if (draft === null) return
const value = draft.trim()
if (value === saved) { setDraft(null); return }
setSaving(true)
try { await onSave({ [router.key]: value }) }
finally { setDraft(null); setSaving(false) }
}
const reset = async () => {
setSaving(true)
try { await onReset(router.key) }
finally { setDraft(null); setSaving(false) }
}
return (
<div className="min-w-0 space-y-1.5" aria-busy={saving}>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<label htmlFor={id} className="text-body font-medium text-content-secondary">{t(router.label)}</label>
{defaults[router.key] !== undefined && (
<button type="button" disabled={saving} onClick={reset} className="text-caption text-content-muted underline disabled:opacity-50">
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
)}
</div>
<input id={id} type="url" inputMode="url" value={draft ?? saved} disabled={saving}
onChange={e => setDraft(e.target.value)} onBlur={commit}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
placeholder={router.placeholder} spellCheck={false} autoComplete="off" autoCapitalize="none"
aria-describedby={`${id}-hint`}
className="min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
/>
<p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
</div>
)
}
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
return <div className="flex flex-col gap-3">{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
}
@@ -0,0 +1,37 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Marker, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
}) {
const map = useMap()
const { t } = useTranslation()
const [zoom, setZoom] = useState(() => map.getZoom())
const popup = useRef<L.Popup | null>(null)
useEffect(() => () => { popup.current?.remove() }, [pois, enabled, zoom])
useEffect(() => {
const update = () => setZoom(map.getZoom())
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng]), zoom) : [], [pois, enabled, map, zoom])
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
if (!enabled) return <>{children}</>
return <>{groups.map(group => {
if (group.pois.length === 1) return markers.get(group.pois[0].osm_id)
const label = t('roadtrip.poi.found', { count: group.pois.length })
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, group.lng]} alt={label}
icon={L.divIcon({ className: '', html: poiClusterMarkup(group.pois.length), iconSize: [38, 38], iconAnchor: [19, 19] })}
zIndexOffset={600} eventHandlers={{ click: () => {
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
popup.current?.remove()
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, group.lng])
.setContent(poiClusterList(group.pois, poi => { popup.current?.remove(); onPoiClick?.(poi) })).openOn(map)
} else map.fitBounds(group.pois.map(poi => [poi.lat, poi.lng] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} }} />
})}</>
}
@@ -0,0 +1,15 @@
import { GeoJSON } from 'react-leaflet'
import type { RoadtripHazard } from '@trek/shared'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { hazardPopup, hazardFeature } from './hazardPopup'
export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) {
const { t } = useTranslation()
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
pointToLayer={(_, point) => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })}
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip trek-hazard-popup', maxWidth: 320 })}
/>)}</>
}
+42 -2
View File
@@ -11,6 +11,7 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import * as photoService from '../../services/photoService'
const mapMock = vi.hoisted(() => ({
getContainer: vi.fn(() => document.createElement('div')),
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
@@ -64,6 +65,7 @@ vi.mock('react-leaflet', () => ({
TileLayer: () => <div data-testid="tile-layer" />,
Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
<div
ref={node => { if (node && zIndexOffset === 500) eventHandlers?.add?.({ target: { getElement: () => node } }) }}
data-testid="marker"
data-lat={position[0]}
data-lng={position[1]}
@@ -266,9 +268,9 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBe(3)
})
it('FE-COMP-MAPVIEW-010: MarkerClusterGroup is rendered', () => {
it.each([false, true])('FE-COMP-MAPVIEW-010: place clustering stays enabled with roadtrip=%s', (roadtrip) => {
const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} />)
render(<MapView places={places} clusterLoosely={roadtrip} />)
expect(screen.getByTestId('cluster-group')).toBeTruthy()
})
@@ -650,6 +652,23 @@ describe('MapView explore POIs', () => {
expect(onPoiClick).toHaveBeenCalledWith(poi)
})
it('keeps native POI clicks working after a pan and uses the latest callback', () => {
const first = vi.fn()
const latest = vi.fn()
const poi = buildPoi({ osm_id: 'node/7' })
const { rerender } = render(<MapView pois={[poi]} onPoiClick={first} onPoiDropOnRoute={() => {}} />)
const marker = markersWithZ('500')[0]
expect(marker).toHaveAttribute('draggable', 'true')
fireEvent.mouseDown(marker)
fireEvent.mouseUp(marker)
fireEvent.click(marker)
expect(first).toHaveBeenCalledTimes(1)
rerender(<MapView pois={[poi]} onPoiClick={latest} onPoiDropOnRoute={() => {}} />)
fireEvent.click(markersWithZ('500')[0])
expect(latest).toHaveBeenCalledExactlyOnceWith(poi)
expect(first).toHaveBeenCalledTimes(1)
})
it('FE-COMP-MAPVIEW-035: an unknown POI category falls back to grey and draws no glyph', () => {
render(<MapView pois={[buildPoi({ osm_id: 'node/8', category: 'not-a-category' })]} />)
const html = iconHtmlOf(markersWithZ('500')[0])
@@ -698,6 +717,27 @@ describe('MapView plugin route vias', () => {
expect(markersWithZ('800')[0].textContent).toContain('Supercharger · 1 h 30 min')
})
it('hides night badges at wide zoom and shows the full description when zoomed in', () => {
mapMock.getZoom.mockReturnValue(5)
const label = 'Tagesende von Tag 1 um 18:00 Uhr'
render(<MapView routeVias={[via({ label, hoverCard: true, nightPause: { day: 1, atPlace: true } })]} />)
expect(markersWithZ('800')).toHaveLength(0)
mapMock.getZoom.mockReturnValue(6)
act(() => { mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]() })
const badge = markersWithZ('800')[0]
expect(iconHtmlOf(badge)).toContain('data-night-pause="place"')
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByRole('tooltip')).toHaveTextContent(label)
mapMock.getZoom.mockReturnValue(5)
act(() => {
mapMock.on.mock.calls.find(([events]) => events === 'movestart zoomstart')![1]()
mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]()
})
expect(markersWithZ('800')).toHaveLength(0)
expect(screen.queryByRole('tooltip')).toBeNull()
mapMock.getZoom.mockReturnValue(10)
})
it('FE-COMP-MAPVIEW-041: a dwell under an hour is shown in minutes alone', () => {
render(<MapView routeVias={[via({ dwellSeconds: 1500 })]} />)
expect(markersWithZ('800')[0].textContent).toContain('25 min')
+78 -36
View File
@@ -5,6 +5,7 @@ import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import HazardLayers from './HazardLayers'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
@@ -44,6 +45,12 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { escapeHtml } from '@trek/shared'
import type { Day, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import NightPauseTooltip from './NightPauseTooltip'
import ClusteredPois from './ClusteredPois'
import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
@@ -98,6 +105,26 @@ function formatViaDwell(seconds: number): string {
return h > 0 ? `${h} h ${m} min` : `${m} min`
}
function nightPauseIcon(via: RouteVia): L.DivIcon {
const key = `night:${via.nightPause!.day}:${via.nightPause!.atPlace}`
const cached = viaIconCache.get(key)
if (cached) return cached
const icon = L.divIcon({ className: 'night-pause-marker', html: nightPauseMarker(via), iconSize: [0, 0], iconAnchor: [0, 0] })
viaIconCache.set(key, icon)
return icon
}
function RouteViaMarker({ via, controls, eventHandlers, children }: {
via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode
}) {
const [marker, setMarker] = useState<L.Marker | null>(null)
return <Marker ref={setMarker} position={[via.lat, via.lng]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
alt={via.nightPause ? via.label : undefined} zIndexOffset={800} eventHandlers={eventHandlers}>
{children}
{via.nightPause && <NightPauseDrag marker={marker} via={via} controls={controls} />}
</Marker>
}
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
@@ -226,15 +253,16 @@ function createPoiIcon(category: string, brandWikidata?: string | null) {
// away under a stationary cursor, so the browser never fires mouseout — and
// mouseover/mousemove during the pan animation would immediately re-set the
// tooltip we just cleared (#1404).
function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: boolean }; onMoveStart: () => void }) {
function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { current: boolean }; onMoveStart: () => void; onZoom: (zoom: number) => void }) {
const map = useMap()
useEffect(() => {
const start = () => { movingRef.current = true; onMoveStart() }
const end = () => { movingRef.current = false }
const end = () => { movingRef.current = false; onZoom(map.getZoom()) }
onZoom(map.getZoom())
map.on('movestart zoomstart', start)
map.on('moveend zoomend', end)
return () => { map.off('movestart zoomstart', start); map.off('moveend zoomend', end) }
}, [map, movingRef, onMoveStart])
}, [map, movingRef, onMoveStart, onZoom])
return null
}
@@ -638,6 +666,8 @@ export const MapView = memo(function MapView({
onViewportChange,
tripId,
routeVias = [],
dayBoundaryControls,
hazards,
accessLines = [],
onPoiDropOnRoute,
onRouteClick,
@@ -655,9 +685,12 @@ export const MapView = memo(function MapView({
// that is decides which layer draws it. A saved raster template still wins,
// the default is a vector style.
const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl])
const poiClickRef = useRef(onPoiClick)
poiClickRef.current = onPoiClick
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, poi.lng]}
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
zIndexOffset={500}
@@ -667,7 +700,11 @@ export const MapView = memo(function MapView({
// itself, and this is the first moment there is one to attach to.
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
const el = e.target.getElement()
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
if (!el) return
// Native clicks avoid Leaflet suppressing a click after an earlier map drag.
el.setAttribute('aria-label', poi.name)
el.onclick = event => { event.stopPropagation(); poiClickRef.current?.(poi) }
if (onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
},
}}
>
@@ -712,11 +749,12 @@ export const MapView = memo(function MapView({
})
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
const [hoveredPlace, setHoveredPlace] = useState<MapHoverInfo | null>(null)
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
const [mapZoom, setMapZoom] = useState(0)
const mapMovingRef = useRef(false)
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
const handleMarkerHover = useCallback((place: MapHoverInfo, x: number, y: number) => {
if (hoverDisabled || mapMovingRef.current) return
setHoveredPlace(place)
setTooltipPos({ x, y })
@@ -920,6 +958,7 @@ export const MapView = memo(function MapView({
), [gpxTracks, hasCasingPane])
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
&& (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia))
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
@@ -1001,32 +1040,25 @@ export const MapView = memo(function MapView({
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
<ViewportController onViewportChange={onViewportChange} />
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
zoomed right out, and a day's stops merging into one dot is exactly the shape
it is being looked at for. Rendered outside the group rather than with the
clustering disabled by option, so nothing is left to a plugin's own idea of
what "disabled" means. */}
{clusterLoosely ? markers : (
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
)}
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
{/* 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
@@ -1130,19 +1162,26 @@ export const MapView = memo(function MapView({
roadRoutes={transportRoutes}
/>
{poiMarkers}
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
{/* Charging stops / rest areas a plugin route places on the drawn day route.
Host-vetted data (server-normalized), rendered as plain tone dots. */}
{(routeVias as RouteVia[]).map((v, i) => (
<Marker key={`route-via-${i}`} position={[v.lat, v.lng]} icon={routeViaIcon(v.tone)} zIndexOffset={800}>
{(v.label || v.dwellSeconds != null) && (
<Tooltip direction="top" offset={[0, -8]}>
{(routeVias as RouteVia[]).filter(v => !v.nightPause || mapZoom >= NIGHT_PAUSE_MIN_ZOOM).map((v, i) => (
<RouteViaMarker key={v.nightPause ? `night-${v.nightPause.day}` : `route-via-${i}`} via={v} controls={dayBoundaryControls}
eventHandlers={v.hoverCard ? {
mouseover: e => handleMarkerHover({ name: v.label, routeVia: v }, e.originalEvent.clientX, e.originalEvent.clientY),
mousemove: e => handleMarkerHover({ name: v.label, routeVia: v }, e.originalEvent.clientX, e.originalEvent.clientY),
mouseout: handleMarkerHoverOut,
} : undefined}
>
{(!v.hoverCard || isTouchDevice) && (v.label || v.dwellSeconds != null) && (
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
{v.label}
{v.label && v.dwellSeconds != null ? ' · ' : ''}
{v.dwellSeconds != null ? formatViaDwell(v.dwellSeconds) : ''}
</Tooltip>
)}
</Marker>
</RouteViaMarker>
))}
<PluginMapMarkers tripId={tripId} />
<PluginMapLayers tripId={tripId} />
@@ -1157,12 +1196,15 @@ export const MapView = memo(function MapView({
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
smaller gap its shadow ran into the sidebar edge and the two read as one
surface. */}
<div style={{ position: 'absolute', left: leftWidth + 20, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
<div style={{ position: 'absolute', left: leftWidth + 20, top: hasInspector ? 80 : undefined, bottom: hasInspector ? undefined : switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
</div>
</div>
{TooltipOverlay && (
{TooltipOverlay && hoveredPlace.routeVia?.nightPause && (
<NightPauseTooltip label={hoveredPlace.name ?? ''} x={tooltipPos.x} y={tooltipPos.y} />
)}
{TooltipOverlay && !hoveredPlace.routeVia?.nightPause && (
<div data-testid="tooltip" style={{
position: 'fixed',
left: tooltipPos.x + 14,
+11 -4
View File
@@ -1,8 +1,12 @@
import { Suspense } from 'react'
import { AlertTriangle, Clock, Info } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView'
import ErrorBoundary from '../shared/ErrorBoundary'
import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
import { useRoadtripHazards } from './useRoadtripHazards'
import { useTranslation } from '../../i18n/TranslationContext'
// Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
@@ -14,6 +18,9 @@ import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
// them online (see the GL tile rules in vite.config.js), not prefetched.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) {
const { t } = useTranslation()
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
const mapProps = { ...props, hazards: hazards.feed?.hazards }
const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token)
// Fall back to Leaflet when Mapbox is selected but no token is set,
@@ -33,12 +40,12 @@ export function MapViewAuto(props: any) {
// to Leaflet keeps a usable map instead of an error card.
// resetKeys: with two engine chunks, a failure under one provider must not
// keep showing Leaflet after the user switches to the other.
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...props} />}>
<Suspense fallback={<MapView {...props} />}>
<MapViewGL {...props} glProvider={glProvider} />
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
<Suspense fallback={<MapView {...mapProps} />}>
<MapViewGL {...mapProps} glProvider={glProvider} />
</Suspense>
</ErrorBoundary>
)
}
return <MapView {...props} />
return <MapView {...mapProps} />
}
@@ -1389,6 +1389,32 @@ describe('MapViewGL', () => {
expect(glMarkers.created[1].element.innerHTML).toContain('#6b7280')
})
it('clusters roadtrip stations and zooms before offering overlapping stations individually', async () => {
loadOnAttach()
vi.mocked(glMap.getZoom).mockReturnValue(8)
const poi: Poi = {
osm_id: 'n1', name: 'Station A', lat: 48.854, lng: 2.332, category: 'charging_station',
poi_type: 'charging_station', address: null, website: null, phone: null,
opening_hours: null, cuisine: null, source: 'openstreetmap',
}
const other = { ...poi, osm_id: 'n2', name: 'Station B' }
const onPoiClick = vi.fn()
render(<MapViewGL places={[]} fitKey={1} pois={[poi, other]} clusterLoosely onPoiClick={onPoiClick} />)
await act(async () => {})
const cluster = glMarkers.created.find(pin => pin.element.querySelector('[data-poi-cluster="2"]'))!
expect(cluster).toBeDefined()
act(() => { cluster.element.click() })
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), { padding: 70, maxZoom: 18 })
expect(onPoiClick).not.toHaveBeenCalled()
vi.mocked(glMap.getZoom).mockReturnValueOnce(18)
act(() => { cluster.element.click() })
const calls = vi.mocked(glPopup.setDOMContent).mock.calls
const list = calls[calls.length - 1][0]
act(() => { list.querySelectorAll('button')[1].click() })
expect(onPoiClick).toHaveBeenCalledWith(other)
vi.mocked(glMap.getZoom).mockReturnValue(10)
})
it('FE-COMP-MAPVIEWGL-043: plugin markers render as tone dots with a text-only popup', async () => {
loadOnAttach()
const markers: PluginMapMarker[] = [
+118 -37
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 { useTranslation } from '../../i18n/TranslationContext'
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -24,6 +25,13 @@ import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlM
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import type { RoadtripHazard } from '@trek/shared'
import { useHazardLayerGL } from './useHazardLayerGL'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
import NightPauseTooltip from './NightPauseTooltip'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { buildPoiPopupHtml } from './placePopup'
@@ -120,6 +128,7 @@ interface Props {
tripId?: number | string
// Charging stops / rest areas a plugin route places on the drawn day route.
routeVias?: RouteVia[]
dayBoundaryControls?: DayBoundaryControls
/** 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
@@ -180,6 +189,7 @@ interface Props {
* motorway are the shape of the day, and merging them into one dot hides it.
*/
clusterLoosely?: boolean
hazards?: RoadtripHazard[]
/** Via points to draw as draggable handles, keyed by day (#1797). */
roadtripVias?: Record<number, RoadtripVia[]>
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
@@ -197,7 +207,7 @@ interface Props {
onMapReady?: (map: any | null) => void
}
/** How eagerly place markers merge into a cluster, outside road trip mode. */
/** How eagerly place markers merge into a cluster. */
const CLUSTER_RADIUS = 30
const CLUSTER_MAX_ZOOM = 10
@@ -609,6 +619,7 @@ export function MapViewGL({
dayPlaces = NO_PLACES,
tripId,
routeVias = NO_ROUTE_VIAS,
dayBoundaryControls,
accessLines = NO_ACCESS_LINES,
route = null,
routeColors = null,
@@ -623,6 +634,7 @@ export function MapViewGL({
fitKey = 0,
focusPoints,
clusterLoosely = false,
hazards,
dayOrderMap = NO_DAY_ORDER,
leftWidth = 0,
rightWidth = 0,
@@ -651,6 +663,7 @@ export function MapViewGL({
gl,
onMapReady,
}: Props) {
const { t } = useTranslation()
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 || '')
@@ -675,7 +688,7 @@ export function MapViewGL({
const [mapReady, setMapReady] = useState(false)
// Hover tooltip — a cursor-following name/category/address card, matching the
// Leaflet map's overlay exactly (no anchored popup, no photo thumbnail).
const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null)
const [hoverPlace, setHoverPlace] = useState<MapHoverInfo | null>(null)
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
const hoverIdRef = useRef<number | null>(null)
// True while the camera is moving (flyTo after a click, pan, zoom). Marker
@@ -697,6 +710,8 @@ export function MapViewGL({
const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null)
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, PlacePin>>(new Map())
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
@@ -1781,18 +1796,6 @@ export function MapViewGL({
return
}
// Road trip mode does not cluster at all: the whole drive has to stay readable
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
// is being looked at for. The source is emptied rather than reconfigured — the
// cluster options can only be set when a GeoJSON source is created, and dropping and
// re-adding a source under three layers to change a radius is not worth it when the
// markers are drawn from `places` anyway.
if (clusterLoosely) {
source.setData({ type: 'FeatureCollection', features: [] })
reconcileMarkers(validPlaces)
return
}
source.setData(buildPlaceClusterData(places) as any)
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
let raf: number | null = null
@@ -1833,29 +1836,63 @@ export function MapViewGL({
}
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them).
// Search results stay separate from the planned itinerary.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
popupRef.current?.remove() // same orphan-popup guard as the place markers
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
// Dragging a hit onto the drive puts it where it is passed, rather than where the
// corridor happened to project it. Only wired when someone is listening for it.
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
const redraw = () => {
popupRef.current?.remove()
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
const groups = clusterLoosely ? clusterPois(pois, poi => map.project([poi.lng, poi.lat]), map.getZoom()) : pois.map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng }))
for (const group of groups) {
if (group.pois.length > 1) {
const el = document.createElement('button')
el.type = 'button'
el.style.cssText = 'width:38px;height:38px;border:0;padding:0;background:transparent;'
el.setAttribute('aria-label', t('roadtrip.poi.found', { count: group.pois.length }))
el.innerHTML = poiClusterMarkup(group.pois.length)
el.addEventListener('click', event => {
event.stopPropagation()
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
popupRef.current?.setLngLat([group.lng, group.lat]).setDOMContent(poiClusterList(group.pois, poi => {
popupRef.current?.remove(); onPoiClickRef.current?.(poi)
})).addTo(map)
} else {
const bounds = new gl.LngLatBounds()
group.pois.forEach(poi => bounds.extend([poi.lng, poi.lat]))
map.fitBounds(bounds, { padding: 70, maxZoom: POI_CLUSTER_DETAIL_ZOOM })
}
})
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, group.lng, group.lat))
continue
}
const poi = group.pois[0]
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
// Dragging a hit onto the drive puts it where it is passed, rather than where the
// corridor happened to project it. Only wired when someone is listening for it.
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
}
}
}, [pois, mapReady, glProvider])
redraw()
if (clusterLoosely) map.on('moveend', redraw)
return () => {
map.off('moveend', redraw)
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
poiMarkersRef.current.forEach(pin => pin.remove())
poiMarkersRef.current = []
popupRef.current?.remove()
}
}, [pois, mapReady, glProvider, clusterLoosely, t])
// Fetch plugin map contributions (markers + layers) per trip. Fail-safe: an
// error or missing tripId just means no plugin overlays, the core map is fine.
@@ -1887,11 +1924,30 @@ export function MapViewGL({
if (!map || !mapReady) return
routeViaMarkersRef.current.forEach(m => m.remove())
routeViaMarkersRef.current = []
const pauses: HTMLElement[] = []
const unbind: (() => void)[] = []
for (const v of routeVias) {
const el = document.createElement('div')
el.style.cssText = 'width:13px;height:13px;cursor:pointer;'
const color = PLUGIN_TONE_COLORS[v.tone] ?? PLUGIN_TONE_COLORS.default
el.innerHTML = `<span style="display:block;width:13px;height:13px;border-radius:50%;background:#fff;border:3.5px solid ${color};box-shadow:0 1px 4px rgba(0,0,0,0.35);box-sizing:border-box"></span>`
if (v.nightPause) {
el.style.cssText = 'width:0;height:0;overflow:visible;cursor:pointer;'
el.innerHTML = nightPauseMarker(v)
el.setAttribute('aria-label', v.label ?? '')
pauses.push(el)
}
if (v.hoverCard) {
const show = (ev: MouseEvent) => {
if (hoverDisabledRef.current || camMovingRef.current) return
hoverIdRef.current = null
setHoverPlace({ name: v.label, routeVia: v })
setHoverPos({ x: ev.clientX, y: ev.clientY })
}
el.addEventListener('mouseenter', show)
el.addEventListener('mousemove', show)
el.addEventListener('mouseleave', () => { setHoverPlace(null); setHoverPos(null) })
}
if (v.label || v.dwellSeconds != null) {
const text = [v.label, v.dwellSeconds != null ? formatViaDwellGl(v.dwellSeconds) : null].filter(Boolean).join(' · ')
el.addEventListener('click', (ev) => {
@@ -1899,9 +1955,30 @@ export function MapViewGL({
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
})
}
routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat))
const pin = attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat)
routeViaMarkersRef.current.push(pin)
if (v.nightPause && dayBoundaryControls) unbind.push(bindDayBoundaryDrag(el, v, dayBoundaryControls, {
project: (lat, lng) => {
const point = map.project([lng, lat])
const rect = map.getContainer().getBoundingClientRect()
return { x: point.x + rect.left, y: point.y + rect.top }
},
setPosition: (lat, lng) => { pin.setLngLat([lng, lat]) },
lock: () => {
const enabled = map.dragPan.isEnabled()
map.dragPan.disable()
return () => { if (enabled) map.dragPan.enable() }
},
}))
}
}, [routeVias, mapReady, glProvider])
const updateZoom = () => {
const visible = map.getZoom() >= NIGHT_PAUSE_MIN_ZOOM
for (const el of pauses) el.style.display = visible ? '' : 'none'
}
updateZoom()
map.on('zoomend', updateZoom)
return () => { map.off('zoomend', updateZoom); unbind.forEach(dispose => dispose()) }
}, [routeVias, mapReady, glProvider, dayBoundaryControls])
// Reconcile plugin markers (imperative, same lifecycle as the POI markers).
useEffect(() => {
@@ -2222,7 +2299,8 @@ export function MapViewGL({
does, which is why the lift is only there. */}
<div style={{
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
bottom: isMobile && hasDayDetail
top: hasInspector ? 80 : undefined,
bottom: hasInspector ? undefined : isMobile && hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
}}>
@@ -2230,7 +2308,10 @@ export function MapViewGL({
</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 && (
{!hoverDisabled && hoverPlace?.routeVia?.nightPause && hoverPos && !isMobile && routeVias.includes(hoverPlace.routeVia) && (
<NightPauseTooltip label={hoverPlace.name ?? ''} x={hoverPos.x} y={hoverPos.y} />
)}
{!hoverDisabled && hoverPlace && !hoverPlace.routeVia?.nightPause && hoverPos && !isMobile && (!hoverPlace.routeVia || routeVias.includes(hoverPlace.routeVia)) && (
<div data-testid="tooltip" style={{
position: 'fixed',
left: hoverPos.x + 14,
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { useMap } from 'react-leaflet'
import type L from 'leaflet'
import type { RouteVia } from '../../types'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
export function NightPauseDrag({ marker, via, controls }: { marker: L.Marker | null; via: RouteVia; controls?: DayBoundaryControls }) {
const map = useMap()
useEffect(() => {
const el = marker?.getElement()
if (!marker || !el || !controls) return
return bindDayBoundaryDrag(el, via, controls, {
project: (lat, lng) => {
const point = map.latLngToContainerPoint([lat, lng])
const rect = map.getContainer().getBoundingClientRect()
return { x: point.x + rect.left, y: point.y + rect.top }
},
setPosition: (lat, lng) => { marker.setLatLng([lat, lng]) },
lock: () => {
const enabled = map.dragging.enabled()
map.dragging.disable()
return () => { if (enabled) map.dragging.enable() }
},
})
}, [map, marker, via, controls])
return null
}
@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import NightPauseTooltip from './NightPauseTooltip'
afterEach(() => vi.restoreAllMocks())
it('keeps the full pause description inside the viewport at its lower right edge', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 240, height: 56 } as DOMRect)
const label = 'Tagesende von Tag 12 um 18:00 Uhr'
render(<NightPauseTooltip label={label} x={window.innerWidth - 5} y={window.innerHeight - 5} />)
const tooltip = screen.getByRole('tooltip')
expect(tooltip).toHaveTextContent(label)
expect(parseFloat(tooltip.style.left) + 240).toBeLessThanOrEqual(window.innerWidth - 12)
expect(parseFloat(tooltip.style.top) + 56).toBeLessThanOrEqual(window.innerHeight - 12)
expect(tooltip.style.overflowWrap).toBe('anywhere')
})
it('keeps descriptions visible at the upper left edge', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 240, height: 56 } as DOMRect)
render(<NightPauseTooltip label="End of day 1 at 18:00" x={-50} y={-50} />)
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '12px', top: '12px' })
})
@@ -0,0 +1,24 @@
import { useLayoutEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
export default function NightPauseTooltip({ label, x, y }: { label: string; x: number; y: number }) {
const card = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const place = () => {
const node = card.current!
const { width, height } = node.getBoundingClientRect()
const left = x + 14 + width > window.innerWidth - 12 ? x - width - 14 : x + 14
node.style.left = `${Math.max(12, Math.min(left, window.innerWidth - width - 12))}px`
node.style.top = `${Math.max(12, Math.min(y - 10, window.innerHeight - height - 12))}px`
}
place()
window.addEventListener('resize', place)
return () => window.removeEventListener('resize', place)
}, [label, x, y])
return createPortal(
<div ref={card} role="tooltip" data-testid="tooltip"
className="pointer-events-none fixed z-[var(--z-toast)] rounded-lg border border-edge-faint bg-surface-card px-3 py-2 font-semibold leading-relaxed text-content shadow-popover"
style={{ left: x + 14, top: y - 10, maxWidth: 'min(16rem, calc(100vw - 24px))', overflowWrap: 'anywhere', fontFamily: 'var(--font-system)', fontSize: 12 }}
>{label}</div>, document.body,
)
}
@@ -0,0 +1,72 @@
import { expect, it, vi } from 'vitest'
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
import type { RoadtripStop } from '../Roadtrip/useRoadtripRoutes'
function setup(withHandle = false) {
const el = document.createElement('span')
if (withHandle) el.innerHTML = '<span data-night-pause="route">1</span>'
document.body.append(el)
const save = vi.fn(async () => true), setPosition = vi.fn(), unlock = vi.fn()
const stop = (assignmentId: number, lng: number) => ({ assignmentId, lat: 0, lng }) as RoadtripStop
const dispose = bindDayBoundaryDrag(el, { lat: 0, lng: 0.5, tone: 'default', nightPause: { day: 1, atPlace: false, manual: true, position: 0.5 } }, {
path: [{ from: stop(1, 0), to: stop(2, 1), position: 0, line: [[0, 0], [0, 1]] }], move: save, hint: 'Move',
}, { project: (lat, lng) => ({ x: lng * 1000, y: lat * 1000 }), setPosition, lock: () => unlock })
const pointer = (node: EventTarget, type: string, x: number) => node.dispatchEvent(new MouseEvent(type, { clientX: x, clientY: 0, bubbles: true, cancelable: true, button: 0 }))
return { el, save, setPosition, unlock, dispose, pointer }
}
it('keeps dragging and keyboard controls after Leaflet replaces the label contents', async () => {
const s = setup(true)
for (const kind of ['route', 'place', 'route']) {
const label = s.el.firstElementChild!
s.pointer(label, 'pointerdown', 500)
s.pointer(document, 'pointermove', 990)
s.pointer(document, 'pointerup', 990)
await Promise.resolve()
s.el.innerHTML = `<span data-night-pause="${kind}">1</span>`
}
expect(s.save).toHaveBeenCalledTimes(3)
expect(s.unlock).toHaveBeenCalledTimes(3)
s.el.firstElementChild!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }))
expect(s.save).toHaveBeenLastCalledWith(1, null)
await Promise.resolve()
s.dispose()
s.pointer(s.el.firstElementChild!, 'pointerdown', 500)
s.pointer(document, 'pointermove', 990)
s.pointer(document, 'pointerup', 990)
expect(s.save).toHaveBeenCalledTimes(4)
s.el.remove()
})
it('saves the snapped visit once on release and restores map dragging', async () => {
const s = setup()
s.pointer(s.el, 'pointerdown', 500)
s.pointer(document, 'pointermove', 990)
expect(s.save).not.toHaveBeenCalled()
s.pointer(document, 'pointerup', 990)
expect(s.save).toHaveBeenCalledWith(1, { day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 })
expect(s.unlock).toHaveBeenCalledOnce()
s.dispose()
})
it('escape cancels without saving and removes document handlers', () => {
const s = setup()
s.pointer(s.el, 'pointerdown', 500)
s.pointer(document, 'pointermove', 300)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
s.pointer(document, 'pointerup', 300)
expect(s.save).not.toHaveBeenCalled()
expect(s.setPosition).toHaveBeenLastCalledWith(0, 0.5)
expect(s.unlock).toHaveBeenCalledOnce()
s.dispose()
})
it('a right click clears the manual override and a click alone does not save', async () => {
const s = setup()
s.pointer(s.el, 'pointerdown', 500)
s.pointer(document, 'pointerup', 500)
expect(s.save).not.toHaveBeenCalled()
s.el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
expect(s.save).toHaveBeenCalledWith(1, null)
s.dispose()
})
@@ -0,0 +1,110 @@
import type { RoadtripDayBoundary } from '@trek/shared'
import type { RouteVia } from '../../types'
import { nearestDayBoundary, type DayBoundaryLeg, type DayBoundaryTarget, type ProjectPoint } from '../Roadtrip/dayBoundaryPath'
export interface DayBoundaryControls {
path: DayBoundaryLeg[]
move: (day: number, boundary: RoadtripDayBoundary | null) => Promise<boolean>
hint: string
}
interface DragMap {
project: ProjectPoint
setPosition: (lat: number, lng: number) => void
lock: () => () => void
}
export function bindDayBoundaryDrag(el: HTMLElement, via: RouteVia, controls: DayBoundaryControls, map: DragMap) {
const pause = via.nightPause!
// Leaflet replaces the label contents when its icon changes, but keeps this root.
const handle = el
handle.style.cursor = 'grab'
handle.style.touchAction = 'none'
handle.tabIndex = 0
handle.setAttribute('role', 'button')
handle.setAttribute('aria-label', via.label ?? '')
handle.setAttribute('aria-description', controls.hint)
let origin: { x: number; y: number } | null = null
let target: DayBoundaryTarget | null = null
let unlock: (() => void) | null = null
let moved = false
let saving = false
let disposed = false
const restore = () => map.setPosition(via.lat, via.lng)
const detach = () => {
document.removeEventListener('pointermove', move)
document.removeEventListener('pointerup', up)
document.removeEventListener('pointercancel', cancel)
document.removeEventListener('keydown', escape)
unlock?.()
unlock = null
origin = null
handle.style.cursor = 'grab'
}
const cancel = () => { detach(); restore() }
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); cancel() } }
const commit = async (boundary: RoadtripDayBoundary | null) => {
saving = true
handle.setAttribute('aria-busy', 'true')
try { if (!await controls.move(pause.day, boundary) && !disposed) restore() }
finally { saving = false; handle.removeAttribute('aria-busy') }
}
const move = (event: PointerEvent) => {
if (!origin) return
if (!moved && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) < 4) return
moved = true
event.preventDefault()
target = nearestDayBoundary(controls.path, pause.day, { x: event.clientX, y: event.clientY }, map.project, pause.minPosition, pause.maxPosition)
if (target) map.setPosition(target.lat, target.lng)
}
const up = () => {
const next = moved ? target : null
detach()
if (next) void commit(next.boundary)
else restore()
}
const down = (event: PointerEvent) => {
if (event.button !== 0 || saving) return
event.stopPropagation()
event.preventDefault()
origin = { x: event.clientX, y: event.clientY }
target = null
moved = false
unlock = map.lock()
handle.style.cursor = 'grabbing'
document.addEventListener('pointermove', move, { passive: false })
document.addEventListener('pointerup', up)
document.addEventListener('pointercancel', cancel)
document.addEventListener('keydown', escape)
}
const click = (event: Event) => {
if (moved || saving) { event.preventDefault(); event.stopImmediatePropagation() }
}
const reset = (event: Event) => {
event.preventDefault()
event.stopPropagation()
if (!saving && pause.manual) void commit(null)
}
const key = (event: KeyboardEvent) => {
if (event.key === 'Delete' || event.key === 'Backspace') return reset(event)
if (!['ArrowLeft', 'ArrowRight'].includes(event.key) || saving) return
event.preventDefault()
event.stopPropagation()
const position = (pause.position ?? 0) + (event.key === 'ArrowLeft' ? -0.05 : 0.05)
const leg = controls.path.find(leg => position >= leg.position && position <= leg.position + 1)
if (!leg || position <= (pause.minPosition ?? -1) || position >= (pause.maxPosition ?? Infinity)) return
void commit({ day_number: pause.day, from_assignment_id: leg.from.assignmentId, to_assignment_id: leg.to.assignmentId, fraction: position - leg.position })
}
handle.addEventListener('pointerdown', down)
el.addEventListener('click', click, true)
handle.addEventListener('contextmenu', reset)
handle.addEventListener('keydown', key)
return () => {
disposed = true
detach()
handle.removeEventListener('pointerdown', down)
el.removeEventListener('click', click, true)
handle.removeEventListener('contextmenu', reset)
handle.removeEventListener('keydown', key)
}
}
+133
View File
@@ -0,0 +1,133 @@
.trek-hazard-content {
font-family: var(--font-system);
}
.trek-hazard-score {
padding: 7px 10px 5px;
border: 1px solid var(--border-faint);
border-radius: 10px;
background: var(--bg-hover);
}
.trek-hazard-score-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
color: var(--text-secondary);
font-weight: 500;
}
.trek-hazard-score-heading strong {
font-size: calc(16px * var(--fs-scale-caption, 1));
font-weight: 650;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.trek-hazard-score-track {
position: relative;
display: flex;
gap: 3px;
height: 5px;
}
.trek-hazard-score-track > span:not(.trek-hazard-score-marker) {
flex: 1;
border-radius: 3px;
opacity: 0.75;
}
.trek-hazard-score-marker {
position: absolute;
top: 50%;
width: 10px;
height: 10px;
border: 2px solid var(--bg-card);
border-radius: 50%;
background: var(--text-primary);
box-sizing: border-box;
transform: translate(-50%, -50%);
box-shadow: var(--shadow-sm);
}
.trek-hazard-score-ticks {
display: flex;
justify-content: space-between;
margin-top: 4px;
color: var(--text-muted);
font-size: calc(10px * var(--fs-scale-caption, 1));
font-variant-numeric: tabular-nums;
}
.trek-hazard-popup .leaflet-popup-content {
margin: 14px 16px;
}
.trek-hazard-popup .mapboxgl-popup-content,
.trek-hazard-popup .maplibregl-popup-content {
padding: 14px 16px;
border: 1px solid var(--border-faint);
border-radius: 14px;
background: var(--bg-card);
color: var(--text-primary);
box-shadow: var(--shadow-popover);
}
.trek-hazard-popup a.trek-hazard-source {
color: var(--text-secondary);
text-decoration: none;
}
.trek-hazard-popup a.trek-hazard-source:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.leaflet-container .trek-hazard-popup a.leaflet-popup-close-button,
.trek-hazard-popup .mapboxgl-popup-close-button,
.trek-hazard-popup .maplibregl-popup-close-button {
top: 10px;
right: 10px;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-muted);
font-size: 0;
transition: background-color 150ms ease, color 150ms ease !important;
}
.leaflet-container .trek-hazard-popup a.leaflet-popup-close-button span {
display: none;
}
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::before,
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::after {
content: '';
position: absolute;
top: 12px;
left: 6px;
width: 14px;
height: 1.5px;
border-radius: 1px;
background: currentColor;
transform: rotate(45deg);
}
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::after {
transform: rotate(-45deg);
}
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button):hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: none;
}
.trek-hazard-popup :is(a, button):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@@ -0,0 +1,12 @@
import { expect, it } from 'vitest'
import { hazardPopup } from './hazardPopup'
it('renders provider text without HTML and identifies point-only notices', () => {
const popup = hazardPopup({ id: 'test', source: 'DWD', title: '<img src=x>', description: '<script>bad()</script>',
updatedAt: '2026-09-12T08:00:00Z', validUntil: null, url: 'https://www.dwd.de/',
geometry: { type: 'Point', coordinates: [10, 50] },
}, 'Not a closure', 'Location only')
expect(popup.querySelector('img,script')).toBeNull()
expect(popup.textContent).toContain('Location only Not a closure')
expect(popup.querySelector('a')?.rel).toBe('noopener noreferrer')
})
+89
View File
@@ -0,0 +1,89 @@
import { createElement } from 'react'
import { AlertTriangle, ArrowUpRight } from 'lucide-react'
import { renderIconMarkup } from '../../utils/iconMarkup'
import './hazardPopup.css'
import type { RoadtripHazard } from '@trek/shared'
import type { Feature, Geometry } from 'geojson'
export function hazardFeature(hazard: RoadtripHazard): Feature {
return { type: 'Feature', properties: { id: hazard.id }, geometry: hazard.geometry as Geometry }
}
export function hazardPopup(hazard: RoadtripHazard, note: string, pointNote: string): HTMLDivElement {
const box = document.createElement('div')
box.className = 'trek-hazard-content flex max-w-xs flex-col gap-3 text-content'
box.style.fontSize = 'calc(12px * var(--fs-scale-caption, 1))'
box.style.lineHeight = '1.45'
const title = document.createElement('div')
title.className = 'flex items-center gap-2.5 pr-7 font-semibold'
const symbol = document.createElement('span')
symbol.className = 'flex size-7 shrink-0 items-center justify-center rounded-lg bg-warning-soft text-warning'
symbol.innerHTML = renderIconMarkup(createElement(AlertTriangle, { size: 15, strokeWidth: 1.8 }))
const heading = document.createElement('span')
heading.textContent = hazard.title
title.append(symbol, heading)
box.append(title)
if (hazard.description.trim() && hazard.description.trim().toLocaleLowerCase() !== hazard.title.trim().toLocaleLowerCase()) {
const details = document.createElement('div')
details.className = 'text-content-secondary'
details.textContent = hazard.description
box.append(details)
}
const metadata = document.createElement('div')
metadata.className = 'flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-content-muted'
const source = document.createElement('a')
source.textContent = hazard.source
source.href = hazard.url
source.target = '_blank'
source.rel = 'noopener noreferrer'
source.className = 'trek-hazard-source inline-flex items-center gap-1 rounded-md bg-surface-hover px-1.5 py-0.5 font-medium'
const external = document.createElement('span')
external.innerHTML = renderIconMarkup(createElement(ArrowUpRight, { size: 12 }))
source.append(external)
const timestamp = document.createElement('time')
timestamp.dateTime = hazard.updatedAt
timestamp.textContent = new Date(hazard.updatedAt).toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
metadata.append(source, timestamp)
const caution = document.createElement('div')
caution.className = 'border-t border-edge-faint pt-2.5 text-content-muted'
caution.textContent = hazard.geometry.type === 'Point' ? pointNote + ' ' + note : note
box.append(metadata)
if (hazard.source === 'GDACS' && hazard.alertScore != null) box.append(scoreBar(hazard.alertScore))
box.append(caution)
return box
}
function scoreBar(score: number): HTMLDivElement {
const panel = document.createElement('div')
panel.className = 'trek-hazard-score'
const heading = document.createElement('div')
heading.className = 'trek-hazard-score-heading'
const label = document.createElement('span')
label.textContent = 'GDACS Score'
const value = document.createElement('strong')
value.textContent = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(score)
value.style.color = score < 1 ? 'var(--success)' : score < 2 ? 'var(--warning)' : 'var(--danger)'
heading.append(label, value)
const track = document.createElement('div')
track.className = 'trek-hazard-score-track'
track.setAttribute('role', 'img')
track.setAttribute('aria-label', 'GDACS Score ' + value.textContent)
for (const color of ['success', 'warning', 'danger']) {
const segment = document.createElement('span')
segment.style.background = 'var(--' + color + ')'
track.append(segment)
}
const marker = document.createElement('span')
marker.className = 'trek-hazard-score-marker'
marker.style.left = Math.min(score / 3, 1) * 100 + '%'
track.append(marker)
const ticks = document.createElement('div')
ticks.className = 'trek-hazard-score-ticks'
for (const tick of ['0', '1', '2', '3+']) {
const number = document.createElement('span')
number.textContent = tick
ticks.append(number)
}
panel.append(heading, track, ticks)
return panel
}
+10
View File
@@ -0,0 +1,10 @@
import type { RouteVia } from '../../types'
export interface MapHoverInfo {
name?: string | null
address?: string | null
category_name?: string | null
category_icon?: string | null
category_color?: string | null
routeVia?: RouteVia
}
@@ -0,0 +1,17 @@
import { createElement } from 'react'
import { Moon } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import { renderIconMarkup } from '../../utils/iconMarkup'
import type { RouteVia } from '../../types'
const moon = renderIconMarkup(createElement(Moon, { size: 13, strokeWidth: 1.8, 'aria-hidden': true }))
export const NIGHT_PAUSE_MIN_ZOOM = 6
export function nightPauseMarker(via: RouteVia): string {
const pause = via.nightPause!
const position = pause.atPlace ? 'left:27px;top:-27px;' : 'left:0;top:-38px;transform:translateX(-50%);'
const stem = pause.atPlace
? 'left:20px;top:-7px;width:10px;height:1px;transform:rotate(-35deg);transform-origin:left center;'
: 'left:-.5px;top:-14px;width:1px;height:14px;'
return `<span aria-hidden="true" style="position:absolute;${stem}background:var(--text-muted);pointer-events:none;"></span><span data-night-pause="${pause.atPlace ? 'place' : 'route'}" style="position:absolute;${position}display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 8px;box-sizing:border-box;border:1px solid var(--border-primary);border-radius:9px;background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow-sm);font-family:var(--font-system);font-size:calc(11px * var(--fs-scale-caption,1));font-weight:600;line-height:1;white-space:nowrap;cursor:inherit;">${moon}<span>${escapeHtml(String(pause.day))}</span></span>`
}
+3 -1
View File
@@ -70,5 +70,7 @@ export interface Poi {
capacity: number | null
fee: boolean | null
} | null
source: 'openstreetmap'
source: string
pluginId?: string
rating?: number | null
}
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterList } from './poiClusters'
const station = (id: string, lng: number): Poi => ({
osm_id: id, name: id, lat: 48, lng, category: 'charging_station',
poi_type: 'charging_station', address: null, website: null, phone: null,
opening_hours: null, cuisine: null, source: 'openstreetmap',
})
const project = (poi: Poi) => ({ x: poi.lng, y: poi.lat })
describe('station clusters', () => {
it('only clusters in the distant overview, even for coincident stations', () => {
const pois = [station('a', 1), station('b', 1)]
expect(clusterPois(pois, project, 8)).toHaveLength(1)
expect(clusterPois(pois, project, 9)).toHaveLength(2)
expect(clusterPois(pois, project, 14)).toHaveLength(2)
})
it('groups across grid edges, counts unique stations and preserves the originals', () => {
const a = station('a', 47), b = station('b', 49), c = station('c', 150)
const groups = clusterPois([c, b, a, a], project)
expect(groups.map(group => group.pois.length)).toEqual([2, 1])
expect(groups[0]).toMatchObject({ lat: 48, lng: 48 })
expect(groups[0].pois[0]).toBe(a)
expect(a.lng).toBe(47)
expect(clusterPois([a, b, c], project)).toEqual(groups)
})
it('separates stations as their projected distance grows on zoom', () => {
const pois = [station('a', 1), station('b', 2)]
expect(clusterPois(pois, project)).toHaveLength(1)
expect(clusterPois(pois, poi => ({ x: poi.lng * 100, y: 0 }))).toHaveLength(2)
})
it('keeps the center near the date line', () => {
const groups = clusterPois([station('a', 179), station('b', -179)], () => ({ x: 0, y: 0 }))
expect(groups[0].lng).toBe(180)
})
it('offers coincident stations individually without interpreting their names as HTML', () => {
const poi = { ...station('a', 1), name: '<img src=x>' }
const select = vi.fn()
const list = poiClusterList([poi], select)
expect(list.querySelector('img')).toBeNull()
expect(list.textContent).toBe(poi.name)
list.querySelector('button')!.click()
expect(select).toHaveBeenCalledWith(poi)
})
})
it('combines nearby small groups into one overview cluster', () => {
const pois = [station('a', 0), station('b', 3), station('c', 20), station('d', 23), station('e', 40), station('f', 43)]
expect(clusterPois(pois, project, 8).map(group => group.pois.length)).toEqual([6])
expect(clusterPois(pois, project, 9)).toHaveLength(6)
})
+57
View File
@@ -0,0 +1,57 @@
import type { Poi } from './poiCategories'
export const POI_CLUSTER_RADIUS = 48
export const POI_CLUSTER_MAX_ZOOM = 8
export const POI_CLUSTER_DETAIL_ZOOM = 18
export interface PoiCluster { pois: Poi[]; lat: number; lng: number }
export function clusterPois(pois: Poi[], project: (poi: Poi) => { x: number; y: number }, zoom = 0): PoiCluster[] {
const groups: (PoiCluster & { x: number; y: number })[] = []
const cells = new Map<string, number[]>()
const unique = new Map(pois.map(poi => [poi.osm_id, poi]))
if (zoom > POI_CLUSTER_MAX_ZOOM) return [...unique.values()].map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng }))
for (const poi of [...unique.values()].sort((a, b) => a.osm_id.localeCompare(b.osm_id))) {
const point = project(poi)
const cx = Math.floor(point.x / POI_CLUSTER_RADIUS), cy = Math.floor(point.y / POI_CLUSTER_RADIUS)
let closest: typeof groups[number] | undefined
let distance = POI_CLUSTER_RADIUS
for (let x = cx - 1; x <= cx + 1; x++) for (let y = cy - 1; y <= cy + 1; y++) {
for (const index of cells.get(`${x},${y}`) ?? []) {
const group = groups[index]
const gap = Math.hypot(point.x - group.x, point.y - group.y)
if (gap < distance) { closest = group; distance = gap }
}
}
if (closest) closest.pois.push(poi)
else {
const key = `${cx},${cy}`
const indices = cells.get(key) ?? []
indices.push(groups.length)
cells.set(key, indices)
groups.push({ pois: [poi], lat: poi.lat, lng: poi.lng, ...point })
}
}
return groups.map(group => ({
pois: group.pois,
lat: group.pois.reduce((sum, poi) => sum + poi.lat, 0) / group.pois.length,
lng: group.lng + group.pois.reduce((sum, poi) => sum + ((poi.lng - group.lng + 540) % 360 - 180), 0) / group.pois.length,
}))
}
export function poiClusterMarkup(count: number): string {
return `<span data-poi-cluster="${count}" style="box-sizing:border-box;display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:3px solid var(--bg-card);background:var(--accent);color:var(--accent-text);box-shadow:var(--shadow-md);font-family:var(--font-system);font-size:calc(13px * var(--fs-scale-caption,1));font-weight:600;cursor:pointer;">${count}</span>`
}
export function poiClusterList(pois: Poi[], select: (poi: Poi) => void): HTMLElement {
const list = document.createElement('div')
list.className = 'flex max-h-64 flex-col gap-1 overflow-y-auto p-1'
for (const poi of pois) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'rounded-lg px-3 py-2 text-start text-caption font-medium text-content hover:bg-surface-hover focus-visible:bg-surface-hover'
button.textContent = poi.name
button.addEventListener('click', event => { event.stopPropagation(); select(poi) })
list.append(button)
}
return list
}
@@ -0,0 +1,41 @@
import { useEffect } from 'react'
import type { Map, Popup, MapMouseEvent } from 'mapbox-gl'
import type { RoadtripHazard } from '@trek/shared'
import { hazardPopup, hazardFeature } from './hazardPopup'
import { useTranslation } from '../../i18n/TranslationContext'
export function useHazardLayerGL(map: Map | null, ready: boolean, hazards: RoadtripHazard[] | undefined, popup: () => Popup) {
const { t } = useTranslation()
useEffect(() => {
if (!map || !ready || !hazards?.length) return
const source = 'roadtrip-hazards'
const layers = [`${source}-fill`, `${source}-line`, `${source}-point`]
let detail: Popup | undefined
const draw = () => {
if (!map.isStyleLoaded() || map.getSource(source)) return
const color = getComputedStyle(document.documentElement).getPropertyValue('--warning').trim()
map.addSource(source, { type: 'geojson', data: { type: 'FeatureCollection', features: hazards.map(hazardFeature) } })
map.addLayer({ id: layers[0], type: 'fill', source, filter: ['!=', ['geometry-type'], 'Point'], paint: { 'fill-color': color, 'fill-opacity': 0.16 } })
map.addLayer({ id: layers[1], type: 'line', source, filter: ['!=', ['geometry-type'], 'Point'], paint: { 'line-color': color, 'line-width': 2 } })
map.addLayer({ id: layers[2], type: 'circle', source, filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-color': color, 'circle-radius': 7 } })
}
const click = (event: MapMouseEvent) => {
if (!map.getLayer(layers[0])) return
const hit = map.queryRenderedFeatures(event.point, { layers })[0]
const hazard = hazards.find(item => item.id === hit?.properties?.id)
if (!hazard) return
detail?.remove()
detail = popup().setLngLat(event.lngLat).setDOMContent(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point'))).addTo(map)
}
draw()
map.on('style.load', draw)
map.on('click', click)
return () => {
detail?.remove()
map.off('style.load', draw)
map.off('click', click)
for (const layer of layers) if (map.getLayer(layer)) map.removeLayer(layer)
if (map.getSource(source)) map.removeSource(source)
}
}, [map, ready, hazards, popup, t])
}
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
import type { RoadtripHazards } from '@trek/shared'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { roadtripHazardsRepo } from '../../repo/roadtripHazardsRepo'
import { isEffectivelyOffline, onNetworkModeChange } from '../../sync/networkMode'
export function useRoadtripHazards(tripId: number | undefined, roadtrip: boolean) {
const enabled = useRoadtripSettings(s => s.roadtrip_show_hazards === true, tripId) && roadtrip && !!tripId
const [feed, setFeed] = useState<RoadtripHazards | null>(null)
const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable' | 'offline'>('loading')
useEffect(() => {
if (!enabled || !tripId) return
let controller: AbortController
const load = () => {
controller?.abort()
controller = new AbortController()
const signal = controller.signal
setFeed(null)
if (isEffectivelyOffline()) { setStatus('offline'); return }
setStatus('loading')
roadtripHazardsRepo.read(tripId, signal).then(saved => {
if (!signal.aborted) { setFeed(saved); setStatus('ready') }
}).catch(() => { if (!signal.aborted) setStatus('unavailable') })
}
load()
const timer = window.setInterval(load, 600000)
const unsubscribe = onNetworkModeChange(load)
window.addEventListener('online', load)
return () => { controller.abort(); window.clearInterval(timer); unsubscribe(); window.removeEventListener('online', load) }
}, [enabled, tripId])
return { enabled, feed: enabled ? feed : null, status }
}
@@ -1,3 +1,5 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { isServiceStopType } from '../Roadtrip/roadtripModel'
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string }
declare global { interface Window { __dragData: DragDataPayload | null } }
@@ -180,6 +182,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const { t, language, locale } = useTranslation()
const ctxMenu = useContextMenu()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false)
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canEditDays = can('day_edit', trip)
@@ -409,7 +412,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}
const getDayAssignments = (dayId) =>
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
(assignments[String(dayId)] || []).filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type)).slice().sort((a, b) => a.order_index - b.order_index)
// Compute initial day_plan_position for a transport based on time
const computeTransportPosition = (r, da) => {
@@ -485,7 +488,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
return map
// getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [days, assignments, dayNotes, reservations, transportPosVersion])
}, [days, assignments, dayNotes, reservations, transportPosVersion, mirrorServiceStops])
// Days whose inline route legs should be computed & shown. Desktop: the selected
// day while the Route toggle is on. Mobile: each expanded day the user tapped
@@ -710,7 +713,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Unified reorder: assigns positions to ALL item types based on new visual order
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
// Capture previous place order for undo
const prevAssignmentIds = getDayAssignments(dayId).map(a => a.id)
const prevAssignmentIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
// …and, per booking, the fields this call is about to overwrite, so a failed write
// can put the visible order back instead of leaving a phantom one behind the error
// toast. Restoring the whole array instead would also drop what a collaborator's
@@ -957,7 +960,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const da = getDayAssignments(dayId)
if (da.length < 3) return
const prevIds = da.map(a => a.id)
const prevIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
// Separate fixed (stay at their index) and movable assignments. A place is
// fixed if it's locked OR has a set time — timed places are anchored by their
@@ -1,3 +1,4 @@
import ChargingInfo from '../Roadtrip/ChargingInfo'
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { safeHttpUrl } from '../../utils/safeUrl'
@@ -35,6 +36,8 @@ import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge
import { NavigationMenu } from '../shared/NavigationMenu'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine } from './placeHoursFormat'
import type { EndDayControlProps } from '../Roadtrip/EndDayControl'
import VisitControls, { type RoadtripStayControl } from '../Roadtrip/VisitControls'
const detailsCache = new Map()
@@ -133,6 +136,9 @@ interface TripMember {
}
interface PlaceInspectorProps {
roadtripActive?: boolean
roadtripEndDay?: EndDayControlProps
roadtripStay?: RoadtripStayControl
place: Place | null
categories: Category[]
/** 'trip' (default) keeps every existing trip-planner behaviour byte-identical;
@@ -179,7 +185,7 @@ export default function PlaceInspector({
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate,
leftWidth = 0, rightWidth = 0,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) {
// Plugins that declared a place-detail slot mount at the bottom of this panel,
// scoped to the open place (trip mode only). Inline-filter like the other sites.
@@ -417,6 +423,8 @@ export default function PlaceInspector({
)}
{/* Description / Summary */}
{roadtripActive && place.stop_type === 'charging' && <ChargingInfo placeId={place.id} />}
{(roadtripEndDay || roadtripStay) && <VisitControls endDay={roadtripEndDay} stay={roadtripStay} />}
{(place.description || googleDetails?.summary) && (
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description || googleDetails?.summary || ''}</Markdown>
@@ -0,0 +1,37 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import AutomaticDayStop from './AutomaticDayStop'
import type { RoadtripStop } from './useRoadtripRoutes'
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
describe('automatic pause row', () => {
it('offers map focus and a custom hover without a native tooltip', async () => {
const focus = vi.fn()
render(<AutomaticDayStop
stop={{ lat: 3, lng: 4, automaticNight: { phase: 'end', fromDayNumber: 1 } } as RoadtripStop}
entry={{ arrival: '18:00', departure: '18:00', anchored: false, dayOffset: 0 }}
onFocus={focus}
/>)
expect(screen.getByText('18:00')).toBeInTheDocument()
expect(screen.queryByText('roadtrip.window.pointHint')).not.toBeInTheDocument()
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(1)
expect(buttons[0]).toHaveAccessibleName('roadtrip.window.stop 18:00')
expect(buttons[0]).not.toHaveAttribute('title')
fireEvent.mouseEnter(buttons[0])
expect(await screen.findByRole('tooltip')).toHaveTextContent('roadtrip.window.pointHint')
fireEvent.click(screen.getByText('18:00'))
expect(focus).toHaveBeenCalledWith(3, 4)
})
it('shows the next departure when a visit continues after the pause', () => {
render(<AutomaticDayStop
stop={{ automaticNight: { phase: 'start', fromDayNumber: 1 } } as RoadtripStop}
entry={{ arrival: '08:00', departure: '09:00', anchored: false, dayOffset: 0 }}
/>)
expect(screen.getByText('roadtrip.window.resume')).toBeInTheDocument()
expect(screen.getByLabelText('roadtrip.window.departure')).toHaveTextContent('09:00')
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,39 @@
import { ArrowRight, Moon, Sunrise } from 'lucide-react'
import type { RoadtripStop } from './useRoadtripRoutes'
import type { ScheduleEntry } from './roadtripModel'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatClockTime } from '../../utils/formatters'
import Tooltip from '../shared/Tooltip'
import { FS } from './typeScale'
export default function AutomaticDayStop({ stop, entry, onFocus }: {
stop: RoadtripStop
entry?: ScheduleEntry
onFocus?: (lat: number, lng: number) => void
}) {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format === '12h')
const ending = stop.automaticNight?.phase === 'end'
const Icon = ending ? Moon : Sunrise
const Row = onFocus ? 'button' : 'div'
return (
<Tooltip label={t('roadtrip.window.pointHint')} placement="top">
<Row
type={onFocus ? 'button' : undefined}
onClick={onFocus ? () => onFocus(stop.lat, stop.lng) : undefined}
className={`my-1 flex min-h-8 w-full items-center gap-2 rounded-md bg-surface-secondary px-2 py-1 text-start text-caption text-content-muted ${onFocus ? 'cursor-pointer transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent' : ''}`}
>
<Icon size={13} className="shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1 font-medium">{t(ending ? 'roadtrip.window.stop' : 'roadtrip.window.resume')}</span>{' '}
<span className="shrink-0 whitespace-nowrap font-medium leading-6 tabular-nums" style={{ fontSize: FS.time }}>{entry?.arrival ? formatClockTime(entry.arrival, is12h) : ''}</span>
{entry?.departure && entry.departure !== entry.arrival ? (
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap font-medium leading-6 tabular-nums" style={{ fontSize: FS.time }} aria-label={t('roadtrip.window.departure', { time: formatClockTime(entry.departure, is12h) })}>
<ArrowRight size={10} aria-hidden />
<span>{formatClockTime(entry.departure, is12h)}</span>
</span>
) : null}
</Row>
</Tooltip>
)
}
@@ -0,0 +1,26 @@
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import ChargingInfo from './ChargingInfo'
import { chargingRepo } from '../../repo/chargingRepo'
vi.mock('../../store/tripStore', () => ({ useTripStore: (select: (s: { trip: { id: number } }) => unknown) => select({ trip: { id: 1 } }) }))
vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn() } }))
const info = { checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Station', source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0', updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0, tariffs: [], pricesUnavailable: false }
describe('Charging information', () => {
it('shows availability, attribution and missing prices without inventing a price', async () => {
vi.mocked(chargingRepo.read).mockResolvedValue(info)
render(<TranslationProvider><ChargingInfo placeId={7} /></TranslationProvider>)
expect(await screen.findByText('2/4 available')).toBeInTheDocument()
expect(screen.getByText('No reliable data available')).toBeInTheDocument()
fireEvent.click(screen.getByText('Charging'))
expect(screen.getByRole('link', { name: 'Operator' })).toHaveAttribute('href', 'https://example.com')
})
it('does not display free capacity for stale data', async () => {
vi.mocked(chargingRepo.read).mockResolvedValue({ ...info, stale: true })
render(<TranslationProvider><ChargingInfo placeId={7} /></TranslationProvider>)
expect(await screen.findByText('Status outdated')).toBeInTheDocument()
expect(screen.queryByText('2/4')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
import { PlugZap, Coins, ChevronDown } from 'lucide-react'
import type { ChargingInfo as Info } from '@trek/shared'
import { useTranslation } from '../../i18n/TranslationContext'
import { useTripStore } from '../../store/tripStore'
import { chargingRepo } from '../../repo/chargingRepo'
import { isEffectivelyOffline } from '../../sync/networkMode'
import { Tooltip } from '../shared/Tooltip'
export default function ChargingInfo({ placeId, compact = false }: { placeId: number; compact?: boolean }) {
const tripId = useTripStore(s => s.trip?.id)
const { t } = useTranslation()
const [info, setInfo] = useState<Info | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
setInfo(null); setLoading(true)
const refresh = async () => {
if (!tripId || document.hidden) return
try { const value = await chargingRepo.read(tripId, placeId); if (active) setInfo(value) }
catch { if (active) setInfo(null) }
finally { if (active) setLoading(false) }
}
const offline = () => { setInfo(null); setLoading(false) }
void refresh()
const timer = window.setInterval(() => void refresh(), 60000)
window.addEventListener('offline', offline)
window.addEventListener('online', refresh)
document.addEventListener('visibilitychange', refresh)
return () => { active = false; clearInterval(timer); window.removeEventListener('offline', offline); window.removeEventListener('online', refresh); document.removeEventListener('visibilitychange', refresh) }
}, [tripId, placeId])
const known = !isEffectivelyOffline() && info?.available != null && !info.stale && Date.now() - Date.parse(info.checkedAt) < 120000
const status = loading ? t('common.loading') : known ? `${info.available}/${info.total} ${t('roadtrip.charging.available')}` : t(info?.stale ? 'roadtrip.charging.stale' : 'roadtrip.charging.unknown')
const components = info?.tariffs.flatMap(tariff => tariff.components.map(component => ({ ...component, currency: tariff.currency }))) ?? []
const energy = components.filter(component => component.kind === 'ENERGY' && component.taxIncluded && !component.conditional)
const sameCurrency = new Set(energy.map(component => component.currency)).size === 1
const cheapest = sameCurrency ? energy.reduce<typeof energy[number] | null>((best, component) => !best || component.price < best.price ? component : best, null) : null
const price = (value: number, currency: string) => new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
const badges = <span className="inline-flex shrink-0 items-center gap-1 align-middle whitespace-nowrap font-medium text-[length:calc(10px*var(--fs-scale-caption,1))] leading-none">
<Tooltip label={`${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`}><span tabIndex={0} className={`inline-flex items-center gap-1 rounded px-1 py-0.5 ${known && info.available! > 0 ? 'bg-success-soft text-success' : 'bg-surface-secondary text-content-muted'}`}><PlugZap size={10} />{known ? `${info.available}/${info.total}` : '?'}</span></Tooltip>
{cheapest && <Tooltip label={t('roadtrip.charging.note')}><span tabIndex={0} className="inline-flex items-center gap-1 rounded bg-surface-secondary px-1 py-0.5 text-content-muted"><Coins size={10} />{price(cheapest.price, cheapest.currency)}/kWh*</span></Tooltip>}
</span>
if (compact) return badges
return <details className="group rounded-[10px] bg-surface-hover text-[length:calc(12px*var(--fs-scale-body,1))] text-content-secondary">
<summary className="flex cursor-pointer list-none items-center gap-1.5 px-3 py-2 [&::-webkit-details-marker]:hidden">
<PlugZap size={13} className="shrink-0 text-content-faint" />
<span className="font-medium">{t('roadtrip.poi.charging')}</span>
<span className="ml-2 text-content-muted">{status}</span>
{cheapest && <span className="ml-auto whitespace-nowrap font-medium">{price(cheapest.price, cheapest.currency)}/kWh*</span>}
<ChevronDown size={13} className={'shrink-0 text-content-faint group-open:rotate-180 ' + (cheapest ? '' : 'ml-auto')} />
</summary>
<div className="space-y-2 px-3 pb-3 text-content-muted">
{info?.station && <p>{info.station}</p>}
<div className="space-y-1"><p className="font-medium">{t('roadtrip.charging.prices')}</p>
{!components.length && <p className="text-content-muted">{info?.pricesUnavailable ? `${t('common.error')}: ` : ''}{t('roadtrip.charging.unknown')}</p>}
{components.map((component, index) => <p key={index}>{price(component.price, component.currency)}{component.kind === 'ENERGY' ? '/kWh' : ['TIME', 'PARKING_TIME'].includes(component.kind) ? '/h' : ''} {component.kind === 'FLAT' ? 'Σ' : ''}{component.conditional || !component.taxIncluded ? '*' : ''}{component.afterSeconds != null && component.afterSeconds > 0 ? ` (> ${Math.round(component.afterSeconds / 60)} min)` : ''}</p>)}
{!!components.length && <p className="text-content-muted">* {t('roadtrip.charging.note')}</p>}
</div>
<p className="text-content-muted">{t('roadtrip.charging.coverage')}</p>
{info?.source && <p className="text-content-muted"><a className="underline" href={info.sourceUrl?.startsWith('https://') ? info.sourceUrl : 'https://mobidata-bw.de/de/dataset/e-ladesaulen'} target="_blank" rel="noreferrer">{info.source}</a> · <a className="underline" href="https://mobidata-bw.de/de/dataset/e-ladesaulen" target="_blank" rel="noreferrer">MobiData BW</a><br />{info.license}{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}</p>}
</div>
</details>
}
@@ -0,0 +1,32 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DayWindowFields from './DayWindowFields'
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
describe('daily travel time settings', () => {
it('saves a complete custom time and preserves the other time', () => {
const save = vi.fn()
render(<DayWindowFields start="08:00" end="18:00" onSave={save} />)
const start = screen.getByRole('textbox', { name: 'roadtrip.window.start' })
fireEvent.change(start, { target: { value: '07:00' } })
expect(save).toHaveBeenCalledWith('roadtrip_day_start', '07:00')
expect(screen.getByLabelText('roadtrip.window.end')).toHaveValue('18:00')
})
it('allows clearing a time to turn automatic planning off', () => {
const save = vi.fn()
render(<DayWindowFields start="08:00" end="18:00" onSave={save} />)
const end = screen.getByLabelText('roadtrip.window.end')
fireEvent.change(end, { target: { value: '' } })
fireEvent.blur(end)
expect(save).toHaveBeenCalledWith('roadtrip_day_end', '')
})
it('announces a reversed window and does not offer disabled inputs as editable', () => {
render(<DayWindowFields start="18:00" end="08:00" />)
expect(screen.getByRole('alert')).toHaveTextContent('roadtrip.window.invalid')
expect(screen.getByLabelText('roadtrip.window.start')).toBeDisabled()
expect(screen.getByLabelText('roadtrip.window.end')).toBeDisabled()
})
})
@@ -0,0 +1,69 @@
import { useState, useRef } from 'react'
import { Sunrise, Sunset } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { dayWindow } from './dayWindow'
import CustomTimePicker from '../shared/CustomTimePicker'
import SettingsHint from './SettingsHint'
export default function DayWindowFields({ start, end, endMode = 'route', onSave }: {
start?: string
end?: string
endMode?: 'route' | 'stop'
onSave?: (key: string, value: string) => void
}) {
const { t } = useTranslation()
const [draft, setDraft] = useState<{ start?: string; end?: string }>({})
const sent = useRef({ start, end })
const shownStart = draft.start ?? start ?? ''
const shownEnd = draft.end ?? end ?? ''
const invalid = !!shownStart && !!shownEnd && !dayWindow(shownStart, shownEnd)
const change = (field: 'start' | 'end', value: string) => {
setDraft(prev => ({ ...prev, [field]: value }))
if (value !== '' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return
if (value !== (sent.current[field] ?? '')) {
sent.current[field] = value
onSave?.(`roadtrip_day_${field}`, value)
}
}
return (
<fieldset className="flex min-w-0 flex-col gap-3">
<legend className="sr-only">{t('roadtrip.window.title')}</legend>
{(['start', 'end'] as const).map(field => {
const Icon = field === 'start' ? Sunrise : Sunset
return (
<div key={field} className="flex min-h-9 items-center gap-3">
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1 text-body text-content-secondary">{t(`roadtrip.window.${field}`)}</span>
<CustomTimePicker
value={field === 'start' ? shownStart : shownEnd}
disabled={!onSave}
aria-label={t(`roadtrip.window.${field}`)}
aria-describedby="roadtrip-window-hint"
aria-invalid={invalid || undefined}
onChange={value => change(field, value)}
style={{ width: '7rem' }}
/>
</div>
)
})}
{invalid
? <p id="roadtrip-window-hint" role="alert" className="text-caption text-danger">{t('roadtrip.window.invalid')}</p>
: <SettingsHint id="roadtrip-window-hint">{t('roadtrip.window.hint')}</SettingsHint>}
<fieldset className="flex min-w-0 flex-col gap-2 border-t border-edge-faint pt-3">
<legend className="sr-only">{t('roadtrip.window.endAt')}</legend>
<span className="text-body text-content-secondary">{t('roadtrip.window.endAt')}</span>
<div className="flex flex-wrap gap-2">
{(['route', 'stop'] as const).map(mode => (
<label key={mode} className={`relative flex min-h-9 flex-1 cursor-pointer items-center rounded-lg border px-3 py-2 text-caption has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent ${endMode === mode ? 'border-accent bg-accent-subtle text-accent-on' : 'border-edge bg-surface-input text-content-secondary'}`}>
<input type="radio" name="roadtrip-day-end-mode" value={mode} checked={endMode === mode} disabled={!onSave}
onChange={() => onSave?.('roadtrip_day_end_mode', mode)}
aria-describedby="roadtrip-end-mode-hint" className="sr-only" />
{t(`roadtrip.window.${mode === 'route' ? 'onRoute' : 'atStop'}`)}
</label>
))}
</div>
<SettingsHint id="roadtrip-end-mode-hint">{t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)}</SettingsHint>
</fieldset>
</fieldset>
)
}
@@ -0,0 +1,19 @@
import { fireEvent, render, screen, act } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import EndDayControl from './EndDayControl'
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
it('shows the saved state and blocks a second click while saving', async () => {
let finish!: () => void
const onToggle = vi.fn(() => new Promise<void>(resolve => { finish = resolve }))
render(<EndDayControl active onToggle={onToggle} />)
const toggle = screen.getByRole('button', { name: 'roadtrip.window.endHere' })
expect(toggle).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(toggle)
expect(toggle).toBeDisabled()
fireEvent.click(toggle)
expect(onToggle).toHaveBeenCalledTimes(1)
await act(async () => finish())
expect(toggle).toBeEnabled()
})
@@ -0,0 +1,24 @@
import { useState } from 'react'
import { Moon } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import ToggleSwitch from '../Settings/ToggleSwitch'
export interface EndDayControlProps {
active: boolean
onToggle: () => Promise<void>
}
export default function EndDayControl({ active, onToggle }: EndDayControlProps) {
const { t } = useTranslation()
const [pending, setPending] = useState(false)
return (
<fieldset disabled={pending} aria-busy={pending} className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 disabled:opacity-60">
<span className="flex flex-1 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary"><Moon size={13} className={`shrink-0 ${active ? 'text-accent-on' : 'text-content-faint'}`} aria-hidden />{t('roadtrip.window.endHere')}</span>
<ToggleSwitch on={active} label={t('roadtrip.window.endHere')} onToggle={async () => {
if (pending) return
setPending(true)
try { await onToggle() } finally { setPending(false) }
}} />
</fieldset>
)
}
@@ -0,0 +1,42 @@
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import GoogleRouteImport from './GoogleRouteImport'
import { googleRouteRepo } from '../../repo/googleRouteRepo'
const { loadTrip, state } = vi.hoisted(() => ({ loadTrip: vi.fn(), state: { days: [{ id: 3, day_number: 1, title: 'First day' }] } }))
vi.mock('../../store/tripStore', () => ({ useTripStore: Object.assign((select: (s: typeof state) => unknown) => select(state), { getState: () => ({ loadTrip }) }) }))
vi.mock('../../repo/googleRouteRepo', () => ({ googleRouteRepo: { preview: vi.fn(), append: vi.fn() } }))
async function openPreview() {
render(<TranslationProvider><GoogleRouteImport tripId={1} dayId={3} /></TranslationProvider>)
fireEvent.click(screen.getByRole('button', { name: 'Import Google Maps route' }))
fireEvent.click(screen.getByText('Import Google Maps route', { selector: 'span' }))
fireEvent.change(screen.getByRole('textbox', { name: 'Google Maps URL' }), { target: { value: 'https://google.com/maps/dir/A/B' } })
fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
await screen.findByText('Munich')
}
describe('Google route preview', () => {
beforeEach(() => { vi.clearAllMocks(); loadTrip.mockResolvedValue(undefined) })
it('previews in order and only saves resolved stops after confirmation', async () => {
vi.mocked(googleRouteRepo.preview).mockResolvedValue({ stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Unknown', lat: null, lng: null }, { name: 'Rome', lat: 41, lng: 12 }] })
vi.mocked(googleRouteRepo.append).mockResolvedValue(undefined)
await openPreview()
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual([expect.stringContaining('Munich'), expect.stringContaining('Unknown'), expect.stringContaining('Rome')])
expect(screen.getByText('Location unresolved; this stop will be skipped.')).toBeInTheDocument()
expect(googleRouteRepo.append).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Import' }))
await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1))
expect(googleRouteRepo.append).toHaveBeenCalledWith(1, { dayId: 3, stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Rome', lat: 41, lng: 12 }] }, expect.any(String))
})
it('blocks import when fewer than two locations resolve', async () => {
vi.mocked(googleRouteRepo.preview).mockResolvedValue({ stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Unknown', lat: null, lng: null }] })
await openPreview()
expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled()
expect(googleRouteRepo.append).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react'
import { MoreHorizontal, Link2 } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { useTripStore } from '../../store/tripStore'
import { googleRouteRepo } from '../../repo/googleRouteRepo'
import { generateUUID } from '../../sync/mutationQueue'
import { getApiErrorMessage } from '../../types'
import type { GoogleRoutePreview } from '@trek/shared'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
import { Tooltip } from '../shared/Tooltip'
export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; dayId?: number }) {
const { t } = useTranslation()
const days = useTripStore(s => s.days)
const [open, setOpen] = useState(false)
const [url, setUrl] = useState('')
const [preview, setPreview] = useState<GoogleRoutePreview | null>(null)
const [target, setTarget] = useState('')
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState('')
const [saved, setSaved] = useState(false)
const controller = useRef<AbortController | null>(null)
const requestId = useRef('')
const menu = useContextMenu()
useEffect(() => () => controller.current?.abort(), [])
const inspect = async () => {
controller.current?.abort()
const abort = new AbortController()
controller.current = abort
setBusy(true); setFailure(''); setPreview(null)
try { const parsed = await googleRouteRepo.preview(url, abort.signal); if (!abort.signal.aborted) setPreview(parsed) }
catch (error) { if (!abort.signal.aborted) setFailure(getApiErrorMessage(error, t('common.error'))) }
finally { if (!abort.signal.aborted) setBusy(false) }
}
const save = async () => {
if (!preview || !target) return
setBusy(true); setFailure('')
try {
const stops = preview.stops.filter(s => s.lat != null && s.lng != null).map(s => ({ name: s.name, lat: s.lat!, lng: s.lng! }))
await googleRouteRepo.append(tripId, { dayId: Number(target), stops }, requestId.current)
setSaved(true)
await useTripStore.getState().loadTrip(tripId)
setOpen(false)
} catch (error) { setFailure(getApiErrorMessage(error, t('common.error'))) }
finally { setBusy(false) }
}
const show = () => {
setUrl(''); setPreview(null); setFailure(''); setSaved(false)
setTarget(String(dayId ?? days[0]?.id ?? ''))
requestId.current = generateUUID(); setOpen(true)
}
const resolved = preview?.stops.filter(s => s.lat != null && s.lng != null) ?? []
return <>
<Tooltip label={t('roadtrip.import.title')}>
<button type="button" aria-label={t('roadtrip.import.title')} className="shrink-0 rounded-lg p-2 text-content-muted hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
onClick={e => menu.open(e, [{ label: t('roadtrip.import.title'), icon: Link2, onClick: show }], true)}>
<MoreHorizontal size={18} />
</button>
</Tooltip>
<ContextMenu menu={menu.menu} onClose={menu.close} />
<Modal isOpen={open} onClose={() => { if (!busy) setOpen(false) }} title={t('roadtrip.import.title')} size="lg">
<div className="space-y-4">
<p className="text-caption text-content-muted">{t('roadtrip.import.note')}</p>
{!preview ? <input type="url" value={url} disabled={busy} onChange={e => setUrl(e.target.value)} aria-label="Google Maps URL" placeholder="https://maps.app.goo.gl/…"
className="w-full rounded-xl border border-edge bg-surface-input p-3 text-body text-content" /> : <>
<CustomSelect value={target} onChange={value => { setTarget(String(value)); requestId.current = generateUUID() }}
options={days.map(day => ({ value: String(day.id), label: day.title || `${t('roadtrip.import.day')} ${day.day_number}` }))} disabled={busy || saved} />
<ol className="max-h-64 space-y-2 overflow-auto">
{preview.stops.map((stop, index) => <li key={index} className="flex items-baseline gap-3 text-body text-content">
<span className="text-caption tabular-nums text-content-muted">{index + 1}</span>
<span className="min-w-0">{stop.name}{stop.name !== `${stop.lat}, ${stop.lng}` && <span className="block text-caption text-content-muted">{stop.lat == null || stop.lng == null ? t('roadtrip.import.unresolved') : `${stop.lat.toFixed(5)}, ${stop.lng.toFixed(5)}`}</span>}</span>
</li>)}
</ol>
</>}
{failure && <p role="alert" className="text-caption text-danger">{saved ? t('common.saved') + ': ' : ''}{failure}</p>}
<div className="flex justify-end gap-2">
<button type="button" disabled={busy} className="rounded-lg px-3 py-2 text-body text-content-secondary" onClick={() => setOpen(false)}>{t('common.cancel')}</button>
<button type="button" disabled={busy || saved || (!preview ? !url.trim() : resolved.length < 2 || !target)} onClick={() => void (preview ? save() : inspect())}
className="rounded-lg bg-accent px-4 py-2 text-body font-medium text-accent-text disabled:opacity-50">
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
</button>
</div>
</div>
</Modal>
</>
}
@@ -279,6 +279,14 @@ describe('RoadtripCorridorPanel', () => {
expect(screen.getByText('Aral')).toBeInTheDocument()
})
it('shows the plugin source and failed providers beside remaining hits', () => {
const c = corridor({}, { failedSources: ['plugin:unavailable'], results: [poi({ osm_id: 'plugin:stations:one', name: 'Plugin Station', pluginId: 'stations' })] })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.getByText('Plugin Station')).toBeInTheDocument()
expect(screen.getByText('stations')).toBeInTheDocument()
expect(screen.getByText('Error: plugin:unavailable')).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-015: a total outage reads as a failure, not as an empty road', () => {
const c = corridor({}, { error: true, failedAreas: 4 })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
@@ -1,3 +1,4 @@
import GoogleRouteImport from './GoogleRouteImport'
import React, { useMemo, useState } from 'react'
import {
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, ChevronDown,
@@ -17,6 +18,8 @@ import type { CorridorPoi } from './useCorridorPois'
import type { RoadtripRoutes } from './useRoadtripRoutes'
interface RoadtripCorridorPanelProps {
tripId?: number
canImport?: boolean
corridor: RoadtripCorridor
routes: RoadtripRoutes
/** Opens the place form prefilled from a POI, on a day and at a position in it. */
@@ -103,6 +106,7 @@ 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>
{poi.pluginId && <p className="truncate text-caption text-content-muted">{poi.pluginId}</p>}
{/* 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
@@ -249,7 +253,7 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
* header rather than among the filters — it is the question's subject, not one of its
* conditions.
*/
export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: RoadtripCorridorPanelProps): React.ReactElement {
export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi, tripId, canImport }: RoadtripCorridorPanelProps): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const { search } = corridor
@@ -299,6 +303,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
: 0
const warnings: [string, string][] = []
if (search.failedSources?.length) warnings.push(['sources', `${t('common.error')}: ${search.failedSources.join(', ')}`])
if (search.capped) warnings.push(['capped', t('roadtrip.poi.capped')])
if (search.error) warnings.push(['failed', t('roadtrip.poi.failed')])
// Some boxes answered and some did not. Saying so is the difference between "there is
@@ -327,6 +332,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
/>
</div>
) : null}
{tripId && canImport && <GoogleRouteImport tripId={tripId} dayId={corridor.day?.dayId} />}
</div>
{/* What to look for. */}
@@ -45,6 +45,21 @@ beforeEach(() => {
})
describe('RoadtripLimitsCard', () => {
it.each(['electric', 'petrol'])('saves manual range before fill changes for %s', kind => {
useSettingsStore.setState({ settings: { roadtrip_vehicle: kind, roadtrip_range_km: 0, distance_unit: 'metric' } as never })
const onSave = vi.fn()
open(onSave)
const range = screen.getByTestId('limit-range')
fireEvent.change(range, { target: { value: '100' } })
expect(onSave).not.toHaveBeenCalled()
fireEvent.blur(range)
expect(onSave).toHaveBeenCalledWith('roadtrip_range_km', 100)
const fill = screen.getByTestId('limit-fill')
fireEvent.change(fill, { target: { value: '80' } })
fireEvent.blur(fill)
expect(onSave).toHaveBeenCalledWith('roadtrip_fill_percent', 80)
})
it('FE-ROADTRIP-LIMITS-001: typing a number writes nothing until the field is left', () => {
const onSave = vi.fn()
open(onSave)
@@ -261,3 +276,16 @@ describe('RoadtripLimitsCard', () => {
})
})
})
vi.mock('../../hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
}))
it('shares service stops with Days by default and lets the trip turn it off', () => {
const onSave = vi.fn()
open(onSave)
const toggle = screen.getByRole('button', { name: 'Show in Days too' })
expect(toggle).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(toggle)
expect(onSave).toHaveBeenCalledWith('roadtrip_service_stops_in_days', false)
})
@@ -1,6 +1,7 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import React, { useState } from 'react'
import {
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship,
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check,
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
} from 'lucide-react'
import Modal from '../shared/Modal'
@@ -14,6 +15,9 @@ import { valhallaAvailable } from '../Map/valhallaRoute'
import ToggleSwitch from '../Settings/ToggleSwitch'
import type { DistanceUnit, RouteAvoidClass } from '../../types'
import { FS } from './typeScale'
import DayWindowFields from './DayWindowFields'
import SettingsHint from './SettingsHint'
import { dayWindow } from './dayWindow'
/**
* Everything that decides when the rail speaks up about the driving.
@@ -190,10 +194,10 @@ function Panel({ icon: Icon, title, note, children }: {
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">
<section className="flex min-w-0 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}
{note ? <SettingsHint>{note}</SettingsHint> : null}
</section>
)
}
@@ -248,7 +252,9 @@ const SPEC_ROWS: Record<VehicleKind, { key: SpecKey; setting: string; Icon: type
*/
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 }: {
export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loading = false }: {
loading?: boolean
onResetDayBoundaries?: () => Promise<void>
/**
* Persists one setting. Absent leaves the dialog read-only.
*
@@ -258,13 +264,15 @@ export default function RoadtripLimitsCard({ onSave }: {
onSave?: (key: string, value: number | string | boolean) => void
}): React.ReactElement {
const { t } = useTranslation()
const settings = useSettingsStore(s => s.settings)
const settings = useRoadtripSettings(s => s)
const unit = useSettingsStore(s => s.settings.distance_unit)
const [open, setOpen] = useState(false)
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
const distanceUnit: DistanceUnit = unit === 'imperial' ? 'imperial' : 'metric'
const imperial = distanceUnit === 'imperial'
const legMinutes = settings.roadtrip_leg_minutes
const dayMinutes = settings.roadtrip_day_minutes
const automaticWindow = dayWindow(settings.roadtrip_day_start, settings.roadtrip_day_end)
const rangeKm = settings.roadtrip_range_km
// Parsed rather than trusted: a per-user setting gets no server-side validation, and
@@ -335,7 +343,8 @@ export default function RoadtripLimitsCard({ onSave }: {
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
// 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 rangeValue = computedKm ?? rangeKm
const rangeShown = rangeValue ? Math.round(convertDistance(rangeValue, distanceUnit)) : undefined
const setRange = (shown: number) => {
const km = imperial ? shown / 0.621371 : shown
onSave?.('roadtrip_range_km', Math.round(km))
@@ -347,6 +356,7 @@ export default function RoadtripLimitsCard({ onSave }: {
// imperial traveller reads miles here and types miles in the dialog, while what is
// stored stays kilometres either way.
const badges = [
automaticWindow ? { key: 'window', Icon: CalendarClock, text: `${settings.roadtrip_day_start} · ${settings.roadtrip_day_end}` } : null,
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
// The planning range, not the typed one: when the vehicle's figures win, the badge
@@ -374,6 +384,8 @@ export default function RoadtripLimitsCard({ onSave }: {
<button
type="button"
onClick={() => setOpen(true)}
disabled={loading}
aria-busy={loading}
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 badges below already carry a clock, a
@@ -381,7 +393,7 @@ export default function RoadtripLimitsCard({ onSave }: {
they need to sit on one line. */}
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-body font-semibold text-content">
{t('roadtrip.limit.title')}
{loading ? t('common.loading') : t('roadtrip.limit.title')}
</span>
{badges.length ? (
<span className="flex flex-wrap items-center gap-1">
@@ -418,7 +430,7 @@ export default function RoadtripLimitsCard({ onSave }: {
{t('roadtrip.limit.title')}
</span>
}>
<div className="flex flex-col gap-4">
<div className="flex min-w-0 flex-col gap-4">
{/* Two columns, kept. One column reads more tidily but turns the dialog into
something that has to be scrolled, and a setting you cannot see while you
change another is worse than a seam down the middle.
@@ -426,9 +438,9 @@ export default function RoadtripLimitsCard({ onSave }: {
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. */}
left column holds trip settings, the right holds the car and route appearance. */}
<div className="grid items-start gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-4">
<div className="flex min-w-0 flex-col gap-4">
<Panel icon={Clock} title={t('roadtrip.limit.sectionDriving')} note={t('roadtrip.limit.hint')}>
<LimitRow
icon={Clock}
@@ -450,6 +462,11 @@ export default function RoadtripLimitsCard({ onSave }: {
/>
</Panel>
<Panel icon={CalendarClock} title={t('roadtrip.window.title')}>
<DayWindowFields start={settings.roadtrip_day_start} end={settings.roadtrip_day_end} endMode={settings.roadtrip_day_end_mode} onSave={onSave} />
{onResetDayBoundaries && <button type="button" onClick={() => void onResetDayBoundaries()} className="text-start text-caption font-medium text-accent-on hover:underline">{t('roadtrip.window.resetBoundaries')}</button>}
</Panel>
<Panel
icon={Signpost}
title={t('roadtrip.avoid.section')}
@@ -466,19 +483,155 @@ export default function RoadtripLimitsCard({ onSave }: {
/>
))}
</Panel>
</div>
<Panel icon={Route} title={t('roadtrip.line.section')} note={t('roadtrip.line.hint')}>
<div className="flex min-w-0 flex-col gap-4">
<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>
<Panel icon={Route} title={t('roadtrip.line.section')} note={automaticWindow ? undefined : 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
{automaticWindow ? (
<div className="flex items-center gap-3" title={t('roadtrip.window.hint')}>
<Link2 size={16} className="shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1 text-body text-content-secondary">{t('roadtrip.line.connect')}</span>
<Check size={16} className="text-accent-on" aria-label={t('roadtrip.window.hint')} />
</div>
) : <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
@@ -489,135 +642,18 @@ export default function RoadtripLimitsCard({ onSave }: {
onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)}
/>
</Panel>
<Panel icon={Signpost} title={t('roadtrip.hazards.current')} note={t('roadtrip.hazards.note')}>
<AvoidRow icon={Signpost} label={t('roadtrip.hazards.show')} on={settings.roadtrip_show_hazards === true} disabled={!onSave}
onToggle={() => onSave?.('roadtrip_show_hazards', !settings.roadtrip_show_hazards)} />
</Panel>
<Panel icon={Signpost} title={t('roadtrip.stops.section')} note={t('roadtrip.stops.daysHint')}>
<AvoidRow icon={Link2} label={t('roadtrip.stops.inDays')}
on={settings.roadtrip_service_stops_in_days !== false}
disabled={!onSave}
onToggle={() => onSave?.('roadtrip_service_stops_in_days', settings.roadtrip_service_stops_in_days === false)}
/>
</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>
@@ -879,7 +879,7 @@ describe('RoadtripSidebar', () => {
// 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()
expect(screen.queryByText('640 km')).toBeNull()
})
})
@@ -1,3 +1,5 @@
import ChargingInfo from './ChargingInfo'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import React, { useState } from 'react'
import {
CarFront, Footprints, Bike, Zap, AlertTriangle,
@@ -27,8 +29,10 @@ import type { RouteVia } from '../../types'
import { FS } from './typeScale'
import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop'
interface RoadtripSidebarProps {
onFocusPoint?: (lat: number, lng: number) => void
/** Legs and totals for the whole trip, computed once in the planner hook. */
routes: RoadtripRoutes
selectedAssignmentId?: number | null
@@ -769,7 +773,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
const { t } = useTranslation()
// Read here rather than threaded down: both stop shapes need the same two, and the
// badge is the only thing in the rail that depends on them.
const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent)
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent)
const { vehicleKind } = useVehicleRange()
const kind = STOP_KIND_BY_KEY[stop.stopType ?? '']
const Icon = kind?.Icon ?? ParkingSquare
@@ -830,10 +834,10 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
{/* Truncated, not wrapped: a service stop is a waypoint, and its full name lives
on the map pin — where a place's name is the row's whole reason to exist. */}
<span
className="min-w-0 truncate font-semibold leading-6 tracking-[-0.012em] text-content-secondary"
className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content-secondary"
style={{ fontSize: FS.name }}
>
{stop.name}
<span className="min-w-0 truncate">{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}<CheckoutDeparture stop={stop} entry={entry} />
</span>
<span className="flex flex-wrap items-center gap-1">
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
@@ -955,6 +959,17 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea
* the state the whole view gets opened for. It is also the only filled accent left in the
* rail: selection moved to the row's own background, so nothing else competes with it.
*/
function CheckoutDeparture({ stop, entry }: { stop: RoadtripStop; entry?: ScheduleEntry }): React.ReactElement | null {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
if (stop.checkoutAt === undefined || !entry?.departure) return null
return <Tooltip label={t('roadtrip.spill.departs', { time: formatClockTime(entry.departure, is12h) })}>
<span dir="ltr" tabIndex={0} className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-secondary px-1 py-0.5 align-middle whitespace-nowrap font-medium text-[length:calc(10px*var(--fs-scale-caption,1))] leading-none tabular-nums text-content-muted">
<Clock size={10} aria-hidden />{formatClockTime(entry.departure, is12h)}
</span>
</Tooltip>
}
function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
@@ -1018,7 +1033,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
const { t } = useTranslation()
// Read here rather than threaded down: both stop shapes need the same two, and the
// badge is the only thing in the rail that depends on them.
const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent)
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent)
const { vehicleKind } = useVehicleRange()
return (
<button
@@ -1087,10 +1102,10 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
{/* Wraps rather than truncates: the name is what the row is for, and thirty of
them cut off mid-word is a list nobody reads. */}
<span
className="min-w-0 break-words font-semibold leading-6 tracking-[-0.012em] text-content"
className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content"
style={{ fontSize: FS.name }}
>
{stop.name}
<span className={stop.stopType === 'charging' || stop.checkoutAt !== undefined ? 'min-w-0 truncate' : 'min-w-0 break-words'}>{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}<CheckoutDeparture stop={stop} entry={entry} />
</span>
{/* Two halves under one border: the word says what the number means, so the
number needs no unit of explanation beside it. */}
@@ -1203,13 +1218,7 @@ function SpillBlock({ spill, children }: {
boxShadow: `inset 0 1px 0 color-mix(in srgb, var(--info) 20%, transparent)`,
}}
>
{/* Moonlight from the left, falling on the mascot. A radial wash rather than a
second background colour: it has no edge of its own, so the head reads as lit
rather than as another box stacked on the first. */}
<div
className="px-2 pb-1 pt-2"
style={{ backgroundImage: `radial-gradient(140px 64px at 22px 26px, color-mix(in srgb, var(--info) 12%, transparent), transparent 72%)` }}
>
<div className="px-2 pb-1 pt-2">
<div className="flex items-center gap-2 text-info">
<MDancingTrek scene="idle" mood="sleepy" size={26} />
<span
@@ -1231,7 +1240,7 @@ function SpillBlock({ spill, children }: {
reason the block exists, and the kilometres on it are the ones the card's
header now counts. Not clickable: alternatives are asked for on the day the
leg is stored on, and offering the same leg twice would be two answers. */}
<div className="grid" style={RAIL_GRID}>
<div className={spill.automatic ? 'hidden' : 'grid'} style={RAIL_GRID}>
<span className="relative z-[1] flex flex-col items-center" aria-hidden>
<span className="flex-1" style={RAIL_DASH} />
</span>
@@ -1259,7 +1268,7 @@ function SpillBlock({ spill, children }: {
The stop it leaves from is not named — it is drawn on yesterday's card
directly above, and a second row for it would read as a stop made
twice. */}
{departure ? (
{departure && spill.fromStop?.checkoutAt === undefined ? (
<span
dir="ltr"
className="ms-auto shrink-0 whitespace-nowrap font-semibold leading-6 tabular-nums"
@@ -1294,7 +1303,8 @@ function SpillBlock({ spill, children }: {
* move, a stay edit or a refuel offer still names the day the server knows it by. See
* `nightSpill.ts`.
*/
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle }: {
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
day: RoadtripDay
/** Folded down to the header, and off the map with it. */
collapsed?: boolean
@@ -1328,20 +1338,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
// The colour the map draws this day in, or none at all while the map is drawing one
// blue line for the whole trip.
const dayColorsOn = useSettingsStore(s => !!s.settings.roadtrip_day_colors)
const dayColorsOn = useRoadtripSettings(s => !!s.roadtrip_day_colors)
const tint = dayColorsOn ? dayColor(day.dayNumber).line : null
const last = day.stops.length - 1
/**
* The findings a stop wears, minus the one the refuel band already says better.
*
* The range warning marks the stop somebody FINDS OUT at and counts the whole tank;
* the band sits on the leg the fuel actually ends on and offers somewhere to stop. Both
* at once says the same thing twice, in the wrong order — the answer above the problem
* — so the warning stands down wherever the band is showing.
*/
const showingBand = !!refuel && !loading
const findingsFor = (i: number) =>
day.driveWarnings.filter(w => w.index === i && !(showingBand && w.code === 'range'))
day.driveWarnings.filter(w => w.index === i && w.code !== 'range')
// The running number a stop wears, with the service stops passed over — so a day with a
// charger halfway through still counts one, two, three the way its map pins do.
let counted = 0
@@ -1354,7 +1355,21 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
* `stop.ownerIndex` instead, because a stop driven onto this date through the night is
* still stored on the day it set off from (`nightSpill.ts`).
*/
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => {
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement => {
if (stop.automaticNight) return (
<li key={stop.assignmentId}>
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
{i < last && day.legs[i]?.distance !== 0 ? <DriveBand leg={day.legs[i]} /> : null}
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === i).map(dry => (
<RefuelBand key={`dry-${dry.legIndex}`} dry={dry} dayId={day.dayId} refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
)) : null}
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
)) : null}
</li>
)
const service = isServiceStopType(stop.stopType)
if (!service) counted += 1
// Every finding at this index is read on its own. Taking the first match let an
@@ -1424,10 +1439,10 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
/>
)}
{i < last ? (
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
<DriveBand
leg={day.legs[i]}
onAskAlternatives={onAskAlternatives ? () => onAskAlternatives(day.dayId, i) : undefined}
onAskAlternatives={onAskAlternatives && !day.stops[i + 1].automaticNight ? () => onAskAlternatives(day.dayId, i) : undefined}
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
/>
) : null}
@@ -1469,6 +1484,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
* Built rather than mapped straight through, because a spill is drawn inside a block of
* its own and a block cannot be opened halfway down a `map`.
*/
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => (
<React.Fragment key={stop.assignmentId}>
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === -i - 1).map(dry => (
<li key={`inbound-${i}`}>
<RefuelBand dry={dry} dayId={day.dayId} refuel={refuel}
onAsk={() => onAskRefuel?.(day.dayId, dry)}
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
</li>
)) : null}
{renderStopContent(stop, i)}
</React.Fragment>
)
const runs: { spill: SpillMark | null; from: number; to: number }[] = []
{
let at = 0
@@ -1556,7 +1583,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
</Tooltip>
) : null}
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })}
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })}
</span>
{/* The other half of "where possible". The setting is a weighting, so a day
with no untolled crossing comes back on the toll road — and the only thing
@@ -1714,7 +1741,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
export default function RoadtripSidebar({
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
collapsedDayIds, onToggleDay,
collapsedDayIds, onToggleDay, onFocusPoint,
}: RoadtripSidebarProps): React.ReactElement {
const { t } = useTranslation()
// One drag state for the whole rail rather than one per day: a stop that cannot leave
@@ -1751,12 +1778,18 @@ export default function RoadtripSidebar({
<div className="flex min-h-0 flex-1 flex-col gap-3 pt-1">
<div className="shrink-0">
<TripSummary routes={routes} />
{routes.dayWindowIssue ? (
<p role="status" className="mx-3.5 mt-2 rounded-xl bg-warning-soft p-3 text-caption text-content">
{t(`roadtrip.window.${routes.dayWindowIssue}`)}
</p>
) : null}
</div>
<div className="roadtrip-rail-scroll flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto pb-3.5">
{routes.days.map(day => (
<DaySection
key={day.dayId}
day={day}
onFocusPoint={onFocusPoint}
selectedAssignmentId={selectedAssignmentId}
onSelectStop={onSelectStop}
onReorderStop={onReorderStop}
@@ -1767,7 +1800,7 @@ export default function RoadtripSidebar({
onEditStay={onEditStay}
onSetStopKind={onSetStopKind}
onSetStopFill={onSetStopFill}
onFollowTrack={onFollowTrack}
onFollowTrack={day.dayId < 0 ? undefined : onFollowTrack}
viaCount={viaCounts?.[day.dayId] ?? 0}
trackName={trackNames?.[day.dayId]}
refuel={refuel}
@@ -90,13 +90,13 @@ describe('RoadtripStopPopup', () => {
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
})
it('FE-ROADTRIP-STOPPOPUP-007: unpicking the kind leaves an ordinary place', async () => {
it('FE-ROADTRIP-STOPPOPUP-007: clicking the selected kind keeps a service stop', async () => {
const onSave = vi.fn()
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
fireEvent.click(screen.getByRole('button', { name: /Fuel/ }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: null, dwellMinutes: 10 }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 10 }))
})
/**
@@ -275,3 +275,30 @@ describe('RoadtripStopPopup', () => {
expect(screen.getByText('Kind of stop')).toBeInTheDocument()
})
})
describe('editing a roadtrip stop', () => {
it('loads the saved duration and saves without adding another stop', async () => {
const onSave = vi.fn()
wrap(<RoadtripStopPopup draft={draft({ editing: { placeId: 7, stopType: 'fuel', dwellMinutes: 45 } })} {...noop} onSave={onSave} />)
expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
})
it('loads and preserves an existing overnight booking', async () => {
const onSaveNight = vi.fn()
wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category: 'hotel' }), overnight: { days: [{ id: 4, number: 1, date: null }, { id: 5, number: 2, date: null }], defaultEndDayId: 5 }, editing: { placeId: 7, stopType: 'hotel', dwellMinutes: 30, accommodationId: 8, checkIn: '16:00', checkOut: '10:00' } })} {...noop} onSaveNight={onSaveNight} />)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '16:00', checkOut: '10:00' }))
})
})
it('prefills check-in from arrival while preserving a saved manual time', () => {
const onSaveNight = vi.fn()
wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category: 'hotel' }), arrivalTime: '15:45', overnight: { days: [{ id: 4, number: 1, date: null }, { id: 5, number: 2, date: null }], defaultEndDayId: 5 } })} {...noop} onSaveNight={onSaveNight} />)
expect(screen.getByRole('textbox', { name: 'Check-in' })).toHaveValue('15:45')
fireEvent.change(screen.getByRole('textbox', { name: 'Check-in' }), { target: { value: '16:30' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '16:30', checkOut: '' })
})
@@ -2,10 +2,12 @@ import React, { useState } from 'react'
import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import StayPortals from './StayPortals'
import { useTranslation } from '../../i18n/TranslationContext'
import { safeExternalHref } from '../../utils/safeUrl'
import { formatDate } from '../../utils/formatters'
import { formatDurationShort, SERVICE_COLORS } from './roadtripModel'
import { formatDurationShort } from './roadtripModel'
import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds'
import type { CorridorPoi } from './useCorridorPois'
import type { RoadtripStopType } from '@trek/shared'
@@ -28,6 +30,8 @@ import type { RoadtripStopType } from '@trek/shared'
const DWELL_CHOICES = [5, 10, 20, 30, 45, 60]
export interface RoadtripStopDraft {
editing?: { placeId: number; dwellMinutes: number; stopType: RoadtripStopType | null; accommodationId?: number; checkIn?: string; checkOut?: string }
arrivalTime?: string | null
poi: CorridorPoi
dayId: number
/** Where in the day's chain it goes, worked out from how far along the drive it sits. */
@@ -81,15 +85,15 @@ export default function RoadtripStopPopup({
// scheme for a bare host and drops anything that is not http(s).
const websiteHref = safeExternalHref(draft?.poi.website)
const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category)
const [stopType, setStopType] = useState<RoadtripStopType | null>(suggested?.key ?? null)
const [dwell, setDwell] = useState<number>(suggested?.defaultMinutes ?? 30)
const [stopType, setStopType] = useState<RoadtripStopType | null>(draft?.editing ? draft.editing.stopType : suggested?.key ?? null)
const [dwell, setDwell] = useState<number>(draft?.editing?.dwellMinutes ?? suggested?.defaultMinutes ?? 30)
// A hotel is a night by default and a campsite a pause, which is what each already
// means everywhere else — but both offer the other, because a campsite is somewhere
// people sleep and a hotel is somewhere people stop for lunch.
const [night, setNight] = useState<boolean>(draft?.poi.category === 'hotel')
const [night, setNight] = useState<boolean>(draft?.editing ? !!draft.editing.accommodationId : draft?.poi.category === 'hotel')
const [endDayId, setEndDayId] = useState<number | null>(draft?.overnight?.defaultEndDayId ?? null)
const [checkIn, setCheckIn] = useState('')
const [checkOut, setCheckOut] = useState('')
const [checkIn, setCheckIn] = useState(draft?.editing?.checkIn || draft?.arrivalTime || '')
const [checkOut, setCheckOut] = useState(draft?.editing?.checkOut ?? '')
const [saving, setSaving] = useState(false)
if (!draft) return null
@@ -116,8 +120,10 @@ export default function RoadtripStopPopup({
}
return (
<Modal isOpen onClose={onClose} title={t('roadtrip.stop.addTitle')} size="sm">
<Modal isOpen onClose={onClose} title={t(draft.editing ? 'common.edit' : 'roadtrip.stop.addTitle')} size={overnight ? 'xl' : 'sm'}>
<div className="flex flex-col gap-4">
<div className={overnight ? "grid grid-cols-1 gap-5 sm:grid-cols-2" : ""}>
<div className="flex min-w-0 flex-col gap-4">
<div className="flex items-start gap-3">
<span
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
@@ -201,28 +207,15 @@ export default function RoadtripStopPopup({
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('day.checkIn')}
</span>
<input
type="time"
value={checkIn}
onChange={e => setCheckIn(e.target.value)}
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
/>
<CustomTimePicker value={checkIn} onChange={setCheckIn} aria-label={t('day.checkIn')} placeholder="" style={{ marginTop: 6, width: '100%' }} />
</label>
<label className="min-w-0 flex-1">
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('day.checkOut')}
</span>
<input
type="time"
value={checkOut}
onChange={e => setCheckOut(e.target.value)}
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
/>
<CustomTimePicker value={checkOut} onChange={setCheckOut} aria-label={t('day.checkOut')} placeholder="" style={{ marginTop: 6, width: '100%' }} />
</label>
</div>
{/* What OSM knows about the house, which is the whole of what we offer: no
price, no availability, no link into a booking portal. TREK links to maps
and to the place's own site, never to a vendor. */}
{websiteHref || draft.poi.phone ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-caption">
{websiteHref ? (
@@ -258,7 +251,7 @@ export default function RoadtripStopPopup({
type="button"
aria-pressed={on}
onClick={() => {
setStopType(on ? null : key)
setStopType(key)
// Picking a kind is also picking how long it takes, until the user
// says otherwise — a charge is not a fuel stop.
if (!on) setDwell(STOP_KIND_BY_KEY[key].defaultMinutes)
@@ -303,6 +296,12 @@ export default function RoadtripStopPopup({
</>
)}
</div>
{overnight ? <StayPortals lat={draft.poi.lat} lng={draft.poi.lng} name={draft.poi.name} camping={draft.poi.category === 'campsite'}
arrival={overnight.days.find(day => day.id === draft.dayId)?.date}
departure={overnight.days.find(day => day.id === (endDayId ?? overnight.defaultEndDayId))?.date} /> : null}
</div>
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
<button
type="button"
@@ -317,7 +316,7 @@ export default function RoadtripStopPopup({
disabled={saving}
className="ms-auto rounded-lg bg-accent px-3.5 py-1.5 text-body font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{t('roadtrip.poi.add')}
{t(draft.editing ? 'common.save' : 'roadtrip.poi.add')}
</button>
</div>
</div>
@@ -0,0 +1,7 @@
import { Tooltip } from '../shared/Tooltip'
export default function SettingsHint({ children, id }: { children: string; id?: string }) {
return <Tooltip label={children} placement="top">
<p id={id} tabIndex={0} className="min-w-0 truncate text-caption leading-snug text-content-faint">{children}</p>
</Tooltip>
}
@@ -0,0 +1,53 @@
import React, { useState } from 'react'
import { Check, Copy, ExternalLink } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { trivagoSearchHref, campingSearchHref } from './stayPortalLinks'
import { copyText } from '../../utils/clipboard'
const HOTEL_PORTALS = [
{ name: 'trivago', icon: '/images/portals/trivago.png', href: 'https://www.trivago.com/' },
{ name: 'CHECK24', icon: '/images/portals/check24.png', href: 'https://hotel.check24.de/' },
]
const CAMPING_PORTALS = [
{ name: 'PiNCAMP', icon: '/images/portals/pincamp.png', href: 'https://www.pincamp.com/' },
{ name: 'Pitchup', icon: '/images/portals/pitchup.ico', href: 'https://www.pitchup.com/' },
]
export default function StayPortals({ name, camping, lat, lng, arrival, departure }: { name: string; camping: boolean; lat: number; lng: number; arrival?: string | null; departure?: string | null }): React.ReactElement {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const [copyFailed, setCopyFailed] = useState(false)
const copy = async (): Promise<void> => {
const didCopy = await copyText(name)
setCopied(didCopy)
setCopyFailed(!didCopy)
}
return (
<aside className="flex min-w-0 flex-col gap-3 rounded-xl bg-surface-secondary p-4">
<p className="text-body font-semibold text-content">{t('roadtrip.stay.portals')}</p>
<p className="text-caption text-content-muted">{t('roadtrip.stay.portalHint')}</p>
<div className="flex items-center gap-2 rounded-lg border border-edge bg-surface px-3 py-2">
<span className="min-w-0 flex-1 select-text break-words text-caption text-content-secondary">{name}</span>
<button type="button" onClick={() => void copy()} aria-label={t(copied ? 'common.copied' : 'common.copy')}
className="shrink-0 rounded-md p-1 text-content-muted hover:bg-surface-hover hover:text-content">
{copied ? <Check size={14} aria-hidden /> : <Copy size={14} aria-hidden />}
</button>
</div>
{copyFailed ? <p role="alert" className="text-caption text-danger">{t('common.error')}</p> : null}
<div className="flex flex-col gap-2">
{(camping ? CAMPING_PORTALS : HOTEL_PORTALS).map(portal => (
<a key={portal.name} href={portal.name === 'trivago' ? trivagoSearchHref(name, arrival, departure) : portal.name === 'PiNCAMP' || portal.name === 'Pitchup' ? campingSearchHref(portal.name, name, lat, lng, arrival, departure) : portal.href} target="_blank" rel="noopener noreferrer"
className="flex items-center justify-between gap-3 rounded-lg border border-edge bg-surface px-3 py-2.5 text-body font-medium text-content transition-colors hover:bg-surface-hover">
<span className="flex min-w-0 items-center gap-2.5">
<img src={portal.icon} alt="" width={20} height={20} className="h-5 w-5 shrink-0 rounded object-contain" />
{portal.name}
</span>
<ExternalLink size={14} className="text-content-muted" aria-hidden />
</a>
))}
</div>
</aside>
)
}
@@ -110,3 +110,7 @@ describe('StopFillPicker', () => {
expect(onPick).toHaveBeenCalledWith(null)
})
})
vi.mock('../../hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
}))
@@ -1,3 +1,4 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import React from 'react'
import { RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
@@ -40,7 +41,7 @@ export default function StopFillPicker({ anchor, current, onPick, onClose }: {
}): 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 fallback = useRoadtripSettings(s => s.roadtrip_fill_percent)
const { rangeKm } = useVehicleRange()
// Zero, absent and 100 all mean the same thing in the settings, so they read as full
@@ -0,0 +1,19 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import VisitControls from './VisitControls'
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
it('edits the shared stay without changing the day ending', () => {
const edit = vi.fn(), toggle = vi.fn(async () => {})
render(<VisitControls endDay={{ active: false, onToggle: toggle }} stay={{ minutes: 90, onEdit: edit }} />)
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 1 h 30 min' }))
expect(edit).toHaveBeenCalledOnce()
expect(toggle).not.toHaveBeenCalled()
})
it('keeps stay editing when daily travel times are off', () => {
render(<VisitControls stay={{ minutes: null, onEdit: vi.fn() }} />)
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 0 min' })).toBeEnabled()
expect(screen.queryByRole('button', { name: 'roadtrip.window.endHere' })).not.toBeInTheDocument()
})
@@ -0,0 +1,25 @@
import { Clock, Pencil } from 'lucide-react'
import EndDayControl, { type EndDayControlProps } from './EndDayControl'
import { useTranslation } from '../../i18n/TranslationContext'
import { formatDurationShort } from './roadtripModel'
export interface RoadtripStayControl {
minutes: number | null
onEdit?: () => void
}
export default function VisitControls({ endDay, stay }: { endDay?: EndDayControlProps; stay?: RoadtripStayControl }) {
const { t } = useTranslation()
return (
<div className={`grid gap-2 ${endDay && stay ? 'grid-cols-2' : 'grid-cols-1'}`}>
{endDay && <EndDayControl {...endDay} />}
{stay && <button type="button" onClick={stay.onEdit} disabled={!stay.onEdit}
className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 text-start enabled:hover:bg-surface-tertiary disabled:cursor-default">
<span className="flex items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium uppercase text-content-secondary"><Clock size={13} className="shrink-0 text-content-faint" aria-hidden />{t('roadtrip.stop.stayShort')}</span>
{' '}<span className="flex shrink-0 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary">
{stay.minutes === null ? '+' : formatDurationShort(stay.minutes * 60)}{stay.onEdit && <Pencil size={12} className="text-content-faint" aria-hidden />}
</span>
</button>}
</div>
)
}
+2 -342
View File
@@ -1,342 +1,2 @@
/**
* Corridor geometry — "what lies along this drive", without asking Overpass to think
* about a polyline.
*
* The naive form (`around:` with a hundred coordinates) is what every POI provider warns
* about and times out on. OsmAnd solves it the other way round and this follows it: ask
* for plain bounding boxes that cover the route, then measure each candidate's
* perpendicular distance to the line and throw away what is too far. The boxes are what
* the existing /api/maps/pois route already accepts, so no new server surface is needed.
*
* Everything here is pure: no React, no network, no store.
*/
export interface LatLng {
lat: number
lng: number
}
export interface Bbox {
south: number
west: number
north: number
east: number
}
const EARTH_RADIUS_KM = 6371
const KM_PER_DEG_LAT = 111.32
const toRad = (deg: number): number => (deg * Math.PI) / 180
/** Great-circle distance in kilometres. */
export function haversineKm(a: LatLng, b: LatLng): number {
const dLat = toRad(b.lat - a.lat)
const dLng = toRad(b.lng - a.lng)
const lat1 = toRad(a.lat)
const lat2 = toRad(b.lat)
const h =
Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2)
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)))
}
/**
* Perpendicular distance from a point to a line segment, in kilometres.
*
* Works in a local equirectangular projection around the segment: longitudes shrink by
* cos(lat), which is the whole reason a uniform degree padding is wrong at 60° north.
* Over the few kilometres a corridor spans, the error against a proper geodesic is far
* below the width the user picked.
*/
export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number {
return projectOnSegment(p, a, b).distanceKm
}
/**
* Perpendicular distance to a segment plus where along it the foot of that perpendicular
* falls (0 at `a`, 1 at `b`). The second number is what places a candidate correctly in
* the drive — taking the segment's midpoint instead makes every petrol station on a long
* straight report the same position.
*/
/** A segment shorter than a millimetre, squared — below this there is nothing to project onto. */
const DEGENERATE_SEGMENT_KM2 = 1e-12
export function projectOnSegment(p: LatLng, a: LatLng, b: LatLng): { distanceKm: number; t: number } {
const latScale = KM_PER_DEG_LAT
const lngScale = KM_PER_DEG_LAT * Math.cos(toRad((a.lat + b.lat) / 2))
const ax = a.lng * lngScale
const ay = a.lat * latScale
const bx = b.lng * lngScale
const by = b.lat * latScale
const px = p.lng * lngScale
const py = p.lat * latScale
const dx = bx - ax
const dy = by - ay
const lenSq = dx * dx + dy * dy
// A segment whose two ends are the same point to within a millimetre has no
// direction to project onto, so measure straight to it. Written as a range because
// the ends came through a chain of floating point arithmetic and never land on 0.
if (lenSq < DEGENERATE_SEGMENT_KM2) return { distanceKm: haversineKm(p, a), t: 0 }
// Clamped so a point beside the segment's end measures to the end, not to the
// infinite line — otherwise a stop 200 km past the destination looks "on the way".
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq))
const cx = ax + t * dx
const cy = ay + t * dy
return { distanceKm: Math.hypot(px - cx, py - cy), t }
}
export interface CorridorHit {
/** Kilometres from the route line at its closest point. */
offRouteKm: number
/** Kilometres travelled along the route before reaching that closest point. */
alongKm: number
}
/**
* Where a point sits relative to the route: how far off it is, and how far into the
* drive it comes. The second number is what puts "lunch" before "the hotel" in a list
* instead of sorting by name.
*/
export function projectOntoRoute(p: LatLng, line: LatLng[]): CorridorHit | null {
if (line.length < 2) return null
let best = Number.POSITIVE_INFINITY
let bestAlong = 0
let travelled = 0
for (let i = 0; i < line.length - 1; i++) {
const a = line[i]
const b = line[i + 1]
const segment = haversineKm(a, b)
const { distanceKm: d, t } = projectOnSegment(p, a, b)
if (d < best) {
best = d
bestAlong = travelled + t * segment
}
travelled += segment
}
return { offRouteKm: best, alongKm: bestAlong }
}
/**
* Bounding boxes covering the route, each no larger than `maxSpanDeg`.
*
* The server clamps any box bigger than that (MAX_BBOX_SPAN_DEG) so Overpass is not
* asked to scan a continent, which means a long drive has to be cut into boxes here
* rather than sent as one. Boxes are grown along the route until one would exceed the
* span, so a straight motorway needs far fewer requests than a mountain road.
*/
/**
* Splits a step that is longer than a box into even pieces.
*
* The simplifier is right to reduce a dead straight motorway stretch to its two ends,
* but tiling walks point to point: two ends 100 km apart produce a box at each end and
* nothing in between, so the middle of the drive would never be searched.
*/
function densify(line: LatLng[], maxStepDeg: number): LatLng[] {
if (line.length < 2) return line
const out: LatLng[] = [line[0]]
for (let i = 1; i < line.length; i++) {
const a = line[i - 1]
const b = line[i]
const span = Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lng - a.lng))
const steps = Math.max(1, Math.ceil(span / maxStepDeg))
for (let s = 1; s <= steps; s++) {
out.push({
lat: a.lat + ((b.lat - a.lat) * s) / steps,
lng: a.lng + ((b.lng - a.lng) * s) / steps,
})
}
}
return out
}
export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45): Bbox[] {
if (line.length === 0) return []
const padLat = widthKm / KM_PER_DEG_LAT
const tiles: Bbox[] = []
let current: Bbox | null = null
const padded = (b: Bbox): Bbox => {
// Longitude degrees shrink towards the poles, so the padding has to grow with
// latitude or the corridor is narrower than asked for in the north.
const midLat = (b.north + b.south) / 2
const padLng = padLat / Math.max(0.2, Math.cos(toRad(midLat)))
return {
south: b.south - padLat,
north: b.north + padLat,
west: b.west - padLng,
east: b.east + padLng,
}
}
// The point the open box last swallowed. When a box closes, the next one starts
// from it rather than from `p` alone, so the two overlap by exactly one step of
// the walk. Starting at `p` left the stretch between them covered only by the
// padding on either side — and the walk steps in up to maxSpanDeg/2 while the
// padding is widthKm, so at the offered widths a band of several kilometres of
// route fell into no box at all. Nothing downstream noticed: the panel counts
// tiles asked and tiles answered, so it reported a finished, uncapped,
// error-free search of a drive it had never fully looked at. "Find fuel along
// the route" is the whole feature.
let previous: LatLng | null = null
for (const p of densify(line, maxSpanDeg / 2)) {
if (!current) {
current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng }
previous = p
continue
}
const next: Bbox = {
south: Math.min(current.south, p.lat),
north: Math.max(current.north, p.lat),
west: Math.min(current.west, p.lng),
east: Math.max(current.east, p.lng),
}
const grown = padded(next)
if (grown.north - grown.south > maxSpanDeg || grown.east - grown.west > maxSpanDeg) {
tiles.push(padded(current))
const from = previous ?? p
current = {
south: Math.min(from.lat, p.lat),
north: Math.max(from.lat, p.lat),
west: Math.min(from.lng, p.lng),
east: Math.max(from.lng, p.lng),
}
} else {
current = next
}
previous = p
}
if (current) tiles.push(padded(current))
return tiles
}
/**
* Thins a route down to the points that still describe it, so the corridor is built
* from tens of coordinates rather than the thousands a routed geometry carries.
* Ramer-Douglas-Peucker with the tolerance expressed in kilometres.
*/
export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
if (line.length < 3) return line.slice()
let maxDist = 0
let index = 0
const first = line[0]
const last = line[line.length - 1]
for (let i = 1; i < line.length - 1; i++) {
const d = distanceToSegmentKm(line[i], first, last)
if (d > maxDist) {
maxDist = d
index = i
}
}
if (maxDist <= toleranceKm) return [first, last]
const left = simplifyLine(line.slice(0, index + 1), toleranceKm)
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,
}
}
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround } from '@trek/shared/roadtrip'
export type { LatLng, Bbox, CorridorHit } from '@trek/shared/roadtrip'
@@ -0,0 +1,21 @@
import { expect, it } from 'vitest'
import { nearestDayBoundary, type DayBoundaryLeg } from './dayBoundaryPath'
import type { RoadtripStop } from './useRoadtripRoutes'
const point = (assignmentId: number, lng: number) => ({ assignmentId, lat: 0, lng }) as RoadtripStop
const path: DayBoundaryLeg[] = [{ from: point(10, 0), to: point(20, 1), position: 0, line: [[0, 0], [0, 0.5], [0, 1]] }]
const project = (lat: number, lng: number) => ({ x: lng * 1000, y: lat * 1000 })
it('snaps to a visit before considering nearby road segments', () => {
expect(nearestDayBoundary(path, 1, { x: 985, y: 2 }, project)?.boundary).toEqual({ day_number: 1, from_assignment_id: 20, to_assignment_id: null, fraction: 1 })
})
it('projects the pointer onto the full route with a distance fraction', () => {
const target = nearestDayBoundary(path, 2, { x: 300, y: 100 }, project)!
expect(target.boundary.fraction).toBeCloseTo(0.3)
expect(target.lng).toBeCloseTo(0.3)
expect(target.lat).toBe(0)
})
it('excludes the previous and next fixed day endings', () => {
expect(nearestDayBoundary(path, 2, { x: 0, y: 0 }, project, 1, 2)).toBeNull()
expect(nearestDayBoundary(path, 2, { x: 1000, y: 0 }, project, 0, 0.2)).toBeNull()
})
@@ -0,0 +1,57 @@
import type { RoadtripDayBoundary } from '@trek/shared'
import type { RoadtripStop } from './useRoadtripRoutes'
import { lineMetres } from './corridor'
export interface DayBoundaryLeg {
from: RoadtripStop
to: RoadtripStop
position: number
line: [number, number][]
}
export interface DayBoundaryTarget {
boundary: RoadtripDayBoundary
lat: number
lng: number
position: number
}
export type ProjectPoint = (lat: number, lng: number) => { x: number; y: number }
export function nearestDayBoundary(
legs: DayBoundaryLeg[], day: number, point: { x: number; y: number }, project: ProjectPoint,
min = -1, max = Infinity,
): DayBoundaryTarget | null {
let best: DayBoundaryTarget | null = null
let distance = Infinity
const offer = (target: DayBoundaryTarget, gap: number) => {
if (target.position <= min + 0.000001 || target.position >= max - 0.000001 || gap >= distance) return
best = target
distance = gap
}
for (const leg of legs) {
for (const [stop, position] of [[leg.from, leg.position], [leg.to, leg.position + 1]] as const) {
const p = project(stop.lat, stop.lng)
const gap = Math.hypot(p.x - point.x, p.y - point.y)
if (gap <= 25) offer({ boundary: { day_number: day, from_assignment_id: stop.assignmentId, to_assignment_id: null, fraction: 1 }, lat: stop.lat, lng: stop.lng, position }, gap)
}
}
if (best) return best
for (const leg of legs) {
const metres = lineMetres(leg.line.map(([lat, lng]) => ({ lat, lng })))
if (!metres) continue
let travelled = 0
for (let i = 1; i < leg.line.length; i++) {
const a = leg.line[i - 1], b = leg.line[i]
const p = project(a[0], a[1]), q = project(b[0], b[1])
const dx = q.x - p.x, dy = q.y - p.y
const share = dx || dy ? Math.max(0, Math.min(1, ((point.x - p.x) * dx + (point.y - p.y) * dy) / (dx * dx + dy * dy))) : 0
const length = lineMetres([{ lat: a[0], lng: a[1] }, { lat: b[0], lng: b[1] }])
const fraction = (travelled + length * share) / metres
offer({
boundary: { day_number: day, from_assignment_id: leg.from.assignmentId, to_assignment_id: leg.to.assignmentId, fraction },
lat: a[0] + (b[0] - a[0]) * share, lng: a[1] + (b[1] - a[1]) * share, position: leg.position + fraction,
}, Math.hypot(p.x + dx * share - point.x, p.y + dy * share - point.y))
travelled += length
}
}
return best
}
@@ -0,0 +1,2 @@
export { dayWindow, planDayWindow, roadtripInsertion } from '@trek/shared/roadtrip'
export type { DayWindow, AutomaticNight, WindowPlan } from '@trek/shared/roadtrip'
+2 -212
View File
@@ -1,212 +1,2 @@
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
}
export { spillChains } from '@trek/shared/roadtrip'
export type { SpillMark, SpillChain } from '@trek/shared/roadtrip'
+2 -803
View File
@@ -1,803 +1,2 @@
/**
* Pure road-trip arithmetic — no React, no network, no store. Everything here is a
* function of the stops, the routed legs and the day's rules, so it can be unit tested
* 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.
*
* Every kind the corridor search can find, and the list is meant to stay that way: what
* you pick off "along the route" is by definition something you come across on the way,
* so it belongs inside the leg it was found on rather than among the places the trip is
* for. They are drawn on the dashed line with their own icon, carry no number, and are
* left out of every stop count — four places with a charger, a rest stop and a bakery
* between them is a four-stop day.
*
* Extended together with the corridor's own categories (`RoadtripCorridorPanel`) and the
* enum in `@trek/shared`; a kind in one list and not the others is a stop that looks like
* a destination on one screen and a pause on the next.
*/
export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const
/**
* What each kind of pause is coloured, on the rail, in the corridor list and on the map.
*
* Read as road signage rather than as a palette: a pump is petrol orange, a charger is
* the yellow of electricity, a rest area takes the blue every parking sign in Europe is
* printed in, a campsite is green. The last two are the colours the general place search
* already gives food and sights, so a restaurant found along the route looks like the
* restaurants found anywhere else.
*
* Deliberately literal and shared rather than tokenised: these mean the same thing in
* both themes the way a motorway sign does, and one definition is what stops the rail,
* the search result and the map pin from drifting into three different reds.
*/
export const SERVICE_COLORS: Record<string, string> = {
fuel: '#E8590C',
charging: '#CA8A04',
rest_area: '#3B82F6',
campsite: '#16A34A',
restaurant: '#EF4444',
sights: '#EC4899',
}
/** The colour for a kind, or the neutral one for a kind nobody has a colour for yet. */
export function serviceColor(stopType: string | null | undefined): string {
return SERVICE_COLORS[stopType ?? ''] ?? '#64748B'
}
/** Whether a stop breaks the drive. The rail's shape and every count turn on this. */
export function isServiceStopType(stopType: string | null | undefined): boolean {
return !!stopType && (SERVICE_STOP_TYPES as readonly string[]).includes(stopType)
}
/** Seconds → "2 h 10 min" / "45 min", matching the wording of the map's route connectors. */
export function formatDurationShort(seconds: number): string {
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
const h = Math.floor(safe / 3600)
const m = Math.round((safe % 3600) / 60)
// 59.6 min must not print as "60 min"; carry it.
if (m === 60) return `${h + 1} h`
if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h`
return `${m} min`
}
/** "09:45" → 585. Anything that is not a wall-clock time returns null. */
export function parseClock(value: string | null | undefined): number | null {
if (!value) return null
const m = /^(\d{1,2}):(\d{2})/.exec(value.trim())
if (!m) return null
const h = Number(m[1])
const min = Number(m[2])
if (h > 23 || min > 59) return null
return h * 60 + min
}
/** 585 → "09:45". Minutes past midnight beyond a day wrap around and report the carry. */
export function formatClock(minutes: number): string {
const day = 24 * 60
const wrapped = ((minutes % day) + day) % day
const h = Math.floor(wrapped / 60)
const m = Math.round(wrapped % 60)
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
export interface ScheduleStop {
/** Wall-clock time the user pinned on this stop, if any. */
anchor: string | null
/** Planned time spent here, in minutes. */
dwellMinutes: number | null
}
export interface ScheduleWarning {
/**
* Index of the stop the finding belongs to.
*
* For the two leg codes this is the stop the leg ARRIVES AT, not the one it leaves.
* Both findings are a running total that only becomes true on arrival: "125 km since
* the last fill-up" is what the tank reads when you get there, and the figure has to
* agree with the one anybody adds up off the drive bands above it. Anchored to the
* departure it read as the distance of the leg leaving that stop, which is a different
* number entirely and the one that made the badge look wrong.
*/
index: number
code: 'late' | 'overnight' | 'leg' | 'range'
/** Minutes the computed arrival misses the anchor by (code 'late'). */
minutes?: number
/** Minutes this leg runs over the longest single drive allowed (code 'leg'). */
overMinutes?: number
/** Kilometres driven since the last fill-up when the range ran out (code 'range'). */
sinceKm?: number
}
/**
* The two kinds of stop that put fuel or charge back.
*
* Deliberately shorter than `SERVICE_STOP_TYPES`: a two-hour lunch fills no tank, and
* TREK knows nothing about whether the restaurant has a charger in its car park. Reusing
* the service list here would quietly reset the range budget at every rest area and hide
* the one finding this feature exists to produce.
*/
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
/**
* 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 ?? '')
}
/**
* The three numbers a traveller can set. null means no limit, which is an explicit off
* 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. */
dayMinutes: number | null
/** How far one tank or charge goes, in kilometres. */
rangeKm: number | null
}
/**
* A finding about the day as a whole.
*
* Its own shape rather than a ScheduleWarning with index -1: a sentinel index would be
* read by every consumer that filters findings by stop, and one of them would eventually
* draw the day's finding on the first stop.
*/
export interface DayWarning {
code: 'dayDriving'
/** Driving time of the day in minutes, the same number the badge beside it shows. */
minutes: number
limitMinutes: number
}
export interface ScheduleEntry {
/** Computed arrival, or the anchor where the user pinned one. */
arrival: string | null
departure: string | null
/** True when the time was pinned by the user rather than derived. */
anchored: boolean
/** Days past the first stop, so a chain running past midnight stays readable. */
dayOffset: number
}
export interface Schedule {
entries: ScheduleEntry[]
warnings: ScheduleWarning[]
}
/**
* Walks the chain forward: arrival = previous departure + driving time, departure =
* arrival + time spent at the stop. A stop the user pinned a time on does not move —
* it restarts the cascade from its own time, which is what makes a ferry, a museum
* ticket or a hotel check-in usable as a fixed point (Furkot solves it the same way).
*
* `legSeconds[i]` is the drive from stop i to stop i+1; a leg that never routed is
* `undefined` and breaks the chain rather than inventing a duration.
*/
/**
* Where a stop's clock actually starts.
*
* A pinned time wins over whatever the drive before it worked out and restarts the
* cascade from there. When it sits before the arrival the drive implies, the plan does
* not fit: the caller is told by how much rather than the pinned time being moved.
*/
const DAY_MINUTES = 24 * 60
function resolveArrival(
anchor: number | null,
cursor: number | null,
dayOffset: number,
): { arrival: number | null; lateBy: number | null } {
if (anchor === null) return { arrival: cursor, lateBy: null }
// No drive has been timed yet, so there is nothing to be late against — but the
// day carried so far still applies, or a pinned stop after an untimed leg
// prints under a day-1 stop as though it happened first.
if (cursor === null) return { arrival: anchor + dayOffset * DAY_MINUTES, lateBy: null }
// The occurrence of that wall-clock time nearest the cursor, not the one on the
// day the cascade has reached so far. `dayOffset` only advances once a computed
// arrival crosses midnight, and it is read before this stop's arrival is known —
// so when the stop that crosses midnight is the pinned one, its anchor was
// placed a whole day early and the plan was reported as 24 hours late while
// being exactly on time. Rounding also keeps the ordinary case: 08:00, a three
// hour leg, a 09:00 pin still picks the same day and is two hours late.
const k = Math.round((cursor - anchor) / DAY_MINUTES)
const anchorAt = anchor + k * DAY_MINUTES
return {
arrival: anchorAt,
lateBy: cursor > anchorAt + 1 ? Math.round(cursor - anchorAt) : null,
}
}
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
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
for (let i = 0; i < stops.length; i++) {
const stop = stops[i]
const anchor = parseClock(stop.anchor)
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset)
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy })
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 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(arrival + (stops[i].dwellMinutes ?? 0)),
anchored: anchored[i],
dayOffset: offset,
})
}
return { entries, warnings }
}
/**
* Splits a day's stops into the stretches that can be asked for in one routing request:
* consecutive stops whose leg shares a travel mode.
*
* The router returns a leg for every consecutive pair of the waypoints it is handed, so
* a day travelled one way is a single request rather than one per leg — the difference
* between a road trip appearing at once and crawling in over ten seconds. A day that
* mixes walking and driving splits at the change, because one request carries one mode.
*/
export function splitIntoRuns<T>(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {
const runs: { stops: T[]; mode: string }[] = []
let current: T[] = []
let currentMode: string | null = null
for (let i = 0; i < stops.length - 1; i++) {
const mode = modeOfLeg(stops[i], stops[i + 1])
if (currentMode === null) {
current = [stops[i], stops[i + 1]]
currentMode = mode
} else if (mode === currentMode) {
current.push(stops[i + 1])
} else {
runs.push({ stops: current, mode: currentMode })
current = [stops[i], stops[i + 1]]
currentMode = mode
}
}
if (currentMode !== null && current.length > 1) runs.push({ stops: current, mode: currentMode })
return runs
}
/** Total driving seconds of the legs that actually routed. */
export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
return legSeconds.reduce<number>((sum, s) => sum + (s ?? 0), 0)
}
/**
* Where a place found along the drive belongs in the day's chain.
*
* Both numbers are distances travelled along the same routed line, so the comparison is
* "which stops has the car already passed when it reaches this one". A petrol station
* 120 km into the day goes after every stop nearer than that and before the rest,
* which is what makes adding one a single click rather than a drag afterwards.
*
* Clamped to sit inside the day: a corridor is wider than the road, so a hit can project
* slightly before the first stop or past the last, and neither "before where you set off"
* nor "after where you arrive" is a place a fuel stop can go.
*/
/**
* Which leg a point on the drive belongs to, given where each leg ends.
*
* Measured against the legs' own lengths rather than by projecting the stops as well: the
* router's leg distances are exact by construction, while two stops close together
* project onto each other's stretch and would put a charging halt on the wrong side of a
* 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.
*
* Reads the legs and nothing else. The clock cascade is not available to lean on: with no
* stop pinned to a time it produces no times at all, for any stop, so a limit expressed as
* an hour of the day would simply never fire. All three limits are therefore durations and
* kilometres, which exist as soon as a leg has routed.
*
* The range budget resets at a fuel or charging stop and NOWHERE ELSE. It carries across
* midnight, because a tank does not empty overnight, and it keeps counting past a finding,
* because a warning is not a fill-up. Zeroing it there made the second figure wrong and
* every one after it: a day reading 245 km at one stop and 121 at the next claims the 121
* is a fresh tank, when it is 366 on the same one. A run of warnings down a stretch with
* no fuel on it is not noise; it is the answer, and it stops the moment a fuel or
* charging stop is put in.
*
* An unrouted leg gives the budget up entirely rather than guessing, the same way the
* schedule abandons its cursor: a distance we do not have cannot be added to one we do.
*/
export function deriveDriveWarnings(
/**
* `mode` decides whether a leg counts at all. A day may legitimately mix
* modes — splitIntoRuns exists for exactly that — and a two hour walk to a
* viewpoint is not two hours of driving, nor are its kilometres on the tank.
* Counting them reported a hike as "over the longest drive allowed" and let a
* ferry crossing trip a range warning. Anything that is not a driving profile
* is skipped; an absent mode counts, because every leg was a drive before the
* field existed.
*/
legs: ({ duration?: number; distance?: number; mode?: string } | undefined)[],
/** Whether the stop at each index refuels. One entry longer than `legs`. */
refuelsAt: boolean[],
limits: DriveLimits,
/** Kilometres already on the tank when the day starts; null when that is unknown. */
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 = 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
// Not a drive: neither its minutes nor its kilometres belong in findings
// about the driving. The budget is left exactly as it was — walking to a
// viewpoint and back does not use fuel, and it does not fill the tank
// either.
if (leg && leg.mode !== undefined && leg.mode !== 'driving') continue
const seconds = leg?.duration
if (typeof seconds === 'number') {
totalSeconds += seconds
if (limits.legMinutes && seconds / 60 > limits.legMinutes) {
warnings.push({ index: at, code: 'leg', overMinutes: Math.round(seconds / 60 - limits.legMinutes) })
}
}
const metres = leg?.distance
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
// to keep counting until a fuel or charging stop actually puts one in.
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 = 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, emptyAt }
}
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
if (!legEndMeters.length) return -1
for (let i = 0; i < legEndMeters.length; i++) {
if (alongMeters < legEndMeters[i]) return i
}
return legEndMeters.length - 1
}
/**
* The places on a day somebody would plan a break around.
*
* Every stop, and the middle of every leg between two of them. The leg midpoints are the
* point of the whole thing: a break on a five-hour drive is planned in the middle of the
* drive, not at either end of it, and offering only the stops would mean asking "what is
* near Berlin" when the question is "what is near the halfway mark".
*/
export interface SectionAnchor {
kind: 'stop' | 'leg'
/** Index of the stop, or of the stop the leg leaves. */
index: number
alongKm: number
}
export function sectionAnchors(stopsAlongKm: number[]): SectionAnchor[] {
const out: SectionAnchor[] = []
for (let i = 0; i < stopsAlongKm.length; i++) {
out.push({ kind: 'stop', index: i, alongKm: stopsAlongKm[i] })
if (i + 1 < stopsAlongKm.length) {
out.push({ kind: 'leg', index: i, alongKm: (stopsAlongKm[i] + stopsAlongKm[i + 1]) / 2 })
}
}
return out
}
export function insertIndexForAlong(stopsAlongKm: number[], hitAlongKm: number): number {
if (stopsAlongKm.length < 2) return stopsAlongKm.length
let i = 0
while (i < stopsAlongKm.length && stopsAlongKm[i] <= hitAlongKm) i++
return Math.min(Math.max(i, 1), stopsAlongKm.length - 1)
}
/* ── Via re-anchoring ────────────────────────────────────────────────────────
*
* A via records which stop it follows as a POSITION in the day's stop list, and that
* position is resolved fresh every time the day is routed. So the moment the list
* changes shape — a stop inserted, removed, reordered, pushed to another day — every
* anchor at or past the change means a different leg than it did when it was written.
* The visible symptom is the route quietly snapping back to the road the user had
* steered it away from, with the via handle still sitting on the map where they left it.
*
* Nothing about that is repairable after the fact: once the stops have moved there is no
* record of which leg a via was drawn for. So the correction has to be computed against
* the list as it stands and applied with the same change.
*
* These are pure index arithmetic on purpose. The one judgement that needs geometry —
* which half of a split leg a via belongs to — is asked of the caller, because only the
* caller has the routed line to measure against.
*/
/**
* A via, reduced to what re-anchoring needs to know about it.
*
* The coordinates are part of it because the one decision that is not pure arithmetic —
* which half of a split leg a via belongs to — is answered by measuring where it sits on
* the road, and the caller is handed the whole via to measure.
*/
export interface AnchoredVia {
id: number
after_order_index: number
lat: number
lng: number
}
/** Where one via ends up. */
export interface ReanchoredVia {
id: number
after_order_index: number
}
/**
* The corrected anchoring, and the vias that no longer have a leg at all.
*
* `vias` holds only what actually moved: sending the unchanged ones would be a larger
* write for no reason, and it keeps the diff readable when something goes wrong.
*/
export interface Reanchoring {
vias: ReanchoredVia[]
remove: number[]
}
const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] }
/** Only what moved, so an operation that changes nothing costs no request. */
function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring {
const moved: ReanchoredVia[] = []
const remove: number[] = []
for (const via of vias) {
const next = at(via.after_order_index)
if (next === null) remove.push(via.id)
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
}
return { vias: moved, remove }
}
/**
* A stop was inserted at `position`, so everything from there on sits one place later.
*
* The leg the new stop lands in is SPLIT in two, and its vias have to be told apart: one
* before the new stop stays on the first half, one after it moves to the second. That is
* the whole reason a petrol station added in the middle of a reshaped drive used to undo
* the reshaping — the via went to the first half by accident of arithmetic, the second
* half got none, and the router took its own road again for the rest of the leg.
*
* `liesBeforeInsert` is asked only about vias on the split leg, and only when there is
* one: inserting at the very front or the very end splits nothing.
*/
export function reanchorAfterInsert(
vias: AnchoredVia[],
position: number,
liesBeforeInsert: (via: AnchoredVia) => boolean,
): Reanchoring {
if (!vias.length) return EMPTY_REANCHORING
const split = position - 1
const moved: ReanchoredVia[] = []
for (const via of vias) {
const i = via.after_order_index
let next = i
if (i === split) next = liesBeforeInsert(via) ? i : i + 1
else if (i >= position) next = i + 1
if (next !== i) moved.push({ id: via.id, after_order_index: next })
}
return { vias: moved, remove: [] }
}
/**
* A stop was taken out at `position`, so the legs either side of it become one.
*
* A via on either half keeps bending the same drive, so both end up on the merged leg —
* dropping them would throw away a decision the user made about a road that still exists.
* The exception is the ends: remove the first stop and the leg leaving it is gone, remove
* the last and the leg into it is gone, and a via pinned to one of those has nowhere left
* to sit. Those are reported for deletion rather than left pointing past the day.
*
* `stopCount` is the number of stops BEFORE the removal.
*/
export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring {
if (!vias.length) return EMPTY_REANCHORING
// Two stops become one: the day has no drive left, so no via has a leg.
if (stopCount <= 2) return { vias: [], remove: vias.map(v => v.id) }
return collect(vias, i => {
if (position === 0) return i === 0 ? null : i - 1
if (position === stopCount - 1) return i === position - 1 ? null : i
if (i === position) return position - 1
return i > position ? i - 1 : i
})
}
/**
* A stop moved from `from` to `to` inside its own day.
*
* Composed from the two operations it actually is — taken out, put back — because that
* is the only reading under which the answer stays consistent with the other two. No
* geometry is asked for: a reorder changes which stops are neighbours, so the roads
* themselves are about to be different and there is nothing meaningful to measure a via
* against. Vias keep the earlier half of a split leg, which is the reading that leaves
* them where they were relative to the stop they were drawn after.
*
* `stopCount` is the number of stops BEFORE the move. `to` is the index in the list as
* it looks AFTER the stop has been taken out.
*/
/**
* Re-pin a day's vias after its stops were reordered wholesale.
*
* The single-move helpers above take a from/to pair, which is what the rail
* hands them. The plan-mode drag hands a whole new ordering instead, and any
* permutation is possible — so this maps by the stop each anchor refers to
* rather than by arithmetic on the index. A via anchored after a stop stays
* anchored after that same stop, wherever it ended up.
*
* Both lists are in the rail's index space, i.e. only stops that can be routed.
* A via whose stop is gone, or which lands on the last stop and so has no leg
* left to bend, is reported for removal.
*/
export function reanchorByStopOrder(
vias: AnchoredVia[],
previousIds: number[],
nextIds: number[],
): Reanchoring {
if (!vias.length) return EMPTY_REANCHORING
const moved: ReanchoredVia[] = []
const remove: number[] = []
for (const via of vias) {
const stopId = previousIds[via.after_order_index]
const next = stopId === undefined ? -1 : nextIds.indexOf(stopId)
// Gone, or now the last stop of the day: either way there is no leg for it.
if (next === -1 || next >= nextIds.length - 1) {
remove.push(via.id)
continue
}
if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
}
return { vias: moved, remove }
}
export function reanchorAfterReorder(
vias: AnchoredVia[],
from: number,
to: number,
stopCount: number,
): Reanchoring {
if (!vias.length || from === to) return EMPTY_REANCHORING
// A reorder is modelled as remove-then-insert, and the removal half refuses to
// treat a two-stop day as still having a drive: below three stops it reports
// every via for deletion, which is right when a stop really goes away and
// catastrophic here. Nothing is removed by a reorder — swapping the only two
// stops leaves leg 0 exactly where it was, merely driven the other way round —
// so the anchors are already correct and there is nothing to re-pin.
//
// Two stops is the shape "follow this track" produces: it lays up to nine vias
// on the single leg between one pair, and one drag in the rail deleted all of
// them, with no prompt and no undo.
if (stopCount <= 2) return EMPTY_REANCHORING
const afterRemove = reanchorAfterRemove(vias, from, stopCount)
const dropped = new Set(afterRemove.remove)
const movedTo = new Map(afterRemove.vias.map(v => [v.id, v.after_order_index] as const))
// The list the insert acts on is the one the removal left behind, so each via is
// carried through at whatever index it holds by then.
const shifted: AnchoredVia[] = vias
.filter(v => !dropped.has(v.id))
.map(v => ({ ...v, after_order_index: movedTo.get(v.id) ?? v.after_order_index }))
const afterInsert = reanchorAfterInsert(shifted, to, () => true)
const finalIndex = new Map(afterInsert.vias.map(v => [v.id, v.after_order_index] as const))
const result: ReanchoredVia[] = []
for (const via of vias) {
if (dropped.has(via.id)) continue
const mid = movedTo.get(via.id) ?? via.after_order_index
const end = finalIndex.get(via.id) ?? mid
if (end !== via.after_order_index) result.push({ id: via.id, after_order_index: end })
}
return { vias: result, remove: afterRemove.remove }
}
/** Whether a re-anchoring has anything to write at all. */
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(',')
}
export { SERVICE_STOP_TYPES, SERVICE_COLORS, serviceColor, isServiceStopType, formatDurationShort, parseClock, formatClock, REFUELLING_STOP_TYPES, refuelStopTypeFor, refuelsRange, computeSchedule, splitIntoRuns, sumLegSeconds, deriveDriveWarnings, legIndexForAlong, sectionAnchors, insertIndexForAlong, reanchorAfterInsert, reanchorAfterRemove, reanchorByStopOrder, reanchorAfterReorder, isEmptyReanchoring, parseAvoid, AVOIDABLE, serializeAvoid } from '@trek/shared/roadtrip'
export type { ScheduleStop, ScheduleWarning, VehicleKind, DriveLimits, DayWarning, ScheduleEntry, Schedule, DryPoint, SectionAnchor, AnchoredVia, ReanchoredVia, Reanchoring } from '@trek/shared/roadtrip'
@@ -0,0 +1,33 @@
export function trivagoSearchHref(name: string, arrival?: string | null, departure?: string | null): string {
const datePattern = /^\d{4}-\d{2}-\d{2}$/
const dates = arrival && departure && datePattern.test(arrival) && datePattern.test(departure) && departure > arrival
? 'dr-' + arrival.replace(/-/g, '') + '-' + departure.replace(/-/g, '') + ';'
: ''
return 'https://www.trivago.com/de/lm/hotels-weltweit?search=' + dates + 'qs-' + encodeURIComponent(name).replace(/;/g, '%3B')
}
export function campingSearchHref(portal: 'PiNCAMP' | 'Pitchup', name: string, lat: number, lng: number, arrival?: string | null, departure?: string | null): string {
const query = new URLSearchParams()
const dated = arrival && departure && /^\d{4}-\d{2}-\d{2}$/.test(arrival) && /^\d{4}-\d{2}-\d{2}$/.test(departure) && departure > arrival
if (portal === 'Pitchup') {
query.set('lat', String(lat))
query.set('lng', String(lng))
query.set('placename', name)
if (dated) {
query.set('arrive', arrival)
query.set('depart', departure)
}
return 'https://www.pitchup.com/campsites/?' + query
}
const latitudeSpan = 20 / 111.32
const longitudeSpan = Math.min(180, latitudeSpan / Math.max(0.001, Math.cos(lat * Math.PI / 180)))
query.set('viewport', [Math.max(-90, lat - latitudeSpan), Math.max(-180, lng - longitudeSpan), Math.min(90, lat + latitudeSpan), Math.min(180, lng + longitudeSpan)].map(n => n.toFixed(6)).join(','))
query.set('title', name)
if (dated) {
query.set('date_from', arrival)
query.set('date_to', departure)
query.set('flex', 'false')
}
return 'https://www.pincamp.com/search?' + query
}
@@ -0,0 +1,14 @@
import { formatClock, parseClock } from './roadtripModel'
import type { RoadtripDay } from '@trek/shared/roadtrip'
export function stopArrival(day: RoadtripDay, index: number, alongKm: number): string | null {
if (index === 0) return day.schedule.entries[0]?.arrival ?? null
const departure = parseClock(day.schedule.entries[index - 1]?.departure)
const leg = day.legs[index - 1]
if (departure === null || !leg) return null
const before = day.legs.slice(0, index - 1)
if (before.some(leg => !leg)) return null
const distanceBefore = before.reduce((total, leg) => total + leg!.distance, 0)
const fraction = leg.distance > 0 ? Math.max(0, Math.min(1, (alongKm * 1000 - distanceBefore) / leg.distance)) : 0
return formatClock(Math.round(departure + leg.duration / 60 * fraction))
}
@@ -37,13 +37,10 @@ describe('stop kinds', () => {
expect(CORRIDOR_CATEGORY_KEYS).toEqual(['fuel', 'charging', 'rest_area', 'campsite', 'hotel', 'restaurant', 'sights'])
})
it('FE-STOPKIND-008: a hotel is a category without being a stop kind', () => {
// The whole reason the two lists are separate. Sleeping somewhere ends the day rather
// than interrupting the drive, so a hotel keeps its number in the chain and never
// becomes a stop_type.
expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBeNull()
expect(STOP_KIND_BY_KEY.hotel).toBeUndefined()
expect(SERVICE_KIND_KEYS).not.toContain('hotel')
it('FE-STOPKIND-008: accommodation is a service stop as well as an overnight category', () => {
expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBe('hotel')
expect(STOP_KIND_BY_KEY.hotel).toBeDefined()
expect(SERVICE_KIND_KEYS).toContain('hotel')
})
it('FE-STOPKIND-009: a night can be booked at a campsite too, so the popup asks', () => {
@@ -68,7 +65,7 @@ describe('stop kinds', () => {
expect(STOP_KIND_BY_KEY.rest_area.labelKey).toBe('roadtrip.poi.rest')
expect(STOP_KIND_BY_KEY.restaurant.labelKey).toBe('roadtrip.poi.food')
for (const kind of STOP_KINDS) {
if (kind.key !== 'rest_area' && kind.key !== 'restaurant') {
if (kind.key !== 'rest_area' && kind.key !== 'restaurant' && kind.key !== 'hotel') {
expect(kind.labelKey).toBe(`roadtrip.poi.${kind.key}`)
}
}
+3 -14
View File
@@ -19,10 +19,6 @@ import type { RoadtripStopType } from '@trek/shared'
* and `roadtripModel.ts` is deliberately free of React so it can be unit tested on its
* own. The colours and the service list stay there and are read from here.
*
* `isService` and `isCorridorCategory` are not the same question, and a coming lodging
* kind is why they are separate: the corridor can find a hotel, but sleeping somewhere
* ends the day rather than interrupting the drive, so it would be a category without
* being a service stop.
*/
export interface StopKind {
key: RoadtripStopType
@@ -39,6 +35,7 @@ export interface StopKind {
}
export const STOP_KINDS: StopKind[] = [
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: SERVICE_COLORS.hotel, defaultMinutes: 30, isService: true, isCorridorCategory: true },
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel, defaultMinutes: 10, isService: true, isCorridorCategory: true },
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging, defaultMinutes: 30, isService: true, isCorridorCategory: true },
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area, defaultMinutes: 20, isService: true, isCorridorCategory: true },
@@ -52,14 +49,6 @@ export const STOP_KIND_BY_KEY: Record<string, StopKind> = Object.fromEntries(
STOP_KINDS.map(k => [k.key, k]),
)
/**
* A category the corridor search offers, which is not the same question as a stop kind.
*
* Every stop kind is a category, but not every category is a stop kind: somewhere to
* sleep is something the search can find, and staying there ends the day rather than
* interrupting the drive. A hotel therefore keeps its number in the chain and gets a row
* in `day_accommodations`; it never becomes a `stop_type`.
*/
export interface CorridorCategory {
key: string
labelKey: string
@@ -83,9 +72,9 @@ export const HOTEL_COLOR = '#2563EB'
* table happens to end.
*/
export const CORRIDOR_CATEGORIES: CorridorCategory[] = [
...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights')
...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights' && k.key !== 'hotel')
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: null },
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: 'hotel' },
...STOP_KINDS.filter(k => k.key === 'restaurant' || k.key === 'sights')
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
]
@@ -0,0 +1,36 @@
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useAutomaticDayPoints } from './useAutomaticDayPoints'
import type { RoadtripRoutes } from './useRoadtripRoutes'
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string, args?: { day: number; time: string }) => args ? `End of day ${args.day} at ${args.time}` : key }) }))
describe('automatic pause map points', () => {
it('adds each pause once, respects collapsed days and discards obsolete focus', () => {
const routes = {
vias: [{ lat: 1, lng: 1, label: 'Charger', tone: 'success' }],
days: [{
dayId: 1, dayNumber: 1,
stops: [
{ lat: 2, lng: 3, automaticNight: { phase: 'end' } },
{ lat: 2, lng: 3, automaticNight: { phase: 'start' } },
{ lat: 4, lng: 5 },
],
schedule: { entries: [{ arrival: '18:00' }] },
}],
} as unknown as RoadtripRoutes
const { result, rerender } = renderHook(
({ current, collapsed }) => useAutomaticDayPoints(current, collapsed),
{ initialProps: { current: routes, collapsed: new Set<number>() } },
)
expect(result.current.markers).toHaveLength(2)
expect(result.current.markers[1]).toMatchObject({ lat: 2, lng: 3, hoverCard: true, nightPause: { day: 1, atPlace: false } })
expect(result.current.markers[1].label).toBe('End of day 1 at 18:00')
act(() => result.current.focusPoint(2, 3))
expect(result.current.focusPoints).toEqual([[2, 3]])
rerender({ current: routes, collapsed: new Set([1]) })
expect(result.current.markers).toEqual(routes.vias)
rerender({ current: { ...routes, days: [] }, collapsed: new Set() })
expect(result.current.focusPoints).toBeNull()
})
})
@@ -0,0 +1,28 @@
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from '../../i18n/TranslationContext'
import type { RouteVia } from '../../types'
import type { RoadtripRoutes } from './useRoadtripRoutes'
export function useAutomaticDayPoints(routes: RoadtripRoutes, collapsed: Set<number>) {
const { t } = useTranslation()
const [selection, setSelection] = useState<{ days: RoadtripRoutes['days']; points: [number, number][] } | null>(null)
const focusPoint = useCallback((lat: number, lng: number) => {
setSelection({ days: routes.days, points: [[lat, lng]] })
}, [routes.days])
const markers = useMemo<RouteVia[]>(() => {
const places = new Set(routes.days.flatMap(day => day.stops.filter(stop => !stop.automaticNight).map(stop => `${stop.lat},${stop.lng}`)))
const nights = routes.days.flatMap(day => day.stops.filter(stop => stop.automaticNight?.phase === 'end').map(stop => ({ day: day.dayNumber, ...stop.automaticNight! })))
const ends = routes.days.filter(d => !collapsed.has(d.dayId)).flatMap(day => day.stops.flatMap((stop, i) =>
stop.automaticNight?.phase === 'end' ? [{
lat: stop.lat, lng: stop.lng, tone: 'default' as const, hoverCard: true,
nightPause: { day: day.dayNumber, atPlace: places.has(`${stop.lat},${stop.lng}`),
position: stop.automaticNight.position, manual: stop.automaticNight.manual,
minPosition: nights.filter(n => n.day < day.dayNumber).slice(-1)[0]?.position,
maxPosition: nights.find(n => n.day > day.dayNumber && n.manual)?.position },
label: t('roadtrip.window.pointLabel', { day: day.dayNumber, time: day.schedule.entries[i]?.arrival ?? '' }),
}] : [],
))
return [...routes.vias, ...ends]
}, [routes.days, routes.vias, collapsed, t])
return { markers, focusPoint, focusPoints: selection?.days === routes.days ? selection.points : null }
}
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { LatLng } from './corridor'
const { pois } = vi.hoisted(() => ({ pois: vi.fn() }))
vi.mock('../../api/client', () => ({ mapsApi: { pois } }))
vi.mock('../../repo/roadtripSearchRepo', () => ({ roadtripSearchRepo: { search: pois } }))
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
import { useCorridorPois } from './useCorridorPois'
@@ -30,7 +30,7 @@ const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
source: 'openstreetmap',
})
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, source: 'openstreetmap', truncated: false, clamped: false })
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, sources: ['openstreetmap'], failedSources: [], truncated: false, clamped: false })
beforeEach(() => {
pois.mockReset()
@@ -38,6 +38,14 @@ beforeEach(() => {
})
describe('useCorridorPois', () => {
it('keeps plugin results and exposes failed sources', async () => {
pois.mockResolvedValue({ ...answer(hit('plugin:stations:1', 53.5, 9.81)), failedSources: ['plugin:unavailable'] })
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.results[0].osm_id).toBe('plugin:stations:1')
expect(result.current.failedSources).toEqual(['plugin:unavailable'])
})
it('FE-ROADTRIP-CORRIDOR-001: asks each box once for every category together', async () => {
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel', 'charging', 'rest_area'], 10))
act(() => { result.current.search() })
@@ -45,7 +53,7 @@ describe('useCorridorPois', () => {
expect(pois).toHaveBeenCalled()
// The whole point of the batching: the kinds ride along in one query per box.
for (const call of pois.mock.calls) expect(call[0]).toBe('fuel,charging,rest_area')
for (const call of pois.mock.calls) expect(call[0]).toEqual(['fuel', 'charging', 'rest_area'])
expect(result.current.progress.total).toBe(pois.mock.calls.length)
})
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { mapsApi } from '../../api/client'
import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo'
import { useTranslation } from '../../i18n'
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
import type { Poi } from '../Map/poiCategories'
@@ -18,6 +18,7 @@ export interface CorridorSearch {
/** True when the route was longer than the request budget and the tail was left out. */
capped: boolean
/** Boxes the place search could not answer — a partial result is still shown. */
failedSources?: string[]
failedAreas: number
/**
* Boxes where the server hit its own per-request ceiling and dropped the rest.
@@ -71,6 +72,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
const [capped, setCapped] = useState(false)
const [failedAreas, setFailedAreas] = useState(0)
const [truncatedAreas, setTruncatedAreas] = useState(0)
const [failedSources, setFailedSources] = useState<string[]>([])
const [error, setError] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const runIdRef = useRef(0)
@@ -91,6 +93,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
setProgress({ done: 0, total: 0 })
setLoading(false)
setCapped(false)
setFailedSources([])
setFailedAreas(0)
setTruncatedAreas(0)
setError(false)
@@ -112,6 +115,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
const tiles = allTiles.slice(0, MAX_TILES)
setCapped(allTiles.length > tiles.length)
setFailedSources([])
setFailedAreas(0)
setTruncatedAreas(0)
setError(false)
@@ -122,11 +126,12 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
// All the wanted kinds in one query per box. Asking per kind was the same answer for
// four times the requests, and every extra request is another chance for a shared
// mirror to time out — which is what made a search both slow and patchy.
const wanted = categories.join(',')
const jobs = [...tiles]
void (async () => {
const seen = new Map<string, CorridorPoi>()
const unavailable = new Set<string>()
let failures = 0
let shortened = 0
let done = 0
@@ -139,15 +144,26 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
// cheaply; the small gap afterwards keeps one worker from queueing straight up.
const collect = async (tile: Bbox): Promise<boolean> => {
try {
const data = await mapsApi.pois(wanted, tile, locale, controller.signal)
const data = await roadtripSearchRepo.search(categories, tile, locale, controller.signal)
if (controller.signal.aborted || runId !== runIdRef.current) return false
data.failedSources.forEach(source => unavailable.add(source))
setFailedSources([...unavailable])
// The server answers with at most a few hundred per box and says so. A hit
// dropped there is invisible here, so the count has to reach the surface.
if (data.truncated) setTruncatedAreas(++shortened)
if (data.truncated || data.clamped) setTruncatedAreas(++shortened)
for (const poi of data.pois) {
if (seen.has(poi.osm_id)) continue
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
if (!hit || hit.offRouteKm > widthKm) continue
seen.set(poi.osm_id, { ...poi, offRouteKm: hit.offRouteKm, alongKm: hit.alongKm })
seen.set(poi.osm_id, {
...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null,
opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null,
charging: poi.charging ? {
capacity: poi.charging.capacity ?? null, fee: poi.charging.fee ?? null,
sockets: poi.charging.sockets.map(s => ({ type: s.type, count: s.count ?? null, kw: s.kw ?? null })),
} : null,
offRouteKm: hit.offRouteKm, alongKm: hit.alongKm,
})
}
return true
} catch {
@@ -193,5 +209,5 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
useEffect(() => () => abortRef.current?.abort(), [])
return { results, progress, loading, capped, failedAreas, truncatedAreas, error, spine, search, clear }
return { results, progress, loading, capped, failedAreas, failedSources, truncatedAreas, error, spine, search, clear }
}
@@ -0,0 +1,61 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { roadtripDayBoundaryListSchema, type RoadtripDayBoundary } from '@trek/shared'
import { apiClient } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useNetworkMode } from '../../hooks/useNetworkMode'
const EMPTY: RoadtripDayBoundary[] = []
export function useDayBoundaries(tripId: number | string | null, active: boolean, assignments?: unknown) {
const { offline } = useNetworkMode()
const [stored, setStored] = useState<{ tripId: number | string | null; boundaries: RoadtripDayBoundary[] }>({ tripId: null, boundaries: EMPTY })
const [stale, setStale] = useState(false)
const [pending, setPending] = useState(false)
const revision = useRef(0)
const busy = useRef(false)
const currentTrip = useRef(tripId)
currentTrip.current = tripId
useEffect(() => {
if (!tripId || !active || offline) return
let cancelled = false
const version = ++revision.current
apiClient.get(`/trips/${tripId}/roadtrip/day-boundaries`).then(saved => {
if (cancelled || revision.current !== version) return
setStored({ tripId, boundaries: roadtripDayBoundaryListSchema.parse(saved.data).boundaries })
setStale(false)
}).catch(() => { if (!cancelled) setStale(true) })
return () => { cancelled = true }
}, [tripId, active, offline, assignments])
useEffect(() => {
if (!tripId || !active) return
const receive = (event: Record<string, unknown>) => {
if (event.type !== 'roadtripBoundary:changed' || String(event.tripId) !== String(tripId)) return
const parsed = roadtripDayBoundaryListSchema.safeParse(event)
if (!parsed.success) return
revision.current++
setStored({ tripId, boundaries: parsed.data.boundaries })
setStale(false)
}
addListener(receive)
return () => removeListener(receive)
}, [tripId, active])
const save = useCallback(async (day: number, boundary: RoadtripDayBoundary | null) => {
if (!tripId || offline || busy.current) return false
busy.current = true
setPending(true)
const version = ++revision.current
try {
const saved = boundary
? await apiClient.put(`/trips/${tripId}/roadtrip/day-boundaries`, { ...boundary, day_number: day })
: await apiClient.delete(`/trips/${tripId}/roadtrip/day-boundaries/${day}`)
if (currentTrip.current !== tripId) return false
if (revision.current === version) setStored({ tripId, boundaries: roadtripDayBoundaryListSchema.parse(saved.data).boundaries })
setStale(false)
return true
} finally {
busy.current = false
setPending(false)
}
}, [tripId, offline])
return { boundaries: stored.tripId === tripId ? stored.boundaries : EMPTY, save, stale, pending, editable: active && !offline && !stale && stored.tripId === tripId }
}
@@ -82,6 +82,22 @@ beforeEach(() => {
})
describe('useFollowTrack', () => {
it('clears the original leg when an automatic pause splits its displayed days', async () => {
const [a, b] = day().stops
const end = { ...a, assignmentId: -10, automaticNight: { phase: 'end' as const, fromDayNumber: 1 } }
const start = { ...end, assignmentId: -11, automaticNight: { phase: 'start' as const, fromDayNumber: 1 } }
const split = routes([
day({ dayId: 1, automaticSchedule: true, stops: [a, end] }),
day({ dayId: 2, dayNumber: 2, automaticSchedule: true, stops: [start, b] }),
])
const vias = viasStub()
const { result } = renderHook(() => useFollowTrack(1, [TRACK], split, vias))
act(() => result.current.open(1))
await act(async () => result.current.clear())
expect(vias.addMany).toHaveBeenCalledWith(1, [], [0], null)
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
})
it('FE-FOLLOWHOOK-001: nothing is parsed while the dialog is closed', () => {
const { result } = renderHook(() => useFollowTrack(1, [TRACK, PLAIN], routes([day()]), viasStub()))
expect(result.current.dayId).toBeNull()
@@ -100,7 +100,12 @@ export function useFollowTrack(
const abort = useRef<AbortController | null>(null)
const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId])
const stops = useMemo<LatLng[]>(() => (day?.stops ?? []).map(s => ({ lat: s.lat, lng: s.lng })), [day])
const stops = useMemo<LatLng[]>(() => {
const original = day?.automaticSchedule
? routes.days.flatMap(d => d.stops).filter(s => !s.automaticNight && s.ownerDayId === dayId).sort((a, b) => a.ownerIndex - b.ownerIndex)
: day?.stops ?? []
return original.map(s => ({ lat: s.lat, lng: s.lng }))
}, [day, routes.days, dayId])
/**
* Parsed only while the dialog is open.
@@ -10,8 +10,8 @@ const { pois, offline, stored } = vi.hoisted(() => ({
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),
vi.mock('../../hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (settings: typeof stored.settings) => unknown) => select(stored.settings),
}))
import { useRefuelSearch } from './useRefuelSearch'
@@ -280,3 +280,20 @@ describe('useRefuelSearch', () => {
expect(result.current.loading).toBe(false)
})
})
it('searches farther back when nearby chargers cannot be reached', async () => {
pois.mockResolvedValueOnce(answer([station('too-late', 58)]))
.mockResolvedValueOnce(answer([station('earlier', 25)]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
expect(pois).toHaveBeenCalledTimes(2)
expect(result.current.results.map(p => p.osm_id)).toEqual(['earlier'])
expect(result.current.outcome).toBe('found')
})
it('does not offer stations behind the last refuelling stop', async () => {
pois.mockResolvedValue(answer([station('behind', 20)]))
const { result } = renderHook(() => useRefuelSearch())
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, [], 30) })
expect(result.current.results).toEqual([])
expect(pois).toHaveBeenCalledTimes(2)
})
@@ -1,28 +1,12 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
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.
*
@@ -41,6 +25,8 @@ const LOOK_KM = 14
* reach, so it is pulled back far enough that the useful half becomes the whole of it.
*/
const LOOK_BACK_KM = 12
const SEARCH_STEP_KM = 20
const MAX_SEARCHES = 8
/** Nothing on offer, as one stable array, so an idle search never redraws the map. */
const NONE: RefuelCandidate[] = []
@@ -60,8 +46,8 @@ export interface RefuelSearch {
* 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>
/** Searches backwards until an available stop is found. `key` identifies the dry point, so only one is open at a time. */
ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[], fromAlongKm?: number) => Promise<void>
close: () => void
}
@@ -69,7 +55,7 @@ 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 vehicle = useRoadtripSettings(s => s.roadtrip_vehicle)
const [openFor, setOpenFor] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [outcome, setOutcome] = useState<RefuelOutcome | null>(null)
@@ -91,6 +77,7 @@ export function useRefuelSearch(): RefuelSearch {
line: LatLng[],
dryAlongKm: number,
existing: LatLng[],
fromAlongKm = 0,
) => {
abortRef.current?.abort()
const controller = new AbortController()
@@ -99,6 +86,7 @@ export function useRefuelSearch(): RefuelSearch {
setResults([])
setOutcome(null)
setLoading(false)
// 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.
@@ -115,12 +103,25 @@ export function useRefuelSearch(): RefuelSearch {
// 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))
let incomplete = false
for (let attempt = 0; attempt < MAX_SEARCHES; attempt++) {
const along = Math.max(fromAlongKm, dryAlongKm - LOOK_BACK_KM - attempt * SEARCH_STEP_KM)
const centre = pointAtMeters(line, along * 1000) ?? at
const answer = await mapsApi.pois(wanted, boxAround(centre, LOOK_KM), locale, controller.signal)
if (controller.signal.aborted) return
incomplete ||= !!answer.truncated
const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing }).filter(p => p.alongKm >= fromAlongKm)
if (candidates.length) {
setResults(candidates)
setOutcome('found')
return
}
if (along === fromAlongKm) {
setOutcome(outcomeOf([], { truncated: incomplete }))
return
}
}
setOutcome('incomplete')
} catch {
if (!controller.signal.aborted) setOutcome('failed')
} finally {
@@ -79,6 +79,61 @@ afterEach(() => {
vi.useRealTimers()
})
describe('automatic daily travel times', () => {
beforeEach(() => {
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' } })
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), duration: 43200, legs: [{ ...routed(1).legs[0], duration: 43200 }] })
})
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
it('splits existing routes, keeps manual starts and recalculates without routing the pause', async () => {
const days = [day(1, 1), day(2, 2)]
const original = [{ id: 1, at: HAMBURG, time: '07:00' }, { id: 2, at: BERLIN }]
const { result, rerender } = renderHook(({ stops }) => useRoadtripRoutes(7, days, map(1, stops)), {
initialProps: { stops: original as StopSpec[] },
})
await waitFor(() => expect(result.current.dayWindowIssue).toBeNull())
expect(result.current.days).toHaveLength(2)
expect(result.current.totalStops).toBe(2)
expect(result.current.totalDuration).toBe(43200)
expect(result.current.days[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '18:00'])
expect(result.current.days[1].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '09:00'])
expect(result.current.days[1].stops[1]).toMatchObject({ assignmentId: 2, ownerDayId: 1, ownerIndex: 1 })
const end = result.current.days[0].stops[1]
rerender({ stops: [{ ...original[0], dwell: 120 }, original[1]] })
await waitFor(() => expect(result.current.days[1].schedule.entries[1].arrival).toBe('11:00'))
expect(result.current.days[0].stops[1].lng).not.toBe(end.lng)
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } }))
await waitFor(() => expect(result.current.days).toHaveLength(1))
expect(result.current.days[0].schedule.entries[1].arrival).toBe('21:00')
expect(result.current.days[0].stops.some(s => s.automaticNight)).toBe(false)
})
it('routes the join between stored days and includes it exactly once', async () => {
const days = [day(1, 1), day(2, 2)]
const assignments = { ...map(1, [{ id: 1, at: HAMBURG }]), ...map(2, [{ id: 2, at: BERLIN }]) }
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
await waitFor(() => expect(result.current.dayWindowIssue).toBeNull())
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
expect(calculateRouteWithLegs.mock.calls[0][0]).toEqual([{ lat: HAMBURG[0], lng: HAMBURG[1] }, { lat: BERLIN[0], lng: BERLIN[1] }])
expect(result.current.days.map(d => d.dayNumber)).toEqual([1, 2, 3])
expect(result.current.totalDuration).toBe(43200)
expect(result.current.totalDistance).toBe(100000)
expect(result.current.totalStops).toBe(2)
})
it('exposes a fixed appointment conflict and keeps stored dates and times', async () => {
const days = [day(1, 1), day(2, 2)]
const assignments = map(1, [{ id: 1, at: HAMBURG, time: '07:00' }, { id: 2, at: BERLIN, time: '10:00' }])
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
await waitFor(() => expect(result.current.dayWindowIssue).toBe('conflict'))
expect(result.current.days).toHaveLength(1)
expect(result.current.days[0].stops.map(s => s.time)).toEqual(['07:00', '10:00'])
expect(result.current.days[0].stops.some(s => s.automaticNight)).toBe(false)
})
})
describe('useRoadtripRoutes', () => {
it('FE-ROADTRIP-ROUTES-001: routes a whole day in one request, not one per leg', async () => {
const days = [day(1, 1)]
@@ -663,6 +718,16 @@ describe('useRoadtripRoutes', () => {
// 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])
setting({ roadtrip_range_km: 150 })
await waitFor(() => expect(result.current.days[1].dryPoints![0].legIndex).toBe(-1))
const inboundDry = result.current.days[1].dryPoints![0]
expect(inboundDry).toMatchObject({ intoLegKm: 50, drivenMeters: 50000, inboundLine: [LUENEBURG, BERLIN] })
expect(inboundDry.lat).toBeGreaterThan(BERLIN[0])
expect(inboundDry.lat).toBeLessThan(LUENEBURG[0])
})
})
})
vi.mock('../../hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
}))
@@ -1,194 +1,18 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
import { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
import { resolveLegMode } from '../Planner/legMode'
import {
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 { splitIntoRuns, parseClock, parseAvoid, type DriveLimits } from './roadtripModel'
import { spillChains } from './nightSpill'
import { useSettingsStore } from '../../store/settingsStore'
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'
import type { Assignment, AssignmentsMap, Accommodation, Day, RouteAvoidClass, SnappedWaypoint } from '../../types'
import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared'
import { dayWindow } from './dayWindow'
import { useTranslation } from '../../i18n/TranslationContext'
/** 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
lng: number
/** Planned time on the assignment, or the place's own time as the fallback the API already resolves. */
time: string | null
/** How long the visit is planned to take. `places.duration_minutes` has carried this for years. */
dwellMinutes: number | null
/** Mode of the leg LEAVING this stop; null inherits the day default. */
legMode: string | null
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.
*
* null means either not routed yet or close enough that nobody would call it a gap.
*/
offRoadMeters?: number | null
}
export interface RoadtripDay {
dayId: number
dayNumber: number
date: string | null
title: string | null
stops: RoadtripStop[]
/** Leg i connects stops[i] → stops[i+1]. Shorter than `stops` by one once every leg resolved. */
legs: (RouteSegment | undefined)[]
/** Arrival and departure per stop, walked forward from the day's first pinned time. */
schedule: Schedule
/**
* Halts a routing plugin placed on each leg, indexed alongside `legs`.
*
* Read-only and never written back: a plugin halt that became a via would go out as a
* waypoint on the next run, and the plugin would then optimise around its own charging
* 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
* Berlin the straight line runs across open country while the motorway swings north of
* it, so a corridor built from the stops finds the wrong side of the map.
*/
geometry: [number, number][]
distance: number
duration: number
/**
* Findings about the driving itself, per leg: too long behind the wheel, the tank
* running out before the next fuel stop. Separate from `schedule.warnings`, which are
* findings about a stop, because these belong to no stop and because `Schedule` is
* pinned shape-for-shape by its own tests.
*/
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]]
meters: number
/** So the rail and the map can point at the same one. */
stopKey: string
}
/** A day the rail does not draw a drive for, but that a stop can still be moved onto. */
export interface QuietDay {
dayId: number
dayNumber: number
date: string | null
title: string | null
/** Usually none or one — two would have made it a drive. */
stops: RoadtripStop[]
}
export interface RoadtripRoutes {
days: RoadtripDay[]
/**
* Days with fewer than two stops, in trip order.
*
* They carry no drive, so they are not part of `days` and nothing is routed for them —
* but a stop has to be able to move onto one, and a day that is not on screen cannot be
* dropped on. Listed separately rather than folded into `days` so the totals and the
* routing keep meaning exactly what they meant before.
*/
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.
*
* Drawn dashed, the way Google draws the walk from the kerb to the door, so a stop well
* off the road reads as "the drive ends here and the rest is not driving" instead of
* looking like a route that cuts across open country.
*/
accessLines: AccessSpur[]
/** Every plugin halt of the trip, flat, which is the shape the map draws. */
vias: RouteVia[]
/** The routed legs themselves, so the map can label each connector. */
segments: RouteSegment[]
totalDistance: number
totalDuration: number
totalStops: number
/** True while legs are still coming in — the numbers below are a partial sum until then. */
loading: boolean
}
export type { RoadtripStop, RoadtripDay, RoadtripRoutes, QuietDay, AccessSpur, RoutedLeg, SnappedPoint } from '@trek/shared/roadtrip'
/**
* Gap between two routing requests. The public OSRM hosts TREK ships with state one
@@ -213,35 +37,6 @@ const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
signal.addEventListener('abort', done, { once: true })
})
/**
* A leg that routed: the numbers for the rail plus the geometry the map draws.
*
* The two optional fields are filled by their own features and are declared here rather
* than added later, because this shape is written in one place and read in another and
* three separate changes to the same two lines is three merge conflicts.
*/
interface RoutedLeg {
seg: RouteSegment
line: [number, number][]
/**
* Where the router actually put each end of this leg, and how far that is from the
* place we asked for. OSRM snaps every waypoint to the nearest road with no distance
* limit and reports both in every response; a place set well off the road otherwise
* looks like it is on the route when the drive really starts hundreds of metres away.
*/
snapped?: { from: SnappedPoint | null; to: SnappedPoint | null }
/** Halts a routing plugin placed on this leg, in the order they are driven through. */
vias: RouteVia[]
}
/** A waypoint as the router resolved it, beside the coordinate that was asked for. */
export interface SnappedPoint {
lat: number
lng: number
/** Straight-line metres from the requested coordinate to the road it snapped to. */
offRoadMeters: number
}
/**
* What makes a stop the same stop as far as routing is concerned. `planKey` is built from
* these, and so are the keys the routed legs are filed under, so the two can never drift
@@ -274,10 +69,16 @@ const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>)
/** 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, ownerDayId: number, ownerIndex: number): RoadtripStop | null => {
const EMPTY_ACCOMMODATIONS: Accommodation[] = []
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[], days: Day[]): RoadtripStop | null => {
const p = a.place
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
const stay = accommodations.find(stay => stay.place_id === a.place_id && stay.start_day_id === ownerDayId)
const checkoutDay = days.find(day => day.id === stay?.end_day_id)?.day_number
const checkoutTime = parseClock(stay?.check_out)
return {
...(checkoutDay != null && checkoutTime !== null ? { checkoutAt: checkoutDay * 1440 + checkoutTime } : {}),
assignmentId: a.id,
ownerDayId,
ownerIndex,
@@ -285,8 +86,9 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): Roadtrip
name: p.name,
lat: p.lat,
lng: p.lng,
time: a.assignment_time ?? p.place_time ?? null,
time: a.assignment_time ?? p.place_time ?? stay?.check_in ?? null,
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
endDay: a.end_day === true,
legMode: a.leg_transport_mode ?? null,
incomingLegMode: a.incoming_leg_transport_mode ?? null,
stopType: p.stop_type ?? null,
@@ -318,7 +120,10 @@ export function useRoadtripRoutes(
* chose rather than the one it prefers.
*/
viasByDay: Record<number, RoadtripVia[]> = {},
boundaries: RoadtripDayBoundary[] = [],
accommodations: Accommodation[] = EMPTY_ACCOMMODATIONS,
): RoadtripRoutes {
const { t } = useTranslation()
const routeProfile = fallbackProfile || 'driving'
// Leg text is pre-formatted in the chosen unit, so a km↔mi switch has to re-fetch.
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
@@ -347,9 +152,9 @@ export function useRoadtripRoutes(
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 fillPercent = useSettingsStore(s => s.settings.roadtrip_fill_percent)
const legMinutes = useRoadtripSettings(s => s.roadtrip_leg_minutes, tripId)
const dayMinutes = useRoadtripSettings(s => s.roadtrip_day_minutes, tripId)
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent, tripId)
/**
* Road classes to weight away, as the settings row stores them: a comma list.
*
@@ -358,7 +163,7 @@ export function useRoadtripRoutes(
* 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)
const avoidSetting = useRoadtripSettings(s => s.roadtrip_avoid, tripId)
/**
* Whether the gaps between days are driven too.
*
@@ -367,11 +172,16 @@ export function useRoadtripRoutes(
* 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 connectSetting = useRoadtripSettings(s => !!s.roadtrip_connect_days, tripId)
const startTime = useRoadtripSettings(s => s.roadtrip_day_start, tripId)
const endTime = useRoadtripSettings(s => s.roadtrip_day_end, tripId)
const endMode = useRoadtripSettings(s => s.roadtrip_day_end_mode, tripId)
const window = useMemo(() => dayWindow(startTime, endTime, endMode), [startTime, endTime, endMode])
const connectDays = connectSetting || window !== null
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 { vehicleKind, rangeKm: planningRangeKm } = useVehicleRange(tripId)
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.
@@ -394,7 +204,7 @@ export function useRoadtripRoutes(
const stops = (assignments[String(d.id)] ?? [])
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map(a => asStop(a, d.id, 0))
.map(a => asStop(a, d.id, 0, accommodations, days))
.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
@@ -409,7 +219,7 @@ export function useRoadtripRoutes(
}
})
.filter(d => d.stops.length > 1)
}, [days, assignments])
}, [days, assignments, accommodations])
const quietDays = useMemo<QuietDay[]>(() => {
return [...days]
@@ -422,12 +232,12 @@ export function useRoadtripRoutes(
stops: (assignments[String(d.id)] ?? [])
.slice()
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map(a => asStop(a, d.id, 0))
.map(a => asStop(a, d.id, 0, accommodations, days))
.filter((s): s is RoadtripStop => s !== null)
.map((s, i) => ({ ...s, ownerIndex: i })),
}))
.filter(d => d.stops.length < 2)
}, [days, assignments])
}, [days, assignments, accommodations])
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
// editing its notes must not fire a routing round.
@@ -521,70 +331,7 @@ export function useRoadtripRoutes(
if (s) daySnaps[stopKey(stop)] = s
})
}
// Where a plugin's halts sit along this run, measured once. Only the ones
// that say something get projected — a bare coordinate has nothing to show
// in the rail, and projection is not free.
const spine = r.coordinates.map(([la, ln]) => ({ lat: la, lng: ln }))
const viaAlong = (r.vias ?? [])
.filter(v => v.label || v.dwellSeconds != null)
.map(v => ({ via: v, alongMeters: (projectOntoRoute({ lat: v.lat, lng: v.lng }, spine)?.alongKm ?? 0) * 1000 }))
// Fold the router's per-waypoint legs back onto the stop pairs: a stop pair
// with a via between it comes back as two legs, and the rail shows one.
// A running metre count comes with it, so each halt lands on the leg it is
// actually driven on rather than on the nearest stop, which two towns close
// 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]
const to = run[i + 1]
if (!from || !to) continue
const parts = r.legs.slice(stopAt[i], stopAt[i + 1])
if (!parts.length) continue
const merged = parts.length === 1 ? parts[0] : {
...parts[0],
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)
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)
if (idx >= 0) dayLegs[legKeys[idx]].vias.push(via)
}
Object.assign(dayLegs, foldRouteRun(run, stopAt, r, mode))
return
} catch (err) {
// Almost always a rate limit on the shared routing host. Back off and try
@@ -671,7 +418,10 @@ export function useRoadtripRoutes(
if (have && have.shape === seamShape(from, viasByDay)) return
out.push({ from, to, dayId })
}
for (const chain of chains) {
const routingChains = window
? [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).filter(d => d.stops.length)
: chains
for (const chain of routingChains) {
for (let i = 0; i < chain.stops.length - 1; i++) {
const from = chain.stops[i]
const to = chain.stops[i + 1]
@@ -683,14 +433,14 @@ export function useRoadtripRoutes(
// 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)
for (let d = 0; d < routingChains.length - 1; d++) {
const from = routingChains[d].stops[routingChains[d].stops.length - 1]
const to = routingChains[d + 1].stops[0]
if (from && to) want(from, to, routingChains[d + 1].dayId)
}
}
return out
}, [chains, legsByDay, seamLegs, viasByDay, connectDays])
}, [chains, plan, quietDays, window, legsByDay, seamLegs, viasByDay, connectDays])
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';')
useEffect(() => {
@@ -754,168 +504,7 @@ export function useRoadtripRoutes(
// 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. `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
/** 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); lineDays.push(chain.dayNumber) }
segments.push(leg.seg)
}
// 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)
+ inbound.reduce((sum, l) => sum + (l?.duration ?? 0), 0)
const schedule = chain.schedule
const legVias = routed.map(l => l?.vias ?? [])
const stops = chain.stops.map(s => {
const snap = allSnaps[stopKey(s)]
const line = spurFor(snap)
if (line) accessLines.push({ line, meters: snap.meters, stopKey: stopKey(s) })
return { ...s, offRoadMeters: line ? snap.meters : null }
})
// The night drive is on the tank too, and it is in no leg list: it ends at this
// chain's first stop rather than leaving from it. Added to the carry so the budget
// has spent it before the first stop of the day gets a chance to fill up again.
const inboundKm = inbound
.filter(l => l && (l.mode === undefined || l.mode === 'driving'))
.reduce((sum, l) => sum + (l?.distance ?? 0), 0) / 1000
const startKm = carryKm === null ? null : carryKm + inboundKm
const drive = deriveDriveWarnings(
legs,
// One longer than the legs: the last stop of the day counts too, because filling
// up on arrival is what makes the next morning start full.
stops.map(s => refuelsRange(s.stopType, vehicleKind)),
limits,
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({
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: drives,
lines,
lineDays,
segments,
accessLines,
vias: out.flatMap(d => d.legVias.flat()),
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: 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, chains, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays])
}
return useMemo(() => assembleRoadtrip({ plan, quietDays, window, distanceUnit, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries,
labels: { start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') },
}), [plan, quietDays, window, distanceUnit, t, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries])
}
@@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useSettingsStore } from '../../store/settingsStore'
import type { Settings } from '../../types'
import { useVehicleRange } from './useVehicleRange'
@@ -197,3 +197,7 @@ describe('useVehicleRange', () => {
expect(result.current).not.toBe(first)
})
})
vi.mock('../../hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
}))
@@ -1,31 +1,16 @@
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
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)
export function useVehicleRange(tripId?: number | string | null): { vehicleKind: VehicleKind | null; rangeKm: number | null } {
const rangeKm = useRoadtripSettings(s => s.roadtrip_range_km, tripId)
const vehicle = useRoadtripSettings(s => s.roadtrip_vehicle, tripId)
const tankLitres = useRoadtripSettings(s => s.roadtrip_tank_litres, tripId)
const litresPer100 = useRoadtripSettings(s => s.roadtrip_litres_per_100, tripId)
const batteryKwh = useRoadtripSettings(s => s.roadtrip_battery_kwh, tripId)
const kwhPer100 = useRoadtripSettings(s => s.roadtrip_kwh_per_100, tripId)
const degradationPercent = useRoadtripSettings(s => s.roadtrip_battery_degradation, tripId)
const vehicleKind: VehicleKind | null =
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
+2 -170
View File
@@ -1,170 +1,2 @@
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)
}
export { rangeFromSpec, effectiveRangeKm, specUnit, showSpec, storeSpec } from '@trek/shared/roadtrip'
export type { VehicleSpec, SpecKey } from '@trek/shared/roadtrip'
+10 -5
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useLayoutEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { LucideIcon } from 'lucide-react'
@@ -14,15 +14,17 @@ interface MenuState {
x: number
y: number
items: MenuItem[]
alignEnd?: boolean
}
export function useContextMenu() {
const [menu, setMenu] = useState<MenuState | null>(null)
const open = (e: React.MouseEvent, items: MenuItem[]) => {
const open = (e: React.MouseEvent, items: MenuItem[], alignEnd = false) => {
e.preventDefault()
e.stopPropagation()
setMenu({ x: e.clientX, y: e.clientY, items })
const anchor = e.currentTarget.getBoundingClientRect()
setMenu({ x: alignEnd ? anchor.right : e.clientX, y: alignEnd ? anchor.bottom + 6 : e.clientY, items, alignEnd })
}
const close = () => setMenu(null)
@@ -49,11 +51,12 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
}
}, [menu, onClose])
useEffect(() => {
useLayoutEffect(() => {
if (!menu || !ref.current) return
const el = ref.current
const rect = el.getBoundingClientRect()
let { x, y } = menu
if (menu.alignEnd) x = Math.max(8, x - rect.width)
if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8
if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8
if (x !== menu.x || y !== menu.y) {
@@ -72,8 +75,10 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
minWidth: 160,
width: menu.alignEnd ? 'max-content' : undefined,
whiteSpace: menu.alignEnd ? 'nowrap' : undefined,
fontFamily: "var(--font-system)",
transformOrigin: 'top left',
transformOrigin: menu.alignEnd ? 'top right' : 'top left',
}}>
{menu.items.filter(Boolean).map((item, i) => {
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
@@ -13,9 +13,12 @@ interface CustomTimePickerProps {
/** Read-only surfaces keep the field, they just cannot type in it — same
* affordance the native input they replaced offered (#2067). */
disabled?: boolean
'aria-label'?: string
'aria-describedby'?: string
'aria-invalid'?: boolean
}
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {}, disabled = false }: CustomTimePickerProps) {
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {}, disabled = false, ...aria }: CustomTimePickerProps) {
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [open, setOpen] = useState(false)
const [inputFocused, setInputFocused] = useState(false)
@@ -129,6 +132,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
transition: 'border-color 0.15s',
}}>
<input
{...aria}
type="text"
value={inputFocused ? value : formatClockTime(value, is12h)}
onChange={handleInput}
+8
View File
@@ -0,0 +1,8 @@
import { offlineDb } from './offlineDb'
import type { Assignment } from '../types'
export async function cacheAssignment(assignment: Assignment): Promise<void> {
await offlineDb.days.where('id').equals(assignment.day_id).modify(day => {
day.assignments = (day.assignments ?? []).map(a => a.id === assignment.id ? { ...a, ...assignment } : a)
})
}
+5
View File
@@ -1,3 +1,4 @@
import type { RoadtripPreferences } from '@trek/shared';
import Dexie, { type Table } from 'dexie';
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types';
@@ -160,6 +161,7 @@ function initialDbName(): string {
}
class TrekOfflineDb extends Dexie {
roadtripPreferences!: Table<{ tripId: number; preferences: RoadtripPreferences }, number>;
trips!: Table<Trip, number>;
days!: Table<Day, number>;
places!: Table<Place, number>;
@@ -221,6 +223,7 @@ class TrekOfflineDb extends Dexie {
// v5: places from the TREK Places index for the trip's area, so search
// answers offline. `searchName` is indexed because that is what an offline
// query filters on; `tripId` so the set drops with its trip.
this.version(6).stores({ roadtripPreferences: 'tripId' });
this.version(5).stores({
areaPlaces: 'gers, tripId, searchName',
});
@@ -448,8 +451,10 @@ export async function clearTripData(tripId: number): Promise<void> {
offlineDb.syncMeta,
offlineDb.blobCache,
offlineDb.areaPlaces,
offlineDb.roadtripPreferences,
],
async () => {
await offlineDb.roadtripPreferences.delete(tripId);
await offlineDb.days.where('trip_id').equals(tripId).delete();
await offlineDb.places.where('trip_id').equals(tripId).delete();
await offlineDb.packingItems.where('trip_id').equals(tripId).delete();
@@ -0,0 +1,33 @@
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useRoadtripPreferencesSync } from './useRoadtripPreferencesSync'
import { useRoadtripSettings } from './useRoadtripSettings'
import { useRoadtripPreferencesStore } from '../store/roadtripPreferencesStore'
import { useAuthStore } from '../store/authStore'
import { addListener, removeListener } from '../api/websocket'
vi.mock('../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() }))
vi.mock('../repo/roadtripPreferencesRepo', () => ({ cachedRoadtripPreferences: vi.fn(async () => ({})) }))
vi.mock('../db/offlineDb', () => ({ offlineDb: { roadtripPreferences: { put: vi.fn(async () => {}) } } }))
describe('shared Roadtrip settings', () => {
it('scopes events and reads by trip and signed-in account', () => {
useAuthStore.setState({ user: { id: 1 } as never })
useRoadtripPreferencesStore.setState({ byTrip: {} })
const hook = renderHook(() => {
useRoadtripPreferencesSync()
return useRoadtripSettings(s => s.roadtrip_day_start, 10)
})
const listener = vi.mocked(addListener).mock.calls[vi.mocked(addListener).mock.calls.length - 1][0]
act(() => listener({ type: 'roadtripPreferences:changed', tripId: '10', preferences: { roadtrip_day_start: '07:00' } }))
expect(hook.result.current).toBe('07:00')
act(() => listener({ type: 'roadtripPreferences:changed', tripId: '11', preferences: { roadtrip_day_start: '09:00' } }))
expect(hook.result.current).toBe('07:00')
act(() => listener({ type: 'roadtripPreferences:changed', tripId: '10', preferences: { roadtrip_day_start: '99:00' } }))
expect(hook.result.current).toBe('07:00')
act(() => useAuthStore.setState({ user: { id: 2 } as never }))
expect(hook.result.current).toBeUndefined()
hook.unmount()
expect(removeListener).toHaveBeenCalledWith(listener)
})
})
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { roadtripPreferencesSchema } from '@trek/shared'
import { addListener, removeListener } from '../api/websocket'
import { useAuthStore } from '../store/authStore'
import { publishRoadtripPreferences } from '../store/roadtripPreferencesStore'
import { offlineDb } from '../db/offlineDb'
import { cachedRoadtripPreferences } from '../repo/roadtripPreferencesRepo'
export function useRoadtripPreferencesSync() {
const userId = useAuthStore(s => s.user?.id)
useEffect(() => {
if (!userId) return
let disposed = false
const listener = (event: Record<string, unknown>) => {
if (event.type !== 'roadtripPreferences:changed') return
const tripId = Number(event.tripId)
const parsed = roadtripPreferencesSchema.safeParse(event.preferences)
if (!parsed.success || !Number.isSafeInteger(tripId) || tripId < 1) return
publishRoadtripPreferences(userId, tripId, parsed.data)
void offlineDb.roadtripPreferences.put({ tripId, preferences: parsed.data }).then(() => cachedRoadtripPreferences(tripId)).then(preferences => {
if (!disposed) publishRoadtripPreferences(userId, tripId, preferences)
}).catch(() => {})
}
addListener(listener)
return () => { disposed = true; removeListener(listener) }
}, [userId])
}
+42
View File
@@ -0,0 +1,42 @@
import { liveQuery } from 'dexie'
import { onNetworkModeChange } from '../sync/networkMode'
import { useEffect, useState } from 'react'
import type { RoadtripPreferences } from '@trek/shared'
import { useAuthStore } from '../store/authStore'
import { useTripStore } from '../store/tripStore'
import { EMPTY_ROADTRIP_PREFERENCES, publishRoadtripPreferences, useRoadtripPreferencesStore } from '../store/roadtripPreferencesStore'
import { cachedRoadtripPreferences, roadtripPreferencesRepo } from '../repo/roadtripPreferencesRepo'
export function useRoadtripSettings<T>(select: (settings: RoadtripPreferences) => T, requestedTripId?: number | string | null): T {
const activeTripId = useTripStore(s => s.trip?.id)
const userId = useAuthStore(s => s.user?.id)
const key = `${userId}:${requestedTripId ?? activeTripId}`
return useRoadtripPreferencesStore(s => select(s.byTrip[key] ?? EMPTY_ROADTRIP_PREFERENCES))
}
export function useLoadRoadtripSettings(tripId: number, enabled: boolean) {
const userId = useAuthStore(s => s.user?.id)
const [loadedKey, setLoadedKey] = useState('')
const [failedKey, setFailedKey] = useState('')
const key = `${userId}:${tripId}`
useEffect(() => {
if (!enabled || !userId || !Number.isFinite(tripId)) return
let cancelled = false
const load = () => {
roadtripPreferencesRepo.read(tripId).then(preferences => {
if (cancelled) return
publishRoadtripPreferences(userId, tripId, preferences)
setLoadedKey(key)
setFailedKey('')
}).catch(() => { if (!cancelled) setFailedKey(key) })
}
const cacheSubscription = liveQuery(() => cachedRoadtripPreferences(tripId).catch(() => null)).subscribe(preferences => {
if (!cancelled && preferences) publishRoadtripPreferences(userId, tripId, preferences)
})
load()
const unsubscribe = onNetworkModeChange(load)
window.addEventListener('online', load)
return () => { cancelled = true; cacheSubscription.unsubscribe(); unsubscribe(); window.removeEventListener('online', load) }
}, [tripId, userId, enabled, key])
return { ready: loadedKey === key, failed: failedKey === key }
}
+6 -3
View File
@@ -1,3 +1,5 @@
import { useRoadtripSettings } from './useRoadtripSettings'
import { isServiceStopType } from '../components/Roadtrip/roadtripModel'
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
@@ -20,6 +22,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
// Charging stops / rest areas a plugin route places on the drawn line.
const [routeVias, setRouteVias] = useState<RouteVia[]>([])
const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false)
const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations)
// Recompute when the selected day's whole-day default mode changes (#1281) —
@@ -44,7 +47,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
const allDays = state.days || []
const runsWithHotel = buildDayRouteRuns(dayId, {
days: allDays,
assignments: state.assignments || {},
assignments: Object.fromEntries(Object.entries(state.assignments || {}).map(([id, entries]) => [id, entries.filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type))])),
reservations: state.reservations || [],
accommodations,
optimizeFromAccommodation,
@@ -113,7 +116,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) }
}
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode])
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops])
// Stable signature for transport reservations on the selected day — changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
@@ -137,7 +140,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (!selectedDayId) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode])
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops])
return { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
@@ -22,6 +22,7 @@ import MSegmented from '../../components/MSegmented'
import { MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi'
import { MSetSelectRow } from '../settings/MSettingsUi'
import MSetPickerSheet from '../settings/MSetPickerSheet'
import RoutingInstanceFields, { type RoutingDefaults } from '../../../components/Admin/RoutingInstanceFields'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -37,7 +38,7 @@ const MAP_PRESETS = [
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
type Defaults = {
type Defaults = RoutingDefaults & {
temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean
@@ -317,6 +318,8 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
</MAdminField>
)}
{!managed && <RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} hintClassName="font-geist text-[0.625rem] leading-relaxed text-m-muted" />}
{/* Live tile preview */}
<div className="relative h-[200px] w-full overflow-hidden rounded-xl">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+16 -9
View File
@@ -268,10 +268,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
setRoadtripStopKind,
setRoadtripStopFill,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
stayDraft, setStayDraft, setRoadtripStay,
saveRoadtripLimit, roadtripSettingsLoading, storedAssignments,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, resetDayBoundaries,
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, dayBoundaryControls,
stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay,
highlightedAlternative, setHighlightedAlternative,
moveRoadtripStopToDay,
dropPoiOnRoute,
@@ -410,7 +410,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
dayPlaces={dayPlaces}
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
routeColors={roadtripActive ? roadtripLineColors : overviewActive ? tripOverview.lineColors : undefined}
routeVias={roadtripActive ? roadtripRoutes.vias : routeVias}
routeVias={roadtripActive ? roadtripMapVias : routeVias}
dayBoundaryControls={roadtripActive ? dayBoundaryControls : undefined}
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
showTransitRoutes={transitRoutesShown}
// The route toggle belongs to one day, so the map needs that day to
@@ -568,13 +569,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<LazyPanel id="roadtrip-rail">
<RoadtripSidebar
routes={roadtripRoutes}
onFocusPoint={focusRoadtripPoint}
selectedAssignmentId={selectedAssignmentId}
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)}
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
openAlternatives={routeAlternatives.open}
onEditStay={can('place_edit', trip) ? setStayDraft : undefined}
onEditStay={can('place_edit', trip) ? editRoadtripStay : 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}
@@ -595,7 +597,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
days={days}
places={places}
categories={categories}
assignments={assignments}
assignments={storedAssignments}
selectedDayId={selectedDayId}
selectedPlaceId={selectedPlaceId}
selectedAssignmentId={selectedAssignmentId}
@@ -703,6 +705,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
returns silently without the permission, which reads as a broken
button rather than a missing one. */}
<RoadtripCorridorPanel
tripId={Number(tripId)} canImport={can('place_edit', trip) && can('day_edit', trip)}
corridor={roadtripCorridor}
routes={roadtripRoutes}
onAddPoi={can('place_edit', trip) ? handlePoiClick : undefined}
@@ -710,7 +713,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{/* Under the search, because the limits are read while looking at
what the drive is doing rather than set up front. */}
<div className="px-3.5 pb-3.5">
<RoadtripLimitsCard onSave={saveRoadtripLimit} />
<RoadtripLimitsCard loading={roadtripSettingsLoading} onSave={saveRoadtripLimit} onResetDayBoundaries={resetDayBoundaries} />
</div>
</LazyPanel>
) : (
@@ -794,6 +797,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{selectedPlace && !isMobile && (
<PlaceInspector
roadtripEndDay={roadtripEndDay}
roadtripStay={roadtripStay} roadtripActive={roadtripActive}
onEditTransport={openLinkedTransport}
onEditReservation={openLinkedReservation}
place={selectedPlace}
@@ -836,6 +841,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<div className="bg-[rgba(0,0,0,0.3)]" style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 'var(--bottom-nav-h)' }} role="presentation" onClick={() => setSelectedPlaceId(null)}>
<div style={{ width: '100%', maxHeight: '85vh' }} role="presentation" onClick={e => e.stopPropagation()}>
<PlaceInspector
roadtripEndDay={roadtripEndDay}
roadtripStay={roadtripStay} roadtripActive={roadtripActive}
onEditTransport={openLinkedTransport}
onEditReservation={openLinkedReservation}
place={selectedPlace}
@@ -888,7 +895,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</div>
<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 />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={storedAssignments} 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} 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>
@@ -1,3 +1,4 @@
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
// FE-TP-ROAD-001 to FE-TP-ROAD-074
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
@@ -170,6 +171,7 @@ function makeActions() {
moveAssignment: vi.fn(async () => undefined),
removeAssignment: vi.fn(async () => undefined),
reorderAssignments: vi.fn(async () => undefined),
setAssignmentEndDay: vi.fn(async () => undefined),
reorderDays: vi.fn(async () => undefined),
insertDay: vi.fn(async () => undefined),
updateDayTitle: vi.fn(async () => undefined),
@@ -187,6 +189,23 @@ function wrapper({ children }: { children: React.ReactNode }) {
/** Mount with the addon on and the mode on, and wait until both have landed. */
async function renderRoadtrip() {
if (!rt.routes.days.length && rt.corridor.day) {
const dayId = Number(rt.corridor.day.dayId)
const assigned = useTripStore.getState().assignments[String(dayId)] ?? []
const stops = assigned.length
? assigned.filter(a => typeof a.place.lat === 'number' && typeof a.place.lng === 'number').map(a => ({
assignmentId: a.id, placeId: a.place_id, lat: a.place.lat, lng: a.place.lng,
}))
: [0, 1, 2].map(i => ({ assignmentId: i + 1, placeId: i + 1, lat: 53 - i, lng: 10 + i }))
rt.routes.days = [{ ...rt.corridor.day, stops }]
}
rt.routes.days = rt.routes.days.map(day => ({
...day,
stops: (day.stops as Array<Record<string, unknown>>).map((stop, i) => ({
ownerDayId: day.dayId, ownerIndex: i, ...stop,
})),
}))
if (rt.corridor.day) rt.corridor.day = rt.routes.days.find(d => d.dayId === rt.corridor.day?.dayId) ?? rt.corridor.day
const rendered = renderHook(() => useTripPlanner(), { wrapper })
await act(async () => { await Promise.resolve() })
await waitFor(() => expect(rendered.result.current.roadtripActive).toBe(true))
@@ -311,6 +330,51 @@ afterEach(() => {
vi.restoreAllMocks()
})
describe('visit day-end controls', () => {
const setup = async () => {
const place = buildPlace({ id: 101 })
seedTrip({ places: [place], days: [buildDay({ id: 5 })] })
rt.routes.days = [{ dayId: 5, stops: [{ assignmentId: 11, placeId: 101, endDay: true }] }]
useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' } }))
const rendered = await renderRoadtrip()
act(() => rendered.result.current.selectAssignment(11, 101))
return rendered
}
it('offers the selected visit and saves to its stored day', async () => {
const { result } = await setup()
expect(result.current.roadtripEndDay?.active).toBe(true)
await act(async () => result.current.roadtripEndDay?.onToggle())
expect(actions.setAssignmentEndDay).toHaveBeenCalledWith(42, 5, 11, false)
})
it('hides the control when daily times are off and restores the stored choice when enabled', async () => {
const { result } = await setup()
act(() => useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_day_end: '' } })))
expect(result.current.roadtripEndDay).toBeUndefined()
act(() => useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_day_end: '18:00' } })))
expect(result.current.roadtripEndDay?.active).toBe(true)
})
it('hides the control in Days mode and from a reader', async () => {
const { result } = await setup()
act(() => result.current.toggleRoadtripMode())
expect(result.current.roadtripEndDay).toBeUndefined()
act(() => {
result.current.toggleRoadtripMode()
asReader('day_edit')
})
expect(result.current.roadtripEndDay).toBeUndefined()
})
it('reports a rejected change', async () => {
const { result } = await setup()
actions.setAssignmentEndDay.mockRejectedValueOnce(new Error('Save failed'))
await act(async () => result.current.roadtripEndDay?.onToggle())
expect(toasts.some(t => t.type === 'error')).toBe(true)
})
})
describe('useTripPlanner road trip: a hit on the drive', () => {
it('FE-TP-ROAD-001: a corridor hit opens the popup on the day being searched, at its place in the chain', async () => {
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
@@ -796,15 +860,15 @@ describe('useTripPlanner road trip: one stop at a time', () => {
it('FE-TP-ROAD-033: a driving limit goes straight to the settings, and a failure is said', async () => {
oneStop()
const updateSettings = vi.fn(async () => {})
useSettingsStore.setState({ updateSettings } as never)
const updateSettings = vi.mocked(roadtripPreferencesRepo.update)
updateSettings.mockResolvedValue({})
const { result } = await renderRoadtrip()
await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_leg_minutes', 180) })
expect(updateSettings).toHaveBeenCalledWith({ roadtrip_max_leg_minutes: 180 })
await act(async () => { await result.current.saveRoadtripLimit?.('roadtrip_leg_minutes', 180) })
expect(updateSettings).toHaveBeenCalledWith(42, { roadtrip_leg_minutes: 180 })
updateSettings.mockRejectedValue(new Error('nope'))
await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_leg_minutes', 90) })
await act(async () => { await result.current.saveRoadtripLimit?.('roadtrip_leg_minutes', 90) })
expect(toasts.some(t => t.type === 'error')).toBe(true)
})
})
@@ -1429,6 +1493,7 @@ describe('useTripPlanner road trip: somewhere to fill up', () => {
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]],
legs: [{ mode: 'driving', distance: 111000 }, { mode: 'driving', distance: 111000 }],
}]
}
@@ -1536,3 +1601,36 @@ describe('useTripPlanner road trip: somewhere to fill up', () => {
expect(result.current.stopDraft).toBeNull()
})
})
vi.mock('../../hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
useLoadRoadtripSettings: () => ({ ready: true, failed: false }),
}))
vi.mock('../../repo/roadtripPreferencesRepo', () => ({ roadtripPreferencesRepo: { update: vi.fn(async () => ({})) } }))
it('keeps exclusive service stops in Roadtrip, hides them in Days and restores them without duplicates', async () => {
const normal = buildPlace({ id: 101, name: 'Berlin' })
const charging = buildPlace({ id: 102, name: 'Charger', stop_type: 'charging' })
const visits = [buildAssignment({ id: 11, day_id: 5, place: normal }), buildAssignment({ id: 12, day_id: 5, place: charging })]
seedTrip({ places: [normal, charging], days: [buildDay({ id: 5 })], assignments: { '5': visits } })
useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_service_stops_in_days: false } }))
const { result } = await renderRoadtrip()
expect(result.current.assignments['5']).toHaveLength(2)
act(() => result.current.toggleRoadtripMode())
expect(result.current.assignments['5'].map(a => a.id)).toEqual([11])
expect(result.current.places.map(p => p.id)).toEqual([101])
expect(useTripStore.getState().assignments['5']).toHaveLength(2)
act(() => useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_service_stops_in_days: true } })))
expect(result.current.assignments['5']).toHaveLength(2)
expect(result.current.places).toHaveLength(2)
})
it('preserves exclusive service stops when reordering the visible Days stops', async () => {
const visits = [stopAt(11, 5, 0), stopAt(12, 5, 1, { place: buildPlace({ stop_type: 'charging' }) }), stopAt(13, 5, 2)]
seedTrip({ days: [buildDay({ id: 5 })], assignments: { '5': visits } })
useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_service_stops_in_days: false } }))
const { result } = await renderRoadtrip()
act(() => result.current.toggleRoadtripMode())
await act(async () => result.current.handleReorder(5, [13, 11]))
expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 5, [13, 12, 11])
})
+201 -52
View File
@@ -1,3 +1,6 @@
import { useLoadRoadtripSettings, useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
import { publishRoadtripPreferences } from '../../store/roadtripPreferencesStore'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router'
import { useTripStore } from '../../store/tripStore'
@@ -22,8 +25,12 @@ import { useAuthStore } from '../../store/authStore'
import { useResizablePanels } from '../../hooks/useResizablePanels'
import { useTripWebSocket } from '../../hooks/useTripWebSocket'
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
import { useRoadtripRoutes, type RoadtripStop } from '../../components/Roadtrip/useRoadtripRoutes'
import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDayPoints'
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
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'
@@ -31,6 +38,7 @@ 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'
import { stopArrival } from '../../components/Roadtrip/stopArrival'
import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois'
import { projectOntoRoute, type LatLng } from '../../components/Roadtrip/corridor'
import {
@@ -38,7 +46,7 @@ import {
reanchorAfterInsert,
reanchorAfterRemove,
reanchorByStopOrder,
reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel'
isServiceStopType, refuelStopTypeFor, 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'
@@ -46,7 +54,7 @@ import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import { useIsTouch } from '../../hooks/useIsTouch'
import { usePluginStore } from '../../store/pluginStore'
import type { Accommodation, TripMember, Day, Place, Reservation, Settings } from '../../types'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
import { OFM_POSITRON, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { useTileUrl } from '../../hooks/useTileUrl'
import { resolvePoolAssignmentId } from './tripPlannerModel'
@@ -79,15 +87,16 @@ export function useTripPlanner() {
const navigate = useNavigate()
const toast = useToast()
const { t, language } = useTranslation()
const { settings, updateSettings } = useSettingsStore()
const { settings } = useSettingsStore()
const roadtripSettings = useRoadtripSettings(s => s, tripId)
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
const allPlugins = usePluginStore(s => s.plugins)
const pluginsLoaded = usePluginStore(s => s.loaded)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const trip = useTripStore(s => s.trip)
const days = useTripStore(s => s.days)
const places = useTripStore(s => s.places)
const assignments = useTripStore(s => s.assignments)
const allPlaces = useTripStore(s => s.places)
const storedAssignments = useTripStore(s => s.assignments)
const packingItems = useTripStore(s => s.packingItems)
const todoItems = useTripStore(s => s.todoItems)
const categories = useTripStore(s => s.categories)
@@ -118,6 +127,10 @@ export function useTripPlanner() {
// someone planning a road trip stays in it across reloads without it leaking into
// their next, non-driving trip.
const [roadtripMode, setRoadtripMode] = useState<boolean>(() => sessionStorage.getItem(`trip-roadtrip-${tripId}`) === '1')
const assignments = useMemo(() => roadtripMode || roadtripSettings.roadtrip_service_stops_in_days !== false ? storedAssignments : Object.fromEntries(
Object.entries(storedAssignments).map(([dayId, visits]) => [dayId, visits.filter(visit => !isServiceStopType(visit.place?.stop_type))]),
), [roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, storedAssignments])
const places = useMemo(() => roadtripMode || roadtripSettings.roadtrip_service_stops_in_days !== false ? allPlaces : allPlaces.filter(place => !isServiceStopType(place.stop_type)), [roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, allPlaces])
const toggleRoadtripMode = useCallback(() => {
setRoadtripMode(prev => {
const next = !prev
@@ -556,14 +569,28 @@ export function useTripPlanner() {
// Road trip mode reads the whole trip, not the selected day, so it owns its own legs.
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
const roadtripActive = !!enabledAddons.roadtrip && roadtripMode
const roadtripPreferencesState = useLoadRoadtripSettings(tripId, !!enabledAddons.roadtrip)
useEffect(() => {
if (roadtripPreferencesState.failed) toast.error(t('common.error'))
}, [roadtripPreferencesState.failed, toast, t])
const dailyTimesActive = !!dayWindow(roadtripSettings.roadtrip_day_start, roadtripSettings.roadtrip_day_end)
const dayBoundaries = useDayBoundaries(tripId, roadtripActive && dailyTimesActive, assignments)
useEffect(() => { if (dayBoundaries.stale) toast.error(t('trip.toast.loadError')) }, [dayBoundaries.stale, toast, t])
const resetDayBoundaries = dayBoundaries.editable && dayBoundaries.boundaries.length && can('day_edit', trip) ? async () => {
try {
for (const boundary of dayBoundaries.boundaries) await dayBoundaries.save(boundary.day_number, null)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
} : undefined
const roadtripVias = useRoadtripVias(tripId, roadtripActive)
const refuel = useRefuelSearch()
const roadtripRoutes = useRoadtripRoutes(
tripId,
roadtripActive ? days : EMPTY_DAYS,
roadtripActive && roadtripPreferencesState.ready ? days : EMPTY_DAYS,
assignments,
routeProfile,
roadtripVias.byDay,
dayBoundaries.boundaries,
tripAccommodations,
)
// Lives here rather than in the panel because the map draws what it finds.
const roadtripCorridor = useRoadtripCorridor(roadtripRoutes)
@@ -723,20 +750,25 @@ export function useTripPlanner() {
// the chain in driving order instead of being dragged there afterwards.
const hit = roadtripActive && 'alongKm' in poi ? (poi as unknown as CorridorPoi) : null
if (hit && roadtripDayId != null) {
const displayed = roadtripRoutes.days.find(d => d.dayId === roadtripDayId)
const insert = displayed && roadtripInsertion(displayed, roadtripInsertIndexFor(hit))
if (!insert) return
setStopDraft({
poi: hit,
dayId: roadtripDayId,
position: roadtripInsertIndexFor(hit),
arrivalTime: displayed ? stopArrival(displayed, roadtripInsertIndexFor(hit), hit.alongKm) : null,
...insert,
dayNumber: roadtripDayNumber,
// Only for a hit somebody could sleep at, and it is what gives the popup its
// second mode. The check-out options are the days from this one on in travel
// order; the default is the next one, which is what a night usually means.
...(isOvernightCategory(hit.category) ? { overnight: overnightOptions(roadtripDayId) } : {}),
...(isOvernightCategory(hit.category) ? { overnight: overnightOptions(insert.dayId) } : {}),
})
return
}
openAddPlaceFromPoi(poi, roadtripActive ? roadtripDayId : undefined)
}, [openAddPlaceFromPoi, roadtripActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, overnightOptions, can, trip])
const selected = roadtripRoutes.days.find(d => d.dayId === roadtripDayId)
const target = selected && roadtripInsertion(selected, selected.stops.length)
openAddPlaceFromPoi(poi, roadtripActive ? target?.dayId ?? roadtripDayId : undefined)
}, [openAddPlaceFromPoi, roadtripActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, roadtripRoutes.days, overnightOptions, can, trip])
/**
* The stops of a day as the road trip counts them, in the order it drives them.
@@ -846,38 +878,52 @@ export function useTripPlanner() {
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 stop of day.stops) if (!stop.automaticNight) 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)
for (const stop of day.stops) if (!stop.automaticNight) 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
if (!roadtripSettings.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],
[roadtripSettings.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>)
if (!can('day_edit', trip) || !roadtripPreferencesState.ready) return
const userId = useAuthStore.getState().user?.id
if (!userId) return
const preferences = await roadtripPreferencesRepo.update(tripId, { [key]: value })
publishRoadtripPreferences(userId, tripId, preferences)
} catch {
toast.error(t('places.saveError'))
}
}, [updateSettings, toast, t])
}, [tripId, trip, can, roadtripPreferencesState.ready, toast, t])
const saveStopDraft = useCallback(async ({ stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number }) => {
if (!stopDraft) return
const { poi, dayId, position } = stopDraft
try {
if (stopDraft.editing) {
await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: stopType, duration_minutes: dwellMinutes })
if (stopDraft.editing.accommodationId) {
await accommodationsApi.delete(tripId, stopDraft.editing.accommodationId)
await loadAccommodations()
}
updateRouteForDay(dayId)
setStopDraft(null)
return
}
const place = await tripActions.addPlace(tripId, {
name: poi.name,
lat: poi.lat,
@@ -910,19 +956,8 @@ export function useTripPlanner() {
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore])
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations])
/**
* Saves a corridor hit as somewhere to sleep rather than as a pause on the drive.
*
* Three writes and deliberately not one transaction: the place, its day, and the row in
* day_accommodations. A hotel keeps its number in the chain — it is where the day ends,
* not something the drive passes through — so it gets no `stop_type` and no dwell.
*
* The check-out day is the traveller's answer, defaulting to the day after. Both times
* are optional and go through as empty when nobody filled them in: a hotel found on a
* map has no idea when its reception opens, and the server stores null.
*/
const saveStopDraftAsNight = useCallback(async ({ endDayId, checkIn, checkOut }: {
endDayId: number
checkIn: string
@@ -931,6 +966,16 @@ export function useTripPlanner() {
if (!stopDraft) return
const { poi, dayId, position } = stopDraft
try {
if (stopDraft.editing) {
const booking = { place_id: stopDraft.editing.placeId, start_day_id: dayId, end_day_id: endDayId, check_in: checkIn || null, check_out: checkOut || null }
if (stopDraft.editing.accommodationId) await accommodationsApi.update(tripId, stopDraft.editing.accommodationId, booking)
else await accommodationsApi.create(tripId, booking)
await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel' })
await loadAccommodations()
updateRouteForDay(dayId)
setStopDraft(null)
return
}
const place = await tripActions.addPlace(tripId, {
name: poi.name,
lat: poi.lat,
@@ -939,6 +984,7 @@ export function useTripPlanner() {
website: poi.website || undefined,
phone: poi.phone || undefined,
osm_id: poi.osm_id,
stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel',
})
if (place?.id) {
const plan = reanchorAfterInsert(
@@ -1060,6 +1106,20 @@ export function useTripPlanner() {
*/
const [stayDraft, setStayDraft] = useState<{ placeId: number; name: string; minutes: number | null; arrival: string | null } | null>(null)
const setRoadtripEndDay = useCallback(async (stop: RoadtripStop) => {
if (!dailyTimesActive || !can('day_edit', trip)) return
try {
const manual = dayBoundaries.boundaries.find(b => b.to_assignment_id === null && b.from_assignment_id === stop.assignmentId)
if (manual) {
await dayBoundaries.save(manual.day_number, null)
if (!stop.endDay) return
}
await tripActions.setAssignmentEndDay(tripId, stop.ownerDayId, stop.assignmentId, !stop.endDay)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
}, [dailyTimesActive, can, trip, tripActions, tripId, toast, t, dayBoundaries.boundaries, dayBoundaries.save])
/**
* How long the traveller stays at one stop.
*
@@ -1193,11 +1253,33 @@ export function useTripPlanner() {
* 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 automaticPoints = useAutomaticDayPoints(roadtripRoutes, collapsedRoadtripDays)
const focusRoadtripPoint = useCallback((lat: number, lng: number) => {
refuel.close()
routeAlternatives.close()
automaticPoints.focusPoint(lat, lng)
}, [refuel, routeAlternatives, automaticPoints])
const roadtripMapVias = automaticPoints.markers
const dayBoundaryControls = useMemo<DayBoundaryControls | undefined>(() => {
if (!dayBoundaries.editable || !can('day_edit', trip) || !roadtripRoutes.boundaryPath?.length) return undefined
return {
path: roadtripRoutes.boundaryPath,
hint: t('roadtrip.window.dragHint'),
move: async (day, boundary) => {
const next = dayBoundaries.boundaries.filter(b => b.day_number !== day)
if (boundary) next.push(boundary)
const issue = roadtripRoutes.validateBoundaries?.(next)
if (boundary && issue) { toast.error(t(`roadtrip.window.${issue}`)); return false }
try { return await dayBoundaries.save(day, boundary) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')); return false }
},
}
}, [dayBoundaries.editable, dayBoundaries.boundaries, dayBoundaries.save, can, trip, roadtripRoutes, t, toast])
const mapFocusPoints = useMemo<[number, number][]>(
() => (refuel.offered.length
? refuel.offered.map(p => [p.lat, p.lng] as [number, number])
: alternativeFocusPoints),
[refuel.offered, alternativeFocusPoints],
: alternativeFocusPoints.length ? alternativeFocusPoints : automaticPoints.focusPoints ?? alternativeFocusPoints),
[refuel.offered, alternativeFocusPoints, automaticPoints.focusPoints],
)
/** Asks the router for other ways of driving one leg of one day. */
@@ -1212,8 +1294,8 @@ export function useTripPlanner() {
}
// The vias on THIS leg, so the road currently driven is offered alongside the
// router's own suggestions rather than being missing from its own picker.
const dayIndex = day.stops.indexOf(from)
const legVias = (roadtripVias.byDay[dayId] ?? [])
const dayIndex = from.ownerIndex
const legVias = (roadtripVias.byDay[from.ownerDayId] ?? [])
.filter(v => v.after_order_index === dayIndex)
.sort((a, b) => a.sequence - b.sequence)
routeAlternatives.ask(dayId, legIndex, from, to, routeProfile, legVias)
@@ -1237,7 +1319,7 @@ export function useTripPlanner() {
if (alt.direct || !alt.divergence) {
const day = roadtripRoutes.days.find(d => d.dayId === open.dayId)
const stop = day?.stops[open.index]
const dayIndex = stop && day ? day.stops.indexOf(stop) : -1
const dayIndex = stop?.ownerIndex ?? -1
// Clearing the leg in one write. One delete per via meant a full trip re-route
// between each of them, so undoing a detour with three vias drew three routes.
//
@@ -1247,7 +1329,7 @@ export function useTripPlanner() {
// request failed, not even the reload ran to contradict them.
if (dayIndex >= 0) {
try {
await roadtripVias.addMany(open.dayId, [], [dayIndex])
await roadtripVias.addMany(stop!.ownerDayId, [], [dayIndex])
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
return
@@ -1382,16 +1464,26 @@ export function useTripPlanner() {
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 }))
const line = (dry.inboundLine ?? day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng }))
if (line.length < 2) return
const vehicle = roadtripSettings.roadtrip_vehicle
const refuelTypes = refuelStopTypeFor(vehicle === 'electric' || vehicle === 'combustion' ? vehicle : null)
let fromAlongKm = 0
let drivenKm = 0
for (let i = 0; i <= dry.legIndex; i++) {
if (refuelTypes.includes(day.stops[i]?.stopType as 'fuel' | 'charging')) fromAlongKm = drivenKm
const leg = day.legs[i]
if (leg?.mode === 'driving') drivenKm += (leg.distance ?? 0) / 1000
}
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 })),
fromAlongKm,
)
}, [roadtripRoutes.days, refuel])
}, [roadtripRoutes.days, refuel, roadtripSettings.roadtrip_vehicle])
/**
* Accepting one hands it to the same popup a corridor hit goes through.
@@ -1409,7 +1501,13 @@ export function useTripPlanner() {
// Before the stop the tank would have run out on, which is the leg the dry point
// names. A station reached after the day's last stop is tomorrow's problem, and
// clamping it onto the final leg would re-route the arrival through it.
const at = Math.min(dry.legIndex + 1, day.stops.length - 1)
let drivenKm = 0
const stationLeg = day.legs.findIndex(leg => {
if (leg?.mode !== 'driving') return false
drivenKm += (leg.distance ?? 0) / 1000
return poi.alongKm <= drivenKm
})
const at = dry.inboundLine ? -dry.legIndex - 1 : Math.min((stationLeg >= 0 ? stationLeg : 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 —
@@ -1418,7 +1516,7 @@ export function useTripPlanner() {
const anchor = day.stops[at]
if (!anchor) return
refuel.close()
setStopDraft({ poi, dayId: anchor.ownerDayId, position: anchor.ownerIndex, dayNumber: day.dayNumber })
setStopDraft({ poi, ...roadtripInsertion(day, at)!, dayNumber: day.dayNumber })
}, [roadtripRoutes.days, refuel, can, trip])
/** Removing a via lets the drive take the direct road again. */
@@ -1451,10 +1549,11 @@ export function useTripPlanner() {
if (!hit || !day) return
const at = projectOntoRoute({ lat, lng }, roadtripCorridor.search.spine)
if (!at || at.offRouteKm > roadtripCorridor.widthKm) return
const insert = roadtripInsertion(day, insertIndexForAlong(roadtripCorridor.stopsAlongKm, at.alongKm))
if (!insert) return
setStopDraft({
poi: hit,
dayId: day.dayId,
position: insertIndexForAlong(roadtripCorridor.stopsAlongKm, at.alongKm),
...insert,
dayNumber: day.dayNumber,
})
}, [roadtripCorridor, can, trip])
@@ -1464,11 +1563,20 @@ export function useTripPlanner() {
if (!stopDraft) return
const { poi, dayId, position } = stopDraft
setStopDraft(null)
if (stopDraft.editing) {
const place = places.find(place => place.id === stopDraft.editing?.placeId)
if (place) {
setEditingPlace(place)
setEditingAssignmentId(resolvePoolAssignmentId(assignments, place.id))
setShowPlaceForm(true)
}
return
}
// Carries the kind and the dwell the popup had already worked out. Leaving them
// behind is what turned a fuel stop into a numbered destination on the way to the
// full form, silently and in every total.
openAddPlaceFromPoi(poi, dayId, position, stop ?? null)
}, [stopDraft, openAddPlaceFromPoi])
}, [stopDraft, openAddPlaceFromPoi, places, assignments])
/**
* A place on this trip that came from the same OSM object.
@@ -1477,7 +1585,7 @@ export function useTripPlanner() {
* would be the quickest way to add one petrol station twice.
*/
const stopDraftDuplicate = useMemo(() => {
if (!stopDraft?.poi.osm_id) return null
if (stopDraft?.editing || !stopDraft?.poi.osm_id) return null
return places.find(p => p.osm_id === stopDraft.poi.osm_id)?.name ?? null
}, [stopDraft, places])
@@ -1553,11 +1661,38 @@ export function useTripPlanner() {
// place's lone assignment to hydrate & persist its times; with 0 or 2+
// assignments the time is ambiguous and the modal hides the fields (#1247).
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') {
const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)
const entry = Object.entries(assignments).find(([, visits]) => visits.some(visit => visit.id === visitId))
if (entry) {
const dayId = Number(entry[0])
const visit = entry[1].find(visit => visit.id === visitId)!
const stay = tripAccommodations.find(stay => stay.place_id === place.id && stay.start_day_id === dayId)
const category = place.stop_type ?? (stay ? 'hotel' : '')
const routedDay = roadtripRoutes.days.find(day => day.stops.some(stop => stop.assignmentId === visitId))
const arrivalTime = routedDay?.schedule.entries[routedDay.stops.findIndex(stop => stop.assignmentId === visitId)]?.arrival ?? null
setStopDraft({
poi: { osm_id: place.osm_id ?? '', name: place.name, lat: place.lat, lng: place.lng, category, poi_type: category, address: place.address ?? null, website: place.website ?? null, phone: place.phone ?? null, opening_hours: null, cuisine: null, source: 'trek', offRouteKm: 0, alongKm: 0 },
arrivalTime, dayId, dayNumber: days.find(day => day.id === dayId)?.day_number ?? 0, position: visit.order_index ?? 0,
editing: { placeId: place.id, stopType: place.stop_type ?? (stay ? 'hotel' : null), dwellMinutes: place.duration_minutes ?? 30, accommodationId: stay?.id, checkIn: stay?.check_in ?? '', checkOut: stay?.check_out ?? '' },
...(isOvernightCategory(category) ? { overnight: { ...overnightOptions(dayId), ...(stay ? { defaultEndDayId: stay.end_day_id } : {}) } } : {}),
})
return
}
}
setEditingPlace(place)
setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id))
setPlaceFormDayId(null)
setShowPlaceForm(true)
}, [assignments])
}, [assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
const editRoadtripStay = useCallback((draft: NonNullable<typeof stayDraft>) => {
const stay = tripAccommodations.find(stay => stay.place_id === draft.placeId && stay.check_out)
const place = places.find(place => place.id === draft.placeId)
const visit = stay && assignments[String(stay.start_day_id)]?.find(visit => visit.place_id === draft.placeId)
if (stay && place && visit) openPlaceEditor(place, visit.id)
else setStayDraft(draft)
}, [tripAccommodations, places, assignments, openPlaceEditor])
const handleDeletePlace = useCallback((placeId) => {
setDeletePlaceId(placeId)
@@ -1729,11 +1864,14 @@ export function useTripPlanner() {
// pinned behind instead of being shifted arithmetically. Without this the
// day's detours stayed on their old numbers and the drive quietly took a
// different road, persisted and visible to every collaborator.
const visible = new Set(orderedIds)
let nextVisible = 0
const completeOrder = prevIds.map(id => visible.has(id) ? orderedIds[nextVisible++] : id)
const stopIdsBefore = roadtripStopsOf(dayId).map(a => a.id)
const stopIdsAfter = orderedIds.filter(id => stopIdsBefore.includes(id))
const stopIdsAfter = completeOrder.filter(id => stopIdsBefore.includes(id))
const plan = reanchorByStopOrder(roadtripVias.byDay[dayId] ?? [], stopIdsBefore, stopIdsAfter)
try {
tripActions.reorderAssignments(tripId, dayId, orderedIds)
tripActions.reorderAssignments(tripId, dayId, completeOrder)
.then(async () => {
if (plan.vias.length || plan.remove.length) await roadtripVias.reanchor(dayId, plan)
const capturedDayId = dayId
@@ -1984,6 +2122,16 @@ export function useTripPlanner() {
}
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
const selectedRoadtripStops = roadtripRoutes.days.flatMap(day => day.stops).filter(stop =>
!stop.automaticNight && (selectedAssignmentId ? stop.assignmentId === selectedAssignmentId : stop.placeId === selectedPlaceId),
)
const endDayStop = selectedRoadtripStops.length === 1 ? selectedRoadtripStops[0] : undefined
const roadtripEndDay = roadtripActive && dailyTimesActive && can('day_edit', trip) && endDayStop && endDayStop.assignmentId > 0
? { active: !!endDayStop.endDay || dayBoundaries.boundaries.some(b => b.to_assignment_id === null && b.from_assignment_id === endDayStop.assignmentId), onToggle: () => setRoadtripEndDay(endDayStop) }
: undefined
const roadtripStay = roadtripActive && selectedPlace
? { minutes: endDayStop ? endDayStop.dwellMinutes : selectedPlace.duration_minutes ?? null, onEdit: can('place_edit', trip) ? () => editRoadtripStay({ placeId: selectedPlace.id, name: selectedPlace.name, minutes: selectedPlace.duration_minutes ?? null, arrival: null }) : undefined }
: undefined
// Build placeId → order-number map from the selected day's assignments
const dayOrderMap = useMemo(() => {
@@ -2021,7 +2169,7 @@ export function useTripPlanner() {
return {
tripId, navigate, toast, t, language, settings, placesPhotosEnabled,
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
@@ -2041,11 +2189,12 @@ export function useTripPlanner() {
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
setRoadtripStopKind,
setRoadtripStopFill,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
roadtripSettingsLoading: !roadtripPreferencesState.ready && !roadtripPreferencesState.failed,
saveRoadtripLimit: roadtripPreferencesState.ready && can('day_edit', trip) ? saveRoadtripLimit : undefined,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, dayBoundaryControls, resetDayBoundaries,
refuel, askRefuel, acceptRefuel,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
stayDraft, setStayDraft, setRoadtripStay,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint,
stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay,
highlightedAlternative, setHighlightedAlternative,
moveRoadtripStopToDay,
dropPoiOnRoute,
+52
View File
@@ -0,0 +1,52 @@
import 'fake-indexeddb/auto'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { assignmentRepo } from './assignmentRepo'
import { offlineDb, clearAll } from '../db/offlineDb'
import { saveAssignmentEndDay } from '../api/assignmentEndDay'
import { isEffectivelyOffline } from '../sync/networkMode'
import { mutationQueue } from '../sync/mutationQueue'
import { apiClient } from '../api/client'
import type { Assignment, Day } from '../types'
vi.mock('../api/assignmentEndDay', () => ({ saveAssignmentEndDay: vi.fn() }))
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => true) }))
vi.mock('../sync/authGate', () => ({ isAuthed: () => true }))
vi.mock('../api/client', () => ({ apiClient: { request: vi.fn() } }))
const assignment = { id: 7, day_id: 1, place_id: 2, order_index: 0, assignment_time: '07:00', place: { id: 2, name: 'Berlin' } } as Assignment
beforeEach(async () => {
await clearAll()
vi.clearAllMocks()
vi.mocked(isEffectivelyOffline).mockReturnValue(true)
await offlineDb.days.put({ id: 1, trip_id: 9, day_number: 1, assignments: [assignment] } as Day)
})
describe('assignment day-end persistence', () => {
it('stores the offline flag with a replayable write and preserves manual time', async () => {
await assignmentRepo.setEndDay(9, assignment, true)
expect((await offlineDb.days.get(1))?.assignments?.[0]).toMatchObject({ end_day: true, assignment_time: '07:00' })
expect(await offlineDb.mutationQueue.toArray()).toEqual([expect.objectContaining({
url: '/trips/9/assignments/7/end-day', method: 'PUT', body: { end_day: true }, resource: 'assignments',
})])
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
vi.mocked(apiClient.request).mockResolvedValue({ data: { assignment: { ...assignment, end_day: true } } })
await mutationQueue.flush()
expect(await offlineDb.mutationQueue.count()).toBe(0)
expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBe(true)
})
it('saves and clears through the online API', async () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
vi.mocked(saveAssignmentEndDay).mockResolvedValue({ ...assignment, end_day: false })
await assignmentRepo.setEndDay(9, assignment, false)
expect(saveAssignmentEndDay).toHaveBeenCalledWith(9, 7, { end_day: false })
expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBe(false)
})
it('keeps the cached visit unchanged after a rejected save', async () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
vi.mocked(saveAssignmentEndDay).mockRejectedValue(new Error('Denied'))
await expect(assignmentRepo.setEndDay(9, assignment, true)).rejects.toThrow('Denied')
expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBeUndefined()
})
})
+26
View File
@@ -0,0 +1,26 @@
import { saveAssignmentEndDay } from '../api/assignmentEndDay'
import { offlineDb } from '../db/offlineDb'
import { cacheAssignment } from '../db/cacheAssignment'
import { generateUUID, mutationQueue } from '../sync/mutationQueue'
import { isEffectivelyOffline } from '../sync/networkMode'
import type { Assignment } from '../types'
export const assignmentRepo = {
async setEndDay(tripId: number | string, assignment: Assignment, endDay: boolean): Promise<Assignment> {
if (!isEffectivelyOffline()) {
const saved = await saveAssignmentEndDay(tripId, assignment.id, { end_day: endDay })
await cacheAssignment(saved)
return saved
}
const updated = { ...assignment, end_day: endDay }
await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => {
await mutationQueue.enqueue({
id: generateUUID(), tripId: Number(tripId), method: 'PUT',
url: `/trips/${tripId}/assignments/${assignment.id}/end-day`,
body: { end_day: endDay }, resource: 'assignments', entityId: assignment.id,
})
await cacheAssignment(updated)
})
return updated
},
}
+17
View File
@@ -0,0 +1,17 @@
import { chargingInfoSchema, type ChargingInfo } from '@trek/shared'
import { apiClient } from '../api/client'
import { isEffectivelyOffline } from '../sync/networkMode'
const pending = new Map<string, { until: number; value: Promise<ChargingInfo> }>()
export const chargingRepo = {
async read(tripId: number, placeId: number) {
if (isEffectivelyOffline()) throw new Error('Offline')
const key = `${tripId}/${placeId}`
const cached = pending.get(key)
if (cached && cached.until > Date.now()) return cached.value
if (pending.size > 200) pending.clear()
const value = apiClient.get(`/trips/${tripId}/roadtrip/charging/${placeId}`).then(reply => chargingInfoSchema.parse(reply.data))
pending.set(key, { until: Date.now() + 55000, value })
return value
},
}
+17
View File
@@ -0,0 +1,17 @@
import { googleRoutePreviewSchema, type GoogleRouteImport } from '@trek/shared'
import { apiClient } from '../api/client'
import { isEffectivelyOffline } from '../sync/networkMode'
export const googleRouteRepo = {
async preview(url: string, signal: AbortSignal) {
if (isEffectivelyOffline()) throw new Error('Import requires a connection')
const reply = await apiClient.post('/roadtrip/google-maps-preview', { url }, { signal, timeout: 120000 })
return googleRoutePreviewSchema.parse(reply.data)
},
async append(tripId: number, input: GoogleRouteImport, idempotencyKey: string) {
if (isEffectivelyOffline()) throw new Error('Import requires a connection')
await apiClient.post(`/trips/${tripId}/roadtrip/google-maps-import`, input, {
headers: { 'X-Idempotency-Key': idempotencyKey },
})
},
}
+9
View File
@@ -0,0 +1,9 @@
import { roadtripHazardsSchema } from '@trek/shared'
import { apiClient } from '../api/client'
export const roadtripHazardsRepo = {
async read(tripId: number, signal: AbortSignal) {
const fetched = await apiClient.get(`/trips/${tripId}/roadtrip/hazards`, { signal })
return roadtripHazardsSchema.parse(fetched.data)
},
}
@@ -0,0 +1,44 @@
import 'fake-indexeddb/auto'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { roadtripPreferencesRepo } from './roadtripPreferencesRepo'
import { offlineDb, clearAll } from '../db/offlineDb'
import { isEffectivelyOffline } from '../sync/networkMode'
import { mutationQueue } from '../sync/mutationQueue'
import { apiClient } from '../api/client'
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => true) }))
vi.mock('../sync/authGate', () => ({ isAuthed: () => true }))
vi.mock('../api/client', () => ({ apiClient: { get: vi.fn(), put: vi.fn(), request: vi.fn() } }))
beforeEach(async () => {
await clearAll()
vi.clearAllMocks()
vi.mocked(isEffectivelyOffline).mockReturnValue(true)
await offlineDb.roadtripPreferences.put({ tripId: 9, preferences: { roadtrip_day_start: '08:00', roadtrip_day_end: '18:00', roadtrip_range_km: 120 } })
})
describe('trip driving preferences offline cache', () => {
it('overlays queued changes and replays into the same trip', async () => {
await roadtripPreferencesRepo.update(9, { roadtrip_range_km: 200 })
expect((await roadtripPreferencesRepo.read(9)).roadtrip_range_km).toBe(200)
expect((await offlineDb.roadtripPreferences.get(9))?.preferences.roadtrip_range_km).toBe(120)
expect(await offlineDb.mutationQueue.toArray()).toEqual([expect.objectContaining({ tripId: 9, url: '/trips/9/roadtrip/preferences', method: 'PUT', body: { roadtrip_range_km: 200 } })])
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
vi.mocked(apiClient.request).mockResolvedValue({ data: { tripId: 9, preferences: { roadtrip_range_km: 200 } } })
await mutationQueue.flush()
expect(await offlineDb.mutationQueue.count()).toBe(0)
expect((await offlineDb.roadtripPreferences.get(9))?.preferences.roadtrip_range_km).toBe(200)
expect(await offlineDb.roadtripPreferences.get(10)).toBeUndefined()
})
it('refuses an uncached trip and an invalid merged day window', async () => {
await expect(roadtripPreferencesRepo.read(10)).rejects.toThrow('not available offline')
await expect(roadtripPreferencesRepo.update(9, { roadtrip_day_end: '07:00' })).rejects.toThrow('Day end')
expect(await offlineDb.mutationQueue.count()).toBe(0)
})
it('keeps cached preferences on a rejected online update', async () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
vi.mocked(apiClient.put).mockRejectedValue(new Error('Denied'))
await expect(roadtripPreferencesRepo.update(9, { roadtrip_range_km: 999 })).rejects.toThrow('Denied')
expect((await offlineDb.roadtripPreferences.get(9))?.preferences.roadtrip_range_km).toBe(120)
})
})
@@ -0,0 +1,55 @@
import { roadtripPreferencesResponseSchema, roadtripPreferencesUpdateSchema, type RoadtripPreferences } from '@trek/shared'
import { apiClient } from '../api/client'
import { offlineDb } from '../db/offlineDb'
import { generateUUID, mutationQueue } from '../sync/mutationQueue'
import { isEffectivelyOffline } from '../sync/networkMode'
import { onlineThenCache } from './withOfflineFallback'
export async function cachedRoadtripPreferences(tripId: number, excludeMutation?: string): Promise<RoadtripPreferences> {
const cached = await offlineDb.roadtripPreferences.get(tripId)
if (!cached) throw new Error('Driving settings are not available offline.')
const pending = await offlineDb.mutationQueue.where('tripId').equals(tripId).sortBy('createdAt')
return pending.reduce((preferences, mutation) => {
if (mutation.resource !== 'roadtripPreferences' || mutation.id === excludeMutation || !['pending', 'syncing'].includes(mutation.status)) return preferences
return { ...preferences, ...roadtripPreferencesUpdateSchema.parse(mutation.body) }
}, cached.preferences)
}
const writes = new Map<number, Promise<RoadtripPreferences>>()
export const roadtripPreferencesRepo = {
async read(tripId: number): Promise<RoadtripPreferences> {
const cacheName = offlineDb.name
return onlineThenCache(async () => {
const fetched = await apiClient.get(`/trips/${tripId}/roadtrip/preferences`)
if (offlineDb.name !== cacheName) throw new Error('Account changed.')
const saved = roadtripPreferencesResponseSchema.parse(fetched.data)
await offlineDb.roadtripPreferences.put(saved)
return cachedRoadtripPreferences(tripId)
}, () => cachedRoadtripPreferences(tripId))
},
update(tripId: number, patch: RoadtripPreferences): Promise<RoadtripPreferences> {
const cacheName = offlineDb.name
const save = (writes.get(tripId) ?? Promise.resolve({})).catch(() => ({})).then(async () => {
if (offlineDb.name !== cacheName) throw new Error('Account changed.')
const validated = roadtripPreferencesUpdateSchema.parse(patch)
if (!isEffectivelyOffline()) {
const saved = await apiClient.put(`/trips/${tripId}/roadtrip/preferences`, validated)
if (offlineDb.name !== cacheName) throw new Error('Account changed.')
await offlineDb.roadtripPreferences.put(roadtripPreferencesResponseSchema.parse(saved.data))
} else {
const next = { ...await cachedRoadtripPreferences(tripId), ...validated }
if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) throw new Error('Day end must be later than day start.')
await mutationQueue.enqueue({
id: generateUUID(), tripId, method: 'PUT', url: `/trips/${tripId}/roadtrip/preferences`,
body: validated, resource: 'roadtripPreferences', entityId: tripId,
})
}
return cachedRoadtripPreferences(tripId)
})
writes.set(tripId, save)
void save.finally(() => { if (writes.get(tripId) === save) writes.delete(tripId) }).catch(() => {})
return save
},
}

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