mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(roadtrip): add daily scheduling and configurable day endings
This commit is contained in:
@@ -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 })
|
||||
} }} />
|
||||
})}</>
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user