mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Merge pull request #2334 from liketrek/codex/roadtrip-day-boundaries
Add configurable Roadtrip day endings and station clusters
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -24,6 +24,7 @@ import { tripRepo } from './repo/tripRepo'
|
||||
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
@@ -364,6 +365,7 @@ export default function App() {
|
||||
const { settings } = useSettingsStore()
|
||||
|
||||
useInAppNotificationListener()
|
||||
useRoadtripPreferencesSync()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { assignmentSchema, type AssignmentEndDayRequest } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
|
||||
export async function saveAssignmentEndDay(tripId: number | string, id: number, body: AssignmentEndDayRequest) {
|
||||
const saved = await apiClient.put(`/trips/${tripId}/assignments/${id}/end-day`, body)
|
||||
return assignmentSchema.parse(saved.data.assignment)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// second copy of the same list to keep in step.
|
||||
'roadtripVia:changed',
|
||||
'roadtripTrack:changed',
|
||||
'roadtripPreferences:changed',
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import RoutingInstanceFields, { type RoutingDefaults } from './RoutingInstanceFields'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -34,7 +35,7 @@ const MAP_PRESETS = [
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
type Defaults = {
|
||||
type Defaults = RoutingDefaults & {
|
||||
temperature_unit?: string
|
||||
distance_unit?: DistanceUnit
|
||||
dark_mode?: string | boolean
|
||||
@@ -43,8 +44,6 @@ type Defaults = {
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -121,8 +120,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [routingBase, setRoutingBase] = useState('')
|
||||
const [valhallaBase, setValhallaBase] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,8 +129,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setRoutingBase(data.routing_base_url || '')
|
||||
setValhallaBase(data.valhalla_base_url || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -156,8 +151,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'routing_base_url') setRoutingBase('')
|
||||
if (key === 'valhalla_base_url') setValhallaBase('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -387,53 +380,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Instance configuration, not a taste, which is why it sits here rather
|
||||
than in a user's own Map settings. The origin has to appear in the CSP
|
||||
`connect-src` the server emits at boot, and that list is built from
|
||||
this default — a value on a personal settings row is read by the route
|
||||
calculator and then refused by the browser, so the whole app stops
|
||||
routing with no error it could report. The write route refuses one
|
||||
from a non-admin for the same reason. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.routingBase')}
|
||||
<ResetButton field="routing_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={routingBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
|
||||
onBlur={() => save({ routing_base_url: routingBase.trim() })}
|
||||
placeholder="https://osrm.example.org"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* The second engine, next to the first because it is the same kind of decision
|
||||
and carries the same restart caveat. Only ever asked to leave a road class
|
||||
out of one leg, which is the one question the OSRM above refuses. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.valhallaBase')}
|
||||
<ResetButton field="valhalla_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={valhallaBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValhallaBase(e.target.value)}
|
||||
onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })}
|
||||
placeholder="https://valhalla.example.org"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.valhallaBaseHint')}</p>
|
||||
</div>
|
||||
<div className="mt-3.5">
|
||||
<RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
export interface RoutingDefaults {
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
}
|
||||
|
||||
interface RoutingFieldsProps {
|
||||
defaults: RoutingDefaults
|
||||
onSave: (patch: RoutingDefaults) => Promise<void>
|
||||
onReset: (key: keyof RoutingDefaults) => Promise<void>
|
||||
hintClassName?: string
|
||||
}
|
||||
|
||||
const ROUTERS = [
|
||||
{ key: 'routing_base_url', label: 'settings.routingBase', hint: 'settings.routingBaseHint', placeholder: 'https://osrm.example.org' },
|
||||
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
|
||||
] as const
|
||||
|
||||
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 'text-caption leading-relaxed text-content-faint' }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const saved = defaults[router.key] ?? ''
|
||||
const commit = async () => {
|
||||
if (draft === null) return
|
||||
const value = draft.trim()
|
||||
if (value === saved) { setDraft(null); return }
|
||||
setSaving(true)
|
||||
try { await onSave({ [router.key]: value }) }
|
||||
finally { setDraft(null); setSaving(false) }
|
||||
}
|
||||
const reset = async () => {
|
||||
setSaving(true)
|
||||
try { await onReset(router.key) }
|
||||
finally { setDraft(null); setSaving(false) }
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 space-y-1.5" aria-busy={saving}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<label htmlFor={id} className="text-body font-medium text-content-secondary">{t(router.label)}</label>
|
||||
{defaults[router.key] !== undefined && (
|
||||
<button type="button" disabled={saving} onClick={reset} className="text-caption text-content-muted underline disabled:opacity-50">
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input id={id} type="url" inputMode="url" value={draft ?? saved} disabled={saving}
|
||||
onChange={e => setDraft(e.target.value)} onBlur={commit}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||
placeholder={router.placeholder} spellCheck={false} autoComplete="off" autoCapitalize="none"
|
||||
aria-describedby={`${id}-hint`}
|
||||
className="min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
/>
|
||||
<p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
|
||||
return <div className="flex flex-col gap-3">{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Marker, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import type { Poi } from './poiCategories'
|
||||
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
|
||||
|
||||
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
|
||||
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
|
||||
}) {
|
||||
const map = useMap()
|
||||
const { t } = useTranslation()
|
||||
const [zoom, setZoom] = useState(() => map.getZoom())
|
||||
const popup = useRef<L.Popup | null>(null)
|
||||
useEffect(() => () => { popup.current?.remove() }, [pois, enabled, zoom])
|
||||
useEffect(() => {
|
||||
const update = () => setZoom(map.getZoom())
|
||||
map.on('zoomend', update)
|
||||
return () => { map.off('zoomend', update) }
|
||||
}, [map])
|
||||
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng]), zoom) : [], [pois, enabled, map, zoom])
|
||||
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
|
||||
if (!enabled) return <>{children}</>
|
||||
return <>{groups.map(group => {
|
||||
if (group.pois.length === 1) return markers.get(group.pois[0].osm_id)
|
||||
const label = t('roadtrip.poi.found', { count: group.pois.length })
|
||||
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, group.lng]} alt={label}
|
||||
icon={L.divIcon({ className: '', html: poiClusterMarkup(group.pois.length), iconSize: [38, 38], iconAnchor: [19, 19] })}
|
||||
zIndexOffset={600} eventHandlers={{ click: () => {
|
||||
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
|
||||
popup.current?.remove()
|
||||
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, group.lng])
|
||||
.setContent(poiClusterList(group.pois, poi => { popup.current?.remove(); onPoiClick?.(poi) })).openOn(map)
|
||||
} else map.fitBounds(group.pois.map(poi => [poi.lat, poi.lng] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
|
||||
} }} />
|
||||
})}</>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GeoJSON } from 'react-leaflet'
|
||||
import type { RoadtripHazard } from '@trek/shared'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
import { hazardPopup, hazardFeature } from './hazardPopup'
|
||||
|
||||
export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) {
|
||||
const { t } = useTranslation()
|
||||
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
|
||||
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
|
||||
pointToLayer={(_, point) => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })}
|
||||
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip trek-hazard-popup', maxWidth: 320 })}
|
||||
/>)}</>
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import * as photoService from '../../services/photoService'
|
||||
|
||||
const mapMock = vi.hoisted(() => ({
|
||||
getContainer: vi.fn(() => document.createElement('div')),
|
||||
panTo: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
@@ -64,6 +65,7 @@ vi.mock('react-leaflet', () => ({
|
||||
TileLayer: () => <div data-testid="tile-layer" />,
|
||||
Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
|
||||
<div
|
||||
ref={node => { if (node && zIndexOffset === 500) eventHandlers?.add?.({ target: { getElement: () => node } }) }}
|
||||
data-testid="marker"
|
||||
data-lat={position[0]}
|
||||
data-lng={position[1]}
|
||||
@@ -266,9 +268,9 @@ describe('MapView', () => {
|
||||
expect(screen.getAllByTestId('polyline').length).toBe(3)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-010: MarkerClusterGroup is rendered', () => {
|
||||
it.each([false, true])('FE-COMP-MAPVIEW-010: place clustering stays enabled with roadtrip=%s', (roadtrip) => {
|
||||
const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} />)
|
||||
render(<MapView places={places} clusterLoosely={roadtrip} />)
|
||||
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -650,6 +652,23 @@ describe('MapView explore POIs', () => {
|
||||
expect(onPoiClick).toHaveBeenCalledWith(poi)
|
||||
})
|
||||
|
||||
it('keeps native POI clicks working after a pan and uses the latest callback', () => {
|
||||
const first = vi.fn()
|
||||
const latest = vi.fn()
|
||||
const poi = buildPoi({ osm_id: 'node/7' })
|
||||
const { rerender } = render(<MapView pois={[poi]} onPoiClick={first} onPoiDropOnRoute={() => {}} />)
|
||||
const marker = markersWithZ('500')[0]
|
||||
expect(marker).toHaveAttribute('draggable', 'true')
|
||||
fireEvent.mouseDown(marker)
|
||||
fireEvent.mouseUp(marker)
|
||||
fireEvent.click(marker)
|
||||
expect(first).toHaveBeenCalledTimes(1)
|
||||
rerender(<MapView pois={[poi]} onPoiClick={latest} onPoiDropOnRoute={() => {}} />)
|
||||
fireEvent.click(markersWithZ('500')[0])
|
||||
expect(latest).toHaveBeenCalledExactlyOnceWith(poi)
|
||||
expect(first).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-035: an unknown POI category falls back to grey and draws no glyph', () => {
|
||||
render(<MapView pois={[buildPoi({ osm_id: 'node/8', category: 'not-a-category' })]} />)
|
||||
const html = iconHtmlOf(markersWithZ('500')[0])
|
||||
@@ -698,6 +717,27 @@ describe('MapView plugin route vias', () => {
|
||||
expect(markersWithZ('800')[0].textContent).toContain('Supercharger · 1 h 30 min')
|
||||
})
|
||||
|
||||
it('hides night badges at wide zoom and shows the full description when zoomed in', () => {
|
||||
mapMock.getZoom.mockReturnValue(5)
|
||||
const label = 'Tagesende von Tag 1 um 18:00 Uhr'
|
||||
render(<MapView routeVias={[via({ label, hoverCard: true, nightPause: { day: 1, atPlace: true } })]} />)
|
||||
expect(markersWithZ('800')).toHaveLength(0)
|
||||
mapMock.getZoom.mockReturnValue(6)
|
||||
act(() => { mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]() })
|
||||
const badge = markersWithZ('800')[0]
|
||||
expect(iconHtmlOf(badge)).toContain('data-night-pause="place"')
|
||||
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(label)
|
||||
mapMock.getZoom.mockReturnValue(5)
|
||||
act(() => {
|
||||
mapMock.on.mock.calls.find(([events]) => events === 'movestart zoomstart')![1]()
|
||||
mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]()
|
||||
})
|
||||
expect(markersWithZ('800')).toHaveLength(0)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
mapMock.getZoom.mockReturnValue(10)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-041: a dwell under an hour is shown in minutes alone', () => {
|
||||
render(<MapView routeVias={[via({ dwellSeconds: 1500 })]} />)
|
||||
expect(markersWithZ('800')[0].textContent).toContain('25 min')
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
@@ -44,6 +45,12 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Day, Reservation, RouteVia } from '../../types'
|
||||
import type { MapHoverInfo } from './mapHover'
|
||||
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
|
||||
import NightPauseTooltip from './NightPauseTooltip'
|
||||
import ClusteredPois from './ClusteredPois'
|
||||
import { NightPauseDrag } from './NightPauseDrag'
|
||||
import type { DayBoundaryControls } from './dayBoundaryDrag'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
|
||||
@@ -98,6 +105,26 @@ function formatViaDwell(seconds: number): string {
|
||||
return h > 0 ? `${h} h ${m} min` : `${m} min`
|
||||
}
|
||||
|
||||
function nightPauseIcon(via: RouteVia): L.DivIcon {
|
||||
const key = `night:${via.nightPause!.day}:${via.nightPause!.atPlace}`
|
||||
const cached = viaIconCache.get(key)
|
||||
if (cached) return cached
|
||||
const icon = L.divIcon({ className: 'night-pause-marker', html: nightPauseMarker(via), iconSize: [0, 0], iconAnchor: [0, 0] })
|
||||
viaIconCache.set(key, icon)
|
||||
return icon
|
||||
}
|
||||
|
||||
function RouteViaMarker({ via, controls, eventHandlers, children }: {
|
||||
via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode
|
||||
}) {
|
||||
const [marker, setMarker] = useState<L.Marker | null>(null)
|
||||
return <Marker ref={setMarker} position={[via.lat, via.lng]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
|
||||
alt={via.nightPause ? via.label : undefined} zIndexOffset={800} eventHandlers={eventHandlers}>
|
||||
{children}
|
||||
{via.nightPause && <NightPauseDrag marker={marker} via={via} controls={controls} />}
|
||||
</Marker>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a round photo-circle marker.
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
@@ -226,15 +253,16 @@ function createPoiIcon(category: string, brandWikidata?: string | null) {
|
||||
// away under a stationary cursor, so the browser never fires mouseout — and
|
||||
// mouseover/mousemove during the pan animation would immediately re-set the
|
||||
// tooltip we just cleared (#1404).
|
||||
function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: boolean }; onMoveStart: () => void }) {
|
||||
function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { current: boolean }; onMoveStart: () => void; onZoom: (zoom: number) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const start = () => { movingRef.current = true; onMoveStart() }
|
||||
const end = () => { movingRef.current = false }
|
||||
const end = () => { movingRef.current = false; onZoom(map.getZoom()) }
|
||||
onZoom(map.getZoom())
|
||||
map.on('movestart zoomstart', start)
|
||||
map.on('moveend zoomend', end)
|
||||
return () => { map.off('movestart zoomstart', start); map.off('moveend zoomend', end) }
|
||||
}, [map, movingRef, onMoveStart])
|
||||
}, [map, movingRef, onMoveStart, onZoom])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -638,6 +666,8 @@ export const MapView = memo(function MapView({
|
||||
onViewportChange,
|
||||
tripId,
|
||||
routeVias = [],
|
||||
dayBoundaryControls,
|
||||
hazards,
|
||||
accessLines = [],
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
@@ -655,9 +685,12 @@ export const MapView = memo(function MapView({
|
||||
// that is decides which layer draws it. A saved raster template still wins,
|
||||
// the default is a vector style.
|
||||
const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl])
|
||||
const poiClickRef = useRef(onPoiClick)
|
||||
poiClickRef.current = onPoiClick
|
||||
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
||||
<Marker
|
||||
key={`poi-${poi.osm_id}`}
|
||||
alt={poi.name}
|
||||
position={[poi.lat, poi.lng]}
|
||||
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
|
||||
zIndexOffset={500}
|
||||
@@ -667,7 +700,11 @@ export const MapView = memo(function MapView({
|
||||
// itself, and this is the first moment there is one to attach to.
|
||||
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
|
||||
const el = e.target.getElement()
|
||||
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
|
||||
if (!el) return
|
||||
// Native clicks avoid Leaflet suppressing a click after an earlier map drag.
|
||||
el.setAttribute('aria-label', poi.name)
|
||||
el.onclick = event => { event.stopPropagation(); poiClickRef.current?.(poi) }
|
||||
if (onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -712,11 +749,12 @@ export const MapView = memo(function MapView({
|
||||
})
|
||||
|
||||
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<MapHoverInfo | null>(null)
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [mapZoom, setMapZoom] = useState(0)
|
||||
const mapMovingRef = useRef(false)
|
||||
|
||||
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
|
||||
const handleMarkerHover = useCallback((place: MapHoverInfo, x: number, y: number) => {
|
||||
if (hoverDisabled || mapMovingRef.current) return
|
||||
setHoveredPlace(place)
|
||||
setTooltipPos({ x, y })
|
||||
@@ -920,6 +958,7 @@ export const MapView = memo(function MapView({
|
||||
), [gpxTracks, hasCasingPane])
|
||||
|
||||
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
|
||||
&& (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia))
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||
@@ -1001,32 +1040,25 @@ export const MapView = memo(function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
zoomed right out, and a day's stops merging into one dot is exactly the shape
|
||||
it is being looked at for. Rendered outside the group rather than with the
|
||||
clustering disabled by option, so nothing is left to a plugin's own idea of
|
||||
what "disabled" means. */}
|
||||
{clusterLoosely ? markers : (
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
)}
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
@@ -1130,19 +1162,26 @@ export const MapView = memo(function MapView({
|
||||
roadRoutes={transportRoutes}
|
||||
/>
|
||||
|
||||
{poiMarkers}
|
||||
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
|
||||
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
|
||||
{/* Charging stops / rest areas a plugin route places on the drawn day route.
|
||||
Host-vetted data (server-normalized), rendered as plain tone dots. */}
|
||||
{(routeVias as RouteVia[]).map((v, i) => (
|
||||
<Marker key={`route-via-${i}`} position={[v.lat, v.lng]} icon={routeViaIcon(v.tone)} zIndexOffset={800}>
|
||||
{(v.label || v.dwellSeconds != null) && (
|
||||
<Tooltip direction="top" offset={[0, -8]}>
|
||||
{(routeVias as RouteVia[]).filter(v => !v.nightPause || mapZoom >= NIGHT_PAUSE_MIN_ZOOM).map((v, i) => (
|
||||
<RouteViaMarker key={v.nightPause ? `night-${v.nightPause.day}` : `route-via-${i}`} via={v} controls={dayBoundaryControls}
|
||||
eventHandlers={v.hoverCard ? {
|
||||
mouseover: e => handleMarkerHover({ name: v.label, routeVia: v }, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mousemove: e => handleMarkerHover({ name: v.label, routeVia: v }, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mouseout: handleMarkerHoverOut,
|
||||
} : undefined}
|
||||
>
|
||||
{(!v.hoverCard || isTouchDevice) && (v.label || v.dwellSeconds != null) && (
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
{v.label}
|
||||
{v.label && v.dwellSeconds != null ? ' · ' : ''}
|
||||
{v.dwellSeconds != null ? formatViaDwell(v.dwellSeconds) : ''}
|
||||
</Tooltip>
|
||||
)}
|
||||
</Marker>
|
||||
</RouteViaMarker>
|
||||
))}
|
||||
<PluginMapMarkers tripId={tripId} />
|
||||
<PluginMapLayers tripId={tripId} />
|
||||
@@ -1157,12 +1196,15 @@ export const MapView = memo(function MapView({
|
||||
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
|
||||
smaller gap its shadow ran into the sidebar edge and the two read as one
|
||||
surface. */}
|
||||
<div style={{ position: 'absolute', left: leftWidth + 20, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
|
||||
<div style={{ position: 'absolute', left: leftWidth + 20, top: hasInspector ? 80 : undefined, bottom: hasInspector ? undefined : switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
|
||||
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{TooltipOverlay && (
|
||||
{TooltipOverlay && hoveredPlace.routeVia?.nightPause && (
|
||||
<NightPauseTooltip label={hoveredPlace.name ?? ''} x={tooltipPos.x} y={tooltipPos.y} />
|
||||
)}
|
||||
{TooltipOverlay && !hoveredPlace.routeVia?.nightPause && (
|
||||
<div data-testid="tooltip" style={{
|
||||
position: 'fixed',
|
||||
left: tooltipPos.x + 14,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Suspense } from 'react'
|
||||
import { AlertTriangle, Clock, Info } from 'lucide-react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapView } from './MapView'
|
||||
import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
|
||||
import { useRoadtripHazards } from './useRoadtripHazards'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||
@@ -14,6 +18,9 @@ import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
|
||||
// them online (see the GL tile rules in vite.config.js), not prefetched.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function MapViewAuto(props: any) {
|
||||
const { t } = useTranslation()
|
||||
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
|
||||
const mapProps = { ...props, hazards: hazards.feed?.hazards }
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
// Fall back to Leaflet when Mapbox is selected but no token is set,
|
||||
@@ -33,12 +40,12 @@ export function MapViewAuto(props: any) {
|
||||
// to Leaflet keeps a usable map instead of an error card.
|
||||
// resetKeys: with two engine chunks, a failure under one provider must not
|
||||
// keep showing Leaflet after the user switches to the other.
|
||||
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...props} />}>
|
||||
<Suspense fallback={<MapView {...props} />}>
|
||||
<MapViewGL {...props} glProvider={glProvider} />
|
||||
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
|
||||
<Suspense fallback={<MapView {...mapProps} />}>
|
||||
<MapViewGL {...mapProps} glProvider={glProvider} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
return <MapView {...props} />
|
||||
return <MapView {...mapProps} />
|
||||
}
|
||||
|
||||
@@ -1389,6 +1389,32 @@ describe('MapViewGL', () => {
|
||||
expect(glMarkers.created[1].element.innerHTML).toContain('#6b7280')
|
||||
})
|
||||
|
||||
it('clusters roadtrip stations and zooms before offering overlapping stations individually', async () => {
|
||||
loadOnAttach()
|
||||
vi.mocked(glMap.getZoom).mockReturnValue(8)
|
||||
const poi: Poi = {
|
||||
osm_id: 'n1', name: 'Station A', lat: 48.854, lng: 2.332, category: 'charging_station',
|
||||
poi_type: 'charging_station', address: null, website: null, phone: null,
|
||||
opening_hours: null, cuisine: null, source: 'openstreetmap',
|
||||
}
|
||||
const other = { ...poi, osm_id: 'n2', name: 'Station B' }
|
||||
const onPoiClick = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} pois={[poi, other]} clusterLoosely onPoiClick={onPoiClick} />)
|
||||
await act(async () => {})
|
||||
const cluster = glMarkers.created.find(pin => pin.element.querySelector('[data-poi-cluster="2"]'))!
|
||||
expect(cluster).toBeDefined()
|
||||
act(() => { cluster.element.click() })
|
||||
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), { padding: 70, maxZoom: 18 })
|
||||
expect(onPoiClick).not.toHaveBeenCalled()
|
||||
vi.mocked(glMap.getZoom).mockReturnValueOnce(18)
|
||||
act(() => { cluster.element.click() })
|
||||
const calls = vi.mocked(glPopup.setDOMContent).mock.calls
|
||||
const list = calls[calls.length - 1][0]
|
||||
act(() => { list.querySelectorAll('button')[1].click() })
|
||||
expect(onPoiClick).toHaveBeenCalledWith(other)
|
||||
vi.mocked(glMap.getZoom).mockReturnValue(10)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-043: plugin markers render as tone dots with a text-only popup', async () => {
|
||||
loadOnAttach()
|
||||
const markers: PluginMapMarker[] = [
|
||||
|
||||
@@ -8,6 +8,7 @@ import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
@@ -24,6 +25,13 @@ import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlM
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Day, Place, Reservation, RouteVia } from '../../types'
|
||||
import type { MapHoverInfo } from './mapHover'
|
||||
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
|
||||
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
|
||||
import type { RoadtripHazard } from '@trek/shared'
|
||||
import { useHazardLayerGL } from './useHazardLayerGL'
|
||||
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
|
||||
import NightPauseTooltip from './NightPauseTooltip'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
@@ -120,6 +128,7 @@ interface Props {
|
||||
tripId?: number | string
|
||||
// Charging stops / rest areas a plugin route places on the drawn day route.
|
||||
routeVias?: RouteVia[]
|
||||
dayBoundaryControls?: DayBoundaryControls
|
||||
/** The dashed last bit to a place the road network does not reach. */
|
||||
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
|
||||
route?: [number, number][][] | null
|
||||
@@ -180,6 +189,7 @@ interface Props {
|
||||
* motorway are the shape of the day, and merging them into one dot hides it.
|
||||
*/
|
||||
clusterLoosely?: boolean
|
||||
hazards?: RoadtripHazard[]
|
||||
/** Via points to draw as draggable handles, keyed by day (#1797). */
|
||||
roadtripVias?: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
@@ -197,7 +207,7 @@ interface Props {
|
||||
onMapReady?: (map: any | null) => void
|
||||
}
|
||||
|
||||
/** How eagerly place markers merge into a cluster, outside road trip mode. */
|
||||
/** How eagerly place markers merge into a cluster. */
|
||||
const CLUSTER_RADIUS = 30
|
||||
const CLUSTER_MAX_ZOOM = 10
|
||||
|
||||
@@ -609,6 +619,7 @@ export function MapViewGL({
|
||||
dayPlaces = NO_PLACES,
|
||||
tripId,
|
||||
routeVias = NO_ROUTE_VIAS,
|
||||
dayBoundaryControls,
|
||||
accessLines = NO_ACCESS_LINES,
|
||||
route = null,
|
||||
routeColors = null,
|
||||
@@ -623,6 +634,7 @@ export function MapViewGL({
|
||||
fitKey = 0,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
hazards,
|
||||
dayOrderMap = NO_DAY_ORDER,
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
@@ -651,6 +663,7 @@ export function MapViewGL({
|
||||
gl,
|
||||
onMapReady,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
@@ -675,7 +688,7 @@ export function MapViewGL({
|
||||
const [mapReady, setMapReady] = useState(false)
|
||||
// Hover tooltip — a cursor-following name/category/address card, matching the
|
||||
// Leaflet map's overlay exactly (no anchored popup, no photo thumbnail).
|
||||
const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null)
|
||||
const [hoverPlace, setHoverPlace] = useState<MapHoverInfo | null>(null)
|
||||
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const hoverIdRef = useRef<number | null>(null)
|
||||
// True while the camera is moving (flyTo after a click, pan, zoom). Marker
|
||||
@@ -697,6 +710,8 @@ export function MapViewGL({
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapRef = useRef<any | null>(null)
|
||||
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
|
||||
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
||||
@@ -1781,18 +1796,6 @@ export function MapViewGL({
|
||||
return
|
||||
}
|
||||
|
||||
// Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
|
||||
// is being looked at for. The source is emptied rather than reconfigured — the
|
||||
// cluster options can only be set when a GeoJSON source is created, and dropping and
|
||||
// re-adding a source under three layers to change a radius is not worth it when the
|
||||
// markers are drawn from `places` anyway.
|
||||
if (clusterLoosely) {
|
||||
source.setData({ type: 'FeatureCollection', features: [] })
|
||||
reconcileMarkers(validPlaces)
|
||||
return
|
||||
}
|
||||
|
||||
source.setData(buildPlaceClusterData(places) as any)
|
||||
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
||||
let raf: number | null = null
|
||||
@@ -1833,29 +1836,63 @@ export function MapViewGL({
|
||||
}
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
|
||||
|
||||
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
||||
// planned-place markers so they don't cluster or get confused with them).
|
||||
// Search results stay separate from the planned itinerary.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
poiCleanupRef.current.forEach(off => off())
|
||||
poiCleanupRef.current = []
|
||||
for (const poi of (pois as Poi[])) {
|
||||
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
// Dragging a hit onto the drive puts it where it is passed, rather than where the
|
||||
// corridor happened to project it. Only wired when someone is listening for it.
|
||||
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
|
||||
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
|
||||
const redraw = () => {
|
||||
popupRef.current?.remove()
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
poiCleanupRef.current.forEach(off => off())
|
||||
poiCleanupRef.current = []
|
||||
const groups = clusterLoosely ? clusterPois(pois, poi => map.project([poi.lng, poi.lat]), map.getZoom()) : pois.map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng }))
|
||||
for (const group of groups) {
|
||||
if (group.pois.length > 1) {
|
||||
const el = document.createElement('button')
|
||||
el.type = 'button'
|
||||
el.style.cssText = 'width:38px;height:38px;border:0;padding:0;background:transparent;'
|
||||
el.setAttribute('aria-label', t('roadtrip.poi.found', { count: group.pois.length }))
|
||||
el.innerHTML = poiClusterMarkup(group.pois.length)
|
||||
el.addEventListener('click', event => {
|
||||
event.stopPropagation()
|
||||
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
|
||||
popupRef.current?.setLngLat([group.lng, group.lat]).setDOMContent(poiClusterList(group.pois, poi => {
|
||||
popupRef.current?.remove(); onPoiClickRef.current?.(poi)
|
||||
})).addTo(map)
|
||||
} else {
|
||||
const bounds = new gl.LngLatBounds()
|
||||
group.pois.forEach(poi => bounds.extend([poi.lng, poi.lat]))
|
||||
map.fitBounds(bounds, { padding: 70, maxZoom: POI_CLUSTER_DETAIL_ZOOM })
|
||||
}
|
||||
})
|
||||
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, group.lng, group.lat))
|
||||
continue
|
||||
}
|
||||
const poi = group.pois[0]
|
||||
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
// Dragging a hit onto the drive puts it where it is passed, rather than where the
|
||||
// corridor happened to project it. Only wired when someone is listening for it.
|
||||
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
|
||||
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
|
||||
}
|
||||
}
|
||||
}, [pois, mapReady, glProvider])
|
||||
redraw()
|
||||
if (clusterLoosely) map.on('moveend', redraw)
|
||||
return () => {
|
||||
map.off('moveend', redraw)
|
||||
poiCleanupRef.current.forEach(off => off())
|
||||
poiCleanupRef.current = []
|
||||
poiMarkersRef.current.forEach(pin => pin.remove())
|
||||
poiMarkersRef.current = []
|
||||
popupRef.current?.remove()
|
||||
}
|
||||
}, [pois, mapReady, glProvider, clusterLoosely, t])
|
||||
|
||||
// Fetch plugin map contributions (markers + layers) per trip. Fail-safe: an
|
||||
// error or missing tripId just means no plugin overlays, the core map is fine.
|
||||
@@ -1887,11 +1924,30 @@ export function MapViewGL({
|
||||
if (!map || !mapReady) return
|
||||
routeViaMarkersRef.current.forEach(m => m.remove())
|
||||
routeViaMarkersRef.current = []
|
||||
const pauses: HTMLElement[] = []
|
||||
const unbind: (() => void)[] = []
|
||||
for (const v of routeVias) {
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'width:13px;height:13px;cursor:pointer;'
|
||||
const color = PLUGIN_TONE_COLORS[v.tone] ?? PLUGIN_TONE_COLORS.default
|
||||
el.innerHTML = `<span style="display:block;width:13px;height:13px;border-radius:50%;background:#fff;border:3.5px solid ${color};box-shadow:0 1px 4px rgba(0,0,0,0.35);box-sizing:border-box"></span>`
|
||||
if (v.nightPause) {
|
||||
el.style.cssText = 'width:0;height:0;overflow:visible;cursor:pointer;'
|
||||
el.innerHTML = nightPauseMarker(v)
|
||||
el.setAttribute('aria-label', v.label ?? '')
|
||||
pauses.push(el)
|
||||
}
|
||||
if (v.hoverCard) {
|
||||
const show = (ev: MouseEvent) => {
|
||||
if (hoverDisabledRef.current || camMovingRef.current) return
|
||||
hoverIdRef.current = null
|
||||
setHoverPlace({ name: v.label, routeVia: v })
|
||||
setHoverPos({ x: ev.clientX, y: ev.clientY })
|
||||
}
|
||||
el.addEventListener('mouseenter', show)
|
||||
el.addEventListener('mousemove', show)
|
||||
el.addEventListener('mouseleave', () => { setHoverPlace(null); setHoverPos(null) })
|
||||
}
|
||||
if (v.label || v.dwellSeconds != null) {
|
||||
const text = [v.label, v.dwellSeconds != null ? formatViaDwellGl(v.dwellSeconds) : null].filter(Boolean).join(' · ')
|
||||
el.addEventListener('click', (ev) => {
|
||||
@@ -1899,9 +1955,30 @@ export function MapViewGL({
|
||||
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
|
||||
})
|
||||
}
|
||||
routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat))
|
||||
const pin = attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat)
|
||||
routeViaMarkersRef.current.push(pin)
|
||||
if (v.nightPause && dayBoundaryControls) unbind.push(bindDayBoundaryDrag(el, v, dayBoundaryControls, {
|
||||
project: (lat, lng) => {
|
||||
const point = map.project([lng, lat])
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
return { x: point.x + rect.left, y: point.y + rect.top }
|
||||
},
|
||||
setPosition: (lat, lng) => { pin.setLngLat([lng, lat]) },
|
||||
lock: () => {
|
||||
const enabled = map.dragPan.isEnabled()
|
||||
map.dragPan.disable()
|
||||
return () => { if (enabled) map.dragPan.enable() }
|
||||
},
|
||||
}))
|
||||
}
|
||||
}, [routeVias, mapReady, glProvider])
|
||||
const updateZoom = () => {
|
||||
const visible = map.getZoom() >= NIGHT_PAUSE_MIN_ZOOM
|
||||
for (const el of pauses) el.style.display = visible ? '' : 'none'
|
||||
}
|
||||
updateZoom()
|
||||
map.on('zoomend', updateZoom)
|
||||
return () => { map.off('zoomend', updateZoom); unbind.forEach(dispose => dispose()) }
|
||||
}, [routeVias, mapReady, glProvider, dayBoundaryControls])
|
||||
|
||||
// Reconcile plugin markers (imperative, same lifecycle as the POI markers).
|
||||
useEffect(() => {
|
||||
@@ -2222,7 +2299,8 @@ export function MapViewGL({
|
||||
does, which is why the lift is only there. */}
|
||||
<div style={{
|
||||
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
|
||||
bottom: isMobile && hasDayDetail
|
||||
top: hasInspector ? 80 : undefined,
|
||||
bottom: hasInspector ? undefined : isMobile && hasDayDetail
|
||||
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
|
||||
}}>
|
||||
@@ -2230,7 +2308,10 @@ export function MapViewGL({
|
||||
</div>
|
||||
{/* Hover tooltip — cursor-following name/category/address card, identical to
|
||||
the Leaflet map's overlay (no anchored popup, no photo). */}
|
||||
{!hoverDisabled && hoverPlace && hoverPos && !isMobile && (
|
||||
{!hoverDisabled && hoverPlace?.routeVia?.nightPause && hoverPos && !isMobile && routeVias.includes(hoverPlace.routeVia) && (
|
||||
<NightPauseTooltip label={hoverPlace.name ?? ''} x={hoverPos.x} y={hoverPos.y} />
|
||||
)}
|
||||
{!hoverDisabled && hoverPlace && !hoverPlace.routeVia?.nightPause && hoverPos && !isMobile && (!hoverPlace.routeVia || routeVias.includes(hoverPlace.routeVia)) && (
|
||||
<div data-testid="tooltip" style={{
|
||||
position: 'fixed',
|
||||
left: hoverPos.x + 14,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import type L from 'leaflet'
|
||||
import type { RouteVia } from '../../types'
|
||||
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
|
||||
|
||||
export function NightPauseDrag({ marker, via, controls }: { marker: L.Marker | null; via: RouteVia; controls?: DayBoundaryControls }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const el = marker?.getElement()
|
||||
if (!marker || !el || !controls) return
|
||||
return bindDayBoundaryDrag(el, via, controls, {
|
||||
project: (lat, lng) => {
|
||||
const point = map.latLngToContainerPoint([lat, lng])
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
return { x: point.x + rect.left, y: point.y + rect.top }
|
||||
},
|
||||
setPosition: (lat, lng) => { marker.setLatLng([lat, lng]) },
|
||||
lock: () => {
|
||||
const enabled = map.dragging.enabled()
|
||||
map.dragging.disable()
|
||||
return () => { if (enabled) map.dragging.enable() }
|
||||
},
|
||||
})
|
||||
}, [map, marker, via, controls])
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import NightPauseTooltip from './NightPauseTooltip'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('keeps the full pause description inside the viewport at its lower right edge', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 240, height: 56 } as DOMRect)
|
||||
const label = 'Tagesende von Tag 12 um 18:00 Uhr'
|
||||
render(<NightPauseTooltip label={label} x={window.innerWidth - 5} y={window.innerHeight - 5} />)
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
expect(tooltip).toHaveTextContent(label)
|
||||
expect(parseFloat(tooltip.style.left) + 240).toBeLessThanOrEqual(window.innerWidth - 12)
|
||||
expect(parseFloat(tooltip.style.top) + 56).toBeLessThanOrEqual(window.innerHeight - 12)
|
||||
expect(tooltip.style.overflowWrap).toBe('anywhere')
|
||||
})
|
||||
|
||||
it('keeps descriptions visible at the upper left edge', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 240, height: 56 } as DOMRect)
|
||||
render(<NightPauseTooltip label="End of day 1 at 18:00" x={-50} y={-50} />)
|
||||
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '12px', top: '12px' })
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export default function NightPauseTooltip({ label, x, y }: { label: string; x: number; y: number }) {
|
||||
const card = useRef<HTMLDivElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
const place = () => {
|
||||
const node = card.current!
|
||||
const { width, height } = node.getBoundingClientRect()
|
||||
const left = x + 14 + width > window.innerWidth - 12 ? x - width - 14 : x + 14
|
||||
node.style.left = `${Math.max(12, Math.min(left, window.innerWidth - width - 12))}px`
|
||||
node.style.top = `${Math.max(12, Math.min(y - 10, window.innerHeight - height - 12))}px`
|
||||
}
|
||||
place()
|
||||
window.addEventListener('resize', place)
|
||||
return () => window.removeEventListener('resize', place)
|
||||
}, [label, x, y])
|
||||
return createPortal(
|
||||
<div ref={card} role="tooltip" data-testid="tooltip"
|
||||
className="pointer-events-none fixed z-[var(--z-toast)] rounded-lg border border-edge-faint bg-surface-card px-3 py-2 font-semibold leading-relaxed text-content shadow-popover"
|
||||
style={{ left: x + 14, top: y - 10, maxWidth: 'min(16rem, calc(100vw - 24px))', overflowWrap: 'anywhere', fontFamily: 'var(--font-system)', fontSize: 12 }}
|
||||
>{label}</div>, document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
|
||||
import type { RoadtripStop } from '../Roadtrip/useRoadtripRoutes'
|
||||
|
||||
function setup(withHandle = false) {
|
||||
const el = document.createElement('span')
|
||||
if (withHandle) el.innerHTML = '<span data-night-pause="route">1</span>'
|
||||
document.body.append(el)
|
||||
const save = vi.fn(async () => true), setPosition = vi.fn(), unlock = vi.fn()
|
||||
const stop = (assignmentId: number, lng: number) => ({ assignmentId, lat: 0, lng }) as RoadtripStop
|
||||
const dispose = bindDayBoundaryDrag(el, { lat: 0, lng: 0.5, tone: 'default', nightPause: { day: 1, atPlace: false, manual: true, position: 0.5 } }, {
|
||||
path: [{ from: stop(1, 0), to: stop(2, 1), position: 0, line: [[0, 0], [0, 1]] }], move: save, hint: 'Move',
|
||||
}, { project: (lat, lng) => ({ x: lng * 1000, y: lat * 1000 }), setPosition, lock: () => unlock })
|
||||
const pointer = (node: EventTarget, type: string, x: number) => node.dispatchEvent(new MouseEvent(type, { clientX: x, clientY: 0, bubbles: true, cancelable: true, button: 0 }))
|
||||
return { el, save, setPosition, unlock, dispose, pointer }
|
||||
}
|
||||
|
||||
it('keeps dragging and keyboard controls after Leaflet replaces the label contents', async () => {
|
||||
const s = setup(true)
|
||||
for (const kind of ['route', 'place', 'route']) {
|
||||
const label = s.el.firstElementChild!
|
||||
s.pointer(label, 'pointerdown', 500)
|
||||
s.pointer(document, 'pointermove', 990)
|
||||
s.pointer(document, 'pointerup', 990)
|
||||
await Promise.resolve()
|
||||
s.el.innerHTML = `<span data-night-pause="${kind}">1</span>`
|
||||
}
|
||||
expect(s.save).toHaveBeenCalledTimes(3)
|
||||
expect(s.unlock).toHaveBeenCalledTimes(3)
|
||||
s.el.firstElementChild!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }))
|
||||
expect(s.save).toHaveBeenLastCalledWith(1, null)
|
||||
await Promise.resolve()
|
||||
s.dispose()
|
||||
s.pointer(s.el.firstElementChild!, 'pointerdown', 500)
|
||||
s.pointer(document, 'pointermove', 990)
|
||||
s.pointer(document, 'pointerup', 990)
|
||||
expect(s.save).toHaveBeenCalledTimes(4)
|
||||
s.el.remove()
|
||||
})
|
||||
|
||||
it('saves the snapped visit once on release and restores map dragging', async () => {
|
||||
const s = setup()
|
||||
s.pointer(s.el, 'pointerdown', 500)
|
||||
s.pointer(document, 'pointermove', 990)
|
||||
expect(s.save).not.toHaveBeenCalled()
|
||||
s.pointer(document, 'pointerup', 990)
|
||||
expect(s.save).toHaveBeenCalledWith(1, { day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 })
|
||||
expect(s.unlock).toHaveBeenCalledOnce()
|
||||
s.dispose()
|
||||
})
|
||||
|
||||
it('escape cancels without saving and removes document handlers', () => {
|
||||
const s = setup()
|
||||
s.pointer(s.el, 'pointerdown', 500)
|
||||
s.pointer(document, 'pointermove', 300)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
s.pointer(document, 'pointerup', 300)
|
||||
expect(s.save).not.toHaveBeenCalled()
|
||||
expect(s.setPosition).toHaveBeenLastCalledWith(0, 0.5)
|
||||
expect(s.unlock).toHaveBeenCalledOnce()
|
||||
s.dispose()
|
||||
})
|
||||
|
||||
it('a right click clears the manual override and a click alone does not save', async () => {
|
||||
const s = setup()
|
||||
s.pointer(s.el, 'pointerdown', 500)
|
||||
s.pointer(document, 'pointerup', 500)
|
||||
expect(s.save).not.toHaveBeenCalled()
|
||||
s.el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
||||
expect(s.save).toHaveBeenCalledWith(1, null)
|
||||
s.dispose()
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { RoadtripDayBoundary } from '@trek/shared'
|
||||
import type { RouteVia } from '../../types'
|
||||
import { nearestDayBoundary, type DayBoundaryLeg, type DayBoundaryTarget, type ProjectPoint } from '../Roadtrip/dayBoundaryPath'
|
||||
|
||||
export interface DayBoundaryControls {
|
||||
path: DayBoundaryLeg[]
|
||||
move: (day: number, boundary: RoadtripDayBoundary | null) => Promise<boolean>
|
||||
hint: string
|
||||
}
|
||||
|
||||
interface DragMap {
|
||||
project: ProjectPoint
|
||||
setPosition: (lat: number, lng: number) => void
|
||||
lock: () => () => void
|
||||
}
|
||||
|
||||
export function bindDayBoundaryDrag(el: HTMLElement, via: RouteVia, controls: DayBoundaryControls, map: DragMap) {
|
||||
const pause = via.nightPause!
|
||||
// Leaflet replaces the label contents when its icon changes, but keeps this root.
|
||||
const handle = el
|
||||
handle.style.cursor = 'grab'
|
||||
handle.style.touchAction = 'none'
|
||||
handle.tabIndex = 0
|
||||
handle.setAttribute('role', 'button')
|
||||
handle.setAttribute('aria-label', via.label ?? '')
|
||||
handle.setAttribute('aria-description', controls.hint)
|
||||
let origin: { x: number; y: number } | null = null
|
||||
let target: DayBoundaryTarget | null = null
|
||||
let unlock: (() => void) | null = null
|
||||
let moved = false
|
||||
let saving = false
|
||||
let disposed = false
|
||||
const restore = () => map.setPosition(via.lat, via.lng)
|
||||
const detach = () => {
|
||||
document.removeEventListener('pointermove', move)
|
||||
document.removeEventListener('pointerup', up)
|
||||
document.removeEventListener('pointercancel', cancel)
|
||||
document.removeEventListener('keydown', escape)
|
||||
unlock?.()
|
||||
unlock = null
|
||||
origin = null
|
||||
handle.style.cursor = 'grab'
|
||||
}
|
||||
const cancel = () => { detach(); restore() }
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); cancel() } }
|
||||
const commit = async (boundary: RoadtripDayBoundary | null) => {
|
||||
saving = true
|
||||
handle.setAttribute('aria-busy', 'true')
|
||||
try { if (!await controls.move(pause.day, boundary) && !disposed) restore() }
|
||||
finally { saving = false; handle.removeAttribute('aria-busy') }
|
||||
}
|
||||
const move = (event: PointerEvent) => {
|
||||
if (!origin) return
|
||||
if (!moved && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) < 4) return
|
||||
moved = true
|
||||
event.preventDefault()
|
||||
target = nearestDayBoundary(controls.path, pause.day, { x: event.clientX, y: event.clientY }, map.project, pause.minPosition, pause.maxPosition)
|
||||
if (target) map.setPosition(target.lat, target.lng)
|
||||
}
|
||||
const up = () => {
|
||||
const next = moved ? target : null
|
||||
detach()
|
||||
if (next) void commit(next.boundary)
|
||||
else restore()
|
||||
}
|
||||
const down = (event: PointerEvent) => {
|
||||
if (event.button !== 0 || saving) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
origin = { x: event.clientX, y: event.clientY }
|
||||
target = null
|
||||
moved = false
|
||||
unlock = map.lock()
|
||||
handle.style.cursor = 'grabbing'
|
||||
document.addEventListener('pointermove', move, { passive: false })
|
||||
document.addEventListener('pointerup', up)
|
||||
document.addEventListener('pointercancel', cancel)
|
||||
document.addEventListener('keydown', escape)
|
||||
}
|
||||
const click = (event: Event) => {
|
||||
if (moved || saving) { event.preventDefault(); event.stopImmediatePropagation() }
|
||||
}
|
||||
const reset = (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!saving && pause.manual) void commit(null)
|
||||
}
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') return reset(event)
|
||||
if (!['ArrowLeft', 'ArrowRight'].includes(event.key) || saving) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const position = (pause.position ?? 0) + (event.key === 'ArrowLeft' ? -0.05 : 0.05)
|
||||
const leg = controls.path.find(leg => position >= leg.position && position <= leg.position + 1)
|
||||
if (!leg || position <= (pause.minPosition ?? -1) || position >= (pause.maxPosition ?? Infinity)) return
|
||||
void commit({ day_number: pause.day, from_assignment_id: leg.from.assignmentId, to_assignment_id: leg.to.assignmentId, fraction: position - leg.position })
|
||||
}
|
||||
handle.addEventListener('pointerdown', down)
|
||||
el.addEventListener('click', click, true)
|
||||
handle.addEventListener('contextmenu', reset)
|
||||
handle.addEventListener('keydown', key)
|
||||
return () => {
|
||||
disposed = true
|
||||
detach()
|
||||
handle.removeEventListener('pointerdown', down)
|
||||
el.removeEventListener('click', click, true)
|
||||
handle.removeEventListener('contextmenu', reset)
|
||||
handle.removeEventListener('keydown', key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
.trek-hazard-content {
|
||||
font-family: var(--font-system);
|
||||
}
|
||||
|
||||
.trek-hazard-score {
|
||||
padding: 7px 10px 5px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.trek-hazard-score-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.trek-hazard-score-heading strong {
|
||||
font-size: calc(16px * var(--fs-scale-caption, 1));
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.trek-hazard-score-track {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.trek-hazard-score-track > span:not(.trek-hazard-score-marker) {
|
||||
flex: 1;
|
||||
border-radius: 3px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.trek-hazard-score-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid var(--bg-card);
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
box-sizing: border-box;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.trek-hazard-score-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: calc(10px * var(--fs-scale-caption, 1));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.trek-hazard-popup .leaflet-popup-content {
|
||||
margin: 14px 16px;
|
||||
}
|
||||
|
||||
.trek-hazard-popup .mapboxgl-popup-content,
|
||||
.trek-hazard-popup .maplibregl-popup-content {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-popover);
|
||||
}
|
||||
|
||||
.trek-hazard-popup a.trek-hazard-source {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.trek-hazard-popup a.trek-hazard-source:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.leaflet-container .trek-hazard-popup a.leaflet-popup-close-button,
|
||||
.trek-hazard-popup .mapboxgl-popup-close-button,
|
||||
.trek-hazard-popup .maplibregl-popup-close-button {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0;
|
||||
transition: background-color 150ms ease, color 150ms ease !important;
|
||||
}
|
||||
|
||||
.leaflet-container .trek-hazard-popup a.leaflet-popup-close-button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::before,
|
||||
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 6px;
|
||||
width: 14px;
|
||||
height: 1.5px;
|
||||
border-radius: 1px;
|
||||
background: currentColor;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button):hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.trek-hazard-popup :is(a, button):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { hazardPopup } from './hazardPopup'
|
||||
|
||||
it('renders provider text without HTML and identifies point-only notices', () => {
|
||||
const popup = hazardPopup({ id: 'test', source: 'DWD', title: '<img src=x>', description: '<script>bad()</script>',
|
||||
updatedAt: '2026-09-12T08:00:00Z', validUntil: null, url: 'https://www.dwd.de/',
|
||||
geometry: { type: 'Point', coordinates: [10, 50] },
|
||||
}, 'Not a closure', 'Location only')
|
||||
expect(popup.querySelector('img,script')).toBeNull()
|
||||
expect(popup.textContent).toContain('Location only Not a closure')
|
||||
expect(popup.querySelector('a')?.rel).toBe('noopener noreferrer')
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createElement } from 'react'
|
||||
import { AlertTriangle, ArrowUpRight } from 'lucide-react'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import './hazardPopup.css'
|
||||
import type { RoadtripHazard } from '@trek/shared'
|
||||
import type { Feature, Geometry } from 'geojson'
|
||||
|
||||
export function hazardFeature(hazard: RoadtripHazard): Feature {
|
||||
return { type: 'Feature', properties: { id: hazard.id }, geometry: hazard.geometry as Geometry }
|
||||
}
|
||||
|
||||
export function hazardPopup(hazard: RoadtripHazard, note: string, pointNote: string): HTMLDivElement {
|
||||
const box = document.createElement('div')
|
||||
box.className = 'trek-hazard-content flex max-w-xs flex-col gap-3 text-content'
|
||||
box.style.fontSize = 'calc(12px * var(--fs-scale-caption, 1))'
|
||||
box.style.lineHeight = '1.45'
|
||||
const title = document.createElement('div')
|
||||
title.className = 'flex items-center gap-2.5 pr-7 font-semibold'
|
||||
const symbol = document.createElement('span')
|
||||
symbol.className = 'flex size-7 shrink-0 items-center justify-center rounded-lg bg-warning-soft text-warning'
|
||||
symbol.innerHTML = renderIconMarkup(createElement(AlertTriangle, { size: 15, strokeWidth: 1.8 }))
|
||||
const heading = document.createElement('span')
|
||||
heading.textContent = hazard.title
|
||||
title.append(symbol, heading)
|
||||
box.append(title)
|
||||
if (hazard.description.trim() && hazard.description.trim().toLocaleLowerCase() !== hazard.title.trim().toLocaleLowerCase()) {
|
||||
const details = document.createElement('div')
|
||||
details.className = 'text-content-secondary'
|
||||
details.textContent = hazard.description
|
||||
box.append(details)
|
||||
}
|
||||
const metadata = document.createElement('div')
|
||||
metadata.className = 'flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-content-muted'
|
||||
const source = document.createElement('a')
|
||||
source.textContent = hazard.source
|
||||
source.href = hazard.url
|
||||
source.target = '_blank'
|
||||
source.rel = 'noopener noreferrer'
|
||||
source.className = 'trek-hazard-source inline-flex items-center gap-1 rounded-md bg-surface-hover px-1.5 py-0.5 font-medium'
|
||||
const external = document.createElement('span')
|
||||
external.innerHTML = renderIconMarkup(createElement(ArrowUpRight, { size: 12 }))
|
||||
source.append(external)
|
||||
const timestamp = document.createElement('time')
|
||||
timestamp.dateTime = hazard.updatedAt
|
||||
timestamp.textContent = new Date(hazard.updatedAt).toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
metadata.append(source, timestamp)
|
||||
const caution = document.createElement('div')
|
||||
caution.className = 'border-t border-edge-faint pt-2.5 text-content-muted'
|
||||
caution.textContent = hazard.geometry.type === 'Point' ? pointNote + ' ' + note : note
|
||||
box.append(metadata)
|
||||
if (hazard.source === 'GDACS' && hazard.alertScore != null) box.append(scoreBar(hazard.alertScore))
|
||||
box.append(caution)
|
||||
return box
|
||||
}
|
||||
|
||||
function scoreBar(score: number): HTMLDivElement {
|
||||
const panel = document.createElement('div')
|
||||
panel.className = 'trek-hazard-score'
|
||||
const heading = document.createElement('div')
|
||||
heading.className = 'trek-hazard-score-heading'
|
||||
const label = document.createElement('span')
|
||||
label.textContent = 'GDACS Score'
|
||||
const value = document.createElement('strong')
|
||||
value.textContent = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(score)
|
||||
value.style.color = score < 1 ? 'var(--success)' : score < 2 ? 'var(--warning)' : 'var(--danger)'
|
||||
heading.append(label, value)
|
||||
const track = document.createElement('div')
|
||||
track.className = 'trek-hazard-score-track'
|
||||
track.setAttribute('role', 'img')
|
||||
track.setAttribute('aria-label', 'GDACS Score ' + value.textContent)
|
||||
for (const color of ['success', 'warning', 'danger']) {
|
||||
const segment = document.createElement('span')
|
||||
segment.style.background = 'var(--' + color + ')'
|
||||
track.append(segment)
|
||||
}
|
||||
const marker = document.createElement('span')
|
||||
marker.className = 'trek-hazard-score-marker'
|
||||
marker.style.left = Math.min(score / 3, 1) * 100 + '%'
|
||||
track.append(marker)
|
||||
const ticks = document.createElement('div')
|
||||
ticks.className = 'trek-hazard-score-ticks'
|
||||
for (const tick of ['0', '1', '2', '3+']) {
|
||||
const number = document.createElement('span')
|
||||
number.textContent = tick
|
||||
ticks.append(number)
|
||||
}
|
||||
panel.append(heading, track, ticks)
|
||||
return panel
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteVia } from '../../types'
|
||||
|
||||
export interface MapHoverInfo {
|
||||
name?: string | null
|
||||
address?: string | null
|
||||
category_name?: string | null
|
||||
category_icon?: string | null
|
||||
category_color?: string | null
|
||||
routeVia?: RouteVia
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createElement } from 'react'
|
||||
import { Moon } from 'lucide-react'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type { RouteVia } from '../../types'
|
||||
|
||||
const moon = renderIconMarkup(createElement(Moon, { size: 13, strokeWidth: 1.8, 'aria-hidden': true }))
|
||||
export const NIGHT_PAUSE_MIN_ZOOM = 6
|
||||
|
||||
export function nightPauseMarker(via: RouteVia): string {
|
||||
const pause = via.nightPause!
|
||||
const position = pause.atPlace ? 'left:27px;top:-27px;' : 'left:0;top:-38px;transform:translateX(-50%);'
|
||||
const stem = pause.atPlace
|
||||
? 'left:20px;top:-7px;width:10px;height:1px;transform:rotate(-35deg);transform-origin:left center;'
|
||||
: 'left:-.5px;top:-14px;width:1px;height:14px;'
|
||||
return `<span aria-hidden="true" style="position:absolute;${stem}background:var(--text-muted);pointer-events:none;"></span><span data-night-pause="${pause.atPlace ? 'place' : 'route'}" style="position:absolute;${position}display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 8px;box-sizing:border-box;border:1px solid var(--border-primary);border-radius:9px;background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow-sm);font-family:var(--font-system);font-size:calc(11px * var(--fs-scale-caption,1));font-weight:600;line-height:1;white-space:nowrap;cursor:inherit;">${moon}<span>${escapeHtml(String(pause.day))}</span></span>`
|
||||
}
|
||||
@@ -70,5 +70,7 @@ export interface Poi {
|
||||
capacity: number | null
|
||||
fee: boolean | null
|
||||
} | null
|
||||
source: 'openstreetmap'
|
||||
source: string
|
||||
pluginId?: string
|
||||
rating?: number | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Poi } from './poiCategories'
|
||||
import { clusterPois, poiClusterList } from './poiClusters'
|
||||
|
||||
const station = (id: string, lng: number): Poi => ({
|
||||
osm_id: id, name: id, lat: 48, lng, category: 'charging_station',
|
||||
poi_type: 'charging_station', address: null, website: null, phone: null,
|
||||
opening_hours: null, cuisine: null, source: 'openstreetmap',
|
||||
})
|
||||
const project = (poi: Poi) => ({ x: poi.lng, y: poi.lat })
|
||||
|
||||
describe('station clusters', () => {
|
||||
it('only clusters in the distant overview, even for coincident stations', () => {
|
||||
const pois = [station('a', 1), station('b', 1)]
|
||||
expect(clusterPois(pois, project, 8)).toHaveLength(1)
|
||||
expect(clusterPois(pois, project, 9)).toHaveLength(2)
|
||||
expect(clusterPois(pois, project, 14)).toHaveLength(2)
|
||||
})
|
||||
it('groups across grid edges, counts unique stations and preserves the originals', () => {
|
||||
const a = station('a', 47), b = station('b', 49), c = station('c', 150)
|
||||
const groups = clusterPois([c, b, a, a], project)
|
||||
expect(groups.map(group => group.pois.length)).toEqual([2, 1])
|
||||
expect(groups[0]).toMatchObject({ lat: 48, lng: 48 })
|
||||
expect(groups[0].pois[0]).toBe(a)
|
||||
expect(a.lng).toBe(47)
|
||||
expect(clusterPois([a, b, c], project)).toEqual(groups)
|
||||
})
|
||||
|
||||
it('separates stations as their projected distance grows on zoom', () => {
|
||||
const pois = [station('a', 1), station('b', 2)]
|
||||
expect(clusterPois(pois, project)).toHaveLength(1)
|
||||
expect(clusterPois(pois, poi => ({ x: poi.lng * 100, y: 0 }))).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps the center near the date line', () => {
|
||||
const groups = clusterPois([station('a', 179), station('b', -179)], () => ({ x: 0, y: 0 }))
|
||||
expect(groups[0].lng).toBe(180)
|
||||
})
|
||||
|
||||
it('offers coincident stations individually without interpreting their names as HTML', () => {
|
||||
const poi = { ...station('a', 1), name: '<img src=x>' }
|
||||
const select = vi.fn()
|
||||
const list = poiClusterList([poi], select)
|
||||
expect(list.querySelector('img')).toBeNull()
|
||||
expect(list.textContent).toBe(poi.name)
|
||||
list.querySelector('button')!.click()
|
||||
expect(select).toHaveBeenCalledWith(poi)
|
||||
})
|
||||
})
|
||||
|
||||
it('combines nearby small groups into one overview cluster', () => {
|
||||
const pois = [station('a', 0), station('b', 3), station('c', 20), station('d', 23), station('e', 40), station('f', 43)]
|
||||
expect(clusterPois(pois, project, 8).map(group => group.pois.length)).toEqual([6])
|
||||
expect(clusterPois(pois, project, 9)).toHaveLength(6)
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
export const POI_CLUSTER_RADIUS = 48
|
||||
export const POI_CLUSTER_MAX_ZOOM = 8
|
||||
export const POI_CLUSTER_DETAIL_ZOOM = 18
|
||||
export interface PoiCluster { pois: Poi[]; lat: number; lng: number }
|
||||
|
||||
export function clusterPois(pois: Poi[], project: (poi: Poi) => { x: number; y: number }, zoom = 0): PoiCluster[] {
|
||||
const groups: (PoiCluster & { x: number; y: number })[] = []
|
||||
const cells = new Map<string, number[]>()
|
||||
const unique = new Map(pois.map(poi => [poi.osm_id, poi]))
|
||||
if (zoom > POI_CLUSTER_MAX_ZOOM) return [...unique.values()].map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng }))
|
||||
for (const poi of [...unique.values()].sort((a, b) => a.osm_id.localeCompare(b.osm_id))) {
|
||||
const point = project(poi)
|
||||
const cx = Math.floor(point.x / POI_CLUSTER_RADIUS), cy = Math.floor(point.y / POI_CLUSTER_RADIUS)
|
||||
let closest: typeof groups[number] | undefined
|
||||
let distance = POI_CLUSTER_RADIUS
|
||||
for (let x = cx - 1; x <= cx + 1; x++) for (let y = cy - 1; y <= cy + 1; y++) {
|
||||
for (const index of cells.get(`${x},${y}`) ?? []) {
|
||||
const group = groups[index]
|
||||
const gap = Math.hypot(point.x - group.x, point.y - group.y)
|
||||
if (gap < distance) { closest = group; distance = gap }
|
||||
}
|
||||
}
|
||||
if (closest) closest.pois.push(poi)
|
||||
else {
|
||||
const key = `${cx},${cy}`
|
||||
const indices = cells.get(key) ?? []
|
||||
indices.push(groups.length)
|
||||
cells.set(key, indices)
|
||||
groups.push({ pois: [poi], lat: poi.lat, lng: poi.lng, ...point })
|
||||
}
|
||||
}
|
||||
return groups.map(group => ({
|
||||
pois: group.pois,
|
||||
lat: group.pois.reduce((sum, poi) => sum + poi.lat, 0) / group.pois.length,
|
||||
lng: group.lng + group.pois.reduce((sum, poi) => sum + ((poi.lng - group.lng + 540) % 360 - 180), 0) / group.pois.length,
|
||||
}))
|
||||
}
|
||||
|
||||
export function poiClusterMarkup(count: number): string {
|
||||
return `<span data-poi-cluster="${count}" style="box-sizing:border-box;display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:3px solid var(--bg-card);background:var(--accent);color:var(--accent-text);box-shadow:var(--shadow-md);font-family:var(--font-system);font-size:calc(13px * var(--fs-scale-caption,1));font-weight:600;cursor:pointer;">${count}</span>`
|
||||
}
|
||||
|
||||
export function poiClusterList(pois: Poi[], select: (poi: Poi) => void): HTMLElement {
|
||||
const list = document.createElement('div')
|
||||
list.className = 'flex max-h-64 flex-col gap-1 overflow-y-auto p-1'
|
||||
for (const poi of pois) {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.className = 'rounded-lg px-3 py-2 text-start text-caption font-medium text-content hover:bg-surface-hover focus-visible:bg-surface-hover'
|
||||
button.textContent = poi.name
|
||||
button.addEventListener('click', event => { event.stopPropagation(); select(poi) })
|
||||
list.append(button)
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { Map, Popup, MapMouseEvent } from 'mapbox-gl'
|
||||
import type { RoadtripHazard } from '@trek/shared'
|
||||
import { hazardPopup, hazardFeature } from './hazardPopup'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
export function useHazardLayerGL(map: Map | null, ready: boolean, hazards: RoadtripHazard[] | undefined, popup: () => Popup) {
|
||||
const { t } = useTranslation()
|
||||
useEffect(() => {
|
||||
if (!map || !ready || !hazards?.length) return
|
||||
const source = 'roadtrip-hazards'
|
||||
const layers = [`${source}-fill`, `${source}-line`, `${source}-point`]
|
||||
let detail: Popup | undefined
|
||||
const draw = () => {
|
||||
if (!map.isStyleLoaded() || map.getSource(source)) return
|
||||
const color = getComputedStyle(document.documentElement).getPropertyValue('--warning').trim()
|
||||
map.addSource(source, { type: 'geojson', data: { type: 'FeatureCollection', features: hazards.map(hazardFeature) } })
|
||||
map.addLayer({ id: layers[0], type: 'fill', source, filter: ['!=', ['geometry-type'], 'Point'], paint: { 'fill-color': color, 'fill-opacity': 0.16 } })
|
||||
map.addLayer({ id: layers[1], type: 'line', source, filter: ['!=', ['geometry-type'], 'Point'], paint: { 'line-color': color, 'line-width': 2 } })
|
||||
map.addLayer({ id: layers[2], type: 'circle', source, filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-color': color, 'circle-radius': 7 } })
|
||||
}
|
||||
const click = (event: MapMouseEvent) => {
|
||||
if (!map.getLayer(layers[0])) return
|
||||
const hit = map.queryRenderedFeatures(event.point, { layers })[0]
|
||||
const hazard = hazards.find(item => item.id === hit?.properties?.id)
|
||||
if (!hazard) return
|
||||
detail?.remove()
|
||||
detail = popup().setLngLat(event.lngLat).setDOMContent(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point'))).addTo(map)
|
||||
}
|
||||
draw()
|
||||
map.on('style.load', draw)
|
||||
map.on('click', click)
|
||||
return () => {
|
||||
detail?.remove()
|
||||
map.off('style.load', draw)
|
||||
map.off('click', click)
|
||||
for (const layer of layers) if (map.getLayer(layer)) map.removeLayer(layer)
|
||||
if (map.getSource(source)) map.removeSource(source)
|
||||
}
|
||||
}, [map, ready, hazards, popup, t])
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RoadtripHazards } from '@trek/shared'
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { roadtripHazardsRepo } from '../../repo/roadtripHazardsRepo'
|
||||
import { isEffectivelyOffline, onNetworkModeChange } from '../../sync/networkMode'
|
||||
|
||||
export function useRoadtripHazards(tripId: number | undefined, roadtrip: boolean) {
|
||||
const enabled = useRoadtripSettings(s => s.roadtrip_show_hazards === true, tripId) && roadtrip && !!tripId
|
||||
const [feed, setFeed] = useState<RoadtripHazards | null>(null)
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable' | 'offline'>('loading')
|
||||
useEffect(() => {
|
||||
if (!enabled || !tripId) return
|
||||
let controller: AbortController
|
||||
const load = () => {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
setFeed(null)
|
||||
if (isEffectivelyOffline()) { setStatus('offline'); return }
|
||||
setStatus('loading')
|
||||
roadtripHazardsRepo.read(tripId, signal).then(saved => {
|
||||
if (!signal.aborted) { setFeed(saved); setStatus('ready') }
|
||||
}).catch(() => { if (!signal.aborted) setStatus('unavailable') })
|
||||
}
|
||||
load()
|
||||
const timer = window.setInterval(load, 600000)
|
||||
const unsubscribe = onNetworkModeChange(load)
|
||||
window.addEventListener('online', load)
|
||||
return () => { controller.abort(); window.clearInterval(timer); unsubscribe(); window.removeEventListener('online', load) }
|
||||
}, [enabled, tripId])
|
||||
return { enabled, feed: enabled ? feed : null, status }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { isServiceStopType } from '../Roadtrip/roadtripModel'
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
@@ -180,6 +182,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false)
|
||||
const tripActions = useRef(useTripStore.getState()).current
|
||||
const can = useCanDo()
|
||||
const canEditDays = can('day_edit', trip)
|
||||
@@ -409,7 +412,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}
|
||||
|
||||
const getDayAssignments = (dayId) =>
|
||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
(assignments[String(dayId)] || []).filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type)).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
|
||||
// Compute initial day_plan_position for a transport based on time
|
||||
const computeTransportPosition = (r, da) => {
|
||||
@@ -485,7 +488,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
return map
|
||||
// getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [days, assignments, dayNotes, reservations, transportPosVersion])
|
||||
}, [days, assignments, dayNotes, reservations, transportPosVersion, mirrorServiceStops])
|
||||
|
||||
// Days whose inline route legs should be computed & shown. Desktop: the selected
|
||||
// day while the Route toggle is on. Mobile: each expanded day the user tapped
|
||||
@@ -710,7 +713,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// Unified reorder: assigns positions to ALL item types based on new visual order
|
||||
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
|
||||
// Capture previous place order for undo
|
||||
const prevAssignmentIds = getDayAssignments(dayId).map(a => a.id)
|
||||
const prevAssignmentIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
|
||||
// …and, per booking, the fields this call is about to overwrite, so a failed write
|
||||
// can put the visible order back instead of leaving a phantom one behind the error
|
||||
// toast. Restoring the whole array instead would also drop what a collaborator's
|
||||
@@ -957,7 +960,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const da = getDayAssignments(dayId)
|
||||
if (da.length < 3) return
|
||||
|
||||
const prevIds = da.map(a => a.id)
|
||||
const prevIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
|
||||
|
||||
// Separate fixed (stay at their index) and movable assignments. A place is
|
||||
// fixed if it's locked OR has a set time — timed places are anchored by their
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ChargingInfo from '../Roadtrip/ChargingInfo'
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
@@ -35,6 +36,8 @@ import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge
|
||||
import { NavigationMenu } from '../shared/NavigationMenu'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine } from './placeHoursFormat'
|
||||
import type { EndDayControlProps } from '../Roadtrip/EndDayControl'
|
||||
import VisitControls, { type RoadtripStayControl } from '../Roadtrip/VisitControls'
|
||||
|
||||
const detailsCache = new Map()
|
||||
|
||||
@@ -133,6 +136,9 @@ interface TripMember {
|
||||
}
|
||||
|
||||
interface PlaceInspectorProps {
|
||||
roadtripActive?: boolean
|
||||
roadtripEndDay?: EndDayControlProps
|
||||
roadtripStay?: RoadtripStayControl
|
||||
place: Place | null
|
||||
categories: Category[]
|
||||
/** 'trip' (default) keeps every existing trip-planner behaviour byte-identical;
|
||||
@@ -179,7 +185,7 @@ export default function PlaceInspector({
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate,
|
||||
leftWidth = 0, rightWidth = 0,
|
||||
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList,
|
||||
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
|
||||
}: PlaceInspectorProps) {
|
||||
// Plugins that declared a place-detail slot mount at the bottom of this panel,
|
||||
// scoped to the open place (trip mode only). Inline-filter like the other sites.
|
||||
@@ -417,6 +423,8 @@ export default function PlaceInspector({
|
||||
)}
|
||||
|
||||
{/* Description / Summary */}
|
||||
{roadtripActive && place.stop_type === 'charging' && <ChargingInfo placeId={place.id} />}
|
||||
{(roadtripEndDay || roadtripStay) && <VisitControls endDay={roadtripEndDay} stay={roadtripStay} />}
|
||||
{(place.description || googleDetails?.summary) && (
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import AutomaticDayStop from './AutomaticDayStop'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
|
||||
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
describe('automatic pause row', () => {
|
||||
it('offers map focus and a custom hover without a native tooltip', async () => {
|
||||
const focus = vi.fn()
|
||||
render(<AutomaticDayStop
|
||||
stop={{ lat: 3, lng: 4, automaticNight: { phase: 'end', fromDayNumber: 1 } } as RoadtripStop}
|
||||
entry={{ arrival: '18:00', departure: '18:00', anchored: false, dayOffset: 0 }}
|
||||
onFocus={focus}
|
||||
/>)
|
||||
expect(screen.getByText('18:00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.window.pointHint')).not.toBeInTheDocument()
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0]).toHaveAccessibleName('roadtrip.window.stop 18:00')
|
||||
expect(buttons[0]).not.toHaveAttribute('title')
|
||||
fireEvent.mouseEnter(buttons[0])
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('roadtrip.window.pointHint')
|
||||
fireEvent.click(screen.getByText('18:00'))
|
||||
expect(focus).toHaveBeenCalledWith(3, 4)
|
||||
})
|
||||
|
||||
it('shows the next departure when a visit continues after the pause', () => {
|
||||
render(<AutomaticDayStop
|
||||
stop={{ automaticNight: { phase: 'start', fromDayNumber: 1 } } as RoadtripStop}
|
||||
entry={{ arrival: '08:00', departure: '09:00', anchored: false, dayOffset: 0 }}
|
||||
/>)
|
||||
expect(screen.getByText('roadtrip.window.resume')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('roadtrip.window.departure')).toHaveTextContent('09:00')
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ArrowRight, Moon, Sunrise } from 'lucide-react'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { ScheduleEntry } from './roadtripModel'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
export default function AutomaticDayStop({ stop, entry, onFocus }: {
|
||||
stop: RoadtripStop
|
||||
entry?: ScheduleEntry
|
||||
onFocus?: (lat: number, lng: number) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format === '12h')
|
||||
const ending = stop.automaticNight?.phase === 'end'
|
||||
const Icon = ending ? Moon : Sunrise
|
||||
const Row = onFocus ? 'button' : 'div'
|
||||
return (
|
||||
<Tooltip label={t('roadtrip.window.pointHint')} placement="top">
|
||||
<Row
|
||||
type={onFocus ? 'button' : undefined}
|
||||
onClick={onFocus ? () => onFocus(stop.lat, stop.lng) : undefined}
|
||||
className={`my-1 flex min-h-8 w-full items-center gap-2 rounded-md bg-surface-secondary px-2 py-1 text-start text-caption text-content-muted ${onFocus ? 'cursor-pointer transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent' : ''}`}
|
||||
>
|
||||
<Icon size={13} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 font-medium">{t(ending ? 'roadtrip.window.stop' : 'roadtrip.window.resume')}</span>{' '}
|
||||
<span className="shrink-0 whitespace-nowrap font-medium leading-6 tabular-nums" style={{ fontSize: FS.time }}>{entry?.arrival ? formatClockTime(entry.arrival, is12h) : ''}</span>
|
||||
{entry?.departure && entry.departure !== entry.arrival ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap font-medium leading-6 tabular-nums" style={{ fontSize: FS.time }} aria-label={t('roadtrip.window.departure', { time: formatClockTime(entry.departure, is12h) })}>
|
||||
<ArrowRight size={10} aria-hidden />
|
||||
<span>{formatClockTime(entry.departure, is12h)}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</Row>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import ChargingInfo from './ChargingInfo'
|
||||
import { chargingRepo } from '../../repo/chargingRepo'
|
||||
|
||||
vi.mock('../../store/tripStore', () => ({ useTripStore: (select: (s: { trip: { id: number } }) => unknown) => select({ trip: { id: 1 } }) }))
|
||||
vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn() } }))
|
||||
const info = { checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Station', source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0', updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0, tariffs: [], pricesUnavailable: false }
|
||||
describe('Charging information', () => {
|
||||
it('shows availability, attribution and missing prices without inventing a price', async () => {
|
||||
vi.mocked(chargingRepo.read).mockResolvedValue(info)
|
||||
render(<TranslationProvider><ChargingInfo placeId={7} /></TranslationProvider>)
|
||||
expect(await screen.findByText('2/4 available')).toBeInTheDocument()
|
||||
expect(screen.getByText('No reliable data available')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Charging'))
|
||||
expect(screen.getByRole('link', { name: 'Operator' })).toHaveAttribute('href', 'https://example.com')
|
||||
})
|
||||
it('does not display free capacity for stale data', async () => {
|
||||
vi.mocked(chargingRepo.read).mockResolvedValue({ ...info, stale: true })
|
||||
render(<TranslationProvider><ChargingInfo placeId={7} /></TranslationProvider>)
|
||||
expect(await screen.findByText('Status outdated')).toBeInTheDocument()
|
||||
expect(screen.queryByText('2/4')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { PlugZap, Coins, ChevronDown } from 'lucide-react'
|
||||
import type { ChargingInfo as Info } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { chargingRepo } from '../../repo/chargingRepo'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
export default function ChargingInfo({ placeId, compact = false }: { placeId: number; compact?: boolean }) {
|
||||
const tripId = useTripStore(s => s.trip?.id)
|
||||
const { t } = useTranslation()
|
||||
const [info, setInfo] = useState<Info | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setInfo(null); setLoading(true)
|
||||
const refresh = async () => {
|
||||
if (!tripId || document.hidden) return
|
||||
try { const value = await chargingRepo.read(tripId, placeId); if (active) setInfo(value) }
|
||||
catch { if (active) setInfo(null) }
|
||||
finally { if (active) setLoading(false) }
|
||||
}
|
||||
const offline = () => { setInfo(null); setLoading(false) }
|
||||
void refresh()
|
||||
const timer = window.setInterval(() => void refresh(), 60000)
|
||||
window.addEventListener('offline', offline)
|
||||
window.addEventListener('online', refresh)
|
||||
document.addEventListener('visibilitychange', refresh)
|
||||
return () => { active = false; clearInterval(timer); window.removeEventListener('offline', offline); window.removeEventListener('online', refresh); document.removeEventListener('visibilitychange', refresh) }
|
||||
}, [tripId, placeId])
|
||||
const known = !isEffectivelyOffline() && info?.available != null && !info.stale && Date.now() - Date.parse(info.checkedAt) < 120000
|
||||
const status = loading ? t('common.loading') : known ? `${info.available}/${info.total} ${t('roadtrip.charging.available')}` : t(info?.stale ? 'roadtrip.charging.stale' : 'roadtrip.charging.unknown')
|
||||
const components = info?.tariffs.flatMap(tariff => tariff.components.map(component => ({ ...component, currency: tariff.currency }))) ?? []
|
||||
const energy = components.filter(component => component.kind === 'ENERGY' && component.taxIncluded && !component.conditional)
|
||||
const sameCurrency = new Set(energy.map(component => component.currency)).size === 1
|
||||
const cheapest = sameCurrency ? energy.reduce<typeof energy[number] | null>((best, component) => !best || component.price < best.price ? component : best, null) : null
|
||||
const price = (value: number, currency: string) => new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
|
||||
const badges = <span className="inline-flex shrink-0 items-center gap-1 align-middle whitespace-nowrap font-medium text-[length:calc(10px*var(--fs-scale-caption,1))] leading-none">
|
||||
<Tooltip label={`${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`}><span tabIndex={0} className={`inline-flex items-center gap-1 rounded px-1 py-0.5 ${known && info.available! > 0 ? 'bg-success-soft text-success' : 'bg-surface-secondary text-content-muted'}`}><PlugZap size={10} />{known ? `${info.available}/${info.total}` : '?'}</span></Tooltip>
|
||||
{cheapest && <Tooltip label={t('roadtrip.charging.note')}><span tabIndex={0} className="inline-flex items-center gap-1 rounded bg-surface-secondary px-1 py-0.5 text-content-muted"><Coins size={10} />{price(cheapest.price, cheapest.currency)}/kWh*</span></Tooltip>}
|
||||
</span>
|
||||
if (compact) return badges
|
||||
return <details className="group rounded-[10px] bg-surface-hover text-[length:calc(12px*var(--fs-scale-body,1))] text-content-secondary">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 px-3 py-2 [&::-webkit-details-marker]:hidden">
|
||||
<PlugZap size={13} className="shrink-0 text-content-faint" />
|
||||
<span className="font-medium">{t('roadtrip.poi.charging')}</span>
|
||||
<span className="ml-2 text-content-muted">{status}</span>
|
||||
{cheapest && <span className="ml-auto whitespace-nowrap font-medium">{price(cheapest.price, cheapest.currency)}/kWh*</span>}
|
||||
<ChevronDown size={13} className={'shrink-0 text-content-faint group-open:rotate-180 ' + (cheapest ? '' : 'ml-auto')} />
|
||||
</summary>
|
||||
<div className="space-y-2 px-3 pb-3 text-content-muted">
|
||||
{info?.station && <p>{info.station}</p>}
|
||||
<div className="space-y-1"><p className="font-medium">{t('roadtrip.charging.prices')}</p>
|
||||
{!components.length && <p className="text-content-muted">{info?.pricesUnavailable ? `${t('common.error')}: ` : ''}{t('roadtrip.charging.unknown')}</p>}
|
||||
{components.map((component, index) => <p key={index}>{price(component.price, component.currency)}{component.kind === 'ENERGY' ? '/kWh' : ['TIME', 'PARKING_TIME'].includes(component.kind) ? '/h' : ''} {component.kind === 'FLAT' ? 'Σ' : ''}{component.conditional || !component.taxIncluded ? '*' : ''}{component.afterSeconds != null && component.afterSeconds > 0 ? ` (> ${Math.round(component.afterSeconds / 60)} min)` : ''}</p>)}
|
||||
{!!components.length && <p className="text-content-muted">* {t('roadtrip.charging.note')}</p>}
|
||||
</div>
|
||||
<p className="text-content-muted">{t('roadtrip.charging.coverage')}</p>
|
||||
{info?.source && <p className="text-content-muted"><a className="underline" href={info.sourceUrl?.startsWith('https://') ? info.sourceUrl : 'https://mobidata-bw.de/de/dataset/e-ladesaulen'} target="_blank" rel="noreferrer">{info.source}</a> · <a className="underline" href="https://mobidata-bw.de/de/dataset/e-ladesaulen" target="_blank" rel="noreferrer">MobiData BW</a><br />{info.license}{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}</p>}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import DayWindowFields from './DayWindowFields'
|
||||
|
||||
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
describe('daily travel time settings', () => {
|
||||
it('saves a complete custom time and preserves the other time', () => {
|
||||
const save = vi.fn()
|
||||
render(<DayWindowFields start="08:00" end="18:00" onSave={save} />)
|
||||
const start = screen.getByRole('textbox', { name: 'roadtrip.window.start' })
|
||||
fireEvent.change(start, { target: { value: '07:00' } })
|
||||
expect(save).toHaveBeenCalledWith('roadtrip_day_start', '07:00')
|
||||
expect(screen.getByLabelText('roadtrip.window.end')).toHaveValue('18:00')
|
||||
})
|
||||
|
||||
it('allows clearing a time to turn automatic planning off', () => {
|
||||
const save = vi.fn()
|
||||
render(<DayWindowFields start="08:00" end="18:00" onSave={save} />)
|
||||
const end = screen.getByLabelText('roadtrip.window.end')
|
||||
fireEvent.change(end, { target: { value: '' } })
|
||||
fireEvent.blur(end)
|
||||
expect(save).toHaveBeenCalledWith('roadtrip_day_end', '')
|
||||
})
|
||||
|
||||
it('announces a reversed window and does not offer disabled inputs as editable', () => {
|
||||
render(<DayWindowFields start="18:00" end="08:00" />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('roadtrip.window.invalid')
|
||||
expect(screen.getByLabelText('roadtrip.window.start')).toBeDisabled()
|
||||
expect(screen.getByLabelText('roadtrip.window.end')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Sunrise, Sunset } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { dayWindow } from './dayWindow'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import SettingsHint from './SettingsHint'
|
||||
|
||||
export default function DayWindowFields({ start, end, endMode = 'route', onSave }: {
|
||||
start?: string
|
||||
end?: string
|
||||
endMode?: 'route' | 'stop'
|
||||
onSave?: (key: string, value: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [draft, setDraft] = useState<{ start?: string; end?: string }>({})
|
||||
const sent = useRef({ start, end })
|
||||
const shownStart = draft.start ?? start ?? ''
|
||||
const shownEnd = draft.end ?? end ?? ''
|
||||
const invalid = !!shownStart && !!shownEnd && !dayWindow(shownStart, shownEnd)
|
||||
const change = (field: 'start' | 'end', value: string) => {
|
||||
setDraft(prev => ({ ...prev, [field]: value }))
|
||||
if (value !== '' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return
|
||||
if (value !== (sent.current[field] ?? '')) {
|
||||
sent.current[field] = value
|
||||
onSave?.(`roadtrip_day_${field}`, value)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<fieldset className="flex min-w-0 flex-col gap-3">
|
||||
<legend className="sr-only">{t('roadtrip.window.title')}</legend>
|
||||
{(['start', 'end'] as const).map(field => {
|
||||
const Icon = field === 'start' ? Sunrise : Sunset
|
||||
return (
|
||||
<div key={field} className="flex min-h-9 items-center gap-3">
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{t(`roadtrip.window.${field}`)}</span>
|
||||
<CustomTimePicker
|
||||
value={field === 'start' ? shownStart : shownEnd}
|
||||
disabled={!onSave}
|
||||
aria-label={t(`roadtrip.window.${field}`)}
|
||||
aria-describedby="roadtrip-window-hint"
|
||||
aria-invalid={invalid || undefined}
|
||||
onChange={value => change(field, value)}
|
||||
style={{ width: '7rem' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{invalid
|
||||
? <p id="roadtrip-window-hint" role="alert" className="text-caption text-danger">{t('roadtrip.window.invalid')}</p>
|
||||
: <SettingsHint id="roadtrip-window-hint">{t('roadtrip.window.hint')}</SettingsHint>}
|
||||
<fieldset className="flex min-w-0 flex-col gap-2 border-t border-edge-faint pt-3">
|
||||
<legend className="sr-only">{t('roadtrip.window.endAt')}</legend>
|
||||
<span className="text-body text-content-secondary">{t('roadtrip.window.endAt')}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['route', 'stop'] as const).map(mode => (
|
||||
<label key={mode} className={`relative flex min-h-9 flex-1 cursor-pointer items-center rounded-lg border px-3 py-2 text-caption has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent ${endMode === mode ? 'border-accent bg-accent-subtle text-accent-on' : 'border-edge bg-surface-input text-content-secondary'}`}>
|
||||
<input type="radio" name="roadtrip-day-end-mode" value={mode} checked={endMode === mode} disabled={!onSave}
|
||||
onChange={() => onSave?.('roadtrip_day_end_mode', mode)}
|
||||
aria-describedby="roadtrip-end-mode-hint" className="sr-only" />
|
||||
{t(`roadtrip.window.${mode === 'route' ? 'onRoute' : 'atStop'}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<SettingsHint id="roadtrip-end-mode-hint">{t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)}</SettingsHint>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fireEvent, render, screen, act } from '@testing-library/react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import EndDayControl from './EndDayControl'
|
||||
|
||||
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
it('shows the saved state and blocks a second click while saving', async () => {
|
||||
let finish!: () => void
|
||||
const onToggle = vi.fn(() => new Promise<void>(resolve => { finish = resolve }))
|
||||
render(<EndDayControl active onToggle={onToggle} />)
|
||||
const toggle = screen.getByRole('button', { name: 'roadtrip.window.endHere' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toBeDisabled()
|
||||
fireEvent.click(toggle)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
await act(async () => finish())
|
||||
expect(toggle).toBeEnabled()
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { Moon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
export interface EndDayControlProps {
|
||||
active: boolean
|
||||
onToggle: () => Promise<void>
|
||||
}
|
||||
|
||||
export default function EndDayControl({ active, onToggle }: EndDayControlProps) {
|
||||
const { t } = useTranslation()
|
||||
const [pending, setPending] = useState(false)
|
||||
return (
|
||||
<fieldset disabled={pending} aria-busy={pending} className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 disabled:opacity-60">
|
||||
<span className="flex flex-1 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary"><Moon size={13} className={`shrink-0 ${active ? 'text-accent-on' : 'text-content-faint'}`} aria-hidden />{t('roadtrip.window.endHere')}</span>
|
||||
<ToggleSwitch on={active} label={t('roadtrip.window.endHere')} onToggle={async () => {
|
||||
if (pending) return
|
||||
setPending(true)
|
||||
try { await onToggle() } finally { setPending(false) }
|
||||
}} />
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import GoogleRouteImport from './GoogleRouteImport'
|
||||
import { googleRouteRepo } from '../../repo/googleRouteRepo'
|
||||
|
||||
const { loadTrip, state } = vi.hoisted(() => ({ loadTrip: vi.fn(), state: { days: [{ id: 3, day_number: 1, title: 'First day' }] } }))
|
||||
vi.mock('../../store/tripStore', () => ({ useTripStore: Object.assign((select: (s: typeof state) => unknown) => select(state), { getState: () => ({ loadTrip }) }) }))
|
||||
vi.mock('../../repo/googleRouteRepo', () => ({ googleRouteRepo: { preview: vi.fn(), append: vi.fn() } }))
|
||||
|
||||
async function openPreview() {
|
||||
render(<TranslationProvider><GoogleRouteImport tripId={1} dayId={3} /></TranslationProvider>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Google Maps route' }))
|
||||
fireEvent.click(screen.getByText('Import Google Maps route', { selector: 'span' }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Google Maps URL' }), { target: { value: 'https://google.com/maps/dir/A/B' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
|
||||
await screen.findByText('Munich')
|
||||
}
|
||||
|
||||
describe('Google route preview', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); loadTrip.mockResolvedValue(undefined) })
|
||||
|
||||
it('previews in order and only saves resolved stops after confirmation', async () => {
|
||||
vi.mocked(googleRouteRepo.preview).mockResolvedValue({ stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Unknown', lat: null, lng: null }, { name: 'Rome', lat: 41, lng: 12 }] })
|
||||
vi.mocked(googleRouteRepo.append).mockResolvedValue(undefined)
|
||||
await openPreview()
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual([expect.stringContaining('Munich'), expect.stringContaining('Unknown'), expect.stringContaining('Rome')])
|
||||
expect(screen.getByText('Location unresolved; this stop will be skipped.')).toBeInTheDocument()
|
||||
expect(googleRouteRepo.append).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import' }))
|
||||
await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1))
|
||||
expect(googleRouteRepo.append).toHaveBeenCalledWith(1, { dayId: 3, stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Rome', lat: 41, lng: 12 }] }, expect.any(String))
|
||||
})
|
||||
|
||||
it('blocks import when fewer than two locations resolve', async () => {
|
||||
vi.mocked(googleRouteRepo.preview).mockResolvedValue({ stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Unknown', lat: null, lng: null }] })
|
||||
await openPreview()
|
||||
expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled()
|
||||
expect(googleRouteRepo.append).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MoreHorizontal, Link2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { googleRouteRepo } from '../../repo/googleRouteRepo'
|
||||
import { generateUUID } from '../../sync/mutationQueue'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { GoogleRoutePreview } from '@trek/shared'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; dayId?: number }) {
|
||||
const { t } = useTranslation()
|
||||
const days = useTripStore(s => s.days)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [preview, setPreview] = useState<GoogleRoutePreview | null>(null)
|
||||
const [target, setTarget] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const requestId = useRef('')
|
||||
const menu = useContextMenu()
|
||||
useEffect(() => () => controller.current?.abort(), [])
|
||||
const inspect = async () => {
|
||||
controller.current?.abort()
|
||||
const abort = new AbortController()
|
||||
controller.current = abort
|
||||
setBusy(true); setFailure(''); setPreview(null)
|
||||
try { const parsed = await googleRouteRepo.preview(url, abort.signal); if (!abort.signal.aborted) setPreview(parsed) }
|
||||
catch (error) { if (!abort.signal.aborted) setFailure(getApiErrorMessage(error, t('common.error'))) }
|
||||
finally { if (!abort.signal.aborted) setBusy(false) }
|
||||
}
|
||||
const save = async () => {
|
||||
if (!preview || !target) return
|
||||
setBusy(true); setFailure('')
|
||||
try {
|
||||
const stops = preview.stops.filter(s => s.lat != null && s.lng != null).map(s => ({ name: s.name, lat: s.lat!, lng: s.lng! }))
|
||||
await googleRouteRepo.append(tripId, { dayId: Number(target), stops }, requestId.current)
|
||||
setSaved(true)
|
||||
await useTripStore.getState().loadTrip(tripId)
|
||||
setOpen(false)
|
||||
} catch (error) { setFailure(getApiErrorMessage(error, t('common.error'))) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const show = () => {
|
||||
setUrl(''); setPreview(null); setFailure(''); setSaved(false)
|
||||
setTarget(String(dayId ?? days[0]?.id ?? ''))
|
||||
requestId.current = generateUUID(); setOpen(true)
|
||||
}
|
||||
const resolved = preview?.stops.filter(s => s.lat != null && s.lng != null) ?? []
|
||||
return <>
|
||||
<Tooltip label={t('roadtrip.import.title')}>
|
||||
<button type="button" aria-label={t('roadtrip.import.title')} className="shrink-0 rounded-lg p-2 text-content-muted hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||
onClick={e => menu.open(e, [{ label: t('roadtrip.import.title'), icon: Link2, onClick: show }], true)}>
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||
<Modal isOpen={open} onClose={() => { if (!busy) setOpen(false) }} title={t('roadtrip.import.title')} size="lg">
|
||||
<div className="space-y-4">
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.import.note')}</p>
|
||||
{!preview ? <input type="url" value={url} disabled={busy} onChange={e => setUrl(e.target.value)} aria-label="Google Maps URL" placeholder="https://maps.app.goo.gl/…"
|
||||
className="w-full rounded-xl border border-edge bg-surface-input p-3 text-body text-content" /> : <>
|
||||
<CustomSelect value={target} onChange={value => { setTarget(String(value)); requestId.current = generateUUID() }}
|
||||
options={days.map(day => ({ value: String(day.id), label: day.title || `${t('roadtrip.import.day')} ${day.day_number}` }))} disabled={busy || saved} />
|
||||
<ol className="max-h-64 space-y-2 overflow-auto">
|
||||
{preview.stops.map((stop, index) => <li key={index} className="flex items-baseline gap-3 text-body text-content">
|
||||
<span className="text-caption tabular-nums text-content-muted">{index + 1}</span>
|
||||
<span className="min-w-0">{stop.name}{stop.name !== `${stop.lat}, ${stop.lng}` && <span className="block text-caption text-content-muted">{stop.lat == null || stop.lng == null ? t('roadtrip.import.unresolved') : `${stop.lat.toFixed(5)}, ${stop.lng.toFixed(5)}`}</span>}</span>
|
||||
</li>)}
|
||||
</ol>
|
||||
</>}
|
||||
{failure && <p role="alert" className="text-caption text-danger">{saved ? t('common.saved') + ': ' : ''}{failure}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" disabled={busy} className="rounded-lg px-3 py-2 text-body text-content-secondary" onClick={() => setOpen(false)}>{t('common.cancel')}</button>
|
||||
<button type="button" disabled={busy || saved || (!preview ? !url.trim() : resolved.length < 2 || !target)} onClick={() => void (preview ? save() : inspect())}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-body font-medium text-accent-text disabled:opacity-50">
|
||||
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
}
|
||||
@@ -279,6 +279,14 @@ describe('RoadtripCorridorPanel', () => {
|
||||
expect(screen.getByText('Aral')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the plugin source and failed providers beside remaining hits', () => {
|
||||
const c = corridor({}, { failedSources: ['plugin:unavailable'], results: [poi({ osm_id: 'plugin:stations:one', name: 'Plugin Station', pluginId: 'stations' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
expect(screen.getByText('Plugin Station')).toBeInTheDocument()
|
||||
expect(screen.getByText('stations')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error: plugin:unavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-015: a total outage reads as a failure, not as an empty road', () => {
|
||||
const c = corridor({}, { error: true, failedAreas: 4 })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import GoogleRouteImport from './GoogleRouteImport'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, ChevronDown,
|
||||
@@ -17,6 +18,8 @@ import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
interface RoadtripCorridorPanelProps {
|
||||
tripId?: number
|
||||
canImport?: boolean
|
||||
corridor: RoadtripCorridor
|
||||
routes: RoadtripRoutes
|
||||
/** Opens the place form prefilled from a POI, on a day and at a position in it. */
|
||||
@@ -103,6 +106,7 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
|
||||
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
{poi.name}
|
||||
</div>
|
||||
{poi.pluginId && <p className="truncate text-caption text-content-muted">{poi.pluginId}</p>}
|
||||
{/* Two facts, two chips. Where it is off the road and how far into the drive it
|
||||
comes are separate answers, and as two phrases sharing a line they read as one
|
||||
run-on sentence about the same thing. A chip each gives them an edge, and the
|
||||
@@ -249,7 +253,7 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
|
||||
* header rather than among the filters — it is the question's subject, not one of its
|
||||
* conditions.
|
||||
*/
|
||||
export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: RoadtripCorridorPanelProps): React.ReactElement {
|
||||
export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi, tripId, canImport }: RoadtripCorridorPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const { search } = corridor
|
||||
@@ -299,6 +303,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
: 0
|
||||
|
||||
const warnings: [string, string][] = []
|
||||
if (search.failedSources?.length) warnings.push(['sources', `${t('common.error')}: ${search.failedSources.join(', ')}`])
|
||||
if (search.capped) warnings.push(['capped', t('roadtrip.poi.capped')])
|
||||
if (search.error) warnings.push(['failed', t('roadtrip.poi.failed')])
|
||||
// Some boxes answered and some did not. Saying so is the difference between "there is
|
||||
@@ -327,6 +332,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{tripId && canImport && <GoogleRouteImport tripId={tripId} dayId={corridor.day?.dayId} />}
|
||||
</div>
|
||||
|
||||
{/* What to look for. */}
|
||||
|
||||
@@ -45,6 +45,21 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('RoadtripLimitsCard', () => {
|
||||
it.each(['electric', 'petrol'])('saves manual range before fill changes for %s', kind => {
|
||||
useSettingsStore.setState({ settings: { roadtrip_vehicle: kind, roadtrip_range_km: 0, distance_unit: 'metric' } as never })
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
const range = screen.getByTestId('limit-range')
|
||||
fireEvent.change(range, { target: { value: '100' } })
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
fireEvent.blur(range)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_range_km', 100)
|
||||
const fill = screen.getByTestId('limit-fill')
|
||||
fireEvent.change(fill, { target: { value: '80' } })
|
||||
fireEvent.blur(fill)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_fill_percent', 80)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-001: typing a number writes nothing until the field is left', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
@@ -261,3 +276,16 @@ describe('RoadtripLimitsCard', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||
}))
|
||||
|
||||
it('shares service stops with Days by default and lets the trip turn it off', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
const toggle = screen.getByRole('button', { name: 'Show in Days too' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(toggle)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_service_stops_in_days', false)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship,
|
||||
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check,
|
||||
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
|
||||
} from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
@@ -14,6 +15,9 @@ import { valhallaAvailable } from '../Map/valhallaRoute'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import type { DistanceUnit, RouteAvoidClass } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
import DayWindowFields from './DayWindowFields'
|
||||
import SettingsHint from './SettingsHint'
|
||||
import { dayWindow } from './dayWindow'
|
||||
|
||||
/**
|
||||
* Everything that decides when the rail speaks up about the driving.
|
||||
@@ -190,10 +194,10 @@ function Panel({ icon: Icon, title, note, children }: {
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-4 py-3.5">
|
||||
<section className="flex min-w-0 flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-4 py-3.5">
|
||||
<SectionTitle icon={Icon}>{title}</SectionTitle>
|
||||
{children}
|
||||
{note ? <p className="text-caption leading-snug text-content-faint">{note}</p> : null}
|
||||
{note ? <SettingsHint>{note}</SettingsHint> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -248,7 +252,9 @@ const SPEC_ROWS: Record<VehicleKind, { key: SpecKey; setting: string; Icon: type
|
||||
*/
|
||||
const SPEC_SETTINGS = ['roadtrip_tank_litres', 'roadtrip_litres_per_100', 'roadtrip_battery_kwh', 'roadtrip_kwh_per_100', 'roadtrip_battery_degradation'] as const
|
||||
|
||||
export default function RoadtripLimitsCard({ onSave }: {
|
||||
export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loading = false }: {
|
||||
loading?: boolean
|
||||
onResetDayBoundaries?: () => Promise<void>
|
||||
/**
|
||||
* Persists one setting. Absent leaves the dialog read-only.
|
||||
*
|
||||
@@ -258,13 +264,15 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
onSave?: (key: string, value: number | string | boolean) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
const settings = useRoadtripSettings(s => s)
|
||||
const unit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [open, setOpen] = useState(false)
|
||||
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
||||
const distanceUnit: DistanceUnit = unit === 'imperial' ? 'imperial' : 'metric'
|
||||
const imperial = distanceUnit === 'imperial'
|
||||
|
||||
const legMinutes = settings.roadtrip_leg_minutes
|
||||
const dayMinutes = settings.roadtrip_day_minutes
|
||||
const automaticWindow = dayWindow(settings.roadtrip_day_start, settings.roadtrip_day_end)
|
||||
const rangeKm = settings.roadtrip_range_km
|
||||
|
||||
// Parsed rather than trusted: a per-user setting gets no server-side validation, and
|
||||
@@ -335,7 +343,8 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
|
||||
// third too early for ever after. The same rule runs through the vehicle's own figures,
|
||||
// where showSpec and storeSpec do it per field.
|
||||
const rangeShown = planningKm ? Math.round(convertDistance(planningKm, distanceUnit)) : undefined
|
||||
const rangeValue = computedKm ?? rangeKm
|
||||
const rangeShown = rangeValue ? Math.round(convertDistance(rangeValue, distanceUnit)) : undefined
|
||||
const setRange = (shown: number) => {
|
||||
const km = imperial ? shown / 0.621371 : shown
|
||||
onSave?.('roadtrip_range_km', Math.round(km))
|
||||
@@ -347,6 +356,7 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
// imperial traveller reads miles here and types miles in the dialog, while what is
|
||||
// stored stays kilometres either way.
|
||||
const badges = [
|
||||
automaticWindow ? { key: 'window', Icon: CalendarClock, text: `${settings.roadtrip_day_start} · ${settings.roadtrip_day_end}` } : null,
|
||||
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
|
||||
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
|
||||
// The planning range, not the typed one: when the vehicle's figures win, the badge
|
||||
@@ -374,6 +384,8 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={loading}
|
||||
aria-busy={loading}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{/* No icon tile on the trigger: the badges below already carry a clock, a
|
||||
@@ -381,7 +393,7 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
they need to sit on one line. */}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate text-body font-semibold text-content">
|
||||
{t('roadtrip.limit.title')}
|
||||
{loading ? t('common.loading') : t('roadtrip.limit.title')}
|
||||
</span>
|
||||
{badges.length ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
@@ -418,7 +430,7 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
{t('roadtrip.limit.title')}
|
||||
</span>
|
||||
}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
{/* Two columns, kept. One column reads more tidily but turns the dialog into
|
||||
something that has to be scrolled, and a setting you cannot see while you
|
||||
change another is worse than a seam down the middle.
|
||||
@@ -426,9 +438,9 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
What answers the seam is not the count of columns but what is in them: each
|
||||
section is now a panel of its own with a visible edge, every row inside it
|
||||
ends on the same right edge, and the panels are stacked to one rhythm. The
|
||||
left column is the trip, the right one is the car. */}
|
||||
left column holds trip settings, the right holds the car and route appearance. */}
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Panel icon={Clock} title={t('roadtrip.limit.sectionDriving')} note={t('roadtrip.limit.hint')}>
|
||||
<LimitRow
|
||||
icon={Clock}
|
||||
@@ -450,6 +462,11 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel icon={CalendarClock} title={t('roadtrip.window.title')}>
|
||||
<DayWindowFields start={settings.roadtrip_day_start} end={settings.roadtrip_day_end} endMode={settings.roadtrip_day_end_mode} onSave={onSave} />
|
||||
{onResetDayBoundaries && <button type="button" onClick={() => void onResetDayBoundaries()} className="text-start text-caption font-medium text-accent-on hover:underline">{t('roadtrip.window.resetBoundaries')}</button>}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
icon={Signpost}
|
||||
title={t('roadtrip.avoid.section')}
|
||||
@@ -466,19 +483,155 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
/>
|
||||
))}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel icon={Route} title={t('roadtrip.line.section')} note={t('roadtrip.line.hint')}>
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Panel
|
||||
icon={Car}
|
||||
title={t('roadtrip.limit.sectionVehicle')}
|
||||
note={vehicleKind ? t('roadtrip.limit.specHint') : t('roadtrip.limit.vehicleHint')}
|
||||
>
|
||||
{/* First, because it decides what everything under it means: with no
|
||||
vehicle named, a petrol station and a charger both fill the tank, which
|
||||
is arithmetic that is wrong for everybody who drives just one of them.
|
||||
|
||||
Radios in a segmented shell rather than a dropdown: three options that
|
||||
change the meaning of everything below them should be readable without
|
||||
being opened, and the arrow keys, the names and the roles then come
|
||||
from the browser instead of from a rebuilt listbox. */}
|
||||
<fieldset className="min-w-0">
|
||||
<legend className="sr-only">{t('roadtrip.limit.vehicleLabel')}</legend>
|
||||
<div className="grid grid-cols-3 gap-1 rounded-xl border border-edge bg-surface-tertiary p-1">
|
||||
{VEHICLES.map(({ key, labelKey, Icon }) => (
|
||||
<label key={key ?? 'unset'} className="min-w-0">
|
||||
<input
|
||||
type="radio"
|
||||
name="roadtrip-vehicle"
|
||||
className="peer sr-only"
|
||||
checked={(settings.roadtrip_vehicle || '') === (key ?? '')}
|
||||
onChange={() => {
|
||||
// Never writes the car's figures away: switching kind leaves
|
||||
// both sets stored, `rangeFromSpec` simply returns null for
|
||||
// the other one, and the typed range comes back as an editable
|
||||
// field. Deleting the other kind's values here would lose data
|
||||
// on a mis-click.
|
||||
setPreview(null)
|
||||
onSave?.('roadtrip_vehicle', key ?? '')
|
||||
}}
|
||||
/>
|
||||
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent">
|
||||
<Icon size={14} className="shrink-0" aria-hidden />
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<RangeStrip
|
||||
rangeKm={planningKm}
|
||||
fillPercent={shownFill}
|
||||
wearPercent={vehicleKind === 'electric' ? spec.degradationPercent ?? null : null}
|
||||
unit={distanceUnit}
|
||||
electric={vehicleKind === 'electric'}
|
||||
/>
|
||||
|
||||
{/* Read-only once the parts add up, because then it IS the parts. Leaving
|
||||
it editable would offer a second answer to a question that already has
|
||||
one, and the traveller would have no way of telling which the warnings
|
||||
were using. Clearing a field above hands the row straight back. */}
|
||||
<LimitRow
|
||||
icon={Route}
|
||||
// "on one tank" is the wrong noun for half the travellers who set this.
|
||||
label={t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel')}
|
||||
suffix={imperial ? 'mi' : 'km'}
|
||||
value={rangeShown}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
derived={computedKm ? t('roadtrip.limit.computed') : undefined}
|
||||
testId="limit-range"
|
||||
onDraft={v => setPreview({ key: 'range', value: v })}
|
||||
onChange={setRange}
|
||||
/>
|
||||
{/* Under the range, because it is a fraction OF it. Nobody charges to
|
||||
100 % on the road: the last fifth takes as long as the first four, so a
|
||||
stop counted as a full tank overstates everything after it by that
|
||||
fifth. */}
|
||||
<LimitRow
|
||||
icon={BatteryCharging}
|
||||
label={t('roadtrip.limit.fillLabel')}
|
||||
suffix="%"
|
||||
value={settings.roadtrip_fill_percent}
|
||||
placeholder={t('roadtrip.limit.fillFull')}
|
||||
testId="limit-fill"
|
||||
onDraft={v => setPreview({ key: 'fill', value: v > 100 ? 100 : v })}
|
||||
onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)}
|
||||
/>
|
||||
|
||||
{/* The car's own figures, folded away. Nothing here is required — somebody
|
||||
who knows their range types it above and never opens this — but it
|
||||
starts open the moment any of them is stored, because otherwise the
|
||||
range row is read-only with its cause out of sight. */}
|
||||
{vehicleKind ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSpecOpen(o => !o); setPreview(null) }}
|
||||
aria-expanded={specOpen}
|
||||
aria-controls="roadtrip-spec"
|
||||
className="-mx-1 flex items-center gap-1.5 self-start rounded-lg px-1 py-0.5 text-caption font-semibold text-accent-on transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className={`shrink-0 transition-transform ${specOpen ? 'rotate-90' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t('roadtrip.limit.specToggle')}
|
||||
</button>
|
||||
{/* `hidden` rather than unmounting: it really leaves the tab order,
|
||||
and a field that kept a draft while folded away would be a draft
|
||||
nobody can see. */}
|
||||
{/* The display class only while it is open. `[hidden]` sets
|
||||
`display: none` in the preflight, but a `flex` class beats it on
|
||||
specificity — so the attribute flipped, the semantics were right,
|
||||
and the panel stayed on screen. */}
|
||||
<div id="roadtrip-spec" hidden={!specOpen} className={specOpen ? 'flex flex-col gap-3' : ''}>
|
||||
{SPEC_ROWS[vehicleKind].map(({ key, setting, Icon, labelKey, step }) => (
|
||||
<LimitRow
|
||||
key={key}
|
||||
icon={Icon}
|
||||
label={t(labelKey)}
|
||||
suffix={specUnit(key, imperial)}
|
||||
step={step}
|
||||
value={showSpec(key, saved[key], imperial)}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
testId={`limit-${key}`}
|
||||
onDraft={v => setPreview({ key, value: v })}
|
||||
onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Panel icon={Route} title={t('roadtrip.line.section')} note={automaticWindow ? undefined : t('roadtrip.line.hint')}>
|
||||
{/* The road between one day's last stop and the next day's first is real
|
||||
driving that the rail has never drawn: days are routed one at a time,
|
||||
so that gap was never asked for. Off by default because it costs a
|
||||
routing request per join and changes every day's kilometres. */}
|
||||
<AvoidRow
|
||||
{automaticWindow ? (
|
||||
<div className="flex items-center gap-3" title={t('roadtrip.window.hint')}>
|
||||
<Link2 size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{t('roadtrip.line.connect')}</span>
|
||||
<Check size={16} className="text-accent-on" aria-label={t('roadtrip.window.hint')} />
|
||||
</div>
|
||||
) : <AvoidRow
|
||||
icon={Link2}
|
||||
label={t('roadtrip.line.connect')}
|
||||
on={!!settings.roadtrip_connect_days}
|
||||
disabled={false}
|
||||
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
|
||||
/>
|
||||
/>}
|
||||
{/* Which matters most once the line IS continuous: end to end it is one
|
||||
stroke, and a colour per day is what puts the days back into it. */}
|
||||
<AvoidRow
|
||||
@@ -489,135 +642,18 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel icon={Signpost} title={t('roadtrip.hazards.current')} note={t('roadtrip.hazards.note')}>
|
||||
<AvoidRow icon={Signpost} label={t('roadtrip.hazards.show')} on={settings.roadtrip_show_hazards === true} disabled={!onSave}
|
||||
onToggle={() => onSave?.('roadtrip_show_hazards', !settings.roadtrip_show_hazards)} />
|
||||
</Panel>
|
||||
<Panel icon={Signpost} title={t('roadtrip.stops.section')} note={t('roadtrip.stops.daysHint')}>
|
||||
<AvoidRow icon={Link2} label={t('roadtrip.stops.inDays')}
|
||||
on={settings.roadtrip_service_stops_in_days !== false}
|
||||
disabled={!onSave}
|
||||
onToggle={() => onSave?.('roadtrip_service_stops_in_days', settings.roadtrip_service_stops_in_days === false)}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
icon={Car}
|
||||
title={t('roadtrip.limit.sectionVehicle')}
|
||||
note={vehicleKind ? t('roadtrip.limit.specHint') : t('roadtrip.limit.vehicleHint')}
|
||||
>
|
||||
{/* First, because it decides what everything under it means: with no
|
||||
vehicle named, a petrol station and a charger both fill the tank, which
|
||||
is arithmetic that is wrong for everybody who drives just one of them.
|
||||
|
||||
Radios in a segmented shell rather than a dropdown: three options that
|
||||
change the meaning of everything below them should be readable without
|
||||
being opened, and the arrow keys, the names and the roles then come
|
||||
from the browser instead of from a rebuilt listbox. */}
|
||||
<fieldset className="min-w-0">
|
||||
<legend className="sr-only">{t('roadtrip.limit.vehicleLabel')}</legend>
|
||||
<div className="grid grid-cols-3 gap-1 rounded-xl border border-edge bg-surface-tertiary p-1">
|
||||
{VEHICLES.map(({ key, labelKey, Icon }) => (
|
||||
<label key={key ?? 'unset'} className="min-w-0">
|
||||
<input
|
||||
type="radio"
|
||||
name="roadtrip-vehicle"
|
||||
className="peer sr-only"
|
||||
checked={(settings.roadtrip_vehicle || '') === (key ?? '')}
|
||||
onChange={() => {
|
||||
// Never writes the car's figures away: switching kind leaves
|
||||
// both sets stored, `rangeFromSpec` simply returns null for
|
||||
// the other one, and the typed range comes back as an editable
|
||||
// field. Deleting the other kind's values here would lose data
|
||||
// on a mis-click.
|
||||
setPreview(null)
|
||||
onSave?.('roadtrip_vehicle', key ?? '')
|
||||
}}
|
||||
/>
|
||||
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent">
|
||||
<Icon size={14} className="shrink-0" aria-hidden />
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<RangeStrip
|
||||
rangeKm={planningKm}
|
||||
fillPercent={shownFill}
|
||||
wearPercent={vehicleKind === 'electric' ? spec.degradationPercent ?? null : null}
|
||||
unit={distanceUnit}
|
||||
electric={vehicleKind === 'electric'}
|
||||
/>
|
||||
|
||||
{/* Read-only once the parts add up, because then it IS the parts. Leaving
|
||||
it editable would offer a second answer to a question that already has
|
||||
one, and the traveller would have no way of telling which the warnings
|
||||
were using. Clearing a field above hands the row straight back. */}
|
||||
<LimitRow
|
||||
icon={Route}
|
||||
// "on one tank" is the wrong noun for half the travellers who set this.
|
||||
label={t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel')}
|
||||
suffix={imperial ? 'mi' : 'km'}
|
||||
value={rangeShown}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
derived={computedKm ? t('roadtrip.limit.computed') : undefined}
|
||||
testId="limit-range"
|
||||
onDraft={v => setPreview({ key: 'range', value: v })}
|
||||
onChange={setRange}
|
||||
/>
|
||||
{/* Under the range, because it is a fraction OF it. Nobody charges to
|
||||
100 % on the road: the last fifth takes as long as the first four, so a
|
||||
stop counted as a full tank overstates everything after it by that
|
||||
fifth. */}
|
||||
<LimitRow
|
||||
icon={BatteryCharging}
|
||||
label={t('roadtrip.limit.fillLabel')}
|
||||
suffix="%"
|
||||
value={settings.roadtrip_fill_percent}
|
||||
placeholder={t('roadtrip.limit.fillFull')}
|
||||
testId="limit-fill"
|
||||
onDraft={v => setPreview({ key: 'fill', value: v > 100 ? 100 : v })}
|
||||
onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)}
|
||||
/>
|
||||
|
||||
{/* The car's own figures, folded away. Nothing here is required — somebody
|
||||
who knows their range types it above and never opens this — but it
|
||||
starts open the moment any of them is stored, because otherwise the
|
||||
range row is read-only with its cause out of sight. */}
|
||||
{vehicleKind ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSpecOpen(o => !o); setPreview(null) }}
|
||||
aria-expanded={specOpen}
|
||||
aria-controls="roadtrip-spec"
|
||||
className="-mx-1 flex items-center gap-1.5 self-start rounded-lg px-1 py-0.5 text-caption font-semibold text-accent-on transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className={`shrink-0 transition-transform ${specOpen ? 'rotate-90' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t('roadtrip.limit.specToggle')}
|
||||
</button>
|
||||
{/* `hidden` rather than unmounting: it really leaves the tab order,
|
||||
and a field that kept a draft while folded away would be a draft
|
||||
nobody can see. */}
|
||||
{/* The display class only while it is open. `[hidden]` sets
|
||||
`display: none` in the preflight, but a `flex` class beats it on
|
||||
specificity — so the attribute flipped, the semantics were right,
|
||||
and the panel stayed on screen. */}
|
||||
<div id="roadtrip-spec" hidden={!specOpen} className={specOpen ? 'flex flex-col gap-3' : ''}>
|
||||
{SPEC_ROWS[vehicleKind].map(({ key, setting, Icon, labelKey, step }) => (
|
||||
<LimitRow
|
||||
key={key}
|
||||
icon={Icon}
|
||||
label={t(labelKey)}
|
||||
suffix={specUnit(key, imperial)}
|
||||
step={step}
|
||||
value={showSpec(key, saved[key], imperial)}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
testId={`limit-${key}`}
|
||||
onDraft={v => setPreview({ key, value: v })}
|
||||
onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -879,7 +879,7 @@ describe('RoadtripSidebar', () => {
|
||||
// With no search to run there is nothing better, so the finding is all there is and
|
||||
// has to stay.
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} />)
|
||||
expect(screen.getByText('640 km')).toBeInTheDocument()
|
||||
expect(screen.queryByText('640 km')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import ChargingInfo from './ChargingInfo'
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
CarFront, Footprints, Bike, Zap, AlertTriangle,
|
||||
@@ -27,8 +29,10 @@ import type { RouteVia } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
import type { RouteSegment } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import AutomaticDayStop from './AutomaticDayStop'
|
||||
|
||||
interface RoadtripSidebarProps {
|
||||
onFocusPoint?: (lat: number, lng: number) => void
|
||||
/** Legs and totals for the whole trip, computed once in the planner hook. */
|
||||
routes: RoadtripRoutes
|
||||
selectedAssignmentId?: number | null
|
||||
@@ -769,7 +773,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
|
||||
const { t } = useTranslation()
|
||||
// Read here rather than threaded down: both stop shapes need the same two, and the
|
||||
// badge is the only thing in the rail that depends on them.
|
||||
const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent)
|
||||
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent)
|
||||
const { vehicleKind } = useVehicleRange()
|
||||
const kind = STOP_KIND_BY_KEY[stop.stopType ?? '']
|
||||
const Icon = kind?.Icon ?? ParkingSquare
|
||||
@@ -830,10 +834,10 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
|
||||
{/* Truncated, not wrapped: a service stop is a waypoint, and its full name lives
|
||||
on the map pin — where a place's name is the row's whole reason to exist. */}
|
||||
<span
|
||||
className="min-w-0 truncate font-semibold leading-6 tracking-[-0.012em] text-content-secondary"
|
||||
className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content-secondary"
|
||||
style={{ fontSize: FS.name }}
|
||||
>
|
||||
{stop.name}
|
||||
<span className="min-w-0 truncate">{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}<CheckoutDeparture stop={stop} entry={entry} />
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
|
||||
@@ -955,6 +959,17 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea
|
||||
* the state the whole view gets opened for. It is also the only filled accent left in the
|
||||
* rail: selection moved to the row's own background, so nothing else competes with it.
|
||||
*/
|
||||
function CheckoutDeparture({ stop, entry }: { stop: RoadtripStop; entry?: ScheduleEntry }): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
if (stop.checkoutAt === undefined || !entry?.departure) return null
|
||||
return <Tooltip label={t('roadtrip.spill.departs', { time: formatClockTime(entry.departure, is12h) })}>
|
||||
<span dir="ltr" tabIndex={0} className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-secondary px-1 py-0.5 align-middle whitespace-nowrap font-medium text-[length:calc(10px*var(--fs-scale-caption,1))] leading-none tabular-nums text-content-muted">
|
||||
<Clock size={10} aria-hidden />{formatClockTime(entry.departure, is12h)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -1018,7 +1033,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
||||
const { t } = useTranslation()
|
||||
// Read here rather than threaded down: both stop shapes need the same two, and the
|
||||
// badge is the only thing in the rail that depends on them.
|
||||
const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent)
|
||||
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent)
|
||||
const { vehicleKind } = useVehicleRange()
|
||||
return (
|
||||
<button
|
||||
@@ -1087,10 +1102,10 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
||||
{/* Wraps rather than truncates: the name is what the row is for, and thirty of
|
||||
them cut off mid-word is a list nobody reads. */}
|
||||
<span
|
||||
className="min-w-0 break-words font-semibold leading-6 tracking-[-0.012em] text-content"
|
||||
className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content"
|
||||
style={{ fontSize: FS.name }}
|
||||
>
|
||||
{stop.name}
|
||||
<span className={stop.stopType === 'charging' || stop.checkoutAt !== undefined ? 'min-w-0 truncate' : 'min-w-0 break-words'}>{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}<CheckoutDeparture stop={stop} entry={entry} />
|
||||
</span>
|
||||
{/* Two halves under one border: the word says what the number means, so the
|
||||
number needs no unit of explanation beside it. */}
|
||||
@@ -1203,13 +1218,7 @@ function SpillBlock({ spill, children }: {
|
||||
boxShadow: `inset 0 1px 0 color-mix(in srgb, var(--info) 20%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{/* Moonlight from the left, falling on the mascot. A radial wash rather than a
|
||||
second background colour: it has no edge of its own, so the head reads as lit
|
||||
rather than as another box stacked on the first. */}
|
||||
<div
|
||||
className="px-2 pb-1 pt-2"
|
||||
style={{ backgroundImage: `radial-gradient(140px 64px at 22px 26px, color-mix(in srgb, var(--info) 12%, transparent), transparent 72%)` }}
|
||||
>
|
||||
<div className="px-2 pb-1 pt-2">
|
||||
<div className="flex items-center gap-2 text-info">
|
||||
<MDancingTrek scene="idle" mood="sleepy" size={26} />
|
||||
<span
|
||||
@@ -1231,7 +1240,7 @@ function SpillBlock({ spill, children }: {
|
||||
reason the block exists, and the kilometres on it are the ones the card's
|
||||
header now counts. Not clickable: alternatives are asked for on the day the
|
||||
leg is stored on, and offering the same leg twice would be two answers. */}
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<div className={spill.automatic ? 'hidden' : 'grid'} style={RAIL_GRID}>
|
||||
<span className="relative z-[1] flex flex-col items-center" aria-hidden>
|
||||
<span className="flex-1" style={RAIL_DASH} />
|
||||
</span>
|
||||
@@ -1259,7 +1268,7 @@ function SpillBlock({ spill, children }: {
|
||||
The stop it leaves from is not named — it is drawn on yesterday's card
|
||||
directly above, and a second row for it would read as a stop made
|
||||
twice. */}
|
||||
{departure ? (
|
||||
{departure && spill.fromStop?.checkoutAt === undefined ? (
|
||||
<span
|
||||
dir="ltr"
|
||||
className="ms-auto shrink-0 whitespace-nowrap font-semibold leading-6 tabular-nums"
|
||||
@@ -1294,7 +1303,8 @@ function SpillBlock({ spill, children }: {
|
||||
* move, a stay edit or a refuel offer still names the day the server knows it by. See
|
||||
* `nightSpill.ts`.
|
||||
*/
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle }: {
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
|
||||
onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
|
||||
day: RoadtripDay
|
||||
/** Folded down to the header, and off the map with it. */
|
||||
collapsed?: boolean
|
||||
@@ -1328,20 +1338,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
// The colour the map draws this day in, or none at all while the map is drawing one
|
||||
// blue line for the whole trip.
|
||||
const dayColorsOn = useSettingsStore(s => !!s.settings.roadtrip_day_colors)
|
||||
const dayColorsOn = useRoadtripSettings(s => !!s.roadtrip_day_colors)
|
||||
const tint = dayColorsOn ? dayColor(day.dayNumber).line : null
|
||||
const last = day.stops.length - 1
|
||||
/**
|
||||
* The findings a stop wears, minus the one the refuel band already says better.
|
||||
*
|
||||
* The range warning marks the stop somebody FINDS OUT at and counts the whole tank;
|
||||
* the band sits on the leg the fuel actually ends on and offers somewhere to stop. Both
|
||||
* at once says the same thing twice, in the wrong order — the answer above the problem
|
||||
* — so the warning stands down wherever the band is showing.
|
||||
*/
|
||||
const showingBand = !!refuel && !loading
|
||||
const findingsFor = (i: number) =>
|
||||
day.driveWarnings.filter(w => w.index === i && !(showingBand && w.code === 'range'))
|
||||
day.driveWarnings.filter(w => w.index === i && w.code !== 'range')
|
||||
// The running number a stop wears, with the service stops passed over — so a day with a
|
||||
// charger halfway through still counts one, two, three the way its map pins do.
|
||||
let counted = 0
|
||||
@@ -1354,7 +1355,21 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
* `stop.ownerIndex` instead, because a stop driven onto this date through the night is
|
||||
* still stored on the day it set off from (`nightSpill.ts`).
|
||||
*/
|
||||
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => {
|
||||
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement => {
|
||||
if (stop.automaticNight) return (
|
||||
<li key={stop.assignmentId}>
|
||||
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
|
||||
{i < last && day.legs[i]?.distance !== 0 ? <DriveBand leg={day.legs[i]} /> : null}
|
||||
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === i).map(dry => (
|
||||
<RefuelBand key={`dry-${dry.legIndex}`} dry={dry} dayId={day.dayId} refuel={refuel}
|
||||
onAsk={() => onAskRefuel?.(day.dayId, dry)}
|
||||
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
|
||||
)) : null}
|
||||
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
|
||||
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
|
||||
)) : null}
|
||||
</li>
|
||||
)
|
||||
const service = isServiceStopType(stop.stopType)
|
||||
if (!service) counted += 1
|
||||
// Every finding at this index is read on its own. Taking the first match let an
|
||||
@@ -1424,10 +1439,10 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
|
||||
/>
|
||||
)}
|
||||
{i < last ? (
|
||||
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
|
||||
<DriveBand
|
||||
leg={day.legs[i]}
|
||||
onAskAlternatives={onAskAlternatives ? () => onAskAlternatives(day.dayId, i) : undefined}
|
||||
onAskAlternatives={onAskAlternatives && !day.stops[i + 1].automaticNight ? () => onAskAlternatives(day.dayId, i) : undefined}
|
||||
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1469,6 +1484,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
* Built rather than mapped straight through, because a spill is drawn inside a block of
|
||||
* its own and a block cannot be opened halfway down a `map`.
|
||||
*/
|
||||
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => (
|
||||
<React.Fragment key={stop.assignmentId}>
|
||||
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === -i - 1).map(dry => (
|
||||
<li key={`inbound-${i}`}>
|
||||
<RefuelBand dry={dry} dayId={day.dayId} refuel={refuel}
|
||||
onAsk={() => onAskRefuel?.(day.dayId, dry)}
|
||||
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
|
||||
</li>
|
||||
)) : null}
|
||||
{renderStopContent(stop, i)}
|
||||
</React.Fragment>
|
||||
)
|
||||
const runs: { spill: SpillMark | null; from: number; to: number }[] = []
|
||||
{
|
||||
let at = 0
|
||||
@@ -1556,7 +1583,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })}
|
||||
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })}
|
||||
</span>
|
||||
{/* The other half of "where possible". The setting is a weighting, so a day
|
||||
with no untolled crossing comes back on the toll road — and the only thing
|
||||
@@ -1714,7 +1741,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
|
||||
export default function RoadtripSidebar({
|
||||
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
|
||||
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
|
||||
collapsedDayIds, onToggleDay,
|
||||
collapsedDayIds, onToggleDay, onFocusPoint,
|
||||
}: RoadtripSidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
// One drag state for the whole rail rather than one per day: a stop that cannot leave
|
||||
@@ -1751,12 +1778,18 @@ export default function RoadtripSidebar({
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 pt-1">
|
||||
<div className="shrink-0">
|
||||
<TripSummary routes={routes} />
|
||||
{routes.dayWindowIssue ? (
|
||||
<p role="status" className="mx-3.5 mt-2 rounded-xl bg-warning-soft p-3 text-caption text-content">
|
||||
{t(`roadtrip.window.${routes.dayWindowIssue}`)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="roadtrip-rail-scroll flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto pb-3.5">
|
||||
{routes.days.map(day => (
|
||||
<DaySection
|
||||
key={day.dayId}
|
||||
day={day}
|
||||
onFocusPoint={onFocusPoint}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
onSelectStop={onSelectStop}
|
||||
onReorderStop={onReorderStop}
|
||||
@@ -1767,7 +1800,7 @@ export default function RoadtripSidebar({
|
||||
onEditStay={onEditStay}
|
||||
onSetStopKind={onSetStopKind}
|
||||
onSetStopFill={onSetStopFill}
|
||||
onFollowTrack={onFollowTrack}
|
||||
onFollowTrack={day.dayId < 0 ? undefined : onFollowTrack}
|
||||
viaCount={viaCounts?.[day.dayId] ?? 0}
|
||||
trackName={trackNames?.[day.dayId]}
|
||||
refuel={refuel}
|
||||
|
||||
@@ -90,13 +90,13 @@ describe('RoadtripStopPopup', () => {
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-007: unpicking the kind leaves an ordinary place', async () => {
|
||||
it('FE-ROADTRIP-STOPPOPUP-007: clicking the selected kind keeps a service stop', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Fuel/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: null, dwellMinutes: 10 }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 10 }))
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -275,3 +275,30 @@ describe('RoadtripStopPopup', () => {
|
||||
expect(screen.getByText('Kind of stop')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('editing a roadtrip stop', () => {
|
||||
it('loads the saved duration and saves without adding another stop', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft({ editing: { placeId: 7, stopType: 'fuel', dwellMinutes: 45 } })} {...noop} onSave={onSave} />)
|
||||
expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
|
||||
})
|
||||
it('loads and preserves an existing overnight booking', async () => {
|
||||
const onSaveNight = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category: 'hotel' }), overnight: { days: [{ id: 4, number: 1, date: null }, { id: 5, number: 2, date: null }], defaultEndDayId: 5 }, editing: { placeId: 7, stopType: 'hotel', dwellMinutes: 30, accommodationId: 8, checkIn: '16:00', checkOut: '10:00' } })} {...noop} onSaveNight={onSaveNight} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '16:00', checkOut: '10:00' }))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
it('prefills check-in from arrival while preserving a saved manual time', () => {
|
||||
const onSaveNight = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category: 'hotel' }), arrivalTime: '15:45', overnight: { days: [{ id: 4, number: 1, date: null }, { id: 5, number: 2, date: null }], defaultEndDayId: 5 } })} {...noop} onSaveNight={onSaveNight} />)
|
||||
expect(screen.getByRole('textbox', { name: 'Check-in' })).toHaveValue('15:45')
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Check-in' }), { target: { value: '16:30' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '16:30', checkOut: '' })
|
||||
})
|
||||
|
||||
@@ -2,10 +2,12 @@ import React, { useState } from 'react'
|
||||
import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import StayPortals from './StayPortals'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { formatDurationShort, SERVICE_COLORS } from './roadtripModel'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
@@ -28,6 +30,8 @@ import type { RoadtripStopType } from '@trek/shared'
|
||||
const DWELL_CHOICES = [5, 10, 20, 30, 45, 60]
|
||||
|
||||
export interface RoadtripStopDraft {
|
||||
editing?: { placeId: number; dwellMinutes: number; stopType: RoadtripStopType | null; accommodationId?: number; checkIn?: string; checkOut?: string }
|
||||
arrivalTime?: string | null
|
||||
poi: CorridorPoi
|
||||
dayId: number
|
||||
/** Where in the day's chain it goes, worked out from how far along the drive it sits. */
|
||||
@@ -81,15 +85,15 @@ export default function RoadtripStopPopup({
|
||||
// scheme for a bare host and drops anything that is not http(s).
|
||||
const websiteHref = safeExternalHref(draft?.poi.website)
|
||||
const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category)
|
||||
const [stopType, setStopType] = useState<RoadtripStopType | null>(suggested?.key ?? null)
|
||||
const [dwell, setDwell] = useState<number>(suggested?.defaultMinutes ?? 30)
|
||||
const [stopType, setStopType] = useState<RoadtripStopType | null>(draft?.editing ? draft.editing.stopType : suggested?.key ?? null)
|
||||
const [dwell, setDwell] = useState<number>(draft?.editing?.dwellMinutes ?? suggested?.defaultMinutes ?? 30)
|
||||
// A hotel is a night by default and a campsite a pause, which is what each already
|
||||
// means everywhere else — but both offer the other, because a campsite is somewhere
|
||||
// people sleep and a hotel is somewhere people stop for lunch.
|
||||
const [night, setNight] = useState<boolean>(draft?.poi.category === 'hotel')
|
||||
const [night, setNight] = useState<boolean>(draft?.editing ? !!draft.editing.accommodationId : draft?.poi.category === 'hotel')
|
||||
const [endDayId, setEndDayId] = useState<number | null>(draft?.overnight?.defaultEndDayId ?? null)
|
||||
const [checkIn, setCheckIn] = useState('')
|
||||
const [checkOut, setCheckOut] = useState('')
|
||||
const [checkIn, setCheckIn] = useState(draft?.editing?.checkIn || draft?.arrivalTime || '')
|
||||
const [checkOut, setCheckOut] = useState(draft?.editing?.checkOut ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
if (!draft) return null
|
||||
@@ -116,8 +120,10 @@ export default function RoadtripStopPopup({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={t('roadtrip.stop.addTitle')} size="sm">
|
||||
<Modal isOpen onClose={onClose} title={t(draft.editing ? 'common.edit' : 'roadtrip.stop.addTitle')} size={overnight ? 'xl' : 'sm'}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className={overnight ? "grid grid-cols-1 gap-5 sm:grid-cols-2" : ""}>
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
|
||||
@@ -201,28 +207,15 @@ export default function RoadtripStopPopup({
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('day.checkIn')}
|
||||
</span>
|
||||
<input
|
||||
type="time"
|
||||
value={checkIn}
|
||||
onChange={e => setCheckIn(e.target.value)}
|
||||
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<CustomTimePicker value={checkIn} onChange={setCheckIn} aria-label={t('day.checkIn')} placeholder="" style={{ marginTop: 6, width: '100%' }} />
|
||||
</label>
|
||||
<label className="min-w-0 flex-1">
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('day.checkOut')}
|
||||
</span>
|
||||
<input
|
||||
type="time"
|
||||
value={checkOut}
|
||||
onChange={e => setCheckOut(e.target.value)}
|
||||
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<CustomTimePicker value={checkOut} onChange={setCheckOut} aria-label={t('day.checkOut')} placeholder="" style={{ marginTop: 6, width: '100%' }} />
|
||||
</label>
|
||||
</div>
|
||||
{/* What OSM knows about the house, which is the whole of what we offer: no
|
||||
price, no availability, no link into a booking portal. TREK links to maps
|
||||
and to the place's own site, never to a vendor. */}
|
||||
{websiteHref || draft.poi.phone ? (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-caption">
|
||||
{websiteHref ? (
|
||||
@@ -258,7 +251,7 @@ export default function RoadtripStopPopup({
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => {
|
||||
setStopType(on ? null : key)
|
||||
setStopType(key)
|
||||
// Picking a kind is also picking how long it takes, until the user
|
||||
// says otherwise — a charge is not a fuel stop.
|
||||
if (!on) setDwell(STOP_KIND_BY_KEY[key].defaultMinutes)
|
||||
@@ -303,6 +296,12 @@ export default function RoadtripStopPopup({
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
{overnight ? <StayPortals lat={draft.poi.lat} lng={draft.poi.lng} name={draft.poi.name} camping={draft.poi.category === 'campsite'}
|
||||
arrival={overnight.days.find(day => day.id === draft.dayId)?.date}
|
||||
departure={overnight.days.find(day => day.id === (endDayId ?? overnight.defaultEndDayId))?.date} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -317,7 +316,7 @@ export default function RoadtripStopPopup({
|
||||
disabled={saving}
|
||||
className="ms-auto rounded-lg bg-accent px-3.5 py-1.5 text-body font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{t('roadtrip.poi.add')}
|
||||
{t(draft.editing ? 'common.save' : 'roadtrip.poi.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
export default function SettingsHint({ children, id }: { children: string; id?: string }) {
|
||||
return <Tooltip label={children} placement="top">
|
||||
<p id={id} tabIndex={0} className="min-w-0 truncate text-caption leading-snug text-content-faint">{children}</p>
|
||||
</Tooltip>
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Check, Copy, ExternalLink } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { trivagoSearchHref, campingSearchHref } from './stayPortalLinks'
|
||||
import { copyText } from '../../utils/clipboard'
|
||||
|
||||
const HOTEL_PORTALS = [
|
||||
{ name: 'trivago', icon: '/images/portals/trivago.png', href: 'https://www.trivago.com/' },
|
||||
{ name: 'CHECK24', icon: '/images/portals/check24.png', href: 'https://hotel.check24.de/' },
|
||||
]
|
||||
const CAMPING_PORTALS = [
|
||||
{ name: 'PiNCAMP', icon: '/images/portals/pincamp.png', href: 'https://www.pincamp.com/' },
|
||||
{ name: 'Pitchup', icon: '/images/portals/pitchup.ico', href: 'https://www.pitchup.com/' },
|
||||
]
|
||||
|
||||
export default function StayPortals({ name, camping, lat, lng, arrival, departure }: { name: string; camping: boolean; lat: number; lng: number; arrival?: string | null; departure?: string | null }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [copyFailed, setCopyFailed] = useState(false)
|
||||
|
||||
const copy = async (): Promise<void> => {
|
||||
const didCopy = await copyText(name)
|
||||
setCopied(didCopy)
|
||||
setCopyFailed(!didCopy)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex min-w-0 flex-col gap-3 rounded-xl bg-surface-secondary p-4">
|
||||
<p className="text-body font-semibold text-content">{t('roadtrip.stay.portals')}</p>
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.stay.portalHint')}</p>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-edge bg-surface px-3 py-2">
|
||||
<span className="min-w-0 flex-1 select-text break-words text-caption text-content-secondary">{name}</span>
|
||||
<button type="button" onClick={() => void copy()} aria-label={t(copied ? 'common.copied' : 'common.copy')}
|
||||
className="shrink-0 rounded-md p-1 text-content-muted hover:bg-surface-hover hover:text-content">
|
||||
{copied ? <Check size={14} aria-hidden /> : <Copy size={14} aria-hidden />}
|
||||
</button>
|
||||
</div>
|
||||
{copyFailed ? <p role="alert" className="text-caption text-danger">{t('common.error')}</p> : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
{(camping ? CAMPING_PORTALS : HOTEL_PORTALS).map(portal => (
|
||||
<a key={portal.name} href={portal.name === 'trivago' ? trivagoSearchHref(name, arrival, departure) : portal.name === 'PiNCAMP' || portal.name === 'Pitchup' ? campingSearchHref(portal.name, name, lat, lng, arrival, departure) : portal.href} target="_blank" rel="noopener noreferrer"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-edge bg-surface px-3 py-2.5 text-body font-medium text-content transition-colors hover:bg-surface-hover">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<img src={portal.icon} alt="" width={20} height={20} className="h-5 w-5 shrink-0 rounded object-contain" />
|
||||
{portal.name}
|
||||
</span>
|
||||
<ExternalLink size={14} className="text-content-muted" aria-hidden />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -110,3 +110,7 @@ describe('StopFillPicker', () => {
|
||||
expect(onPick).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||
}))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import React from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
@@ -40,7 +41,7 @@ export default function StopFillPicker({ anchor, current, onPick, onClose }: {
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const unit: DistanceUnit = useSettingsStore(s => s.settings.distance_unit) === 'imperial' ? 'imperial' : 'metric'
|
||||
const fallback = useSettingsStore(s => s.settings.roadtrip_fill_percent)
|
||||
const fallback = useRoadtripSettings(s => s.roadtrip_fill_percent)
|
||||
const { rangeKm } = useVehicleRange()
|
||||
|
||||
// Zero, absent and 100 all mean the same thing in the settings, so they read as full
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import VisitControls from './VisitControls'
|
||||
|
||||
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
it('edits the shared stay without changing the day ending', () => {
|
||||
const edit = vi.fn(), toggle = vi.fn(async () => {})
|
||||
render(<VisitControls endDay={{ active: false, onToggle: toggle }} stay={{ minutes: 90, onEdit: edit }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 1 h 30 min' }))
|
||||
expect(edit).toHaveBeenCalledOnce()
|
||||
expect(toggle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps stay editing when daily travel times are off', () => {
|
||||
render(<VisitControls stay={{ minutes: null, onEdit: vi.fn() }} />)
|
||||
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 0 min' })).toBeEnabled()
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.window.endHere' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Clock, Pencil } from 'lucide-react'
|
||||
import EndDayControl, { type EndDayControlProps } from './EndDayControl'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
|
||||
export interface RoadtripStayControl {
|
||||
minutes: number | null
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
export default function VisitControls({ endDay, stay }: { endDay?: EndDayControlProps; stay?: RoadtripStayControl }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={`grid gap-2 ${endDay && stay ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{endDay && <EndDayControl {...endDay} />}
|
||||
{stay && <button type="button" onClick={stay.onEdit} disabled={!stay.onEdit}
|
||||
className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 text-start enabled:hover:bg-surface-tertiary disabled:cursor-default">
|
||||
<span className="flex items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium uppercase text-content-secondary"><Clock size={13} className="shrink-0 text-content-faint" aria-hidden />{t('roadtrip.stop.stayShort')}</span>
|
||||
{' '}<span className="flex shrink-0 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary">
|
||||
{stay.minutes === null ? '+' : formatDurationShort(stay.minutes * 60)}{stay.onEdit && <Pencil size={12} className="text-content-faint" aria-hidden />}
|
||||
</span>
|
||||
</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,342 +1,2 @@
|
||||
/**
|
||||
* Corridor geometry — "what lies along this drive", without asking Overpass to think
|
||||
* about a polyline.
|
||||
*
|
||||
* The naive form (`around:` with a hundred coordinates) is what every POI provider warns
|
||||
* about and times out on. OsmAnd solves it the other way round and this follows it: ask
|
||||
* for plain bounding boxes that cover the route, then measure each candidate's
|
||||
* perpendicular distance to the line and throw away what is too far. The boxes are what
|
||||
* the existing /api/maps/pois route already accepts, so no new server surface is needed.
|
||||
*
|
||||
* Everything here is pure: no React, no network, no store.
|
||||
*/
|
||||
|
||||
export interface LatLng {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
export interface Bbox {
|
||||
south: number
|
||||
west: number
|
||||
north: number
|
||||
east: number
|
||||
}
|
||||
|
||||
const EARTH_RADIUS_KM = 6371
|
||||
const KM_PER_DEG_LAT = 111.32
|
||||
|
||||
const toRad = (deg: number): number => (deg * Math.PI) / 180
|
||||
|
||||
/** Great-circle distance in kilometres. */
|
||||
export function haversineKm(a: LatLng, b: LatLng): number {
|
||||
const dLat = toRad(b.lat - a.lat)
|
||||
const dLng = toRad(b.lng - a.lng)
|
||||
const lat1 = toRad(a.lat)
|
||||
const lat2 = toRad(b.lat)
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2)
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Perpendicular distance from a point to a line segment, in kilometres.
|
||||
*
|
||||
* Works in a local equirectangular projection around the segment: longitudes shrink by
|
||||
* cos(lat), which is the whole reason a uniform degree padding is wrong at 60° north.
|
||||
* Over the few kilometres a corridor spans, the error against a proper geodesic is far
|
||||
* below the width the user picked.
|
||||
*/
|
||||
export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number {
|
||||
return projectOnSegment(p, a, b).distanceKm
|
||||
}
|
||||
|
||||
/**
|
||||
* Perpendicular distance to a segment plus where along it the foot of that perpendicular
|
||||
* falls (0 at `a`, 1 at `b`). The second number is what places a candidate correctly in
|
||||
* the drive — taking the segment's midpoint instead makes every petrol station on a long
|
||||
* straight report the same position.
|
||||
*/
|
||||
/** A segment shorter than a millimetre, squared — below this there is nothing to project onto. */
|
||||
const DEGENERATE_SEGMENT_KM2 = 1e-12
|
||||
|
||||
export function projectOnSegment(p: LatLng, a: LatLng, b: LatLng): { distanceKm: number; t: number } {
|
||||
const latScale = KM_PER_DEG_LAT
|
||||
const lngScale = KM_PER_DEG_LAT * Math.cos(toRad((a.lat + b.lat) / 2))
|
||||
const ax = a.lng * lngScale
|
||||
const ay = a.lat * latScale
|
||||
const bx = b.lng * lngScale
|
||||
const by = b.lat * latScale
|
||||
const px = p.lng * lngScale
|
||||
const py = p.lat * latScale
|
||||
const dx = bx - ax
|
||||
const dy = by - ay
|
||||
const lenSq = dx * dx + dy * dy
|
||||
// A segment whose two ends are the same point to within a millimetre has no
|
||||
// direction to project onto, so measure straight to it. Written as a range because
|
||||
// the ends came through a chain of floating point arithmetic and never land on 0.
|
||||
if (lenSq < DEGENERATE_SEGMENT_KM2) return { distanceKm: haversineKm(p, a), t: 0 }
|
||||
// Clamped so a point beside the segment's end measures to the end, not to the
|
||||
// infinite line — otherwise a stop 200 km past the destination looks "on the way".
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq))
|
||||
const cx = ax + t * dx
|
||||
const cy = ay + t * dy
|
||||
return { distanceKm: Math.hypot(px - cx, py - cy), t }
|
||||
}
|
||||
|
||||
export interface CorridorHit {
|
||||
/** Kilometres from the route line at its closest point. */
|
||||
offRouteKm: number
|
||||
/** Kilometres travelled along the route before reaching that closest point. */
|
||||
alongKm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a point sits relative to the route: how far off it is, and how far into the
|
||||
* drive it comes. The second number is what puts "lunch" before "the hotel" in a list
|
||||
* instead of sorting by name.
|
||||
*/
|
||||
export function projectOntoRoute(p: LatLng, line: LatLng[]): CorridorHit | null {
|
||||
if (line.length < 2) return null
|
||||
let best = Number.POSITIVE_INFINITY
|
||||
let bestAlong = 0
|
||||
let travelled = 0
|
||||
for (let i = 0; i < line.length - 1; i++) {
|
||||
const a = line[i]
|
||||
const b = line[i + 1]
|
||||
const segment = haversineKm(a, b)
|
||||
const { distanceKm: d, t } = projectOnSegment(p, a, b)
|
||||
if (d < best) {
|
||||
best = d
|
||||
bestAlong = travelled + t * segment
|
||||
}
|
||||
travelled += segment
|
||||
}
|
||||
return { offRouteKm: best, alongKm: bestAlong }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding boxes covering the route, each no larger than `maxSpanDeg`.
|
||||
*
|
||||
* The server clamps any box bigger than that (MAX_BBOX_SPAN_DEG) so Overpass is not
|
||||
* asked to scan a continent, which means a long drive has to be cut into boxes here
|
||||
* rather than sent as one. Boxes are grown along the route until one would exceed the
|
||||
* span, so a straight motorway needs far fewer requests than a mountain road.
|
||||
*/
|
||||
/**
|
||||
* Splits a step that is longer than a box into even pieces.
|
||||
*
|
||||
* The simplifier is right to reduce a dead straight motorway stretch to its two ends,
|
||||
* but tiling walks point to point: two ends 100 km apart produce a box at each end and
|
||||
* nothing in between, so the middle of the drive would never be searched.
|
||||
*/
|
||||
function densify(line: LatLng[], maxStepDeg: number): LatLng[] {
|
||||
if (line.length < 2) return line
|
||||
const out: LatLng[] = [line[0]]
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const a = line[i - 1]
|
||||
const b = line[i]
|
||||
const span = Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lng - a.lng))
|
||||
const steps = Math.max(1, Math.ceil(span / maxStepDeg))
|
||||
for (let s = 1; s <= steps; s++) {
|
||||
out.push({
|
||||
lat: a.lat + ((b.lat - a.lat) * s) / steps,
|
||||
lng: a.lng + ((b.lng - a.lng) * s) / steps,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45): Bbox[] {
|
||||
if (line.length === 0) return []
|
||||
const padLat = widthKm / KM_PER_DEG_LAT
|
||||
const tiles: Bbox[] = []
|
||||
let current: Bbox | null = null
|
||||
|
||||
const padded = (b: Bbox): Bbox => {
|
||||
// Longitude degrees shrink towards the poles, so the padding has to grow with
|
||||
// latitude or the corridor is narrower than asked for in the north.
|
||||
const midLat = (b.north + b.south) / 2
|
||||
const padLng = padLat / Math.max(0.2, Math.cos(toRad(midLat)))
|
||||
return {
|
||||
south: b.south - padLat,
|
||||
north: b.north + padLat,
|
||||
west: b.west - padLng,
|
||||
east: b.east + padLng,
|
||||
}
|
||||
}
|
||||
|
||||
// The point the open box last swallowed. When a box closes, the next one starts
|
||||
// from it rather than from `p` alone, so the two overlap by exactly one step of
|
||||
// the walk. Starting at `p` left the stretch between them covered only by the
|
||||
// padding on either side — and the walk steps in up to maxSpanDeg/2 while the
|
||||
// padding is widthKm, so at the offered widths a band of several kilometres of
|
||||
// route fell into no box at all. Nothing downstream noticed: the panel counts
|
||||
// tiles asked and tiles answered, so it reported a finished, uncapped,
|
||||
// error-free search of a drive it had never fully looked at. "Find fuel along
|
||||
// the route" is the whole feature.
|
||||
let previous: LatLng | null = null
|
||||
|
||||
for (const p of densify(line, maxSpanDeg / 2)) {
|
||||
if (!current) {
|
||||
current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng }
|
||||
previous = p
|
||||
continue
|
||||
}
|
||||
const next: Bbox = {
|
||||
south: Math.min(current.south, p.lat),
|
||||
north: Math.max(current.north, p.lat),
|
||||
west: Math.min(current.west, p.lng),
|
||||
east: Math.max(current.east, p.lng),
|
||||
}
|
||||
const grown = padded(next)
|
||||
if (grown.north - grown.south > maxSpanDeg || grown.east - grown.west > maxSpanDeg) {
|
||||
tiles.push(padded(current))
|
||||
const from = previous ?? p
|
||||
current = {
|
||||
south: Math.min(from.lat, p.lat),
|
||||
north: Math.max(from.lat, p.lat),
|
||||
west: Math.min(from.lng, p.lng),
|
||||
east: Math.max(from.lng, p.lng),
|
||||
}
|
||||
} else {
|
||||
current = next
|
||||
}
|
||||
previous = p
|
||||
}
|
||||
if (current) tiles.push(padded(current))
|
||||
return tiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Thins a route down to the points that still describe it, so the corridor is built
|
||||
* from tens of coordinates rather than the thousands a routed geometry carries.
|
||||
* Ramer-Douglas-Peucker with the tolerance expressed in kilometres.
|
||||
*/
|
||||
export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
|
||||
if (line.length < 3) return line.slice()
|
||||
let maxDist = 0
|
||||
let index = 0
|
||||
const first = line[0]
|
||||
const last = line[line.length - 1]
|
||||
for (let i = 1; i < line.length - 1; i++) {
|
||||
const d = distanceToSegmentKm(line[i], first, last)
|
||||
if (d > maxDist) {
|
||||
maxDist = d
|
||||
index = i
|
||||
}
|
||||
}
|
||||
if (maxDist <= toleranceKm) return [first, last]
|
||||
const left = simplifyLine(line.slice(0, index + 1), toleranceKm)
|
||||
const right = simplifyLine(line.slice(index), toleranceKm)
|
||||
return [...left.slice(0, -1), ...right]
|
||||
}
|
||||
|
||||
/**
|
||||
* The point that lies `metres` along a line, measured the way a car drives it.
|
||||
*
|
||||
* Every other helper in this file goes the other way: given a point, where on the line
|
||||
* is it. This is the inverse, and it exists because the range budget produces a distance
|
||||
* ("the tank is empty 340 km in") with no idea where that is on the map.
|
||||
*
|
||||
* Walked segment by segment rather than approximated, and interpolated inside the
|
||||
* segment it lands in, because the answer becomes a marker somebody drives to. Past the
|
||||
* end it returns the last point rather than null: a tank that runs out after the day's
|
||||
* final stop still ran out somewhere, and the caller decides what to do about that.
|
||||
*/
|
||||
export function pointAtMeters(line: LatLng[], metres: number): LatLng | null {
|
||||
if (!line.length) return null
|
||||
if (line.length === 1 || metres <= 0) return line[0]
|
||||
|
||||
let covered = 0
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const step = haversineKm(line[i - 1], line[i]) * 1000
|
||||
if (covered + step >= metres) {
|
||||
// How far into this segment, as a fraction. A zero-length segment (two identical
|
||||
// vertices, which a concatenated route line does produce) would divide by zero,
|
||||
// and its start point is the right answer anyway.
|
||||
const t = step > 0 ? (metres - covered) / step : 0
|
||||
return {
|
||||
lat: line[i - 1].lat + (line[i].lat - line[i - 1].lat) * t,
|
||||
lng: line[i - 1].lng + (line[i].lng - line[i - 1].lng) * t,
|
||||
}
|
||||
}
|
||||
covered += step
|
||||
}
|
||||
return line[line.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a line is, in metres, measured the way `sliceAtMeters` walks it.
|
||||
*
|
||||
* Not the same figure the router reports for the same road: that comes off its own graph
|
||||
* while this is great-circle hops between the vertices it sent back. Anything cutting a
|
||||
* line at a distance has to measure it with this, or the last cut falls short of the end
|
||||
* by the difference.
|
||||
*/
|
||||
export function lineMetres(line: LatLng[]): number {
|
||||
let total = 0
|
||||
for (let i = 1; i < line.length; i++) total += haversineKm(line[i - 1], line[i]) * 1000
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* The stretch of a line between two distances along it, ends included.
|
||||
*
|
||||
* The router answers a whole run with one polyline and a leg list beside it, so the road
|
||||
* belonging to any single leg has to be cut out afterwards. Which matters because a road
|
||||
* trip draws its stops by the day they are REACHED: a drive across midnight hands its
|
||||
* stops to the next card, and the road under them has to go with them — otherwise the
|
||||
* day they set off from keeps a line nobody drives on it, and the corridor search along
|
||||
* "day 2" looks at road that was covered yesterday.
|
||||
*
|
||||
* Both ends are interpolated inside the segment they fall in rather than snapped to the
|
||||
* nearest vertex, so two adjacent slices meet exactly and concatenating them gives the
|
||||
* original line back.
|
||||
*/
|
||||
export function sliceAtMeters(line: LatLng[], fromMetres: number, toMetres: number): LatLng[] {
|
||||
if (line.length < 2 || toMetres <= fromMetres) return []
|
||||
const from = Math.max(0, fromMetres)
|
||||
const out: LatLng[] = []
|
||||
let covered = 0
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const step = haversineKm(line[i - 1], line[i]) * 1000
|
||||
const segStart = covered
|
||||
const segEnd = covered + step
|
||||
covered = segEnd
|
||||
if (segEnd < from) continue
|
||||
if (segStart > toMetres) break
|
||||
// A zero-length segment divides by zero; both its ends are the same point, so the
|
||||
// fraction does not matter and zero is the one that never produces NaN.
|
||||
const at = (m: number): LatLng => {
|
||||
const t = step > 0 ? (m - segStart) / step : 0
|
||||
return {
|
||||
lat: line[i - 1].lat + (line[i].lat - line[i - 1].lat) * t,
|
||||
lng: line[i - 1].lng + (line[i].lng - line[i - 1].lng) * t,
|
||||
}
|
||||
}
|
||||
if (!out.length) out.push(segStart >= from ? line[i - 1] : at(from))
|
||||
out.push(segEnd <= toMetres ? line[i] : at(toMetres))
|
||||
}
|
||||
return out.length > 1 ? out : []
|
||||
}
|
||||
|
||||
/**
|
||||
* A box around a point, sized so its inscribed circle reaches `radiusKm`.
|
||||
*
|
||||
* The server turns a box into centre plus half the diagonal, so a box of ±r asks for
|
||||
* r·√2 of reach and finds things in the corners that are further away than they look.
|
||||
* Sized from the radius rather than guessed, and the caller still has to read the
|
||||
* answer's own `clamped` flag: above 20 km the index narrows the search silently.
|
||||
*/
|
||||
export function boxAround(point: LatLng, radiusKm: number): Bbox {
|
||||
const dLat = radiusKm / KM_PER_DEG_LAT
|
||||
const dLng = radiusKm / (KM_PER_DEG_LAT * Math.max(0.01, Math.cos(toRad(point.lat))))
|
||||
return {
|
||||
south: point.lat - dLat,
|
||||
west: point.lng - dLng,
|
||||
north: point.lat + dLat,
|
||||
east: point.lng + dLng,
|
||||
}
|
||||
}
|
||||
export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround } from '@trek/shared/roadtrip'
|
||||
export type { LatLng, Bbox, CorridorHit } from '@trek/shared/roadtrip'
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { nearestDayBoundary, type DayBoundaryLeg } from './dayBoundaryPath'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
|
||||
const point = (assignmentId: number, lng: number) => ({ assignmentId, lat: 0, lng }) as RoadtripStop
|
||||
const path: DayBoundaryLeg[] = [{ from: point(10, 0), to: point(20, 1), position: 0, line: [[0, 0], [0, 0.5], [0, 1]] }]
|
||||
const project = (lat: number, lng: number) => ({ x: lng * 1000, y: lat * 1000 })
|
||||
|
||||
it('snaps to a visit before considering nearby road segments', () => {
|
||||
expect(nearestDayBoundary(path, 1, { x: 985, y: 2 }, project)?.boundary).toEqual({ day_number: 1, from_assignment_id: 20, to_assignment_id: null, fraction: 1 })
|
||||
})
|
||||
it('projects the pointer onto the full route with a distance fraction', () => {
|
||||
const target = nearestDayBoundary(path, 2, { x: 300, y: 100 }, project)!
|
||||
expect(target.boundary.fraction).toBeCloseTo(0.3)
|
||||
expect(target.lng).toBeCloseTo(0.3)
|
||||
expect(target.lat).toBe(0)
|
||||
})
|
||||
it('excludes the previous and next fixed day endings', () => {
|
||||
expect(nearestDayBoundary(path, 2, { x: 0, y: 0 }, project, 1, 2)).toBeNull()
|
||||
expect(nearestDayBoundary(path, 2, { x: 1000, y: 0 }, project, 0, 0.2)).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { RoadtripDayBoundary } from '@trek/shared'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
import { lineMetres } from './corridor'
|
||||
|
||||
export interface DayBoundaryLeg {
|
||||
from: RoadtripStop
|
||||
to: RoadtripStop
|
||||
position: number
|
||||
line: [number, number][]
|
||||
}
|
||||
export interface DayBoundaryTarget {
|
||||
boundary: RoadtripDayBoundary
|
||||
lat: number
|
||||
lng: number
|
||||
position: number
|
||||
}
|
||||
export type ProjectPoint = (lat: number, lng: number) => { x: number; y: number }
|
||||
|
||||
export function nearestDayBoundary(
|
||||
legs: DayBoundaryLeg[], day: number, point: { x: number; y: number }, project: ProjectPoint,
|
||||
min = -1, max = Infinity,
|
||||
): DayBoundaryTarget | null {
|
||||
let best: DayBoundaryTarget | null = null
|
||||
let distance = Infinity
|
||||
const offer = (target: DayBoundaryTarget, gap: number) => {
|
||||
if (target.position <= min + 0.000001 || target.position >= max - 0.000001 || gap >= distance) return
|
||||
best = target
|
||||
distance = gap
|
||||
}
|
||||
for (const leg of legs) {
|
||||
for (const [stop, position] of [[leg.from, leg.position], [leg.to, leg.position + 1]] as const) {
|
||||
const p = project(stop.lat, stop.lng)
|
||||
const gap = Math.hypot(p.x - point.x, p.y - point.y)
|
||||
if (gap <= 25) offer({ boundary: { day_number: day, from_assignment_id: stop.assignmentId, to_assignment_id: null, fraction: 1 }, lat: stop.lat, lng: stop.lng, position }, gap)
|
||||
}
|
||||
}
|
||||
if (best) return best
|
||||
for (const leg of legs) {
|
||||
const metres = lineMetres(leg.line.map(([lat, lng]) => ({ lat, lng })))
|
||||
if (!metres) continue
|
||||
let travelled = 0
|
||||
for (let i = 1; i < leg.line.length; i++) {
|
||||
const a = leg.line[i - 1], b = leg.line[i]
|
||||
const p = project(a[0], a[1]), q = project(b[0], b[1])
|
||||
const dx = q.x - p.x, dy = q.y - p.y
|
||||
const share = dx || dy ? Math.max(0, Math.min(1, ((point.x - p.x) * dx + (point.y - p.y) * dy) / (dx * dx + dy * dy))) : 0
|
||||
const length = lineMetres([{ lat: a[0], lng: a[1] }, { lat: b[0], lng: b[1] }])
|
||||
const fraction = (travelled + length * share) / metres
|
||||
offer({
|
||||
boundary: { day_number: day, from_assignment_id: leg.from.assignmentId, to_assignment_id: leg.to.assignmentId, fraction },
|
||||
lat: a[0] + (b[0] - a[0]) * share, lng: a[1] + (b[1] - a[1]) * share, position: leg.position + fraction,
|
||||
}, Math.hypot(p.x + dx * share - point.x, p.y + dy * share - point.y))
|
||||
travelled += length
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { dayWindow, planDayWindow, roadtripInsertion } from '@trek/shared/roadtrip'
|
||||
export type { DayWindow, AutomaticNight, WindowPlan } from '@trek/shared/roadtrip'
|
||||
@@ -1,212 +1,2 @@
|
||||
import { computeSchedule, parseClock, type Schedule } from './roadtripModel'
|
||||
import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
/**
|
||||
* Which date each stop of a road trip is drawn on.
|
||||
*
|
||||
* A day plan stores a stop against a day, and that is the right model for planning: the
|
||||
* traveller put it there. But a road trip is read as a drive, and a drive that leaves at
|
||||
* 21:00 and arrives at 01:23 arrives TOMORROW. The rail used to say so with a band
|
||||
* between the two stops and then carry on listing the arrival under yesterday's date —
|
||||
* the one thing the band had just said was wrong.
|
||||
*
|
||||
* So a road trip lays its stops out by the day they are actually reached, and the drive
|
||||
* across midnight travels with them, which is what makes the kilometres count on the day
|
||||
* they are driven rather than on the day they were set off on.
|
||||
*
|
||||
* **Nothing is written.** The stop still belongs to the day it was planned on, the day
|
||||
* plan shows it there, and every stop carries `ownerDayId`/`ownerIndex` so a reorder, a
|
||||
* move, a stay edit or a refuel offer still names the day the server knows it by. Because
|
||||
* this is worked out again from the arrival times every time they change, shortening the
|
||||
* stay before a night drive brings the stop back on its own — there is no state to undo,
|
||||
* and no way for the arrangement and the times to disagree.
|
||||
*/
|
||||
|
||||
/** A stretch of one card's chain that was driven onto it from an earlier day. */
|
||||
export interface SpillMark {
|
||||
/** Where it starts in the card's `stops`. */
|
||||
at: number
|
||||
/** How many stops it covers. */
|
||||
count: number
|
||||
/** The day they are stored on, as the traveller numbers it. */
|
||||
fromDayNumber: number
|
||||
/** When the drive set off, read off that day's own schedule. */
|
||||
departure: string | null
|
||||
/** The drive across midnight itself, or undefined when that leg never routed. */
|
||||
leg: RouteSegment | undefined
|
||||
/**
|
||||
* The stop the night drive LEAVES from, still on the earlier card.
|
||||
*
|
||||
* Needed because that drive is drawn on this card while belonging, leg-wise, to the
|
||||
* stop before it: anything measured along this card's line that lands on the incoming
|
||||
* stretch has to be anchored to this stop, not to the first one drawn here. Putting a
|
||||
* via there and anchoring it to the wrong stop is what made a route double back.
|
||||
*/
|
||||
fromStop: RoadtripStop | undefined
|
||||
/**
|
||||
* The road it covers, so the card it lands on can draw it and count it.
|
||||
*
|
||||
* Without this the night is a hole: the day it set off from ends before it and the day
|
||||
* it arrives on starts after it, so its kilometres belong to neither and the map draws
|
||||
* two runs with a gap between them.
|
||||
*/
|
||||
line: [number, number][]
|
||||
}
|
||||
|
||||
/** One date's worth of driving, before the legs and warnings are hung off it. */
|
||||
export interface SpillChain {
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
/** In driving order: anything inherited first, then the day's own. */
|
||||
stops: RoadtripStop[]
|
||||
/** Arrivals as each stop's OWN day worked them out, renumbered onto this chain. */
|
||||
schedule: Schedule
|
||||
/** The inherited stretches, in the order they appear. Empty on an ordinary day. */
|
||||
spills: SpillMark[]
|
||||
}
|
||||
|
||||
/** A day as the plan holds it, before anything is routed onto it. */
|
||||
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
|
||||
|
||||
/** What the caller can find out about the road between two stops. */
|
||||
type LegLookup = (from: RoadtripStop, to: RoadtripStop) => { seg: RouteSegment; line: [number, number][] } | undefined
|
||||
|
||||
/** One stop on its way to a card, with the times its own day gave it. */
|
||||
interface Placed {
|
||||
stop: RoadtripStop
|
||||
arrival: string | null
|
||||
departure: string | null
|
||||
/** Days past its own day's first stop. Renumbered against the card it lands on. */
|
||||
dayOffset: number
|
||||
/** Findings its own day filed against it, to be renumbered with it. */
|
||||
codes: ('late' | 'overnight' | 'leg' | 'range')[]
|
||||
lateMinutes: number | null
|
||||
/** Which day it came from, or null when it is the card's own. */
|
||||
fromDayNumber: number | null
|
||||
departedAt: string | null
|
||||
leg: RouteSegment | undefined
|
||||
line: [number, number][]
|
||||
from: RoadtripStop | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays a trip out by the date each stop is reached.
|
||||
*
|
||||
* Every day of the trip gets a chain, in trip order, including the ones that carry no
|
||||
* drive of their own — a day whose only content was driven onto it through the night is
|
||||
* still a day, and the alternative is a date that silently disappears. The caller drops
|
||||
* the chains that end up with fewer than two stops, exactly as it did before.
|
||||
*/
|
||||
export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegLookup): SpillChain[] {
|
||||
const all: PlanDay[] = [...plan, ...quietDays.map(d => ({
|
||||
dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops,
|
||||
}))].sort((a, b) => a.dayNumber - b.dayNumber)
|
||||
const numbers = new Set(all.map(d => d.dayNumber))
|
||||
const landing = new Map<number, Placed[]>()
|
||||
for (const d of all) landing.set(d.dayNumber, [])
|
||||
|
||||
for (const d of all) {
|
||||
// A day's own schedule, walked over its own stops: this is what says where midnight
|
||||
// falls, and it is the only reading of these times that is anchored to the times the
|
||||
// traveller actually pinned.
|
||||
const routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1]))
|
||||
const legs = routed.map(l => l?.seg)
|
||||
const schedule = computeSchedule(
|
||||
d.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
|
||||
legs.map(l => l?.duration),
|
||||
)
|
||||
/**
|
||||
* Which day of this chain each stop is reached on, read off the clock itself.
|
||||
*
|
||||
* Not `dayOffset`. That is arithmetic on top of a pinned time resolved to the
|
||||
* occurrence NEAREST the drive reaching it — so a stop pinned at 23:00 after one
|
||||
* pinned at 07:00 resolves to 23:00 yesterday (nine hours late reads as nearer than
|
||||
* fifteen hours of waiting), the chain drops below midnight, and the schedule lifts
|
||||
* the whole day back up to keep it positive. The result is an early stop wearing a
|
||||
* higher offset than the one after it, and placing stops on those offsets tears the
|
||||
* drive into pieces that are visited out of order.
|
||||
*
|
||||
* A clock that goes backwards is the one signal none of that can fake. Arrivals only
|
||||
* ever move forward along a drive, so 23:00 followed by 03:12 is one thing and one
|
||||
* thing only: midnight happened in between. A stop with no arrival yet inherits the
|
||||
* day of the one before it rather than resetting the count.
|
||||
*/
|
||||
let day = 0
|
||||
let previous: number | null = null
|
||||
d.stops.forEach((stop, i) => {
|
||||
const entry = schedule.entries[i]
|
||||
const clock = parseClock(entry?.arrival)
|
||||
if (clock !== null) {
|
||||
if (previous !== null && clock < previous) day += 1
|
||||
previous = clock
|
||||
}
|
||||
const offset = day
|
||||
const marks = schedule.warnings.filter(w => w.index === i)
|
||||
// A day the trip does not have is not a day to move onto. Those stops stay where
|
||||
// they are and keep the crossing marked the way the rail always marked it, because
|
||||
// the only other answer would be to invent a date nobody planned. The ratchet stays
|
||||
// where it was too, so the rest of the chain stays with them.
|
||||
const reachable = offset > 0 && numbers.has(d.dayNumber + offset)
|
||||
const target = reachable ? d.dayNumber + offset : d.dayNumber
|
||||
const moved = target !== d.dayNumber
|
||||
landing.get(target)?.push({
|
||||
stop,
|
||||
arrival: entry?.arrival ?? null,
|
||||
departure: entry?.departure ?? null,
|
||||
// Renumbered against the card it lands on: 01:23 is not "the next day" once it is
|
||||
// drawn under the next day's date. A stop that could not move keeps its offset,
|
||||
// which is what still makes the "+1" and the crossing band read correctly.
|
||||
dayOffset: moved ? 0 : offset,
|
||||
// The crossing itself is the spill block's own head wherever the stop moved, so
|
||||
// it would be said twice.
|
||||
codes: marks.map(w => w.code).filter(c => !(moved && c === 'overnight')),
|
||||
lateMinutes: marks.find(w => w.code === 'late')?.minutes ?? null,
|
||||
fromDayNumber: moved ? d.dayNumber : null,
|
||||
departedAt: moved ? schedule.entries[i - 1]?.departure ?? null : null,
|
||||
leg: moved ? legs[i - 1] : undefined,
|
||||
line: moved ? routed[i - 1]?.line ?? [] : [],
|
||||
from: moved ? d.stops[i - 1] : undefined,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const out: SpillChain[] = []
|
||||
for (const d of all) {
|
||||
const placed = landing.get(d.dayNumber) ?? []
|
||||
if (!placed.length) continue
|
||||
// Inherited stretches first, oldest day first, then the card's own stops: that is
|
||||
// the order they are driven in.
|
||||
placed.sort((a, b) => (a.fromDayNumber ?? Number.MAX_SAFE_INTEGER) - (b.fromDayNumber ?? Number.MAX_SAFE_INTEGER))
|
||||
const spills: SpillMark[] = []
|
||||
for (let i = 0; i < placed.length; i++) {
|
||||
const p = placed[i]
|
||||
if (p.fromDayNumber === null) continue
|
||||
const last = spills[spills.length - 1]
|
||||
if (last && last.fromDayNumber === p.fromDayNumber && last.at + last.count === i) last.count += 1
|
||||
else spills.push({ at: i, count: 1, fromDayNumber: p.fromDayNumber, departure: p.departedAt, leg: p.leg, line: p.line, fromStop: p.from })
|
||||
}
|
||||
out.push({
|
||||
dayId: d.dayId,
|
||||
dayNumber: d.dayNumber,
|
||||
date: d.date,
|
||||
title: d.title,
|
||||
stops: placed.map(p => p.stop),
|
||||
schedule: {
|
||||
entries: placed.map(p => ({
|
||||
arrival: p.arrival,
|
||||
departure: p.departure,
|
||||
anchored: p.stop.time !== null && p.stop.time !== undefined,
|
||||
dayOffset: p.dayOffset,
|
||||
})),
|
||||
warnings: placed.flatMap((p, i) => p.codes.map(code => (
|
||||
code === 'late' ? { index: i, code, minutes: p.lateMinutes ?? 0 } : { index: i, code }
|
||||
))),
|
||||
},
|
||||
spills,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
export { spillChains } from '@trek/shared/roadtrip'
|
||||
export type { SpillMark, SpillChain } from '@trek/shared/roadtrip'
|
||||
|
||||
@@ -1,803 +1,2 @@
|
||||
/**
|
||||
* Pure road-trip arithmetic — no React, no network, no store. Everything here is a
|
||||
* function of the stops, the routed legs and the day's rules, so it can be unit tested
|
||||
* on its own and reused by the map, the rail and (later) the MCP tool.
|
||||
*/
|
||||
|
||||
import type { RouteAvoidClass } from '../../types'
|
||||
|
||||
/**
|
||||
* The stop kinds that interrupt a drive rather than end it.
|
||||
*
|
||||
* Every kind the corridor search can find, and the list is meant to stay that way: what
|
||||
* you pick off "along the route" is by definition something you come across on the way,
|
||||
* so it belongs inside the leg it was found on rather than among the places the trip is
|
||||
* for. They are drawn on the dashed line with their own icon, carry no number, and are
|
||||
* left out of every stop count — four places with a charger, a rest stop and a bakery
|
||||
* between them is a four-stop day.
|
||||
*
|
||||
* Extended together with the corridor's own categories (`RoadtripCorridorPanel`) and the
|
||||
* enum in `@trek/shared`; a kind in one list and not the others is a stop that looks like
|
||||
* a destination on one screen and a pause on the next.
|
||||
*/
|
||||
export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const
|
||||
|
||||
/**
|
||||
* What each kind of pause is coloured, on the rail, in the corridor list and on the map.
|
||||
*
|
||||
* Read as road signage rather than as a palette: a pump is petrol orange, a charger is
|
||||
* the yellow of electricity, a rest area takes the blue every parking sign in Europe is
|
||||
* printed in, a campsite is green. The last two are the colours the general place search
|
||||
* already gives food and sights, so a restaurant found along the route looks like the
|
||||
* restaurants found anywhere else.
|
||||
*
|
||||
* Deliberately literal and shared rather than tokenised: these mean the same thing in
|
||||
* both themes the way a motorway sign does, and one definition is what stops the rail,
|
||||
* the search result and the map pin from drifting into three different reds.
|
||||
*/
|
||||
export const SERVICE_COLORS: Record<string, string> = {
|
||||
fuel: '#E8590C',
|
||||
charging: '#CA8A04',
|
||||
rest_area: '#3B82F6',
|
||||
campsite: '#16A34A',
|
||||
restaurant: '#EF4444',
|
||||
sights: '#EC4899',
|
||||
}
|
||||
|
||||
/** The colour for a kind, or the neutral one for a kind nobody has a colour for yet. */
|
||||
export function serviceColor(stopType: string | null | undefined): string {
|
||||
return SERVICE_COLORS[stopType ?? ''] ?? '#64748B'
|
||||
}
|
||||
|
||||
/** Whether a stop breaks the drive. The rail's shape and every count turn on this. */
|
||||
export function isServiceStopType(stopType: string | null | undefined): boolean {
|
||||
return !!stopType && (SERVICE_STOP_TYPES as readonly string[]).includes(stopType)
|
||||
}
|
||||
|
||||
/** Seconds → "2 h 10 min" / "45 min", matching the wording of the map's route connectors. */
|
||||
export function formatDurationShort(seconds: number): string {
|
||||
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
|
||||
const h = Math.floor(safe / 3600)
|
||||
const m = Math.round((safe % 3600) / 60)
|
||||
// 59.6 min must not print as "60 min"; carry it.
|
||||
if (m === 60) return `${h + 1} h`
|
||||
if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h`
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
/** "09:45" → 585. Anything that is not a wall-clock time returns null. */
|
||||
export function parseClock(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec(value.trim())
|
||||
if (!m) return null
|
||||
const h = Number(m[1])
|
||||
const min = Number(m[2])
|
||||
if (h > 23 || min > 59) return null
|
||||
return h * 60 + min
|
||||
}
|
||||
|
||||
/** 585 → "09:45". Minutes past midnight beyond a day wrap around and report the carry. */
|
||||
export function formatClock(minutes: number): string {
|
||||
const day = 24 * 60
|
||||
const wrapped = ((minutes % day) + day) % day
|
||||
const h = Math.floor(wrapped / 60)
|
||||
const m = Math.round(wrapped % 60)
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export interface ScheduleStop {
|
||||
/** Wall-clock time the user pinned on this stop, if any. */
|
||||
anchor: string | null
|
||||
/** Planned time spent here, in minutes. */
|
||||
dwellMinutes: number | null
|
||||
}
|
||||
|
||||
export interface ScheduleWarning {
|
||||
/**
|
||||
* Index of the stop the finding belongs to.
|
||||
*
|
||||
* For the two leg codes this is the stop the leg ARRIVES AT, not the one it leaves.
|
||||
* Both findings are a running total that only becomes true on arrival: "125 km since
|
||||
* the last fill-up" is what the tank reads when you get there, and the figure has to
|
||||
* agree with the one anybody adds up off the drive bands above it. Anchored to the
|
||||
* departure it read as the distance of the leg leaving that stop, which is a different
|
||||
* number entirely and the one that made the badge look wrong.
|
||||
*/
|
||||
index: number
|
||||
code: 'late' | 'overnight' | 'leg' | 'range'
|
||||
/** Minutes the computed arrival misses the anchor by (code 'late'). */
|
||||
minutes?: number
|
||||
/** Minutes this leg runs over the longest single drive allowed (code 'leg'). */
|
||||
overMinutes?: number
|
||||
/** Kilometres driven since the last fill-up when the range ran out (code 'range'). */
|
||||
sinceKm?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The two kinds of stop that put fuel or charge back.
|
||||
*
|
||||
* Deliberately shorter than `SERVICE_STOP_TYPES`: a two-hour lunch fills no tank, and
|
||||
* TREK knows nothing about whether the restaurant has a charger in its car park. Reusing
|
||||
* the service list here would quietly reset the range budget at every rest area and hide
|
||||
* the one finding this feature exists to produce.
|
||||
*/
|
||||
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
|
||||
|
||||
/**
|
||||
* What the traveller drives, which decides what actually fills the tank.
|
||||
*
|
||||
* Absent means both count, which is what TREK did before the setting existed and is the
|
||||
* right answer for somebody who never said. It is only wrong once a vehicle IS named:
|
||||
* a petrol station does not charge a battery, and a charger does not fill a tank.
|
||||
*/
|
||||
export type VehicleKind = 'combustion' | 'electric'
|
||||
|
||||
/** The stop kind that refills the named vehicle, for filtering a search. */
|
||||
export function refuelStopTypeFor(vehicle: VehicleKind | null | undefined): readonly string[] {
|
||||
if (vehicle === 'combustion') return ['fuel']
|
||||
if (vehicle === 'electric') return ['charging']
|
||||
return REFUELLING_STOP_TYPES
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether stopping here starts the range budget over.
|
||||
*
|
||||
* With no vehicle named, either kind does — a plain reading of "I filled up". Once one is
|
||||
* named the other stops counting, because the alternative is arithmetic that is simply
|
||||
* wrong: an electric car pausing at a petrol station had its battery refilled on paper,
|
||||
* the warnings went quiet for the rest of the day, and the driver was told nothing.
|
||||
*/
|
||||
export function refuelsRange(stopType: string | null | undefined, vehicle?: VehicleKind | null): boolean {
|
||||
return refuelStopTypeFor(vehicle).includes(stopType ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The three numbers a traveller can set. null means no limit, which is an explicit off
|
||||
* rather than a sentinel: zero is a legal thing to type and means the same as unset.
|
||||
*/
|
||||
export interface DriveLimits {
|
||||
/**
|
||||
* How full a fill-up actually goes, 1 to 100, or null for "all the way".
|
||||
*
|
||||
* Nobody charges an electric car to 100 % on the road: the last fifth takes as long as
|
||||
* the first four and the manual says not to. Treating every stop as a full tank
|
||||
* overstates the range after it by exactly that fifth, which is a whole leg on a long
|
||||
* day.
|
||||
*/
|
||||
fillPercent?: number | null
|
||||
/** Longest single drive between two stops, in minutes. */
|
||||
legMinutes: number | null
|
||||
/** Longest total driving in one day, in minutes. */
|
||||
dayMinutes: number | null
|
||||
/** How far one tank or charge goes, in kilometres. */
|
||||
rangeKm: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A finding about the day as a whole.
|
||||
*
|
||||
* Its own shape rather than a ScheduleWarning with index -1: a sentinel index would be
|
||||
* read by every consumer that filters findings by stop, and one of them would eventually
|
||||
* draw the day's finding on the first stop.
|
||||
*/
|
||||
export interface DayWarning {
|
||||
code: 'dayDriving'
|
||||
/** Driving time of the day in minutes, the same number the badge beside it shows. */
|
||||
minutes: number
|
||||
limitMinutes: number
|
||||
}
|
||||
|
||||
export interface ScheduleEntry {
|
||||
/** Computed arrival, or the anchor where the user pinned one. */
|
||||
arrival: string | null
|
||||
departure: string | null
|
||||
/** True when the time was pinned by the user rather than derived. */
|
||||
anchored: boolean
|
||||
/** Days past the first stop, so a chain running past midnight stays readable. */
|
||||
dayOffset: number
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
entries: ScheduleEntry[]
|
||||
warnings: ScheduleWarning[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the chain forward: arrival = previous departure + driving time, departure =
|
||||
* arrival + time spent at the stop. A stop the user pinned a time on does not move —
|
||||
* it restarts the cascade from its own time, which is what makes a ferry, a museum
|
||||
* ticket or a hotel check-in usable as a fixed point (Furkot solves it the same way).
|
||||
*
|
||||
* `legSeconds[i]` is the drive from stop i to stop i+1; a leg that never routed is
|
||||
* `undefined` and breaks the chain rather than inventing a duration.
|
||||
*/
|
||||
/**
|
||||
* Where a stop's clock actually starts.
|
||||
*
|
||||
* A pinned time wins over whatever the drive before it worked out and restarts the
|
||||
* cascade from there. When it sits before the arrival the drive implies, the plan does
|
||||
* not fit: the caller is told by how much rather than the pinned time being moved.
|
||||
*/
|
||||
const DAY_MINUTES = 24 * 60
|
||||
|
||||
function resolveArrival(
|
||||
anchor: number | null,
|
||||
cursor: number | null,
|
||||
dayOffset: number,
|
||||
): { arrival: number | null; lateBy: number | null } {
|
||||
if (anchor === null) return { arrival: cursor, lateBy: null }
|
||||
// No drive has been timed yet, so there is nothing to be late against — but the
|
||||
// day carried so far still applies, or a pinned stop after an untimed leg
|
||||
// prints under a day-1 stop as though it happened first.
|
||||
if (cursor === null) return { arrival: anchor + dayOffset * DAY_MINUTES, lateBy: null }
|
||||
// The occurrence of that wall-clock time nearest the cursor, not the one on the
|
||||
// day the cascade has reached so far. `dayOffset` only advances once a computed
|
||||
// arrival crosses midnight, and it is read before this stop's arrival is known —
|
||||
// so when the stop that crosses midnight is the pinned one, its anchor was
|
||||
// placed a whole day early and the plan was reported as 24 hours late while
|
||||
// being exactly on time. Rounding also keeps the ordinary case: 08:00, a three
|
||||
// hour leg, a 09:00 pin still picks the same day and is two hours late.
|
||||
const k = Math.round((cursor - anchor) / DAY_MINUTES)
|
||||
const anchorAt = anchor + k * DAY_MINUTES
|
||||
return {
|
||||
arrival: anchorAt,
|
||||
lateBy: cursor > anchorAt + 1 ? Math.round(cursor - anchorAt) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
|
||||
const warnings: ScheduleWarning[] = []
|
||||
// Arrivals in minutes, so the chain can be walked twice before anything is formatted.
|
||||
const arrivals: (number | null)[] = new Array(stops.length).fill(null)
|
||||
const anchored: boolean[] = new Array(stops.length).fill(false)
|
||||
// Minutes since the first stop's midnight, so a chain crossing midnight keeps counting.
|
||||
let cursor: number | null = null
|
||||
let dayOffset = 0
|
||||
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const stop = stops[i]
|
||||
const anchor = parseClock(stop.anchor)
|
||||
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset)
|
||||
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy })
|
||||
|
||||
if (arrival === null) continue
|
||||
|
||||
const offset = Math.floor(arrival / DAY_MINUTES)
|
||||
if (offset > dayOffset) dayOffset = offset
|
||||
|
||||
arrivals[i] = arrival
|
||||
anchored[i] = anchor !== null
|
||||
|
||||
const leg = legSeconds[i]
|
||||
cursor = leg === undefined ? null : arrival + (stop.dwellMinutes ?? 0) + Math.round(leg / 60)
|
||||
}
|
||||
|
||||
// Then backwards, for the stops the forward walk left blank because nobody had pinned
|
||||
// a time yet. Pinning a time on the second stop is the ordinary way to plan: the museum
|
||||
// opens at ten, so when do we have to leave? Working back from the first known arrival
|
||||
// answers it — departure = the next arrival minus the drive, arrival = that minus the
|
||||
// stay — and it stops at the first leg that never routed rather than inventing one.
|
||||
const firstKnown = arrivals.findIndex(a => a !== null)
|
||||
for (let i = firstKnown - 1; i >= 0; i--) {
|
||||
const leg = legSeconds[i]
|
||||
const next = arrivals[i + 1]
|
||||
if (leg === undefined || next === null) break
|
||||
arrivals[i] = next - Math.round(leg / 60) - (stops[i].dwellMinutes ?? 0)
|
||||
}
|
||||
|
||||
// Working back can land before the anchor day's midnight, which would print as a
|
||||
// negative day. `dayOffset` counts days past the FIRST stop, so the whole chain shifts
|
||||
// up instead until the earliest stop sits on day zero again.
|
||||
const earliest = arrivals.reduce<number | null>((m, a) => (a === null ? m : m === null || a < m ? a : m), null)
|
||||
const shift = earliest === null || earliest >= 0 ? 0 : -Math.floor(earliest / DAY_MINUTES) * DAY_MINUTES
|
||||
|
||||
const entries: ScheduleEntry[] = []
|
||||
let lastOffset = 0
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const raw = arrivals[i]
|
||||
if (raw === null) {
|
||||
entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 })
|
||||
continue
|
||||
}
|
||||
const arrival = raw + shift
|
||||
const offset = Math.floor(arrival / DAY_MINUTES)
|
||||
// Read off the finished chain rather than during the forward walk, so a midnight the
|
||||
// backward pass introduced is marked too.
|
||||
if (offset > lastOffset) warnings.push({ index: i, code: 'overnight' })
|
||||
lastOffset = offset
|
||||
entries.push({
|
||||
arrival: formatClock(arrival),
|
||||
departure: formatClock(arrival + (stops[i].dwellMinutes ?? 0)),
|
||||
anchored: anchored[i],
|
||||
dayOffset: offset,
|
||||
})
|
||||
}
|
||||
|
||||
return { entries, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a day's stops into the stretches that can be asked for in one routing request:
|
||||
* consecutive stops whose leg shares a travel mode.
|
||||
*
|
||||
* The router returns a leg for every consecutive pair of the waypoints it is handed, so
|
||||
* a day travelled one way is a single request rather than one per leg — the difference
|
||||
* between a road trip appearing at once and crawling in over ten seconds. A day that
|
||||
* mixes walking and driving splits at the change, because one request carries one mode.
|
||||
*/
|
||||
export function splitIntoRuns<T>(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {
|
||||
const runs: { stops: T[]; mode: string }[] = []
|
||||
let current: T[] = []
|
||||
let currentMode: string | null = null
|
||||
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const mode = modeOfLeg(stops[i], stops[i + 1])
|
||||
if (currentMode === null) {
|
||||
current = [stops[i], stops[i + 1]]
|
||||
currentMode = mode
|
||||
} else if (mode === currentMode) {
|
||||
current.push(stops[i + 1])
|
||||
} else {
|
||||
runs.push({ stops: current, mode: currentMode })
|
||||
current = [stops[i], stops[i + 1]]
|
||||
currentMode = mode
|
||||
}
|
||||
}
|
||||
if (currentMode !== null && current.length > 1) runs.push({ stops: current, mode: currentMode })
|
||||
return runs
|
||||
}
|
||||
|
||||
/** Total driving seconds of the legs that actually routed. */
|
||||
export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
|
||||
return legSeconds.reduce<number>((sum, s) => sum + (s ?? 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a place found along the drive belongs in the day's chain.
|
||||
*
|
||||
* Both numbers are distances travelled along the same routed line, so the comparison is
|
||||
* "which stops has the car already passed when it reaches this one". A petrol station
|
||||
* 120 km into the day goes after every stop nearer than that and before the rest,
|
||||
* which is what makes adding one a single click rather than a drag afterwards.
|
||||
*
|
||||
* Clamped to sit inside the day: a corridor is wider than the road, so a hit can project
|
||||
* slightly before the first stop or past the last, and neither "before where you set off"
|
||||
* nor "after where you arrive" is a place a fuel stop can go.
|
||||
*/
|
||||
/**
|
||||
* Which leg a point on the drive belongs to, given where each leg ends.
|
||||
*
|
||||
* Measured against the legs' own lengths rather than by projecting the stops as well: the
|
||||
* router's leg distances are exact by construction, while two stops close together
|
||||
* project onto each other's stretch and would put a charging halt on the wrong side of a
|
||||
* town. A point past the end lands in the last leg rather than nowhere, because that is
|
||||
* a rounding edge and not a missing leg.
|
||||
*/
|
||||
/**
|
||||
* Where a tank runs dry, in the day's own driving coordinates.
|
||||
*
|
||||
* A separate thing from the range WARNING, and the difference is the whole point. The
|
||||
* warning is filed against the stop the car arrives at, which is where somebody finds
|
||||
* out; this is where the fuel actually runs out, which can be most of a leg earlier and
|
||||
* is the only place worth suggesting a filling station.
|
||||
*
|
||||
* `drivenMeters` counts DRIVING legs only, so it is not an offset into the day's drawn
|
||||
* line: that line also carries ferry and walking runs. Converting it to a coordinate
|
||||
* means walking the driving legs, which `dryPointOn` does.
|
||||
*/
|
||||
export interface DryPoint {
|
||||
/** Index into the day's legs, i.e. the leg the car is on when the tank empties. */
|
||||
legIndex: number
|
||||
/** How far into that leg, in kilometres. */
|
||||
intoLegKm: number
|
||||
/** Metres of driving from the start of the day to that point. */
|
||||
drivenMeters: number
|
||||
/** The range limit that was crossed, which is what the traveller set. */
|
||||
sinceKm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The findings about the driving itself: too long at the wheel, too long in one day, and
|
||||
* the tank running out before anywhere to fill it.
|
||||
*
|
||||
* Reads the legs and nothing else. The clock cascade is not available to lean on: with no
|
||||
* stop pinned to a time it produces no times at all, for any stop, so a limit expressed as
|
||||
* an hour of the day would simply never fire. All three limits are therefore durations and
|
||||
* kilometres, which exist as soon as a leg has routed.
|
||||
*
|
||||
* The range budget resets at a fuel or charging stop and NOWHERE ELSE. It carries across
|
||||
* midnight, because a tank does not empty overnight, and it keeps counting past a finding,
|
||||
* because a warning is not a fill-up. Zeroing it there made the second figure wrong and
|
||||
* every one after it: a day reading 245 km at one stop and 121 at the next claims the 121
|
||||
* is a fresh tank, when it is 366 on the same one. A run of warnings down a stretch with
|
||||
* no fuel on it is not noise; it is the answer, and it stops the moment a fuel or
|
||||
* charging stop is put in.
|
||||
*
|
||||
* An unrouted leg gives the budget up entirely rather than guessing, the same way the
|
||||
* schedule abandons its cursor: a distance we do not have cannot be added to one we do.
|
||||
*/
|
||||
export function deriveDriveWarnings(
|
||||
/**
|
||||
* `mode` decides whether a leg counts at all. A day may legitimately mix
|
||||
* modes — splitIntoRuns exists for exactly that — and a two hour walk to a
|
||||
* viewpoint is not two hours of driving, nor are its kilometres on the tank.
|
||||
* Counting them reported a hike as "over the longest drive allowed" and let a
|
||||
* ferry crossing trip a range warning. Anything that is not a driving profile
|
||||
* is skipped; an absent mode counts, because every leg was a drive before the
|
||||
* field existed.
|
||||
*/
|
||||
legs: ({ duration?: number; distance?: number; mode?: string } | undefined)[],
|
||||
/** Whether the stop at each index refuels. One entry longer than `legs`. */
|
||||
refuelsAt: boolean[],
|
||||
limits: DriveLimits,
|
||||
/** Kilometres already on the tank when the day starts; null when that is unknown. */
|
||||
carryKm: number | null,
|
||||
/**
|
||||
* Per stop, how full THAT stop fills up, 1-100. Absent follows `limits.fillPercent`.
|
||||
*
|
||||
* A property of the stop rather than of the traveller: a motorway rapid charger is
|
||||
* worth about 80 % because the last fifth costs as long again, while the one at the
|
||||
* hotel is worth all of it because the car stands there all night. One figure for the
|
||||
* whole trip cannot say both, and the difference between them is a leg.
|
||||
*
|
||||
* Last and optional so the four-argument call, which is every caller that has no
|
||||
* per-stop opinion, keeps meaning exactly what it did.
|
||||
*/
|
||||
fillAt: readonly (number | null | undefined)[] = [],
|
||||
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null; emptyAt: DryPoint[] } {
|
||||
const warnings: ScheduleWarning[] = []
|
||||
const emptyAt: DryPoint[] = []
|
||||
let budget = carryKm
|
||||
let totalSeconds = 0
|
||||
// Metres of DRIVING covered so far. Not the same as metres along the day's line: the
|
||||
// line carries every run, and a ferry or a walk in the middle of a day adds to it
|
||||
// without adding to this. Whatever converts a dry point back into a coordinate has to
|
||||
// walk the driving legs only, which is why this is counted here rather than derived.
|
||||
let drivenMeters = 0
|
||||
|
||||
/**
|
||||
* What is already on the clock the moment the car pulls away from stop `i`.
|
||||
*
|
||||
* A tank filled to 80 % has used a fifth of its range before it moves, which is how a
|
||||
* budget that counts upwards says "not full". Zero, absent and 100 all mean it filled
|
||||
* right up, so all three land on nothing used.
|
||||
*/
|
||||
const usedAfterFilling = (i: number): number => {
|
||||
const percent = fillAt[i] ?? limits.fillPercent
|
||||
return limits.rangeKm && percent && percent > 0 && percent < 100
|
||||
? limits.rangeKm * (1 - percent / 100)
|
||||
: 0
|
||||
}
|
||||
|
||||
for (let i = 0; i < legs.length; i++) {
|
||||
if (refuelsAt[i]) budget = usedAfterFilling(i)
|
||||
const leg = legs[i]
|
||||
// The stop this leg arrives at. Both findings are about what is true on arrival.
|
||||
const at = i + 1
|
||||
// Not a drive: neither its minutes nor its kilometres belong in findings
|
||||
// about the driving. The budget is left exactly as it was — walking to a
|
||||
// viewpoint and back does not use fuel, and it does not fill the tank
|
||||
// either.
|
||||
if (leg && leg.mode !== undefined && leg.mode !== 'driving') continue
|
||||
const seconds = leg?.duration
|
||||
if (typeof seconds === 'number') {
|
||||
totalSeconds += seconds
|
||||
if (limits.legMinutes && seconds / 60 > limits.legMinutes) {
|
||||
warnings.push({ index: at, code: 'leg', overMinutes: Math.round(seconds / 60 - limits.legMinutes) })
|
||||
}
|
||||
}
|
||||
const metres = leg?.distance
|
||||
if (typeof metres !== 'number') {
|
||||
budget = null
|
||||
} else if (budget !== null) {
|
||||
const before = budget
|
||||
budget += metres / 1000
|
||||
// Where the tank actually runs dry, as opposed to where somebody notices. The
|
||||
// warning below sits on the arriving stop, which can be a hundred kilometres past
|
||||
// the point the fuel ran out; a suggestion has to be offered at the point, or it
|
||||
// suggests filling up somewhere the car cannot reach.
|
||||
//
|
||||
// Once per tank, not once per warning. A long stretch with nothing on it produces
|
||||
// a run of warnings on purpose (see the note above), and one refuel offer per
|
||||
// warning would stack three identical offers down one day for a single fill-up.
|
||||
if (limits.rangeKm && before <= limits.rangeKm && budget > limits.rangeKm) {
|
||||
const intoLegKm = limits.rangeKm - before
|
||||
emptyAt.push({
|
||||
legIndex: i,
|
||||
intoLegKm,
|
||||
drivenMeters: drivenMeters + intoLegKm * 1000,
|
||||
sinceKm: Math.round(limits.rangeKm),
|
||||
})
|
||||
}
|
||||
// Not on a stop that fills up: arriving at a petrol station with an empty tank is
|
||||
// the plan working, not a problem, and a warning there would sit on the one stop
|
||||
// that answers it. No reset either — a warning is not a fill-up, and the figure has
|
||||
// to keep counting until a fuel or charging stop actually puts one in.
|
||||
if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]) {
|
||||
warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) })
|
||||
}
|
||||
drivenMeters += metres
|
||||
}
|
||||
}
|
||||
// The last stop of the day counts too: filling up on arrival is what makes the next
|
||||
// morning start with a full tank.
|
||||
if (refuelsAt[legs.length]) budget = usedAfterFilling(legs.length)
|
||||
|
||||
const minutes = Math.round(totalSeconds / 60)
|
||||
const day = limits.dayMinutes && minutes > limits.dayMinutes
|
||||
? { code: 'dayDriving' as const, minutes, limitMinutes: limits.dayMinutes }
|
||||
: null
|
||||
|
||||
return { warnings, day, carryKm: budget, emptyAt }
|
||||
}
|
||||
|
||||
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
|
||||
if (!legEndMeters.length) return -1
|
||||
for (let i = 0; i < legEndMeters.length; i++) {
|
||||
if (alongMeters < legEndMeters[i]) return i
|
||||
}
|
||||
return legEndMeters.length - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The places on a day somebody would plan a break around.
|
||||
*
|
||||
* Every stop, and the middle of every leg between two of them. The leg midpoints are the
|
||||
* point of the whole thing: a break on a five-hour drive is planned in the middle of the
|
||||
* drive, not at either end of it, and offering only the stops would mean asking "what is
|
||||
* near Berlin" when the question is "what is near the halfway mark".
|
||||
*/
|
||||
export interface SectionAnchor {
|
||||
kind: 'stop' | 'leg'
|
||||
/** Index of the stop, or of the stop the leg leaves. */
|
||||
index: number
|
||||
alongKm: number
|
||||
}
|
||||
|
||||
export function sectionAnchors(stopsAlongKm: number[]): SectionAnchor[] {
|
||||
const out: SectionAnchor[] = []
|
||||
for (let i = 0; i < stopsAlongKm.length; i++) {
|
||||
out.push({ kind: 'stop', index: i, alongKm: stopsAlongKm[i] })
|
||||
if (i + 1 < stopsAlongKm.length) {
|
||||
out.push({ kind: 'leg', index: i, alongKm: (stopsAlongKm[i] + stopsAlongKm[i + 1]) / 2 })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function insertIndexForAlong(stopsAlongKm: number[], hitAlongKm: number): number {
|
||||
if (stopsAlongKm.length < 2) return stopsAlongKm.length
|
||||
let i = 0
|
||||
while (i < stopsAlongKm.length && stopsAlongKm[i] <= hitAlongKm) i++
|
||||
return Math.min(Math.max(i, 1), stopsAlongKm.length - 1)
|
||||
}
|
||||
|
||||
/* ── Via re-anchoring ────────────────────────────────────────────────────────
|
||||
*
|
||||
* A via records which stop it follows as a POSITION in the day's stop list, and that
|
||||
* position is resolved fresh every time the day is routed. So the moment the list
|
||||
* changes shape — a stop inserted, removed, reordered, pushed to another day — every
|
||||
* anchor at or past the change means a different leg than it did when it was written.
|
||||
* The visible symptom is the route quietly snapping back to the road the user had
|
||||
* steered it away from, with the via handle still sitting on the map where they left it.
|
||||
*
|
||||
* Nothing about that is repairable after the fact: once the stops have moved there is no
|
||||
* record of which leg a via was drawn for. So the correction has to be computed against
|
||||
* the list as it stands and applied with the same change.
|
||||
*
|
||||
* These are pure index arithmetic on purpose. The one judgement that needs geometry —
|
||||
* which half of a split leg a via belongs to — is asked of the caller, because only the
|
||||
* caller has the routed line to measure against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A via, reduced to what re-anchoring needs to know about it.
|
||||
*
|
||||
* The coordinates are part of it because the one decision that is not pure arithmetic —
|
||||
* which half of a split leg a via belongs to — is answered by measuring where it sits on
|
||||
* the road, and the caller is handed the whole via to measure.
|
||||
*/
|
||||
export interface AnchoredVia {
|
||||
id: number
|
||||
after_order_index: number
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
/** Where one via ends up. */
|
||||
export interface ReanchoredVia {
|
||||
id: number
|
||||
after_order_index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The corrected anchoring, and the vias that no longer have a leg at all.
|
||||
*
|
||||
* `vias` holds only what actually moved: sending the unchanged ones would be a larger
|
||||
* write for no reason, and it keeps the diff readable when something goes wrong.
|
||||
*/
|
||||
export interface Reanchoring {
|
||||
vias: ReanchoredVia[]
|
||||
remove: number[]
|
||||
}
|
||||
|
||||
const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] }
|
||||
|
||||
/** Only what moved, so an operation that changes nothing costs no request. */
|
||||
function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring {
|
||||
const moved: ReanchoredVia[] = []
|
||||
const remove: number[] = []
|
||||
for (const via of vias) {
|
||||
const next = at(via.after_order_index)
|
||||
if (next === null) remove.push(via.id)
|
||||
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove }
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop was inserted at `position`, so everything from there on sits one place later.
|
||||
*
|
||||
* The leg the new stop lands in is SPLIT in two, and its vias have to be told apart: one
|
||||
* before the new stop stays on the first half, one after it moves to the second. That is
|
||||
* the whole reason a petrol station added in the middle of a reshaped drive used to undo
|
||||
* the reshaping — the via went to the first half by accident of arithmetic, the second
|
||||
* half got none, and the router took its own road again for the rest of the leg.
|
||||
*
|
||||
* `liesBeforeInsert` is asked only about vias on the split leg, and only when there is
|
||||
* one: inserting at the very front or the very end splits nothing.
|
||||
*/
|
||||
export function reanchorAfterInsert(
|
||||
vias: AnchoredVia[],
|
||||
position: number,
|
||||
liesBeforeInsert: (via: AnchoredVia) => boolean,
|
||||
): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
const split = position - 1
|
||||
const moved: ReanchoredVia[] = []
|
||||
for (const via of vias) {
|
||||
const i = via.after_order_index
|
||||
let next = i
|
||||
if (i === split) next = liesBeforeInsert(via) ? i : i + 1
|
||||
else if (i >= position) next = i + 1
|
||||
if (next !== i) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop was taken out at `position`, so the legs either side of it become one.
|
||||
*
|
||||
* A via on either half keeps bending the same drive, so both end up on the merged leg —
|
||||
* dropping them would throw away a decision the user made about a road that still exists.
|
||||
* The exception is the ends: remove the first stop and the leg leaving it is gone, remove
|
||||
* the last and the leg into it is gone, and a via pinned to one of those has nowhere left
|
||||
* to sit. Those are reported for deletion rather than left pointing past the day.
|
||||
*
|
||||
* `stopCount` is the number of stops BEFORE the removal.
|
||||
*/
|
||||
export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
// Two stops become one: the day has no drive left, so no via has a leg.
|
||||
if (stopCount <= 2) return { vias: [], remove: vias.map(v => v.id) }
|
||||
return collect(vias, i => {
|
||||
if (position === 0) return i === 0 ? null : i - 1
|
||||
if (position === stopCount - 1) return i === position - 1 ? null : i
|
||||
if (i === position) return position - 1
|
||||
return i > position ? i - 1 : i
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop moved from `from` to `to` inside its own day.
|
||||
*
|
||||
* Composed from the two operations it actually is — taken out, put back — because that
|
||||
* is the only reading under which the answer stays consistent with the other two. No
|
||||
* geometry is asked for: a reorder changes which stops are neighbours, so the roads
|
||||
* themselves are about to be different and there is nothing meaningful to measure a via
|
||||
* against. Vias keep the earlier half of a split leg, which is the reading that leaves
|
||||
* them where they were relative to the stop they were drawn after.
|
||||
*
|
||||
* `stopCount` is the number of stops BEFORE the move. `to` is the index in the list as
|
||||
* it looks AFTER the stop has been taken out.
|
||||
*/
|
||||
/**
|
||||
* Re-pin a day's vias after its stops were reordered wholesale.
|
||||
*
|
||||
* The single-move helpers above take a from/to pair, which is what the rail
|
||||
* hands them. The plan-mode drag hands a whole new ordering instead, and any
|
||||
* permutation is possible — so this maps by the stop each anchor refers to
|
||||
* rather than by arithmetic on the index. A via anchored after a stop stays
|
||||
* anchored after that same stop, wherever it ended up.
|
||||
*
|
||||
* Both lists are in the rail's index space, i.e. only stops that can be routed.
|
||||
* A via whose stop is gone, or which lands on the last stop and so has no leg
|
||||
* left to bend, is reported for removal.
|
||||
*/
|
||||
export function reanchorByStopOrder(
|
||||
vias: AnchoredVia[],
|
||||
previousIds: number[],
|
||||
nextIds: number[],
|
||||
): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
const moved: ReanchoredVia[] = []
|
||||
const remove: number[] = []
|
||||
for (const via of vias) {
|
||||
const stopId = previousIds[via.after_order_index]
|
||||
const next = stopId === undefined ? -1 : nextIds.indexOf(stopId)
|
||||
// Gone, or now the last stop of the day: either way there is no leg for it.
|
||||
if (next === -1 || next >= nextIds.length - 1) {
|
||||
remove.push(via.id)
|
||||
continue
|
||||
}
|
||||
if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove }
|
||||
}
|
||||
|
||||
export function reanchorAfterReorder(
|
||||
vias: AnchoredVia[],
|
||||
from: number,
|
||||
to: number,
|
||||
stopCount: number,
|
||||
): Reanchoring {
|
||||
if (!vias.length || from === to) return EMPTY_REANCHORING
|
||||
// A reorder is modelled as remove-then-insert, and the removal half refuses to
|
||||
// treat a two-stop day as still having a drive: below three stops it reports
|
||||
// every via for deletion, which is right when a stop really goes away and
|
||||
// catastrophic here. Nothing is removed by a reorder — swapping the only two
|
||||
// stops leaves leg 0 exactly where it was, merely driven the other way round —
|
||||
// so the anchors are already correct and there is nothing to re-pin.
|
||||
//
|
||||
// Two stops is the shape "follow this track" produces: it lays up to nine vias
|
||||
// on the single leg between one pair, and one drag in the rail deleted all of
|
||||
// them, with no prompt and no undo.
|
||||
if (stopCount <= 2) return EMPTY_REANCHORING
|
||||
const afterRemove = reanchorAfterRemove(vias, from, stopCount)
|
||||
const dropped = new Set(afterRemove.remove)
|
||||
const movedTo = new Map(afterRemove.vias.map(v => [v.id, v.after_order_index] as const))
|
||||
// The list the insert acts on is the one the removal left behind, so each via is
|
||||
// carried through at whatever index it holds by then.
|
||||
const shifted: AnchoredVia[] = vias
|
||||
.filter(v => !dropped.has(v.id))
|
||||
.map(v => ({ ...v, after_order_index: movedTo.get(v.id) ?? v.after_order_index }))
|
||||
const afterInsert = reanchorAfterInsert(shifted, to, () => true)
|
||||
const finalIndex = new Map(afterInsert.vias.map(v => [v.id, v.after_order_index] as const))
|
||||
|
||||
const result: ReanchoredVia[] = []
|
||||
for (const via of vias) {
|
||||
if (dropped.has(via.id)) continue
|
||||
const mid = movedTo.get(via.id) ?? via.after_order_index
|
||||
const end = finalIndex.get(via.id) ?? mid
|
||||
if (end !== via.after_order_index) result.push({ id: via.id, after_order_index: end })
|
||||
}
|
||||
return { vias: result, remove: afterRemove.remove }
|
||||
}
|
||||
|
||||
/** Whether a re-anchoring has anything to write at all. */
|
||||
export function isEmptyReanchoring(r: Reanchoring): boolean {
|
||||
return r.vias.length === 0 && r.remove.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The road classes a stored setting asks to leave out, validated.
|
||||
*
|
||||
* Parsed rather than trusted. A per-user setting has no server-side validation at all —
|
||||
* the write route stores any key with any value and says so — so an unknown word here
|
||||
* would travel straight into a costing option the router does not have. Unknown entries
|
||||
* are dropped, the order is fixed so two equal settings produce one cache key, and
|
||||
* anything that is not a non-empty list reads as "route normally".
|
||||
*/
|
||||
export function parseAvoid(raw: unknown): RouteAvoidClass[] {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return []
|
||||
const asked = new Set(raw.split(',').map(part => part.trim().toLowerCase()))
|
||||
return AVOIDABLE.filter(cls => asked.has(cls))
|
||||
}
|
||||
|
||||
/** Every class that can be avoided, in the order they are offered and stored. */
|
||||
export const AVOIDABLE: readonly RouteAvoidClass[] = ['toll', 'motorway', 'ferry']
|
||||
|
||||
/** The setting value for a set of classes, in the fixed order. */
|
||||
export function serializeAvoid(classes: readonly RouteAvoidClass[]): string {
|
||||
return AVOIDABLE.filter(cls => classes.includes(cls)).join(',')
|
||||
}
|
||||
export { SERVICE_STOP_TYPES, SERVICE_COLORS, serviceColor, isServiceStopType, formatDurationShort, parseClock, formatClock, REFUELLING_STOP_TYPES, refuelStopTypeFor, refuelsRange, computeSchedule, splitIntoRuns, sumLegSeconds, deriveDriveWarnings, legIndexForAlong, sectionAnchors, insertIndexForAlong, reanchorAfterInsert, reanchorAfterRemove, reanchorByStopOrder, reanchorAfterReorder, isEmptyReanchoring, parseAvoid, AVOIDABLE, serializeAvoid } from '@trek/shared/roadtrip'
|
||||
export type { ScheduleStop, ScheduleWarning, VehicleKind, DriveLimits, DayWarning, ScheduleEntry, Schedule, DryPoint, SectionAnchor, AnchoredVia, ReanchoredVia, Reanchoring } from '@trek/shared/roadtrip'
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export function trivagoSearchHref(name: string, arrival?: string | null, departure?: string | null): string {
|
||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/
|
||||
const dates = arrival && departure && datePattern.test(arrival) && datePattern.test(departure) && departure > arrival
|
||||
? 'dr-' + arrival.replace(/-/g, '') + '-' + departure.replace(/-/g, '') + ';'
|
||||
: ''
|
||||
return 'https://www.trivago.com/de/lm/hotels-weltweit?search=' + dates + 'qs-' + encodeURIComponent(name).replace(/;/g, '%3B')
|
||||
}
|
||||
|
||||
|
||||
export function campingSearchHref(portal: 'PiNCAMP' | 'Pitchup', name: string, lat: number, lng: number, arrival?: string | null, departure?: string | null): string {
|
||||
const query = new URLSearchParams()
|
||||
const dated = arrival && departure && /^\d{4}-\d{2}-\d{2}$/.test(arrival) && /^\d{4}-\d{2}-\d{2}$/.test(departure) && departure > arrival
|
||||
if (portal === 'Pitchup') {
|
||||
query.set('lat', String(lat))
|
||||
query.set('lng', String(lng))
|
||||
query.set('placename', name)
|
||||
if (dated) {
|
||||
query.set('arrive', arrival)
|
||||
query.set('depart', departure)
|
||||
}
|
||||
return 'https://www.pitchup.com/campsites/?' + query
|
||||
}
|
||||
const latitudeSpan = 20 / 111.32
|
||||
const longitudeSpan = Math.min(180, latitudeSpan / Math.max(0.001, Math.cos(lat * Math.PI / 180)))
|
||||
query.set('viewport', [Math.max(-90, lat - latitudeSpan), Math.max(-180, lng - longitudeSpan), Math.min(90, lat + latitudeSpan), Math.min(180, lng + longitudeSpan)].map(n => n.toFixed(6)).join(','))
|
||||
query.set('title', name)
|
||||
if (dated) {
|
||||
query.set('date_from', arrival)
|
||||
query.set('date_to', departure)
|
||||
query.set('flex', 'false')
|
||||
}
|
||||
return 'https://www.pincamp.com/search?' + query
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { formatClock, parseClock } from './roadtripModel'
|
||||
import type { RoadtripDay } from '@trek/shared/roadtrip'
|
||||
|
||||
export function stopArrival(day: RoadtripDay, index: number, alongKm: number): string | null {
|
||||
if (index === 0) return day.schedule.entries[0]?.arrival ?? null
|
||||
const departure = parseClock(day.schedule.entries[index - 1]?.departure)
|
||||
const leg = day.legs[index - 1]
|
||||
if (departure === null || !leg) return null
|
||||
const before = day.legs.slice(0, index - 1)
|
||||
if (before.some(leg => !leg)) return null
|
||||
const distanceBefore = before.reduce((total, leg) => total + leg!.distance, 0)
|
||||
const fraction = leg.distance > 0 ? Math.max(0, Math.min(1, (alongKm * 1000 - distanceBefore) / leg.distance)) : 0
|
||||
return formatClock(Math.round(departure + leg.duration / 60 * fraction))
|
||||
}
|
||||
@@ -37,13 +37,10 @@ describe('stop kinds', () => {
|
||||
expect(CORRIDOR_CATEGORY_KEYS).toEqual(['fuel', 'charging', 'rest_area', 'campsite', 'hotel', 'restaurant', 'sights'])
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-008: a hotel is a category without being a stop kind', () => {
|
||||
// The whole reason the two lists are separate. Sleeping somewhere ends the day rather
|
||||
// than interrupting the drive, so a hotel keeps its number in the chain and never
|
||||
// becomes a stop_type.
|
||||
expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBeNull()
|
||||
expect(STOP_KIND_BY_KEY.hotel).toBeUndefined()
|
||||
expect(SERVICE_KIND_KEYS).not.toContain('hotel')
|
||||
it('FE-STOPKIND-008: accommodation is a service stop as well as an overnight category', () => {
|
||||
expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBe('hotel')
|
||||
expect(STOP_KIND_BY_KEY.hotel).toBeDefined()
|
||||
expect(SERVICE_KIND_KEYS).toContain('hotel')
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-009: a night can be booked at a campsite too, so the popup asks', () => {
|
||||
@@ -68,7 +65,7 @@ describe('stop kinds', () => {
|
||||
expect(STOP_KIND_BY_KEY.rest_area.labelKey).toBe('roadtrip.poi.rest')
|
||||
expect(STOP_KIND_BY_KEY.restaurant.labelKey).toBe('roadtrip.poi.food')
|
||||
for (const kind of STOP_KINDS) {
|
||||
if (kind.key !== 'rest_area' && kind.key !== 'restaurant') {
|
||||
if (kind.key !== 'rest_area' && kind.key !== 'restaurant' && kind.key !== 'hotel') {
|
||||
expect(kind.labelKey).toBe(`roadtrip.poi.${kind.key}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,6 @@ import type { RoadtripStopType } from '@trek/shared'
|
||||
* and `roadtripModel.ts` is deliberately free of React so it can be unit tested on its
|
||||
* own. The colours and the service list stay there and are read from here.
|
||||
*
|
||||
* `isService` and `isCorridorCategory` are not the same question, and a coming lodging
|
||||
* kind is why they are separate: the corridor can find a hotel, but sleeping somewhere
|
||||
* ends the day rather than interrupting the drive, so it would be a category without
|
||||
* being a service stop.
|
||||
*/
|
||||
export interface StopKind {
|
||||
key: RoadtripStopType
|
||||
@@ -39,6 +35,7 @@ export interface StopKind {
|
||||
}
|
||||
|
||||
export const STOP_KINDS: StopKind[] = [
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: SERVICE_COLORS.hotel, defaultMinutes: 30, isService: true, isCorridorCategory: true },
|
||||
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel, defaultMinutes: 10, isService: true, isCorridorCategory: true },
|
||||
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging, defaultMinutes: 30, isService: true, isCorridorCategory: true },
|
||||
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area, defaultMinutes: 20, isService: true, isCorridorCategory: true },
|
||||
@@ -52,14 +49,6 @@ export const STOP_KIND_BY_KEY: Record<string, StopKind> = Object.fromEntries(
|
||||
STOP_KINDS.map(k => [k.key, k]),
|
||||
)
|
||||
|
||||
/**
|
||||
* A category the corridor search offers, which is not the same question as a stop kind.
|
||||
*
|
||||
* Every stop kind is a category, but not every category is a stop kind: somewhere to
|
||||
* sleep is something the search can find, and staying there ends the day rather than
|
||||
* interrupting the drive. A hotel therefore keeps its number in the chain and gets a row
|
||||
* in `day_accommodations`; it never becomes a `stop_type`.
|
||||
*/
|
||||
export interface CorridorCategory {
|
||||
key: string
|
||||
labelKey: string
|
||||
@@ -83,9 +72,9 @@ export const HOTEL_COLOR = '#2563EB'
|
||||
* table happens to end.
|
||||
*/
|
||||
export const CORRIDOR_CATEGORIES: CorridorCategory[] = [
|
||||
...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights')
|
||||
...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights' && k.key !== 'hotel')
|
||||
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: null },
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: 'hotel' },
|
||||
...STOP_KINDS.filter(k => k.key === 'restaurant' || k.key === 'sights')
|
||||
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useAutomaticDayPoints } from './useAutomaticDayPoints'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string, args?: { day: number; time: string }) => args ? `End of day ${args.day} at ${args.time}` : key }) }))
|
||||
|
||||
describe('automatic pause map points', () => {
|
||||
it('adds each pause once, respects collapsed days and discards obsolete focus', () => {
|
||||
const routes = {
|
||||
vias: [{ lat: 1, lng: 1, label: 'Charger', tone: 'success' }],
|
||||
days: [{
|
||||
dayId: 1, dayNumber: 1,
|
||||
stops: [
|
||||
{ lat: 2, lng: 3, automaticNight: { phase: 'end' } },
|
||||
{ lat: 2, lng: 3, automaticNight: { phase: 'start' } },
|
||||
{ lat: 4, lng: 5 },
|
||||
],
|
||||
schedule: { entries: [{ arrival: '18:00' }] },
|
||||
}],
|
||||
} as unknown as RoadtripRoutes
|
||||
const { result, rerender } = renderHook(
|
||||
({ current, collapsed }) => useAutomaticDayPoints(current, collapsed),
|
||||
{ initialProps: { current: routes, collapsed: new Set<number>() } },
|
||||
)
|
||||
expect(result.current.markers).toHaveLength(2)
|
||||
expect(result.current.markers[1]).toMatchObject({ lat: 2, lng: 3, hoverCard: true, nightPause: { day: 1, atPlace: false } })
|
||||
expect(result.current.markers[1].label).toBe('End of day 1 at 18:00')
|
||||
act(() => result.current.focusPoint(2, 3))
|
||||
expect(result.current.focusPoints).toEqual([[2, 3]])
|
||||
rerender({ current: routes, collapsed: new Set([1]) })
|
||||
expect(result.current.markers).toEqual(routes.vias)
|
||||
rerender({ current: { ...routes, days: [] }, collapsed: new Set() })
|
||||
expect(result.current.focusPoints).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import type { RouteVia } from '../../types'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
export function useAutomaticDayPoints(routes: RoadtripRoutes, collapsed: Set<number>) {
|
||||
const { t } = useTranslation()
|
||||
const [selection, setSelection] = useState<{ days: RoadtripRoutes['days']; points: [number, number][] } | null>(null)
|
||||
const focusPoint = useCallback((lat: number, lng: number) => {
|
||||
setSelection({ days: routes.days, points: [[lat, lng]] })
|
||||
}, [routes.days])
|
||||
const markers = useMemo<RouteVia[]>(() => {
|
||||
const places = new Set(routes.days.flatMap(day => day.stops.filter(stop => !stop.automaticNight).map(stop => `${stop.lat},${stop.lng}`)))
|
||||
const nights = routes.days.flatMap(day => day.stops.filter(stop => stop.automaticNight?.phase === 'end').map(stop => ({ day: day.dayNumber, ...stop.automaticNight! })))
|
||||
const ends = routes.days.filter(d => !collapsed.has(d.dayId)).flatMap(day => day.stops.flatMap((stop, i) =>
|
||||
stop.automaticNight?.phase === 'end' ? [{
|
||||
lat: stop.lat, lng: stop.lng, tone: 'default' as const, hoverCard: true,
|
||||
nightPause: { day: day.dayNumber, atPlace: places.has(`${stop.lat},${stop.lng}`),
|
||||
position: stop.automaticNight.position, manual: stop.automaticNight.manual,
|
||||
minPosition: nights.filter(n => n.day < day.dayNumber).slice(-1)[0]?.position,
|
||||
maxPosition: nights.find(n => n.day > day.dayNumber && n.manual)?.position },
|
||||
label: t('roadtrip.window.pointLabel', { day: day.dayNumber, time: day.schedule.entries[i]?.arrival ?? '' }),
|
||||
}] : [],
|
||||
))
|
||||
return [...routes.vias, ...ends]
|
||||
}, [routes.days, routes.vias, collapsed, t])
|
||||
return { markers, focusPoint, focusPoints: selection?.days === routes.days ? selection.points : null }
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { LatLng } from './corridor'
|
||||
|
||||
const { pois } = vi.hoisted(() => ({ pois: vi.fn() }))
|
||||
vi.mock('../../api/client', () => ({ mapsApi: { pois } }))
|
||||
vi.mock('../../repo/roadtripSearchRepo', () => ({ roadtripSearchRepo: { search: pois } }))
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
|
||||
|
||||
import { useCorridorPois } from './useCorridorPois'
|
||||
@@ -30,7 +30,7 @@ const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
|
||||
source: 'openstreetmap',
|
||||
})
|
||||
|
||||
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, source: 'openstreetmap', truncated: false, clamped: false })
|
||||
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, sources: ['openstreetmap'], failedSources: [], truncated: false, clamped: false })
|
||||
|
||||
beforeEach(() => {
|
||||
pois.mockReset()
|
||||
@@ -38,6 +38,14 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('useCorridorPois', () => {
|
||||
it('keeps plugin results and exposes failed sources', async () => {
|
||||
pois.mockResolvedValue({ ...answer(hit('plugin:stations:1', 53.5, 9.81)), failedSources: ['plugin:unavailable'] })
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.results[0].osm_id).toBe('plugin:stations:1')
|
||||
expect(result.current.failedSources).toEqual(['plugin:unavailable'])
|
||||
})
|
||||
it('FE-ROADTRIP-CORRIDOR-001: asks each box once for every category together', async () => {
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel', 'charging', 'rest_area'], 10))
|
||||
act(() => { result.current.search() })
|
||||
@@ -45,7 +53,7 @@ describe('useCorridorPois', () => {
|
||||
|
||||
expect(pois).toHaveBeenCalled()
|
||||
// The whole point of the batching: the kinds ride along in one query per box.
|
||||
for (const call of pois.mock.calls) expect(call[0]).toBe('fuel,charging,rest_area')
|
||||
for (const call of pois.mock.calls) expect(call[0]).toEqual(['fuel', 'charging', 'rest_area'])
|
||||
expect(result.current.progress.total).toBe(pois.mock.calls.length)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
|
||||
import type { Poi } from '../Map/poiCategories'
|
||||
@@ -18,6 +18,7 @@ export interface CorridorSearch {
|
||||
/** True when the route was longer than the request budget and the tail was left out. */
|
||||
capped: boolean
|
||||
/** Boxes the place search could not answer — a partial result is still shown. */
|
||||
failedSources?: string[]
|
||||
failedAreas: number
|
||||
/**
|
||||
* Boxes where the server hit its own per-request ceiling and dropped the rest.
|
||||
@@ -71,6 +72,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
const [capped, setCapped] = useState(false)
|
||||
const [failedAreas, setFailedAreas] = useState(0)
|
||||
const [truncatedAreas, setTruncatedAreas] = useState(0)
|
||||
const [failedSources, setFailedSources] = useState<string[]>([])
|
||||
const [error, setError] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const runIdRef = useRef(0)
|
||||
@@ -91,6 +93,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
setProgress({ done: 0, total: 0 })
|
||||
setLoading(false)
|
||||
setCapped(false)
|
||||
setFailedSources([])
|
||||
setFailedAreas(0)
|
||||
setTruncatedAreas(0)
|
||||
setError(false)
|
||||
@@ -112,6 +115,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
const tiles = allTiles.slice(0, MAX_TILES)
|
||||
|
||||
setCapped(allTiles.length > tiles.length)
|
||||
setFailedSources([])
|
||||
setFailedAreas(0)
|
||||
setTruncatedAreas(0)
|
||||
setError(false)
|
||||
@@ -122,11 +126,12 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
// All the wanted kinds in one query per box. Asking per kind was the same answer for
|
||||
// four times the requests, and every extra request is another chance for a shared
|
||||
// mirror to time out — which is what made a search both slow and patchy.
|
||||
const wanted = categories.join(',')
|
||||
|
||||
const jobs = [...tiles]
|
||||
|
||||
void (async () => {
|
||||
const seen = new Map<string, CorridorPoi>()
|
||||
const unavailable = new Set<string>()
|
||||
let failures = 0
|
||||
let shortened = 0
|
||||
let done = 0
|
||||
@@ -139,15 +144,26 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
// cheaply; the small gap afterwards keeps one worker from queueing straight up.
|
||||
const collect = async (tile: Bbox): Promise<boolean> => {
|
||||
try {
|
||||
const data = await mapsApi.pois(wanted, tile, locale, controller.signal)
|
||||
const data = await roadtripSearchRepo.search(categories, tile, locale, controller.signal)
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return false
|
||||
data.failedSources.forEach(source => unavailable.add(source))
|
||||
setFailedSources([...unavailable])
|
||||
// The server answers with at most a few hundred per box and says so. A hit
|
||||
// dropped there is invisible here, so the count has to reach the surface.
|
||||
if (data.truncated) setTruncatedAreas(++shortened)
|
||||
if (data.truncated || data.clamped) setTruncatedAreas(++shortened)
|
||||
for (const poi of data.pois) {
|
||||
if (seen.has(poi.osm_id)) continue
|
||||
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
|
||||
if (!hit || hit.offRouteKm > widthKm) continue
|
||||
seen.set(poi.osm_id, { ...poi, offRouteKm: hit.offRouteKm, alongKm: hit.alongKm })
|
||||
seen.set(poi.osm_id, {
|
||||
...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null,
|
||||
opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null,
|
||||
charging: poi.charging ? {
|
||||
capacity: poi.charging.capacity ?? null, fee: poi.charging.fee ?? null,
|
||||
sockets: poi.charging.sockets.map(s => ({ type: s.type, count: s.count ?? null, kw: s.kw ?? null })),
|
||||
} : null,
|
||||
offRouteKm: hit.offRouteKm, alongKm: hit.alongKm,
|
||||
})
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
@@ -193,5 +209,5 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
return { results, progress, loading, capped, failedAreas, truncatedAreas, error, spine, search, clear }
|
||||
return { results, progress, loading, capped, failedAreas, failedSources, truncatedAreas, error, spine, search, clear }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { roadtripDayBoundaryListSchema, type RoadtripDayBoundary } from '@trek/shared'
|
||||
import { apiClient } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
|
||||
const EMPTY: RoadtripDayBoundary[] = []
|
||||
|
||||
export function useDayBoundaries(tripId: number | string | null, active: boolean, assignments?: unknown) {
|
||||
const { offline } = useNetworkMode()
|
||||
const [stored, setStored] = useState<{ tripId: number | string | null; boundaries: RoadtripDayBoundary[] }>({ tripId: null, boundaries: EMPTY })
|
||||
const [stale, setStale] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const revision = useRef(0)
|
||||
const busy = useRef(false)
|
||||
const currentTrip = useRef(tripId)
|
||||
currentTrip.current = tripId
|
||||
useEffect(() => {
|
||||
if (!tripId || !active || offline) return
|
||||
let cancelled = false
|
||||
const version = ++revision.current
|
||||
apiClient.get(`/trips/${tripId}/roadtrip/day-boundaries`).then(saved => {
|
||||
if (cancelled || revision.current !== version) return
|
||||
setStored({ tripId, boundaries: roadtripDayBoundaryListSchema.parse(saved.data).boundaries })
|
||||
setStale(false)
|
||||
}).catch(() => { if (!cancelled) setStale(true) })
|
||||
return () => { cancelled = true }
|
||||
}, [tripId, active, offline, assignments])
|
||||
useEffect(() => {
|
||||
if (!tripId || !active) return
|
||||
const receive = (event: Record<string, unknown>) => {
|
||||
if (event.type !== 'roadtripBoundary:changed' || String(event.tripId) !== String(tripId)) return
|
||||
const parsed = roadtripDayBoundaryListSchema.safeParse(event)
|
||||
if (!parsed.success) return
|
||||
revision.current++
|
||||
setStored({ tripId, boundaries: parsed.data.boundaries })
|
||||
setStale(false)
|
||||
}
|
||||
addListener(receive)
|
||||
return () => removeListener(receive)
|
||||
}, [tripId, active])
|
||||
const save = useCallback(async (day: number, boundary: RoadtripDayBoundary | null) => {
|
||||
if (!tripId || offline || busy.current) return false
|
||||
busy.current = true
|
||||
setPending(true)
|
||||
const version = ++revision.current
|
||||
try {
|
||||
const saved = boundary
|
||||
? await apiClient.put(`/trips/${tripId}/roadtrip/day-boundaries`, { ...boundary, day_number: day })
|
||||
: await apiClient.delete(`/trips/${tripId}/roadtrip/day-boundaries/${day}`)
|
||||
if (currentTrip.current !== tripId) return false
|
||||
if (revision.current === version) setStored({ tripId, boundaries: roadtripDayBoundaryListSchema.parse(saved.data).boundaries })
|
||||
setStale(false)
|
||||
return true
|
||||
} finally {
|
||||
busy.current = false
|
||||
setPending(false)
|
||||
}
|
||||
}, [tripId, offline])
|
||||
return { boundaries: stored.tripId === tripId ? stored.boundaries : EMPTY, save, stale, pending, editable: active && !offline && !stale && stored.tripId === tripId }
|
||||
}
|
||||
@@ -82,6 +82,22 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('useFollowTrack', () => {
|
||||
it('clears the original leg when an automatic pause splits its displayed days', async () => {
|
||||
const [a, b] = day().stops
|
||||
const end = { ...a, assignmentId: -10, automaticNight: { phase: 'end' as const, fromDayNumber: 1 } }
|
||||
const start = { ...end, assignmentId: -11, automaticNight: { phase: 'start' as const, fromDayNumber: 1 } }
|
||||
const split = routes([
|
||||
day({ dayId: 1, automaticSchedule: true, stops: [a, end] }),
|
||||
day({ dayId: 2, dayNumber: 2, automaticSchedule: true, stops: [start, b] }),
|
||||
])
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], split, vias))
|
||||
act(() => result.current.open(1))
|
||||
await act(async () => result.current.clear())
|
||||
expect(vias.addMany).toHaveBeenCalledWith(1, [], [0], null)
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-001: nothing is parsed while the dialog is closed', () => {
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK, PLAIN], routes([day()]), viasStub()))
|
||||
expect(result.current.dayId).toBeNull()
|
||||
|
||||
@@ -100,7 +100,12 @@ export function useFollowTrack(
|
||||
const abort = useRef<AbortController | null>(null)
|
||||
|
||||
const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId])
|
||||
const stops = useMemo<LatLng[]>(() => (day?.stops ?? []).map(s => ({ lat: s.lat, lng: s.lng })), [day])
|
||||
const stops = useMemo<LatLng[]>(() => {
|
||||
const original = day?.automaticSchedule
|
||||
? routes.days.flatMap(d => d.stops).filter(s => !s.automaticNight && s.ownerDayId === dayId).sort((a, b) => a.ownerIndex - b.ownerIndex)
|
||||
: day?.stops ?? []
|
||||
return original.map(s => ({ lat: s.lat, lng: s.lng }))
|
||||
}, [day, routes.days, dayId])
|
||||
|
||||
/**
|
||||
* Parsed only while the dialog is open.
|
||||
|
||||
@@ -10,8 +10,8 @@ const { pois, offline, stored } = vi.hoisted(() => ({
|
||||
vi.mock('../../api/client', () => ({ mapsApi: { pois } }))
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
|
||||
vi.mock('../../sync/networkMode', () => ({ isEffectivelyOffline: offline }))
|
||||
vi.mock('../../store/settingsStore', () => ({
|
||||
useSettingsStore: (select: (state: typeof stored) => unknown) => select(stored),
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (settings: typeof stored.settings) => unknown) => select(stored.settings),
|
||||
}))
|
||||
|
||||
import { useRefuelSearch } from './useRefuelSearch'
|
||||
@@ -280,3 +280,20 @@ describe('useRefuelSearch', () => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('searches farther back when nearby chargers cannot be reached', async () => {
|
||||
pois.mockResolvedValueOnce(answer([station('too-late', 58)]))
|
||||
.mockResolvedValueOnce(answer([station('earlier', 25)]))
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
expect(pois).toHaveBeenCalledTimes(2)
|
||||
expect(result.current.results.map(p => p.osm_id)).toEqual(['earlier'])
|
||||
expect(result.current.outcome).toBe('found')
|
||||
})
|
||||
it('does not offer stations behind the last refuelling stop', async () => {
|
||||
pois.mockResolvedValue(answer([station('behind', 20)]))
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, [], 30) })
|
||||
expect(result.current.results).toEqual([])
|
||||
expect(pois).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { refuelStopTypeFor, type VehicleKind } from './roadtripModel'
|
||||
import { boxAround, pointAtMeters, type LatLng } from './corridor'
|
||||
import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } from './refuelSuggestion'
|
||||
|
||||
/**
|
||||
* Somewhere to fill up before the tank runs out, asked for once, when asked for.
|
||||
*
|
||||
* Deliberately NOT automatic, for the same reason the corridor search next door is not:
|
||||
* every run is a real request against a shared service. It is also not the corridor
|
||||
* search itself, although it looks like a smaller version of it. Three reasons, all
|
||||
* about behaviour rather than tidiness:
|
||||
*
|
||||
* - that search is bound to the day the panel has selected, so it cannot answer a
|
||||
* warning on day five while the panel shows day one;
|
||||
* - starting it clears the panel's results and the traveller's own name filter;
|
||||
* - it asks up to sixteen boxes along a whole day. This question needs one small circle.
|
||||
*
|
||||
* One request, one day, no shared state touched.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How far around the search point to look, in kilometres.
|
||||
*
|
||||
@@ -41,6 +25,8 @@ const LOOK_KM = 14
|
||||
* reach, so it is pulled back far enough that the useful half becomes the whole of it.
|
||||
*/
|
||||
const LOOK_BACK_KM = 12
|
||||
const SEARCH_STEP_KM = 20
|
||||
const MAX_SEARCHES = 8
|
||||
|
||||
/** Nothing on offer, as one stable array, so an idle search never redraws the map. */
|
||||
const NONE: RefuelCandidate[] = []
|
||||
@@ -60,8 +46,8 @@ export interface RefuelSearch {
|
||||
* reference changes.
|
||||
*/
|
||||
offered: RefuelCandidate[]
|
||||
/** Runs the one request. `key` identifies the dry point, so only one is open at a time. */
|
||||
ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[]) => Promise<void>
|
||||
/** Searches backwards until an available stop is found. `key` identifies the dry point, so only one is open at a time. */
|
||||
ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[], fromAlongKm?: number) => Promise<void>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
@@ -69,7 +55,7 @@ export function useRefuelSearch(): RefuelSearch {
|
||||
const { locale } = useTranslation()
|
||||
// What to look for. Somebody who said they drive an electric car has no use for a
|
||||
// petrol station in the list, and the other way round; with nothing said, both.
|
||||
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
|
||||
const vehicle = useRoadtripSettings(s => s.roadtrip_vehicle)
|
||||
const [openFor, setOpenFor] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [outcome, setOutcome] = useState<RefuelOutcome | null>(null)
|
||||
@@ -91,6 +77,7 @@ export function useRefuelSearch(): RefuelSearch {
|
||||
line: LatLng[],
|
||||
dryAlongKm: number,
|
||||
existing: LatLng[],
|
||||
fromAlongKm = 0,
|
||||
) => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
@@ -99,6 +86,7 @@ export function useRefuelSearch(): RefuelSearch {
|
||||
setResults([])
|
||||
setOutcome(null)
|
||||
|
||||
setLoading(false)
|
||||
// Offline the answer would be a list of places that cannot be saved: the place write
|
||||
// queues offline but the day assignment does not, so accepting one would leave an
|
||||
// orphan. Say so instead of offering it.
|
||||
@@ -115,12 +103,25 @@ export function useRefuelSearch(): RefuelSearch {
|
||||
// Pulled back along the road, because only what lies before the dry point can be
|
||||
// reached. `at` stays the dry point itself: it is what the band names and what the
|
||||
// map is asked to show.
|
||||
const centre = pointAtMeters(line, Math.max(0, (dryAlongKm - LOOK_BACK_KM) * 1000)) ?? at
|
||||
const answer = await mapsApi.pois(wanted, boxAround(centre, LOOK_KM), locale, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing })
|
||||
setResults(candidates)
|
||||
setOutcome(outcomeOf(candidates, answer))
|
||||
let incomplete = false
|
||||
for (let attempt = 0; attempt < MAX_SEARCHES; attempt++) {
|
||||
const along = Math.max(fromAlongKm, dryAlongKm - LOOK_BACK_KM - attempt * SEARCH_STEP_KM)
|
||||
const centre = pointAtMeters(line, along * 1000) ?? at
|
||||
const answer = await mapsApi.pois(wanted, boxAround(centre, LOOK_KM), locale, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
incomplete ||= !!answer.truncated
|
||||
const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing }).filter(p => p.alongKm >= fromAlongKm)
|
||||
if (candidates.length) {
|
||||
setResults(candidates)
|
||||
setOutcome('found')
|
||||
return
|
||||
}
|
||||
if (along === fromAlongKm) {
|
||||
setOutcome(outcomeOf([], { truncated: incomplete }))
|
||||
return
|
||||
}
|
||||
}
|
||||
setOutcome('incomplete')
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setOutcome('failed')
|
||||
} finally {
|
||||
|
||||
@@ -79,6 +79,61 @@ afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('automatic daily travel times', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' } })
|
||||
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), duration: 43200, legs: [{ ...routed(1).legs[0], duration: 43200 }] })
|
||||
})
|
||||
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
|
||||
|
||||
it('splits existing routes, keeps manual starts and recalculates without routing the pause', async () => {
|
||||
const days = [day(1, 1), day(2, 2)]
|
||||
const original = [{ id: 1, at: HAMBURG, time: '07:00' }, { id: 2, at: BERLIN }]
|
||||
const { result, rerender } = renderHook(({ stops }) => useRoadtripRoutes(7, days, map(1, stops)), {
|
||||
initialProps: { stops: original as StopSpec[] },
|
||||
})
|
||||
await waitFor(() => expect(result.current.dayWindowIssue).toBeNull())
|
||||
expect(result.current.days).toHaveLength(2)
|
||||
expect(result.current.totalStops).toBe(2)
|
||||
expect(result.current.totalDuration).toBe(43200)
|
||||
expect(result.current.days[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '18:00'])
|
||||
expect(result.current.days[1].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '09:00'])
|
||||
expect(result.current.days[1].stops[1]).toMatchObject({ assignmentId: 2, ownerDayId: 1, ownerIndex: 1 })
|
||||
const end = result.current.days[0].stops[1]
|
||||
rerender({ stops: [{ ...original[0], dwell: 120 }, original[1]] })
|
||||
await waitFor(() => expect(result.current.days[1].schedule.entries[1].arrival).toBe('11:00'))
|
||||
expect(result.current.days[0].stops[1].lng).not.toBe(end.lng)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } }))
|
||||
await waitFor(() => expect(result.current.days).toHaveLength(1))
|
||||
expect(result.current.days[0].schedule.entries[1].arrival).toBe('21:00')
|
||||
expect(result.current.days[0].stops.some(s => s.automaticNight)).toBe(false)
|
||||
})
|
||||
|
||||
it('routes the join between stored days and includes it exactly once', async () => {
|
||||
const days = [day(1, 1), day(2, 2)]
|
||||
const assignments = { ...map(1, [{ id: 1, at: HAMBURG }]), ...map(2, [{ id: 2, at: BERLIN }]) }
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
|
||||
await waitFor(() => expect(result.current.dayWindowIssue).toBeNull())
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
expect(calculateRouteWithLegs.mock.calls[0][0]).toEqual([{ lat: HAMBURG[0], lng: HAMBURG[1] }, { lat: BERLIN[0], lng: BERLIN[1] }])
|
||||
expect(result.current.days.map(d => d.dayNumber)).toEqual([1, 2, 3])
|
||||
expect(result.current.totalDuration).toBe(43200)
|
||||
expect(result.current.totalDistance).toBe(100000)
|
||||
expect(result.current.totalStops).toBe(2)
|
||||
})
|
||||
|
||||
it('exposes a fixed appointment conflict and keeps stored dates and times', async () => {
|
||||
const days = [day(1, 1), day(2, 2)]
|
||||
const assignments = map(1, [{ id: 1, at: HAMBURG, time: '07:00' }, { id: 2, at: BERLIN, time: '10:00' }])
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
|
||||
await waitFor(() => expect(result.current.dayWindowIssue).toBe('conflict'))
|
||||
expect(result.current.days).toHaveLength(1)
|
||||
expect(result.current.days[0].stops.map(s => s.time)).toEqual(['07:00', '10:00'])
|
||||
expect(result.current.days[0].stops.some(s => s.automaticNight)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRoadtripRoutes', () => {
|
||||
it('FE-ROADTRIP-ROUTES-001: routes a whole day in one request, not one per leg', async () => {
|
||||
const days = [day(1, 1)]
|
||||
@@ -663,6 +718,16 @@ describe('useRoadtripRoutes', () => {
|
||||
// Walked along the roads this day drives, not along the drawn line: 50 km down that
|
||||
// one is still on the join, north of Berlin, a whole leg from where the tank empties.
|
||||
expect(dry.lat).toBeLessThan(BERLIN[0])
|
||||
setting({ roadtrip_range_km: 150 })
|
||||
await waitFor(() => expect(result.current.days[1].dryPoints![0].legIndex).toBe(-1))
|
||||
const inboundDry = result.current.days[1].dryPoints![0]
|
||||
expect(inboundDry).toMatchObject({ intoLegKm: 50, drivenMeters: 50000, inboundLine: [LUENEBURG, BERLIN] })
|
||||
expect(inboundDry.lat).toBeGreaterThan(BERLIN[0])
|
||||
expect(inboundDry.lat).toBeLessThan(LUENEBURG[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||
}))
|
||||
|
||||
@@ -1,194 +1,18 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import {
|
||||
deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
|
||||
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint } from './roadtripModel'
|
||||
import { projectOntoRoute, pointAtMeters, sliceAtMeters, lineMetres } from './corridor'
|
||||
import { spillChains, type SpillMark } from './nightSpill'
|
||||
import { splitIntoRuns, parseClock, parseAvoid, type DriveLimits } from './roadtripModel'
|
||||
import { spillChains } from './nightSpill'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
|
||||
import { spurFor } from './accessSpur'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import type { Assignment, AssignmentsMap, Accommodation, Day, RouteAvoidClass, SnappedWaypoint } from '../../types'
|
||||
import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared'
|
||||
import { dayWindow } from './dayWindow'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
/** One stop on a day's drive — an assignment whose place actually has coordinates. */
|
||||
export interface RoadtripStop {
|
||||
assignmentId: number
|
||||
/**
|
||||
* The day this stop is STORED on, and its position there.
|
||||
*
|
||||
* A road trip draws its stops by the day they are reached, which after a drive across
|
||||
* midnight is not the day they are stored on — see `nightSpill.ts`. Nothing is written
|
||||
* to make that so, which means every callback that reorders, moves, re-anchors or adds
|
||||
* beside a stop has to name the day the server knows it by. That is this pair, and it
|
||||
* travels with the stop so no caller has to work it out.
|
||||
*/
|
||||
ownerDayId: number
|
||||
ownerIndex: number
|
||||
placeId: number
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
/** Planned time on the assignment, or the place's own time as the fallback the API already resolves. */
|
||||
time: string | null
|
||||
/** How long the visit is planned to take. `places.duration_minutes` has carried this for years. */
|
||||
dwellMinutes: number | null
|
||||
/** Mode of the leg LEAVING this stop; null inherits the day default. */
|
||||
legMode: string | null
|
||||
incomingLegMode: string | null
|
||||
/** fuel / charging / rest_area / campsite, or null for an ordinary place (#1797). */
|
||||
stopType: string | null
|
||||
/**
|
||||
* How full THIS stop fills up, 1-100, or null to follow the traveller's own setting.
|
||||
*
|
||||
* A property of the stop: the motorway rapid charger is worth 80 % because the last
|
||||
* fifth costs as long again, the one at the hotel is worth all of it because the car
|
||||
* stands there all night.
|
||||
*
|
||||
* Optional, like `offRoadMeters`: absent and null say the same thing, and a stop built
|
||||
* anywhere that has no opinion about filling up should not have to say so.
|
||||
*/
|
||||
fillPercent?: number | null
|
||||
/**
|
||||
* How far the router had to go to find a road, in metres, once this stop has routed.
|
||||
*
|
||||
* null means either not routed yet or close enough that nobody would call it a gap.
|
||||
*/
|
||||
offRoadMeters?: number | null
|
||||
}
|
||||
|
||||
export interface RoadtripDay {
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
stops: RoadtripStop[]
|
||||
/** Leg i connects stops[i] → stops[i+1]. Shorter than `stops` by one once every leg resolved. */
|
||||
legs: (RouteSegment | undefined)[]
|
||||
/** Arrival and departure per stop, walked forward from the day's first pinned time. */
|
||||
schedule: Schedule
|
||||
/**
|
||||
* Halts a routing plugin placed on each leg, indexed alongside `legs`.
|
||||
*
|
||||
* Read-only and never written back: a plugin halt that became a via would go out as a
|
||||
* waypoint on the next run, and the plugin would then optimise around its own charging
|
||||
* stop. Kept out of `RouteSegment` because that type is read by half the map.
|
||||
*/
|
||||
legVias: RouteVia[][]
|
||||
/**
|
||||
* Classes this day was asked to leave out and could not.
|
||||
*
|
||||
* Empty when the drive got what it asked for, and empty when nothing was asked. The
|
||||
* switch says "where possible", and this is what makes that honest rather than a
|
||||
* hedge: a drive with no untolled crossing comes back on the toll road, and the day
|
||||
* has to be able to say so instead of wearing a label the road disproves.
|
||||
*/
|
||||
avoidMissed?: RouteAvoidClass[]
|
||||
/**
|
||||
* Where the tank runs dry on this day, once per fill-up, with a coordinate.
|
||||
*
|
||||
* Empty when no range limit is set, when the day never crosses it, or when a leg did
|
||||
* not route — the budget gives up rather than guessing, and so does this.
|
||||
*/
|
||||
dryPoints?: (DryPoint & { lat: number; lng: number })[]
|
||||
/**
|
||||
* `geometry` with the non-driving runs left out, which is what `dryPoints` are measured
|
||||
* along. The same array as `geometry` on a day that drives the whole way.
|
||||
*/
|
||||
drivingGeometry?: [number, number][]
|
||||
/**
|
||||
* The roads actually driven that day, as [lat, lng] — not the straight lines between
|
||||
* stops. Anything asking "what is along this day" has to use this: between Hamburg and
|
||||
* Berlin the straight line runs across open country while the motorway swings north of
|
||||
* it, so a corridor built from the stops finds the wrong side of the map.
|
||||
*/
|
||||
geometry: [number, number][]
|
||||
distance: number
|
||||
duration: number
|
||||
/**
|
||||
* Findings about the driving itself, per leg: too long behind the wheel, the tank
|
||||
* running out before the next fuel stop. Separate from `schedule.warnings`, which are
|
||||
* findings about a stop, because these belong to no stop and because `Schedule` is
|
||||
* pinned shape-for-shape by its own tests.
|
||||
*/
|
||||
driveWarnings: ScheduleWarning[]
|
||||
/** More driving in this day than the traveller allows, or null. */
|
||||
dayWarning: DayWarning | null
|
||||
/**
|
||||
* Stretches of this day's chain that were driven onto it from an earlier day.
|
||||
*
|
||||
* Empty on an ordinary day, which is most of them. See `nightSpill.ts`: a drive that
|
||||
* crosses midnight is read on the date it ARRIVES, and the stops it reaches are drawn
|
||||
* here even though they are stored on the day they set off from. Each stop still names
|
||||
* that day in `ownerDayId`, so nothing that writes has to know about this.
|
||||
*/
|
||||
spills?: SpillMark[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A day with its stops and nothing routed onto it yet — what the routing effect is handed
|
||||
* and what the arrangement by date is worked out from.
|
||||
*/
|
||||
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
|
||||
|
||||
/** A place, the road it is driven to from, and how far apart the two are. */
|
||||
export interface AccessSpur {
|
||||
line: [[number, number], [number, number]]
|
||||
meters: number
|
||||
/** So the rail and the map can point at the same one. */
|
||||
stopKey: string
|
||||
}
|
||||
|
||||
/** A day the rail does not draw a drive for, but that a stop can still be moved onto. */
|
||||
export interface QuietDay {
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
/** Usually none or one — two would have made it a drive. */
|
||||
stops: RoadtripStop[]
|
||||
}
|
||||
|
||||
export interface RoadtripRoutes {
|
||||
days: RoadtripDay[]
|
||||
/**
|
||||
* Days with fewer than two stops, in trip order.
|
||||
*
|
||||
* They carry no drive, so they are not part of `days` and nothing is routed for them —
|
||||
* but a stop has to be able to move onto one, and a day that is not on screen cannot be
|
||||
* dropped on. Listed separately rather than folded into `days` so the totals and the
|
||||
* routing keep meaning exactly what they meant before.
|
||||
*/
|
||||
quietDays: QuietDay[]
|
||||
/** One polyline per routed leg, in trip order — what the map draws in road trip mode. */
|
||||
lines: [number, number][][]
|
||||
/**
|
||||
* The day each entry of `lines` is driven on, same length and same order.
|
||||
*
|
||||
* So the map can colour the trip by day without knowing anything about how the days
|
||||
* were worked out. A night drive belongs to the day it ARRIVES on, the same rule its
|
||||
* kilometres follow.
|
||||
*/
|
||||
lineDays: number[]
|
||||
/**
|
||||
* The gap between a place and the road it was routed from, as a two-point line.
|
||||
*
|
||||
* Drawn dashed, the way Google draws the walk from the kerb to the door, so a stop well
|
||||
* off the road reads as "the drive ends here and the rest is not driving" instead of
|
||||
* looking like a route that cuts across open country.
|
||||
*/
|
||||
accessLines: AccessSpur[]
|
||||
/** Every plugin halt of the trip, flat, which is the shape the map draws. */
|
||||
vias: RouteVia[]
|
||||
/** The routed legs themselves, so the map can label each connector. */
|
||||
segments: RouteSegment[]
|
||||
totalDistance: number
|
||||
totalDuration: number
|
||||
totalStops: number
|
||||
/** True while legs are still coming in — the numbers below are a partial sum until then. */
|
||||
loading: boolean
|
||||
}
|
||||
export type { RoadtripStop, RoadtripDay, RoadtripRoutes, QuietDay, AccessSpur, RoutedLeg, SnappedPoint } from '@trek/shared/roadtrip'
|
||||
|
||||
/**
|
||||
* Gap between two routing requests. The public OSRM hosts TREK ships with state one
|
||||
@@ -213,35 +37,6 @@ const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
|
||||
signal.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* A leg that routed: the numbers for the rail plus the geometry the map draws.
|
||||
*
|
||||
* The two optional fields are filled by their own features and are declared here rather
|
||||
* than added later, because this shape is written in one place and read in another and
|
||||
* three separate changes to the same two lines is three merge conflicts.
|
||||
*/
|
||||
interface RoutedLeg {
|
||||
seg: RouteSegment
|
||||
line: [number, number][]
|
||||
/**
|
||||
* Where the router actually put each end of this leg, and how far that is from the
|
||||
* place we asked for. OSRM snaps every waypoint to the nearest road with no distance
|
||||
* limit and reports both in every response; a place set well off the road otherwise
|
||||
* looks like it is on the route when the drive really starts hundreds of metres away.
|
||||
*/
|
||||
snapped?: { from: SnappedPoint | null; to: SnappedPoint | null }
|
||||
/** Halts a routing plugin placed on this leg, in the order they are driven through. */
|
||||
vias: RouteVia[]
|
||||
}
|
||||
|
||||
/** A waypoint as the router resolved it, beside the coordinate that was asked for. */
|
||||
export interface SnappedPoint {
|
||||
lat: number
|
||||
lng: number
|
||||
/** Straight-line metres from the requested coordinate to the road it snapped to. */
|
||||
offRoadMeters: number
|
||||
}
|
||||
|
||||
/**
|
||||
* What makes a stop the same stop as far as routing is concerned. `planKey` is built from
|
||||
* these, and so are the keys the routed legs are filed under, so the two can never drift
|
||||
@@ -274,10 +69,16 @@ const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>)
|
||||
/** The drive from one stop to the next, identified the same way `planKey` identifies them. */
|
||||
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
|
||||
|
||||
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): RoadtripStop | null => {
|
||||
const EMPTY_ACCOMMODATIONS: Accommodation[] = []
|
||||
|
||||
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[], days: Day[]): RoadtripStop | null => {
|
||||
const p = a.place
|
||||
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
|
||||
const stay = accommodations.find(stay => stay.place_id === a.place_id && stay.start_day_id === ownerDayId)
|
||||
const checkoutDay = days.find(day => day.id === stay?.end_day_id)?.day_number
|
||||
const checkoutTime = parseClock(stay?.check_out)
|
||||
return {
|
||||
...(checkoutDay != null && checkoutTime !== null ? { checkoutAt: checkoutDay * 1440 + checkoutTime } : {}),
|
||||
assignmentId: a.id,
|
||||
ownerDayId,
|
||||
ownerIndex,
|
||||
@@ -285,8 +86,9 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): Roadtrip
|
||||
name: p.name,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
time: a.assignment_time ?? p.place_time ?? null,
|
||||
time: a.assignment_time ?? p.place_time ?? stay?.check_in ?? null,
|
||||
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
|
||||
endDay: a.end_day === true,
|
||||
legMode: a.leg_transport_mode ?? null,
|
||||
incomingLegMode: a.incoming_leg_transport_mode ?? null,
|
||||
stopType: p.stop_type ?? null,
|
||||
@@ -318,7 +120,10 @@ export function useRoadtripRoutes(
|
||||
* chose rather than the one it prefers.
|
||||
*/
|
||||
viasByDay: Record<number, RoadtripVia[]> = {},
|
||||
boundaries: RoadtripDayBoundary[] = [],
|
||||
accommodations: Accommodation[] = EMPTY_ACCOMMODATIONS,
|
||||
): RoadtripRoutes {
|
||||
const { t } = useTranslation()
|
||||
const routeProfile = fallbackProfile || 'driving'
|
||||
// Leg text is pre-formatted in the chosen unit, so a km↔mi switch has to re-fetch.
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
@@ -347,9 +152,9 @@ export function useRoadtripRoutes(
|
||||
const [loading, setLoading] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes)
|
||||
const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes)
|
||||
const fillPercent = useSettingsStore(s => s.settings.roadtrip_fill_percent)
|
||||
const legMinutes = useRoadtripSettings(s => s.roadtrip_leg_minutes, tripId)
|
||||
const dayMinutes = useRoadtripSettings(s => s.roadtrip_day_minutes, tripId)
|
||||
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent, tripId)
|
||||
/**
|
||||
* Road classes to weight away, as the settings row stores them: a comma list.
|
||||
*
|
||||
@@ -358,7 +163,7 @@ export function useRoadtripRoutes(
|
||||
* any value — and an unknown word here would become a costing option the router does
|
||||
* not have.
|
||||
*/
|
||||
const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid)
|
||||
const avoidSetting = useRoadtripSettings(s => s.roadtrip_avoid, tripId)
|
||||
/**
|
||||
* Whether the gaps between days are driven too.
|
||||
*
|
||||
@@ -367,11 +172,16 @@ export function useRoadtripRoutes(
|
||||
* the trip one continuous drive — every one of those gaps is routed, drawn, and counted
|
||||
* towards the day it ARRIVES on, which is the same rule a night drive already follows.
|
||||
*/
|
||||
const connectDays = useSettingsStore(s => !!s.settings.roadtrip_connect_days)
|
||||
const connectSetting = useRoadtripSettings(s => !!s.roadtrip_connect_days, tripId)
|
||||
const startTime = useRoadtripSettings(s => s.roadtrip_day_start, tripId)
|
||||
const endTime = useRoadtripSettings(s => s.roadtrip_day_end, tripId)
|
||||
const endMode = useRoadtripSettings(s => s.roadtrip_day_end_mode, tripId)
|
||||
const window = useMemo(() => dayWindow(startTime, endTime, endMode), [startTime, endTime, endMode])
|
||||
const connectDays = connectSetting || window !== null
|
||||
const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting])
|
||||
// What the car is and how far it goes on one fill, assembled in one place because the
|
||||
// rail needs the same answer to say what a given fill buys at a given stop.
|
||||
const { vehicleKind, rangeKm: planningRangeKm } = useVehicleRange()
|
||||
const { vehicleKind, rangeKm: planningRangeKm } = useVehicleRange(tripId)
|
||||
const avoidKey = avoid.join(',')
|
||||
// Zero and absent both mean "no limit": zero is a legal thing to type and says the
|
||||
// same thing, so it is folded here rather than guarded at every reading.
|
||||
@@ -394,7 +204,7 @@ export function useRoadtripRoutes(
|
||||
const stops = (assignments[String(d.id)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(a => asStop(a, d.id, 0))
|
||||
.map(a => asStop(a, d.id, 0, accommodations, days))
|
||||
.filter((s): s is RoadtripStop => s !== null)
|
||||
// The index is filled in after the drop, because it is the index into THIS
|
||||
// list: an assignment whose place has no coordinates never becomes a stop, and
|
||||
@@ -409,7 +219,7 @@ export function useRoadtripRoutes(
|
||||
}
|
||||
})
|
||||
.filter(d => d.stops.length > 1)
|
||||
}, [days, assignments])
|
||||
}, [days, assignments, accommodations])
|
||||
|
||||
const quietDays = useMemo<QuietDay[]>(() => {
|
||||
return [...days]
|
||||
@@ -422,12 +232,12 @@ export function useRoadtripRoutes(
|
||||
stops: (assignments[String(d.id)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(a => asStop(a, d.id, 0))
|
||||
.map(a => asStop(a, d.id, 0, accommodations, days))
|
||||
.filter((s): s is RoadtripStop => s !== null)
|
||||
.map((s, i) => ({ ...s, ownerIndex: i })),
|
||||
}))
|
||||
.filter(d => d.stops.length < 2)
|
||||
}, [days, assignments])
|
||||
}, [days, assignments, accommodations])
|
||||
|
||||
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
|
||||
// editing its notes must not fire a routing round.
|
||||
@@ -521,70 +331,7 @@ export function useRoadtripRoutes(
|
||||
if (s) daySnaps[stopKey(stop)] = s
|
||||
})
|
||||
}
|
||||
// Where a plugin's halts sit along this run, measured once. Only the ones
|
||||
// that say something get projected — a bare coordinate has nothing to show
|
||||
// in the rail, and projection is not free.
|
||||
const spine = r.coordinates.map(([la, ln]) => ({ lat: la, lng: ln }))
|
||||
const viaAlong = (r.vias ?? [])
|
||||
.filter(v => v.label || v.dwellSeconds != null)
|
||||
.map(v => ({ via: v, alongMeters: (projectOntoRoute({ lat: v.lat, lng: v.lng }, spine)?.alongKm ?? 0) * 1000 }))
|
||||
|
||||
// Fold the router's per-waypoint legs back onto the stop pairs: a stop pair
|
||||
// with a via between it comes back as two legs, and the rail shows one.
|
||||
// A running metre count comes with it, so each halt lands on the leg it is
|
||||
// actually driven on rather than on the nearest stop, which two towns close
|
||||
// together would get wrong.
|
||||
const legEndMeters: number[] = []
|
||||
const legKeys: string[] = []
|
||||
/**
|
||||
* Where each leg starts and ends along the run, so its own stretch of road
|
||||
* can be cut out of the run's one polyline.
|
||||
*
|
||||
* Collected rather than cut on the spot because the cut is proportional:
|
||||
* the router reports a leg's length off its own graph while the polyline is
|
||||
* great-circle hops between the vertices it sent back, and the two never
|
||||
* agree exactly. Cutting at absolute metres left the last leg of every run
|
||||
* short by the difference. Cutting at the same FRACTION of each makes the
|
||||
* last cut land on the last vertex, so the pieces still rebuild the run.
|
||||
*/
|
||||
const cuts: { key: string; from: number; to: number; seg: RouteSegment }[] = []
|
||||
let travelled = 0
|
||||
for (let i = 0; i < run.length - 1; i++) {
|
||||
const from = run[i]
|
||||
const to = run[i + 1]
|
||||
if (!from || !to) continue
|
||||
const parts = r.legs.slice(stopAt[i], stopAt[i + 1])
|
||||
if (!parts.length) continue
|
||||
const merged = parts.length === 1 ? parts[0] : {
|
||||
...parts[0],
|
||||
distance: parts.reduce((sum, l) => sum + (l.distance ?? 0), 0),
|
||||
duration: parts.reduce((sum, l) => sum + (l.duration ?? 0), 0),
|
||||
}
|
||||
const startedAt = travelled
|
||||
travelled += merged.distance ?? 0
|
||||
legEndMeters.push(travelled)
|
||||
const key = legKey(from, to)
|
||||
legKeys.push(key)
|
||||
cuts.push({ key, from: startedAt, to: travelled, seg: { ...merged, mode } })
|
||||
}
|
||||
// The road under each leg, in the polyline's own measure. It used to be
|
||||
// "the whole run hangs off leg 0", which was enough while a day was always
|
||||
// drawn whole — but a drive across midnight hands its stops to the next
|
||||
// card and the road has to travel with them.
|
||||
const drawnMetres = lineMetres(spine)
|
||||
const scale = travelled > 0 ? drawnMetres / travelled : 0
|
||||
for (const cut of cuts) {
|
||||
dayLegs[cut.key] = {
|
||||
seg: cut.seg,
|
||||
line: sliceAtMeters(spine, cut.from * scale, cut.to * scale)
|
||||
.map(pt => [pt.lat, pt.lng] as [number, number]),
|
||||
vias: [],
|
||||
}
|
||||
}
|
||||
for (const { via, alongMeters } of viaAlong) {
|
||||
const idx = legIndexForAlong(legEndMeters, alongMeters)
|
||||
if (idx >= 0) dayLegs[legKeys[idx]].vias.push(via)
|
||||
}
|
||||
Object.assign(dayLegs, foldRouteRun(run, stopAt, r, mode))
|
||||
return
|
||||
} catch (err) {
|
||||
// Almost always a rate limit on the shared routing host. Back off and try
|
||||
@@ -671,7 +418,10 @@ export function useRoadtripRoutes(
|
||||
if (have && have.shape === seamShape(from, viasByDay)) return
|
||||
out.push({ from, to, dayId })
|
||||
}
|
||||
for (const chain of chains) {
|
||||
const routingChains = window
|
||||
? [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).filter(d => d.stops.length)
|
||||
: chains
|
||||
for (const chain of routingChains) {
|
||||
for (let i = 0; i < chain.stops.length - 1; i++) {
|
||||
const from = chain.stops[i]
|
||||
const to = chain.stops[i + 1]
|
||||
@@ -683,14 +433,14 @@ export function useRoadtripRoutes(
|
||||
// And, when the traveller asks for one continuous drive, the road from each card's
|
||||
// last stop to the next card's first — the one gap a day-at-a-time routing leaves.
|
||||
if (connectDays) {
|
||||
for (let d = 0; d < chains.length - 1; d++) {
|
||||
const from = chains[d].stops[chains[d].stops.length - 1]
|
||||
const to = chains[d + 1].stops[0]
|
||||
if (from && to) want(from, to, chains[d + 1].dayId)
|
||||
for (let d = 0; d < routingChains.length - 1; d++) {
|
||||
const from = routingChains[d].stops[routingChains[d].stops.length - 1]
|
||||
const to = routingChains[d + 1].stops[0]
|
||||
if (from && to) want(from, to, routingChains[d + 1].dayId)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [chains, legsByDay, seamLegs, viasByDay, connectDays])
|
||||
}, [chains, plan, quietDays, window, legsByDay, seamLegs, viasByDay, connectDays])
|
||||
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -754,168 +504,7 @@ export function useRoadtripRoutes(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seamKey, routeProfile, tripId, avoidKey])
|
||||
|
||||
return useMemo(() => {
|
||||
// Legs and snaps, flat. They are filed under a key built from the two stops' own
|
||||
// coordinates, so which day they were fetched under stops mattering the moment a
|
||||
// drive across midnight moves its stops to the next card — the road between two
|
||||
// places is the road between them whichever date it is driven on.
|
||||
const allSnaps: Record<string, SnappedWaypoint> = {}
|
||||
for (const day of plan) Object.assign(allSnaps, snapByDay[day.dayId] ?? {})
|
||||
const legFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)]
|
||||
|
||||
const lines: [number, number][][] = []
|
||||
const lineDays: number[] = []
|
||||
const segments: RouteSegment[] = []
|
||||
const accessLines: RoadtripRoutes['accessLines'] = []
|
||||
// A loop rather than a map, because the range budget outlives a single day: a tank
|
||||
// does not empty overnight, so what is left at the end of one day is what the next
|
||||
// one starts with. `chains` is already in day order, and the legs inside them are in
|
||||
// driving order, which is the order the tank is emptied in.
|
||||
const out: RoadtripDay[] = []
|
||||
let carryKm: number | null = 0
|
||||
/** The stop the previous card ended at, so the next one can be driven to from it. */
|
||||
let previousStop: RoadtripStop | undefined
|
||||
/** And which day that was, so the road out of it is drawn as that day's. */
|
||||
let previousDayNumber: number | undefined
|
||||
for (const chain of chains) {
|
||||
const routed = chain.stops.slice(0, -1).map((s, i) => legFor(s, chain.stops[i + 1]))
|
||||
/**
|
||||
* The drives that reach this card rather than leave from it, and only when the
|
||||
* traveller asked for them.
|
||||
*
|
||||
* Every one of these crosses from one card to the next: a night drive that handed
|
||||
* its stops forward, or plainly the road from yesterday's last stop to today's
|
||||
* first. Whether a road trip is one continuous line or a day at a time is exactly
|
||||
* what the switch asks, so with it off a card holds only the driving between its
|
||||
* own stops — no stroke across the join, and none of those kilometres in its total.
|
||||
*
|
||||
* A night drive is no exception. It is stored inside one day and it is real
|
||||
* driving, but on screen it runs from a stop on one card to a stop on the next, and
|
||||
* a line between two cards is the thing the switch is about.
|
||||
*
|
||||
* `drawnAs` is the day it LEAVES rather than the one it arrives on. The kilometres
|
||||
* count towards the arrival, because that is when they are driven, but the stroke
|
||||
* starts at a stop on yesterday's card and colouring it as tomorrow makes tomorrow
|
||||
* look like it begins at a stop that is not on it.
|
||||
*/
|
||||
const inboundAt = new Map<number, { seg: RouteSegment | undefined; line: [number, number][]; drawnAs: number }>()
|
||||
if (connectDays) {
|
||||
for (const spill of chain.spills) {
|
||||
inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber })
|
||||
}
|
||||
// And the road out of the previous card where no night drive already opens this
|
||||
// one. Nothing moved for that one: it is the gap a day-at-a-time plan leaves.
|
||||
const joined = inboundAt.has(0) ? undefined : previousStop && legFor(previousStop, chain.stops[0])
|
||||
if (joined) inboundAt.set(0, { seg: joined.seg, line: joined.line, drawnAs: previousDayNumber ?? chain.dayNumber })
|
||||
}
|
||||
previousStop = chain.stops[chain.stops.length - 1] ?? previousStop
|
||||
previousDayNumber = chain.stops.length ? chain.dayNumber : previousDayNumber
|
||||
for (let i = 0; i < chain.stops.length; i++) {
|
||||
const inbound = inboundAt.get(i)
|
||||
if (inbound) {
|
||||
if (inbound.line.length > 1) { lines.push(inbound.line); lineDays.push(inbound.drawnAs) }
|
||||
if (inbound.seg) segments.push(inbound.seg)
|
||||
}
|
||||
const leg = routed[i]
|
||||
if (!leg) continue
|
||||
if (leg.line.length > 1) { lines.push(leg.line); lineDays.push(chain.dayNumber) }
|
||||
segments.push(leg.seg)
|
||||
}
|
||||
// Each leg carries its own stretch of road now, so the day's line is simply its
|
||||
// legs end to end — with the night that reached it threaded in at the stop it
|
||||
// arrives at, so the line is drawn in the order it is driven.
|
||||
const geometry: [number, number][] = []
|
||||
for (let i = 0; i < chain.stops.length; i++) {
|
||||
const inbound = inboundAt.get(i)
|
||||
if (inbound) geometry.push(...inbound.line)
|
||||
geometry.push(...(routed[i]?.line ?? []))
|
||||
}
|
||||
const legs = routed.map(l => l?.seg)
|
||||
const inbound = [...inboundAt.values()].map(l => l.seg)
|
||||
const distance = legs.reduce((sum, l) => sum + (l?.distance ?? 0), 0)
|
||||
+ inbound.reduce((sum, l) => sum + (l?.distance ?? 0), 0)
|
||||
const duration = legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0)
|
||||
+ inbound.reduce((sum, l) => sum + (l?.duration ?? 0), 0)
|
||||
const schedule = chain.schedule
|
||||
const legVias = routed.map(l => l?.vias ?? [])
|
||||
const stops = chain.stops.map(s => {
|
||||
const snap = allSnaps[stopKey(s)]
|
||||
const line = spurFor(snap)
|
||||
if (line) accessLines.push({ line, meters: snap.meters, stopKey: stopKey(s) })
|
||||
return { ...s, offRoadMeters: line ? snap.meters : null }
|
||||
})
|
||||
// The night drive is on the tank too, and it is in no leg list: it ends at this
|
||||
// chain's first stop rather than leaving from it. Added to the carry so the budget
|
||||
// has spent it before the first stop of the day gets a chance to fill up again.
|
||||
const inboundKm = inbound
|
||||
.filter(l => l && (l.mode === undefined || l.mode === 'driving'))
|
||||
.reduce((sum, l) => sum + (l?.distance ?? 0), 0) / 1000
|
||||
const startKm = carryKm === null ? null : carryKm + inboundKm
|
||||
const drive = deriveDriveWarnings(
|
||||
legs,
|
||||
// One longer than the legs: the last stop of the day counts too, because filling
|
||||
// up on arrival is what makes the next morning start full.
|
||||
stops.map(s => refuelsRange(s.stopType, vehicleKind)),
|
||||
limits,
|
||||
startKm,
|
||||
// Same length and same order, so a stop that says how far it fills is read
|
||||
// against itself rather than against the traveller's default.
|
||||
stops.map(s => s.fillPercent),
|
||||
)
|
||||
carryKm = drive.carryKm
|
||||
// Where each tank runs dry, as a place rather than a distance.
|
||||
//
|
||||
// Walked along the DRIVING legs only. `geometry` above is every leg end to end,
|
||||
// ferries and walks included, while the budget counts none of those — so walking
|
||||
// the drawn line to the same figure overshoots by the whole length of any crossing
|
||||
// the day happens to contain, and the marker lands a ferry's width off the road.
|
||||
const drivingLine = routed
|
||||
.filter(l => l && l.seg.mode !== undefined && l.seg.mode === 'driving')
|
||||
.flatMap(l => l?.line ?? [])
|
||||
const dryPoints = drive.emptyAt
|
||||
.map(dry => {
|
||||
const at = pointAtMeters(drivingLine.map(([lat, lng]) => ({ lat, lng })), dry.drivenMeters)
|
||||
return at ? { ...dry, lat: at.lat, lng: at.lng } : null
|
||||
})
|
||||
.filter((d): d is DryPoint & { lat: number; lng: number } => d !== null)
|
||||
// The same line the dry points are measured along, so anything offered against
|
||||
// them is projected in the same space. Handed on as the SAME array when the day
|
||||
// drives all the way through, which is nearly every day — only a day carrying a
|
||||
// ferry or a walk pays for a second copy.
|
||||
const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine
|
||||
out.push({
|
||||
dayId: chain.dayId,
|
||||
dayNumber: chain.dayNumber,
|
||||
date: chain.date,
|
||||
title: chain.title,
|
||||
avoidMissed: missedByDay[chain.dayId],
|
||||
spills: chain.spills,
|
||||
stops, legs, legVias, schedule, geometry, distance, duration, dryPoints, drivingGeometry,
|
||||
driveWarnings: drive.warnings,
|
||||
dayWarning: drive.day,
|
||||
})
|
||||
}
|
||||
const drives = out.filter(d => d.stops.length > 1)
|
||||
return {
|
||||
days: drives,
|
||||
lines,
|
||||
lineDays,
|
||||
segments,
|
||||
accessLines,
|
||||
vias: out.flatMap(d => d.legVias.flat()),
|
||||
totalDistance: drives.reduce((s, d) => s + d.distance, 0),
|
||||
totalDuration: drives.reduce((s, d) => s + d.duration, 0),
|
||||
// Over the days the head is a summary OF, which is the ones with a drive: a day
|
||||
// holding a single stop draws no card and contributes nothing else here either.
|
||||
// Service stops are not stops in this sense — a charger on the way is part of the
|
||||
// drive, and counting it here would make the head disagree with the day cards.
|
||||
totalStops: drives.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0),
|
||||
// A day left with one stop or none draws no drive, so it stays what it was: an
|
||||
// outline that appears while a stop is in flight and is not worth a row otherwise.
|
||||
quietDays: out
|
||||
.filter(d => d.stops.length < 2)
|
||||
.map(d => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })),
|
||||
loading,
|
||||
}
|
||||
}, [plan, chains, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays])
|
||||
}
|
||||
return useMemo(() => assembleRoadtrip({ plan, quietDays, window, distanceUnit, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries,
|
||||
labels: { start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') },
|
||||
}), [plan, quietDays, window, distanceUnit, t, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Settings } from '../../types'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
@@ -197,3 +197,7 @@ describe('useVehicleRange', () => {
|
||||
expect(result.current).not.toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||
}))
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { useMemo } from 'react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { effectiveRangeKm } from './vehicleRange'
|
||||
import type { VehicleKind } from './roadtripModel'
|
||||
|
||||
/**
|
||||
* How far the traveller's car goes on one fill, and what kind of car it is.
|
||||
*
|
||||
* One place rather than two, because the answer is assembled from seven settings and two
|
||||
* readers need it: the routes hook, which turns it into the range budget every warning is
|
||||
* measured against, and the rail, which tells a traveller what a given fill buys at a
|
||||
* given stop. Assembling it twice is the hand-mirrored state this codebase refuses
|
||||
* everywhere else, and the two copies would drift the first time a field is added.
|
||||
*
|
||||
* Each setting is read as its own primitive so the memo compares numbers. An object
|
||||
* rebuilt on every render would give the result a new identity every time, and what
|
||||
* depends on it is the effect that recalculates every day's driving.
|
||||
*/
|
||||
export function useVehicleRange(): { vehicleKind: VehicleKind | null; rangeKm: number | null } {
|
||||
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
|
||||
// Validated rather than trusted: a per-user setting gets no server-side check, and an
|
||||
// unknown word here would silently stop both kinds of stop counting as a fill-up.
|
||||
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
|
||||
const tankLitres = useSettingsStore(s => s.settings.roadtrip_tank_litres)
|
||||
const litresPer100 = useSettingsStore(s => s.settings.roadtrip_litres_per_100)
|
||||
const batteryKwh = useSettingsStore(s => s.settings.roadtrip_battery_kwh)
|
||||
const kwhPer100 = useSettingsStore(s => s.settings.roadtrip_kwh_per_100)
|
||||
const degradationPercent = useSettingsStore(s => s.settings.roadtrip_battery_degradation)
|
||||
export function useVehicleRange(tripId?: number | string | null): { vehicleKind: VehicleKind | null; rangeKm: number | null } {
|
||||
const rangeKm = useRoadtripSettings(s => s.roadtrip_range_km, tripId)
|
||||
const vehicle = useRoadtripSettings(s => s.roadtrip_vehicle, tripId)
|
||||
const tankLitres = useRoadtripSettings(s => s.roadtrip_tank_litres, tripId)
|
||||
const litresPer100 = useRoadtripSettings(s => s.roadtrip_litres_per_100, tripId)
|
||||
const batteryKwh = useRoadtripSettings(s => s.roadtrip_battery_kwh, tripId)
|
||||
const kwhPer100 = useRoadtripSettings(s => s.roadtrip_kwh_per_100, tripId)
|
||||
const degradationPercent = useRoadtripSettings(s => s.roadtrip_battery_degradation, tripId)
|
||||
|
||||
const vehicleKind: VehicleKind | null =
|
||||
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
|
||||
|
||||
@@ -1,170 +1,2 @@
|
||||
import type { VehicleKind } from './roadtripModel'
|
||||
|
||||
/**
|
||||
* How far the vehicle goes on one fill, worked out from what it is made of.
|
||||
*
|
||||
* Two ways to the same number, and the point of having both is that people know their
|
||||
* car in different ways. Some know "about 600 km"; some know the tank holds 55 litres
|
||||
* and it does 7 per hundred, and would have to do the division themselves. Whoever types
|
||||
* a range directly still gets exactly what they typed — this only fills that number in
|
||||
* when it can and the field is empty.
|
||||
*
|
||||
* Deliberately not a consumption model. ABRP asks for reference speed, relative speed and
|
||||
* a drive profile because it predicts a specific drive; this answers "how far on one
|
||||
* fill", which is a division. Adding speed terms would make the number look precise
|
||||
* without making it right, since the biggest factor by far is how the person drives.
|
||||
*
|
||||
* Pure: no React, no store, no network.
|
||||
*/
|
||||
|
||||
export interface VehicleSpec {
|
||||
/** Litres the tank holds. Petrol only. */
|
||||
tankLitres?: number | null
|
||||
/** Litres per 100 km. Petrol only. */
|
||||
litresPer100?: number | null
|
||||
/** Usable battery when new, in kWh. Electric only. */
|
||||
batteryKwh?: number | null
|
||||
/** kWh per 100 km. Electric only. */
|
||||
kwhPer100?: number | null
|
||||
/**
|
||||
* How much of the battery the years have taken, as a percentage.
|
||||
*
|
||||
* A five-year-old car does not carry the capacity on its data sheet, and the gap is
|
||||
* the difference between arriving and not on the last leg of a day.
|
||||
*/
|
||||
degradationPercent?: number | null
|
||||
}
|
||||
|
||||
/** A positive, finite number, or null. Everything here comes from a text field. */
|
||||
function positive(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The range in kilometres that the parts add up to, or null when they do not add up.
|
||||
*
|
||||
* Null rather than a guess whenever a piece is missing: half a specification is not a
|
||||
* range, and a made-up figure here becomes a fuel warning that fires at the wrong place.
|
||||
*/
|
||||
/**
|
||||
* A rounded range that came out at nothing is a typo, not a car.
|
||||
*
|
||||
* A consumption typed as 500 rather than 5.0 rounds to a range of zero, and zero is a
|
||||
* number: `effectiveRangeKm` would take it as the answer and throw away the range the
|
||||
* traveller had typed by hand right next to it. What reached the rail was "no range at
|
||||
* all", so every warning went quiet, with nothing on screen to say which field did it.
|
||||
* Reported as nothing instead, so the typed figure gets its turn.
|
||||
*/
|
||||
function atLeastAKilometre(km: number): number | null {
|
||||
return km >= 1 ? km : null
|
||||
}
|
||||
|
||||
export function rangeFromSpec(vehicle: VehicleKind | null | undefined, spec: VehicleSpec): number | null {
|
||||
if (vehicle === 'combustion') {
|
||||
const tank = positive(spec.tankLitres)
|
||||
const per100 = positive(spec.litresPer100)
|
||||
if (!tank || !per100) return null
|
||||
return atLeastAKilometre(Math.round((tank / per100) * 100))
|
||||
}
|
||||
|
||||
if (vehicle === 'electric') {
|
||||
const battery = positive(spec.batteryKwh)
|
||||
const per100 = positive(spec.kwhPer100)
|
||||
if (!battery || !per100) return null
|
||||
// Degradation only ever takes away, and never all of it: a battery reported as 100 %
|
||||
// gone is a typo, not a car, and dividing by zero range would put the dry point at
|
||||
// the first metre of the trip.
|
||||
const lost = Math.min(90, Math.max(0, positive(spec.degradationPercent) ?? 0))
|
||||
const usable = battery * (1 - lost / 100)
|
||||
return atLeastAKilometre(Math.round((usable / per100) * 100))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The range to plan with: what the parts say, or what was typed, in that order.
|
||||
*
|
||||
* The computed one wins because it is the more specific answer — somebody who filled in
|
||||
* a tank size and a consumption said something more exact than a round number, and the
|
||||
* dialog shows them the result so it is never a surprise.
|
||||
*/
|
||||
export function effectiveRangeKm(
|
||||
vehicle: VehicleKind | null | undefined,
|
||||
spec: VehicleSpec,
|
||||
typedKm: number | null | undefined,
|
||||
): number | null {
|
||||
return rangeFromSpec(vehicle, spec) ?? positive(typedKm)
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit each field is typed in, per measurement system.
|
||||
*
|
||||
* Storage is always metric, exactly as the range itself is always kilometres: an imperial
|
||||
* traveller who typed 15 into a field labelled "gallons" must not have 15 litres stored,
|
||||
* or every warning after it fires at a quarter of the right distance.
|
||||
*
|
||||
* The imperial pair is not a straight translation of the metric one. Fuel economy is
|
||||
* quoted as miles per gallon, which is the INVERSE of litres per hundred, while electric
|
||||
* economy is quoted by the EPA as kWh per 100 miles, which is not. Forcing one shape onto
|
||||
* both would leave one of the two reading as a figure nobody recognises.
|
||||
*/
|
||||
export type SpecKey = keyof VehicleSpec
|
||||
|
||||
const LITRES_PER_GALLON = 3.785411784
|
||||
/** litres/100 km × mpg = this. The one constant that makes the inversion exact. */
|
||||
const MPG_PRODUCT = 235.214583
|
||||
const KM_PER_MILE = 1.609344
|
||||
|
||||
export function specUnit(key: SpecKey, imperial: boolean): string {
|
||||
switch (key) {
|
||||
case 'tankLitres': return imperial ? 'gal' : 'L'
|
||||
case 'litresPer100': return imperial ? 'mpg' : 'L/100 km'
|
||||
case 'batteryKwh': return 'kWh'
|
||||
case 'kwhPer100': return imperial ? 'kWh/100 mi' : 'kWh/100 km'
|
||||
default: return '%'
|
||||
}
|
||||
}
|
||||
|
||||
/** How many decimals the field is worth typing. A tank is not measured to a tenth. */
|
||||
function places(key: SpecKey, imperial: boolean): number {
|
||||
if (key === 'degradationPercent') return 0
|
||||
if (key === 'tankLitres') return imperial ? 1 : 0
|
||||
if (key === 'litresPer100') return imperial ? 0 : 1
|
||||
return 1
|
||||
}
|
||||
|
||||
function convert(key: SpecKey, value: number, imperial: boolean, toStorage: boolean): number {
|
||||
if (!imperial) return value
|
||||
switch (key) {
|
||||
case 'tankLitres': return toStorage ? value * LITRES_PER_GALLON : value / LITRES_PER_GALLON
|
||||
// Inverse in both directions, which is why one expression covers the pair.
|
||||
case 'litresPer100': return MPG_PRODUCT / value
|
||||
case 'kwhPer100': return toStorage ? value / KM_PER_MILE : value * KM_PER_MILE
|
||||
default: return value
|
||||
}
|
||||
}
|
||||
|
||||
function round(value: number, decimals: number): number {
|
||||
const f = 10 ** decimals
|
||||
return Math.round(value * f) / f
|
||||
}
|
||||
|
||||
/** The stored figure as it should appear in the field, or undefined when there is none. */
|
||||
export function showSpec(key: SpecKey, stored: number | null | undefined, imperial: boolean): number | undefined {
|
||||
const value = positive(stored)
|
||||
if (value === null) return undefined
|
||||
return round(convert(key, value, imperial, false), places(key, imperial))
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed figure as it should be stored. Zero comes back as zero, meaning "not said".
|
||||
*
|
||||
* Two decimals rather than none: a gallon is 3.79 litres, so an imperial figure that
|
||||
* round-tripped through a whole number would drift by up to half a litre and show the
|
||||
* traveller a different number than the one they typed.
|
||||
*/
|
||||
export function storeSpec(key: SpecKey, shown: number, imperial: boolean): number {
|
||||
if (!(shown > 0)) return 0
|
||||
return round(convert(key, shown, imperial, true), 2)
|
||||
}
|
||||
export { rangeFromSpec, effectiveRangeKm, specUnit, showSpec, storeSpec } from '@trek/shared/roadtrip'
|
||||
export type { VehicleSpec, SpecKey } from '@trek/shared/roadtrip'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
@@ -14,15 +14,17 @@ interface MenuState {
|
||||
x: number
|
||||
y: number
|
||||
items: MenuItem[]
|
||||
alignEnd?: boolean
|
||||
}
|
||||
|
||||
export function useContextMenu() {
|
||||
const [menu, setMenu] = useState<MenuState | null>(null)
|
||||
|
||||
const open = (e: React.MouseEvent, items: MenuItem[]) => {
|
||||
const open = (e: React.MouseEvent, items: MenuItem[], alignEnd = false) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMenu({ x: e.clientX, y: e.clientY, items })
|
||||
const anchor = e.currentTarget.getBoundingClientRect()
|
||||
setMenu({ x: alignEnd ? anchor.right : e.clientX, y: alignEnd ? anchor.bottom + 6 : e.clientY, items, alignEnd })
|
||||
}
|
||||
|
||||
const close = () => setMenu(null)
|
||||
@@ -49,11 +51,12 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
}
|
||||
}, [menu, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!menu || !ref.current) return
|
||||
const el = ref.current
|
||||
const rect = el.getBoundingClientRect()
|
||||
let { x, y } = menu
|
||||
if (menu.alignEnd) x = Math.max(8, x - rect.width)
|
||||
if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8
|
||||
if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8
|
||||
if (x !== menu.x || y !== menu.y) {
|
||||
@@ -72,8 +75,10 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
minWidth: 160,
|
||||
width: menu.alignEnd ? 'max-content' : undefined,
|
||||
whiteSpace: menu.alignEnd ? 'nowrap' : undefined,
|
||||
fontFamily: "var(--font-system)",
|
||||
transformOrigin: 'top left',
|
||||
transformOrigin: menu.alignEnd ? 'top right' : 'top left',
|
||||
}}>
|
||||
{menu.items.filter(Boolean).map((item, i) => {
|
||||
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
|
||||
|
||||
@@ -13,9 +13,12 @@ interface CustomTimePickerProps {
|
||||
/** Read-only surfaces keep the field, they just cannot type in it — same
|
||||
* affordance the native input they replaced offered (#2067). */
|
||||
disabled?: boolean
|
||||
'aria-label'?: string
|
||||
'aria-describedby'?: string
|
||||
'aria-invalid'?: boolean
|
||||
}
|
||||
|
||||
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {}, disabled = false }: CustomTimePickerProps) {
|
||||
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {}, disabled = false, ...aria }: CustomTimePickerProps) {
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputFocused, setInputFocused] = useState(false)
|
||||
@@ -129,6 +132,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
transition: 'border-color 0.15s',
|
||||
}}>
|
||||
<input
|
||||
{...aria}
|
||||
type="text"
|
||||
value={inputFocused ? value : formatClockTime(value, is12h)}
|
||||
onChange={handleInput}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { offlineDb } from './offlineDb'
|
||||
import type { Assignment } from '../types'
|
||||
|
||||
export async function cacheAssignment(assignment: Assignment): Promise<void> {
|
||||
await offlineDb.days.where('id').equals(assignment.day_id).modify(day => {
|
||||
day.assignments = (day.assignments ?? []).map(a => a.id === assignment.id ? { ...a, ...assignment } : a)
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RoadtripPreferences } from '@trek/shared';
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types';
|
||||
|
||||
@@ -160,6 +161,7 @@ function initialDbName(): string {
|
||||
}
|
||||
|
||||
class TrekOfflineDb extends Dexie {
|
||||
roadtripPreferences!: Table<{ tripId: number; preferences: RoadtripPreferences }, number>;
|
||||
trips!: Table<Trip, number>;
|
||||
days!: Table<Day, number>;
|
||||
places!: Table<Place, number>;
|
||||
@@ -221,6 +223,7 @@ class TrekOfflineDb extends Dexie {
|
||||
// v5: places from the TREK Places index for the trip's area, so search
|
||||
// answers offline. `searchName` is indexed because that is what an offline
|
||||
// query filters on; `tripId` so the set drops with its trip.
|
||||
this.version(6).stores({ roadtripPreferences: 'tripId' });
|
||||
this.version(5).stores({
|
||||
areaPlaces: 'gers, tripId, searchName',
|
||||
});
|
||||
@@ -448,8 +451,10 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
offlineDb.syncMeta,
|
||||
offlineDb.blobCache,
|
||||
offlineDb.areaPlaces,
|
||||
offlineDb.roadtripPreferences,
|
||||
],
|
||||
async () => {
|
||||
await offlineDb.roadtripPreferences.delete(tripId);
|
||||
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.places.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.packingItems.where('trip_id').equals(tripId).delete();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useRoadtripPreferencesSync } from './useRoadtripPreferencesSync'
|
||||
import { useRoadtripSettings } from './useRoadtripSettings'
|
||||
import { useRoadtripPreferencesStore } from '../store/roadtripPreferencesStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { addListener, removeListener } from '../api/websocket'
|
||||
|
||||
vi.mock('../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() }))
|
||||
vi.mock('../repo/roadtripPreferencesRepo', () => ({ cachedRoadtripPreferences: vi.fn(async () => ({})) }))
|
||||
vi.mock('../db/offlineDb', () => ({ offlineDb: { roadtripPreferences: { put: vi.fn(async () => {}) } } }))
|
||||
|
||||
describe('shared Roadtrip settings', () => {
|
||||
it('scopes events and reads by trip and signed-in account', () => {
|
||||
useAuthStore.setState({ user: { id: 1 } as never })
|
||||
useRoadtripPreferencesStore.setState({ byTrip: {} })
|
||||
const hook = renderHook(() => {
|
||||
useRoadtripPreferencesSync()
|
||||
return useRoadtripSettings(s => s.roadtrip_day_start, 10)
|
||||
})
|
||||
const listener = vi.mocked(addListener).mock.calls[vi.mocked(addListener).mock.calls.length - 1][0]
|
||||
act(() => listener({ type: 'roadtripPreferences:changed', tripId: '10', preferences: { roadtrip_day_start: '07:00' } }))
|
||||
expect(hook.result.current).toBe('07:00')
|
||||
act(() => listener({ type: 'roadtripPreferences:changed', tripId: '11', preferences: { roadtrip_day_start: '09:00' } }))
|
||||
expect(hook.result.current).toBe('07:00')
|
||||
act(() => listener({ type: 'roadtripPreferences:changed', tripId: '10', preferences: { roadtrip_day_start: '99:00' } }))
|
||||
expect(hook.result.current).toBe('07:00')
|
||||
act(() => useAuthStore.setState({ user: { id: 2 } as never }))
|
||||
expect(hook.result.current).toBeUndefined()
|
||||
hook.unmount()
|
||||
expect(removeListener).toHaveBeenCalledWith(listener)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from 'react'
|
||||
import { roadtripPreferencesSchema } from '@trek/shared'
|
||||
import { addListener, removeListener } from '../api/websocket'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { publishRoadtripPreferences } from '../store/roadtripPreferencesStore'
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { cachedRoadtripPreferences } from '../repo/roadtripPreferencesRepo'
|
||||
|
||||
export function useRoadtripPreferencesSync() {
|
||||
const userId = useAuthStore(s => s.user?.id)
|
||||
useEffect(() => {
|
||||
if (!userId) return
|
||||
let disposed = false
|
||||
const listener = (event: Record<string, unknown>) => {
|
||||
if (event.type !== 'roadtripPreferences:changed') return
|
||||
const tripId = Number(event.tripId)
|
||||
const parsed = roadtripPreferencesSchema.safeParse(event.preferences)
|
||||
if (!parsed.success || !Number.isSafeInteger(tripId) || tripId < 1) return
|
||||
publishRoadtripPreferences(userId, tripId, parsed.data)
|
||||
void offlineDb.roadtripPreferences.put({ tripId, preferences: parsed.data }).then(() => cachedRoadtripPreferences(tripId)).then(preferences => {
|
||||
if (!disposed) publishRoadtripPreferences(userId, tripId, preferences)
|
||||
}).catch(() => {})
|
||||
}
|
||||
addListener(listener)
|
||||
return () => { disposed = true; removeListener(listener) }
|
||||
}, [userId])
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { liveQuery } from 'dexie'
|
||||
import { onNetworkModeChange } from '../sync/networkMode'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RoadtripPreferences } from '@trek/shared'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { EMPTY_ROADTRIP_PREFERENCES, publishRoadtripPreferences, useRoadtripPreferencesStore } from '../store/roadtripPreferencesStore'
|
||||
import { cachedRoadtripPreferences, roadtripPreferencesRepo } from '../repo/roadtripPreferencesRepo'
|
||||
|
||||
export function useRoadtripSettings<T>(select: (settings: RoadtripPreferences) => T, requestedTripId?: number | string | null): T {
|
||||
const activeTripId = useTripStore(s => s.trip?.id)
|
||||
const userId = useAuthStore(s => s.user?.id)
|
||||
const key = `${userId}:${requestedTripId ?? activeTripId}`
|
||||
return useRoadtripPreferencesStore(s => select(s.byTrip[key] ?? EMPTY_ROADTRIP_PREFERENCES))
|
||||
}
|
||||
|
||||
export function useLoadRoadtripSettings(tripId: number, enabled: boolean) {
|
||||
const userId = useAuthStore(s => s.user?.id)
|
||||
const [loadedKey, setLoadedKey] = useState('')
|
||||
const [failedKey, setFailedKey] = useState('')
|
||||
const key = `${userId}:${tripId}`
|
||||
useEffect(() => {
|
||||
if (!enabled || !userId || !Number.isFinite(tripId)) return
|
||||
let cancelled = false
|
||||
const load = () => {
|
||||
roadtripPreferencesRepo.read(tripId).then(preferences => {
|
||||
if (cancelled) return
|
||||
publishRoadtripPreferences(userId, tripId, preferences)
|
||||
setLoadedKey(key)
|
||||
setFailedKey('')
|
||||
}).catch(() => { if (!cancelled) setFailedKey(key) })
|
||||
}
|
||||
const cacheSubscription = liveQuery(() => cachedRoadtripPreferences(tripId).catch(() => null)).subscribe(preferences => {
|
||||
if (!cancelled && preferences) publishRoadtripPreferences(userId, tripId, preferences)
|
||||
})
|
||||
load()
|
||||
const unsubscribe = onNetworkModeChange(load)
|
||||
window.addEventListener('online', load)
|
||||
return () => { cancelled = true; cacheSubscription.unsubscribe(); unsubscribe(); window.removeEventListener('online', load) }
|
||||
}, [tripId, userId, enabled, key])
|
||||
return { ready: loadedKey === key, failed: failedKey === key }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useRoadtripSettings } from './useRoadtripSettings'
|
||||
import { isServiceStopType } from '../components/Roadtrip/roadtripModel'
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -20,6 +22,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
// Charging stops / rest areas a plugin route places on the drawn line.
|
||||
const [routeVias, setRouteVias] = useState<RouteVia[]>([])
|
||||
const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false)
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||
// Recompute when the selected day's whole-day default mode changes (#1281) —
|
||||
@@ -44,7 +47,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
const allDays = state.days || []
|
||||
const runsWithHotel = buildDayRouteRuns(dayId, {
|
||||
days: allDays,
|
||||
assignments: state.assignments || {},
|
||||
assignments: Object.fromEntries(Object.entries(state.assignments || {}).map(([id, entries]) => [id, entries.filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type))])),
|
||||
reservations: state.reservations || [],
|
||||
accommodations,
|
||||
optimizeFromAccommodation,
|
||||
@@ -113,7 +116,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
||||
if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) }
|
||||
}
|
||||
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode])
|
||||
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops])
|
||||
|
||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||
@@ -137,7 +140,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode])
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops])
|
||||
|
||||
return { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import MSegmented from '../../components/MSegmented'
|
||||
import { MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi'
|
||||
import { MSetSelectRow } from '../settings/MSettingsUi'
|
||||
import MSetPickerSheet from '../settings/MSetPickerSheet'
|
||||
import RoutingInstanceFields, { type RoutingDefaults } from '../../../components/Admin/RoutingInstanceFields'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -37,7 +38,7 @@ const MAP_PRESETS = [
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
type Defaults = {
|
||||
type Defaults = RoutingDefaults & {
|
||||
temperature_unit?: string
|
||||
distance_unit?: DistanceUnit
|
||||
dark_mode?: string | boolean
|
||||
@@ -317,6 +318,8 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
|
||||
</MAdminField>
|
||||
)}
|
||||
|
||||
{!managed && <RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} hintClassName="font-geist text-[0.625rem] leading-relaxed text-m-muted" />}
|
||||
|
||||
{/* Live tile preview */}
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-xl">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
|
||||
@@ -268,10 +268,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
|
||||
setRoadtripStopKind,
|
||||
setRoadtripStopFill,
|
||||
saveRoadtripLimit,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
|
||||
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
|
||||
stayDraft, setStayDraft, setRoadtripStay,
|
||||
saveRoadtripLimit, roadtripSettingsLoading, storedAssignments,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, resetDayBoundaries,
|
||||
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, dayBoundaryControls,
|
||||
stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay,
|
||||
highlightedAlternative, setHighlightedAlternative,
|
||||
moveRoadtripStopToDay,
|
||||
dropPoiOnRoute,
|
||||
@@ -410,7 +410,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
dayPlaces={dayPlaces}
|
||||
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
|
||||
routeColors={roadtripActive ? roadtripLineColors : overviewActive ? tripOverview.lineColors : undefined}
|
||||
routeVias={roadtripActive ? roadtripRoutes.vias : routeVias}
|
||||
routeVias={roadtripActive ? roadtripMapVias : routeVias}
|
||||
dayBoundaryControls={roadtripActive ? dayBoundaryControls : undefined}
|
||||
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
|
||||
showTransitRoutes={transitRoutesShown}
|
||||
// The route toggle belongs to one day, so the map needs that day to
|
||||
@@ -568,13 +569,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<LazyPanel id="roadtrip-rail">
|
||||
<RoadtripSidebar
|
||||
routes={roadtripRoutes}
|
||||
onFocusPoint={focusRoadtripPoint}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)}
|
||||
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
|
||||
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
|
||||
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
|
||||
openAlternatives={routeAlternatives.open}
|
||||
onEditStay={can('place_edit', trip) ? setStayDraft : undefined}
|
||||
onEditStay={can('place_edit', trip) ? editRoadtripStay : undefined}
|
||||
onSetStopKind={can('place_edit', trip) ? setRoadtripStopKind : undefined}
|
||||
onSetStopFill={can('place_edit', trip) ? setRoadtripStopFill : undefined}
|
||||
onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined}
|
||||
@@ -595,7 +597,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
days={days}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
assignments={storedAssignments}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
@@ -703,6 +705,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
returns silently without the permission, which reads as a broken
|
||||
button rather than a missing one. */}
|
||||
<RoadtripCorridorPanel
|
||||
tripId={Number(tripId)} canImport={can('place_edit', trip) && can('day_edit', trip)}
|
||||
corridor={roadtripCorridor}
|
||||
routes={roadtripRoutes}
|
||||
onAddPoi={can('place_edit', trip) ? handlePoiClick : undefined}
|
||||
@@ -710,7 +713,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
{/* Under the search, because the limits are read while looking at
|
||||
what the drive is doing rather than set up front. */}
|
||||
<div className="px-3.5 pb-3.5">
|
||||
<RoadtripLimitsCard onSave={saveRoadtripLimit} />
|
||||
<RoadtripLimitsCard loading={roadtripSettingsLoading} onSave={saveRoadtripLimit} onResetDayBoundaries={resetDayBoundaries} />
|
||||
</div>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
@@ -794,6 +797,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
|
||||
{selectedPlace && !isMobile && (
|
||||
<PlaceInspector
|
||||
roadtripEndDay={roadtripEndDay}
|
||||
roadtripStay={roadtripStay} roadtripActive={roadtripActive}
|
||||
onEditTransport={openLinkedTransport}
|
||||
onEditReservation={openLinkedReservation}
|
||||
place={selectedPlace}
|
||||
@@ -836,6 +841,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<div className="bg-[rgba(0,0,0,0.3)]" style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 'var(--bottom-nav-h)' }} role="presentation" onClick={() => setSelectedPlaceId(null)}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh' }} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<PlaceInspector
|
||||
roadtripEndDay={roadtripEndDay}
|
||||
roadtripStay={roadtripStay} roadtripActive={roadtripActive}
|
||||
onEditTransport={openLinkedTransport}
|
||||
onEditReservation={openLinkedReservation}
|
||||
place={selectedPlace}
|
||||
@@ -888,7 +895,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-074
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
@@ -170,6 +171,7 @@ function makeActions() {
|
||||
moveAssignment: vi.fn(async () => undefined),
|
||||
removeAssignment: vi.fn(async () => undefined),
|
||||
reorderAssignments: vi.fn(async () => undefined),
|
||||
setAssignmentEndDay: vi.fn(async () => undefined),
|
||||
reorderDays: vi.fn(async () => undefined),
|
||||
insertDay: vi.fn(async () => undefined),
|
||||
updateDayTitle: vi.fn(async () => undefined),
|
||||
@@ -187,6 +189,23 @@ function wrapper({ children }: { children: React.ReactNode }) {
|
||||
|
||||
/** Mount with the addon on and the mode on, and wait until both have landed. */
|
||||
async function renderRoadtrip() {
|
||||
if (!rt.routes.days.length && rt.corridor.day) {
|
||||
const dayId = Number(rt.corridor.day.dayId)
|
||||
const assigned = useTripStore.getState().assignments[String(dayId)] ?? []
|
||||
const stops = assigned.length
|
||||
? assigned.filter(a => typeof a.place.lat === 'number' && typeof a.place.lng === 'number').map(a => ({
|
||||
assignmentId: a.id, placeId: a.place_id, lat: a.place.lat, lng: a.place.lng,
|
||||
}))
|
||||
: [0, 1, 2].map(i => ({ assignmentId: i + 1, placeId: i + 1, lat: 53 - i, lng: 10 + i }))
|
||||
rt.routes.days = [{ ...rt.corridor.day, stops }]
|
||||
}
|
||||
rt.routes.days = rt.routes.days.map(day => ({
|
||||
...day,
|
||||
stops: (day.stops as Array<Record<string, unknown>>).map((stop, i) => ({
|
||||
ownerDayId: day.dayId, ownerIndex: i, ...stop,
|
||||
})),
|
||||
}))
|
||||
if (rt.corridor.day) rt.corridor.day = rt.routes.days.find(d => d.dayId === rt.corridor.day?.dayId) ?? rt.corridor.day
|
||||
const rendered = renderHook(() => useTripPlanner(), { wrapper })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
await waitFor(() => expect(rendered.result.current.roadtripActive).toBe(true))
|
||||
@@ -311,6 +330,51 @@ afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('visit day-end controls', () => {
|
||||
const setup = async () => {
|
||||
const place = buildPlace({ id: 101 })
|
||||
seedTrip({ places: [place], days: [buildDay({ id: 5 })] })
|
||||
rt.routes.days = [{ dayId: 5, stops: [{ assignmentId: 11, placeId: 101, endDay: true }] }]
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' } }))
|
||||
const rendered = await renderRoadtrip()
|
||||
act(() => rendered.result.current.selectAssignment(11, 101))
|
||||
return rendered
|
||||
}
|
||||
|
||||
it('offers the selected visit and saves to its stored day', async () => {
|
||||
const { result } = await setup()
|
||||
expect(result.current.roadtripEndDay?.active).toBe(true)
|
||||
await act(async () => result.current.roadtripEndDay?.onToggle())
|
||||
expect(actions.setAssignmentEndDay).toHaveBeenCalledWith(42, 5, 11, false)
|
||||
})
|
||||
|
||||
it('hides the control when daily times are off and restores the stored choice when enabled', async () => {
|
||||
const { result } = await setup()
|
||||
act(() => useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_day_end: '' } })))
|
||||
expect(result.current.roadtripEndDay).toBeUndefined()
|
||||
act(() => useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_day_end: '18:00' } })))
|
||||
expect(result.current.roadtripEndDay?.active).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the control in Days mode and from a reader', async () => {
|
||||
const { result } = await setup()
|
||||
act(() => result.current.toggleRoadtripMode())
|
||||
expect(result.current.roadtripEndDay).toBeUndefined()
|
||||
act(() => {
|
||||
result.current.toggleRoadtripMode()
|
||||
asReader('day_edit')
|
||||
})
|
||||
expect(result.current.roadtripEndDay).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a rejected change', async () => {
|
||||
const { result } = await setup()
|
||||
actions.setAssignmentEndDay.mockRejectedValueOnce(new Error('Save failed'))
|
||||
await act(async () => result.current.roadtripEndDay?.onToggle())
|
||||
expect(toasts.some(t => t.type === 'error')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner road trip: a hit on the drive', () => {
|
||||
it('FE-TP-ROAD-001: a corridor hit opens the popup on the day being searched, at its place in the chain', async () => {
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
|
||||
@@ -796,15 +860,15 @@ describe('useTripPlanner road trip: one stop at a time', () => {
|
||||
|
||||
it('FE-TP-ROAD-033: a driving limit goes straight to the settings, and a failure is said', async () => {
|
||||
oneStop()
|
||||
const updateSettings = vi.fn(async () => {})
|
||||
useSettingsStore.setState({ updateSettings } as never)
|
||||
const updateSettings = vi.mocked(roadtripPreferencesRepo.update)
|
||||
updateSettings.mockResolvedValue({})
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_leg_minutes', 180) })
|
||||
expect(updateSettings).toHaveBeenCalledWith({ roadtrip_max_leg_minutes: 180 })
|
||||
await act(async () => { await result.current.saveRoadtripLimit?.('roadtrip_leg_minutes', 180) })
|
||||
expect(updateSettings).toHaveBeenCalledWith(42, { roadtrip_leg_minutes: 180 })
|
||||
|
||||
updateSettings.mockRejectedValue(new Error('nope'))
|
||||
await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_leg_minutes', 90) })
|
||||
await act(async () => { await result.current.saveRoadtripLimit?.('roadtrip_leg_minutes', 90) })
|
||||
expect(toasts.some(t => t.type === 'error')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1429,6 +1493,7 @@ describe('useTripPlanner road trip: somewhere to fill up', () => {
|
||||
stops: [drawn(1201, 53, 10, 5, 1), drawn(1202, 52, 10, 5, 2), drawn(1203, 51, 10, 6, 0)],
|
||||
geometry: [[53, 10], [52, 10], [51, 10]],
|
||||
drivingGeometry: [[53, 10], [52, 10], [51, 10]],
|
||||
legs: [{ mode: 'driving', distance: 111000 }, { mode: 'driving', distance: 111000 }],
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -1536,3 +1601,36 @@ describe('useTripPlanner road trip: somewhere to fill up', () => {
|
||||
expect(result.current.stopDraft).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||
useLoadRoadtripSettings: () => ({ ready: true, failed: false }),
|
||||
}))
|
||||
vi.mock('../../repo/roadtripPreferencesRepo', () => ({ roadtripPreferencesRepo: { update: vi.fn(async () => ({})) } }))
|
||||
|
||||
it('keeps exclusive service stops in Roadtrip, hides them in Days and restores them without duplicates', async () => {
|
||||
const normal = buildPlace({ id: 101, name: 'Berlin' })
|
||||
const charging = buildPlace({ id: 102, name: 'Charger', stop_type: 'charging' })
|
||||
const visits = [buildAssignment({ id: 11, day_id: 5, place: normal }), buildAssignment({ id: 12, day_id: 5, place: charging })]
|
||||
seedTrip({ places: [normal, charging], days: [buildDay({ id: 5 })], assignments: { '5': visits } })
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_service_stops_in_days: false } }))
|
||||
const { result } = await renderRoadtrip()
|
||||
expect(result.current.assignments['5']).toHaveLength(2)
|
||||
act(() => result.current.toggleRoadtripMode())
|
||||
expect(result.current.assignments['5'].map(a => a.id)).toEqual([11])
|
||||
expect(result.current.places.map(p => p.id)).toEqual([101])
|
||||
expect(useTripStore.getState().assignments['5']).toHaveLength(2)
|
||||
act(() => useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_service_stops_in_days: true } })))
|
||||
expect(result.current.assignments['5']).toHaveLength(2)
|
||||
expect(result.current.places).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('preserves exclusive service stops when reordering the visible Days stops', async () => {
|
||||
const visits = [stopAt(11, 5, 0), stopAt(12, 5, 1, { place: buildPlace({ stop_type: 'charging' }) }), stopAt(13, 5, 2)]
|
||||
seedTrip({ days: [buildDay({ id: 5 })], assignments: { '5': visits } })
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_service_stops_in_days: false } }))
|
||||
const { result } = await renderRoadtrip()
|
||||
act(() => result.current.toggleRoadtripMode())
|
||||
await act(async () => result.current.handleReorder(5, [13, 11]))
|
||||
expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 5, [13, 12, 11])
|
||||
})
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useLoadRoadtripSettings, useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
|
||||
import { publishRoadtripPreferences } from '../../store/roadtripPreferencesStore'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -22,8 +25,12 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { useResizablePanels } from '../../hooks/useResizablePanels'
|
||||
import { useTripWebSocket } from '../../hooks/useTripWebSocket'
|
||||
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||
import { useRoadtripRoutes, type RoadtripStop } from '../../components/Roadtrip/useRoadtripRoutes'
|
||||
import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDayPoints'
|
||||
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
|
||||
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
|
||||
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
|
||||
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
||||
import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes'
|
||||
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
||||
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
|
||||
@@ -31,6 +38,7 @@ import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion
|
||||
import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack'
|
||||
import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternatives'
|
||||
import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
|
||||
import { stopArrival } from '../../components/Roadtrip/stopArrival'
|
||||
import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois'
|
||||
import { projectOntoRoute, type LatLng } from '../../components/Roadtrip/corridor'
|
||||
import {
|
||||
@@ -38,7 +46,7 @@ import {
|
||||
reanchorAfterInsert,
|
||||
reanchorAfterRemove,
|
||||
reanchorByStopOrder,
|
||||
reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel'
|
||||
isServiceStopType, refuelStopTypeFor, reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel'
|
||||
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
@@ -46,7 +54,7 @@ import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import { useIsTouch } from '../../hooks/useIsTouch'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation, Settings } from '../../types'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
||||
@@ -79,15 +87,16 @@ export function useTripPlanner() {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { settings, updateSettings } = useSettingsStore()
|
||||
const { settings } = useSettingsStore()
|
||||
const roadtripSettings = useRoadtripSettings(s => s, tripId)
|
||||
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
|
||||
const allPlugins = usePluginStore(s => s.plugins)
|
||||
const pluginsLoaded = usePluginStore(s => s.loaded)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const days = useTripStore(s => s.days)
|
||||
const places = useTripStore(s => s.places)
|
||||
const assignments = useTripStore(s => s.assignments)
|
||||
const allPlaces = useTripStore(s => s.places)
|
||||
const storedAssignments = useTripStore(s => s.assignments)
|
||||
const packingItems = useTripStore(s => s.packingItems)
|
||||
const todoItems = useTripStore(s => s.todoItems)
|
||||
const categories = useTripStore(s => s.categories)
|
||||
@@ -118,6 +127,10 @@ export function useTripPlanner() {
|
||||
// someone planning a road trip stays in it across reloads without it leaking into
|
||||
// their next, non-driving trip.
|
||||
const [roadtripMode, setRoadtripMode] = useState<boolean>(() => sessionStorage.getItem(`trip-roadtrip-${tripId}`) === '1')
|
||||
const assignments = useMemo(() => roadtripMode || roadtripSettings.roadtrip_service_stops_in_days !== false ? storedAssignments : Object.fromEntries(
|
||||
Object.entries(storedAssignments).map(([dayId, visits]) => [dayId, visits.filter(visit => !isServiceStopType(visit.place?.stop_type))]),
|
||||
), [roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, storedAssignments])
|
||||
const places = useMemo(() => roadtripMode || roadtripSettings.roadtrip_service_stops_in_days !== false ? allPlaces : allPlaces.filter(place => !isServiceStopType(place.stop_type)), [roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, allPlaces])
|
||||
const toggleRoadtripMode = useCallback(() => {
|
||||
setRoadtripMode(prev => {
|
||||
const next = !prev
|
||||
@@ -556,14 +569,28 @@ export function useTripPlanner() {
|
||||
// Road trip mode reads the whole trip, not the selected day, so it owns its own legs.
|
||||
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
|
||||
const roadtripActive = !!enabledAddons.roadtrip && roadtripMode
|
||||
const roadtripPreferencesState = useLoadRoadtripSettings(tripId, !!enabledAddons.roadtrip)
|
||||
useEffect(() => {
|
||||
if (roadtripPreferencesState.failed) toast.error(t('common.error'))
|
||||
}, [roadtripPreferencesState.failed, toast, t])
|
||||
const dailyTimesActive = !!dayWindow(roadtripSettings.roadtrip_day_start, roadtripSettings.roadtrip_day_end)
|
||||
const dayBoundaries = useDayBoundaries(tripId, roadtripActive && dailyTimesActive, assignments)
|
||||
useEffect(() => { if (dayBoundaries.stale) toast.error(t('trip.toast.loadError')) }, [dayBoundaries.stale, toast, t])
|
||||
const resetDayBoundaries = dayBoundaries.editable && dayBoundaries.boundaries.length && can('day_edit', trip) ? async () => {
|
||||
try {
|
||||
for (const boundary of dayBoundaries.boundaries) await dayBoundaries.save(boundary.day_number, null)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
} : undefined
|
||||
const roadtripVias = useRoadtripVias(tripId, roadtripActive)
|
||||
const refuel = useRefuelSearch()
|
||||
const roadtripRoutes = useRoadtripRoutes(
|
||||
tripId,
|
||||
roadtripActive ? days : EMPTY_DAYS,
|
||||
roadtripActive && roadtripPreferencesState.ready ? days : EMPTY_DAYS,
|
||||
assignments,
|
||||
routeProfile,
|
||||
roadtripVias.byDay,
|
||||
dayBoundaries.boundaries,
|
||||
tripAccommodations,
|
||||
)
|
||||
// Lives here rather than in the panel because the map draws what it finds.
|
||||
const roadtripCorridor = useRoadtripCorridor(roadtripRoutes)
|
||||
@@ -723,20 +750,25 @@ export function useTripPlanner() {
|
||||
// the chain in driving order instead of being dragged there afterwards.
|
||||
const hit = roadtripActive && 'alongKm' in poi ? (poi as unknown as CorridorPoi) : null
|
||||
if (hit && roadtripDayId != null) {
|
||||
const displayed = roadtripRoutes.days.find(d => d.dayId === roadtripDayId)
|
||||
const insert = displayed && roadtripInsertion(displayed, roadtripInsertIndexFor(hit))
|
||||
if (!insert) return
|
||||
setStopDraft({
|
||||
poi: hit,
|
||||
dayId: roadtripDayId,
|
||||
position: roadtripInsertIndexFor(hit),
|
||||
arrivalTime: displayed ? stopArrival(displayed, roadtripInsertIndexFor(hit), hit.alongKm) : null,
|
||||
...insert,
|
||||
dayNumber: roadtripDayNumber,
|
||||
// Only for a hit somebody could sleep at, and it is what gives the popup its
|
||||
// second mode. The check-out options are the days from this one on in travel
|
||||
// order; the default is the next one, which is what a night usually means.
|
||||
...(isOvernightCategory(hit.category) ? { overnight: overnightOptions(roadtripDayId) } : {}),
|
||||
...(isOvernightCategory(hit.category) ? { overnight: overnightOptions(insert.dayId) } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
openAddPlaceFromPoi(poi, roadtripActive ? roadtripDayId : undefined)
|
||||
}, [openAddPlaceFromPoi, roadtripActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, overnightOptions, can, trip])
|
||||
const selected = roadtripRoutes.days.find(d => d.dayId === roadtripDayId)
|
||||
const target = selected && roadtripInsertion(selected, selected.stops.length)
|
||||
openAddPlaceFromPoi(poi, roadtripActive ? target?.dayId ?? roadtripDayId : undefined)
|
||||
}, [openAddPlaceFromPoi, roadtripActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, roadtripRoutes.days, overnightOptions, can, trip])
|
||||
|
||||
/**
|
||||
* The stops of a day as the road trip counts them, in the order it drives them.
|
||||
@@ -846,38 +878,52 @@ export function useTripPlanner() {
|
||||
const hidden = new Set<number>()
|
||||
for (const day of roadtripRoutes.days) {
|
||||
if (!collapsedRoadtripDays.has(day.dayId)) continue
|
||||
for (const stop of day.stops) hidden.add(stop.placeId)
|
||||
for (const stop of day.stops) if (!stop.automaticNight) hidden.add(stop.placeId)
|
||||
}
|
||||
for (const day of roadtripRoutes.days) {
|
||||
if (collapsedRoadtripDays.has(day.dayId)) continue
|
||||
for (const stop of day.stops) hidden.delete(stop.placeId)
|
||||
for (const stop of day.stops) if (!stop.automaticNight) hidden.delete(stop.placeId)
|
||||
}
|
||||
return mapPlaces.filter(p => !hidden.has(p.id))
|
||||
}, [mapPlaces, roadtripRoutes.days, collapsedRoadtripDays])
|
||||
|
||||
const roadtripLineColors = useMemo(
|
||||
() => {
|
||||
if (!settings.roadtrip_day_colors) return undefined
|
||||
if (!roadtripSettings.roadtrip_day_colors) return undefined
|
||||
const hidden = new Set(
|
||||
roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber),
|
||||
)
|
||||
return roadtripRoutes.lineDays.filter(n => !hidden.has(n)).map(n => dayColor(n))
|
||||
},
|
||||
[settings.roadtrip_day_colors, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays],
|
||||
[roadtripSettings.roadtrip_day_colors, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays],
|
||||
)
|
||||
|
||||
const saveRoadtripLimit = useCallback(async (key: string, value: number | string | boolean) => {
|
||||
try {
|
||||
await updateSettings({ [key]: value } as Partial<Settings>)
|
||||
if (!can('day_edit', trip) || !roadtripPreferencesState.ready) return
|
||||
const userId = useAuthStore.getState().user?.id
|
||||
if (!userId) return
|
||||
const preferences = await roadtripPreferencesRepo.update(tripId, { [key]: value })
|
||||
publishRoadtripPreferences(userId, tripId, preferences)
|
||||
} catch {
|
||||
toast.error(t('places.saveError'))
|
||||
}
|
||||
}, [updateSettings, toast, t])
|
||||
}, [tripId, trip, can, roadtripPreferencesState.ready, toast, t])
|
||||
|
||||
const saveStopDraft = useCallback(async ({ stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number }) => {
|
||||
if (!stopDraft) return
|
||||
const { poi, dayId, position } = stopDraft
|
||||
try {
|
||||
if (stopDraft.editing) {
|
||||
await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: stopType, duration_minutes: dwellMinutes })
|
||||
if (stopDraft.editing.accommodationId) {
|
||||
await accommodationsApi.delete(tripId, stopDraft.editing.accommodationId)
|
||||
await loadAccommodations()
|
||||
}
|
||||
updateRouteForDay(dayId)
|
||||
setStopDraft(null)
|
||||
return
|
||||
}
|
||||
const place = await tripActions.addPlace(tripId, {
|
||||
name: poi.name,
|
||||
lat: poi.lat,
|
||||
@@ -910,19 +956,8 @@ export function useTripPlanner() {
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore])
|
||||
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations])
|
||||
|
||||
/**
|
||||
* Saves a corridor hit as somewhere to sleep rather than as a pause on the drive.
|
||||
*
|
||||
* Three writes and deliberately not one transaction: the place, its day, and the row in
|
||||
* day_accommodations. A hotel keeps its number in the chain — it is where the day ends,
|
||||
* not something the drive passes through — so it gets no `stop_type` and no dwell.
|
||||
*
|
||||
* The check-out day is the traveller's answer, defaulting to the day after. Both times
|
||||
* are optional and go through as empty when nobody filled them in: a hotel found on a
|
||||
* map has no idea when its reception opens, and the server stores null.
|
||||
*/
|
||||
const saveStopDraftAsNight = useCallback(async ({ endDayId, checkIn, checkOut }: {
|
||||
endDayId: number
|
||||
checkIn: string
|
||||
@@ -931,6 +966,16 @@ export function useTripPlanner() {
|
||||
if (!stopDraft) return
|
||||
const { poi, dayId, position } = stopDraft
|
||||
try {
|
||||
if (stopDraft.editing) {
|
||||
const booking = { place_id: stopDraft.editing.placeId, start_day_id: dayId, end_day_id: endDayId, check_in: checkIn || null, check_out: checkOut || null }
|
||||
if (stopDraft.editing.accommodationId) await accommodationsApi.update(tripId, stopDraft.editing.accommodationId, booking)
|
||||
else await accommodationsApi.create(tripId, booking)
|
||||
await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel' })
|
||||
await loadAccommodations()
|
||||
updateRouteForDay(dayId)
|
||||
setStopDraft(null)
|
||||
return
|
||||
}
|
||||
const place = await tripActions.addPlace(tripId, {
|
||||
name: poi.name,
|
||||
lat: poi.lat,
|
||||
@@ -939,6 +984,7 @@ export function useTripPlanner() {
|
||||
website: poi.website || undefined,
|
||||
phone: poi.phone || undefined,
|
||||
osm_id: poi.osm_id,
|
||||
stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel',
|
||||
})
|
||||
if (place?.id) {
|
||||
const plan = reanchorAfterInsert(
|
||||
@@ -1060,6 +1106,20 @@ export function useTripPlanner() {
|
||||
*/
|
||||
const [stayDraft, setStayDraft] = useState<{ placeId: number; name: string; minutes: number | null; arrival: string | null } | null>(null)
|
||||
|
||||
const setRoadtripEndDay = useCallback(async (stop: RoadtripStop) => {
|
||||
if (!dailyTimesActive || !can('day_edit', trip)) return
|
||||
try {
|
||||
const manual = dayBoundaries.boundaries.find(b => b.to_assignment_id === null && b.from_assignment_id === stop.assignmentId)
|
||||
if (manual) {
|
||||
await dayBoundaries.save(manual.day_number, null)
|
||||
if (!stop.endDay) return
|
||||
}
|
||||
await tripActions.setAssignmentEndDay(tripId, stop.ownerDayId, stop.assignmentId, !stop.endDay)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [dailyTimesActive, can, trip, tripActions, tripId, toast, t, dayBoundaries.boundaries, dayBoundaries.save])
|
||||
|
||||
/**
|
||||
* How long the traveller stays at one stop.
|
||||
*
|
||||
@@ -1193,11 +1253,33 @@ export function useTripPlanner() {
|
||||
* They are the newer, smaller and more specific answer, so they take the view from the
|
||||
* alternatives rather than being averaged with them into a frame that shows neither.
|
||||
*/
|
||||
const automaticPoints = useAutomaticDayPoints(roadtripRoutes, collapsedRoadtripDays)
|
||||
const focusRoadtripPoint = useCallback((lat: number, lng: number) => {
|
||||
refuel.close()
|
||||
routeAlternatives.close()
|
||||
automaticPoints.focusPoint(lat, lng)
|
||||
}, [refuel, routeAlternatives, automaticPoints])
|
||||
const roadtripMapVias = automaticPoints.markers
|
||||
const dayBoundaryControls = useMemo<DayBoundaryControls | undefined>(() => {
|
||||
if (!dayBoundaries.editable || !can('day_edit', trip) || !roadtripRoutes.boundaryPath?.length) return undefined
|
||||
return {
|
||||
path: roadtripRoutes.boundaryPath,
|
||||
hint: t('roadtrip.window.dragHint'),
|
||||
move: async (day, boundary) => {
|
||||
const next = dayBoundaries.boundaries.filter(b => b.day_number !== day)
|
||||
if (boundary) next.push(boundary)
|
||||
const issue = roadtripRoutes.validateBoundaries?.(next)
|
||||
if (boundary && issue) { toast.error(t(`roadtrip.window.${issue}`)); return false }
|
||||
try { return await dayBoundaries.save(day, boundary) }
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')); return false }
|
||||
},
|
||||
}
|
||||
}, [dayBoundaries.editable, dayBoundaries.boundaries, dayBoundaries.save, can, trip, roadtripRoutes, t, toast])
|
||||
const mapFocusPoints = useMemo<[number, number][]>(
|
||||
() => (refuel.offered.length
|
||||
? refuel.offered.map(p => [p.lat, p.lng] as [number, number])
|
||||
: alternativeFocusPoints),
|
||||
[refuel.offered, alternativeFocusPoints],
|
||||
: alternativeFocusPoints.length ? alternativeFocusPoints : automaticPoints.focusPoints ?? alternativeFocusPoints),
|
||||
[refuel.offered, alternativeFocusPoints, automaticPoints.focusPoints],
|
||||
)
|
||||
|
||||
/** Asks the router for other ways of driving one leg of one day. */
|
||||
@@ -1212,8 +1294,8 @@ export function useTripPlanner() {
|
||||
}
|
||||
// The vias on THIS leg, so the road currently driven is offered alongside the
|
||||
// router's own suggestions rather than being missing from its own picker.
|
||||
const dayIndex = day.stops.indexOf(from)
|
||||
const legVias = (roadtripVias.byDay[dayId] ?? [])
|
||||
const dayIndex = from.ownerIndex
|
||||
const legVias = (roadtripVias.byDay[from.ownerDayId] ?? [])
|
||||
.filter(v => v.after_order_index === dayIndex)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
routeAlternatives.ask(dayId, legIndex, from, to, routeProfile, legVias)
|
||||
@@ -1237,7 +1319,7 @@ export function useTripPlanner() {
|
||||
if (alt.direct || !alt.divergence) {
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === open.dayId)
|
||||
const stop = day?.stops[open.index]
|
||||
const dayIndex = stop && day ? day.stops.indexOf(stop) : -1
|
||||
const dayIndex = stop?.ownerIndex ?? -1
|
||||
// Clearing the leg in one write. One delete per via meant a full trip re-route
|
||||
// between each of them, so undoing a detour with three vias drew three routes.
|
||||
//
|
||||
@@ -1247,7 +1329,7 @@ export function useTripPlanner() {
|
||||
// request failed, not even the reload ran to contradict them.
|
||||
if (dayIndex >= 0) {
|
||||
try {
|
||||
await roadtripVias.addMany(open.dayId, [], [dayIndex])
|
||||
await roadtripVias.addMany(stop!.ownerDayId, [], [dayIndex])
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
return
|
||||
@@ -1382,16 +1464,26 @@ export function useTripPlanner() {
|
||||
const askRefuel = useCallback((dayId: number, dry: DryPoint & { lat: number; lng: number }) => {
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
||||
if (!day) return
|
||||
const line = (day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng }))
|
||||
const line = (dry.inboundLine ?? day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng }))
|
||||
if (line.length < 2) return
|
||||
const vehicle = roadtripSettings.roadtrip_vehicle
|
||||
const refuelTypes = refuelStopTypeFor(vehicle === 'electric' || vehicle === 'combustion' ? vehicle : null)
|
||||
let fromAlongKm = 0
|
||||
let drivenKm = 0
|
||||
for (let i = 0; i <= dry.legIndex; i++) {
|
||||
if (refuelTypes.includes(day.stops[i]?.stopType as 'fuel' | 'charging')) fromAlongKm = drivenKm
|
||||
const leg = day.legs[i]
|
||||
if (leg?.mode === 'driving') drivenKm += (leg.distance ?? 0) / 1000
|
||||
}
|
||||
void refuel.ask(
|
||||
`${dayId}:${dry.legIndex}`,
|
||||
{ lat: dry.lat, lng: dry.lng },
|
||||
line,
|
||||
dry.drivenMeters / 1000,
|
||||
day.stops.map(stop => ({ lat: stop.lat, lng: stop.lng })),
|
||||
fromAlongKm,
|
||||
)
|
||||
}, [roadtripRoutes.days, refuel])
|
||||
}, [roadtripRoutes.days, refuel, roadtripSettings.roadtrip_vehicle])
|
||||
|
||||
/**
|
||||
* Accepting one hands it to the same popup a corridor hit goes through.
|
||||
@@ -1409,7 +1501,13 @@ export function useTripPlanner() {
|
||||
// Before the stop the tank would have run out on, which is the leg the dry point
|
||||
// names. A station reached after the day's last stop is tomorrow's problem, and
|
||||
// clamping it onto the final leg would re-route the arrival through it.
|
||||
const at = Math.min(dry.legIndex + 1, day.stops.length - 1)
|
||||
let drivenKm = 0
|
||||
const stationLeg = day.legs.findIndex(leg => {
|
||||
if (leg?.mode !== 'driving') return false
|
||||
drivenKm += (leg.distance ?? 0) / 1000
|
||||
return poi.alongKm <= drivenKm
|
||||
})
|
||||
const at = dry.inboundLine ? -dry.legIndex - 1 : Math.min((stationLeg >= 0 ? stationLeg : dry.legIndex) + 1, day.stops.length - 1)
|
||||
// That index counts along the CARD, and after a night drive a card is not one stored
|
||||
// day: its first stops belong to yesterday. The new stop goes in front of the one it
|
||||
// was measured against, so it is that stop's own day and position that place it —
|
||||
@@ -1418,7 +1516,7 @@ export function useTripPlanner() {
|
||||
const anchor = day.stops[at]
|
||||
if (!anchor) return
|
||||
refuel.close()
|
||||
setStopDraft({ poi, dayId: anchor.ownerDayId, position: anchor.ownerIndex, dayNumber: day.dayNumber })
|
||||
setStopDraft({ poi, ...roadtripInsertion(day, at)!, dayNumber: day.dayNumber })
|
||||
}, [roadtripRoutes.days, refuel, can, trip])
|
||||
|
||||
/** Removing a via lets the drive take the direct road again. */
|
||||
@@ -1451,10 +1549,11 @@ export function useTripPlanner() {
|
||||
if (!hit || !day) return
|
||||
const at = projectOntoRoute({ lat, lng }, roadtripCorridor.search.spine)
|
||||
if (!at || at.offRouteKm > roadtripCorridor.widthKm) return
|
||||
const insert = roadtripInsertion(day, insertIndexForAlong(roadtripCorridor.stopsAlongKm, at.alongKm))
|
||||
if (!insert) return
|
||||
setStopDraft({
|
||||
poi: hit,
|
||||
dayId: day.dayId,
|
||||
position: insertIndexForAlong(roadtripCorridor.stopsAlongKm, at.alongKm),
|
||||
...insert,
|
||||
dayNumber: day.dayNumber,
|
||||
})
|
||||
}, [roadtripCorridor, can, trip])
|
||||
@@ -1464,11 +1563,20 @@ export function useTripPlanner() {
|
||||
if (!stopDraft) return
|
||||
const { poi, dayId, position } = stopDraft
|
||||
setStopDraft(null)
|
||||
if (stopDraft.editing) {
|
||||
const place = places.find(place => place.id === stopDraft.editing?.placeId)
|
||||
if (place) {
|
||||
setEditingPlace(place)
|
||||
setEditingAssignmentId(resolvePoolAssignmentId(assignments, place.id))
|
||||
setShowPlaceForm(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Carries the kind and the dwell the popup had already worked out. Leaving them
|
||||
// behind is what turned a fuel stop into a numbered destination on the way to the
|
||||
// full form, silently and in every total.
|
||||
openAddPlaceFromPoi(poi, dayId, position, stop ?? null)
|
||||
}, [stopDraft, openAddPlaceFromPoi])
|
||||
}, [stopDraft, openAddPlaceFromPoi, places, assignments])
|
||||
|
||||
/**
|
||||
* A place on this trip that came from the same OSM object.
|
||||
@@ -1477,7 +1585,7 @@ export function useTripPlanner() {
|
||||
* would be the quickest way to add one petrol station twice.
|
||||
*/
|
||||
const stopDraftDuplicate = useMemo(() => {
|
||||
if (!stopDraft?.poi.osm_id) return null
|
||||
if (stopDraft?.editing || !stopDraft?.poi.osm_id) return null
|
||||
return places.find(p => p.osm_id === stopDraft.poi.osm_id)?.name ?? null
|
||||
}, [stopDraft, places])
|
||||
|
||||
@@ -1553,11 +1661,38 @@ export function useTripPlanner() {
|
||||
// place's lone assignment to hydrate & persist its times; with 0 or 2+
|
||||
// assignments the time is ambiguous and the modal hides the fields (#1247).
|
||||
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
|
||||
if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') {
|
||||
const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)
|
||||
const entry = Object.entries(assignments).find(([, visits]) => visits.some(visit => visit.id === visitId))
|
||||
if (entry) {
|
||||
const dayId = Number(entry[0])
|
||||
const visit = entry[1].find(visit => visit.id === visitId)!
|
||||
const stay = tripAccommodations.find(stay => stay.place_id === place.id && stay.start_day_id === dayId)
|
||||
const category = place.stop_type ?? (stay ? 'hotel' : '')
|
||||
const routedDay = roadtripRoutes.days.find(day => day.stops.some(stop => stop.assignmentId === visitId))
|
||||
const arrivalTime = routedDay?.schedule.entries[routedDay.stops.findIndex(stop => stop.assignmentId === visitId)]?.arrival ?? null
|
||||
setStopDraft({
|
||||
poi: { osm_id: place.osm_id ?? '', name: place.name, lat: place.lat, lng: place.lng, category, poi_type: category, address: place.address ?? null, website: place.website ?? null, phone: place.phone ?? null, opening_hours: null, cuisine: null, source: 'trek', offRouteKm: 0, alongKm: 0 },
|
||||
arrivalTime, dayId, dayNumber: days.find(day => day.id === dayId)?.day_number ?? 0, position: visit.order_index ?? 0,
|
||||
editing: { placeId: place.id, stopType: place.stop_type ?? (stay ? 'hotel' : null), dwellMinutes: place.duration_minutes ?? 30, accommodationId: stay?.id, checkIn: stay?.check_in ?? '', checkOut: stay?.check_out ?? '' },
|
||||
...(isOvernightCategory(category) ? { overnight: { ...overnightOptions(dayId), ...(stay ? { defaultEndDayId: stay.end_day_id } : {}) } } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
setEditingPlace(place)
|
||||
setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id))
|
||||
setPlaceFormDayId(null)
|
||||
setShowPlaceForm(true)
|
||||
}, [assignments])
|
||||
}, [assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
|
||||
|
||||
const editRoadtripStay = useCallback((draft: NonNullable<typeof stayDraft>) => {
|
||||
const stay = tripAccommodations.find(stay => stay.place_id === draft.placeId && stay.check_out)
|
||||
const place = places.find(place => place.id === draft.placeId)
|
||||
const visit = stay && assignments[String(stay.start_day_id)]?.find(visit => visit.place_id === draft.placeId)
|
||||
if (stay && place && visit) openPlaceEditor(place, visit.id)
|
||||
else setStayDraft(draft)
|
||||
}, [tripAccommodations, places, assignments, openPlaceEditor])
|
||||
|
||||
const handleDeletePlace = useCallback((placeId) => {
|
||||
setDeletePlaceId(placeId)
|
||||
@@ -1729,11 +1864,14 @@ export function useTripPlanner() {
|
||||
// pinned behind instead of being shifted arithmetically. Without this the
|
||||
// day's detours stayed on their old numbers and the drive quietly took a
|
||||
// different road, persisted and visible to every collaborator.
|
||||
const visible = new Set(orderedIds)
|
||||
let nextVisible = 0
|
||||
const completeOrder = prevIds.map(id => visible.has(id) ? orderedIds[nextVisible++] : id)
|
||||
const stopIdsBefore = roadtripStopsOf(dayId).map(a => a.id)
|
||||
const stopIdsAfter = orderedIds.filter(id => stopIdsBefore.includes(id))
|
||||
const stopIdsAfter = completeOrder.filter(id => stopIdsBefore.includes(id))
|
||||
const plan = reanchorByStopOrder(roadtripVias.byDay[dayId] ?? [], stopIdsBefore, stopIdsAfter)
|
||||
try {
|
||||
tripActions.reorderAssignments(tripId, dayId, orderedIds)
|
||||
tripActions.reorderAssignments(tripId, dayId, completeOrder)
|
||||
.then(async () => {
|
||||
if (plan.vias.length || plan.remove.length) await roadtripVias.reanchor(dayId, plan)
|
||||
const capturedDayId = dayId
|
||||
@@ -1984,6 +2122,16 @@ export function useTripPlanner() {
|
||||
}
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
const selectedRoadtripStops = roadtripRoutes.days.flatMap(day => day.stops).filter(stop =>
|
||||
!stop.automaticNight && (selectedAssignmentId ? stop.assignmentId === selectedAssignmentId : stop.placeId === selectedPlaceId),
|
||||
)
|
||||
const endDayStop = selectedRoadtripStops.length === 1 ? selectedRoadtripStops[0] : undefined
|
||||
const roadtripEndDay = roadtripActive && dailyTimesActive && can('day_edit', trip) && endDayStop && endDayStop.assignmentId > 0
|
||||
? { active: !!endDayStop.endDay || dayBoundaries.boundaries.some(b => b.to_assignment_id === null && b.from_assignment_id === endDayStop.assignmentId), onToggle: () => setRoadtripEndDay(endDayStop) }
|
||||
: undefined
|
||||
const roadtripStay = roadtripActive && selectedPlace
|
||||
? { minutes: endDayStop ? endDayStop.dwellMinutes : selectedPlace.duration_minutes ?? null, onEdit: can('place_edit', trip) ? () => editRoadtripStay({ placeId: selectedPlace.id, name: selectedPlace.name, minutes: selectedPlace.duration_minutes ?? null, arrival: null }) : undefined }
|
||||
: undefined
|
||||
|
||||
// Build placeId → order-number map from the selected day's assignments
|
||||
const dayOrderMap = useMemo(() => {
|
||||
@@ -2021,7 +2169,7 @@ export function useTripPlanner() {
|
||||
|
||||
return {
|
||||
tripId, navigate, toast, t, language, settings, placesPhotosEnabled,
|
||||
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
|
||||
trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
@@ -2041,11 +2189,12 @@ export function useTripPlanner() {
|
||||
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
|
||||
setRoadtripStopKind,
|
||||
setRoadtripStopFill,
|
||||
saveRoadtripLimit,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
|
||||
roadtripSettingsLoading: !roadtripPreferencesState.ready && !roadtripPreferencesState.failed,
|
||||
saveRoadtripLimit: roadtripPreferencesState.ready && can('day_edit', trip) ? saveRoadtripLimit : undefined,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, dayBoundaryControls, resetDayBoundaries,
|
||||
refuel, askRefuel, acceptRefuel,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
|
||||
stayDraft, setStayDraft, setRoadtripStay,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint,
|
||||
stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay,
|
||||
highlightedAlternative, setHighlightedAlternative,
|
||||
moveRoadtripStopToDay,
|
||||
dropPoiOnRoute,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'fake-indexeddb/auto'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { assignmentRepo } from './assignmentRepo'
|
||||
import { offlineDb, clearAll } from '../db/offlineDb'
|
||||
import { saveAssignmentEndDay } from '../api/assignmentEndDay'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { mutationQueue } from '../sync/mutationQueue'
|
||||
import { apiClient } from '../api/client'
|
||||
import type { Assignment, Day } from '../types'
|
||||
|
||||
vi.mock('../api/assignmentEndDay', () => ({ saveAssignmentEndDay: vi.fn() }))
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => true) }))
|
||||
vi.mock('../sync/authGate', () => ({ isAuthed: () => true }))
|
||||
vi.mock('../api/client', () => ({ apiClient: { request: vi.fn() } }))
|
||||
const assignment = { id: 7, day_id: 1, place_id: 2, order_index: 0, assignment_time: '07:00', place: { id: 2, name: 'Berlin' } } as Assignment
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll()
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true)
|
||||
await offlineDb.days.put({ id: 1, trip_id: 9, day_number: 1, assignments: [assignment] } as Day)
|
||||
})
|
||||
|
||||
describe('assignment day-end persistence', () => {
|
||||
it('stores the offline flag with a replayable write and preserves manual time', async () => {
|
||||
await assignmentRepo.setEndDay(9, assignment, true)
|
||||
expect((await offlineDb.days.get(1))?.assignments?.[0]).toMatchObject({ end_day: true, assignment_time: '07:00' })
|
||||
expect(await offlineDb.mutationQueue.toArray()).toEqual([expect.objectContaining({
|
||||
url: '/trips/9/assignments/7/end-day', method: 'PUT', body: { end_day: true }, resource: 'assignments',
|
||||
})])
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
vi.mocked(apiClient.request).mockResolvedValue({ data: { assignment: { ...assignment, end_day: true } } })
|
||||
await mutationQueue.flush()
|
||||
expect(await offlineDb.mutationQueue.count()).toBe(0)
|
||||
expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBe(true)
|
||||
})
|
||||
|
||||
it('saves and clears through the online API', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
vi.mocked(saveAssignmentEndDay).mockResolvedValue({ ...assignment, end_day: false })
|
||||
await assignmentRepo.setEndDay(9, assignment, false)
|
||||
expect(saveAssignmentEndDay).toHaveBeenCalledWith(9, 7, { end_day: false })
|
||||
expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the cached visit unchanged after a rejected save', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
vi.mocked(saveAssignmentEndDay).mockRejectedValue(new Error('Denied'))
|
||||
await expect(assignmentRepo.setEndDay(9, assignment, true)).rejects.toThrow('Denied')
|
||||
expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { saveAssignmentEndDay } from '../api/assignmentEndDay'
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { cacheAssignment } from '../db/cacheAssignment'
|
||||
import { generateUUID, mutationQueue } from '../sync/mutationQueue'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import type { Assignment } from '../types'
|
||||
|
||||
export const assignmentRepo = {
|
||||
async setEndDay(tripId: number | string, assignment: Assignment, endDay: boolean): Promise<Assignment> {
|
||||
if (!isEffectivelyOffline()) {
|
||||
const saved = await saveAssignmentEndDay(tripId, assignment.id, { end_day: endDay })
|
||||
await cacheAssignment(saved)
|
||||
return saved
|
||||
}
|
||||
const updated = { ...assignment, end_day: endDay }
|
||||
await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => {
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(), tripId: Number(tripId), method: 'PUT',
|
||||
url: `/trips/${tripId}/assignments/${assignment.id}/end-day`,
|
||||
body: { end_day: endDay }, resource: 'assignments', entityId: assignment.id,
|
||||
})
|
||||
await cacheAssignment(updated)
|
||||
})
|
||||
return updated
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { chargingInfoSchema, type ChargingInfo } from '@trek/shared'
|
||||
import { apiClient } from '../api/client'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
|
||||
const pending = new Map<string, { until: number; value: Promise<ChargingInfo> }>()
|
||||
export const chargingRepo = {
|
||||
async read(tripId: number, placeId: number) {
|
||||
if (isEffectivelyOffline()) throw new Error('Offline')
|
||||
const key = `${tripId}/${placeId}`
|
||||
const cached = pending.get(key)
|
||||
if (cached && cached.until > Date.now()) return cached.value
|
||||
if (pending.size > 200) pending.clear()
|
||||
const value = apiClient.get(`/trips/${tripId}/roadtrip/charging/${placeId}`).then(reply => chargingInfoSchema.parse(reply.data))
|
||||
pending.set(key, { until: Date.now() + 55000, value })
|
||||
return value
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { googleRoutePreviewSchema, type GoogleRouteImport } from '@trek/shared'
|
||||
import { apiClient } from '../api/client'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
|
||||
export const googleRouteRepo = {
|
||||
async preview(url: string, signal: AbortSignal) {
|
||||
if (isEffectivelyOffline()) throw new Error('Import requires a connection')
|
||||
const reply = await apiClient.post('/roadtrip/google-maps-preview', { url }, { signal, timeout: 120000 })
|
||||
return googleRoutePreviewSchema.parse(reply.data)
|
||||
},
|
||||
async append(tripId: number, input: GoogleRouteImport, idempotencyKey: string) {
|
||||
if (isEffectivelyOffline()) throw new Error('Import requires a connection')
|
||||
await apiClient.post(`/trips/${tripId}/roadtrip/google-maps-import`, input, {
|
||||
headers: { 'X-Idempotency-Key': idempotencyKey },
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { roadtripHazardsSchema } from '@trek/shared'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
export const roadtripHazardsRepo = {
|
||||
async read(tripId: number, signal: AbortSignal) {
|
||||
const fetched = await apiClient.get(`/trips/${tripId}/roadtrip/hazards`, { signal })
|
||||
return roadtripHazardsSchema.parse(fetched.data)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'fake-indexeddb/auto'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { roadtripPreferencesRepo } from './roadtripPreferencesRepo'
|
||||
import { offlineDb, clearAll } from '../db/offlineDb'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { mutationQueue } from '../sync/mutationQueue'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => true) }))
|
||||
vi.mock('../sync/authGate', () => ({ isAuthed: () => true }))
|
||||
vi.mock('../api/client', () => ({ apiClient: { get: vi.fn(), put: vi.fn(), request: vi.fn() } }))
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll()
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true)
|
||||
await offlineDb.roadtripPreferences.put({ tripId: 9, preferences: { roadtrip_day_start: '08:00', roadtrip_day_end: '18:00', roadtrip_range_km: 120 } })
|
||||
})
|
||||
|
||||
describe('trip driving preferences offline cache', () => {
|
||||
it('overlays queued changes and replays into the same trip', async () => {
|
||||
await roadtripPreferencesRepo.update(9, { roadtrip_range_km: 200 })
|
||||
expect((await roadtripPreferencesRepo.read(9)).roadtrip_range_km).toBe(200)
|
||||
expect((await offlineDb.roadtripPreferences.get(9))?.preferences.roadtrip_range_km).toBe(120)
|
||||
expect(await offlineDb.mutationQueue.toArray()).toEqual([expect.objectContaining({ tripId: 9, url: '/trips/9/roadtrip/preferences', method: 'PUT', body: { roadtrip_range_km: 200 } })])
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
vi.mocked(apiClient.request).mockResolvedValue({ data: { tripId: 9, preferences: { roadtrip_range_km: 200 } } })
|
||||
await mutationQueue.flush()
|
||||
expect(await offlineDb.mutationQueue.count()).toBe(0)
|
||||
expect((await offlineDb.roadtripPreferences.get(9))?.preferences.roadtrip_range_km).toBe(200)
|
||||
expect(await offlineDb.roadtripPreferences.get(10)).toBeUndefined()
|
||||
})
|
||||
it('refuses an uncached trip and an invalid merged day window', async () => {
|
||||
await expect(roadtripPreferencesRepo.read(10)).rejects.toThrow('not available offline')
|
||||
await expect(roadtripPreferencesRepo.update(9, { roadtrip_day_end: '07:00' })).rejects.toThrow('Day end')
|
||||
expect(await offlineDb.mutationQueue.count()).toBe(0)
|
||||
})
|
||||
it('keeps cached preferences on a rejected online update', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
vi.mocked(apiClient.put).mockRejectedValue(new Error('Denied'))
|
||||
await expect(roadtripPreferencesRepo.update(9, { roadtrip_range_km: 999 })).rejects.toThrow('Denied')
|
||||
expect((await offlineDb.roadtripPreferences.get(9))?.preferences.roadtrip_range_km).toBe(120)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { roadtripPreferencesResponseSchema, roadtripPreferencesUpdateSchema, type RoadtripPreferences } from '@trek/shared'
|
||||
import { apiClient } from '../api/client'
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { generateUUID, mutationQueue } from '../sync/mutationQueue'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
|
||||
export async function cachedRoadtripPreferences(tripId: number, excludeMutation?: string): Promise<RoadtripPreferences> {
|
||||
const cached = await offlineDb.roadtripPreferences.get(tripId)
|
||||
if (!cached) throw new Error('Driving settings are not available offline.')
|
||||
const pending = await offlineDb.mutationQueue.where('tripId').equals(tripId).sortBy('createdAt')
|
||||
return pending.reduce((preferences, mutation) => {
|
||||
if (mutation.resource !== 'roadtripPreferences' || mutation.id === excludeMutation || !['pending', 'syncing'].includes(mutation.status)) return preferences
|
||||
return { ...preferences, ...roadtripPreferencesUpdateSchema.parse(mutation.body) }
|
||||
}, cached.preferences)
|
||||
}
|
||||
|
||||
const writes = new Map<number, Promise<RoadtripPreferences>>()
|
||||
|
||||
export const roadtripPreferencesRepo = {
|
||||
async read(tripId: number): Promise<RoadtripPreferences> {
|
||||
const cacheName = offlineDb.name
|
||||
return onlineThenCache(async () => {
|
||||
const fetched = await apiClient.get(`/trips/${tripId}/roadtrip/preferences`)
|
||||
if (offlineDb.name !== cacheName) throw new Error('Account changed.')
|
||||
const saved = roadtripPreferencesResponseSchema.parse(fetched.data)
|
||||
await offlineDb.roadtripPreferences.put(saved)
|
||||
return cachedRoadtripPreferences(tripId)
|
||||
}, () => cachedRoadtripPreferences(tripId))
|
||||
},
|
||||
|
||||
update(tripId: number, patch: RoadtripPreferences): Promise<RoadtripPreferences> {
|
||||
const cacheName = offlineDb.name
|
||||
const save = (writes.get(tripId) ?? Promise.resolve({})).catch(() => ({})).then(async () => {
|
||||
if (offlineDb.name !== cacheName) throw new Error('Account changed.')
|
||||
const validated = roadtripPreferencesUpdateSchema.parse(patch)
|
||||
if (!isEffectivelyOffline()) {
|
||||
const saved = await apiClient.put(`/trips/${tripId}/roadtrip/preferences`, validated)
|
||||
if (offlineDb.name !== cacheName) throw new Error('Account changed.')
|
||||
await offlineDb.roadtripPreferences.put(roadtripPreferencesResponseSchema.parse(saved.data))
|
||||
} else {
|
||||
const next = { ...await cachedRoadtripPreferences(tripId), ...validated }
|
||||
if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) throw new Error('Day end must be later than day start.')
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(), tripId, method: 'PUT', url: `/trips/${tripId}/roadtrip/preferences`,
|
||||
body: validated, resource: 'roadtripPreferences', entityId: tripId,
|
||||
})
|
||||
}
|
||||
return cachedRoadtripPreferences(tripId)
|
||||
})
|
||||
writes.set(tripId, save)
|
||||
void save.finally(() => { if (writes.get(tripId) === save) writes.delete(tripId) }).catch(() => {})
|
||||
return save
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user