feat(roadtrip): add daily scheduling and configurable day endings

This commit is contained in:
Maurice
2026-09-11 22:28:02 +02:00
parent 4f8605b529
commit 491cfccc56
124 changed files with 3512 additions and 354 deletions
@@ -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])) : [], [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 })
} }} />
})}</>
}
+23 -2
View File
@@ -248,9 +248,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()
})
@@ -680,6 +680,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')
+66 -34
View File
@@ -44,6 +44,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 +104,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 +252,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 +665,7 @@ export const MapView = memo(function MapView({
onViewportChange,
tripId,
routeVias = [],
dayBoundaryControls,
accessLines = [],
onPoiDropOnRoute,
onRouteClick,
@@ -712,11 +740,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 +949,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 +1031,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 +1153,25 @@ export const MapView = memo(function MapView({
roadRoutes={transportRoutes}
/>
{poiMarkers}
<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} />
@@ -1162,7 +1191,10 @@ export const MapView = memo(function MapView({
</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,
@@ -1389,6 +1389,30 @@ describe('MapViewGL', () => {
expect(glMarkers.created[1].element.innerHTML).toContain('#6b7280')
})
it('clusters roadtrip stations and zooms before offering overlapping stations individually', async () => {
loadOnAttach()
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)
})
it('FE-COMP-MAPVIEWGL-043: plugin markers render as tone dots with a text-only popup', async () => {
loadOnAttach()
const markers: PluginMapMarker[] = [
+110 -36
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,11 @@ 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 { 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 +126,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
@@ -197,7 +204,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 +616,7 @@ export function MapViewGL({
dayPlaces = NO_PLACES,
tripId,
routeVias = NO_ROUTE_VIAS,
dayBoundaryControls,
accessLines = NO_ACCESS_LINES,
route = null,
routeColors = null,
@@ -651,6 +659,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 +684,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
@@ -1781,18 +1790,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 +1830,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])) : 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 +1918,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 +1949,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(() => {
@@ -2230,7 +2301,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,48 @@
import { expect, it, vi } from 'vitest'
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
import type { RoadtripStop } from '../Roadtrip/useRoadtripRoutes'
function setup() {
const el = document.createElement('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('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,109 @@
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!
const handle = el.querySelector<HTMLElement>('[data-night-pause]') ?? 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)
}
}
+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:pointer;">${moon}<span>${escapeHtml(String(pause.day))}</span></span>`
}
@@ -0,0 +1,43 @@
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('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)
})
})
+55
View File
@@ -0,0 +1,55 @@
import type { Poi } from './poiCategories'
export const POI_CLUSTER_RADIUS = 48
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 }): 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]))
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
}
@@ -35,6 +35,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 +135,8 @@ interface TripMember {
}
interface PlaceInspectorProps {
roadtripEndDay?: EndDayControlProps
roadtripStay?: RoadtripStayControl
place: Place | null
categories: Category[]
/** 'trip' (default) keeps every existing trip-planner behaviour byte-identical;
@@ -179,7 +183,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,
}: 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 +421,7 @@ export default function PlaceInspector({
)}
{/* Description / Summary */}
{(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,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,70 @@
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'
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 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>
)
})}
<p id="roadtrip-window-hint" role={invalid ? 'alert' : undefined} className={`text-caption ${invalid ? 'text-danger' : 'text-content-faint'}`}>
{t(invalid ? 'roadtrip.window.invalid' : 'roadtrip.window.hint')}
</p>
<fieldset className="flex 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>
<p id="roadtrip-end-mode-hint" className="text-caption text-content-faint">{t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)}</p>
<p className="text-caption text-content-faint">{t('roadtrip.window.endHereHint')}</p>
{!!dayWindow(shownStart, shownEnd) && <p className="text-caption text-content-faint">{t('roadtrip.window.dragHint')}</p>}
</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-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 disabled:opacity-60">
<span className="flex flex-1 items-center gap-2 text-caption font-medium text-content"><Moon size={15} 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>
)
}
@@ -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)
@@ -1,6 +1,6 @@
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 +14,8 @@ 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 { dayWindow } from './dayWindow'
/**
* Everything that decides when the rail speaks up about the driving.
@@ -248,7 +250,8 @@ 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 }: {
onResetDayBoundaries?: () => Promise<void>
/**
* Persists one setting. Absent leaves the dialog read-only.
*
@@ -265,6 +268,7 @@ export default function RoadtripLimitsCard({ onSave }: {
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 +339,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 +352,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
@@ -426,7 +432,7 @@ 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">
<Panel icon={Clock} title={t('roadtrip.limit.sectionDriving')} note={t('roadtrip.limit.hint')}>
@@ -450,6 +456,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 +477,155 @@ export default function RoadtripLimitsCard({ onSave }: {
/>
))}
</Panel>
</div>
<Panel icon={Route} title={t('roadtrip.line.section')} note={t('roadtrip.line.hint')}>
<div className="flex 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
@@ -490,134 +637,6 @@ export default function RoadtripLimitsCard({ onSave }: {
/>
</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>
@@ -27,8 +27,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
@@ -1203,13 +1205,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 +1227,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>
@@ -1294,7 +1290,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
@@ -1355,6 +1352,20 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
* still stored on the day it set off from (`nightSpill.ts`).
*/
const renderStop = (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 +1435,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}
@@ -1556,7 +1567,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 +1725,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 +1762,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 +1784,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}
@@ -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-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 text-start enabled:hover:bg-surface-hover disabled:cursor-default">
<span className="flex items-center gap-2 text-caption font-medium uppercase text-content"><Clock size={15} className="shrink-0 text-content-faint" aria-hidden />{t('roadtrip.stop.stayShort')}</span>
{' '}<span className="flex shrink-0 items-center gap-2 text-caption font-medium text-content">
{formatDurationShort((stay.minutes ?? 0) * 60)}{stay.onEdit && <Pencil size={12} className="text-content-faint" aria-hidden />}
</span>
</button>}
</div>
)
}
@@ -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,298 @@
import { describe, expect, it } from 'vitest'
import { dayWindow, planDayWindow, roadtripInsertion } from './dayWindow'
import type { RoadtripStop, RoutedLeg } from './useRoadtripRoutes'
const hours = { start: 480, end: 1080 }
const labels = { start: 'Continue', end: 'End of day' }
const stop = (id: number, patch: Partial<RoadtripStop> = {}): RoadtripStop => ({
assignmentId: id, placeId: id, ownerDayId: 1, ownerIndex: id - 1,
lat: 0, lng: id - 1, name: `Stop ${id}`, time: null, dwellMinutes: 0,
legMode: null, incomingLegMode: null, stopType: null, ...patch,
})
const day = (number: number, stops: RoadtripStop[] = []) => ({
dayId: number, dayNumber: number, date: `2026-09-${String(number + 10).padStart(2, '0')}`, title: null, stops,
})
const leg = (minutes: number, from: RoadtripStop, to: RoadtripStop): RoutedLeg => ({
line: [[from.lat, from.lng], [to.lat, to.lng]], vias: [],
seg: {
from: [from.lat, from.lng], to: [to.lat, to.lng], mid: [0, (from.lng + to.lng) / 2],
distance: minutes * 1000, duration: minutes * 60, mode: 'driving',
distanceText: '', drivingText: '', walkingText: '',
},
})
const calculate = (stops: RoadtripStop[], minutes: number[] = [720], extra = [day(2)]) =>
planDayWindow([day(1, stops), ...extra], hours, (a, b) => leg(minutes[stops.indexOf(a)] ?? 60, a, b), 'metric', labels)
describe('daily travel window', () => {
it('pulls an unpinned visit back into day one and ends after its full stay', () => {
const stops = [stop(1, { time: '07:00' }), stop(2, { dwellMinutes: 90 }), stop(3)]
const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(720, a, b)
const automatic = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels)
expect(automatic.chains[0].stops).not.toContain(stops[1])
const manual = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels,
[{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }])
expect(manual.issue).toBeNull()
expect(manual.chains[0].stops).toContain(stops[1])
expect(manual.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '19:00', '20:30'])
expect(manual.chains[1].schedule.entries[0].arrival).toBe('08:00')
expect(stops[0].time).toBe('07:00')
})
it.each([0.25, 0.9])('moves the cut in either direction and preserves the whole drive (%s)', fraction => {
const stops = [stop(1), stop(2)]
const manual = planDayWindow([day(1, stops)], hours, (a, b) => leg(720, a, b), 'metric', labels,
[{ day_number: 1, from_assignment_id: 1, to_assignment_id: 2, fraction }])
expect(manual.issue).toBeNull()
const end = manual.chains[0].stops[1]
expect(end.lng).toBeCloseTo(fraction)
expect(end.automaticNight).toMatchObject({ manual: true, position: fraction })
const start = manual.chains[1].stops[0]
expect(manual.legFor(stops[0], end)!.seg.duration + manual.legFor(start, stops[1])!.seg.duration).toBeCloseTo(720 * 60)
})
it('does not pull a visit with a fixed time out of its stored day', () => {
const pinned = stop(2, { ownerDayId: 2, time: '12:00' })
const manual = planDayWindow([day(1, [stop(1)]), day(2, [pinned])], hours, (a, b) => leg(60, a, b), 'metric', labels,
[{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }])
expect(manual.issue).toBe('conflict')
expect(pinned.time).toBe('12:00')
})
it('rejects crossed or disconnected manual boundaries', () => {
const stops = [stop(1), stop(2), stop(3)]
for (const boundaries of [
[{ day_number: 1, from_assignment_id: 1, to_assignment_id: 3, fraction: 0.5 }],
[{ day_number: 1, from_assignment_id: 3, to_assignment_id: null, fraction: 1 }, { day_number: 2, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }],
]) expect(planDayWindow([day(1, stops)], hours, (a, b) => leg(60, a, b), 'metric', labels, boundaries).issue).toBe('conflict')
})
it('mixes an explicit stop end with automatic cuts on later drives', () => {
const visited = stop(2, { endDay: true, dwellMinutes: 60 })
const planned = calculate([stop(1), visited, stop(3)], [120, 720], [])
expect(planned.issue).toBeNull()
expect(planned.chains.map(d => d.schedule.entries.map(e => e.arrival))).toEqual([
['08:00', '10:00', '11:00'], ['08:00', '18:00'], ['08:00', '10:00'],
])
expect(planned.chains[0].stops[2]).toMatchObject({ lat: visited.lat, lng: visited.lng })
expect(visited.endDay).toBe(true)
})
it('removing an explicit end restores the automatic schedule without adding a final night', () => {
const planned = calculate([stop(1), stop(2, { endDay: false }), stop(3, { endDay: true })], [120, 60], [])
expect(planned.chains).toHaveLength(1)
expect(planned.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00', '11:00'])
})
it('keeps manual times when an explicit end would move their day', () => {
const pinned = stop(2, { time: '12:00' })
const planned = calculate([stop(1, { endDay: true }), pinned], [60], [])
expect(planned.issue).toBe('conflict')
expect(pinned.time).toBe('12:00')
})
it('honors an earlier manual arrival on the following stored day', () => {
const first = stop(1, { endDay: true })
const next = stop(2, { time: '07:00', ownerDayId: 2 })
const planned = planDayWindow([day(1, [first]), day(2, [next])], hours, (a, b) => leg(60, a, b), 'metric', labels)
expect(planned.issue).toBeNull()
expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['06:00', '07:00'])
expect(next.time).toBe('07:00')
})
it('requires two valid increasing HH:mm values', () => {
expect(dayWindow('08:00', '18:00')).toEqual(hours)
expect(dayWindow('00:00', '00:01')).toEqual({ start: 0, end: 1 })
for (const pair of [[null, '18:00'], ['08:00', ''], ['8:00', '18:00'], ['24:00', '25:00'], ['18:00', '08:00'], ['08:00', '08:00'], [800, 1800]]) {
expect(dayWindow(...pair as [unknown, unknown])).toBeNull()
}
})
it('splits the drive at day end and resumes at the same point next morning', () => {
const first = stop(1)
const last = stop(2, { lng: 12 })
const planned = calculate([first, last])
expect(planned.issue).toBeNull()
const [today, tomorrow] = planned.chains
const end = today.stops[1]
const start = tomorrow.stops[0]
expect(today.schedule.entries[0].arrival).toBe('08:00')
expect(today.schedule.entries[1].arrival).toBe('18:00')
expect(end.lng).toBeCloseTo(10)
expect([start.lat, start.lng]).toEqual([end.lat, end.lng])
expect(tomorrow.schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00'])
expect(tomorrow.stops[1]).toBe(last)
expect(tomorrow.spills[0]).toMatchObject({ automatic: true, fromDayNumber: 1, at: 1 })
const before = planned.legFor(first, end)!
const after = planned.legFor(start, last)!
expect(before.seg.duration + after.seg.duration).toBe(720 * 60)
expect(before.seg.distance + after.seg.distance).toBe(720000)
expect(before.line[before.line.length - 1]).toEqual(after.line[0])
expect(first.time).toBeNull()
expect(last.ownerDayId).toBe(1)
})
it('ends at the last visited place after its stay when the next drive will not fit', () => {
const first = stop(1)
const visited = stop(2, { dwellMinutes: 60 })
const next = stop(3)
const route = [first, visited, next]
const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(a === first ? 480 : 120, a, b)
const planned = planDayWindow([day(1, route)], { ...hours, endMode: 'stop' }, lookup, 'metric', labels)
expect(planned.issue).toBeNull()
expect(planned.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '16:00', '17:00'])
expect(planned.chains[0].stops[2]).toMatchObject({ lat: visited.lat, lng: visited.lng })
expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00'])
expect(planned.legFor(planned.chains[1].stops[0], next)?.seg.duration).toBe(7200)
expect(visited.dwellMinutes).toBe(60)
expect(next.ownerDayId).toBe(1)
const alongRoute = planDayWindow([day(1, route)], hours, lookup, 'metric', labels)
expect(alongRoute.chains[0].schedule.entries[2].arrival).toBe('18:00')
expect(alongRoute.chains[1].schedule.entries[1].arrival).toBe('09:00')
})
it('reaches a last place exactly at the cutoff and still respects manual times', () => {
const first = stop(1, { time: '07:00' })
const next = stop(2)
const calculateAtStop = (minutes: number) => planDayWindow([day(1, [first, next])], { ...hours, endMode: 'stop' },
(a, b) => leg(minutes, a, b), 'metric', labels)
expect(calculateAtStop(660).chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '18:00'])
next.time = '19:00'
expect(calculateAtStop(720).chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '19:00'])
expect(first.time).toBe('07:00')
})
it('reports an indivisible drive that cannot fit rather than adding endless empty days', () => {
const planned = planDayWindow([day(1, [stop(1), stop(2)])], { ...hours, endMode: 'stop' },
(a, b) => leg(720, a, b), 'metric', labels)
expect(planned.issue).toBe('legTooLong')
expect(planned.chains).toEqual([])
expect(dayWindow('08:00', '18:00', 'stop')).toEqual({ ...hours, endMode: 'stop' })
expect(dayWindow('08:00', '18:00', 'unknown')).toEqual(hours)
})
it('starts at a manually pinned 07:00 despite an automatic 08:00 start', () => {
const first = stop(1, { time: '07:00' })
const planned = calculate([first, stop(2, { lng: 12 })])
expect(planned.chains[0].schedule.entries[0]).toMatchObject({ arrival: '07:00', anchored: true })
expect(planned.chains[0].stops[1].lng).toBeCloseTo(11)
expect(planned.chains[1].schedule.entries[1].arrival).toBe('09:00')
expect(first.time).toBe('07:00')
})
it('accounts for stays and recalculates the pause position without mutating places', () => {
const first = stop(1, { dwellMinutes: 120 })
const destination = stop(2, { lng: 12 })
const planned = calculate([first, destination])
expect(planned.chains[0].stops[1].lng).toBeCloseTo(8)
expect(planned.chains[1].schedule.entries[1].arrival).toBe('12:00')
const shortened = calculate([{ ...first, dwellMinutes: 0 }, destination])
expect(shortened.chains[0].stops[1].lng).toBeCloseTo(10)
const shortDrive = calculate([first, destination], [120])
expect(shortDrive.chains.flatMap(d => d.stops).some(s => s.automaticNight)).toBe(false)
expect(first.dwellMinutes).toBe(120)
})
it('does not move an arrival exactly at day end to tomorrow', () => {
const last = stop(2)
const planned = calculate([stop(1), last], [600])
expect(planned.chains[0].stops).toHaveLength(2)
expect(planned.chains[0].schedule.entries[1].arrival).toBe('18:00')
expect(planned.chains[1].stops).toEqual([])
})
it('continues across multiple nights and creates preview dates beyond the trip', () => {
const planned = calculate([stop(1), stop(2, { lng: 30 })], [1800], [])
expect(planned.chains.map(d => d.date)).toEqual(['2026-09-11', '2026-09-12', '2026-09-13'])
expect(planned.chains[2].schedule.entries[1].arrival).toBe('18:00')
expect(planned.chains[2].dayId).toBeLessThan(0)
expect(planned.chains.flatMap(d => d.stops).filter(s => !s.automaticNight)).toHaveLength(2)
})
it('keeps fixed appointments and reports an impossible schedule without partial relocation', () => {
const fixed = stop(2, { time: '10:00' })
const impossible = calculate([stop(1), fixed], [180])
expect(impossible.issue).toBe('conflict')
expect(impossible.chains).toEqual([])
expect(fixed.time).toBe('10:00')
const possible = calculate([stop(1), fixed], [60])
expect(possible.chains[0].schedule.entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
})
it('allows a manual late appointment to override the automatic end', () => {
const fixed = stop(2, { time: '19:00' })
const planned = calculate([stop(1), fixed], [660])
expect(planned.issue).toBeNull()
expect(planned.chains[0].schedule.entries[1].arrival).toBe('19:00')
expect(planned.chains[0].stops).toHaveLength(2)
})
it('keeps a pinned next-day start at 07:00 and leaves the pause point early enough', () => {
const a = stop(1)
const b = stop(2, { ownerDayId: 2, ownerIndex: 0, time: '07:00' })
const planned = planDayWindow([day(1, [a]), day(2, [b])], hours, (from, to) => leg(60, from, to), 'metric', labels)
expect(planned.issue).toBeNull()
expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['06:00', '07:00'])
})
it('does not silently move a fixed appointment onto a later date after a previous spill', () => {
const planned = calculate([stop(1), stop(2), stop(3, { time: '17:00' })], [720, 60])
expect(planned.issue).toBe('conflict')
})
it('pauses a long visit at its location and carries the remaining stay into tomorrow', () => {
const first = stop(1, { dwellMinutes: 660 })
const planned = calculate([first, stop(2)], [60])
expect(planned.chains[0].stops[1]).toMatchObject({ lat: first.lat, lng: first.lng })
expect(planned.chains[0].schedule.entries[0].departure).toBe('18:00')
expect(planned.chains[1].schedule.entries[0].departure).toBe('09:00')
expect(planned.chains[1].schedule.entries[1].arrival).toBe('10:00')
expect(first.dwellMinutes).toBe(660)
})
it('does not place a night pause halfway through a ferry crossing', () => {
const a = stop(1)
const b = stop(2)
const ferry = leg(720, a, b)
ferry.seg.mode = 'ferry'
const planned = planDayWindow([day(1, [a, b])], hours, () => ferry, 'metric', labels)
expect(planned.chains[0].stops).toHaveLength(2)
expect(planned.chains[0].schedule.entries[1].arrival).toBe('20:00')
})
it('does not invent times or points for missing routes', () => {
const planned = planDayWindow([day(1, [stop(1), stop(2)])], hours, () => undefined, 'metric', labels)
expect(planned.issue).toBe('incomplete')
expect(planned.chains).toEqual([])
expect(planDayWindow([], hours, () => undefined, 'metric', labels).chains).toEqual([])
})
it('bounds extreme driving and stay durations', () => {
expect(calculate([stop(1), stop(2)], [600 * 367]).issue).toBe('tooLong')
expect(calculate([stop(1, { dwellMinutes: 600 * 367 }), stop(2)], [1]).issue).toBe('tooLong')
})
it('maps insertions on generated days back to real stored stops', () => {
const planned = calculate([stop(1), stop(2)])
expect(roadtripInsertion(planned.chains[1], 0)).toEqual({ dayId: 1, position: 1 })
expect(roadtripInsertion(planned.chains[1], 1)).toEqual({ dayId: 1, position: 1 })
expect(roadtripInsertion({ stops: [] }, 0)).toBeNull()
expect(roadtripInsertion(planned.chains[1], 2)).toEqual({ dayId: 1, position: 2 })
})
it('rounds fractional route minutes across the hour boundary', () => {
const planned = calculate([stop(1), stop(2)], [59.8])
expect(planned.chains[0].schedule.entries[1].arrival).toBe('09:00')
})
it('keeps a zero-distance timed leg at its location when split', () => {
const a = stop(1)
const b = stop(2, { lng: a.lng })
const timed = leg(720, a, b)
timed.seg.distance = 0
timed.line = []
const planned = planDayWindow([day(1, [a, b])], hours, () => timed, 'metric', labels)
expect(planned.issue).toBeNull()
expect(planned.chains[1].stops[0]).toMatchObject({ lat: a.lat, lng: a.lng })
expect(planned.chains[1].schedule.entries[1].arrival).toBe('10:00')
})
})
+283
View File
@@ -0,0 +1,283 @@
import { formatClock, parseClock } from './roadtripModel'
import { lineMetres, projectOntoRoute, sliceAtMeters } from './corridor'
import type { SpillChain } from './nightSpill'
import type { RoadtripDay, RoadtripStop, RoutedLeg } from './useRoadtripRoutes'
import { formatDistance } from '../../utils/units'
import { formatDurationShort } from './roadtripModel'
import type { DistanceUnit } from '../../types'
import type { RoadtripDayBoundary } from '@trek/shared'
export interface DayWindow {
start: number
end: number
endMode?: 'route' | 'stop'
}
export interface AutomaticNight {
phase: 'start' | 'end'
fromDayNumber: number
position?: number
manual?: boolean
}
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
type LegLookup = (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined
export function dayWindow(start: unknown, end: unknown, endMode?: unknown): DayWindow | null {
const minutes = (clock: unknown): number | null => {
if (typeof clock !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(clock)) return null
return Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3))
}
const from = minutes(start)
const to = minutes(end)
return from !== null && to !== null && to > from ? { start: from, end: to, ...(endMode === 'stop' ? { endMode } as const : {}) } : null
}
function portion(leg: RoutedLeg, from: number, to: number, unit: DistanceUnit): RoutedLeg {
if (from === 0 && to === 1) return leg
const coordinates = leg.line.length ? leg.line : [leg.seg.from, leg.seg.to]
const spine = coordinates.map(([lat, lng]) => ({ lat, lng }))
const metres = lineMetres(spine)
const line = metres === 0 ? coordinates : sliceAtMeters(spine, from * metres, to * metres).map(p => [p.lat, p.lng] as [number, number])
const distance = leg.seg.distance * (to - from)
const duration = leg.seg.duration * (to - from)
const durationText = formatDurationShort(duration)
const distanceText = formatDistance(distance / 1000, unit)
return {
seg: {
...leg.seg, distance, duration, distanceText, durationText,
drivingText: `${distanceText} · ${durationText}`,
walkingText: `${distanceText} · ${durationText}`,
from: line[0], to: line[line.length - 1], mid: line[Math.floor(line.length / 2)],
},
line,
vias: leg.vias.filter(v => {
const along = (projectOntoRoute(v, spine)?.alongKm ?? 0) * 1000
return along >= from * metres && (to === 1 ? along <= metres : along < to * metres)
}),
}
}
function stationary(stop: RoadtripStop): RoutedLeg {
const at: [number, number] = [stop.lat, stop.lng]
return {
line: [], vias: [],
seg: { from: at, to: at, mid: at, distance: 0, duration: 0, mode: 'driving', walkingText: '', drivingText: '', distanceText: '' },
}
}
export interface WindowPlan {
chains: SpillChain[]
legFor: LegLookup
issue: 'incomplete' | 'conflict' | 'tooLong' | 'legTooLong' | null
}
export function planDayWindow(
days: PlanDay[], window: DayWindow, lookup: LegLookup, unit: DistanceUnit,
labels: { start: string; end: string },
boundaries: RoadtripDayBoundary[] = [],
): WindowPlan {
const ordered = [...days].sort((a, b) => a.dayNumber - b.dayNumber)
const stops = ordered.flatMap(day => day.stops.map(stop => ({ stop, day })))
const failed = (issue: WindowPlan['issue']): WindowPlan => ({ chains: [], legFor: lookup, issue })
if (!stops.length) return { chains: [], legFor: lookup, issue: null }
const positions = new Map(stops.map(({ stop }, i) => [stop.assignmentId, i]))
const targets = new Map<number, number>()
let lastTarget = -1
for (const boundary of [...boundaries].sort((a, b) => a.day_number - b.day_number)) {
const from = positions.get(boundary.from_assignment_id)
const to = boundary.to_assignment_id === null ? null : positions.get(boundary.to_assignment_id)
if (from === undefined || to !== null && to !== from + 1) return failed('conflict')
const target = from + (to === null ? 0 : boundary.fraction)
if (target <= lastTarget) return failed('conflict')
targets.set(boundary.day_number, target)
lastTarget = target
}
const legs = stops.slice(0, -1).map((s, i) => lookup(s.stop, stops[i + 1].stop))
if (legs.some(l => !l || !Number.isFinite(l.seg.duration) || l.seg.duration < 0 || l.line.length < 2 && l.seg.distance > 0)) {
return failed('incomplete')
}
const chains = new Map<number, SpillChain>()
const pieces = new Map<RoadtripStop, Map<RoadtripStop, RoutedLeg>>()
const firstDay = stops[0].day.dayNumber
let number = firstDay
let clock = window.start
let issue: WindowPlan['issue'] = null
let position = 0
const chainAt = (n: number): SpillChain => {
const existing = chains.get(n)
if (existing) return existing
const stored = ordered.find(d => d.dayNumber === n)
const dated = ordered.find(d => d.date)
let date = stored?.date ?? null
if (!stored && dated?.date) {
const dayDate = new Date(`${dated.date}T12:00:00Z`)
dayDate.setUTCDate(dayDate.getUTCDate() + n - dated.dayNumber)
date = dayDate.toISOString().slice(0, 10)
}
const chain: SpillChain = {
dayId: stored?.dayId ?? -1000000000 - n,
dayNumber: n, date, title: stored?.title ?? null,
stops: [], schedule: { entries: [], warnings: [] }, spills: [],
}
chains.set(n, chain)
return chain
}
const putLeg = (from: RoadtripStop, to: RoadtripStop, leg: RoutedLeg) => {
if (!pieces.has(from)) pieces.set(from, new Map())
pieces.get(from)!.set(to, leg)
}
const append = (stop: RoadtripStop, arrival: number, departure = arrival) => {
const chain = chainAt(number)
chain.stops.push(stop)
chain.schedule.entries.push({
arrival: formatClock(Math.round(arrival)), departure: formatClock(Math.round(departure)),
anchored: !stop.automaticNight && stop.time !== null,
dayOffset: Math.floor(arrival / 1440),
})
}
const night = (at: RoadtripStop, final = false): RoadtripStop => {
const chain = chainAt(number)
const last = chain.stops[chain.stops.length - 1]
const end: RoadtripStop = {
...at, assignmentId: -2000000000 - number * 2,
name: labels.end, time: null, dwellMinutes: 0, stopType: null, fillPercent: null,
automaticNight: { phase: 'end', fromDayNumber: number, position, manual: targets.has(number) },
}
append(end, clock)
if (last) putLeg(last, end, stationary(at))
if (final) return end
number += 1
if (number - firstDay > 365) issue = 'tooLong'
clock = window.start
const start: RoadtripStop = {
...end, assignmentId: end.assignmentId - 1, name: labels.start,
automaticNight: { ...end.automaticNight!, phase: 'start' },
}
append(start, clock)
return start
}
let previous: RoadtripStop | undefined
for (let i = 0; i < stops.length; i++) {
const { stop, day } = stops[i]
const pin = parseClock(stop.time)
if (previous && number < day.dayNumber && !targets.has(number)) {
if (window.endMode !== 'stop') clock = Math.max(clock, window.end)
previous = night(previous)
while (number < day.dayNumber && !issue) {
clock = window.end
previous = night(previous)
}
} else if (!previous) {
number = day.dayNumber
clock = pin ?? window.start
}
if (issue) return failed(issue)
if (pin !== null && day.dayNumber !== number) return failed('conflict')
if (previous) {
const leg = legs[i - 1]!
const minutes = leg.seg.duration / 60
if (pin !== null && chainAt(number).stops.every(s => s.automaticNight)) {
clock = Math.min(clock, pin - minutes)
if (clock < 0) return failed('conflict')
const startEntry = chainAt(number).schedule.entries[0]
startEntry.arrival = formatClock(Math.round(clock))
startEntry.departure = formatClock(Math.round(clock))
}
if (pin !== null && clock + minutes > pin + 1) return failed('conflict')
let fraction = 0
const split = pin === null && (leg.seg.mode === 'driving' || leg.seg.mode === undefined)
if (split && !targets.has(number) && window.endMode === 'stop' && clock + minutes > window.end + 0.000001) {
if (minutes > window.end - window.start) return failed('legTooLong')
previous = night(previous)
if (issue) return failed(issue)
}
while (fraction < 1) {
const target = targets.get(number)
if (target !== undefined && target < i - 1 + fraction - 0.000001) return failed('conflict')
const manualCut = target !== undefined && target < i && target >= i - 1 + fraction
if (!manualCut && (target !== undefined || !split || clock + minutes * (1 - fraction) <= window.end + 0.000001)) break
const available = manualCut ? minutes * (target - (i - 1 + fraction)) : Math.max(0, window.end - clock)
if (available === 0) {
previous = night(previous)
} else {
const until = Math.min(1, fraction + available / minutes)
const part = portion(leg, fraction, until, unit)
const at = part.line[part.line.length - 1]
const point = { ...previous, lat: at[0], lng: at[1] }
clock += available
position = i - 1 + until
const before = previous
previous = night(point)
const previousStops = chainAt(number - 1).stops
const ending = previousStops[previousStops.length - 1]
putLeg(before, ending, part)
fraction = until
}
if (issue) return failed(issue)
}
const remainder = portion(leg, fraction, 1, unit)
clock += minutes * (1 - fraction)
putLeg(previous, stop, remainder)
}
position = i
if (pin !== null && number !== day.dayNumber) return failed('conflict')
const target = targets.get(number)
if (target !== undefined && target < position) return failed('conflict')
if (pin !== null) clock = pin
append(stop, clock)
previous = stop
let dwell = Math.max(0, stop.dwellMinutes ?? 0)
while (dwell > 0) {
const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock)
const spend = Math.min(dwell, remaining)
clock += spend
dwell -= spend
const entries = chainAt(number).schedule.entries
entries[entries.length - 1].departure = formatClock(Math.round(clock))
if (dwell > 0) {
previous = night(previous)
if (issue) return failed(issue)
}
}
if (target === position || !targets.has(number) && stop.endDay && i < stops.length - 1) {
previous = night(previous, i === stops.length - 1)
if (issue) return failed(issue)
}
}
if ([...targets.keys()].some(day => !chains.get(day)?.stops.some(stop => stop.automaticNight?.phase === 'end'))) return failed('conflict')
for (const chain of chains.values()) {
for (let i = 0; i < chain.stops.length; i++) {
const stop = chain.stops[i]
if (stop.automaticNight) continue
const owner = ordered.find(d => d.dayId === stop.ownerDayId)
if (!owner || owner.dayNumber === chain.dayNumber) continue
const last = chain.spills[chain.spills.length - 1]
if (last?.fromDayNumber === owner.dayNumber && last.at + last.count === i) last.count += 1
else chain.spills.push({
automatic: true,
at: i, count: 1, fromDayNumber: owner.dayNumber, departure: null,
leg: undefined, line: [], fromStop: undefined,
})
}
}
for (const day of ordered) chainAt(day.dayNumber)
return {
chains: [...chains.values()].sort((a, b) => a.dayNumber - b.dayNumber),
legFor: (from, to) => pieces.get(from)?.get(to),
issue: null,
}
}
export function roadtripInsertion(day: Pick<RoadtripDay, 'stops'>, index: number) {
const stop = day.stops[Math.min(Math.max(0, index), day.stops.length - 1)]
return stop ? { dayId: stop.ownerDayId, position: stop.ownerIndex + (stop.automaticNight || index >= day.stops.length ? 1 : 0) } : null
}
@@ -25,6 +25,7 @@ import type { RouteSegment } from '../../types'
/** A stretch of one card's chain that was driven onto it from an earlier day. */
export interface SpillMark {
automatic?: boolean
/** Where it starts in the card's `stops`. */
at: number
/** How many stops it covers. */
@@ -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 }
}
@@ -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.
@@ -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)]
@@ -10,10 +10,15 @@ 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 { RoadtripVia, RoadtripDayBoundary } from '@trek/shared'
import { dayWindow, planDayWindow, type AutomaticNight, type WindowPlan } from './dayWindow'
import type { DayBoundaryLeg } from './dayBoundaryPath'
import { useTranslation } from '../../i18n/TranslationContext'
import { computeSchedule } from './roadtripModel'
/** One stop on a day's drive — an assignment whose place actually has coordinates. */
export interface RoadtripStop {
automaticNight?: AutomaticNight
assignmentId: number
/**
* The day this stop is STORED on, and its position there.
@@ -34,6 +39,7 @@ export interface RoadtripStop {
time: string | null
/** How long the visit is planned to take. `places.duration_minutes` has carried this for years. */
dwellMinutes: number | null
endDay?: boolean
/** Mode of the leg LEAVING this stop; null inherits the day default. */
legMode: string | null
incomingLegMode: string | null
@@ -59,6 +65,7 @@ export interface RoadtripStop {
}
export interface RoadtripDay {
automaticSchedule?: boolean
dayId: number
dayNumber: number
date: string | null
@@ -151,6 +158,9 @@ export interface QuietDay {
}
export interface RoadtripRoutes {
boundaryPath?: DayBoundaryLeg[]
validateBoundaries?: (boundaries: RoadtripDayBoundary[]) => WindowPlan['issue']
dayWindowIssue?: 'incomplete' | 'conflict' | 'tooLong' | 'legTooLong' | null
days: RoadtripDay[]
/**
* Days with fewer than two stops, in trip order.
@@ -220,7 +230,7 @@ const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
* 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 {
export interface RoutedLeg {
seg: RouteSegment
line: [number, number][]
/**
@@ -287,6 +297,7 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): Roadtrip
lng: p.lng,
time: a.assignment_time ?? p.place_time ?? 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 +329,9 @@ export function useRoadtripRoutes(
* chose rather than the one it prefers.
*/
viasByDay: Record<number, RoadtripVia[]> = {},
boundaries: RoadtripDayBoundary[] = [],
): 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)
@@ -367,7 +380,12 @@ 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 = useSettingsStore(s => !!s.settings.roadtrip_connect_days)
const startTime = useSettingsStore(s => s.settings.roadtrip_day_start)
const endTime = useSettingsStore(s => s.settings.roadtrip_day_end)
const endMode = useSettingsStore(s => s.settings.roadtrip_day_end_mode)
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.
@@ -671,7 +689,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 +704,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(() => {
@@ -761,7 +782,22 @@ export function useRoadtripRoutes(
// 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 storedLegFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)]
const timed = window ? planDayWindow(
[...plan, ...quietDays], window, storedLegFor, distanceUnit,
{ start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') },
boundaries,
) : null
const automaticSchedule = !!timed && timed.issue === null
const displayChains = automaticSchedule ? timed.chains : timed?.issue
? [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).filter(d => d.stops.length).map(d => ({
...d, spills: [], schedule: computeSchedule(
d.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1])?.seg.duration),
),
}))
: chains
const legFor = automaticSchedule ? timed.legFor : storedLegFor
const lines: [number, number][][] = []
const lineDays: number[] = []
@@ -777,7 +813,7 @@ export function useRoadtripRoutes(
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) {
for (const chain of displayChains) {
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
@@ -799,7 +835,7 @@ export function useRoadtripRoutes(
* 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) {
if (connectDays && !automaticSchedule) {
for (const spill of chain.spills) {
inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber })
}
@@ -839,7 +875,7 @@ export function useRoadtripRoutes(
const schedule = chain.schedule
const legVias = routed.map(l => l?.vias ?? [])
const stops = chain.stops.map(s => {
const snap = allSnaps[stopKey(s)]
const snap = s.automaticNight ? undefined : 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 }
@@ -884,6 +920,7 @@ export function useRoadtripRoutes(
// ferry or a walk pays for a second copy.
const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine
out.push({
automaticSchedule,
dayId: chain.dayId,
dayNumber: chain.dayNumber,
date: chain.date,
@@ -895,8 +932,19 @@ export function useRoadtripRoutes(
dayWarning: drive.day,
})
}
const drives = out.filter(d => d.stops.length > 1)
const drives = out.filter(d => d.stops.length > 1 || d.stops.some(s => s.automaticNight))
const originalStops = [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).flatMap(day => day.stops)
const boundaryPath = originalStops.slice(0, -1).flatMap((from, position) => {
const to = originalStops[position + 1]
const leg = storedLegFor(from, to)
return leg && (!leg.seg.mode || leg.seg.mode === 'driving') && from.assignmentId > 0 && to.assignmentId > 0
? [{ from, to, position, line: leg.line }] : []
})
return {
boundaryPath,
validateBoundaries: (next: RoadtripDayBoundary[]) => window ? planDayWindow([...plan, ...quietDays], window, storedLegFor, distanceUnit,
{ start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') }, next).issue : 'conflict',
dayWindowIssue: timed?.issue ?? null,
days: drives,
lines,
lineDays,
@@ -909,13 +957,13 @@ export function useRoadtripRoutes(
// 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),
totalStops: drives.reduce((s, d) => s + d.stops.filter(st => !st.automaticNight && !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)
.filter(d => d.stops.length < 2 && !d.stops.some(s => s.automaticNight))
.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])
}
}, [plan, quietDays, window, distanceUnit, t, chains, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries])
}
@@ -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}