diff --git a/client/public/images/portals/check24.png b/client/public/images/portals/check24.png new file mode 100644 index 000000000..ef931c5da Binary files /dev/null and b/client/public/images/portals/check24.png differ diff --git a/client/public/images/portals/pincamp.png b/client/public/images/portals/pincamp.png new file mode 100644 index 000000000..d878e863a Binary files /dev/null and b/client/public/images/portals/pincamp.png differ diff --git a/client/public/images/portals/pitchup.ico b/client/public/images/portals/pitchup.ico new file mode 100644 index 000000000..24b7881cd Binary files /dev/null and b/client/public/images/portals/pitchup.ico differ diff --git a/client/public/images/portals/trivago.png b/client/public/images/portals/trivago.png new file mode 100644 index 000000000..1eb2abb50 Binary files /dev/null and b/client/public/images/portals/trivago.png differ diff --git a/client/src/App.tsx b/client/src/App.tsx index 509a943be..ceadffc3d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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) { diff --git a/client/src/api/assignmentEndDay.ts b/client/src/api/assignmentEndDay.ts new file mode 100644 index 000000000..ba146d4f7 --- /dev/null +++ b/client/src/api/assignmentEndDay.ts @@ -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) +} diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index 46c22033a..1ad8c95ba 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -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', diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index d89fa5964..b789f2d1a 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -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 {

{t('admin.defaultSettings.cartoKeyHint')}

)} - {/* 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 && ( -
- - ) => 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" - /> -

{t('settings.routingBaseHint')}

-
- )} - {/* 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 && ( -
- - ) => 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" - /> -

{t('settings.valhallaBaseHint')}

-
+
+ +
)}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} diff --git a/client/src/components/Admin/RoutingInstanceFields.tsx b/client/src/components/Admin/RoutingInstanceFields.tsx new file mode 100644 index 000000000..adf9b1180 --- /dev/null +++ b/client/src/components/Admin/RoutingInstanceFields.tsx @@ -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 + onReset: (key: keyof RoutingDefaults) => Promise + 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(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 ( +
+
+ + {defaults[router.key] !== undefined && ( + + )} +
+ 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" + /> +

{t(router.hint)}

+
+ ) +} + +export default function RoutingInstanceFields(props: RoutingFieldsProps) { + return
{ROUTERS.map(router => )}
+} diff --git a/client/src/components/Map/ClusteredPois.tsx b/client/src/components/Map/ClusteredPois.tsx new file mode 100644 index 000000000..986de81bf --- /dev/null +++ b/client/src/components/Map/ClusteredPois.tsx @@ -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(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 { + 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 }) + } }} /> + })} +} diff --git a/client/src/components/Map/HazardLayers.tsx b/client/src/components/Map/HazardLayers.tsx new file mode 100644 index 000000000..3fe34d4d7 --- /dev/null +++ b/client/src/components/Map/HazardLayers.tsx @@ -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 => 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 })} + />)} +} diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index 72d69f3f5..eb17a6ad5 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -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: () =>
, Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
{ 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() + render() 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( {}} />) + const marker = markersWithZ('500')[0] + expect(marker).toHaveAttribute('draggable', 'true') + fireEvent.mouseDown(marker) + fireEvent.mouseUp(marker) + fireEvent.click(marker) + expect(first).toHaveBeenCalledTimes(1) + rerender( {}} />) + 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() 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() + 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() expect(markersWithZ('800')[0].textContent).toContain('25 min') diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 5d17af5f6..0e222ef27 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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(null) + return + {children} + {via.nightPause && } + +} + /** * 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) => ( 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 ) - const [hoveredPlace, setHoveredPlace] = useState(null) + const [hoveredPlace, setHoveredPlace] = useState(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({ - + - {/* 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 : ( - - {markers} - - )} + + {markers} + {/* 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 && } + {poiMarkers} {/* 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) => ( - - {(v.label || v.dwellSeconds != null) && ( - + {(routeVias as RouteVia[]).filter(v => !v.nightPause || mapZoom >= NIGHT_PAUSE_MIN_ZOOM).map((v, i) => ( + 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) && ( + {v.label} {v.label && v.dwellSeconds != null ? ' · ' : ''} {v.dwellSeconds != null ? formatViaDwell(v.dwellSeconds) : ''} )} - + ))} @@ -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. */} -
+
- {TooltipOverlay && ( + {TooltipOverlay && hoveredPlace.routeVia?.nightPause && ( + + )} + {TooltipOverlay && !hoveredPlace.routeVia?.nightPause && (
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. - }> - }> - + }> + }> + ) } - return + return } diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index 26e38f607..71696ba54 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -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() + 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[] = [ diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 0e830a51f..1295c4e22 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -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 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(null) const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null) const hoverIdRef = useRef(null) // True while the camera is moving (flyTo after a click, pan, zoom). Marker @@ -697,6 +710,8 @@ export function MapViewGL({ const containerRef = useRef(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const mapRef = useRef(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>(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(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 = `` + 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. */}
@@ -2230,7 +2308,10 @@ export function MapViewGL({
{/* 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) && ( + + )} + {!hoverDisabled && hoverPlace && !hoverPlace.routeVia?.nightPause && hoverPos && !isMobile && (!hoverPlace.routeVia || routeVias.includes(hoverPlace.routeVia)) && (
{ + 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 +} diff --git a/client/src/components/Map/NightPauseTooltip.test.tsx b/client/src/components/Map/NightPauseTooltip.test.tsx new file mode 100644 index 000000000..94a4b48f3 --- /dev/null +++ b/client/src/components/Map/NightPauseTooltip.test.tsx @@ -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() + 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() + expect(screen.getByRole('tooltip')).toHaveStyle({ left: '12px', top: '12px' }) +}) diff --git a/client/src/components/Map/NightPauseTooltip.tsx b/client/src/components/Map/NightPauseTooltip.tsx new file mode 100644 index 000000000..84c1d45a4 --- /dev/null +++ b/client/src/components/Map/NightPauseTooltip.tsx @@ -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(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( +
{label}
, document.body, + ) +} diff --git a/client/src/components/Map/dayBoundaryDrag.test.ts b/client/src/components/Map/dayBoundaryDrag.test.ts new file mode 100644 index 000000000..a2e598518 --- /dev/null +++ b/client/src/components/Map/dayBoundaryDrag.test.ts @@ -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 = '1' + 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 = `1` + } + 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() +}) diff --git a/client/src/components/Map/dayBoundaryDrag.ts b/client/src/components/Map/dayBoundaryDrag.ts new file mode 100644 index 000000000..9b2359dfb --- /dev/null +++ b/client/src/components/Map/dayBoundaryDrag.ts @@ -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 + 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) + } +} diff --git a/client/src/components/Map/hazardPopup.css b/client/src/components/Map/hazardPopup.css new file mode 100644 index 000000000..53b33d10e --- /dev/null +++ b/client/src/components/Map/hazardPopup.css @@ -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; +} diff --git a/client/src/components/Map/hazardPopup.test.ts b/client/src/components/Map/hazardPopup.test.ts new file mode 100644 index 000000000..f89da7e2a --- /dev/null +++ b/client/src/components/Map/hazardPopup.test.ts @@ -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: '', description: '', + 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') +}) diff --git a/client/src/components/Map/hazardPopup.ts b/client/src/components/Map/hazardPopup.ts new file mode 100644 index 000000000..1c3d77e8d --- /dev/null +++ b/client/src/components/Map/hazardPopup.ts @@ -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 +} diff --git a/client/src/components/Map/mapHover.ts b/client/src/components/Map/mapHover.ts new file mode 100644 index 000000000..5f58ae245 --- /dev/null +++ b/client/src/components/Map/mapHover.ts @@ -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 +} diff --git a/client/src/components/Map/nightPauseMarker.ts b/client/src/components/Map/nightPauseMarker.ts new file mode 100644 index 000000000..880fa6b4c --- /dev/null +++ b/client/src/components/Map/nightPauseMarker.ts @@ -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 `${moon}${escapeHtml(String(pause.day))}` +} diff --git a/client/src/components/Map/poiCategories.ts b/client/src/components/Map/poiCategories.ts index 0c8feee50..9ecf75df7 100644 --- a/client/src/components/Map/poiCategories.ts +++ b/client/src/components/Map/poiCategories.ts @@ -70,5 +70,7 @@ export interface Poi { capacity: number | null fee: boolean | null } | null - source: 'openstreetmap' + source: string + pluginId?: string + rating?: number | null } diff --git a/client/src/components/Map/poiClusters.test.ts b/client/src/components/Map/poiClusters.test.ts new file mode 100644 index 000000000..c23f6a2eb --- /dev/null +++ b/client/src/components/Map/poiClusters.test.ts @@ -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: '' } + 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) +}) diff --git a/client/src/components/Map/poiClusters.ts b/client/src/components/Map/poiClusters.ts new file mode 100644 index 000000000..03dbaeb01 --- /dev/null +++ b/client/src/components/Map/poiClusters.ts @@ -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() + 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 `${count}` +} + +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 +} diff --git a/client/src/components/Map/useHazardLayerGL.ts b/client/src/components/Map/useHazardLayerGL.ts new file mode 100644 index 000000000..123c363e7 --- /dev/null +++ b/client/src/components/Map/useHazardLayerGL.ts @@ -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]) +} diff --git a/client/src/components/Map/useRoadtripHazards.ts b/client/src/components/Map/useRoadtripHazards.ts new file mode 100644 index 000000000..12453f826 --- /dev/null +++ b/client/src/components/Map/useRoadtripHazards.ts @@ -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(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 } +} diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index bd4c793b8..2017b720d 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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 diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 504f4361a..cebbadeee 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -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' && } + {(roadtripEndDay || roadtripStay) && } {(place.description || googleDetails?.summary) && (
{place.description || googleDetails?.summary || ''} diff --git a/client/src/components/Roadtrip/AutomaticDayStop.test.tsx b/client/src/components/Roadtrip/AutomaticDayStop.test.tsx new file mode 100644 index 000000000..ec4bc30db --- /dev/null +++ b/client/src/components/Roadtrip/AutomaticDayStop.test.tsx @@ -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() + 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() + expect(screen.getByText('roadtrip.window.resume')).toBeInTheDocument() + expect(screen.getByLabelText('roadtrip.window.departure')).toHaveTextContent('09:00') + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/Roadtrip/AutomaticDayStop.tsx b/client/src/components/Roadtrip/AutomaticDayStop.tsx new file mode 100644 index 000000000..627bf35aa --- /dev/null +++ b/client/src/components/Roadtrip/AutomaticDayStop.tsx @@ -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 ( + + 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' : ''}`} + > + + {t(ending ? 'roadtrip.window.stop' : 'roadtrip.window.resume')}{' '} + {entry?.arrival ? formatClockTime(entry.arrival, is12h) : ''} + {entry?.departure && entry.departure !== entry.arrival ? ( + + + {formatClockTime(entry.departure, is12h)} + + ) : null} + + + ) +} diff --git a/client/src/components/Roadtrip/ChargingInfo.test.tsx b/client/src/components/Roadtrip/ChargingInfo.test.tsx new file mode 100644 index 000000000..e46a27dfa --- /dev/null +++ b/client/src/components/Roadtrip/ChargingInfo.test.tsx @@ -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() + 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() + expect(await screen.findByText('Status outdated')).toBeInTheDocument() + expect(screen.queryByText('2/4')).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/Roadtrip/ChargingInfo.tsx b/client/src/components/Roadtrip/ChargingInfo.tsx new file mode 100644 index 000000000..1b35767ad --- /dev/null +++ b/client/src/components/Roadtrip/ChargingInfo.tsx @@ -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(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((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 = + 0 ? 'bg-success-soft text-success' : 'bg-surface-secondary text-content-muted'}`}>{known ? `${info.available}/${info.total}` : '?'} + {cheapest && {price(cheapest.price, cheapest.currency)}/kWh*} + + if (compact) return badges + return
+ + + {t('roadtrip.poi.charging')} + {status} + {cheapest && {price(cheapest.price, cheapest.currency)}/kWh*} + + +
+ {info?.station &&

{info.station}

} +

{t('roadtrip.charging.prices')}

+ {!components.length &&

{info?.pricesUnavailable ? `${t('common.error')}: ` : ''}{t('roadtrip.charging.unknown')}

} + {components.map((component, 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)` : ''}

)} + {!!components.length &&

* {t('roadtrip.charging.note')}

} +
+

{t('roadtrip.charging.coverage')}

+ {info?.source &&

{info.source} · MobiData BW
{info.license}{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}

} +
+
+} diff --git a/client/src/components/Roadtrip/DayWindowFields.test.tsx b/client/src/components/Roadtrip/DayWindowFields.test.tsx new file mode 100644 index 000000000..acde0edb7 --- /dev/null +++ b/client/src/components/Roadtrip/DayWindowFields.test.tsx @@ -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() + 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() + 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() + expect(screen.getByRole('alert')).toHaveTextContent('roadtrip.window.invalid') + expect(screen.getByLabelText('roadtrip.window.start')).toBeDisabled() + expect(screen.getByLabelText('roadtrip.window.end')).toBeDisabled() + }) +}) diff --git a/client/src/components/Roadtrip/DayWindowFields.tsx b/client/src/components/Roadtrip/DayWindowFields.tsx new file mode 100644 index 000000000..5947a003c --- /dev/null +++ b/client/src/components/Roadtrip/DayWindowFields.tsx @@ -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 ( +
+ {t('roadtrip.window.title')} + {(['start', 'end'] as const).map(field => { + const Icon = field === 'start' ? Sunrise : Sunset + return ( +
+ + {t(`roadtrip.window.${field}`)} + change(field, value)} + style={{ width: '7rem' }} + /> +
+ ) + })} + {invalid + ? + : {t('roadtrip.window.hint')}} +
+ {t('roadtrip.window.endAt')} + {t('roadtrip.window.endAt')} +
+ {(['route', 'stop'] as const).map(mode => ( + + ))} +
+ {t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)} +
+
+ ) +} diff --git a/client/src/components/Roadtrip/EndDayControl.test.tsx b/client/src/components/Roadtrip/EndDayControl.test.tsx new file mode 100644 index 000000000..a60b9e1dd --- /dev/null +++ b/client/src/components/Roadtrip/EndDayControl.test.tsx @@ -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(resolve => { finish = resolve })) + render() + 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() +}) diff --git a/client/src/components/Roadtrip/EndDayControl.tsx b/client/src/components/Roadtrip/EndDayControl.tsx new file mode 100644 index 000000000..86d11a7ba --- /dev/null +++ b/client/src/components/Roadtrip/EndDayControl.tsx @@ -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 +} + +export default function EndDayControl({ active, onToggle }: EndDayControlProps) { + const { t } = useTranslation() + const [pending, setPending] = useState(false) + return ( +
+ {t('roadtrip.window.endHere')} + { + if (pending) return + setPending(true) + try { await onToggle() } finally { setPending(false) } + }} /> +
+ ) +} diff --git a/client/src/components/Roadtrip/GoogleRouteImport.test.tsx b/client/src/components/Roadtrip/GoogleRouteImport.test.tsx new file mode 100644 index 000000000..2c1a5207d --- /dev/null +++ b/client/src/components/Roadtrip/GoogleRouteImport.test.tsx @@ -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() + 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() + }) +}) diff --git a/client/src/components/Roadtrip/GoogleRouteImport.tsx b/client/src/components/Roadtrip/GoogleRouteImport.tsx new file mode 100644 index 000000000..19082888a --- /dev/null +++ b/client/src/components/Roadtrip/GoogleRouteImport.tsx @@ -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(null) + const [target, setTarget] = useState('') + const [busy, setBusy] = useState(false) + const [failure, setFailure] = useState('') + const [saved, setSaved] = useState(false) + const controller = useRef(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 <> + + + + + { if (!busy) setOpen(false) }} title={t('roadtrip.import.title')} size="lg"> +
+

{t('roadtrip.import.note')}

+ {!preview ? 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" /> : <> + { 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} /> +
    + {preview.stops.map((stop, index) =>
  1. + {index + 1} + {stop.name}{stop.name !== `${stop.lat}, ${stop.lng}` && {stop.lat == null || stop.lng == null ? t('roadtrip.import.unresolved') : `${stop.lat.toFixed(5)}, ${stop.lng.toFixed(5)}`}} +
  2. )} +
+ } + {failure &&

{saved ? t('common.saved') + ': ' : ''}{failure}

} +
+ + +
+
+
+ +} diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx index 59dcc33b6..a40a5005b 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx @@ -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() + 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() diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index c5b69f5b6..e8d1fe2e5 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -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
{poi.name}
+ {poi.pluginId &&

{poi.pluginId}

} {/* 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 />
) : null} + {tripId && canImport && }
{/* What to look for. */} diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx index 7fcb318bb..a7ffd1c7e 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx @@ -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) +}) diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx index 26180eb69..bba178623 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -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 ( -
+
{title} {children} - {note ?

{note}

: null} + {note ? {note} : null}
) } @@ -248,7 +252,9 @@ const SPEC_ROWS: Record Promise /** * 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 }: { } + + ))} +
- +
+ + {/* 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. */} +
+ {t('roadtrip.limit.vehicleLabel')} +
+ {VEHICLES.map(({ key, labelKey, Icon }) => ( + + ))} +
+
+ + + + {/* 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. */} + 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. */} + 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 ? ( + <> + + {/* `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. */} + + + ) : null} +
+ + {/* 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. */} - + + {t('roadtrip.line.connect')} + +
+ ) : 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. */} onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)} />
+ + onSave?.('roadtrip_show_hazards', !settings.roadtrip_show_hazards)} /> + + + onSave?.('roadtrip_service_stops_in_days', settings.roadtrip_service_stops_in_days === false)} + /> +
- - - {/* 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. */} -
- {t('roadtrip.limit.vehicleLabel')} -
- {VEHICLES.map(({ key, labelKey, Icon }) => ( - - ))} -
-
- - - - {/* 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. */} - 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. */} - 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 ? ( - <> - - {/* `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. */} - - - ) : null} -
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx index 426edcdfa..5b4c850fa 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx @@ -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() - expect(screen.getByText('640 km')).toBeInTheDocument() + expect(screen.queryByText('640 km')).toBeNull() }) }) diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 22e9d271d..aee4d1067 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -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. */} - {stop.name} + {stop.name}{stop.stopType === 'charging' && } @@ -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 + + {formatClockTime(entry.departure, is12h)} + + +} + 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 ( diff --git a/client/src/components/Roadtrip/SettingsHint.tsx b/client/src/components/Roadtrip/SettingsHint.tsx new file mode 100644 index 000000000..22c3c5068 --- /dev/null +++ b/client/src/components/Roadtrip/SettingsHint.tsx @@ -0,0 +1,7 @@ +import { Tooltip } from '../shared/Tooltip' + +export default function SettingsHint({ children, id }: { children: string; id?: string }) { + return +

{children}

+
+} diff --git a/client/src/components/Roadtrip/StayPortals.tsx b/client/src/components/Roadtrip/StayPortals.tsx new file mode 100644 index 000000000..0d1547ca6 --- /dev/null +++ b/client/src/components/Roadtrip/StayPortals.tsx @@ -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 => { + const didCopy = await copyText(name) + setCopied(didCopy) + setCopyFailed(!didCopy) + } + + return ( + + ) +} diff --git a/client/src/components/Roadtrip/StopFillPicker.test.tsx b/client/src/components/Roadtrip/StopFillPicker.test.tsx index 0dbb26f61..94ea961b3 100644 --- a/client/src/components/Roadtrip/StopFillPicker.test.tsx +++ b/client/src/components/Roadtrip/StopFillPicker.test.tsx @@ -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)), +})) diff --git a/client/src/components/Roadtrip/StopFillPicker.tsx b/client/src/components/Roadtrip/StopFillPicker.tsx index 1fa0d63e3..ee9154ac4 100644 --- a/client/src/components/Roadtrip/StopFillPicker.tsx +++ b/client/src/components/Roadtrip/StopFillPicker.tsx @@ -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 diff --git a/client/src/components/Roadtrip/VisitControls.test.tsx b/client/src/components/Roadtrip/VisitControls.test.tsx new file mode 100644 index 000000000..d5c3ff357 --- /dev/null +++ b/client/src/components/Roadtrip/VisitControls.test.tsx @@ -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() + 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() + expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 0 min' })).toBeEnabled() + expect(screen.queryByRole('button', { name: 'roadtrip.window.endHere' })).not.toBeInTheDocument() +}) diff --git a/client/src/components/Roadtrip/VisitControls.tsx b/client/src/components/Roadtrip/VisitControls.tsx new file mode 100644 index 000000000..2f16cc924 --- /dev/null +++ b/client/src/components/Roadtrip/VisitControls.tsx @@ -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 ( +
+ {endDay && } + {stay && } +
+ ) +} diff --git a/client/src/components/Roadtrip/corridor.ts b/client/src/components/Roadtrip/corridor.ts index 08886da6b..57a563526 100644 --- a/client/src/components/Roadtrip/corridor.ts +++ b/client/src/components/Roadtrip/corridor.ts @@ -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' diff --git a/client/src/components/Roadtrip/dayBoundaryPath.test.ts b/client/src/components/Roadtrip/dayBoundaryPath.test.ts new file mode 100644 index 000000000..4207893a9 --- /dev/null +++ b/client/src/components/Roadtrip/dayBoundaryPath.test.ts @@ -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() +}) diff --git a/client/src/components/Roadtrip/dayBoundaryPath.ts b/client/src/components/Roadtrip/dayBoundaryPath.ts new file mode 100644 index 000000000..011793f80 --- /dev/null +++ b/client/src/components/Roadtrip/dayBoundaryPath.ts @@ -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 +} diff --git a/client/src/components/Roadtrip/dayWindow.ts b/client/src/components/Roadtrip/dayWindow.ts new file mode 100644 index 000000000..fc592742c --- /dev/null +++ b/client/src/components/Roadtrip/dayWindow.ts @@ -0,0 +1,2 @@ +export { dayWindow, planDayWindow, roadtripInsertion } from '@trek/shared/roadtrip' +export type { DayWindow, AutomaticNight, WindowPlan } from '@trek/shared/roadtrip' diff --git a/client/src/components/Roadtrip/nightSpill.ts b/client/src/components/Roadtrip/nightSpill.ts index 70f935eed..5457b9915 100644 --- a/client/src/components/Roadtrip/nightSpill.ts +++ b/client/src/components/Roadtrip/nightSpill.ts @@ -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 - -/** 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() - 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' diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index b9d408dd4..87da618f4 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -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 = { - 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((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(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((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' diff --git a/client/src/components/Roadtrip/stayPortalLinks.ts b/client/src/components/Roadtrip/stayPortalLinks.ts new file mode 100644 index 000000000..64b920853 --- /dev/null +++ b/client/src/components/Roadtrip/stayPortalLinks.ts @@ -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 +} diff --git a/client/src/components/Roadtrip/stopArrival.ts b/client/src/components/Roadtrip/stopArrival.ts new file mode 100644 index 000000000..a2755ccb9 --- /dev/null +++ b/client/src/components/Roadtrip/stopArrival.ts @@ -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)) +} diff --git a/client/src/components/Roadtrip/stopKinds.test.ts b/client/src/components/Roadtrip/stopKinds.test.ts index 688cc7e4e..f8dd1f596 100644 --- a/client/src/components/Roadtrip/stopKinds.test.ts +++ b/client/src/components/Roadtrip/stopKinds.test.ts @@ -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}`) } } diff --git a/client/src/components/Roadtrip/stopKinds.ts b/client/src/components/Roadtrip/stopKinds.ts index 982ce74da..b1f3259ab 100644 --- a/client/src/components/Roadtrip/stopKinds.ts +++ b/client/src/components/Roadtrip/stopKinds.ts @@ -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 = 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 })), ] diff --git a/client/src/components/Roadtrip/useAutomaticDayPoints.test.ts b/client/src/components/Roadtrip/useAutomaticDayPoints.test.ts new file mode 100644 index 000000000..2484a4473 --- /dev/null +++ b/client/src/components/Roadtrip/useAutomaticDayPoints.test.ts @@ -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() } }, + ) + 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() + }) +}) diff --git a/client/src/components/Roadtrip/useAutomaticDayPoints.ts b/client/src/components/Roadtrip/useAutomaticDayPoints.ts new file mode 100644 index 000000000..e5a6ea1e3 --- /dev/null +++ b/client/src/components/Roadtrip/useAutomaticDayPoints.ts @@ -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) { + 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(() => { + 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 } +} diff --git a/client/src/components/Roadtrip/useCorridorPois.test.ts b/client/src/components/Roadtrip/useCorridorPois.test.ts index 640c48240..62103fce9 100644 --- a/client/src/components/Roadtrip/useCorridorPois.test.ts +++ b/client/src/components/Roadtrip/useCorridorPois.test.ts @@ -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[]) => ({ pois: items, source: 'openstreetmap', truncated: false, clamped: false }) +const answer = (...items: ReturnType[]) => ({ 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) }) diff --git a/client/src/components/Roadtrip/useCorridorPois.ts b/client/src/components/Roadtrip/useCorridorPois.ts index dee812594..f7b951c74 100644 --- a/client/src/components/Roadtrip/useCorridorPois.ts +++ b/client/src/components/Roadtrip/useCorridorPois.ts @@ -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([]) const [error, setError] = useState(false) const abortRef = useRef(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() + const unavailable = new Set() 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 => { 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 } } diff --git a/client/src/components/Roadtrip/useDayBoundaries.ts b/client/src/components/Roadtrip/useDayBoundaries.ts new file mode 100644 index 000000000..bd16af22e --- /dev/null +++ b/client/src/components/Roadtrip/useDayBoundaries.ts @@ -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) => { + 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 } +} diff --git a/client/src/components/Roadtrip/useFollowTrack.test.ts b/client/src/components/Roadtrip/useFollowTrack.test.ts index a8416ffc9..f26463f9b 100644 --- a/client/src/components/Roadtrip/useFollowTrack.test.ts +++ b/client/src/components/Roadtrip/useFollowTrack.test.ts @@ -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() diff --git a/client/src/components/Roadtrip/useFollowTrack.ts b/client/src/components/Roadtrip/useFollowTrack.ts index a05e182d1..2a0cb8284 100644 --- a/client/src/components/Roadtrip/useFollowTrack.ts +++ b/client/src/components/Roadtrip/useFollowTrack.ts @@ -100,7 +100,12 @@ export function useFollowTrack( const abort = useRef(null) const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId]) - const stops = useMemo(() => (day?.stops ?? []).map(s => ({ lat: s.lat, lng: s.lng })), [day]) + const stops = useMemo(() => { + 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. diff --git a/client/src/components/Roadtrip/useRefuelSearch.test.ts b/client/src/components/Roadtrip/useRefuelSearch.test.ts index 1d6651aec..8f072dc3a 100644 --- a/client/src/components/Roadtrip/useRefuelSearch.test.ts +++ b/client/src/components/Roadtrip/useRefuelSearch.test.ts @@ -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) +}) diff --git a/client/src/components/Roadtrip/useRefuelSearch.ts b/client/src/components/Roadtrip/useRefuelSearch.ts index 1d6ad2413..9f1a75507 100644 --- a/client/src/components/Roadtrip/useRefuelSearch.ts +++ b/client/src/components/Roadtrip/useRefuelSearch.ts @@ -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 + /** 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 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(null) const [loading, setLoading] = useState(false) const [outcome, setOutcome] = useState(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 { diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts index 5850e09b4..ed676cc62 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts @@ -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)), +})) diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 85fed61f9..e01333d0c 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -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 - -/** 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 => 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) /** 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 = {}, + 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(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(() => { 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 = {} - 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() - 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]) -} \ No newline at end of file + 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]) +} diff --git a/client/src/components/Roadtrip/useVehicleRange.test.ts b/client/src/components/Roadtrip/useVehicleRange.test.ts index 9c2b823b0..305699337 100644 --- a/client/src/components/Roadtrip/useVehicleRange.test.ts +++ b/client/src/components/Roadtrip/useVehicleRange.test.ts @@ -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)), +})) diff --git a/client/src/components/Roadtrip/useVehicleRange.ts b/client/src/components/Roadtrip/useVehicleRange.ts index 742a41409..be562a357 100644 --- a/client/src/components/Roadtrip/useVehicleRange.ts +++ b/client/src/components/Roadtrip/useVehicleRange.ts @@ -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 diff --git a/client/src/components/Roadtrip/vehicleRange.ts b/client/src/components/Roadtrip/vehicleRange.ts index cda588664..e5b348a01 100644 --- a/client/src/components/Roadtrip/vehicleRange.ts +++ b/client/src/components/Roadtrip/vehicleRange.ts @@ -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' diff --git a/client/src/components/shared/ContextMenu.tsx b/client/src/components/shared/ContextMenu.tsx index b6c4c9ec6..a1a1d4886 100644 --- a/client/src/components/shared/ContextMenu.tsx +++ b/client/src/components/shared/ContextMenu.tsx @@ -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(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
diff --git a/client/src/components/shared/CustomTimePicker.tsx b/client/src/components/shared/CustomTimePicker.tsx index a84508158..248931993 100644 --- a/client/src/components/shared/CustomTimePicker.tsx +++ b/client/src/components/shared/CustomTimePicker.tsx @@ -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', }}> { + await offlineDb.days.where('id').equals(assignment.day_id).modify(day => { + day.assignments = (day.assignments ?? []).map(a => a.id === assignment.id ? { ...a, ...assignment } : a) + }) +} diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 0da5d0373..3bce5333f 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -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; days!: Table; places!: Table; @@ -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 { 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(); diff --git a/client/src/hooks/useRoadtripPreferencesSync.test.ts b/client/src/hooks/useRoadtripPreferencesSync.test.ts new file mode 100644 index 000000000..aede850d5 --- /dev/null +++ b/client/src/hooks/useRoadtripPreferencesSync.test.ts @@ -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) + }) +}) diff --git a/client/src/hooks/useRoadtripPreferencesSync.ts b/client/src/hooks/useRoadtripPreferencesSync.ts new file mode 100644 index 000000000..f84ad2d57 --- /dev/null +++ b/client/src/hooks/useRoadtripPreferencesSync.ts @@ -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) => { + 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]) +} diff --git a/client/src/hooks/useRoadtripSettings.ts b/client/src/hooks/useRoadtripSettings.ts new file mode 100644 index 000000000..999ff0062 --- /dev/null +++ b/client/src/hooks/useRoadtripSettings.ts @@ -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(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 } +} diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index ef4f9a8fe..06e0a358f 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -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([]) // Charging stops / rest areas a plugin route places on the drawn line. const [routeVias, setRouteVias] = useState([]) + const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false) const routeAbortRef = useRef(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 } } diff --git a/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx index 86fbb9650..193865f6b 100644 --- a/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx +++ b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx @@ -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 { )} + {!managed && } + {/* Live tile preview */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 6e3c6dd9f..511f9f431 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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 { 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. */} - +
) : ( @@ -794,6 +797,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {selectedPlace && !isMobile && ( setSelectedPlaceId(null)}>
e.stopPropagation()}>
{mobileSidebarOpen === 'left' - ? { 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 /> + ? { 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 /> : 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 }} /> }
diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index 2674d09d2..b0bda76b8 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -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>).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]) +}) diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 13d74eb37..1d1e71782 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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(() => 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() 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) + 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(() => { + 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) => { + 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, diff --git a/client/src/repo/assignmentRepo.test.ts b/client/src/repo/assignmentRepo.test.ts new file mode 100644 index 000000000..3187c8f65 --- /dev/null +++ b/client/src/repo/assignmentRepo.test.ts @@ -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() + }) +}) diff --git a/client/src/repo/assignmentRepo.ts b/client/src/repo/assignmentRepo.ts new file mode 100644 index 000000000..95b3e0645 --- /dev/null +++ b/client/src/repo/assignmentRepo.ts @@ -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 { + 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 + }, +} diff --git a/client/src/repo/chargingRepo.ts b/client/src/repo/chargingRepo.ts new file mode 100644 index 000000000..e0632eba0 --- /dev/null +++ b/client/src/repo/chargingRepo.ts @@ -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 }>() +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 + }, +} diff --git a/client/src/repo/googleRouteRepo.ts b/client/src/repo/googleRouteRepo.ts new file mode 100644 index 000000000..fcf8973b3 --- /dev/null +++ b/client/src/repo/googleRouteRepo.ts @@ -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 }, + }) + }, +} diff --git a/client/src/repo/roadtripHazardsRepo.ts b/client/src/repo/roadtripHazardsRepo.ts new file mode 100644 index 000000000..e7e72e88d --- /dev/null +++ b/client/src/repo/roadtripHazardsRepo.ts @@ -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) + }, +} diff --git a/client/src/repo/roadtripPreferencesRepo.test.ts b/client/src/repo/roadtripPreferencesRepo.test.ts new file mode 100644 index 000000000..0694a22fa --- /dev/null +++ b/client/src/repo/roadtripPreferencesRepo.test.ts @@ -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) + }) +}) diff --git a/client/src/repo/roadtripPreferencesRepo.ts b/client/src/repo/roadtripPreferencesRepo.ts new file mode 100644 index 000000000..ad1cd7821 --- /dev/null +++ b/client/src/repo/roadtripPreferencesRepo.ts @@ -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 { + 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>() + +export const roadtripPreferencesRepo = { + async read(tripId: number): Promise { + 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 { + 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 + }, +} diff --git a/client/src/repo/roadtripSearchRepo.ts b/client/src/repo/roadtripSearchRepo.ts new file mode 100644 index 000000000..908f8038c --- /dev/null +++ b/client/src/repo/roadtripSearchRepo.ts @@ -0,0 +1,11 @@ +import { roadtripSearchAreaResponseSchema } from '@trek/shared' +import { apiClient } from '../api/client' +import { isEffectivelyOffline } from '../sync/networkMode' + +export const roadtripSearchRepo = { + async search(categories: string[], bbox: { south: number; west: number; north: number; east: number }, lang: string, signal: AbortSignal) { + if (isEffectivelyOffline()) throw new Error('Search requires a connection') + const reply = await apiClient.post('/roadtrip/search-area', { categories, bbox, lang }, { signal, timeout: 20000 }) + return roadtripSearchAreaResponseSchema.parse(reply.data) + }, +} diff --git a/client/src/store/roadtripPreferencesStore.ts b/client/src/store/roadtripPreferencesStore.ts new file mode 100644 index 000000000..b14f21449 --- /dev/null +++ b/client/src/store/roadtripPreferencesStore.ts @@ -0,0 +1,14 @@ +import { create } from 'zustand' +import type { RoadtripPreferences } from '@trek/shared' + +export const EMPTY_ROADTRIP_PREFERENCES: RoadtripPreferences = {} + +export const useRoadtripPreferencesStore = create<{ + byTrip: Record +}>(() => ({ byTrip: {} })) + +export function publishRoadtripPreferences(userId: number, tripId: number, preferences: RoadtripPreferences) { + useRoadtripPreferencesStore.setState(state => ({ + byTrip: { ...state.byTrip, [`${userId}:${tripId}`]: preferences }, + })) +} diff --git a/client/src/store/slices/assignmentsSlice.ts b/client/src/store/slices/assignmentsSlice.ts index 45a2c786b..0b367ffd8 100644 --- a/client/src/store/slices/assignmentsSlice.ts +++ b/client/src/store/slices/assignmentsSlice.ts @@ -3,11 +3,13 @@ import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { Assignment, AssignmentsMap } from '../../types' import { getApiErrorMessage } from '../../types' +import { assignmentRepo } from '../../repo/assignmentRepo' type SetState = StoreApi['setState'] type GetState = StoreApi['getState'] export interface AssignmentsSlice { + setAssignmentEndDay: (tripId: number | string, dayId: number, assignmentId: number, endDay: boolean) => Promise assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise @@ -16,6 +18,23 @@ export interface AssignmentsSlice { } export const createAssignmentsSlice = (set: SetState, get: GetState): AssignmentsSlice => ({ + setAssignmentEndDay: async (tripId, dayId, assignmentId, endDay) => { + const assignment = get().assignments[String(dayId)]?.find(a => a.id === assignmentId) + if (!assignment || assignmentId < 0) return + const apply = (value: boolean) => set(state => ({ + assignments: Object.fromEntries(Object.entries(state.assignments).map(([key, items]) => [ + key, items.map(a => a.id === assignmentId ? { ...a, end_day: value } : a), + ])), + })) + apply(endDay) + try { + const saved = await assignmentRepo.setEndDay(tripId, assignment, endDay) + apply(saved.end_day === true) + } catch (err: unknown) { + apply(assignment.end_day === true) + throw err + } + }, assignPlaceToDay: async (tripId, dayId, placeId, position) => { const state = get() const place = state.places.find(p => p.id === Number.parseInt(String(placeId))) diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 0eff392d1..6eb4a0784 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -13,6 +13,8 @@ import { getOfflinePrefs } from './offlinePrefs' import { randomId } from '../utils/randomId' import type { QueuedMutation } from '../db/offlineDb' import type { Table } from 'dexie' +import { roadtripPreferencesResponseSchema, assignmentSchema } from '@trek/shared' +import { cacheAssignment } from '../db/cacheAssignment' // Map Dexie table names used in `resource` field → actual Dexie tables. function getTable(resource: string): Table | undefined { @@ -193,7 +195,14 @@ export const mutationQueue = { headers, }) + if (mutation.resource === 'roadtripPreferences') { + const saved = roadtripPreferencesResponseSchema.parse(response.data) + await offlineDb.roadtripPreferences.put(saved) + } // Apply canonical server response to Dexie + if (mutation.resource === 'assignments') { + await cacheAssignment(assignmentSchema.parse(response.data.assignment)) + } if (mutation.method !== 'DELETE' && mutation.resource) { const table = getTable(mutation.resource) if (table && response.data && typeof response.data === 'object') { diff --git a/client/src/sync/tripSyncManager.prepare.test.ts b/client/src/sync/tripSyncManager.prepare.test.ts index 7f9088369..dbdcfef62 100644 --- a/client/src/sync/tripSyncManager.prepare.test.ts +++ b/client/src/sync/tripSyncManager.prepare.test.ts @@ -357,3 +357,5 @@ describe('tripSyncManager.syncAll — background tile pass', () => { expect(prefetchMock).not.toHaveBeenCalled() }) }) + +vi.mock('../repo/roadtripPreferencesRepo', () => ({ roadtripPreferencesRepo: { read: vi.fn(async () => ({})) } })) diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index a5356900c..1968c6410 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -1,3 +1,4 @@ +import { roadtripPreferencesRepo } from '../repo/roadtripPreferencesRepo' /** * Trip sync manager — seeds Dexie with trip data for offline use. * @@ -114,6 +115,9 @@ function isVideo(file: TripFile): boolean { async function syncTrip(tripId: number): Promise { const bundle = await tripsApi.bundle(tripId) as TripBundle + await roadtripPreferencesRepo.read(tripId).catch((error: unknown) => { + if ((error as { response?: { status?: number } }).response?.status !== 404) throw error + }) await upsertTrip(bundle.trip) await upsertDays(bundle.days) await upsertPlaces(bundle.places) diff --git a/client/src/types.ts b/client/src/types.ts index 39ae6ca02..d082992c7 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -148,6 +148,9 @@ export interface Settings { */ roadtrip_leg_minutes?: number roadtrip_day_minutes?: number + roadtrip_day_start?: string + roadtrip_day_end?: string + roadtrip_day_end_mode?: 'route' | 'stop' roadtrip_range_km?: number /** * Road classes the drive should leave out where it can, as a comma list @@ -256,6 +259,8 @@ export interface RouteSegment { /** An intermediate stop a plugin route places on the drawn line (charging stop, rest area). */ export interface RouteVia { + hoverCard?: boolean + nightPause?: { day: number; atPlace: boolean; position?: number; manual?: boolean; minPosition?: number; maxPosition?: number } lat: number lng: number label?: string diff --git a/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx b/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx index 419ab5561..773fd7fb4 100644 --- a/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx @@ -408,6 +408,39 @@ describe('MAdminDefaultUserSettings', () => { await screen.findByText('Default User Settings'); expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('https://osrm.example.org')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('https://valhalla.example.org')).not.toBeInTheDocument(); + }); + + it.each([ + ['routing_base_url', 'https://osrm.example.org'], + ['valhalla_base_url', 'https://valhalla.example.org'], + ])('loads, saves and resets the mobile %s field', async (key, placeholder) => { + const { puts } = stubDefaults({ [key]: 'https://router.example.org' }); + render(); + const input = await screen.findByPlaceholderText(placeholder); + expect(input).toHaveValue('https://router.example.org'); + fireEvent.change(input, { target: { value: ' https://new.example.org ' } }); + expect(puts).toEqual([]); + fireEvent.blur(input); + await waitFor(() => expect(puts).toEqual([{ [key]: 'https://new.example.org' }])); + await waitFor(() => expect(input).toHaveValue('https://new.example.org')); + await waitFor(() => expect(input).toBeEnabled()); + fireEvent.click(within(input.parentElement!).getByRole('button', { name: 'reset' })); + await waitFor(() => expect(input).toHaveValue('')); + expect(puts[1]).toEqual({ [key]: null }); + }); + + it('restores the saved routing URL and shows the error when saving fails', async () => { + stubDefaults({ routing_base_url: 'https://router.example.org' }); + server.use(http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'Routing URL rejected' }, { status: 400 }))); + withToast(); + const input = await screen.findByPlaceholderText('https://osrm.example.org'); + fireEvent.change(input, { target: { value: 'https://new.example.org' } }); + fireEvent.blur(input); + expect(await screen.findByText('Routing URL rejected')).toBeInTheDocument(); + await waitFor(() => expect(input).toHaveValue('https://router.example.org')); + expect(input).toBeEnabled(); }); it('FE-MOB-MDUS-027: a managed instance hides the shared Mapbox token as well', async () => { diff --git a/client/tests/unit/sync/tripSyncManager.test.ts b/client/tests/unit/sync/tripSyncManager.test.ts index 791a21266..e7c16c91e 100644 --- a/client/tests/unit/sync/tripSyncManager.test.ts +++ b/client/tests/unit/sync/tripSyncManager.test.ts @@ -435,3 +435,5 @@ describe('tripSyncManager.syncAll — logout while syncing', () => { expect(await offlineDb.trips.get(secondId)).toBeUndefined(); }); }); + +vi.mock('../../../src/repo/roadtripPreferencesRepo', () => ({ roadtripPreferencesRepo: { read: vi.fn(async () => ({})) } })) diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index 0a15e485d..adc14c99d 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -407,3 +407,7 @@ It is already silent in CI (any `CI` env var), under `NODE_ENV=test`, and whenev stdout isn't a terminal (i.e. when piped or redirected). The SDK tooling in this repo is MIT. Your plugin is your own code under your own license. + +### Roadtrip category searches + +The `searchProvider.search` request can include `category` and `bounds` (south, west, north, east). Search within that rectangle for the category; `query` remains a readable category query and `near` its centre, so existing providers continue to work. These optional fields are absent on older hosts and ordinary name searches. The host validates and filters coordinates, namespaces IDs, and applies the exact route corridor after combining sources. The existing permission and two-second hook timeout still apply. diff --git a/plugin-sdk/package-lock.json b/plugin-sdk/package-lock.json index 5e4813705..ec7820fa4 100644 --- a/plugin-sdk/package-lock.json +++ b/plugin-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-plugin-sdk", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-plugin-sdk", - "version": "1.7.0", + "version": "1.7.1", "license": "MIT", "dependencies": { "@clack/prompts": "^1.7.0", diff --git a/plugin-sdk/package.json b/plugin-sdk/package.json index 7959de547..f22fa78bd 100644 --- a/plugin-sdk/package.json +++ b/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "trek-plugin-sdk", - "version": "1.7.0", + "version": "1.7.1", "description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.", "type": "module", "license": "MIT", diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 1b277f8ee..42c745732 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -505,6 +505,9 @@ export interface SearchRequest { lang?: string; /** Where the person is looking, when there is somewhere to bias toward. */ near?: { lat: number; lng: number }; + /** Category search within a Roadtrip search rectangle. Older hosts omit these fields. */ + category?: string; + bounds?: { south: number; west: number; north: number; east: number }; } export interface SearchProvider { /** Places for a query, drawn into TREK's own search list beside the core results. diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 57299604f..e162c82c8 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1,3 +1,4 @@ +import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared'; import { readEnv } from '../app-config'; import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto'; @@ -4554,6 +4555,27 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_school_holiday_periods_region ON school_holiday_periods(region_id); `); }, + () => { + const cols = db.prepare("SELECT name FROM pragma_table_info('day_assignments')").all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'end_day')) { + db.exec('ALTER TABLE day_assignments ADD COLUMN end_day INTEGER NOT NULL DEFAULT 0'); + } + }, + () => { + db.exec(`CREATE TABLE IF NOT EXISTS roadtrip_day_boundaries ( + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_number INTEGER NOT NULL CHECK (day_number BETWEEN 1 AND 366), + from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), + PRIMARY KEY (trip_id, day_number) + )`); + }, + () => { + db.exec('CREATE TABLE IF NOT EXISTS roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (trip_id, key))'); + const inherit = db.prepare('INSERT OR IGNORE INTO roadtrip_preferences (trip_id, key, value) SELECT t.id, s.key, s.value FROM trips t JOIN settings s ON s.user_id = t.user_id WHERE s.key = ? AND s.value IS NOT NULL'); + for (const key of ROADTRIP_PREFERENCE_KEYS) inherit.run(key); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/assignments/assignments.controller.ts b/server/src/nest/assignments/assignments.controller.ts index f36718c59..3d5908cb9 100644 --- a/server/src/nest/assignments/assignments.controller.ts +++ b/server/src/nest/assignments/assignments.controller.ts @@ -17,6 +17,7 @@ import { AssignmentReorderDto, AssignmentMoveDto, AssignmentTimeDto, + AssignmentEndDayDto, AssignmentNotesDto, AssignmentTransportDto, AssignmentParticipantsDto, @@ -172,6 +173,22 @@ export class AssignmentOpsController { return { assignment }; } + @RequirePermission('day_edit') + @Put(':id/end-day') + endDay( + @Param('tripId') tripId: string, + @Param('id') id: string, + @Body() body: AssignmentEndDayDto, + @Headers('x-socket-id') socketId?: string, + ) { + if (!this.assignments.getAssignmentForTrip(id, tripId)) { + throw new HttpException({ error: 'Assignment not found' }, 404); + } + const assignment = this.assignments.setEndDay(id, body.end_day); + this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId); + return { assignment }; + } + // #2163: the per-assignment note was write-once (create bodies, MCP, plugin // RPC) with no edit path anywhere. Same guard shape and 404 body as its // neighbours; no reconcile — the note doesn't touch the journey skeleton. diff --git a/server/src/nest/assignments/assignments.dto.ts b/server/src/nest/assignments/assignments.dto.ts index 5bb19adf1..b7ffa0a72 100644 --- a/server/src/nest/assignments/assignments.dto.ts +++ b/server/src/nest/assignments/assignments.dto.ts @@ -4,6 +4,7 @@ import { assignmentReorderRequestSchema, assignmentMoveRequestSchema, assignmentTimeRequestSchema, + assignmentEndDayRequestSchema, assignmentNotesRequestSchema, assignmentTransportRequestSchema, assignmentParticipantsRequestSchema, @@ -20,6 +21,7 @@ export class AssignmentCreateDto extends createZodDto(assignmentCreateRequestSch export class AssignmentReorderDto extends createZodDto(assignmentReorderRequestSchema) {} export class AssignmentMoveDto extends createZodDto(assignmentMoveRequestSchema) {} export class AssignmentTimeDto extends createZodDto(assignmentTimeRequestSchema) {} +export class AssignmentEndDayDto extends createZodDto(assignmentEndDayRequestSchema) {} export class AssignmentNotesDto extends createZodDto(assignmentNotesRequestSchema) {} export class AssignmentTransportDto extends createZodDto(assignmentTransportRequestSchema) {} export class AssignmentParticipantsDto extends createZodDto(assignmentParticipantsRequestSchema) {} diff --git a/server/src/nest/assignments/assignments.mcp.ts b/server/src/nest/assignments/assignments.mcp.ts index 5d6c98492..76b5c0c45 100644 --- a/server/src/nest/assignments/assignments.mcp.ts +++ b/server/src/nest/assignments/assignments.mcp.ts @@ -6,6 +6,7 @@ import { } from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; +import { assignmentEndDayRequestSchema, type AssignmentEndDayRequest } from '@trek/shared'; import { AuthService } from '../auth/auth.service'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { AssignmentsService } from './assignments.service'; @@ -56,6 +57,30 @@ export class AssignmentsMcp { return ok({ assignment }); } + @Tool({ + name: 'set_assignment_end_day', + description: 'End the travel day after this visit and its stay. Applies only with daily travel times enabled. Pass false to follow the default again.', + inputSchema: { + tripId: z.number().int().positive(), + assignmentId: z.number().int().positive(), + ...assignmentEndDayRequestSchema.shape, + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'places', mode: 'write' }, + }) + async setAssignmentEndDay( + { tripId, assignmentId, end_day }: AssignmentEndDayRequest & { tripId: number; assignmentId: number }, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); + const assignment = this.assignments.setEndDay(assignmentId, end_day); + this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment }); + return ok({ assignment }); + } + @Tool({ name: 'unassign_place', description: 'Remove a place assignment from a day.', diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index e745aed32..56af252fe 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -225,6 +225,11 @@ export class AssignmentsService { return this.getAssignmentWithPlace(Number(id)); } + setEndDay(id: string | number, endDay: boolean) { + this.dbs.run('UPDATE day_assignments SET end_day = ? WHERE id = ?', endDay ? 1 : 0, id); + return this.getAssignmentWithPlace(Number(id)); + } + /** * Edit the per-assignment note after creation (#2163) — until now the note * was write-once via the create paths (REST body, MCP tools, plugin RPC) and diff --git a/server/src/nest/common/rowShape.ts b/server/src/nest/common/rowShape.ts index 2c964e8ec..d4a5f2062 100644 --- a/server/src/nest/common/rowShape.ts +++ b/server/src/nest/common/rowShape.ts @@ -18,6 +18,7 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[] notes: a.notes, assignment_time: a.assignment_time ?? null, assignment_end_time: a.assignment_end_time ?? null, + end_day: a.end_day === 1, leg_transport_mode: a.leg_transport_mode ?? null, incoming_leg_transport_mode: a.incoming_leg_transport_mode ?? null, participants: participants || [], diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index 8a471ab4a..c73fc28b8 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -112,6 +112,7 @@ export class DaysService { id: a.id, day_id: a.day_id, order_index: a.order_index, + end_day: a.end_day === 1, notes: a.notes, created_at: a.created_at, place: { diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 016f6c40c..35ee45e42 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -2645,21 +2645,23 @@ export class MapsService { lat: string, lng: string, lang?: string, - opts?: { lane?: GeoLane; timeoutMs?: number }, + opts?: { lane?: GeoLane; timeoutMs?: number; locality?: boolean }, ): Promise<{ name: string | null; address: string | null }> { const params = new URLSearchParams({ lat, lon: lng, format: 'json', addressdetails: '1', - zoom: '18', + zoom: opts?.locality ? '10' : '18', 'accept-language': toApiLang(lang), }); const response = await nominatimFetch('reverse', params, opts); if (!response.ok) return { name: null, address: null }; const data = (await response.json()) as { name?: string; display_name?: string; address?: Record }; const addr = data.address || {}; - const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; + const name = opts?.locality + ? addr.city || addr.town || addr.village || addr.municipality || data.name || null + : data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; return { name, address: data.display_name || null }; } diff --git a/server/src/nest/places/maps-dir.helpers.ts b/server/src/nest/places/maps-dir.helpers.ts index 6e49e7775..fbf243db7 100644 --- a/server/src/nest/places/maps-dir.helpers.ts +++ b/server/src/nest/places/maps-dir.helpers.ts @@ -133,7 +133,7 @@ function fromPath(url: URL): DirWaypoint[] { * Empty when the link is not a route, or holds fewer than two stops — one stop is a * place, and the place search box already takes those. */ -export function parseDirectionsUrl(raw: string): DirWaypoint[] { +export function parseDirectionsUrl(raw: string, limit = MAX_DIR_WAYPOINTS): DirWaypoint[] { let url: URL; try { url = new URL(raw); @@ -142,5 +142,5 @@ export function parseDirectionsUrl(raw: string): DirWaypoint[] { } const fromParams = fromQuery(url); const found = fromParams.length ? fromParams : fromPath(url); - return found.length >= 2 ? found.slice(0, MAX_DIR_WAYPOINTS) : []; + return found.length >= 2 ? found.slice(0, limit) : []; } diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index e747bce78..bf3dbc4c8 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -11,6 +11,8 @@ import { placeImportListRequestSchema, placeWebsiteSchema, roadtripStopTypeSchema, + roadtripGpxImportSchema, + type RoadtripGpxImport, type RoadtripStopType, } from '@trek/shared'; import { z } from 'zod'; @@ -77,7 +79,7 @@ export class PlacesMcp { image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'), price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'), currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'), - stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant or sights. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'), + stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant, sights or hotel. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'places', mode: 'write' }, @@ -121,7 +123,7 @@ export class PlacesMcp { assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'), price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'), currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'), - stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant or sights. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'), + stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant, sights or hotel. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'places', mode: 'write' }, @@ -168,9 +170,9 @@ export class PlacesMcp { category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'), price: z.number().optional(), currency: z.string().length(3).optional(), - place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'), - end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'), - duration_minutes: z.number().int().positive().optional(), + place_time: z.string().max(50).nullable().optional().describe('Scheduled time (e.g. "09:00"); null clears it'), + end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"); null clears it'), + duration_minutes: z.number().int().nonnegative().nullable().optional().describe('Stay duration in minutes; null clears it'), notes: z.string().max(2000).optional(), website: placeWebsiteSchema.optional(), phone: z.string().max(50).optional(), @@ -179,7 +181,7 @@ export class PlacesMcp { osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'), google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'), google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'), - stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant or sights. Pass null to turn a service stop back into an ordinary place.'), + stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant, sights or hotel. Pass null to turn a service stop back into an ordinary place.'), fill_percent: z.number().int().min(1).max(100).nullable().optional().describe('How full THIS stop fills the tank, 1-100. A motorway rapid charger is worth about 80 %, the one at the hotel 100 %. Pass null to follow whatever the traveller set as their default fill.'), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -188,8 +190,8 @@ export class PlacesMcp { async updatePlace( { tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, stop_type, fill_percent }: { tripId: number; placeId: number; name?: string; description?: string; lat?: number; lng?: number; - address?: string; category_id?: number; price?: number; currency?: string; place_time?: string; - end_time?: string; duration_minutes?: number; notes?: string; website?: string; phone?: string; + address?: string; category_id?: number; price?: number; currency?: string; place_time?: string | null; + end_time?: string | null; duration_minutes?: number | null; notes?: string; website?: string; phone?: string; image_url?: string | null; transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; osm_id?: string; google_place_id?: string; google_ftid?: string; stop_type?: RoadtripStopType | null; @@ -354,6 +356,28 @@ export class PlacesMcp { return ok({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }); } + @Tool({ + name: 'import_trip_gpx', + description: 'Import GPX waypoints, routes and tracks as places in a trip. Uses the same importer as the file upload. Imported tracks retain their geometry and can be followed with add_route_vias. Does not assign places to days. Use list_places for their ids and geometry, then assign_place_to_day and the Roadtrip tools to plan visits. Existing importer deduplication also applies.', + inputSchema: roadtripGpxImportSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'places', mode: 'write' }, + }) + async importGpx(input: RoadtripGpxImport, ctx: McpContext) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId)) return permissionDenied(); + if (!input.importWaypoints && !input.importRoutes && !input.importTracks) return errorResult('No import types selected.'); + try { + const imported = this.places.importGpx(String(input.tripId), Buffer.from(input.gpx, 'utf8'), { importWaypoints: input.importWaypoints, importRoutes: input.importRoutes, importTracks: input.importTracks, defaultName: input.name }); + if (!imported) return errorResult('No matching places found in GPX.'); + for (const place of imported.places) this.guards.safeBroadcast(input.tripId, 'place:created', { place }); + return ok(imported); + } catch { + return errorResult('Could not import GPX. Check the XML and coordinates.'); + } + } + @Tool({ name: 'export_trip_gpx', description: 'Export a trip as GPX text: its places as waypoints, any imported routes as tracks, and each planned day as a route in visiting order. This is the format handhelds and offline map apps (Organic Maps, OsmAnd, Garmin) read. Prefer export_trip_ics when the user wants the itinerary in a calendar instead.', @@ -428,7 +452,7 @@ export class PlacesMcp { phone: z.string().max(50).optional(), image_url: placeImageUrlSchema.nullable().optional().describe('Thumbnail for every listed place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL. Pass null to strip the pictures off a batch at once'), description: z.string().max(2000).optional(), - stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant or sights. Pass null to turn a service stop back into an ordinary place.'), + stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant, sights or hotel. Pass null to turn a service stop back into an ordinary place.'), fill_percent: z.number().int().min(1).max(100).nullable().optional().describe('How full THIS stop fills the tank, 1-100. A motorway rapid charger is worth about 80 %, the one at the hotel 100 %. Pass null to follow whatever the traveller set as their default fill.'), }, annotations: TOOL_ANNOTATIONS_WRITE, diff --git a/server/src/nest/plugins/contributions/plugin-route-normalize.ts b/server/src/nest/plugins/contributions/plugin-route-normalize.ts new file mode 100644 index 000000000..9963e551e --- /dev/null +++ b/server/src/nest/plugins/contributions/plugin-route-normalize.ts @@ -0,0 +1,136 @@ +import { stripEmoji } from '../text-sanitize'; + +import type Database from 'better-sqlite3'; + +type Tone = 'default' | 'success' | 'warn' | 'danger'; + +interface RouteWaypointIn { + lat: number; + lng: number; + name?: string; + placeId?: number; +} + +interface RouteLegOut { + distance: number; + duration: number; + note?: string; +} + +interface RouteViaOut { + lat: number; + lng: number; + label?: string; + tone: Tone; + dwellSeconds?: number; +} + +export interface PluginRouteOut { + pluginId: string; + profile: string; + coordinates: Array<[number, number]>; + distance: number; + duration: number; + legs: RouteLegOut[]; + viaPoints: RouteViaOut[]; +} + +const TONES: ReadonlySet = new Set(['default', 'success', 'warn', 'danger']); +export const PROFILE_RE = /^[a-z][a-z0-9-]{0,23}$/; +const MAX_WAYPOINTS = 30; +const MAX_COORDINATES = 10_000; // vertex budget for the returned geometry +const MAX_VIAS = 40; +const MAX_DWELL_S = 86_400; // a "stop" longer than a day is nonsense data +const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n); + +function validCoord(lat: unknown, lng: unknown): boolean { + const a = Number(lat); + const b = Number(lng); + return Number.isFinite(a) && Number.isFinite(b) && a >= -90 && a <= 90 && b >= -180 && b <= 180; +} + +export function readWaypoints(raw: unknown): RouteWaypointIn[] | null { + if (!Array.isArray(raw) || raw.length < 2 || raw.length > MAX_WAYPOINTS) return null; + const out: RouteWaypointIn[] = []; + for (const w of raw as Array>) { + if (!w || typeof w !== 'object' || !validCoord(w.lat, w.lng)) return null; + const name = w.name != null ? cap(w.name, 120) : undefined; + const placeId = Number.isInteger(w.placeId) ? (w.placeId as number) : undefined; + out.push({ + lat: Number(w.lat), + lng: Number(w.lng), + ...(name ? { name } : {}), + ...(placeId !== undefined ? { placeId } : {}), + }); + } + return out; +} + +function nonNeg(v: unknown): number | null { + const n = Number(v); + return Number.isFinite(n) && n >= 0 ? n : null; +} + +export function normalize( + pluginId: string, + profile: string, + waypointCount: number, + raw: unknown, +): PluginRouteOut | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + + if (!Array.isArray(r.coordinates) || r.coordinates.length < 2 || r.coordinates.length > MAX_COORDINATES) return null; + const coordinates: Array<[number, number]> = []; + for (const c of r.coordinates) { + if (!Array.isArray(c) || !validCoord(c[0], c[1])) return null; + coordinates.push([Number(c[0]), Number(c[1])]); + } + + const distance = nonNeg(r.distance); + const duration = nonNeg(r.duration); + if (distance == null || duration == null) return null; + if (!Array.isArray(r.legs) || r.legs.length !== waypointCount - 1) return null; + const legs: RouteLegOut[] = []; + for (const l of r.legs as Array>) { + if (!l || typeof l !== 'object') return null; + const legDistance = nonNeg(l.distance); + const legDuration = nonNeg(l.duration); + if (legDistance == null || legDuration == null) return null; + const note = l.note != null ? cap(l.note, 120) : undefined; + legs.push({ distance: legDistance, duration: legDuration, ...(note ? { note } : {}) }); + } + const viaPoints: RouteViaOut[] = []; + if (Array.isArray(r.viaPoints)) { + for (const v of (r.viaPoints as Array>).slice(0, MAX_VIAS * 4)) { + if (viaPoints.length >= MAX_VIAS) break; + if (!v || typeof v !== 'object' || !validCoord(v.lat, v.lng)) continue; + const dwell = Number(v.dwellSeconds); + const label = v.label != null ? cap(v.label, 80) : undefined; + viaPoints.push({ + lat: Number(v.lat), + lng: Number(v.lng), + ...(label ? { label } : {}), + tone: TONES.has(v.tone as string) ? (v.tone as Tone) : 'default', + ...(Number.isFinite(dwell) && dwell >= 0 ? { dwellSeconds: Math.min(Math.round(dwell), MAX_DWELL_S) } : {}), + }); + } + } + + return { pluginId, profile, coordinates, distance, duration, legs, viaPoints }; +} + +export function declaredProfiles(conn: Database.Database, pluginId: string): string[] { + try { + const row = conn.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(pluginId) as + | { capabilities?: string } + | undefined; + const c = JSON.parse(row?.capabilities || '{}') as { routeProfiles?: Array<{ id?: unknown }> }; + if (!Array.isArray(c.routeProfiles)) return []; + return c.routeProfiles + .map((p) => (p && typeof p === 'object' && typeof p.id === 'string' ? p.id : '')) + .filter((id) => PROFILE_RE.test(id)); + } catch { + return []; + } +} diff --git a/server/src/nest/plugins/contributions/plugin-routes.controller.ts b/server/src/nest/plugins/contributions/plugin-routes.controller.ts index 6f19cc47f..fd8f07699 100644 --- a/server/src/nest/plugins/contributions/plugin-routes.controller.ts +++ b/server/src/nest/plugins/contributions/plugin-routes.controller.ts @@ -1,12 +1,12 @@ import { Body, Controller, Param, Post, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; -import type Database from 'better-sqlite3'; import { DatabaseService } from '../../database/database.service'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { pluginsEnabled } from '../kill-switch'; import { PluginHooks } from '../plugin-hooks.service'; -import { stripEmoji } from '../text-sanitize'; import { PluginRouteDto } from '../plugins.dto'; +import { normalize, declaredProfiles, readWaypoints, PROFILE_RE, type PluginRouteOut } from './plugin-route-normalize'; +export type { PluginRouteOut } from './plugin-route-normalize'; /** * POST /api/plugin-routes/:pluginId/:profileId — ask ONE routeProvider plugin to @@ -27,126 +27,6 @@ import { PluginRouteDto } from '../plugins.dto'; * the invoke timeout is 20 s — well above the 5 s of the render-blocking hooks; * the client shows the straight-line route until the result lands. */ -type Tone = 'default' | 'success' | 'warn' | 'danger'; - -interface RouteWaypointIn { - lat: number; - lng: number; - name?: string; - placeId?: number; -} - -interface RouteLegOut { - distance: number; - duration: number; - note?: string; -} - -interface RouteViaOut { - lat: number; - lng: number; - label?: string; - tone: Tone; - dwellSeconds?: number; -} - -export interface PluginRouteOut { - pluginId: string; - profile: string; - coordinates: Array<[number, number]>; - distance: number; - duration: number; - legs: RouteLegOut[]; - viaPoints: RouteViaOut[]; -} - -const TONES: ReadonlySet = new Set(['default', 'success', 'warn', 'danger']); -const PROFILE_RE = /^[a-z][a-z0-9-]{0,23}$/; -const MAX_WAYPOINTS = 30; -const MAX_COORDINATES = 10_000; // vertex budget for the returned geometry -const MAX_VIAS = 40; -const MAX_DWELL_S = 86_400; // a "stop" longer than a day is nonsense data -const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n); - -function validCoord(lat: unknown, lng: unknown): boolean { - const a = Number(lat); - const b = Number(lng); - return Number.isFinite(a) && Number.isFinite(b) && a >= -90 && a <= 90 && b >= -180 && b <= 180; -} - -/** The caller's waypoint list — invalid input is a client bug, answered with null. */ -function readWaypoints(raw: unknown): RouteWaypointIn[] | null { - if (!Array.isArray(raw) || raw.length < 2 || raw.length > MAX_WAYPOINTS) return null; - const out: RouteWaypointIn[] = []; - for (const w of raw as Array>) { - if (!w || typeof w !== 'object' || !validCoord(w.lat, w.lng)) return null; - const name = w.name != null ? cap(w.name, 120) : undefined; - const placeId = Number.isInteger(w.placeId) ? (w.placeId as number) : undefined; - out.push({ lat: Number(w.lat), lng: Number(w.lng), ...(name ? { name } : {}), ...(placeId !== undefined ? { placeId } : {}) }); - } - return out; -} - -function nonNeg(v: unknown): number | null { - const n = Number(v); - return Number.isFinite(n) && n >= 0 ? n : null; -} - -/** The plugin's answer — a malformed route is rejected whole (null), never patched. */ -function normalize(pluginId: string, profile: string, waypointCount: number, raw: unknown): PluginRouteOut | null { - if (!raw || typeof raw !== 'object') return null; - const r = raw as Record; - - if (!Array.isArray(r.coordinates) || r.coordinates.length < 2 || r.coordinates.length > MAX_COORDINATES) return null; - const coordinates: Array<[number, number]> = []; - for (const c of r.coordinates) { - if (!Array.isArray(c) || !validCoord(c[0], c[1])) return null; - coordinates.push([Number(c[0]), Number(c[1])]); - } - - const distance = nonNeg(r.distance); - const duration = nonNeg(r.duration); - if (distance == null || duration == null) return null; - - // Exactly one leg per waypoint pair — a mismatched list means the plugin routed - // something other than the request, and mis-keyed legs would attach wrong times - // to the sidebar connectors. - if (!Array.isArray(r.legs) || r.legs.length !== waypointCount - 1) return null; - const legs: RouteLegOut[] = []; - for (const l of r.legs as Array>) { - if (!l || typeof l !== 'object') return null; - const legDistance = nonNeg(l.distance); - const legDuration = nonNeg(l.duration); - if (legDistance == null || legDuration == null) return null; - const note = l.note != null ? cap(l.note, 120) : undefined; - legs.push({ distance: legDistance, duration: legDuration, ...(note ? { note } : {}) }); - } - - // Via points are decorative (charging stops on the drawn route) — a bad entry - // is dropped individually, it can't invalidate the route. Slice the raw array so - // an all-invalid list can't force unbounded iteration (the output is MAX_VIAS, - // but only valid entries advance that counter). - const viaPoints: RouteViaOut[] = []; - if (Array.isArray(r.viaPoints)) { - for (const v of (r.viaPoints as Array>).slice(0, MAX_VIAS * 4)) { - if (viaPoints.length >= MAX_VIAS) break; - if (!v || typeof v !== 'object' || !validCoord(v.lat, v.lng)) continue; - const dwell = Number(v.dwellSeconds); - const label = v.label != null ? cap(v.label, 80) : undefined; - viaPoints.push({ - lat: Number(v.lat), - lng: Number(v.lng), - ...(label ? { label } : {}), - // Raw-value enum check, same rationale as the marker tone guard. - tone: TONES.has(v.tone as string) ? (v.tone as Tone) : 'default', - ...(Number.isFinite(dwell) && dwell >= 0 ? { dwellSeconds: Math.min(Math.round(dwell), MAX_DWELL_S) } : {}), - }); - } - } - - return { pluginId, profile, coordinates, distance, duration, legs, viaPoints }; -} - @Controller('api/plugin-routes') @UseGuards(JwtAuthGuard) export class PluginRoutesController { @@ -186,16 +66,3 @@ export class PluginRoutesController { } } } - -function declaredProfiles(conn: Database.Database, pluginId: string): string[] { - try { - const row = conn.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(pluginId) as { capabilities?: string } | undefined; - const c = JSON.parse(row?.capabilities || '{}') as { routeProfiles?: Array<{ id?: unknown }> }; - if (!Array.isArray(c.routeProfiles)) return []; - return c.routeProfiles - .map((p) => (p && typeof p === 'object' && typeof p.id === 'string' ? p.id : '')) - .filter((id) => PROFILE_RE.test(id)); - } catch { - return []; - } -} diff --git a/server/src/nest/plugins/plugin-hooks.service.ts b/server/src/nest/plugins/plugin-hooks.service.ts index b6a6a4ff3..b7d0055f0 100644 --- a/server/src/nest/plugins/plugin-hooks.service.ts +++ b/server/src/nest/plugins/plugin-hooks.service.ts @@ -31,6 +31,9 @@ export interface HookSearchRequest { /** The caller's language tag, for providers that return localized names. */ lang?: string; near?: { lat: number; lng: number }; + /** Category search within a Roadtrip search rectangle. Older hosts omit these fields. */ + category?: string; + bounds?: { south: number; west: number; north: number; east: number }; } /** The waypoint request a route provider is asked to solve. */ diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts index 42df67ccb..ef49f548a 100644 --- a/server/src/nest/plugins/runtime/plugin-sdk.ts +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -443,6 +443,9 @@ export interface SearchRequest { lang?: string; /** Where the person is looking, when there is somewhere to bias toward. */ near?: { lat: number; lng: number }; + /** Category search within a Roadtrip search rectangle. Older hosts omit these fields. */ + category?: string; + bounds?: { south: number; west: number; north: number; east: number }; } export interface SearchProvider { search(request: SearchRequest, ctx: PluginContext): Promise; diff --git a/server/src/nest/roadtrip/charging.controller.ts b/server/src/nest/roadtrip/charging.controller.ts new file mode 100644 index 000000000..fce48f220 --- /dev/null +++ b/server/src/nest/roadtrip/charging.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, Param, ParseIntPipe, UseGuards } from '@nestjs/common'; +import { ADDON_IDS } from '../../addons'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { TripAccessGuard } from '../permissions/trip-access.guard'; +import { ChargingService } from './charging.service'; + +@Controller('api/trips/:tripId/roadtrip/charging') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip') +export class ChargingController { + constructor(private readonly charging: ChargingService) {} + @Get(':placeId') + read(@Param('tripId', ParseIntPipe) tripId: number, @Param('placeId', ParseIntPipe) placeId: number) { return this.charging.read(tripId, placeId); } +} diff --git a/server/src/nest/roadtrip/charging.helpers.ts b/server/src/nest/roadtrip/charging.helpers.ts new file mode 100644 index 000000000..2b401e6ff --- /dev/null +++ b/server/src/nest/roadtrip/charging.helpers.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; +import type { ChargingInfo } from '@trek/shared'; + +const evse = z.object({ + uid: z.string(), evse_id: z.string().optional(), status: z.string(), last_updated: z.string(), + status_last_updated: z.string().optional(), + connectors: z.array(z.object({ tariff_ids: z.array(z.string()).optional() })).default([]), +}); +export const chargingLocation = z.object({ + id: z.string(), source: z.string(), name: z.string().nullish(), address: z.string(), + city: z.string(), last_updated: z.string(), + coordinates: z.object({ latitude: z.number(), longitude: z.number() }), + operator: z.object({ name: z.string() }).optional(), + charging_pool: z.array(z.object({ evses: z.array(evse).default([]) })).default([]), +}); +export const chargingSource = z.object({ + uid: z.string(), name: z.string(), public_url: z.string().nullable(), + attribution_license: z.string().nullable(), attribution_contributor: z.string().nullable(), + realtime_data_updated_at: z.string().nullable(), realtime_status: z.string(), +}); +export const chargingTariff = z.object({ + id: z.string(), original_id: z.string().optional(), source: z.string(), currency: z.string().regex(/^[A-Z]{3}$/), last_updated: z.string(), + start_date_time: z.string().nullish(), end_date_time: z.string().nullish(), + elements: z.array(z.object({ + restrictions: z.record(z.string(), z.unknown()).nullish(), + price_components: z.array(z.object({ + type: z.string(), price: z.number().nonnegative(), + taxes: z.array(z.object({ percentage: z.coerce.number().nonnegative() })).optional(), + })), + })), +}); +export type ChargingLocation = z.infer; +export type ChargingSource = z.infer; +export type ChargingTariff = z.infer; + +export function distanceMeters(lat: number, lng: number, location: ChargingLocation) { + const rad = Math.PI / 180; + const dLat = (location.coordinates.latitude - lat) * rad; + const dLng = (location.coordinates.longitude - lng) * rad; + const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat * rad) * Math.cos(location.coordinates.latitude * rad) * Math.sin(dLng / 2) ** 2; + return 12742000 * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0, 1 - a))); +} +const words = (name: string) => name.toLowerCase().normalize('NFKD').replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(word => word.length > 2 && !['charging', 'station', 'ladestation', 'charger'].includes(word)); + +export function matchChargingLocation(locations: ChargingLocation[], lat: number, lng: number, name: string) { + const candidates = locations.map(location => ({ location, distance: distanceMeters(lat, lng, location), + named: words(name).some(word => words(`${location.name ?? ''} ${location.operator?.name ?? ''}`).includes(word)), + })).filter(hit => hit.distance <= 100).sort((a, b) => Number(b.named) - Number(a.named) || a.distance - b.distance); + if (!candidates.length) return null; + const best = candidates[0]; + if (!best.named && best.distance > 30) return 'ambiguous'; + const competing = candidates.find(hit => hit !== best && hit.named === best.named && + hit.location.operator?.name !== best.location.operator?.name && hit.distance < best.distance + 25); + return competing ? 'ambiguous' : best.location; +} + +export function normalizeCharging(location: ChargingLocation, source: ChargingSource, tariffs: ChargingTariff[], now = Date.now()): ChargingInfo { + const evses = [...new Map(location.charging_pool.flatMap(pool => pool.evses).map(point => [point.evse_id || point.uid, point])).values()]; + const timestamps = [source.realtime_data_updated_at, ...evses.map(point => point.status_last_updated)].filter((value): value is string => !!value && Number.isFinite(Date.parse(value))); + const updatedAt = timestamps.sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? null; + const stale = !updatedAt || now - Date.parse(updatedAt) > 20 * 60 * 1000 || Date.parse(updatedAt) > now + 60000 || source.realtime_status !== 'ACTIVE'; + const known = evses.filter(point => ['AVAILABLE', 'CHARGING', 'RESERVED', 'BLOCKED', 'INOPERATIVE', 'OUTOFORDER'].includes(point.status)); + const ids = new Set(evses.flatMap(point => point.connectors.flatMap(connector => connector.tariff_ids ?? []))); + const matching = tariffs.filter(tariff => tariff.source === location.source && (ids.has(tariff.id) || ids.has(tariff.original_id ?? '')) && + (!tariff.start_date_time || Date.parse(tariff.start_date_time) <= now) && (!tariff.end_date_time || Date.parse(tariff.end_date_time) > now)); + return { + checkedAt: new Date(now).toISOString(), status: 'ok', station: location.name || `${location.operator?.name ?? ''} ${location.address}, ${location.city}`.trim(), + source: source.attribution_contributor || source.name, sourceUrl: source.public_url, license: source.attribution_license || 'Datenlizenz Deutschland Namensnennung 2.0 (MobiData BW)', + updatedAt, stale, available: stale || !known.length ? null : known.filter(point => point.status === 'AVAILABLE').length, + total: evses.length, unknown: evses.length - known.length, pricesUnavailable: false, + tariffs: matching.map(tariff => ({ currency: tariff.currency, updatedAt: tariff.last_updated, + components: tariff.elements.flatMap(element => element.price_components.map(component => ({ + kind: component.type, price: component.price * (1 + (component.taxes?.reduce((sum, tax) => sum + tax.percentage, 0) ?? 0) / 100), + taxIncluded: component.taxes !== undefined, + conditional: !!element.restrictions && Object.keys(element.restrictions).length > 0, + afterSeconds: typeof element.restrictions?.min_duration === 'number' ? element.restrictions.min_duration : null, + }))), + })), + }; +} diff --git a/server/src/nest/roadtrip/charging.mcp.ts b/server/src/nest/roadtrip/charging.mcp.ts new file mode 100644 index 000000000..5317d35d0 --- /dev/null +++ b/server/src/nest/roadtrip/charging.mcp.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { ADDON_IDS } from '../../addons'; +import { noAccess } from '../../mcp/tools/_shared'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { DatabaseService } from '../database/database.service'; +import { ChargingService } from './charging.service'; + +@McpController() +export class ChargingMcp { + constructor(private readonly charging: ChargingService, private readonly db: DatabaseService, readonly addons: AddonsService) {} + @Tool({ name: 'get_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a saved charging stop. No API key required. Coverage varies by region and operator. Unknown or stale status is not available capacity. Prices can have conditions and extra fees and are not personalized roaming quotes.', + inputSchema: { tripId: z.number().int().positive(), placeId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) }) + async read({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) { + if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + return ok(await this.charging.read(tripId, placeId)); + } +} diff --git a/server/src/nest/roadtrip/charging.service.ts b/server/src/nest/roadtrip/charging.service.ts new file mode 100644 index 000000000..07a30f0d6 --- /dev/null +++ b/server/src/nest/roadtrip/charging.service.ts @@ -0,0 +1,64 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import type { ChargingInfo } from '@trek/shared'; +import { z } from 'zod'; +import { DatabaseService } from '../database/database.service'; +import { readCappedJson } from '../../utils/cappedFetch'; +import { chargingLocation, chargingSource, chargingTariff, matchChargingLocation, normalizeCharging, type ChargingSource, type ChargingTariff } from './charging.helpers'; + +const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false }); +async function request(path: string) { + const fetched = await fetch(`https://api.mobidata-bw.de/ocpdb${path}`, { headers: { 'User-Agent': 'TREK Roadtrip (https://github.com/liketrek/TREK)' }, signal: AbortSignal.timeout(12000), redirect: 'error' }); + if (!fetched.ok) { await fetched.body?.cancel(); throw new Error('Charging source unavailable'); } + return await readCappedJson(fetched, 8_000_000); +} + +@Injectable() +export class ChargingService { + private cached = new Map }>(); + private sources?: { expires: number; value: Promise }; + private tariffs = new Map }>(); + constructor(private readonly db: DatabaseService) {} + async read(tripId: number, placeId: number) { + const place = this.db.get<{ name: string; lat: number | null; lng: number | null; stop_type: string }>('SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); + if (!place) throw new HttpException({ error: 'Place not found' }, 404); + if (place.stop_type !== 'charging' || place.lat == null || place.lng == null) return empty('unknown'); + const key = `${place.lat},${place.lng}:${place.name}`; + const cached = this.cached.get(key); + if (cached && cached.expires > Date.now()) return cached.value; + if (this.cached.size >= 300) this.cached.delete(this.cached.keys().next().value!); + const value = this.load(place.lat, place.lng, place.name).catch(() => empty('unavailable')); + this.cached.set(key, { expires: Date.now() + 60000, value }); + return value; + } + private async load(lat: number, lng: number, name: string): Promise { + if (!this.sources || this.sources.expires < Date.now()) this.sources = { expires: Date.now() + 60000, value: request('/api/public/v1/sources').then(raw => z.object({ items: z.array(chargingSource) }).parse(raw).items) }; + const [sources, nearby] = await Promise.all([this.sources.value, request(`/api/ocpi/3.0/locations?lat=${lat}&lon=${lng}&radius=100&limit=100&exclude_source_uid=bnetza_api`)]); + const page = z.object({ items: z.array(z.unknown()), total_count: z.number() }).parse(nearby); + if (page.total_count > 100) return empty('ambiguous'); + const locations = page.items.flatMap(item => { const parsed = chargingLocation.safeParse(item); return parsed.success ? [parsed.data] : []; }); + const location = matchChargingLocation(locations, lat, lng, name); + if (!location) return empty('unknown'); + if (location === 'ambiguous') return empty('ambiguous'); + const source = sources.find(source => source.uid === location.source); + if (!source) return empty('unknown'); + let pricesUnavailable = false; + const tariffs = await this.loadTariffs(source.uid).catch(() => { pricesUnavailable = true; return []; }); + return { ...normalizeCharging(location, source, tariffs), pricesUnavailable }; + } + private loadTariffs(source: string): Promise { + const cached = this.tariffs.get(source); + if (cached && cached.expires > Date.now()) return cached.value; + const value = this.fetchTariffs(source); + this.tariffs.set(source, { expires: Date.now() + 300000, value }); + return value; + } + private async fetchTariffs(source: string) { + const tariffs: ChargingTariff[] = []; + for (let offset = 0; offset < 5000; offset += 1000) { + const page = z.object({ items: z.array(z.unknown()), total_count: z.number() }).parse(await request(`/api/ocpi/3.0/tariffs?source_uid=${encodeURIComponent(source)}&limit=1000&offset=${offset}`)); + for (const item of page.items) { const parsed = chargingTariff.safeParse(item); if (parsed.success) tariffs.push(parsed.data); } + if (offset + page.items.length >= page.total_count) return tariffs; + } + throw new Error('Incomplete charging tariffs'); + } +} diff --git a/server/src/nest/roadtrip/day-boundaries.controller.ts b/server/src/nest/roadtrip/day-boundaries.controller.ts new file mode 100644 index 000000000..aca3d9ca7 --- /dev/null +++ b/server/src/nest/roadtrip/day-boundaries.controller.ts @@ -0,0 +1,40 @@ +import { Body, Controller, Delete, Get, Headers, Param, ParseIntPipe, Put, UseGuards } from '@nestjs/common'; +import { createZodDto } from 'nestjs-zod'; +import { roadtripDayBoundarySchema } from '@trek/shared'; +import { ADDON_IDS } from '../../addons'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { AddonGuard } from '../addons/addon.guard'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; +import { RealtimeService } from '../realtime/realtime.service'; +import { DayBoundariesService } from './day-boundaries.service'; + +class DayBoundaryDto extends createZodDto(roadtripDayBoundarySchema) {} + +@Controller('api/trips/:tripId/roadtrip/day-boundaries') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip') +export class DayBoundariesController { + constructor(private readonly boundaries: DayBoundariesService, private readonly realtime: RealtimeService) {} + + @Get() + list(@Param('tripId') tripId: string) { + return { boundaries: this.boundaries.list(tripId) }; + } + + @Put() + @RequirePermission('day_edit') + save(@Param('tripId') tripId: string, @Body() body: DayBoundaryDto, @Headers('x-socket-id') socketId?: string) { + const boundaries = this.boundaries.save(tripId, body); + this.realtime.broadcast(tripId, 'roadtripBoundary:changed', { boundaries }, socketId); + return { boundaries }; + } + + @Delete(':dayNumber') + @RequirePermission('day_edit') + remove(@Param('tripId') tripId: string, @Param('dayNumber', ParseIntPipe) dayNumber: number, @Headers('x-socket-id') socketId?: string) { + const boundaries = this.boundaries.remove(tripId, dayNumber); + this.realtime.broadcast(tripId, 'roadtripBoundary:changed', { boundaries }, socketId); + return { boundaries }; + } +} diff --git a/server/src/nest/roadtrip/day-boundaries.mcp.ts b/server/src/nest/roadtrip/day-boundaries.mcp.ts new file mode 100644 index 000000000..0a35a457f --- /dev/null +++ b/server/src/nest/roadtrip/day-boundaries.mcp.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; +import { roadtripDayBoundarySchema, type RoadtripDayBoundary } from '@trek/shared'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, ok, type McpContext } from '../../nest-mcp'; +import { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared'; +import { DatabaseService } from '../database/database.service'; +import { AuthService } from '../auth/auth.service'; +import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; +import { RealtimeService } from '../realtime/realtime.service'; +import { AddonsService } from '../addons/addons.service'; +import { addonGate } from '../addons/addon-gate'; +import { ADDON_IDS } from '../../addons'; +import { DayBoundariesService } from './day-boundaries.service'; + +const when = addonGate(ADDON_IDS.ROADTRIP); + +@McpController() +export class DayBoundariesMcp { + constructor( + private readonly boundaries: DayBoundariesService, + private readonly db: DatabaseService, + private readonly auth: AuthService, + private readonly guards: McpToolGuardsService, + private readonly realtime: RealtimeService, + readonly addons: AddonsService, + ) {} + + @Tool({ + name: 'list_day_boundaries', description: 'List manual road trip day endings. They apply only with daily travel times enabled.', + inputSchema: { tripId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when, + }) + async list({ tripId }: { tripId: number }, ctx: McpContext) { + if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + return ok({ boundaries: this.boundaries.list(tripId) }); + } + + @Tool({ + name: 'set_day_boundary', + description: 'Override a road trip day ending at a visit or a fraction along the driving leg between consecutive visits. Daily travel times must be enabled in the planner. Fixed visit times stay protected and conflicts are shown. Pass null to restore the automatic day ending.', + inputSchema: { tripId: z.number().int().positive(), dayNumber: z.number().int().min(1).max(366), boundary: roadtripDayBoundarySchema.nullable() }, + annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, when, + }) + async save({ tripId, dayNumber, boundary }: { tripId: number; dayNumber: number; boundary: RoadtripDayBoundary | null }, ctx: McpContext) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + const boundaries = boundary + ? this.boundaries.save(tripId, { ...boundary, day_number: dayNumber }) + : this.boundaries.remove(tripId, dayNumber); + this.realtime.broadcast(String(tripId), 'roadtripBoundary:changed', { boundaries }); + return ok({ boundaries }); + } +} diff --git a/server/src/nest/roadtrip/day-boundaries.service.ts b/server/src/nest/roadtrip/day-boundaries.service.ts new file mode 100644 index 000000000..bcc74fc15 --- /dev/null +++ b/server/src/nest/roadtrip/day-boundaries.service.ts @@ -0,0 +1,33 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import type { RoadtripDayBoundary } from '@trek/shared'; +import { DatabaseService } from '../database/database.service'; + +@Injectable() +export class DayBoundariesService { + constructor(private readonly db: DatabaseService) {} + + list(tripId: string | number): RoadtripDayBoundary[] { + return this.db.all( + 'SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number', tripId, + ); + } + + save(tripId: string | number, boundary: RoadtripDayBoundary): RoadtripDayBoundary[] { + const belongs = (id: number) => this.db.get( + 'SELECT a.id FROM day_assignments a JOIN days d ON d.id = a.day_id WHERE a.id = ? AND d.trip_id = ?', id, tripId, + ); + if (!belongs(boundary.from_assignment_id) || boundary.to_assignment_id !== null && !belongs(boundary.to_assignment_id)) { + throw new HttpException({ error: 'Stop not found' }, 404); + } + this.db.run(`INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) + VALUES (?, ?, ?, ?, ?) ON CONFLICT (trip_id, day_number) DO UPDATE SET + from_assignment_id = excluded.from_assignment_id, to_assignment_id = excluded.to_assignment_id, fraction = excluded.fraction`, + tripId, boundary.day_number, boundary.from_assignment_id, boundary.to_assignment_id, boundary.to_assignment_id === null ? 1 : boundary.fraction); + return this.list(tripId); + } + + remove(tripId: string | number, dayNumber: number): RoadtripDayBoundary[] { + this.db.run('DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?', tripId, dayNumber); + return this.list(tripId); + } +} diff --git a/server/src/nest/roadtrip/google-route.controller.ts b/server/src/nest/roadtrip/google-route.controller.ts new file mode 100644 index 000000000..978f51423 --- /dev/null +++ b/server/src/nest/roadtrip/google-route.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Headers, HttpCode, Param, Post, Req, UseGuards } from '@nestjs/common'; +import { createZodDto } from 'nestjs-zod'; +import type { Request } from 'express'; +import { googleRouteImportSchema, googleRoutePreviewRequestSchema } from '@trek/shared'; +import { ADDON_IDS } from '../../addons'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { TripAccessGuard } from '../permissions/trip-access.guard'; +import { GoogleRouteService } from './google-route.service'; + +class PreviewDto extends createZodDto(googleRoutePreviewRequestSchema) {} +class ImportDto extends createZodDto(googleRouteImportSchema) {} + +@Controller('api') +@UseGuards(AddonGuard, JwtAuthGuard) +@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip') +export class GoogleRouteController { + constructor(private readonly routes: GoogleRouteService) {} + @Post('roadtrip/google-maps-preview') + @HttpCode(200) + preview(@Body() input: PreviewDto) { return this.routes.preview(input.url); } + + @Post('trips/:tripId/roadtrip/google-maps-import') + @HttpCode(200) + @UseGuards(TripAccessGuard) + import(@Param('tripId') tripId: string, @Body() input: ImportDto, + @Req() req: Request & { user: { id: number } }, @Headers('x-socket-id') socketId?: string) { + return this.routes.import(Number(tripId), req.user.id, input, socketId); + } +} diff --git a/server/src/nest/roadtrip/google-route.mcp.ts b/server/src/nest/roadtrip/google-route.mcp.ts new file mode 100644 index 000000000..5198f6dc5 --- /dev/null +++ b/server/src/nest/roadtrip/google-route.mcp.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; +import { googleRouteImportSchema, googleRoutePreviewRequestSchema, type GoogleRouteImport } from '@trek/shared'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; +import { ADDON_IDS } from '../../addons'; +import { AddonsService } from '../addons/addons.service'; +import { addonGate } from '../addons/addon-gate'; +import { GoogleRouteService } from './google-route.service'; + +@McpController() +export class GoogleRouteMcp { + constructor(private readonly routes: GoogleRouteService, readonly addons: AddonsService) {} + @Tool({ name: 'preview_google_maps_route', description: 'Read ordered stops from a Google Maps directions link. No changes are saved. Review geocoded positions and unresolved stops before importing. The exact Google road geometry is not imported.', + inputSchema: googleRoutePreviewRequestSchema.shape, annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) }) + async preview({ url }: { url: string }) { return ok(await this.routes.preview(url)); } + + @Tool({ name: 'import_google_maps_route', description: 'Append reviewed Google Maps stops to an existing trip day in supplied order. Creates places and visits atomically. Requires place and day editing permissions. Existing visits remain. TREK calculates the road geometry; no Google route geometry is preserved.', + inputSchema: { tripId: z.number().int().positive(), ...googleRouteImportSchema.shape }, + access: { group: 'trips', mode: 'write' }, when: addonGate(ADDON_IDS.ROADTRIP) }) + import(input: GoogleRouteImport & { tripId: number }, ctx: McpContext) { return ok(this.routes.import(input.tripId, ctx.userId, input)); } +} diff --git a/server/src/nest/roadtrip/google-route.service.ts b/server/src/nest/roadtrip/google-route.service.ts new file mode 100644 index 000000000..291f06253 --- /dev/null +++ b/server/src/nest/roadtrip/google-route.service.ts @@ -0,0 +1,75 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import type { GoogleRouteImport, GoogleRoutePreview } from '@trek/shared'; +import { MapsService, GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../maps/maps.service'; +import { isDirectionsUrl, parseDirectionsUrl, MAX_DIR_WAYPOINTS } from '../places/maps-dir.helpers'; +import { safeFetchFollow } from '../../utils/ssrfGuard'; +import { DatabaseService } from '../database/database.service'; +import { PlacesService } from '../places/places.service'; +import { AssignmentsService } from '../assignments/assignments.service'; +import { PermissionsService } from '../permissions/permissions.service'; + +@Injectable() +export class GoogleRouteService { + constructor(private readonly maps: MapsService, private readonly db: DatabaseService, + private readonly places: PlacesService, private readonly assignments: AssignmentsService, + private readonly permissions: PermissionsService) {} + + async preview(raw: string): Promise { + let url = new URL(raw); + if (url.protocol !== 'https:' || url.username || url.password || url.port || + !isGoogleMapsHost(url.hostname) && !GOOGLE_SHORT_HOSTS.includes(url.hostname)) + throw new HttpException({ error: 'Use a Google Maps directions link.' }, 400); + if (GOOGLE_SHORT_HOSTS.includes(url.hostname)) { + const reply = await safeFetchFollow(url.href, { signal: AbortSignal.timeout(10000) }); + url = new URL(reply.url); + await reply.body?.cancel(); + if (!isGoogleMapsHost(url.hostname)) throw new HttpException({ error: 'Use a Google Maps directions link.' }, 400); + } + if (url.protocol !== 'https:' || !isDirectionsUrl(url.href)) + throw new HttpException({ error: 'Use a Google Maps directions link.' }, 400); + const waypoints = parseDirectionsUrl(url.href, MAX_DIR_WAYPOINTS + 1); + if (waypoints.length < 2 || waypoints.length > MAX_DIR_WAYPOINTS) + throw new HttpException({ error: 'The link must contain between 2 and 30 readable stops.' }, 400); + const stops: GoogleRoutePreview['stops'] = []; + for (const waypoint of waypoints) { + let name = (waypoint.name || `${waypoint.lat}, ${waypoint.lng}`).slice(0, 200); + if (waypoint.lat !== null && waypoint.lng !== null) { + if (!waypoint.name) { + try { + const place = await this.maps.reverseGeocode(String(waypoint.lat), String(waypoint.lng), undefined, { lane: 'background', timeoutMs: 5000, locality: true }); + name = (place.name || place.address || name).slice(0, 200); + } catch { /* Keep the supplied position when its name cannot be resolved. */ } + } + stops.push({ name, lat: waypoint.lat, lng: waypoint.lng }); + continue; + } + let position: { lat: number; lng: number } | null = null; + if (!/^(your location|my location|dein standort|mein standort|current location)$/i.test(name)) { + try { position = await this.maps.geocodeQuery(name); } catch { position = null; } + } + if (position && (!Number.isFinite(position.lat) || !Number.isFinite(position.lng) || Math.abs(position.lat) > 90 || Math.abs(position.lng) > 180)) position = null; + stops.push({ name, lat: position?.lat ?? null, lng: position?.lng ?? null }); + } + return { stops }; + } + + import(tripId: number, userId: number, input: GoogleRouteImport, socketId?: string) { + const access = this.db.canAccessTrip(tripId, userId); + if (!access) throw new HttpException({ error: 'Trip not found' }, 404); + const role = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId)?.role ?? 'user'; + if (!['place_edit', 'day_edit'].every(action => this.permissions.checkPermission(action, role, access.user_id, userId, access.user_id !== userId))) + throw new HttpException({ error: 'Permission denied' }, 403); + if (!this.assignments.dayExists(String(input.dayId), String(tripId))) throw new HttpException({ error: 'Day not found' }, 404); + const imported = this.db.transaction(() => input.stops.map(stop => { + const place = this.places.create(String(tripId), { ...stop, transport_mode: 'car', duration_minutes: 0 }); + const assignment = this.assignments.createAssignment(input.dayId, place.id); + return { place, assignment }; + })); + for (const { place, assignment } of imported) { + this.places.broadcast(String(tripId), 'place:created', { place }, socketId); + this.assignments.broadcast(String(tripId), 'assignment:created', { assignment }, socketId); + } + this.assignments.reconcile(tripId, socketId); + return { imported: imported.length }; + } +} diff --git a/server/src/nest/roadtrip/roadtrip-hazards.controller.ts b/server/src/nest/roadtrip/roadtrip-hazards.controller.ts new file mode 100644 index 000000000..183b29b87 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-hazards.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ADDON_IDS } from '../../addons'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { TripAccessGuard } from '../permissions/trip-access.guard'; +import { RoadtripHazardsService } from './roadtrip-hazards.service'; + +@Controller('api/trips/:tripId/roadtrip/hazards') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip') +export class RoadtripHazardsController { + constructor(private readonly hazards: RoadtripHazardsService) {} + @Get() + read() { return this.hazards.read(); } +} diff --git a/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts b/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts new file mode 100644 index 000000000..58a5c8fc2 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { ADDON_IDS } from '../../addons'; +import { noAccess } from '../../mcp/tools/_shared'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { DatabaseService } from '../database/database.service'; +import { RoadtripHazardsService } from './roadtrip-hazards.service'; + +@McpController() +export class RoadtripHazardsMcp { + constructor(private readonly hazards: RoadtripHazardsService, private readonly db: DatabaseService, readonly addons: AddonsService) {} + @Tool({ + name: 'get_roadtrip_hazards', + description: 'Read current DWD and GDACS hazard notices, geometry, source timestamps and feed availability. These are current notices, not forecasts for the trip dates or confirmed road closures. A point means no affected-area polygon is available. Coverage can be incomplete. This does not change routes. The shared roadtrip_show_hazards preference controls map display.', + inputSchema: { tripId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP), + }) + async read({ tripId }: { tripId: number }, ctx: McpContext) { + if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + return ok(await this.hazards.read()); + } +} diff --git a/server/src/nest/roadtrip/roadtrip-hazards.service.ts b/server/src/nest/roadtrip/roadtrip-hazards.service.ts new file mode 100644 index 000000000..fe112de8d --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-hazards.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; +import { hazardGeometrySchema, roadtripHazardSchema, type RoadtripHazard, type RoadtripHazards } from '@trek/shared'; +import { z } from 'zod'; +import { readCappedJson } from '../../utils/cappedFetch'; + +const collection = z.object({ + type: z.literal('FeatureCollection'), features: z.array(z.object({ + id: z.union([z.string(), z.number()]).optional(), geometry: z.unknown(), properties: z.record(z.string(), z.unknown()), + })).max(1000), numberMatched: z.union([z.number(), z.string()]).optional(), +}); +const dwdProperties = z.object({ + HEADLINE: z.string(), DESCRIPTION: z.string().nullish(), SENT: z.string(), EXPIRES: z.string(), +}); +const gdacsProperties = z.object({ + eventtype: z.string().regex(/^[A-Z]{2}$/), eventid: z.number().int().positive(), episodeid: z.number().int().nonnegative(), + episodealertscore: z.number().finite().nonnegative().optional().catch(undefined), + alertscore: z.number().finite().nonnegative().optional().catch(undefined), + name: z.string(), description: z.string(), datemodified: z.string(), iscurrent: z.union([z.string(), z.boolean()]), +}); +const utc = (date: string) => new Date(/(?:Z|[+-]\d\d:\d\d)$/.test(date) ? date : `${date}Z`).toISOString(); + +async function fetchCollection(url: string) { + const fetched = await fetch(url, { signal: AbortSignal.timeout(15000), redirect: 'error' }); + if (!fetched.ok) { await fetched.body?.cancel(); throw new Error('Hazard source unavailable'); } + return collection.parse(await readCappedJson(fetched, 8_000_000)); +} + +@Injectable() +export class RoadtripHazardsService { + private cached?: RoadtripHazards; + private pending?: Promise; + + async read(): Promise { + if (this.cached && Date.now() - Date.parse(this.cached.fetchedAt) < 600000) return this.cached; + if (this.pending) return this.pending; + this.pending = this.load(); + try { this.cached = await this.pending; return this.cached; } + finally { this.pending = undefined; } + } + + private async load(): Promise { + const loaded = await Promise.allSettled([this.dwd(), this.gdacs()]); + return { + fetchedAt: new Date().toISOString(), + hazards: loaded.flatMap(entry => entry.status === 'fulfilled' ? entry.value.hazards : []), + sources: loaded.map((entry, index) => ({ source: index === 0 ? 'DWD' : 'GDACS', status: entry.status === 'fulfilled' ? entry.value.status : 'unavailable' })), + }; + } + + private async dwd() { + const feed = await fetchCollection('https://maps.dwd.de/geoserver/dwd/ows?service=WFS&version=2.0.0&request=GetFeature&typeNames=dwd:Warnungen_Gemeinden_vereinigt&outputFormat=application/json&srsName=CRS:84&count=500'); + const hazards: RoadtripHazard[] = []; + let partial = Number(feed.numberMatched) > feed.features.length; + for (const [index, feature] of feed.features.entries()) { + try { + const props = dwdProperties.parse(feature.properties); + if (Date.parse(props.EXPIRES) <= Date.now()) continue; + hazards.push(roadtripHazardSchema.parse({ + id: `dwd-${feature.id ?? index}`, source: 'DWD', title: props.HEADLINE, description: props.DESCRIPTION ?? '', + updatedAt: utc(props.SENT), validUntil: utc(props.EXPIRES), + url: 'https://www.dwd.de/DE/wetter/warnungen_gemeinden/warnWetter_node.html', geometry: feature.geometry, + })); + } catch { partial = true; } + } + return { hazards, status: partial ? 'partial' as const : 'ok' as const }; + } + + private async gdacs() { + const feed = await fetchCollection('https://www.gdacs.org/gdacsapi/api/events/geteventlist/events4app'); + const hazards: RoadtripHazard[] = []; + let partial = feed.features.length >= 100; + const current = feed.features.filter(feature => feature.properties.iscurrent === 'true' || feature.properties.iscurrent === true); + for (const feature of current) { + try { + const props = gdacsProperties.parse(feature.properties); + hazards.push(roadtripHazardSchema.parse({ + id: `gdacs-${props.eventtype}-${props.eventid}-${props.episodeid}`, source: 'GDACS', + title: props.name, description: props.description, updatedAt: utc(props.datemodified), validUntil: null, + url: `https://www.gdacs.org/report.aspx?eventtype=${props.eventtype}&eventid=${props.eventid}&episodeid=${props.episodeid}`, + geometry: feature.geometry, + alertScore: props.episodealertscore ?? props.alertscore, + })); + } catch { partial = true; } + } + const areas = hazards.filter(hazard => /^gdacs-(FL|WF|TC)-/.test(hazard.id)); + if (areas.length > 12) partial = true; + await Promise.all(areas.slice(0, 12).map(async hazard => { + const [, eventtype, eventid, episodeid] = hazard.id.split('-'); + try { + const shapes = await fetchCollection(`https://www.gdacs.org/gdacsapi/api/polygons/getgeometry?eventtype=${eventtype}&eventid=${eventid}&episodeid=${episodeid}`); + const polygons = shapes.features.filter(feature => feature.properties.Class === 'Poly_Affected').flatMap(feature => { + const parsed = hazardGeometrySchema.safeParse(feature.geometry); + if (!parsed.success) { partial = true; return []; } + return parsed.data.type === 'Polygon' ? [parsed.data.coordinates] : parsed.data.type === 'MultiPolygon' ? parsed.data.coordinates : []; + }); + if (polygons.length) hazard.geometry = hazardGeometrySchema.parse({ type: 'MultiPolygon', coordinates: polygons }); + } catch { partial = true; } + })); + return { hazards, status: partial ? 'partial' as const : 'ok' as const }; + } +} diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts new file mode 100644 index 000000000..42647b470 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts @@ -0,0 +1,249 @@ +import { DatabaseService } from '../database/database.service'; +import { SettingsService } from '../settings/settings.service'; +import { DayBoundariesService } from './day-boundaries.service'; +import { RoadtripPreferencesService } from './roadtrip-preferences.service'; +import { RoadtripRouterService } from './roadtrip-router.service'; +import { RoadtripService } from './roadtrip.service'; +import { Injectable, HttpException } from '@nestjs/common'; +import { type RoadtripPreferences } from '@trek/shared'; +import { + assembleRoadtrip, + foldRouteRun, + splitIntoRuns, + spillChains, + dayWindow, + parseClock, + effectiveRangeKm, + parseAvoid, + type RoadtripStop, + type PlanDay, + type RoutedLeg, + type SnappedWaypoint, + type RouteAvoidClass, +} from '@trek/shared/roadtrip'; + +interface StoredDay { + id: number; + day_number: number; + date: string | null; + title: string | null; + default_transport_mode: string | null; +} +interface VisitRow { + check_in: string | null; + check_out: string | null; + checkout_day: number | null; + id: number; + day_id: number; + place_id: number; + name: string; + lat: number | null; + lng: number | null; + time: string | null; + duration_minutes: number | null; + end_day: number; + leg_transport_mode: string | null; + incoming_leg_transport_mode: string | null; + stop_type: string | null; + fill_percent: number | null; +} +const stopKey = (s: RoadtripStop) => + `${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`; + +@Injectable() +export class RoadtripPlanService { + constructor( + private readonly db: DatabaseService, + private readonly settings: SettingsService, + private readonly preferences: RoadtripPreferencesService, + private readonly router: RoadtripRouterService, + private readonly roadtrip: RoadtripService, + private readonly boundaries: DayBoundariesService, + ) {} + + context(tripId: number, userId: number) { + if (!this.db.canAccessTrip(tripId, userId)) throw new HttpException({ error: 'Trip not found' }, 404); + const days = this.db.all( + 'SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number', + tripId, + ); + const visits = this.db.all( + `SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng, + COALESCE(a.assignment_time, p.place_time) AS time, p.duration_minutes, a.end_day, + a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.check_in, stay.check_out, checkout.day_number AS checkout_day + FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id + LEFT JOIN day_accommodations stay ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1) + LEFT JOIN days checkout ON checkout.id = stay.end_day_id + WHERE d.trip_id = ? ORDER BY d.day_number, a.order_index, a.created_at`, + tripId, + ); + return { + days, + visits, + settings: this.preferences.read(tripId), + profiles: this.router.profiles(), + vias: this.roadtrip.listForTrip(tripId), + tracks: this.roadtrip.tracksForTrip(tripId), + boundaries: this.boundaries.list(tripId), + }; + } + + async calculate(tripId: number, userId: number, overrides?: RoadtripPreferences) { + const context = this.context(tripId, userId); + const preferences = { ...context.settings, ...overrides }; + const window = dayWindow( + preferences.roadtrip_day_start, + preferences.roadtrip_day_end, + preferences.roadtrip_day_end_mode, + ); + if (preferences.roadtrip_day_start && preferences.roadtrip_day_end && !window) + throw new HttpException({ error: 'Day end must be later than day start.' }, 400); + const plan: PlanDay[] = context.days.map((day) => ({ + dayId: day.id, + dayNumber: day.day_number, + date: day.date, + title: day.title, + stops: context.visits + .filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null) + .map((v, index) => ({ + assignmentId: v.id, + ownerDayId: day.id, + ownerIndex: index, + placeId: v.place_id, + name: v.name, + lat: v.lat!, + lng: v.lng!, + time: v.time ?? v.check_in ?? null, + ...(v.checkout_day != null && parseClock(v.check_out) !== null ? { checkoutAt: v.checkout_day * 1440 + parseClock(v.check_out)! } : {}), + dwellMinutes: v.duration_minutes, + endDay: v.end_day === 1, + legMode: v.leg_transport_mode, + incomingLegMode: v.incoming_leg_transport_mode, + stopType: v.stop_type, + fillPercent: v.fill_percent, + })), + })); + const allLegs: Record = {}; + const snapByDay: Record> = {}; + const missedByDay: Record = {}; + const failures: { fromAssignmentId: number; toAssignmentId: number; reason: string }[] = []; + const avoid = parseAvoid(preferences.roadtrip_avoid); + const connectDays = preferences.roadtrip_connect_days || window !== null; + if (context.visits.length > 150) + throw new HttpException({ error: 'This trip exceeds the 150-visit calculation limit.' }, 400); + const asked = new Set(); + const fetchRun = async (stops: RoadtripStop[], dayId: number, profile: string) => { + const points: { lat: number; lng: number }[] = []; + const stopAt: number[] = []; + stops.forEach((stop, index) => { + stopAt.push(points.length); + points.push({ lat: stop.lat, lng: stop.lng }); + if (index < stops.length - 1) + points.push( + ...context.vias + .filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex) + .sort((a, b) => a.sequence - b.sequence) + .map((v) => ({ lat: v.lat, lng: v.lng })), + ); + }); + const pairs = stops.slice(0, -1).map((from, index) => ({ from, to: stops[index + 1] })); + pairs.forEach(({ from, to }) => asked.add(stopKey(from) + '>' + stopKey(to))); + try { + if (points.length > 100) throw new Error('Too many waypoints'); + const routed = await this.router.route(userId, tripId, dayId, points, profile, avoid); + const legs = routed.parts.map((part, index) => ({ + ...routed.leg.seg, + ...part, + from: [points[index].lat, points[index].lng] as [number, number], + to: [points[index + 1].lat, points[index + 1].lng] as [number, number], + })); + Object.assign( + allLegs, + foldRouteRun(stops, stopAt, { coordinates: routed.leg.line, legs, vias: routed.leg.vias }, profile), + ); + missedByDay[dayId] = [...new Set([...(missedByDay[dayId] ?? []), ...routed.avoidMissed])]; + stops.forEach((stop, index) => { + const snapped = routed.snapped?.[stopAt[index]]; + if (snapped) (snapByDay[stop.ownerDayId] ??= {})[stopKey(stop)] = snapped; + }); + } catch { + pairs.forEach(({ from, to }) => + failures.push({ + fromAssignmentId: from.assignmentId, + toAssignmentId: to.assignmentId, + reason: + points.length > 100 + ? 'More than 100 waypoints in this run.' + : 'Routing provider could not calculate this run. Check the profile, connection and provider limits.', + }), + ); + } + }; + for (const day of plan.filter((d) => d.stops.length > 1)) { + const fallback = context.days.find((d) => d.id === day.dayId)?.default_transport_mode || 'driving'; + for (const run of splitIntoRuns(day.stops, (from) => from.legMode ?? fallback)) + await fetchRun(run.stops, day.dayId, run.mode); + } + const chains = window + ? plan.filter((d) => d.stops.length) + : spillChains( + plan.filter((d) => d.stops.length > 1), + plan.filter((d) => d.stops.length < 2), + (a, b) => allLegs[stopKey(a) + '>' + stopKey(b)], + ); + let previous: RoadtripStop | undefined; + for (const chain of chains) { + const pairs = chain.stops.slice(0, -1).map((from, index) => ({ from, to: chain.stops[index + 1] })); + if (connectDays && previous && chain.stops.length) pairs.push({ from: previous, to: chain.stops[0] }); + for (const { from, to } of pairs) { + if (asked.has(stopKey(from) + '>' + stopKey(to))) continue; + await fetchRun( + [from, to], + chain.dayId, + from.legMode ?? context.days.find((d) => d.id === chain.dayId)?.default_transport_mode ?? 'driving', + ); + } + previous = chain.stops[chain.stops.length - 1] ?? previous; + } + const vehicleKind = preferences.roadtrip_vehicle || null; + const rangeKm = + effectiveRangeKm( + vehicleKind, + { + tankLitres: preferences.roadtrip_tank_litres, + litresPer100: preferences.roadtrip_litres_per_100, + batteryKwh: preferences.roadtrip_battery_kwh, + kwhPer100: preferences.roadtrip_kwh_per_100, + degradationPercent: preferences.roadtrip_battery_degradation, + }, + preferences.roadtrip_range_km, + ) || null; + const calculated = assembleRoadtrip({ + plan: plan.filter((d) => d.stops.length > 1), + quietDays: plan.filter((d) => d.stops.length < 2), + window, + allLegs, + snapByDay, + missedByDay, + loading: false, + limits: { + legMinutes: preferences.roadtrip_leg_minutes || null, + dayMinutes: preferences.roadtrip_day_minutes || null, + rangeKm, + fillPercent: preferences.roadtrip_fill_percent || null, + }, + vehicleKind, + connectDays, + boundaries: context.boundaries, + distanceUnit: this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric', + labels: { start: 'Continue journey', end: 'End of day' }, + }); + return { + context, + preferences, + calculated, + failures, + omittedVisits: context.visits.filter((v) => v.lat === null || v.lng === null).map((v) => v.id), + }; + } +} diff --git a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts new file mode 100644 index 000000000..12065a361 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts @@ -0,0 +1,160 @@ +import { ADDON_IDS } from '../../addons'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, errorResult, type McpContext } from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { RoadtripSearchService } from './roadtrip-search.service'; +import { RoadtripPlanService } from './roadtrip-plan.service'; +import { + roadtripPlanRequestSchema, + roadtripCorridorRequestSchema, + type RoadtripPlanRequest, + type RoadtripCorridorRequest, +} from '@trek/shared'; +import { corridorTiles, projectOntoRoute, simplifyLine } from '@trek/shared/roadtrip'; + +import { z } from 'zod'; + +const when = addonGate(ADDON_IDS.ROADTRIP); + +@McpController() +export class RoadtripPlanningMcp { + constructor( + private readonly plans: RoadtripPlanService, + private readonly maps: RoadtripSearchService, + readonly addons: AddonsService, + ) {} + + @Tool({ + name: 'get_roadtrip_context', + description: + 'Read the saved roadtrip days, visits, pinned times, stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.', + inputSchema: { tripId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'trips', mode: 'read' }, + when, + }) + async context({ tripId }: { tripId: number }, ctx: McpContext) { + return ok(this.plans.context(tripId, ctx.userId)); + } + + @Tool({ + name: 'calculate_roadtrip', + description: + 'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. Fixed visit times retain priority; no accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.', + inputSchema: roadtripPlanRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'trips', mode: 'read' }, + when, + }) + async calculate(input: RoadtripPlanRequest, ctx: McpContext) { + const plan = await this.plans.calculate(input.tripId, ctx.userId, input.settings); + const { calculated } = plan; + return ok({ + complete: !plan.failures.length && !plan.omittedVisits.length && !calculated.dayWindowIssue, + settings: plan.preferences, + failures: plan.failures, + omittedVisitIds: plan.omittedVisits, + dayWindowIssue: calculated.dayWindowIssue, + totalDistanceMetres: calculated.totalDistance, + totalDurationSeconds: calculated.totalDuration, + totalStops: calculated.totalStops, + days: calculated.days.map((day) => ({ + dayId: day.dayId, + dayNumber: day.dayNumber, + date: day.date, + title: day.title, + stops: day.stops, + schedule: day.schedule, + legs: day.legs, + distanceMetres: day.distance, + durationSeconds: day.duration, + driveWarnings: day.driveWarnings, + dayWarning: day.dayWarning, + avoidMissed: day.avoidMissed, + dryPoints: day.dryPoints, + ...(input.includeGeometry ? { geometry: day.geometry } : {}), + })), + quietDays: calculated.quietDays, + }); + } + + @Tool({ + name: 'search_roadtrip_corridor', + description: + 'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. Routing failures refuse the corridor search rather than searching an invented line.', + inputSchema: roadtripCorridorRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'trips', mode: 'read' }, + when, + }) + async corridor(input: RoadtripCorridorRequest, ctx: McpContext) { + const plan = await this.plans.calculate(input.tripId, ctx.userId); + if (plan.failures.length || plan.omittedVisits.length || plan.calculated.dayWindowIssue) + return errorResult( + 'Resolve missing coordinates, routing or day-window conflicts before searching this corridor.', + ); + if (input.fromKm != null && input.toKm != null && input.fromKm > input.toKm) + return errorResult('fromKm must not exceed toKm.'); + const day = plan.calculated.days.find((d) => d.dayNumber === input.dayNumber); + if (!day?.geometry.length) return errorResult('This day has no routed road.'); + const line = simplifyLine( + day.geometry.map(([lat, lng]) => ({ lat, lng })), + Math.max(1, input.widthKm / 3), + ); + const tiles = corridorTiles(line, input.widthKm); + const hits = new Map< + string, + { poi: Awaited>['pois'][number]; alongKm: number; distanceKm: number } + >(); + const page = tiles.slice(input.offset, input.offset + 6); + const failedAreas: number[] = []; + let truncatedAreas = 0; + const sources = new Set(); + const failedSources = new Set(); + for (const bbox of page) { + try { + const found = await this.maps.search({ categories: [input.category], bbox }, ctx.userId); + found.sources.forEach(source => sources.add(source)); + found.failedSources.forEach(source => failedSources.add(source)); + if (found.truncated || found.clamped) truncatedAreas++; + for (const poi of found.pois) { + const projection = projectOntoRoute(poi, line); + if (!projection || projection.offRouteKm > input.widthKm) continue; + if ( + (input.fromKm != null && projection.alongKm < input.fromKm) || + (input.toKm != null && projection.alongKm > input.toKm) + ) + continue; + if ( + input.name && + !`${poi.name} ${poi.brand ?? ''}`.toLocaleLowerCase().includes(input.name.toLocaleLowerCase()) + ) + continue; + if (poi.category === 'charging') { + if (input.socket && !poi.charging?.sockets.some((socket) => socket.type === input.socket)) continue; + if ( + input.minKw && + poi.charging?.sockets.some((socket) => socket.kw != null) && + !poi.charging.sockets.some((socket) => (socket.kw ?? 0) >= input.minKw!) + ) + continue; + } + hits.set(poi.osm_id, { poi, alongKm: projection.alongKm, distanceKm: projection.offRouteKm }); + } + } catch { + failedAreas.push(input.offset + page.indexOf(bbox)); + } + } + return ok({ + dayNumber: day.dayNumber, + sources: [...sources], + failedSources: [...failedSources], + complete: !failedSources.size && !failedAreas.length && !truncatedAreas && input.offset + page.length >= tiles.length, + failedAreas, + truncatedAreas, + totalAreas: tiles.length, + hits: [...hits.values()].sort((a, b) => a.alongKm - b.alongKm), + nextOffset: input.offset + page.length < tiles.length ? input.offset + page.length : null, + }); + } +} diff --git a/server/src/nest/roadtrip/roadtrip-preferences.controller.ts b/server/src/nest/roadtrip/roadtrip-preferences.controller.ts new file mode 100644 index 000000000..2755f6c55 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-preferences.controller.ts @@ -0,0 +1,30 @@ +import { ADDON_IDS } from '../../addons'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; +import { RoadtripPreferencesService } from './roadtrip-preferences.service'; +import { Body, Controller, Get, Param, Put, UseGuards } from '@nestjs/common'; +import { roadtripPreferencesUpdateSchema } from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +class PreferencesDto extends createZodDto(roadtripPreferencesUpdateSchema) {} + +@Controller('api/trips/:tripId/roadtrip/preferences') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip') +export class RoadtripPreferencesController { + constructor(private readonly preferences: RoadtripPreferencesService) {} + + @Get() + read(@Param('tripId') tripId: string) { + return { tripId: Number(tripId), preferences: this.preferences.read(Number(tripId)) }; + } + + @Put() + @RequirePermission('day_edit') + update(@Param('tripId') tripId: string, @Body() patch: PreferencesDto) { + return { tripId: Number(tripId), preferences: this.preferences.update(Number(tripId), patch) }; + } +} diff --git a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts new file mode 100644 index 000000000..286f42be7 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts @@ -0,0 +1,63 @@ +import { ADDON_IDS } from '../../addons'; +import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; +import { + McpController, + Tool, + TOOL_ANNOTATIONS_READONLY, + TOOL_ANNOTATIONS_WRITE, + demoDenied, + ok, + type McpContext, +} from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { AuthService } from '../auth/auth.service'; +import { DatabaseService } from '../database/database.service'; +import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; +import { RoadtripPreferencesService } from './roadtrip-preferences.service'; +import { roadtripPreferencesUpdateSchema, type RoadtripPreferences } from '@trek/shared'; + +import { z } from 'zod'; + +const when = addonGate(ADDON_IDS.ROADTRIP); + +@McpController() +export class RoadtripPreferencesMcp { + constructor( + private readonly preferences: RoadtripPreferencesService, + private readonly auth: AuthService, + readonly addons: AddonsService, + private readonly db: DatabaseService, + private readonly guards: McpToolGuardsService, + ) {} + + @Tool({ + name: 'get_roadtrip_settings', + description: + 'Read the shared driving preferences for one trip, including daily start and end times, day-ending behavior, vehicle specifications, range, fill percentage, driving limits, avoided road classes and route display options. Every member of this trip sees the same preferences. Missing numbers mean no limit, missing daily times disable automatic scheduling, missing vehicle means unspecified, and missing end mode means route. Range and consumption always use kilometres, litres and kWh. Instance URLs and credentials are not exposed.', + inputSchema: { tripId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'trips', mode: 'read' }, + when, + }) + async read({ tripId }: { tripId: number }, ctx: McpContext) { + if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + return ok({ tripId, settings: this.preferences.read(tripId), scope: 'trip' }); + } + + @Tool({ + name: 'update_roadtrip_settings', + description: + 'Change the shared driving preferences for this trip. Changes apply to all its travellers and require permission to edit days. Read get_roadtrip_settings first for relative changes. Daily times use HH:mm; clear either with an empty string to disable automatic daily scheduling. Fixed visit times retain priority. Numeric zero clears a limit. Vehicle specifications take precedence over manual range when complete; clear the relevant specifications to use a manual range. Avoid classes are a comma-separated selection of toll,motorway,ferry. Unspecified fields remain unchanged. End mode route pauses at the cutoff; stop ends at the last reachable visit. Per-visit end_day and dragged boundaries are edited with set_assignment_end_day and set_day_boundary. This never changes instance routing URLs or credentials.', + inputSchema: { tripId: z.number().int().positive(), settings: roadtripPreferencesUpdateSchema }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'trips', mode: 'write' }, + when, + }) + async update({ tripId, settings }: { tripId: number; settings: RoadtripPreferences }, ctx: McpContext) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + return ok({ tripId, settings: this.preferences.update(tripId, settings), scope: 'trip' }); + } +} diff --git a/server/src/nest/roadtrip/roadtrip-preferences.service.ts b/server/src/nest/roadtrip/roadtrip-preferences.service.ts new file mode 100644 index 000000000..87051decb --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-preferences.service.ts @@ -0,0 +1,58 @@ +import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; +import { HttpException, Injectable } from '@nestjs/common'; +import { + ROADTRIP_PREFERENCE_KEYS, + roadtripPreferencesSchema, + roadtripPreferencesUpdateSchema, + type RoadtripPreferences, +} from '@trek/shared'; + +@Injectable() +export class RoadtripPreferencesService { + constructor( + private readonly db: DatabaseService, + private readonly realtime: RealtimeService, + ) {} + + read(tripId: number): RoadtripPreferences { + const settings: Record = {}; + for (const row of this.db.all<{ key: string; value: string }>( + 'SELECT key, value FROM roadtrip_preferences WHERE trip_id = ?', + tripId, + )) { + try { + settings[row.key] = JSON.parse(row.value); + } catch { + settings[row.key] = row.value; + } + } + const preferences: Record = {}; + for (const key of ROADTRIP_PREFERENCE_KEYS) { + const parsed = roadtripPreferencesSchema.shape[key].safeParse(settings[key]); + if (parsed.success && parsed.data !== undefined) preferences[key] = parsed.data; + } + return roadtripPreferencesSchema.parse(preferences); + } + + update(tripId: number, patch: RoadtripPreferences): RoadtripPreferences { + const validated = roadtripPreferencesUpdateSchema.parse(patch); + const saved = this.db.transaction(() => { + const next = { ...this.read(tripId), ...validated }; + if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) { + throw new HttpException({ error: 'Day end must be later than day start.' }, 400); + } + for (const [key, value] of Object.entries(validated)) { + this.db.run( + 'INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES (?, ?, ?) ON CONFLICT(trip_id, key) DO UPDATE SET value = excluded.value', + tripId, + key, + JSON.stringify(value), + ); + } + return this.read(tripId); + }); + this.realtime.broadcast(String(tripId), 'roadtripPreferences:changed', { preferences: saved }); + return saved; + } +} diff --git a/server/src/nest/roadtrip/roadtrip-router.service.ts b/server/src/nest/roadtrip/roadtrip-router.service.ts new file mode 100644 index 000000000..99945476b --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-router.service.ts @@ -0,0 +1,306 @@ +import { readCappedJson, discardBody } from '../../utils/cappedFetch'; +import { safeFetchAdminConfigured } from '../../utils/ssrfGuard'; +import { DatabaseService } from '../database/database.service'; +import { normalize, declaredProfiles } from '../plugins/contributions/plugin-route-normalize'; +import { pluginsEnabled } from '../plugins/kill-switch'; +import { PluginHooks } from '../plugins/plugin-hooks.service'; +import { SettingsService } from '../settings/settings.service'; +import { Injectable } from '@nestjs/common'; +import { + formatDurationShort, + haversineKm, + type RoutedLeg, + type RouteAvoidClass, + type SnappedWaypoint, +} from '@trek/shared/roadtrip'; + +import { z } from 'zod'; + +const coordinate = z.tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]); +const osrmSchema = z.object({ + code: z.literal('Ok'), + routes: z + .array( + z.object({ + distance: z.number().nonnegative(), + duration: z.number().nonnegative(), + legs: z + .array(z.object({ distance: z.number().nonnegative(), duration: z.number().nonnegative() })) + .min(1) + .max(99), + geometry: z.object({ coordinates: z.array(coordinate).min(2).max(200000) }), + }), + ) + .min(1), + waypoints: z.array(z.object({ location: coordinate, distance: z.number().nonnegative() })).optional(), +}); +const valhallaSchema = z.object({ + trip: z.object({ + legs: z + .array( + z.object({ + shape: z.string().max(2000000), + summary: z.object({ + length: z.number().nonnegative(), + time: z.number().nonnegative(), + has_toll: z.boolean().optional(), + has_highway: z.boolean().optional(), + has_ferry: z.boolean().optional(), + }), + }), + ) + .min(1) + .max(99), + }), +}); +type Point = { lat: number; lng: number }; +export interface RoadtripRoute { + leg: RoutedLeg; + parts: { distance: number; duration: number }[]; + snapped?: SnappedWaypoint[]; + avoidMissed: RouteAvoidClass[]; +} + +function decode(shape: string): [number, number][] { + const line: [number, number][] = []; + let index = 0, + lat = 0, + lng = 0; + while (index < shape.length) { + const pair: number[] = []; + for (let axis = 0; axis < 2; axis++) { + let value = 0, + shift = 0, + byte: number; + do { + if (index >= shape.length || shift > 30) throw new Error('Invalid route geometry'); + byte = shape.charCodeAt(index++) - 63; + if (byte < 0 || byte > 63) throw new Error('Invalid route geometry'); + value |= (byte & 31) << shift; + shift += 5; + } while (byte >= 32); + pair.push(value & 1 ? ~(value >> 1) : value >> 1); + } + lat += pair[0]; + lng += pair[1]; + coordinate.parse([lng / 1e6, lat / 1e6]); + line.push([lat / 1e6, lng / 1e6]); + } + if (line.length < 2) throw new Error('Invalid route geometry'); + return line; +} + +@Injectable() +export class RoadtripRouterService { + private nextRequest = 0; + private queue: Promise = Promise.resolve(); + private cache = new Map(); + + constructor( + private readonly settings: SettingsService, + private readonly hooks: PluginHooks, + private readonly db: DatabaseService, + ) {} + + profiles(): string[] { + return [ + 'driving', + 'walking', + 'cycling', + ...(pluginsEnabled() + ? this.hooks + .providersOf('routeProvider') + .flatMap((id) => declaredProfiles(this.db.connection, id).map((profile) => `plugin:${id}/${profile}`)) + : []), + ]; + } + + private async request(url: string, body?: unknown): Promise { + const before = this.queue; + let release!: () => void; + this.queue = new Promise((resolve) => { + release = resolve; + }); + await before; + try { + const delay = this.nextRequest - Date.now(); + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); + this.nextRequest = Date.now() + 1100; + const response = await safeFetchAdminConfigured(url, { + signal: AbortSignal.timeout(20000), + headers: { 'User-Agent': 'TREK Roadtrip', 'Content-Type': 'application/json', 'X-Client-Id': 'trek' }, + ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}), + }); + if (!response.ok) { + discardBody(response); + throw new Error(`Routing provider returned ${response.status}`); + } + const payload = await readCappedJson(response, 8 * 1024 * 1024); + if (payload === undefined) throw new Error('Routing response is invalid or too large'); + return payload; + } finally { + release(); + } + } + + async route( + userId: number, + tripId: number, + dayId: number, + points: Point[], + profile: string, + avoid: RouteAvoidClass[], + ): Promise { + const settings = this.settings.getUserSettings(userId); + const key = JSON.stringify([ + userId, + tripId, + dayId, + points, + profile, + avoid, + settings.routing_base_url, + settings.valhalla_base_url, + ]); + const cached = this.cache.get(key); + if (!profile.startsWith('plugin:') && cached && Date.now() - cached.at < 300000) return cached.route; + let line: [number, number][], distance: number, duration: number; + let parts: { distance: number; duration: number }[]; + let vias: RoutedLeg['vias'] = []; + let avoidMissed = profile === 'driving' ? [...avoid] : []; + let snapped: SnappedWaypoint[] | undefined; + const plugin = /^plugin:([^/]+)\/(.+)$/.exec(profile); + if (plugin) { + const [, id, name] = plugin; + if ( + points.length > 30 || + !pluginsEnabled() || + !this.hooks.providersOf('routeProvider').includes(id) || + !declaredProfiles(this.db.connection, id).includes(name) + ) + throw new Error('Routing plugin is unavailable or has too many waypoints'); + const raw = await this.hooks.route(id, { tripId, dayId, profile: name, waypoints: points }, userId); + const route = normalize(id, name, points.length, raw); + if (!route) throw new Error('Routing plugin could not calculate this leg'); + line = route.coordinates; + distance = route.distance; + duration = route.duration; + vias = route.viaPoints; + parts = route.legs; + } else { + const mode = profile === 'walking' || profile === 'cycling' ? profile : 'driving'; + const base = + typeof settings.routing_base_url === 'string' ? settings.routing_base_url.trim().replace(/\/+$/, '') : ''; + const configuredValhalla = + typeof settings.valhalla_base_url === 'string' ? settings.valhalla_base_url.trim().replace(/\/+$/, '') : ''; + const valhalla = configuredValhalla || (base ? '' : 'https://valhalla1.openstreetmap.de'); + let weighted = false; + if (mode === 'driving' && avoid.length && valhalla) { + try { + const options = { + use_tolls: avoid.includes('toll') ? 0 : 1, + use_highways: avoid.includes('motorway') ? 0 : 1, + use_ferry: avoid.includes('ferry') ? 0 : 1, + }; + const legs: z.infer['trip']['legs'] = []; + for (let start = 0; start < points.length - 1; start += 9) { + const payload = await this.request(`${valhalla}/route`, { + locations: points.slice(start, start + 10).map((p) => ({ lat: p.lat, lon: p.lng, radius: 50 })), + costing: 'auto', + costing_options: { auto: options }, + directions_options: { units: 'kilometers' }, + }); + legs.push(...valhallaSchema.parse(payload).trip.legs); + } + if (legs.length !== points.length - 1) throw new Error('Routing response has mismatched legs'); + const shapes = legs.map((leg) => decode(leg.shape)); + line = shapes.flat(); + const locations = [...shapes.map((shape) => shape[0]), shapes[shapes.length - 1].at(-1)!]; + snapped = locations.map(([lat, lng], index) => ({ + asked: [points[index].lat, points[index].lng], + at: [lat, lng], + meters: haversineKm(points[index], { lat, lng }) * 1000, + })); + distance = legs.reduce((sum, leg) => sum + leg.summary.length * 1000, 0); + duration = legs.reduce((sum, leg) => sum + leg.summary.time, 0); + parts = legs.map((leg) => ({ distance: leg.summary.length * 1000, duration: leg.summary.time })); + avoidMissed = avoid.filter((cls) => + legs.some((leg) => + cls === 'toll' + ? leg.summary.has_toll + : cls === 'motorway' + ? leg.summary.has_highway + : leg.summary.has_ferry, + ), + ); + weighted = true; + } catch { + avoidMissed = [...avoid]; + snapped = undefined; + } + } + if (!weighted) { + const path = mode === 'walking' ? 'foot' : mode === 'cycling' ? 'bike' : 'driving'; + const origin = + base || + `https://routing.openstreetmap.de/routed-${mode === 'walking' ? 'foot' : mode === 'cycling' ? 'bike' : 'car'}`; + const payload = await this.request( + `${origin}/route/v1/${path}/${points.map((p) => `${p.lng},${p.lat}`).join(';')}?overview=full&geometries=geojson&steps=false`, + ); + const parsed = osrmSchema.parse(payload); + const route = parsed.routes[0]; + line = route.geometry.coordinates.map(([lng, lat]) => [lat, lng]); + distance = route.distance; + duration = route.duration; + parts = route.legs; + if (parsed.waypoints?.length === points.length) + snapped = parsed.waypoints.map((waypoint, index) => ({ + asked: [points[index].lat, points[index].lng], + at: [waypoint.location[1], waypoint.location[0]], + meters: waypoint.distance, + })); + } + } + const from = line[0], + to = line[line.length - 1]; + const durationText = formatDurationShort(duration); + const distanceText = `${Math.round(distance / 100) / 10} km`; + if (parts.length !== points.length - 1) throw new Error('Routing response has mismatched legs'); + const route = { + parts, + snapped, + leg: { + line, + vias, + seg: { + from, + to, + mid: line[Math.floor(line.length / 2)], + distance, + duration, + durationText, + distanceText, + drivingText: durationText, + walkingText: durationText, + mode: profile, + }, + snapped: { + from: { + lat: from[0], + lng: from[1], + offRoadMeters: haversineKm(points[0], { lat: from[0], lng: from[1] }) * 1000, + }, + to: { + lat: to[0], + lng: to[1], + offRoadMeters: haversineKm(points[points.length - 1], { lat: to[0], lng: to[1] }) * 1000, + }, + }, + }, + avoidMissed, + }; + this.cache.set(key, { at: Date.now(), route }); + if (this.cache.size > 100) this.cache.delete(this.cache.keys().next().value!); + return route; + } +} diff --git a/server/src/nest/roadtrip/roadtrip-search.controller.ts b/server/src/nest/roadtrip/roadtrip-search.controller.ts new file mode 100644 index 000000000..7c5777706 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-search.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { roadtripSearchAreaSchema } from '@trek/shared'; +import { createZodDto } from 'nestjs-zod'; +import { ADDON_IDS } from '../../addons'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RoadtripSearchService } from './roadtrip-search.service'; + +class SearchAreaDto extends createZodDto(roadtripSearchAreaSchema) {} + +@Controller('api/roadtrip/search-area') +@UseGuards(JwtAuthGuard, AddonGuard) +@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip') +export class RoadtripSearchController { + constructor(private readonly search: RoadtripSearchService) {} + @Post() + @HttpCode(200) + read(@Body() input: SearchAreaDto, @Req() req: Request & { user: { id: number } }) { + return this.search.search(input, req.user.id); + } +} diff --git a/server/src/nest/roadtrip/roadtrip-search.service.ts b/server/src/nest/roadtrip/roadtrip-search.service.ts new file mode 100644 index 000000000..98504e9e6 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-search.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import type { RoadtripSearchArea, RoadtripSearchAreaResponse } from '@trek/shared'; +import { MapsService } from '../maps/maps.service'; +import { PluginHooks } from '../plugins/plugin-hooks.service'; +import { pluginsEnabled } from '../plugins/kill-switch'; +import { normalizeSearchHits, MAX_HITS } from '../plugins/contributions/plugin-search.helpers'; + +const queries: Record = { + fuel: 'fuel station', charging: 'EV charging station', rest_area: 'rest area', + campsite: 'campsite', restaurant: 'restaurant', sights: 'tourist attraction', hotel: 'hotel', +}; + +@Injectable() +export class RoadtripSearchService { + constructor(private readonly maps: MapsService, private readonly hooks: PluginHooks) {} + + async search(input: RoadtripSearchArea, userId: number): Promise { + const { bbox, lang } = input; + const categories = [...new Set(input.categories)]; + const providers = pluginsEnabled() ? this.hooks.providersOf('searchProvider') : []; + const core = this.maps.pois(categories.join(','), bbox, lang); + const tasks = providers.flatMap(pluginId => categories.map(async category => { + const raw = await this.hooks.searchPlaces(pluginId, { + query: queries[category], category, bounds: bbox, lang, limit: MAX_HITS, + near: { lat: (bbox.south + bbox.north) / 2, lng: (bbox.west + bbox.east) / 2 }, + }, userId); + if (!Array.isArray(raw)) throw new Error('Invalid plugin search answer'); + const normalized = normalizeSearchHits(pluginId, raw); + const pois = normalized.filter(p => p.lat >= bbox.south && p.lat <= bbox.north && p.lng >= bbox.west && p.lng <= bbox.east) + .filter(p => !p.category || !(p.category in queries) || p.category === category) + .map(p => ({ ...p, category, poi_type: category, opening_hours: null, cuisine: null, charging: null })); + return { pois, source: `plugin:${pluginId}`, truncated: raw.length >= MAX_HITS || normalized.length < raw.length }; + })); + const settled = await Promise.allSettled([core, ...tasks]); + const sources = new Set(); + const failedSources = new Set(); + const pois = new Map(); + let truncated = false; + let clamped = false; + settled.forEach((entry, index) => { + if (entry.status === 'rejected') { + failedSources.add(index === 0 ? 'TREK' : `plugin:${providers[Math.floor((index - 1) / categories.length)]}`); + return; + } + sources.add(entry.value.source); + truncated ||= entry.value.truncated; + if ('clamped' in entry.value) clamped ||= entry.value.clamped; + for (const poi of entry.value.pois) pois.set(poi.osm_id, poi); + }); + return { pois: [...pois.values()], sources: [...sources], failedSources: [...failedSources], truncated, clamped }; + } +} diff --git a/server/src/nest/roadtrip/roadtrip.mcp.ts b/server/src/nest/roadtrip/roadtrip.mcp.ts index c7b08e084..c584bbab6 100644 --- a/server/src/nest/roadtrip/roadtrip.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip.mcp.ts @@ -8,6 +8,7 @@ import { AuthService } from '../auth/auth.service'; import { ADDON_IDS } from '../../addons'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; +import { roadtripViaUpdateRequestSchema, type RoadtripViaUpdateRequest } from '@trek/shared'; /** * The whole surface rides the road trip addon, the same way the controller does @@ -184,6 +185,23 @@ export class RoadtripMcp { return ok({ success: true }); } + @Tool({ + name: 'update_route_via', + description: 'Move an existing via point and optionally attach it to another outgoing leg of the same day. This is the same change as dragging a route handle. A via bends the route and does not add a stop or stay.', + inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive(), viaId: z.number().int().positive(), ...roadtripViaUpdateRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'trips', mode: 'write' }, when: roadtripAddonOn, + }) + async updateVia(input: RoadtripViaUpdateRequest & { tripId: number; dayId: number; viaId: number }, ctx: McpContext) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId)) return permissionDenied(); + if (!this.roadtrip.dayExists(input.dayId, input.tripId)) return noAccess(); + const via = this.roadtrip.move(input.viaId, input.dayId, input.lat, input.lng, input.after_order_index); + if (!via) return noAccess(); + this.announce(input.tripId, input.dayId); + return ok({ via }); + } + /** * Says what this day's drive is routed through now. * diff --git a/server/src/nest/roadtrip/roadtrip.module.ts b/server/src/nest/roadtrip/roadtrip.module.ts index 5cdfeecbd..22603ad9f 100644 --- a/server/src/nest/roadtrip/roadtrip.module.ts +++ b/server/src/nest/roadtrip/roadtrip.module.ts @@ -1,20 +1,67 @@ -import { Module } from '@nestjs/common'; -import { RoadtripController } from './roadtrip.controller'; -import { RoadtripService } from './roadtrip.service'; -import { RoadtripMcp } from './roadtrip.mcp'; +import { ChargingMcp } from './charging.mcp'; +import { ChargingService } from './charging.service'; +import { ChargingController } from './charging.controller'; +import { GoogleRouteService } from './google-route.service'; +import { GoogleRouteController } from './google-route.controller'; +import { GoogleRouteMcp } from './google-route.mcp'; +import { PlacesModule } from '../places/places.module'; +import { AssignmentsModule } from '../assignments/assignments.module'; +import { AddonsModule } from '../addons/addons.module'; +import { AuthModule } from '../auth/auth.module'; +import { MapsModule } from '../maps/maps.module'; import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; import { PermissionsModule } from '../permissions/permissions.module'; -import { AuthModule } from '../auth/auth.module'; +import { PluginsRuntimeModule } from '../plugins/plugins-runtime.module'; import { RealtimeModule } from '../realtime/realtime.module'; -import { AddonsModule } from '../addons/addons.module'; +import { SettingsModule } from '../settings/settings.module'; +import { DayBoundariesController } from './day-boundaries.controller'; +import { DayBoundariesMcp } from './day-boundaries.mcp'; +import { DayBoundariesService } from './day-boundaries.service'; +import { RoadtripPlanService } from './roadtrip-plan.service'; +import { RoadtripPlanningMcp } from './roadtrip-planning.mcp'; +import { RoadtripPreferencesController } from './roadtrip-preferences.controller'; +import { RoadtripPreferencesMcp } from './roadtrip-preferences.mcp'; +import { RoadtripPreferencesService } from './roadtrip-preferences.service'; +import { RoadtripRouterService } from './roadtrip-router.service'; +import { RoadtripController } from './roadtrip.controller'; +import { RoadtripMcp } from './roadtrip.mcp'; +import { RoadtripService } from './roadtrip.service'; +import { RoadtripSearchService } from './roadtrip-search.service'; +import { RoadtripSearchController } from './roadtrip-search.controller'; +import { Module } from '@nestjs/common'; +import { RoadtripHazardsController } from './roadtrip-hazards.controller'; +import { RoadtripHazardsService } from './roadtrip-hazards.service'; +import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp'; /** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */ @Module({ // McpShared brings the tool guards, Auth the demo check, Permissions the trip guard, // Addons the enabled-check the MCP tools gate on (the controller has @RequireAddon). - imports: [McpSharedModule, PermissionsModule, AuthModule, AddonsModule, RealtimeModule], - controllers: [RoadtripController], - providers: [RoadtripService, RoadtripMcp], + imports: [ + McpSharedModule, + PermissionsModule, + AuthModule, + AddonsModule, + RealtimeModule, + SettingsModule, + PluginsRuntimeModule, + MapsModule, PlacesModule, AssignmentsModule, + ], + controllers: [ChargingController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController], + providers: [ChargingMcp, ChargingService, GoogleRouteService, GoogleRouteMcp, + RoadtripSearchService, + RoadtripHazardsService, + RoadtripHazardsMcp, + RoadtripService, + RoadtripMcp, + DayBoundariesService, + DayBoundariesMcp, + RoadtripPreferencesService, + RoadtripPreferencesMcp, + RoadtripRouterService, + RoadtripPlanService, + RoadtripPlanningMcp, + ], exports: [RoadtripService], }) export class RoadtripModule {} diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 9d1b49158..c43158511 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -610,8 +610,8 @@ export class TripsService { `).all(sourceTripId) as any[]; const assignmentMap = new Map(); const insertAssignment = this.db.prepare(` - INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time, end_day) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const a of oldAssignments) { const newDayId = dayMap.get(a.day_id); @@ -619,11 +619,22 @@ export class TripsService { if (newDayId && newPlaceId) { const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes, a.reservation_status, a.reservation_notes, a.reservation_datetime, - a.assignment_time, a.assignment_end_time); + a.assignment_time, a.assignment_end_time, a.end_day ?? 0); assignmentMap.set(a.id, r.lastInsertRowid); } } + this.db.prepare('INSERT INTO roadtrip_preferences (trip_id, key, value) SELECT ?, key, value FROM roadtrip_preferences WHERE trip_id = ?').run(newTripId, sourceTripId); + const oldBoundaries = this.db.prepare('SELECT * FROM roadtrip_day_boundaries WHERE trip_id = ?').all(sourceTripId) as { + day_number: number; from_assignment_id: number; to_assignment_id: number | null; fraction: number; + }[]; + const insertBoundary = this.db.prepare('INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, ?, ?, ?, ?)'); + for (const boundary of oldBoundaries) { + const from = assignmentMap.get(boundary.from_assignment_id); + const to = boundary.to_assignment_id === null ? null : assignmentMap.get(boundary.to_assignment_id); + if (from && to !== undefined) insertBoundary.run(newTripId, boundary.day_number, from, to, boundary.fraction); + } + const oldParticipants = this.db.prepare(` SELECT ap.* FROM assignment_participants ap JOIN day_assignments da ON da.id = ap.assignment_id diff --git a/server/src/types.ts b/server/src/types.ts index 9ed944821..c5d54dd32 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -114,6 +114,7 @@ export interface DayAssignment { reservation_datetime?: string | null; assignment_time?: string | null; assignment_end_time?: string | null; + end_day?: number; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; created_at?: string; diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts index 5ae8009e4..70ab12a9e 100644 --- a/server/tests/e2e/roadtrip.e2e.test.ts +++ b/server/tests/e2e/roadtrip.e2e.test.ts @@ -1,3 +1,6 @@ +import { RoadtripSearchService } from '../../src/nest/roadtrip/roadtrip-search.service'; +import { GoogleRouteService } from '../../src/nest/roadtrip/google-route.service'; +import { ChargingService } from '../../src/nest/roadtrip/charging.service'; /** * Road trip module e2e — the real guard chain against a temp SQLite db. * @@ -11,14 +14,20 @@ * The addon ships disabled, so "off" is the state an existing install upgrades * into. That is what makes it worth a test rather than a comment. */ -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; -import request from 'supertest'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { PermissionsService } from '../../src/nest/permissions/permissions.service'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module'; +import { RoadtripHazardsService } from '../../src/nest/roadtrip/roadtrip-hazards.service'; +import { seedUser, sessionCookie } from './harness'; +import { Test } from '@nestjs/testing'; + import cookieParser from 'cookie-parser'; import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { DatabaseModule } from '../../src/nest/database/database.module'; -import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; -import { seedUser, sessionCookie } from './harness'; +import request from 'supertest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; const { db } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -30,6 +39,9 @@ const { db } = vi.hoisted(() => { email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0, avatar TEXT);`); tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, end_date TEXT);'); + tmp.exec( + 'CREATE TABLE roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(trip_id, key));', + ); tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);'); tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT, title TEXT, notes TEXT);`); @@ -52,11 +64,15 @@ const { db } = vi.hoisted(() => { vi.mock('../../src/db/database', () => ({ db, canAccessTrip: (tripId: number | string, userId: number) => - db.prepare(` + db + .prepare( + ` SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) - `).get(userId, tripId, userId), + `, + ) + .get(userId, tripId, userId), isOwner: () => false, getPlaceWithTags: () => null, closeDb: () => {}, @@ -64,16 +80,12 @@ vi.mock('../../src/db/database', () => ({ })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); -import { PermissionsService } from '../../src/nest/permissions/permissions.service'; -import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; -import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; - const ADDON_ID = 'roadtrip'; function setAddon(enabled: boolean): void { - db.prepare('INSERT INTO addons (id, enabled) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled') - .run(ADDON_ID, enabled ? 1 : 0); + db.prepare( + 'INSERT INTO addons (id, enabled) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled', + ).run(ADDON_ID, enabled ? 1 : 0); } describe('Roadtrip e2e (real guard chain + temp SQLite)', () => { @@ -117,15 +129,127 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => { setAddon(true); }); - afterAll(async () => { await app?.close(); }); + afterAll(async () => { + await app?.close(); + }); const cookie = () => sessionCookie(1); + it('gates charging data by addon and trip membership', async () => { + const read = vi.spyOn(app.get(ChargingService), 'read').mockResolvedValue({} as never); + try { + await request(server).get('/api/trips/5/roadtrip/charging/10').expect(401); + await request(server).get('/api/trips/6/roadtrip/charging/10').set('Cookie', cookie()).expect(404); + await request(server).get('/api/trips/5/roadtrip/charging/10').set('Cookie', cookie()).expect(200); + expect(read).toHaveBeenCalledWith(5, 10); + setAddon(false); + await request(server).get('/api/trips/5/roadtrip/charging/10').set('Cookie', cookie()).expect(404); + expect(read).toHaveBeenCalledTimes(1); + } finally { read.mockRestore(); } + }); + it('validates Google route preview and import before executing either service', async () => { + const routes = app.get(GoogleRouteService); + const preview = vi.spyOn(routes, 'preview').mockResolvedValue({ stops: [] }); + const save = vi.spyOn(routes, 'import').mockReturnValue({ imported: 2 }); + const input = { dayId: 3, stops: [{ name: 'A', lat: 48, lng: 11 }, { name: 'B', lat: 41, lng: 12 }] }; + try { + await request(server).post('/api/roadtrip/google-maps-preview').send({ url: 'https://google.com/maps/dir/A/B' }).expect(401); + await request(server).post('/api/roadtrip/google-maps-preview').set('Cookie', cookie()).send({ url: 'bad' }).expect(400); + await request(server).post('/api/roadtrip/google-maps-preview').set('Cookie', cookie()).send({ url: 'https://google.com/maps/dir/A/B' }).expect(200); + await request(server).post('/api/trips/6/roadtrip/google-maps-import').set('Cookie', cookie()).send(input).expect(404); + await request(server).post('/api/trips/5/roadtrip/google-maps-import').set('Cookie', cookie()).send({ ...input, stops: [{ name: 'A', lat: 999, lng: 0 }] }).expect(400); + await request(server).post('/api/trips/5/roadtrip/google-maps-import').set('Cookie', cookie()).send(input).expect(200); + expect(save).toHaveBeenCalledWith(5, 1, input, undefined); + setAddon(false); + await request(server).post('/api/trips/5/roadtrip/google-maps-import').set('Cookie', cookie()).send(input).expect(404); + await request(server).post('/api/roadtrip/google-maps-preview').set('Cookie', cookie()).send({ url: 'https://google.com/maps/dir/A/B' }).expect(404); + expect(save).toHaveBeenCalledTimes(1); + expect(preview).toHaveBeenCalledTimes(1); + } finally { preview.mockRestore(); save.mockRestore(); } + }); + it('validates and gates area search before calling plugins', async () => { + const search = vi.spyOn(app.get(RoadtripSearchService), 'search').mockResolvedValue({ pois: [], sources: [], failedSources: [], truncated: false, clamped: false }); + const input = { categories: ['fuel'], bbox: { south: 48, north: 49, west: 10, east: 11 } }; + try { + await request(server).post('/api/roadtrip/search-area').send(input).expect(401); + await request(server).post('/api/roadtrip/search-area').set('Cookie', cookie()).send({ ...input, categories: ['invalid'] }).expect(400); + await request(server).post('/api/roadtrip/search-area').set('Cookie', cookie()).send(input).expect(200); + expect(search).toHaveBeenCalledWith(input, 1); + setAddon(false); + await request(server).post('/api/roadtrip/search-area').set('Cookie', cookie()).send(input).expect(404); + expect(search).toHaveBeenCalledTimes(1); + } finally { search.mockRestore(); } + }); + it('gates live hazards by addon, authentication and trip access', async () => { + const read = vi.spyOn(app.get(RoadtripHazardsService), 'read').mockResolvedValue({ fetchedAt: new Date().toISOString(), hazards: [], sources: [] }); + try { + await request(server).get('/api/trips/5/roadtrip/hazards').expect(401); + await request(server).get('/api/trips/6/roadtrip/hazards').set('Cookie', cookie()).expect(404); + await request(server).get('/api/trips/5/roadtrip/hazards').set('Cookie', cookie()).expect(200); + setAddon(false); + await request(server).get('/api/trips/5/roadtrip/hazards').set('Cookie', cookie()).expect(404); + expect(read).toHaveBeenCalledTimes(1); + } finally { read.mockRestore(); } + }); + + describe('shared trip driving preferences', () => { + it('shares values with members and isolates other trips', async () => { + db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (5, 2)').run(); + try { + await request(server) + .put('/api/trips/5/roadtrip/preferences') + .set('Cookie', cookie()) + .send({ roadtrip_range_km: 120, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' }) + .expect(200); + const member = await request(server) + .get('/api/trips/5/roadtrip/preferences') + .set('Cookie', sessionCookie(2)) + .expect(200); + expect(member.body.preferences.roadtrip_range_km).toBe(120); + await request(server) + .put('/api/trips/5/roadtrip/preferences') + .set('Cookie', sessionCookie(2)) + .send({ roadtrip_range_km: 160 }) + .expect(200); + const owner = await request(server) + .get('/api/trips/5/roadtrip/preferences') + .set('Cookie', cookie()) + .expect(200); + expect(owner.body.preferences.roadtrip_range_km).toBe(160); + const other = await request(server) + .get('/api/trips/6/roadtrip/preferences') + .set('Cookie', sessionCookie(2)) + .expect(200); + expect(other.body.preferences).toEqual({}); + checkPermission.mockReturnValue(false); + await request(server) + .put('/api/trips/5/roadtrip/preferences') + .set('Cookie', sessionCookie(2)) + .send({ roadtrip_range_km: 999 }) + .expect(403); + expect( + db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_range_km'").get(), + ).toEqual({ value: '160' }); + } finally { + db.prepare('DELETE FROM trip_members WHERE trip_id = 5 AND user_id = 2').run(); + } + }); + it('refuses strangers and invalid daily windows', async () => { + await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', sessionCookie(2)).expect(404); + await request(server) + .put('/api/trips/5/roadtrip/preferences') + .set('Cookie', cookie()) + .send({ roadtrip_day_start: '18:00', roadtrip_day_end: '08:00' }) + .expect(400); + }); + }); describe('the addon gate', () => { // Six routes, all of them. `@RequireAddon` is metadata and inert without the // guard, and the guard is not global — so this block is the only thing that // fails if somebody drops AddonGuard from the chain again. const routes: [string, string, unknown?][] = [ + ['get', '/api/trips/5/roadtrip/preferences'], + ['put', '/api/trips/5/roadtrip/preferences', { roadtrip_range_km: 120 }], ['get', '/api/trips/5/roadtrip/vias'], ['get', '/api/trips/5/roadtrip/days/3/vias'], ['post', '/api/trips/5/roadtrip/days/3/vias', { after_order_index: 0, lat: 53, lng: 10 }], @@ -137,7 +261,8 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => { it('ROADTRIP-E2E-001: answers 404 on every route while the addon is off', async () => { setAddon(false); for (const [method, url, body] of routes) { - const req = (request(server) as never as Record request.Test>)[method](url) + const req = (request(server) as never as Record request.Test>) + [method](url) .set('Cookie', cookie()); const res = await (body ? req.send(body as object) : req); expect(res.status, `${method.toUpperCase()} ${url}`).toBe(404); @@ -224,8 +349,9 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => { expect(moved.status).toBe(200); expect(moved.body.via).toMatchObject({ lat: 54, lng: 11 }); - expect((await request(server).delete(`/api/trips/5/roadtrip/days/3/vias/${id}`).set('Cookie', cookie())).status) - .toBe(200); + expect( + (await request(server).delete(`/api/trips/5/roadtrip/days/3/vias/${id}`).set('Cookie', cookie())).status, + ).toBe(200); const after = await request(server).get('/api/trips/5/roadtrip/days/3/vias').set('Cookie', cookie()); expect(after.body.vias).toEqual([]); }); @@ -235,7 +361,10 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => { .post('/api/trips/5/roadtrip/days/3/vias/batch') .set('Cookie', cookie()) .send({ - vias: [{ after_order_index: 0, lat: 53, lng: 10 }, { after_order_index: 0, lat: 53.5, lng: 10.5 }], + vias: [ + { after_order_index: 0, lat: 53, lng: 10 }, + { after_order_index: 0, lat: 53.5, lng: 10.5 }, + ], track: { place_id: 10, stray_km: 1.5 }, }); expect(res.status).toBe(200); diff --git a/server/tests/integration/assignments.test.ts b/server/tests/integration/assignments.test.ts index 53122b8eb..a8396ffb7 100644 --- a/server/tests/integration/assignments.test.ts +++ b/server/tests/integration/assignments.test.ts @@ -83,6 +83,31 @@ function setupAssignmentFixtures(userId: number) { // ───────────────────────────────────────────────────────────────────────────── describe('Create assignment', () => { + it('sets a day end for one visit, preserves its times, and clears it again', async () => { + const { user } = createUser(testDb); + const { trip, day, place } = setupAssignmentFixtures(user.id); + const created = await request(app).post(`/api/trips/${trip.id}/days/${day.id}/assignments`) + .set('Cookie', authCookie(user.id)).send({ place_id: place.id }); + const id = created.body.assignment.id; + const url = `/api/trips/${trip.id}/assignments/${id}/end-day`; + testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('07:00', id); + const changed = await request(app).put(url).set('Cookie', authCookie(user.id)).send({ end_day: true }).expect(200); + expect(changed.body.assignment).toMatchObject({ end_day: true, assignment_time: '07:00' }); + const listed = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id)).expect(200); + expect(listed.body.days[0].assignments[0].end_day).toBe(true); + const repeatedDay = createDay(testDb, trip.id, { day_number: 2 }); + const repeated = await request(app).post(`/api/trips/${trip.id}/days/${repeatedDay.id}/assignments`) + .set('Cookie', authCookie(user.id)).send({ place_id: place.id }).expect(201); + expect(repeated.body.assignment.end_day).toBe(false); + await request(app).put(url).set('Cookie', authCookie(user.id)).send({ end_day: 'true' }).expect(400); + const foreign = createTrip(testDb, user.id); + await request(app).put(`/api/trips/${foreign.id}/assignments/${id}/end-day`).set('Cookie', authCookie(user.id)) + .send({ end_day: true }).expect(404); + const cleared = await request(app).put(url).set('Cookie', authCookie(user.id)).send({ end_day: false }).expect(200); + expect(cleared.body.assignment).toMatchObject({ end_day: false, assignment_time: '07:00' }); + await request(app).put(url).send({ end_day: true }).expect(401); + }); + it('ASSIGN-001 — POST creates assignment linking place to day', async () => { const { user } = createUser(testDb); const { trip, day, place } = setupAssignmentFixtures(user.id); diff --git a/server/tests/unit/db/roadtrip-preferences-migration.test.ts b/server/tests/unit/db/roadtrip-preferences-migration.test.ts new file mode 100644 index 000000000..a72b89bee --- /dev/null +++ b/server/tests/unit/db/roadtrip-preferences-migration.test.ts @@ -0,0 +1,38 @@ +import { runMigrations } from '../../../src/db/migrations'; +import { createTables } from '../../../src/db/schema'; + +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +describe('trip driving preferences migration', () => { + it('inherits owner values once, tolerates nulls and preserves trip overrides on replay', () => { + const db = new Database(':memory:'); + try { + createTables(db); + runMigrations(db); + db.exec( + "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x'), (2, 'member', 'member@test', 'x')", + ); + db.exec("INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'First'), (11, 2, 'Second')"); + db.exec( + "INSERT INTO settings (user_id, key, value) VALUES (1, 'roadtrip_range_km', '120'), (2, 'roadtrip_range_km', '300'), (1, 'roadtrip_day_start', '\"08:00\"'), (1, 'roadtrip_day_end', NULL), (1, 'routing_base_url', '\"https://private.test\"')", + ); + const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version; + db.prepare('UPDATE schema_version SET version = ?').run(version - 1); + runMigrations(db); + expect(db.prepare('SELECT key, value FROM roadtrip_preferences WHERE trip_id = 10 ORDER BY key').all()).toEqual([ + { key: 'roadtrip_day_start', value: '"08:00"' }, + { key: 'roadtrip_range_km', value: '120' }, + ]); + expect(db.prepare('SELECT value FROM roadtrip_preferences WHERE trip_id = 11').get()).toEqual({ value: '300' }); + db.exec("UPDATE roadtrip_preferences SET value = '200' WHERE trip_id = 10 AND key = 'roadtrip_range_km'"); + db.prepare('UPDATE schema_version SET version = ?').run(version - 1); + runMigrations(db); + expect( + db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 10 AND key = 'roadtrip_range_km'").get(), + ).toEqual({ value: '200' }); + } finally { + db.close(); + } + }); +}); diff --git a/server/tests/unit/mcp/tools-assignments.test.ts b/server/tests/unit/mcp/tools-assignments.test.ts index ad5a02664..887b224bd 100644 --- a/server/tests/unit/mcp/tools-assignments.test.ts +++ b/server/tests/unit/mcp/tools-assignments.test.ts @@ -72,6 +72,23 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise) // --------------------------------------------------------------------------- describe('Tool: assign_place_to_day', () => { + it('sets and clears a visit day end through the shared service', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const visit = createDayAssignment(testDb, day.id, place.id); + await withHarness(user.id, async h => { + for (const end_day of [true, false]) { + const changed = await h.client.callTool({ name: 'set_assignment_end_day', arguments: { tripId: trip.id, assignmentId: visit.id, end_day } }); + expect(parseToolResult(changed)).toMatchObject({ assignment: { end_day } }); + } + const other = createTrip(testDb, user.id); + const refused = await h.client.callTool({ name: 'set_assignment_end_day', arguments: { tripId: other.id, assignmentId: visit.id, end_day: true } }); + expect(refused.isError).toBe(true); + }); + }); + it('assigns a place to a day', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/nest/assignments.controller.test.ts b/server/tests/unit/nest/assignments.controller.test.ts index 0d3e95674..14c4bae28 100644 --- a/server/tests/unit/nest/assignments.controller.test.ts +++ b/server/tests/unit/nest/assignments.controller.test.ts @@ -67,6 +67,18 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route }); describe('AssignmentOpsController (parity with the per-assignment op routes)', () => { + it('scopes explicit day ends to the trip and broadcasts the saved visit', () => { + const setEndDay = vi.fn().mockReturnValue({ id: 9, end_day: true }); + const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), setEndDay }); + const controller = new AssignmentOpsController(s); + expect(controller.endDay('5', '9', { end_day: true }, 'sock')).toEqual({ assignment: { id: 9, end_day: true } }); + expect(s.getAssignmentForTrip).toHaveBeenCalledWith('9', '5'); + expect(setEndDay).toHaveBeenCalledWith('9', true); + expect(s.broadcast).toHaveBeenCalledWith('5', 'assignment:updated', { assignment: { id: 9, end_day: true } }, 'sock'); + vi.mocked(s.getAssignmentForTrip).mockReturnValue(undefined); + expect(thrown(() => controller.endDay('5', '9', { end_day: false }))).toEqual({ status: 404, body: { error: 'Assignment not found' } }); + }); + it('PUT /:id/move 404 assignment, 404 target day, else moves', () => { expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial)).move(user, '5', '9', { new_day_id: 4 }))).toEqual({ status: 404, body: { error: 'Assignment not found' } }); expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ day_id: 3 }), dayExists: vi.fn().mockReturnValue(false) } as Partial)).move(user, '5', '9', { new_day_id: 4 }))).toEqual({ status: 404, body: { error: 'Target day not found' } }); diff --git a/server/tests/unit/nest/charging.test.ts b/server/tests/unit/nest/charging.test.ts new file mode 100644 index 000000000..71355473a --- /dev/null +++ b/server/tests/unit/nest/charging.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ChargingService } from '../../../src/nest/roadtrip/charging.service'; +import { matchChargingLocation, normalizeCharging, type ChargingLocation, type ChargingSource, type ChargingTariff } from '../../../src/nest/roadtrip/charging.helpers'; + +const now = Date.parse('2026-09-12T12:00:00Z'); +const source: ChargingSource = { uid: 'test', name: 'Test operator', public_url: 'https://example.com', attribution_license: 'CC-0', attribution_contributor: null, realtime_data_updated_at: new Date(now).toISOString(), realtime_status: 'ACTIVE' }; +const station: ChargingLocation = { id: '1', source: 'test', name: 'Test charging', operator: { name: 'Test' }, address: 'Main street', city: 'City', last_updated: new Date(now).toISOString(), coordinates: { latitude: 48, longitude: 11 }, charging_pool: [{ evses: [ + { uid: '1', evse_id: 'DE*TEST*1', status: 'AVAILABLE', last_updated: new Date(now - 86400000).toISOString(), connectors: [{ tariff_ids: ['original'] }] }, + { uid: '2', status: 'CHARGING', last_updated: new Date(now).toISOString(), connectors: [] }, + { uid: '3', status: 'STATIC', last_updated: new Date(now).toISOString(), connectors: [] }, +] }] }; +const tariff: ChargingTariff = { id: '2', original_id: 'original', source: 'test', currency: 'EUR', last_updated: new Date(now).toISOString(), elements: [ + { price_components: [{ type: 'ENERGY', price: 0.5, taxes: [{ percentage: 19 }] }] }, + { restrictions: { min_duration: 1800 }, price_components: [{ type: 'TIME', price: 12, taxes: [] }] }, +] }; + +afterEach(() => vi.unstubAllGlobals()); +describe('Open charging data', () => { + it('keeps unchanged availability live when the source has refreshed, and does not count unknown as free', () => { + const info = normalizeCharging(station, source, [], now); + expect(info).toMatchObject({ available: 1, total: 3, unknown: 1, stale: false }); + }); + it('never shows old, future or failed source status as current availability', () => { + for (const date of [new Date(now - 3600000).toISOString(), new Date(now + 3600000).toISOString(), null]) { + expect(normalizeCharging(station, { ...source, realtime_data_updated_at: date }, [], now).available).toBeNull(); + } + expect(normalizeCharging(station, { ...source, realtime_status: 'FAILED' }, [], now).stale).toBe(true); + }); + it('matches tariff identifiers only within their source and retains taxes and conditions', () => { + const info = normalizeCharging(station, source, [tariff, { ...tariff, source: 'other' }], now); + expect(info.tariffs).toHaveLength(1); + expect(info.tariffs[0].components[0]).toMatchObject({ price: 0.595, taxIncluded: true, conditional: false }); + expect(info.tariffs[0].components[1]).toMatchObject({ afterSeconds: 1800, conditional: true }); + }); + it('rejects distant or ambiguous stations instead of attaching another operator’s data', () => { + expect(matchChargingLocation([station], 0, 0, 'Test')).toBeNull(); + expect(matchChargingLocation([station, { ...station, id: 'other', operator: { name: 'Other' } }], 48, 11, 'Charging')).toBe('ambiguous'); + expect(matchChargingLocation([station], 48, 11, 'Test')).toEqual(station); + }); + it('checks trip/place scope and ignores non-charging places', async () => { + const get = vi.fn().mockReturnValueOnce(null).mockReturnValueOnce({ name: 'Hotel', lat: 48, lng: 11, stop_type: 'hotel' }); + const service = new ChargingService({ get } as never); + await expect(service.read(1, 2)).rejects.toThrow(); + expect((await service.read(1, 2)).status).toBe('unknown'); + expect(get).toHaveBeenCalledWith(expect.stringContaining('trip_id = ?'), 2, 1); + }); + it('coalesces station requests and preserves availability if tariffs fail', async () => { + const fetcher = vi.fn(async (url: string) => { + if (url.includes('/sources')) return new Response(JSON.stringify({ items: [source] })); + if (url.includes('/locations')) return new Response(JSON.stringify({ items: [station], total_count: 1 })); + return new Response('', { status: 503 }); + }); + vi.stubGlobal('fetch', fetcher); + const service = new ChargingService({ get: () => ({ name: 'Test', lat: 48, lng: 11, stop_type: 'charging' }) } as never); + const [first, second] = await Promise.all([service.read(1, 2), service.read(1, 2)]); + expect(first).toEqual(second); + expect(first.status).toBe('ok'); + expect(first.pricesUnavailable).toBe(true); + expect(fetcher).toHaveBeenCalledTimes(3); + }); +}); diff --git a/server/tests/unit/nest/day-boundaries.test.ts b/server/tests/unit/nest/day-boundaries.test.ts new file mode 100644 index 000000000..e65553066 --- /dev/null +++ b/server/tests/unit/nest/day-boundaries.test.ts @@ -0,0 +1,82 @@ +import Database from 'better-sqlite3'; +import { afterEach, expect, it, vi } from 'vitest'; +import { HttpException } from '@nestjs/common'; +import { DayBoundariesService } from '../../../src/nest/roadtrip/day-boundaries.service'; +import { DayBoundariesController } from '../../../src/nest/roadtrip/day-boundaries.controller'; +import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp'; +import type { DatabaseService } from '../../../src/nest/database/database.service'; +import type { McpContext } from '../../../src/nest-mcp'; + +const open: Database.Database[] = []; +afterEach(() => open.splice(0).forEach(db => db.close())); +function setup() { + const raw = new Database(':memory:'); + open.push(raw); + raw.pragma('foreign_keys = ON'); + raw.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER); + CREATE TABLE day_assignments (id INTEGER PRIMARY KEY, day_id INTEGER REFERENCES days(id) ON DELETE CASCADE); + CREATE TABLE roadtrip_day_boundaries (trip_id INTEGER, day_number INTEGER, from_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, fraction REAL, PRIMARY KEY(trip_id, day_number)); + INSERT INTO days VALUES (1, 10), (2, 20); + INSERT INTO day_assignments VALUES (11, 1), (12, 1), (21, 2);`); + const db = { + get: (sql: string, ...params: unknown[]) => raw.prepare(sql).get(...params), + all: (sql: string, ...params: unknown[]) => raw.prepare(sql).all(...params), + run: (sql: string, ...params: unknown[]) => raw.prepare(sql).run(...params), + canAccessTrip: vi.fn(() => true), + }; + const service = new DayBoundariesService(db as unknown as DatabaseService); + const realtime = { broadcast: vi.fn() }; + const auth = { isDemoUser: vi.fn(() => false) }; + const guards = { hasTripPermission: vi.fn(() => true) }; + const mcp = new DayBoundariesMcp(service, db as unknown as DatabaseService, auth as never, guards as never, realtime as never, {} as never); + return { raw, service, controller: new DayBoundariesController(service, realtime as never), mcp, db, auth, guards, realtime }; +} +const boundary = { day_number: 1, from_assignment_id: 11, to_assignment_id: 12, fraction: 0.4 }; + +it('replaces one day only, persists place snapping and cascades deleted visits', () => { + const { service, raw } = setup(); + service.save(10, boundary); + service.save(10, { ...boundary, day_number: 2, fraction: 0.8 }); + expect(service.save(10, { ...boundary, to_assignment_id: null })).toEqual([ + { ...boundary, to_assignment_id: null, fraction: 1 }, { ...boundary, day_number: 2, fraction: 0.8 }, + ]); + expect(service.list(20)).toEqual([]); + service.remove(20, 1); + expect(service.list(10)).toHaveLength(2); + raw.prepare('DELETE FROM day_assignments WHERE id = 12').run(); + expect(service.list(10)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]); +}); + +it('rejects either endpoint from a different trip without writing', () => { + const { service } = setup(); + expect(() => service.save(10, { ...boundary, from_assignment_id: 21 })).toThrow(HttpException); + expect(() => service.save(10, { ...boundary, to_assignment_id: 21 })).toThrow(HttpException); + expect(service.list(10)).toEqual([]); +}); + +it('REST changes broadcast the complete state and exclude the saving socket', () => { + const { controller, realtime } = setup(); + controller.save('10', boundary, 'self'); + expect(realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripBoundary:changed', { boundaries: [boundary] }, 'self'); + expect(controller.remove('10', 1)).toEqual({ boundaries: [] }); +}); + +it('MCP checks demo, trip access and edit permission before saving', async () => { + const s = setup(), ctx = { userId: 1 } as McpContext; + const request = { tripId: 10, dayNumber: 1, boundary }; + s.auth.isDemoUser.mockReturnValue(true); + await s.mcp.save(request, ctx); + s.auth.isDemoUser.mockReturnValue(false); + s.db.canAccessTrip.mockReturnValue(false); + await s.mcp.save(request, ctx); + s.db.canAccessTrip.mockReturnValue(true); + s.guards.hasTripPermission.mockReturnValue(false); + await s.mcp.save(request, ctx); + expect(s.service.list(10)).toEqual([]); + s.guards.hasTripPermission.mockReturnValue(true); + await s.mcp.save(request, ctx); + expect(s.service.list(10)).toEqual([boundary]); + await s.mcp.save({ ...request, boundary: null }, ctx); + expect(s.service.list(10)).toEqual([]); +}); diff --git a/server/tests/unit/nest/google-route.test.ts b/server/tests/unit/nest/google-route.test.ts new file mode 100644 index 000000000..4f31afd7c --- /dev/null +++ b/server/tests/unit/nest/google-route.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { GoogleRouteService } from '../../../src/nest/roadtrip/google-route.service'; +import { safeFetchFollow } from '../../../src/utils/ssrfGuard'; + +vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetchFollow: vi.fn() })); + +function setup() { + const maps = { geocodeQuery: vi.fn(), reverseGeocode: vi.fn().mockResolvedValue({ name: null, address: null }) }; + const db = { canAccessTrip: vi.fn(() => ({ user_id: 7 })), get: () => ({ role: 'user' }), transaction: (fn: () => T) => fn() }; + const places = { create: vi.fn((_trip: string, stop: { name: string }) => ({ id: stop.name })), broadcast: vi.fn() }; + const assignments = { dayExists: vi.fn(() => true), createAssignment: vi.fn((dayId: number, placeId: string) => ({ dayId, placeId })), broadcast: vi.fn(), reconcile: vi.fn() }; + const permissions = { checkPermission: vi.fn(() => true) }; + const service = new GoogleRouteService(maps as never, db as never, places as never, assignments as never, permissions as never); + return { service, maps, db, places, assignments, permissions }; +} + +const input = { dayId: 3, stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Rome', lat: 41, lng: 12 }] }; + +describe('Google route import', () => { + it('reads coordinates in route order without geocoding or writing', async () => { + const { service, maps, places } = setup(); + const preview = await service.preview('https://www.google.com/maps/dir/?api=1&origin=48,11&destination=41,12&waypoints=47,11'); + expect(preview.stops.map(stop => stop.lat)).toEqual([48, 47, 41]); + expect(maps.geocodeQuery).not.toHaveBeenCalled(); + expect(places.create).not.toHaveBeenCalled(); + }); + + it('keeps unresolved and dynamic locations visible in their original position', async () => { + const { service, maps } = setup(); + maps.geocodeQuery.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ lat: 41, lng: 12 }); + const preview = await service.preview('https://www.google.com/maps/dir/Your+location/Munich/Rome'); + expect(preview.stops).toEqual([{ name: 'Your location', lat: null, lng: null }, { name: 'Munich', lat: null, lng: null }, { name: 'Rome', lat: 41, lng: 12 }]); + expect(maps.geocodeQuery.mock.calls).toEqual([['Munich'], ['Rome']]); + }); + + it('names coordinate stops without moving their supplied positions', async () => { + const { service, maps } = setup(); + maps.reverseGeocode.mockResolvedValueOnce({ name: 'Munich', address: 'Munich' }).mockRejectedValueOnce(new Error('unavailable')); + const preview = await service.preview('https://google.com/maps/dir/48,11/41,12'); + expect(preview.stops).toEqual([{ name: 'Munich', lat: 48, lng: 11 }, { name: '41, 12', lat: 41, lng: 12 }]); + expect(maps.reverseGeocode).toHaveBeenCalledWith('48', '11', undefined, { lane: 'background', timeoutMs: 5000, locality: true }); + }); + + it('rejects unrelated URLs and oversized routes without fetching or truncating', async () => { + const { service, maps } = setup(); + for (const url of ['https://example.com/maps/dir/A/B', 'http://google.com/maps/dir/A/B', 'https://user:pass@google.com/maps/dir/A/B', 'https://google.com/search?q=A']) { + await expect(service.preview(url)).rejects.toThrow('Http Exception'); + } + await expect(service.preview(`https://google.com/maps/dir/${Array.from({ length: 31 }, (_, i) => `A${i}`).join('/')}`)).rejects.toThrow(); + expect(maps.geocodeQuery).not.toHaveBeenCalled(); + }); + + it('resolves short links through the safe fetcher and checks the destination', async () => { + const { service } = setup(); + const cancel = vi.fn(); + vi.mocked(safeFetchFollow).mockResolvedValueOnce({ url: 'https://google.com/maps/dir/48,11/41,12', body: { cancel } } as never); + expect((await service.preview('https://maps.app.goo.gl/example')).stops).toHaveLength(2); + expect(cancel).toHaveBeenCalled(); + vi.mocked(safeFetchFollow).mockResolvedValueOnce({ url: 'https://example.com/maps/dir/A/B', body: null } as never); + await expect(service.preview('https://maps.app.goo.gl/example')).rejects.toThrow(); + }); + + it('checks membership, both editing permissions and the target day before writing', () => { + const { service, db, permissions, assignments, places } = setup(); + db.canAccessTrip.mockReturnValueOnce(null as never); + expect(() => service.import(1, 7, input)).toThrow(); + permissions.checkPermission.mockReturnValueOnce(false); + expect(() => service.import(1, 7, input)).toThrow(); + assignments.dayExists.mockReturnValueOnce(false); + expect(() => service.import(1, 7, input)).toThrow(); + expect(places.create).not.toHaveBeenCalled(); + expect(assignments.dayExists).toHaveBeenCalledWith('3', '1'); + }); + + it('appends stops in order with car transport and broadcasts after saving', () => { + const { service, places, assignments, permissions } = setup(); + expect(service.import(1, 7, input)).toEqual({ imported: 2 }); + expect(places.create.mock.calls.map(call => call[1].name)).toEqual(['Munich', 'Rome']); + expect(places.create).toHaveBeenCalledWith('1', expect.objectContaining({ transport_mode: 'car', duration_minutes: 0 })); + expect(assignments.createAssignment.mock.calls).toEqual([[3, 'Munich'], [3, 'Rome']]); + expect(permissions.checkPermission).toHaveBeenCalledWith('day_edit', 'user', 7, 7, false); + expect(assignments.reconcile).toHaveBeenCalledWith(1, undefined); + }); + + it('rolls back every created stop when any assignment fails and sends no events', () => { + const { service, db, places, assignments } = setup(); + const sqlite = new Database(':memory:'); + sqlite.exec('CREATE TABLE stops (name TEXT)'); + db.transaction = fn => sqlite.transaction(fn)(); + places.create.mockImplementation((_trip, stop) => { + sqlite.prepare('INSERT INTO stops VALUES (?)').run(stop.name); + return { id: stop.name }; + }); + assignments.createAssignment.mockImplementationOnce(() => ({ dayId: 3, placeId: 'Munich' })).mockImplementationOnce(() => { throw new Error('write failed'); }); + try { + expect(() => service.import(1, 7, input)).toThrow('write failed'); + expect(sqlite.prepare('SELECT * FROM stops').all()).toEqual([]); + expect(places.broadcast).not.toHaveBeenCalled(); + expect(assignments.broadcast).not.toHaveBeenCalled(); + } finally { sqlite.close(); } + }); +}); diff --git a/server/tests/unit/nest/roadtrip-hazards.test.ts b/server/tests/unit/nest/roadtrip-hazards.test.ts new file mode 100644 index 000000000..2d4870226 --- /dev/null +++ b/server/tests/unit/nest/roadtrip-hazards.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RoadtripHazardsService } from '../../../src/nest/roadtrip/roadtrip-hazards.service'; + +const polygon = { type: 'Polygon', coordinates: [[[10, 50], [11, 50], [11, 51], [10, 50]]] }; +const feed = (features: unknown[]) => new Response(JSON.stringify({ type: 'FeatureCollection', features })); +afterEach(() => vi.unstubAllGlobals()); + +describe('roadtrip hazard sources', () => { + it('isolates failed providers and caches concurrent reads', async () => { + const fetcher = vi.fn(async (url: string) => { + if (url.includes('dwd.de')) throw new Error('down'); + return feed([]); + }); + vi.stubGlobal('fetch', fetcher); + const service = new RoadtripHazardsService(); + const [first, second] = await Promise.all([service.read(), service.read()]); + expect(first).toEqual(second); + expect(first.sources).toEqual([{ source: 'DWD', status: 'unavailable' }, { source: 'GDACS', status: 'ok' }]); + await service.read(); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it('drops expired warnings and marks malformed records as incomplete', async () => { + vi.stubGlobal('fetch', vi.fn(async (url: string) => feed(url.includes('dwd.de') ? [ + { geometry: polygon, properties: { HEADLINE: 'Rain', SENT: '2026-01-01T00:00:00Z', EXPIRES: '2099-01-01T00:00:00Z' } }, + { geometry: polygon, properties: { HEADLINE: 'Old', SENT: '2020-01-01T00:00:00Z', EXPIRES: '2020-01-02T00:00:00Z' } }, + { geometry: null, properties: {} }, + ] : []))); + const saved = await new RoadtripHazardsService().read(); + expect(saved.hazards).toHaveLength(1); + expect(saved.hazards[0].title).toBe('Rain'); + expect(saved.sources[0].status).toBe('partial'); + }); + it('uses affected polygons rather than the global event footprint', async () => { + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url.includes('dwd.de')) return feed([]); + if (url.includes('getgeometry')) return feed([ + { geometry: polygon, properties: { Class: 'Poly_Affected' } }, + { geometry: polygon, properties: { Class: 'Poly_Global' } }, + ]); + return feed([{ geometry: { type: 'Point', coordinates: [10, 50] }, properties: { + eventtype: 'FL', eventid: 1, episodeid: 2, name: 'Flood', description: 'Flood report', + datemodified: '2026-09-12T08:00:00', iscurrent: 'true', + } }]); + })); + const saved = await new RoadtripHazardsService().read(); + expect(saved.hazards[0].geometry).toEqual({ type: 'MultiPolygon', coordinates: [polygon.coordinates] }); + expect(saved.hazards[0].updatedAt).toBe('2026-09-12T08:00:00.000Z'); + }); +}); diff --git a/server/tests/unit/nest/roadtrip-planning.test.ts b/server/tests/unit/nest/roadtrip-planning.test.ts new file mode 100644 index 000000000..3481dab7f --- /dev/null +++ b/server/tests/unit/nest/roadtrip-planning.test.ts @@ -0,0 +1,314 @@ +import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy'; +import type { McpContext } from '../../../src/nest-mcp'; +import { createTestRegistry } from '../../../src/nest-mcp'; +import { PlacesMcp } from '../../../src/nest/places/places.mcp'; +import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service'; +import { RoadtripPlanningMcp } from '../../../src/nest/roadtrip/roadtrip-planning.mcp'; +import { RoadtripPreferencesMcp } from '../../../src/nest/roadtrip/roadtrip-preferences.mcp'; +import { RoadtripPreferencesService } from '../../../src/nest/roadtrip/roadtrip-preferences.service'; +import { RoadtripMcp } from '../../../src/nest/roadtrip/roadtrip.mcp'; +import { roadtripPreferencesUpdateSchema } from '@trek/shared'; + +import { describe, it, expect, vi } from 'vitest'; + +const ctx = { userId: 5 } as McpContext; +function setup() { + const settings: Record = { + roadtrip_day_start: '08:00', + roadtrip_day_end: '10:00', + roadtrip_vehicle: 'electric', + roadtrip_range_km: 100, + roadtrip_fill_percent: 80, + routing_base_url: 'https://private.example', + llm_api_key: 'secret', + }; + const store = { + getUserSettings: vi.fn(() => settings), + }; + const realtime = { broadcast: vi.fn() }; + const tripSettings = new Map>([[10, { ...settings }]]); + const preferenceDb = { + all: (_sql: string, tripId: number) => + Object.entries(tripSettings.get(tripId) ?? {}).map(([key, value]) => ({ key, value: JSON.stringify(value) })), + run: vi.fn((_sql: string, tripId: number, key: string, value: string) => + tripSettings.set(tripId, { ...tripSettings.get(tripId), [key]: JSON.parse(value) }), + ), + transaction: (fn: () => unknown) => fn(), + }; + const preferences = new RoadtripPreferencesService(preferenceDb as never, realtime as never); + const days = [{ id: 1, day_number: 1, title: null, date: '2026-09-11', default_transport_mode: 'driving' }]; + const visits = [1, 2, 3].map((id) => ({ + id, + day_id: 1, + place_id: id + 10, + name: `Stop ${id}`, + lat: 48, + lng: id, + time: id === 1 ? '07:00' : null, + duration_minutes: 30, + end_day: 0, + leg_transport_mode: null, + incoming_leg_transport_mode: null, + stop_type: null, + fill_percent: null, + })); + const db = { + canAccessTrip: vi.fn(() => true), + all: vi.fn((sql: string) => (sql.includes('FROM day_assignments') ? visits : days)), + }; + const router = { + profiles: () => ['driving'], + route: vi.fn(async (_user: number, _trip: number, _day: number, points: { lat: number; lng: number }[]) => ({ + parts: points.slice(1).map(() => ({ distance: 120000, duration: 7200 })), + avoidMissed: [], + leg: { + line: points.map((p) => [p.lat, p.lng]), + vias: [], + seg: { + from: [48, 1], + to: [48, 3], + mid: [48, 2], + distance: 240000, + duration: 14400, + mode: 'driving', + distanceText: '', + drivingText: '', + walkingText: '', + }, + }, + })), + }; + const roadtrip = { listForTrip: vi.fn(() => []), tracksForTrip: vi.fn(() => []) }; + const boundaries = { list: vi.fn(() => []) }; + const plans = new RoadtripPlanService( + db as never, + store as never, + preferences, + router as never, + roadtrip as never, + boundaries as never, + ); + return { preferenceDb, preferences, store, realtime, db, plans, router, visits, days, boundaries }; +} + +describe('roadtrip preferences', () => { + it('reads only public driving preferences and never endpoint URLs or credentials', () => { + const s = setup(); + expect(s.preferences.read(10)).toMatchObject({ roadtrip_range_km: 100 }); + expect(JSON.stringify(s.preferences.read(10))).not.toMatch(/secret|private.example|routing_base_url/); + }); + it('validates the complete window before an atomic write and broadcasts to every member of its trip', () => { + const s = setup(); + expect(() => s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).toThrow(); + expect(s.preferenceDb.run).not.toHaveBeenCalled(); + s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' }); + expect(s.realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripPreferences:changed', { + preferences: s.preferences.read(10), + }); + expect(s.preferences.read(10).roadtrip_range_km).toBe(100); + }); + it.each([ + { llm_api_key: 'x' }, + { routing_base_url: 'http://localhost' }, + { roadtrip_day_start: '25:00' }, + { roadtrip_fill_percent: 101 }, + { roadtrip_avoid: 'anything' }, + {}, + ])('refuses invalid or unrelated preference %j', (patch) => { + expect(roadtripPreferencesUpdateSchema.safeParse(patch).success).toBe(false); + }); + it('allows clearing daily times and limits and refuses demo writes', async () => { + const s = setup(); + s.preferences.update(10, { roadtrip_day_start: '', roadtrip_range_km: 0 }); + expect(s.preferences.read(10).roadtrip_day_start).toBe(''); + const mcp = new RoadtripPreferencesMcp( + s.preferences, + { isDemoUser: () => true } as never, + {} as never, + s.db as never, + {} as never, + ); + s.preferenceDb.run.mockClear(); + await mcp.update({ tripId: 10, settings: { roadtrip_range_km: 300 } }, ctx); + expect(s.preferenceDb.run).not.toHaveBeenCalled(); + }); +}); + +describe('browser-independent roadtrip calculation', () => { + it('uses the shared scheduler, preserves the manually pinned start and routes a whole day in one request', async () => { + const s = setup(); + const before = structuredClone(s.visits); + const plan = await s.plans.calculate(10, 5); + expect(s.router.route).toHaveBeenCalledTimes(1); + expect(plan.calculated.days[0].schedule.entries[0].arrival).toBe('07:00'); + expect(plan.calculated.days.length).toBeGreaterThan(1); + expect(plan.calculated.days[0].stops.some((stop) => stop.automaticNight?.phase === 'end')).toBe(true); + expect(plan.calculated.days[0].driveWarnings.some((w) => w.code === 'range')).toBe(true); + expect(s.visits).toEqual(before); + }); + it('previews settings without saving them', async () => { + const s = setup(); + await s.plans.calculate(10, 5, { roadtrip_day_end: '20:00' }); + expect(s.preferences.read(10).roadtrip_day_end).toBe('10:00'); + expect(s.preferenceDb.run).not.toHaveBeenCalled(); + }); + it('checks trip access before reading or routing', async () => { + const s = setup(); + s.db.canAccessTrip.mockReturnValue(false); + await expect(s.plans.calculate(20, 5)).rejects.toThrow(); + expect(s.db.all).not.toHaveBeenCalled(); + expect(s.router.route).not.toHaveBeenCalled(); + }); + it('marks provider failures incomplete and never leaks an endpoint from its exception', async () => { + const s = setup(); + s.router.route.mockRejectedValue(new Error('secret https://private.example')); + const mcp = new RoadtripPlanningMcp(s.plans, {} as never, {} as never); + const answer = await mcp.calculate({ tripId: 10, includeGeometry: false }, ctx); + expect(JSON.stringify(answer)).toContain('incomplete'); + expect(JSON.stringify(answer)).not.toContain('private.example'); + expect(JSON.stringify(answer)).toContain('false'); + }); + it('reports coordinate-less visits instead of inventing a location', async () => { + const s = setup(); + Object.assign(s.visits[1], { lat: null }); + const plan = await s.plans.calculate(10, 5); + expect(plan.omittedVisits).toEqual([2]); + }); + it('keeps explicit end-day visits and manual boundaries in the shared planning path', async () => { + const s = setup(); + s.visits[0].end_day = 1; + const plan = await s.plans.calculate(10, 5); + expect(plan.calculated.days[0].stops.find((stop) => stop.assignmentId === 1)?.endDay).toBe(true); + expect(plan.calculated.days[0].schedule.entries.some((entry) => entry.departure === '07:30')).toBe(true); + }); +}); + +describe('Roadtrip MCP registration and search', () => { + it('imports GPX through the existing service and broadcasts the imported places', async () => { + const places = { importGpx: vi.fn(() => ({ places: [{ id: 12 }], count: 1, skipped: 0 })) }; + const auth = { isDemoUser: vi.fn(() => false) }; + const guards = { hasTripPermission: () => true, safeBroadcast: vi.fn() }; + const mcp = new PlacesMcp( + places as never, + {} as never, + { canAccessTrip: () => true } as never, + auth as never, + {} as never, + {} as never, + guards as never, + ); + const input = { + tripId: 1, + gpx: '', + name: 'Track', + importTracks: true, + importRoutes: false, + importWaypoints: false, + }; + await mcp.importGpx(input, ctx); + expect(places.importGpx).toHaveBeenCalledWith('1', Buffer.from(''), { + defaultName: 'Track', + importTracks: true, + importRoutes: false, + importWaypoints: false, + }); + expect(guards.safeBroadcast).toHaveBeenCalledWith(1, 'place:created', { place: { id: 12 } }); + places.importGpx.mockClear(); + auth.isDemoUser.mockReturnValue(true); + await mcp.importGpx(input, ctx); + expect(places.importGpx).not.toHaveBeenCalled(); + }); + it('hides addon tools when disabled and separates settings reads from writes', () => { + const s = setup(); + const addons = { isAddonEnabled: vi.fn(() => true) }; + const registry = createTestRegistry( + [ + new RoadtripPreferencesMcp(s.preferences, {} as never, addons as never, s.db as never, {} as never), + new RoadtripPlanningMcp(s.plans, {} as never, addons as never), + ], + { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }, + ); + const names: string[] = []; + const registrar = { + registerTool: (name: string) => { + names.push(name); + }, + }; + registry.attach(registrar as never, { ...ctx, scopes: ['trips:read'] }); + expect(names).toContain('get_roadtrip_settings'); + expect(names).not.toContain('update_roadtrip_settings'); + names.length = 0; + addons.isAddonEnabled.mockReturnValue(false); + registry.attach(registrar as never, { ...ctx, scopes: null }); + expect(names).toEqual([]); + }); + it('returns corridor sources, truncation and matching brands without adding stops', async () => { + const plans = { + calculate: vi.fn(async () => ({ + failures: [], + omittedVisits: [], + calculated: { + days: [ + { + dayNumber: 1, + geometry: [ + [48, 10], + [48, 10.1], + ], + }, + ], + dayWindowIssue: null, + }, + })), + }; + const maps = { + search: vi.fn(async () => ({ + pois: [{ osm_id: 'n1', name: 'Fuel', brand: 'Example', lat: 48, lng: 10.05, category: 'fuel' }], + sources: ['trek-places'], failedSources: [], + truncated: true, + clamped: false, + })), + }; + const mcp = new RoadtripPlanningMcp(plans as never, maps as never, {} as never); + const answer = await mcp.corridor( + { tripId: 1, dayNumber: 1, category: 'fuel', widthKm: 5, offset: 0, name: 'example' }, + ctx, + ); + const body = JSON.parse(answer.content[0].text as string); + expect(body.hits).toHaveLength(1); + expect(body.sources).toEqual(['trek-places']); + expect(body.truncatedAreas).toBeGreaterThan(0); + expect(body.complete).toBe(false); + maps.search.mockRejectedValue(new Error('private endpoint')); + const failed = await mcp.corridor({ tripId: 1, dayNumber: 1, category: 'fuel', widthKm: 5, offset: 0 }, ctx); + expect(JSON.stringify(failed)).toContain('failedAreas'); + expect(JSON.stringify(failed)).not.toContain('private endpoint'); + }); + it('checks visit editing permission and ownership before moving a via', async () => { + const service = { + dayExists: vi.fn(() => true), + move: vi.fn(() => ({ id: 4 })), + listForDay: () => [], + broadcast: vi.fn(), + }; + const guards = { hasTripPermission: vi.fn(() => false) }; + const mcp = new RoadtripMcp( + service as never, + { canAccessTrip: () => true } as never, + guards as never, + { isDemoUser: () => false } as never, + {} as never, + ); + const input = { tripId: 1, dayId: 2, viaId: 4, lat: 48, lng: 10 }; + await mcp.updateVia(input, ctx); + expect(service.move).not.toHaveBeenCalled(); + guards.hasTripPermission.mockReturnValue(true); + service.dayExists.mockReturnValue(false); + await mcp.updateVia(input, ctx); + expect(service.move).not.toHaveBeenCalled(); + service.dayExists.mockReturnValue(true); + await mcp.updateVia(input, ctx); + expect(service.move).toHaveBeenCalledWith(4, 2, 48, 10, undefined); + expect(service.broadcast).toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/roadtrip-router.test.ts b/server/tests/unit/nest/roadtrip-router.test.ts new file mode 100644 index 000000000..5b317f7e3 --- /dev/null +++ b/server/tests/unit/nest/roadtrip-router.test.ts @@ -0,0 +1,164 @@ +import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service'; +import { safeFetchAdminConfigured } from '../../../src/utils/ssrfGuard'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetchAdminConfigured: vi.fn() })); +vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => true })); +const points = [ + { lat: 48, lng: 10 }, + { lat: 49, lng: 11 }, + { lat: 50, lng: 12 }, +]; +const osrm = () => + new Response( + JSON.stringify({ + code: 'Ok', + routes: [ + { + distance: 3000, + duration: 300, + geometry: { + coordinates: [ + [10, 48], + [11, 49], + [12, 50], + ], + }, + legs: [ + { distance: 1000, duration: 100 }, + { distance: 2000, duration: 200 }, + ], + }, + ], + waypoints: points.map((p) => ({ location: [p.lng, p.lat], distance: 25 })), + }), + ); +function setup(settings = {}) { + const hooks = { providersOf: vi.fn(() => ['scenic']), route: vi.fn() }; + const db = { + connection: { + prepare: () => ({ get: () => ({ capabilities: JSON.stringify({ routeProfiles: [{ id: 'car' }] }) }) }), + }, + }; + return { + hooks, + router: new RoadtripRouterService({ getUserSettings: () => settings } as never, hooks as never, db as never), + }; +} +beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(safeFetchAdminConfigured) + .mockReset() + .mockImplementation(async () => osrm()); +}); +afterEach(() => vi.useRealTimers()); + +describe('server Roadtrip router', () => { + it('reads Valhalla leg times, geometry and unmet road preferences', async () => { + const encode = (value: number) => { + let remaining = value < 0 ? ~(value << 1) : value << 1; + let encoded = ''; + while (remaining >= 32) { encoded += String.fromCharCode((32 | (remaining & 31)) + 63); remaining >>>= 5; } + return encoded + String.fromCharCode(remaining + 63); + }; + const shape = encode(48000000) + encode(10000000) + encode(1000000) + encode(1000000); + vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response(JSON.stringify({trip:{legs:[{shape,summary:{length:25,time:1200,has_toll:true,has_ferry:false}}]}}))); + const route = await setup().router.route(1,1,1,points.slice(0,2),'driving',['toll','ferry']); + expect(route.leg.line).toEqual([[48,10],[49,11]]); + expect(route.parts).toEqual([{distance:25000,duration:1200}]); + expect(route.avoidMissed).toEqual(['toll']); + expect(route.snapped).toHaveLength(2); + expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1); + }); + it('uses bounded SSRF-checked requests, keeps every snap and caches per user', async () => { + const { router } = setup(); + const route = await router.route(1, 1, 1, points, 'driving', []); + expect(route.parts.map((p) => p.duration)).toEqual([100, 200]); + expect(route.snapped).toHaveLength(3); + expect(route.snapped?.[1].meters).toBe(25); + expect(safeFetchAdminConfigured).toHaveBeenCalledWith( + expect.stringContaining('routing.openstreetmap.de/routed-car'), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + await router.route(1, 1, 1, points, 'driving', []); + expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1); + const other = router.route(2, 1, 1, points, 'driving', []); + await vi.runAllTimersAsync(); + await other; + expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2); + }); + it('keeps a private OSRM instance private and reports unsupported avoidance', async () => { + const { router } = setup({ routing_base_url: 'http://router.internal/' }); + const route = await router.route(1, 1, 1, points, 'driving', ['toll']); + expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1); + expect(safeFetchAdminConfigured).toHaveBeenCalledWith( + expect.stringMatching(/^http:\/\/router.internal\/route/), + expect.anything(), + ); + expect(route.avoidMissed).toEqual(['toll']); + }); + it('falls back from failed Valhalla without claiming avoidance succeeded', async () => { + vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response('', { status: 503 })); + const pending = setup().router.route(1, 1, 1, points, 'driving', ['ferry']); + await vi.runAllTimersAsync(); + expect((await pending).avoidMissed).toEqual(['ferry']); + expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2); + }); + it('does not apply car avoidance to walking routes', async () => { + const route = await setup().router.route(1, 1, 1, points, 'walking', ['toll']); + expect(route.avoidMissed).toEqual([]); + expect(safeFetchAdminConfigured).toHaveBeenCalledWith( + expect.stringContaining('routed-foot/route/v1/foot/'), + expect.anything(), + ); + }); + it.each([ + { code: 'NoRoute' }, + { + code: 'Ok', + routes: [ + { + distance: 1, + duration: 1, + legs: [], + geometry: { + coordinates: [ + [999, 0], + [0, 0], + ], + }, + }, + ], + }, + ])('rejects malformed provider answers', async (body) => { + vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response(JSON.stringify(body))); + await expect(setup().router.route(1, 1, 1, points, 'driving', [])).rejects.toThrow(); + }); + it('requires a granted plugin and a declared profile, without public fallback', async () => { + const { router, hooks } = setup(); + await expect(router.route(1, 1, 1, points, 'plugin:scenic/missing', [])).rejects.toThrow(); + expect(hooks.route).not.toHaveBeenCalled(); + expect(safeFetchAdminConfigured).not.toHaveBeenCalled(); + }); + it('normalizes plugin output under the requesting user and trip', async () => { + const { router, hooks } = setup(); + hooks.route.mockResolvedValue({ + coordinates: [ + [48, 10], + [49, 11], + [50, 12], + ], + distance: 3000, + duration: 300, + legs: [ + { distance: 1000, duration: 100 }, + { distance: 2000, duration: 200 }, + ], + viaPoints: [], + }); + expect((await router.route(7, 8, 9, points, 'plugin:scenic/car', [])).leg.seg.distance).toBe(3000); + expect(hooks.route).toHaveBeenCalledWith('scenic', { tripId: 8, dayId: 9, profile: 'car', waypoints: points }, 7); + expect(router.profiles()).toContain('plugin:scenic/car'); + }); +}); diff --git a/server/tests/unit/nest/roadtrip-search.test.ts b/server/tests/unit/nest/roadtrip-search.test.ts new file mode 100644 index 000000000..7b2be8a8f --- /dev/null +++ b/server/tests/unit/nest/roadtrip-search.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RoadtripSearchService } from '../../../src/nest/roadtrip/roadtrip-search.service'; + +vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => true })); +const bbox = { south: 48, north: 49, west: 10, east: 11 }; +const place = { id: 'one', name: 'Station', lat: 48.5, lng: 10.5 }; + +describe('Roadtrip plugin search', () => { + it('passes category and bounds, keeps sources and rejects out-of-area hits', async () => { + const maps = { pois: vi.fn().mockResolvedValue({ pois: [], source: 'trek-places', truncated: false, clamped: false }) }; + const hooks = { providersOf: () => ['stations'], searchPlaces: vi.fn().mockResolvedValue([ + place, { ...place, id: 'far', lat: 51 }, { ...place, id: 'other', category: 'hotel' }, + ]) }; + const service = new RoadtripSearchService(maps as never, hooks as never); + const answer = await service.search({ categories: ['fuel'], bbox }, 7); + expect(hooks.searchPlaces).toHaveBeenCalledWith('stations', expect.objectContaining({ category: 'fuel', bounds: bbox, limit: 20 }), 7); + expect(answer.pois).toHaveLength(1); + expect(answer.pois[0]).toMatchObject({ osm_id: 'plugin:stations:one', source: 'plugin:stations', category: 'fuel' }); + expect(answer.sources).toEqual(['trek-places', 'plugin:stations']); + expect(answer.failedSources).toEqual([]); + }); + + it('keeps successful providers when core and another provider fail', async () => { + const maps = { pois: vi.fn().mockRejectedValue(new Error('private detail')) }; + const hooks = { providersOf: () => ['broken', 'working'], searchPlaces: vi.fn(async (id: string) => { + if (id === 'broken') throw new Error('private plugin failure'); + return [place]; + }) }; + const answer = await new RoadtripSearchService(maps as never, hooks as never).search({ categories: ['fuel'], bbox }, 7); + expect(answer.pois).toHaveLength(1); + expect(answer.failedSources).toEqual(['TREK', 'plugin:broken']); + expect(JSON.stringify(answer)).not.toContain('private'); + }); + + it('reports malformed answers and deduplicates repeated provider IDs', async () => { + const maps = { pois: vi.fn().mockResolvedValue({ pois: [], source: 'trek-places', truncated: false, clamped: true }) }; + const hooks = { providersOf: () => ['bad', 'good'], searchPlaces: vi.fn(async (id: string) => id === 'bad' ? {} : [place, place]) }; + const answer = await new RoadtripSearchService(maps as never, hooks as never).search({ categories: ['fuel', 'fuel'], bbox }, 7); + expect(answer.failedSources).toEqual(['plugin:bad']); + expect(answer.pois).toHaveLength(1); + expect(answer.clamped).toBe(true); + expect(hooks.searchPlaces).toHaveBeenCalledTimes(2); + }); +}); diff --git a/shared/package.json b/shared/package.json index 1145257bd..0abaa44ed 100644 --- a/shared/package.json +++ b/shared/package.json @@ -37,6 +37,16 @@ "types": "./dist/i18n/*/index.d.cts", "default": "./dist/i18n/*/index.cjs" } + }, + "./roadtrip": { + "import": { + "types": "./dist/roadtrip/planning.d.mts", + "default": "./dist/roadtrip/planning.mjs" + }, + "require": { + "types": "./dist/roadtrip/planning.d.cts", + "default": "./dist/roadtrip/planning.cjs" + } } }, "typesVersions": { diff --git a/shared/src/assignment/assignment.schema.spec.ts b/shared/src/assignment/assignment.schema.spec.ts index f75a03b73..31be55e01 100644 --- a/shared/src/assignment/assignment.schema.spec.ts +++ b/shared/src/assignment/assignment.schema.spec.ts @@ -1,5 +1,6 @@ import { assignmentCreateRequestSchema, + assignmentEndDayRequestSchema, assignmentMoveRequestSchema, assignmentParticipantsRequestSchema, assignmentTransportRequestSchema, @@ -7,6 +8,14 @@ import { import { describe, it, expect } from 'vitest'; +it('requires a boolean for an explicit day end', () => { + expect(assignmentEndDayRequestSchema.parse({ end_day: true })).toEqual({ end_day: true }); + expect(assignmentEndDayRequestSchema.parse({ end_day: false })).toEqual({ end_day: false }); + for (const end_day of [1, 'true', null, undefined]) { + expect(assignmentEndDayRequestSchema.safeParse({ end_day }).success).toBe(false); + } +}); + describe('assignmentCreateRequestSchema', () => { it('requires a place_id; notes optional/nullable', () => { expect(assignmentCreateRequestSchema.safeParse({ place_id: 2 }).success).toBe(true); diff --git a/shared/src/assignment/assignment.schema.ts b/shared/src/assignment/assignment.schema.ts index 3347003d8..64c5a7988 100644 --- a/shared/src/assignment/assignment.schema.ts +++ b/shared/src/assignment/assignment.schema.ts @@ -40,6 +40,7 @@ export const assignmentSchema = z.object({ notes: z.string().nullable().optional(), assignment_time: z.string().nullable().optional(), assignment_end_time: z.string().nullable().optional(), + end_day: z.boolean().optional(), // Per-segment travel mode (#1281): the transport mode of the leg LEAVING this // stop for the next one. null = inherit the day's default_transport_mode. leg_transport_mode: z.string().nullable().optional(), @@ -78,6 +79,11 @@ export const assignmentTimeRequestSchema = z.object({ }); export type AssignmentTimeRequest = z.infer; +export const assignmentEndDayRequestSchema = z.object({ + end_day: z.boolean(), +}); +export type AssignmentEndDayRequest = z.infer; + /** * PUT /:id/notes (#2163) — edit the per-assignment note after creation. * Deliberately uncapped like the create contract above: the REST create body diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 78b99dad4..8ac3a19e0 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -1,6 +1,59 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'العثور على إقامة', + 'roadtrip.stay.portalHint': 'تحقق من الوجهة والتواريخ والضيوف في الموقع. ابحث يدوياً في CHECK24.', + 'roadtrip.charging.available': 'متاح', + 'roadtrip.charging.unknown': 'لا تتوفر بيانات موثوقة', + 'roadtrip.charging.stale': 'الحالة قديمة', + 'roadtrip.charging.prices': 'التعريفات المنشورة', + 'roadtrip.charging.note': 'تعريفة منشورة؛ قد تُطبق ضرائب وشروط ورسوم إضافية. قد يختلف سعر بطاقة الشحن.', + 'roadtrip.charging.coverage': 'بيانات مفتوحة؛ تختلف التغطية حسب المنطقة والمشغل.', + 'roadtrip.import.title': 'استيراد مسار Google Maps', + 'roadtrip.import.note': 'تُضاف المحطات إلى اليوم المحدد. يعيد TREK حساب المسار.', + 'roadtrip.import.unresolved': 'تعذر تحديد الموقع؛ سيتم تخطي هذه المحطة.', + 'roadtrip.import.day': 'اليوم', + 'roadtrip.hazards.show': 'عرض مناطق الخطر', + 'roadtrip.hazards.current': 'التحذيرات الحالية', + 'roadtrip.hazards.note': 'التحذيرات لا تعني إغلاق الطرق. يبقى المسار دون تغيير.', + 'roadtrip.hazards.point': 'الموقع فقط؛ المنطقة المتأثرة غير معروفة.', + 'roadtrip.hazards.loading': 'جارٍ تحميل التحذيرات…', + 'roadtrip.hazards.unavailable': 'غير متاح', + 'roadtrip.hazards.offline': 'دون اتصال: التحذيرات الحالية غير متاحة', + 'roadtrip.hazards.partial': 'تغطية محدودة', + 'roadtrip.stops.section': 'توقفات الخدمة', + 'roadtrip.stops.inDays': 'إظهارها في الأيام أيضًا', + 'roadtrip.stops.daysHint': + 'يسري على جميع توقفات الخدمة في هذه الرحلة. عند الإيقاف تظهر فقط في مخطط Roadtrip. أعد التفعيل لإظهارها في الأيام.', + 'roadtrip.window.dragHint': + 'عند الاتصال بالإنترنت، اسحب نهاية اليوم على المسار أو إلى مكان؛ انقر بزر الفأرة الأيمن لاستعادة النهاية التلقائية.', + 'roadtrip.window.resetBoundaries': 'استعادة نهايات الأيام التلقائية', + 'roadtrip.window.endHere': 'إنهاء اليوم هنا', + 'roadtrip.window.endHereHint': + 'يمكنك إنهاء اليوم عند محطة محددة من تفاصيل المكان عندما يكون وضع Roadtrip وأوقات السفر اليومية مفعّلين.', + 'roadtrip.window.endAt': 'نهاية اليوم', + 'roadtrip.window.onRoute': 'على الطريق', + 'roadtrip.window.atStop': 'في آخر مكان', + 'roadtrip.window.routeHint': 'التوقف على الطريق عند بلوغ وقت نهاية اليوم.', + 'roadtrip.window.stopHint': 'البقاء في آخر مكان إذا تعذر الوصول إلى المكان التالي قبل نهاية اليوم.', + 'roadtrip.window.legTooLong': + 'تتجاوز مدة إحدى الرحلات الفترة اليومية. أضف مكانًا في الطريق أو اختر التوقف على الطريق.', + 'roadtrip.window.pointLabel': 'نهاية اليوم {day} عند الساعة {time}', + 'roadtrip.window.title': 'أوقات السفر اليومية', + 'roadtrip.window.start': 'بداية اليوم', + 'roadtrip.window.end': 'نهاية اليوم', + 'roadtrip.window.hint': + 'حدّد الوقتين لتخطيط الاستراحات اليومية. امسح أحدهما لإيقاف الميزة. الأوقات اليدوية لها الأولوية. يتم وصل الأيام تلقائيًا.', + 'roadtrip.window.invalid': 'يجب أن يكون وقت النهاية بعد وقت البداية.', + 'roadtrip.window.stop': 'نهاية اليوم', + 'roadtrip.window.resume': 'متابعة الرحلة', + 'roadtrip.window.pointHint': 'هذه نقطة التوقف الليلية المتوقعة لرحلتك.', + 'roadtrip.window.departure': 'المتابعة بعد بقية الزيارة الساعة {time}', + 'roadtrip.window.showPoint': 'عرض على الخريطة', + 'roadtrip.window.incomplete': 'يلزم توفر جميع المسارات الرابطة لحساب الاستراحات اليومية.', + 'roadtrip.window.conflict': + 'يتعارض وقت ثابت مع القيادة والاستراحات. تبقى الأوقات اليدوية كما هي؛ تم إيقاف التخطيط التلقائي مؤقتًا.', + 'roadtrip.window.tooLong': 'تتطلب هذه الأوقات أكثر من 365 يومًا إضافيًا. اختر فترة يومية أطول.', 'roadtrip.mode.roadtrip': 'رحلة برية', 'roadtrip.mode.days': 'الأيام', 'roadtrip.mode.label': 'عرض الخطة', @@ -68,7 +121,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.unavailable': 'غير متاح: هذه النسخة تستخدم محركها الخاص، وهو لا يستطيع استبعاد فئة طرق.', 'roadtrip.avoid.badge': 'تجنُّب {count}', 'roadtrip.avoid.missed': 'لا مفر من {classes}', - 'roadtrip.avoid.missedHint': 'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.', + 'roadtrip.avoid.missedHint': + 'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.', 'roadtrip.refuel.dry': 'ينفد الخزان هنا', 'roadtrip.refuel.after': 'بعد {distance}', 'roadtrip.limit.vehicleLabel': 'بماذا تقود', @@ -110,7 +164,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': 'بطول {distance}', 'roadtrip.track.gap': 'على بعد {distance} من هذا اليوم', 'roadtrip.track.onDay': 'على امتداد هذا اليوم', - 'roadtrip.track.explain': 'يُسحب المسار إلى التتبع بواسطة بضع نقاط عبور. تُستبدل نقاط العبور الموجودة على تلك المراحل.', + 'roadtrip.track.explain': + 'يُسحب المسار إلى التتبع بواسطة بضع نقاط عبور. تُستبدل نقاط العبور الموجودة على تلك المراحل.', 'roadtrip.track.action': 'اتبع هذا المسار', 'roadtrip.track.working': 'يجري ملاءمة المسار مع التتبع، الجولة {round}', 'roadtrip.track.done': 'تم وضع {count} نقطة عبور. يبقى المسار ضمن {distance} من التتبع.', diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 287f1fd24..f66ee48f0 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -26,7 +26,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'محرك مسارات خاص', 'settings.routingBaseHint': 'خادم OSRM خاص بك. اتركه فارغًا لاستخدام الخوادم العامة التي تسمح بطلب واحد تقريبًا في الثانية — يكفي ليوم واحد، وضيّق لرحلة برية. يسري بعد إعادة تشغيل الخادم.', 'settings.valhallaBase': 'خادم Valhalla خاص بك', - 'settings.valhallaBaseHint': 'خادم Valhalla خاص بك. يُسأل عندما يجب أن تتجنب المرحلة الرسوم أو الطرق السريعة أو العبّارات — الخوادم العامة لـ OSRM لا تستطيع الإجابة عن ذلك. الفراغ يستخدم Valhalla العام من FOSSGIS، إلا إذا حُدِّد أعلاه محرك توجيه خاص بك، فلا يُسأل أي محرك ثانٍ. يسري بعد إعادة تشغيل الخادم.', + 'settings.valhallaBaseHint': + 'يستخدم TREK خدمة Valhalla العامة من FOSSGIS افتراضيًا لتجنب الطرق ذات الرسوم والطرق السريعة والعبّارات. أدخل رابط خادم Valhalla الخاص بك هنا لاستخدامه بدلًا منها. إذا تم إعداد خادم توجيه خاص فقط، فلن تُستخدم خدمة Valhalla العامة. بعد إدخال رابط خاص، أعد تشغيل الخادم وحمّل الصفحة مجددًا.', 'settings.mapHint': 'قالب URL لبلاطات الخريطة', 'settings.mapProvider': 'مزود الخريطة', 'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 23d42d19d..ae6888f9d 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -1,6 +1,60 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Encontrar hospedagem', + 'roadtrip.stay.portalHint': 'Confira destino, datas e hóspedes no portal. No CHECK24, pesquise manualmente.', + 'roadtrip.charging.available': 'disponíveis', + 'roadtrip.charging.unknown': 'Sem dados confiáveis', + 'roadtrip.charging.stale': 'Status desatualizado', + 'roadtrip.charging.prices': 'Tarifas publicadas', + 'roadtrip.charging.note': + 'Tarifa publicada; impostos, condições e taxas extras podem se aplicar. O preço do seu cartão pode variar.', + 'roadtrip.charging.coverage': 'Dados abertos; cobertura varia por região e operador.', + 'roadtrip.import.title': 'Importar rota do Google Maps', + 'roadtrip.import.note': 'As paradas são adicionadas ao dia escolhido. O TREK recalcula a rota.', + 'roadtrip.import.unresolved': 'Local não encontrado; esta parada será ignorada.', + 'roadtrip.import.day': 'Dia', + 'roadtrip.hazards.show': 'Mostrar áreas de risco', + 'roadtrip.hazards.current': 'Alertas atuais', + 'roadtrip.hazards.note': 'Alertas não são interdições de vias. A rota não muda.', + 'roadtrip.hazards.point': 'Apenas localização; área afetada desconhecida.', + 'roadtrip.hazards.loading': 'Carregando alertas…', + 'roadtrip.hazards.unavailable': 'Indisponível', + 'roadtrip.hazards.offline': 'Offline: alertas indisponíveis', + 'roadtrip.hazards.partial': 'Cobertura limitada', + 'roadtrip.stops.section': 'Paradas de serviço', + 'roadtrip.stops.inDays': 'Mostrar também em Dias', + 'roadtrip.stops.daysHint': + 'Aplica-se a todas as paradas de serviço desta viagem. Desativado, elas ficam apenas no Roadtrip. Ative para mostrá-las novamente em Dias.', + 'roadtrip.window.dragHint': + 'Online, arraste o fim do dia pela rota ou até um lugar; clique com o botão direito para restaurar o fim automático.', + 'roadtrip.window.resetBoundaries': 'Restaurar fins de dia automáticos', + 'roadtrip.window.endHere': 'Encerrar o dia aqui', + 'roadtrip.window.endHereHint': + 'Você pode encerrar o dia em uma parada específica nos detalhes do local quando o modo Roadtrip e os horários diários estiverem ativos.', + 'roadtrip.window.endAt': 'Encerrar o dia', + 'roadtrip.window.onRoute': 'No trajeto', + 'roadtrip.window.atStop': 'No último local', + 'roadtrip.window.routeHint': 'Pausar no trajeto ao atingir o horário de término.', + 'roadtrip.window.stopHint': 'Ficar no último local se não for possível chegar ao próximo antes do fim do dia.', + 'roadtrip.window.legTooLong': + 'Um trecho excede o período diário. Adicione um local no caminho ou escolha pausar no trajeto.', + 'roadtrip.window.pointLabel': 'Fim do dia {day} às {time}', + 'roadtrip.window.title': 'Horários diários', + 'roadtrip.window.start': 'Início do dia', + 'roadtrip.window.end': 'Fim do dia', + 'roadtrip.window.hint': + 'Defina os dois horários para planejar pausas diárias. Limpe um deles para desativar. Horários manuais têm prioridade. Os dias são conectados automaticamente.', + 'roadtrip.window.invalid': 'O horário final deve ser posterior ao inicial.', + 'roadtrip.window.stop': 'Fim do dia', + 'roadtrip.window.resume': 'Continuar viagem', + 'roadtrip.window.pointHint': 'Este é o ponto estimado de pausa durante a noite.', + 'roadtrip.window.departure': 'Continuar após o restante da visita às {time}', + 'roadtrip.window.showPoint': 'Mostrar no mapa', + 'roadtrip.window.incomplete': 'Todas as rotas de conexão são necessárias para calcular as pausas diárias.', + 'roadtrip.window.conflict': + 'Um horário fixo conflita com o trajeto e as pausas. Os horários manuais são mantidos; o planejamento automático está pausado.', + 'roadtrip.window.tooLong': 'Esses horários exigiriam mais de 365 dias extras. Escolha um período diário maior.', 'roadtrip.mode.roadtrip': 'Viagem de carro', 'roadtrip.mode.days': 'Dias', 'roadtrip.mode.label': 'Visão do plano', @@ -64,11 +118,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Pedágios', 'roadtrip.avoid.motorway': 'Rodovias', 'roadtrip.avoid.ferry': 'Balsas', - 'roadtrip.avoid.hint': 'Isso é uma penalização, não uma proibição. Quando o trajeto não tem como desviar, ele usa mesmo assim e o dia avisa.', - 'roadtrip.avoid.unavailable': 'Indisponível: esta instância usa o próprio motor, que não consegue deixar de fora uma classe de via.', + 'roadtrip.avoid.hint': + 'Isso é uma penalização, não uma proibição. Quando o trajeto não tem como desviar, ele usa mesmo assim e o dia avisa.', + 'roadtrip.avoid.unavailable': + 'Indisponível: esta instância usa o próprio motor, que não consegue deixar de fora uma classe de via.', 'roadtrip.avoid.badge': '{count} evitados', 'roadtrip.avoid.missed': '{classes} inevitável', - 'roadtrip.avoid.missedHint': 'Este dia não tem rota que desvie, então o trajeto usa. Os outros dias continuam evitando o que dá.', + 'roadtrip.avoid.missedHint': + 'Este dia não tem rota que desvie, então o trajeto usa. Os outros dias continuam evitando o que dá.', 'roadtrip.refuel.dry': 'Aqui o tanque acaba', 'roadtrip.refuel.after': 'após {distance}', 'roadtrip.limit.vehicleLabel': 'O que você dirige', @@ -110,11 +167,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} de comprimento', 'roadtrip.track.gap': 'a {distance} deste dia', 'roadtrip.track.onDay': 'ao longo deste dia', - 'roadtrip.track.explain': 'O percurso é puxado para a trilha com alguns pontos de passagem. Os pontos existentes nessas etapas são substituídos.', + 'roadtrip.track.explain': + 'O percurso é puxado para a trilha com alguns pontos de passagem. Os pontos existentes nessas etapas são substituídos.', 'roadtrip.track.action': 'Seguir esta trilha', 'roadtrip.track.working': 'Ajustando o percurso à trilha, rodada {round}', 'roadtrip.track.done': '{count} pontos de passagem colocados. O percurso fica a menos de {distance} da trilha.', - 'roadtrip.track.capped': '{count} pontos de passagem colocados. No ponto mais largo, o percurso fica a {distance} da trilha.', + 'roadtrip.track.capped': + '{count} pontos de passagem colocados. No ponto mais largo, o percurso fica a {distance} da trilha.', 'roadtrip.track.already': 'O percurso já seguia esta trilha.', 'roadtrip.track.errorRoute': 'O serviço de rotas não respondeu.', 'roadtrip.track.errorSave': 'Não foi possível salvar os pontos de passagem.', @@ -129,7 +188,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Nada no trajeto corresponde a “{name}”.', 'roadtrip.poi.noneMatchFilters': 'Nada no caminho corresponde a esses filtros.', 'roadtrip.poi.foundFiltered': '{count} de {total} no trajeto', - 'roadtrip.poi.truncated': 'Em {count} trechos havia mais do que cabe em uma resposta — estreite o corredor para ver o restante.', + 'roadtrip.poi.truncated': + 'Em {count} trechos havia mais do que cabe em uma resposta — estreite o corredor para ver o restante.', 'roadtrip.poi.search': 'Buscar', 'roadtrip.poi.searching': 'Buscando {done} de {total}', 'roadtrip.poi.capped': 'A rota é longa — apenas o primeiro trecho foi pesquisado.', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 892e35fa5..e8086a5ef 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -28,7 +28,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Servidor de rotas próprio', 'settings.routingBaseHint': 'Uma instância própria do OSRM. Vazio usa os servidores públicos, que permitem cerca de uma requisição por segundo — suficiente para um dia, apertado para uma viagem de carro. Passa a valer após reiniciar o servidor.', 'settings.valhallaBase': 'Instância Valhalla própria', - 'settings.valhallaBaseHint': 'Uma instância Valhalla própria. Ela é consultada quando um trecho deve evitar pedágios, rodovias ou balsas — os servidores OSRM públicos não conseguem responder isso. Vazio usa a Valhalla pública da FOSSGIS, a não ser que acima esteja indicado um motor de rotas próprio; nesse caso nenhum segundo motor é consultado. Só vale após reiniciar o servidor.', + 'settings.valhallaBaseHint': + 'Por padrão, o TREK usa a Valhalla pública da FOSSGIS para evitar pedágios, rodovias e balsas. Insira aqui a URL da sua própria Valhalla para usá-la no lugar. Se apenas uma instância de roteamento própria estiver configurada, a Valhalla pública não será usada. Após inserir uma URL própria, reinicie o servidor e recarregue a página.', 'settings.mapHint': 'URL do modelo de blocos do mapa', 'settings.mapProvider': 'Provedor de mapa', 'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index c4e4880c9..50cec5f3a 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -1,6 +1,61 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Troba allotjament', + 'roadtrip.stay.portalHint': 'Comprova la destinació, les dates i els hostes al portal. A CHECK24, cerca manualment.', + 'roadtrip.charging.available': 'disponibles', + 'roadtrip.charging.unknown': 'No hi ha dades fiables', + 'roadtrip.charging.stale': 'Estat desactualitzat', + 'roadtrip.charging.prices': 'Tarifes publicades', + 'roadtrip.charging.note': + 'Tarifa publicada; pot haver-hi impostos, condicions i càrrecs addicionals. El preu de la targeta pot variar.', + 'roadtrip.charging.coverage': 'Dades obertes; cobertura segons regió i operador.', + 'roadtrip.import.title': 'Importa una ruta de Google Maps', + 'roadtrip.import.note': 'Les parades s’afegeixen al dia triat. TREK recalcula la ruta.', + 'roadtrip.import.unresolved': 'Ubicació no trobada; aquesta parada s’ometrà.', + 'roadtrip.import.day': 'Dia', + 'roadtrip.hazards.show': 'Mostra zones de perill', + 'roadtrip.hazards.current': 'Avisos actuals', + 'roadtrip.hazards.note': 'Els avisos no són talls de carretera. La ruta no canvia.', + 'roadtrip.hazards.point': 'Només ubicació; àrea afectada desconeguda.', + 'roadtrip.hazards.loading': 'S’estan carregant els avisos…', + 'roadtrip.hazards.unavailable': 'No disponible', + 'roadtrip.hazards.offline': 'Sense connexió: avisos no disponibles', + 'roadtrip.hazards.partial': 'Cobertura limitada', + 'roadtrip.stops.section': 'Parades de servei', + 'roadtrip.stops.inDays': 'Mostra també a Dies', + 'roadtrip.stops.daysHint': + 'S’aplica a totes les parades de servei del viatge. Si es desactiva, només apareixen a Roadtrip. Activa-ho per tornar-les a mostrar a Dies.', + 'roadtrip.window.dragHint': + 'En línia, arrossega el final del dia per la ruta o fins a un lloc; fes clic amb el botó dret per restaurar el final automàtic.', + 'roadtrip.window.resetBoundaries': 'Restaura els finals de dia automàtics', + 'roadtrip.window.endHere': 'Acaba el dia aquí', + 'roadtrip.window.endHereHint': + 'Pots acabar el dia en una parada concreta des dels seus detalls quan el mode Roadtrip i els horaris diaris estiguin actius.', + 'roadtrip.window.endAt': 'Final del dia', + 'roadtrip.window.onRoute': 'Durant el trajecte', + 'roadtrip.window.atStop': 'A l’últim lloc', + 'roadtrip.window.routeHint': 'Fer una pausa al trajecte en arribar a l’hora de finalització.', + 'roadtrip.window.stopHint': 'Quedar-se a l’últim lloc si no es pot arribar al següent abans del final del dia.', + 'roadtrip.window.legTooLong': + 'Un trajecte supera l’horari diari. Afegeix un lloc pel camí o tria fer una pausa durant el trajecte.', + 'roadtrip.window.pointLabel': 'Final del dia {day} a les {time}', + 'roadtrip.window.title': 'Horaris diaris', + 'roadtrip.window.start': 'Inici del dia', + 'roadtrip.window.end': 'Final del dia', + 'roadtrip.window.hint': + 'Indica les dues hores per planificar pauses diàries. Buida un camp per desactivar-les. Les hores manuals tenen prioritat. Els dies es connecten automàticament.', + 'roadtrip.window.invalid': 'L’hora final ha de ser posterior a la inicial.', + 'roadtrip.window.stop': 'Final del dia', + 'roadtrip.window.resume': 'Continuar el viatge', + 'roadtrip.window.pointHint': 'Aquest és el punt estimat de pausa durant la nit.', + 'roadtrip.window.departure': 'Continuar després de la resta de la visita a les {time}', + 'roadtrip.window.showPoint': 'Mostra al mapa', + 'roadtrip.window.incomplete': 'Calen totes les rutes de connexió per calcular les pauses diàries.', + 'roadtrip.window.conflict': + 'Una hora fixa entra en conflicte amb el trajecte i les pauses. Es mantenen les hores manuals; la planificació automàtica està en pausa.', + 'roadtrip.window.tooLong': + 'Aquests horaris requereixen més de 365 dies addicionals. Tria una franja diària més llarga.', 'roadtrip.mode.roadtrip': 'Viatge per carretera', 'roadtrip.mode.days': 'Dies', 'roadtrip.mode.label': 'Vista del pla', @@ -64,11 +119,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Peatges', 'roadtrip.avoid.motorway': 'Autopistes', 'roadtrip.avoid.ferry': 'Ferris', - 'roadtrip.avoid.hint': 'Es penalitzen, no es prohibeixen. Quan un trajecte no té manera d’evitar-ho, ho fa servir igualment i el dia ho indica.', - 'roadtrip.avoid.unavailable': 'No disponible: aquesta instància fa servir el seu propi motor, que no pot ometre una classe de via.', + 'roadtrip.avoid.hint': + 'Es penalitzen, no es prohibeixen. Quan un trajecte no té manera d’evitar-ho, ho fa servir igualment i el dia ho indica.', + 'roadtrip.avoid.unavailable': + 'No disponible: aquesta instància fa servir el seu propi motor, que no pot ometre una classe de via.', 'roadtrip.avoid.badge': '{count} evitats', 'roadtrip.avoid.missed': '{classes} inevitable', - 'roadtrip.avoid.missedHint': 'Aquest dia no té cap ruta que ho eviti, així que el trajecte ho fa servir. La resta de dies continuen evitant el que poden.', + 'roadtrip.avoid.missedHint': + 'Aquest dia no té cap ruta que ho eviti, així que el trajecte ho fa servir. La resta de dies continuen evitant el que poden.', 'roadtrip.refuel.dry': 'Aquí s’esgota el dipòsit', 'roadtrip.refuel.after': 'després de {distance}', 'roadtrip.limit.vehicleLabel': 'Què condueixes', @@ -110,11 +168,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} de llarg', 'roadtrip.track.gap': 'a {distance} d’aquest dia', 'roadtrip.track.onDay': 'al llarg d’aquest dia', - 'roadtrip.track.explain': 'El trajecte s’ajusta a la traça amb uns quants punts de pas. Els punts existents en aquests trams se substitueixen.', + 'roadtrip.track.explain': + 'El trajecte s’ajusta a la traça amb uns quants punts de pas. Els punts existents en aquests trams se substitueixen.', 'roadtrip.track.action': 'Segueix aquesta traça', 'roadtrip.track.working': 'Ajustant el trajecte a la traça, ronda {round}', - 'roadtrip.track.done': 'S’han col·locat {count} punts de pas. El trajecte es manté a menys de {distance} de la traça.', - 'roadtrip.track.capped': 'S’han col·locat {count} punts de pas. En el punt més ample, el trajecte queda a {distance} de la traça.', + 'roadtrip.track.done': + 'S’han col·locat {count} punts de pas. El trajecte es manté a menys de {distance} de la traça.', + 'roadtrip.track.capped': + 'S’han col·locat {count} punts de pas. En el punt més ample, el trajecte queda a {distance} de la traça.', 'roadtrip.track.already': 'El trajecte ja seguia aquesta traça.', 'roadtrip.track.errorRoute': 'El servei de rutes no ha respost.', 'roadtrip.track.errorSave': 'No s’han pogut desar els punts de pas.', @@ -129,7 +190,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Res del trajecte no coincideix amb «{name}».', 'roadtrip.poi.noneMatchFilters': 'Res del camí no coincideix amb aquests filtres.', 'roadtrip.poi.foundFiltered': '{count} de {total} al trajecte', - 'roadtrip.poi.truncated': 'En {count} trams hi havia més del que cap en una resposta: estreny el corredor per veure la resta.', + 'roadtrip.poi.truncated': + 'En {count} trams hi havia més del que cap en una resposta: estreny el corredor per veure la resta.', 'roadtrip.poi.search': 'Cerca', 'roadtrip.poi.searching': 'Cercant {done} de {total}', 'roadtrip.poi.capped': 'La ruta és llarga: només s’ha cercat el primer tram.', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index c415ad51f..1f7007457 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -17,7 +17,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Motor de rutes propi', 'settings.routingBaseHint': 'Una instància pròpia d’OSRM. Buit fa servir els servidors públics, que permeten aproximadament una petició per segon: prou per a un dia, just per a un viatge llarg. Té efecte després de reiniciar el servidor.', 'settings.valhallaBase': 'Instància Valhalla pròpia', - 'settings.valhallaBaseHint': 'Una instància Valhalla pròpia. Es consulta quan una etapa ha d’evitar peatges, autopistes o ferris: els servidors OSRM públics no poden respondre-ho. Buit fa servir el Valhalla públic de FOSSGIS, tret que a dalt s’indiqui un motor de rutes propi, cas en què no es consulta cap segon motor. Té efecte després de reiniciar el servidor.', + 'settings.valhallaBaseHint': + 'TREK utilitza per defecte la Valhalla pública de FOSSGIS per evitar peatges, autopistes i ferris. Introdueix aquí l’URL de la teva Valhalla per utilitzar-la en lloc de la pública. Si només hi ha una instància d’encaminament pròpia configurada, no s’utilitza la Valhalla pública. Després d’introduir una URL pròpia, reinicia el servidor i torna a carregar la pàgina.', 'settings.mapHint': "Plantilla d'URL per als mosaics del mapa", 'settings.mapProvider': 'Proveïdor de mapa', 'settings.mapProviderHint': 'Afecta els mapes de Trip Planner i Journey. Atles sempre utilitza Leaflet.', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 8f082dc43..6d9146fee 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -1,6 +1,60 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Najít ubytování', + 'roadtrip.stay.portalHint': 'Na portálu ověřte cíl, termín a hosty. Na CHECK24 hledejte ručně.', + 'roadtrip.charging.available': 'volné', + 'roadtrip.charging.unknown': 'Spolehlivá data nejsou dostupná', + 'roadtrip.charging.stale': 'Zastaralý stav', + 'roadtrip.charging.prices': 'Zveřejněné tarify', + 'roadtrip.charging.note': + 'Zveřejněný tarif; mohou platit daně, podmínky a další poplatky. Cena vaší karty se může lišit.', + 'roadtrip.charging.coverage': 'Otevřená data; pokrytí závisí na regionu a provozovateli.', + 'roadtrip.import.title': 'Importovat trasu z Google Maps', + 'roadtrip.import.note': 'Zastávky se přidají ke zvolenému dni. TREK trasu přepočítá.', + 'roadtrip.import.unresolved': 'Místo nenalezeno; tato zastávka bude přeskočena.', + 'roadtrip.import.day': 'Den', + 'roadtrip.hazards.show': 'Zobrazit nebezpečné oblasti', + 'roadtrip.hazards.current': 'Aktuální výstrahy', + 'roadtrip.hazards.note': 'Výstrahy nejsou uzavírky silnic. Trasa se nemění.', + 'roadtrip.hazards.point': 'Pouze místo; zasažená oblast není známa.', + 'roadtrip.hazards.loading': 'Načítání výstrah…', + 'roadtrip.hazards.unavailable': 'Nedostupné', + 'roadtrip.hazards.offline': 'Offline: aktuální výstrahy nejsou dostupné', + 'roadtrip.hazards.partial': 'Omezené pokrytí', + 'roadtrip.stops.section': 'Servisní zastávky', + 'roadtrip.stops.inDays': 'Zobrazit také ve Dnech', + 'roadtrip.stops.daysHint': + 'Platí pro všechny servisní zastávky této cesty. Po vypnutí zůstanou pouze v plánu Roadtrip. Zapnutím je znovu zobrazíte ve Dnech.', + 'roadtrip.window.dragHint': + 'Online můžete konec dne přetáhnout po trase nebo na místo; pravým tlačítkem obnovíte automatický konec.', + 'roadtrip.window.resetBoundaries': 'Obnovit automatické konce dnů', + 'roadtrip.window.endHere': 'Ukončit den zde', + 'roadtrip.window.endHereHint': + 'V podrobnostech místa můžete určit zastávku jako konec dne, když je aktivní režim Roadtrip a denní časy cestování.', + 'roadtrip.window.endAt': 'Ukončení dne', + 'roadtrip.window.onRoute': 'Na trase', + 'roadtrip.window.atStop': 'Na posledním místě', + 'roadtrip.window.routeHint': 'Zastavit na trase při dosažení času konce dne.', + 'roadtrip.window.stopHint': 'Zůstat na posledním místě, pokud další nelze dosáhnout před koncem dne.', + 'roadtrip.window.legTooLong': + 'Jeden úsek přesahuje denní časové okno. Přidejte místo po cestě nebo zvolte zastávku na trase.', + 'roadtrip.window.pointLabel': 'Konec dne {day} v {time}', + 'roadtrip.window.title': 'Denní časy cestování', + 'roadtrip.window.start': 'Začátek dne', + 'roadtrip.window.end': 'Konec dne', + 'roadtrip.window.hint': + 'Pro denní přestávky nastavte oba časy. Vymazáním jednoho automatiku vypnete. Ruční časy mají přednost. Dny se automaticky propojí.', + 'roadtrip.window.invalid': 'Konec dne musí být později než začátek.', + 'roadtrip.window.stop': 'Konec dne', + 'roadtrip.window.resume': 'Pokračovat v cestě', + 'roadtrip.window.pointHint': 'Zde je odhadované místo noční přestávky.', + 'roadtrip.window.departure': 'Pokračovat po zbytku návštěvy v {time}', + 'roadtrip.window.showPoint': 'Zobrazit na mapě', + 'roadtrip.window.incomplete': 'Denní přestávky lze spočítat až po získání všech navazujících tras.', + 'roadtrip.window.conflict': + 'Pevný čas je v rozporu s jízdou a přestávkami. Ruční časy zůstávají zachovány; automatické plánování je pozastaveno.', + 'roadtrip.window.tooLong': 'Tyto časy vyžadují více než 365 dalších dní. Zvolte delší denní interval.', 'roadtrip.mode.roadtrip': 'Cesta autem', 'roadtrip.mode.days': 'Dny', 'roadtrip.mode.label': 'Zobrazení plánu', @@ -65,10 +119,12 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.motorway': 'Dálnice', 'roadtrip.avoid.ferry': 'Trajekty', 'roadtrip.avoid.hint': 'Jde o penalizaci, ne o zákaz. Když se jízda nemá jak vyhnout, použije to a den to řekne.', - 'roadtrip.avoid.unavailable': 'Není k dispozici: tato instance jezdí přes vlastní engine, který neumí vynechat třídu silnic.', + 'roadtrip.avoid.unavailable': + 'Není k dispozici: tato instance jezdí přes vlastní engine, který neumí vynechat třídu silnic.', 'roadtrip.avoid.badge': '{count} vynecháno', 'roadtrip.avoid.missed': '{classes} nelze objet', - 'roadtrip.avoid.missedHint': 'Tento den nemá objízdnou trasu, takže tudy jede. Ostatní dny se dál vyhýbají, čemu můžou.', + 'roadtrip.avoid.missedHint': + 'Tento den nemá objízdnou trasu, takže tudy jede. Ostatní dny se dál vyhýbají, čemu můžou.', 'roadtrip.refuel.dry': 'Tady dojde palivo', 'roadtrip.refuel.after': 'po {distance}', 'roadtrip.limit.vehicleLabel': 'Čím jezdíš', @@ -110,7 +166,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': 'délka {distance}', 'roadtrip.track.gap': '{distance} od tohoto dne', 'roadtrip.track.onDay': 'podél tohoto dne', - 'roadtrip.track.explain': 'Jízda se pomocí několika průjezdních bodů přitáhne ke stopě. Stávající body na těchto úsecích budou nahrazeny.', + 'roadtrip.track.explain': + 'Jízda se pomocí několika průjezdních bodů přitáhne ke stopě. Stávající body na těchto úsecích budou nahrazeny.', 'roadtrip.track.action': 'Sledovat tuto stopu', 'roadtrip.track.working': 'Přizpůsobuji jízdu stopě, kolo {round}', 'roadtrip.track.done': 'Umístěno {count} průjezdních bodů. Jízda se drží do {distance} od stopy.', @@ -129,7 +186,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Na trase nic neodpovídá „{name}“.', 'roadtrip.poi.noneMatchFilters': 'Nic na trase neodpovídá těmto filtrům.', 'roadtrip.poi.foundFiltered': '{count} z {total} na trase', - 'roadtrip.poi.truncated': 'Na {count} úsecích jich bylo více, než se vejde do jedné odpovědi — zužte koridor, abyste viděli zbytek.', + 'roadtrip.poi.truncated': + 'Na {count} úsecích jich bylo více, než se vejde do jedné odpovědi — zužte koridor, abyste viděli zbytek.', 'roadtrip.poi.search': 'Hledat', 'roadtrip.poi.searching': 'Hledání {done} z {total}', 'roadtrip.poi.capped': 'Trasa je dlouhá — prohledán byl jen první úsek.', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 17e9d0572..099c66256 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Vlastní směrovací server', 'settings.routingBaseHint': 'Vlastní instance OSRM. Prázdné použije veřejné servery s limitem asi jednoho požadavku za sekundu — na den to stačí, na road trip je to málo. Projeví se po restartu serveru.', 'settings.valhallaBase': 'Vlastní instance Valhalla', - 'settings.valhallaBaseHint': 'Vlastní instance Valhalla. Ptáme se jí, když má úsek objet mýtné, dálnice nebo trajekty — veřejné servery OSRM to neumí zodpovědět. Prázdné použije veřejnou Valhallu od FOSSGIS, ledaže je výše uveden vlastní směrovací engine; pak se žádný druhý engine neptá. Projeví se po restartu serveru.', + 'settings.valhallaBaseHint': + 'TREK ve výchozím nastavení používá veřejnou Valhallu FOSSGIS k vyhýbání se mýtnému, dálnicím a trajektům. Zde můžete zadat URL vlastní Valhally a používat ji místo veřejné. Pokud je nastavena pouze vlastní směrovací instance, veřejná Valhalla se nepoužije. Po zadání vlastní URL restartujte server a znovu načtěte stránku.', 'settings.mapHint': 'URL šablony pro mapové dlaždice', 'settings.mapProvider': 'Poskytovatel mapy', 'settings.mapProviderHint': 'Ovlivňuje mapy v Trip Planneru a Journey. Atlas vždy používá Leaflet.', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 4260d5cfd..eaf440e12 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -1,6 +1,59 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Unterkunft finden', + 'roadtrip.stay.portalHint': 'Ziel, Daten und Gäste im Portal prüfen. Bei CHECK24 manuell suchen.', + 'roadtrip.charging.available': 'frei', + 'roadtrip.charging.unknown': 'Keine verlässlichen Daten verfügbar', + 'roadtrip.charging.stale': 'Status veraltet', + 'roadtrip.charging.prices': 'Veröffentlichte Tarife', + 'roadtrip.charging.note': + 'Veröffentlichter Tarif; Steuern, Bedingungen und Zusatzgebühren beachten. Dein Ladekartenpreis kann abweichen.', + 'roadtrip.charging.coverage': 'Offene Daten; Abdeckung abhängig von Region und Betreiber.', + 'roadtrip.import.title': 'Google-Maps-Route importieren', + 'roadtrip.import.note': 'Stopps werden an den gewählten Tag angehängt. TREK berechnet die Route neu.', + 'roadtrip.import.unresolved': 'Ort nicht gefunden; dieser Stopp wird übersprungen.', + 'roadtrip.import.day': 'Tag', + 'roadtrip.hazards.show': 'Gefahrengebiete anzeigen', + 'roadtrip.hazards.current': 'Aktuelle Warnungen', + 'roadtrip.hazards.note': 'Warnungen sind keine Straßensperrungen. Die Route bleibt unverändert.', + 'roadtrip.hazards.point': 'Nur Ereignisort, betroffene Fläche unbekannt.', + 'roadtrip.hazards.loading': 'Warnungen werden geladen…', + 'roadtrip.hazards.unavailable': 'Nicht verfügbar', + 'roadtrip.hazards.offline': 'Offline: aktuelle Warnungen nicht verfügbar', + 'roadtrip.hazards.partial': 'Eingeschränkte Abdeckung', + 'roadtrip.stops.section': 'Service Stops', + 'roadtrip.stops.inDays': 'Auch in Days anzeigen', + 'roadtrip.stops.daysHint': 'Aus: Alle Service Stops bleiben nur im Roadtrip.', + 'roadtrip.window.dragHint': + 'Online kannst du ein Tagesende entlang der Route oder auf einen Ort ziehen; ein Rechtsklick stellt das automatische Ende wieder her.', + 'roadtrip.window.resetBoundaries': 'Automatische Tagesenden wiederherstellen', + 'roadtrip.window.endHere': 'Tag hier beenden', + 'roadtrip.window.endHereHint': + 'In den Ortsdetails kannst du einen Ort als Tagesende festlegen, solange der Roadtrip-Modus und die täglichen Reisezeiten aktiv sind.', + 'roadtrip.window.endAt': 'Tagesende planen', + 'roadtrip.window.onRoute': 'Auf der Strecke', + 'roadtrip.window.atStop': 'Am letzten Ort', + 'roadtrip.window.routeHint': 'Zur Endzeit auf der Strecke pausieren.', + 'roadtrip.window.stopHint': 'Stoppen, wenn der nächste Ort zu spät erreicht würde.', + 'roadtrip.window.legTooLong': + 'Eine Fahrt ist länger als das tägliche Zeitfenster. Ergänze einen Ort unterwegs oder wähle die Pause auf der Strecke.', + 'roadtrip.window.pointLabel': 'Tagesende von Tag {day} um {time} Uhr', + 'roadtrip.window.title': 'Tägliche Reisezeiten', + 'roadtrip.window.start': 'Tagesbeginn', + 'roadtrip.window.end': 'Tagesende', + 'roadtrip.window.hint': 'Beide Zeiten aktivieren. Manuelle Zeiten gehen vor.', + 'roadtrip.window.invalid': 'Das Tagesende muss nach dem Tagesbeginn liegen.', + 'roadtrip.window.stop': 'Tagesende', + 'roadtrip.window.resume': 'Weiterfahrt', + 'roadtrip.window.pointHint': 'Hier pausiert deine Reise voraussichtlich über Nacht.', + 'roadtrip.window.departure': 'Weiterfahrt nach dem restlichen Aufenthalt um {time}', + 'roadtrip.window.showPoint': 'Auf Karte zeigen', + 'roadtrip.window.incomplete': 'Tagespausen lassen sich erst berechnen, wenn alle Verbindungsrouten vorliegen.', + 'roadtrip.window.conflict': + 'Eine feste Uhrzeit passt nicht zu Fahrzeit und Tagespausen. Manuelle Zeiten bleiben unverändert; die automatische Aufteilung pausiert.', + 'roadtrip.window.tooLong': + 'Diese Reisezeiten erfordern mehr als 365 zusätzliche Tage. Wähle ein längeres Tagesfenster.', 'roadtrip.mode.roadtrip': 'Roadtrip', 'roadtrip.mode.days': 'Tage', 'roadtrip.mode.label': 'Plan-Ansicht', @@ -41,8 +94,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.fullNote': 'Ein Stopp macht wieder voll', 'roadtrip.limit.rangeEmpty': 'Keine Reichweite', 'roadtrip.limit.rangeEmptyHint': 'Trag eine Reichweite ein oder fülle die Felder aus, dann rechnet TREK sie aus.', - 'roadtrip.limit.specHint': 'Ausgefüllt bestimmen diese Werte die Reichweite oben.', - 'roadtrip.limit.vehicleHint': 'Sag, was du fährst, dann kannst du statt der Reichweite die Fahrzeugdaten angeben.', + 'roadtrip.limit.specHint': 'Fahrzeugdaten bestimmen die Reichweite.', + 'roadtrip.limit.vehicleHint': 'Fahrzeug wählen und Reichweite berechnen.', 'roadtrip.limit.minutes': 'Min', 'roadtrip.limit.off': 'aus', 'roadtrip.limit.blockNote': 'Ein Block = {distance}', @@ -51,7 +104,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.echoDry': '{count} Abschnitte ohne erreichbaren Stopp.', 'roadtrip.limit.echoEmpty': 'Öffne einen Roadtrip, um zu sehen, was das bewirkt.', 'roadtrip.limit.specToggle': 'Aus den Fahrzeugdaten rechnen', - 'roadtrip.limit.hint': 'Zählt nur Fahrzeit. Standzeiten und Übernachtungen sind nicht enthalten.', + 'roadtrip.limit.hint': 'Nur Fahrzeit, ohne Aufenthalte und Nachtetappen.', 'roadtrip.limit.legOver': '{time} über deiner längsten Fahrt', 'roadtrip.limit.dayOver': '{time} drüber', 'roadtrip.limit.range': '{distance} seit dem letzten Tanken', @@ -59,16 +112,17 @@ const roadtrip: TranslationStrings = { 'roadtrip.line.section': 'Routenlinie', 'roadtrip.line.connect': 'Tage verbinden', 'roadtrip.line.dayColors': 'Farbe je Tag', - 'roadtrip.line.hint': 'Routet auch die Strecke zwischen zwei Tagen und zählt sie zum Ankunftstag.', + 'roadtrip.line.hint': 'Verbindungsfahrten zählen zum Ankunftstag.', 'roadtrip.avoid.section': 'Möglichst meiden', 'roadtrip.avoid.toll': 'Mautstraßen', 'roadtrip.avoid.motorway': 'Autobahnen', 'roadtrip.avoid.ferry': 'Fähren', - 'roadtrip.avoid.hint': 'Das wird nur schwächer gewichtet, nicht verboten. Wo eine Fahrt keinen Weg drumherum hat, nutzt sie es trotzdem, und der Tag sagt es.', - 'roadtrip.avoid.unavailable': 'Nicht verfügbar: diese Instanz routet über ihre eigene Maschine, die keine Straßenklasse auslassen kann.', + 'roadtrip.avoid.hint': 'Meidet nach Möglichkeit und markiert Ausnahmen.', + 'roadtrip.avoid.unavailable': 'Der Routing-Anbieter unterstützt kein Meiden.', 'roadtrip.avoid.badge': '{count} gemieden', 'roadtrip.avoid.missed': '{classes} unvermeidbar', - 'roadtrip.avoid.missedHint': 'An diesem Tag führt kein Weg daran vorbei, also fährt die Route darüber. Alle anderen Tage meiden weiterhin, was sie können.', + 'roadtrip.avoid.missedHint': + 'An diesem Tag führt kein Weg daran vorbei, also fährt die Route darüber. Alle anderen Tage meiden weiterhin, was sie können.', 'roadtrip.refuel.dry': 'Hier ist der Tank leer', 'roadtrip.refuel.after': 'nach {distance}', 'roadtrip.limit.vehicleLabel': 'Womit du fährst', @@ -110,11 +164,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} lang', 'roadtrip.track.gap': '{distance} von diesem Tag entfernt', 'roadtrip.track.onDay': 'entlang dieses Tages', - 'roadtrip.track.explain': 'Die Fahrt wird mit einigen Zwischenpunkten auf die Spur gezogen. Vorhandene Zwischenpunkte dieser Etappen werden ersetzt.', + 'roadtrip.track.explain': + 'Die Fahrt wird mit einigen Zwischenpunkten auf die Spur gezogen. Vorhandene Zwischenpunkte dieser Etappen werden ersetzt.', 'roadtrip.track.action': 'Dieser Spur folgen', 'roadtrip.track.working': 'Fahrt wird an die Spur angepasst, Runde {round}', 'roadtrip.track.done': '{count} Zwischenpunkte gesetzt. Die Fahrt bleibt bis auf {distance} an der Spur.', - 'roadtrip.track.capped': '{count} Zwischenpunkte gesetzt. An der weitesten Stelle bleibt die Fahrt {distance} von der Spur entfernt.', + 'roadtrip.track.capped': + '{count} Zwischenpunkte gesetzt. An der weitesten Stelle bleibt die Fahrt {distance} von der Spur entfernt.', 'roadtrip.track.already': 'Die Fahrt folgte dieser Spur bereits.', 'roadtrip.track.errorRoute': 'Der Routendienst hat nicht geantwortet.', 'roadtrip.track.errorSave': 'Die Zwischenpunkte konnten nicht gespeichert werden.', @@ -129,7 +185,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Unterwegs passt nichts zu „{name}“.', 'roadtrip.poi.noneMatchFilters': 'Nichts auf dem Weg passt zu diesen Filtern.', 'roadtrip.poi.foundFiltered': '{count} von {total} unterwegs', - 'roadtrip.poi.truncated': 'Auf {count} Abschnitten gab es mehr, als in eine Antwort passt — den Korridor schmaler machen, um den Rest zu sehen.', + 'roadtrip.poi.truncated': + 'Auf {count} Abschnitten gab es mehr, als in eine Antwort passt — den Korridor schmaler machen, um den Rest zu sehen.', 'roadtrip.poi.search': 'Suchen', 'roadtrip.poi.searching': 'Suche {done} von {total}', 'roadtrip.poi.capped': 'Die Route ist lang — nur der erste Abschnitt wurde durchsucht.', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 53bf6ba62..d9e10d945 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -28,7 +28,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Eigene Routing-Instanz', 'settings.routingBaseHint': 'Eine eigene OSRM-Instanz. Leer nutzt die öffentlichen Server, die etwa eine Anfrage pro Sekunde erlauben — für einen Tag genug, für einen Roadtrip knapp. Wirkt erst nach einem Serverneustart.', 'settings.valhallaBase': 'Eigene Valhalla-Instanz', - 'settings.valhallaBaseHint': 'Eine eigene Valhalla-Instanz. Sie wird gefragt, wenn eine Etappe Maut, Autobahn oder Fähre meiden soll — die öffentlichen OSRM-Server können das nicht. Leer nutzt die öffentliche FOSSGIS-Valhalla, außer oben steht schon eine eigene Routing-Instanz; dann wird gar keine zweite Maschine gefragt. Wirkt erst nach einem Serverneustart.', + 'settings.valhallaBaseHint': + 'Zum Meiden von Mautstraßen, Autobahnen und Fähren nutzt TREK standardmäßig die öffentliche FOSSGIS-Valhalla. Hier kannst du stattdessen eine eigene Valhalla-URL eintragen. Ist nur eine eigene Routing-Instanz konfiguriert, wird die öffentliche Valhalla nicht verwendet. Nach dem Eintragen einer eigenen URL den Server neu starten und die Seite neu laden.', 'settings.mapHint': 'URL-Template für die Kartenkacheln', 'settings.mapProvider': 'Kartenanbieter', 'settings.mapProviderHint': 'Gilt für Trip Planner und Journey. Atlas nutzt immer Leaflet.', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index e49283bba..0a369764f 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -1,6 +1,58 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Find accommodation', + 'roadtrip.stay.portalHint': 'Check destination, dates and guests on the portal. CHECK24 requires a manual search.', + 'roadtrip.charging.available': 'available', + 'roadtrip.charging.unknown': 'No reliable data available', + 'roadtrip.charging.stale': 'Status outdated', + 'roadtrip.charging.prices': 'Published tariffs', + 'roadtrip.charging.note': + 'Published tariff; taxes, conditions and extra fees may apply. Your charging-card price can differ.', + 'roadtrip.charging.coverage': 'Open data; coverage varies by region and operator.', + 'roadtrip.import.title': 'Import Google Maps route', + 'roadtrip.import.note': 'Stops are appended to the selected day. TREK recalculates the route.', + 'roadtrip.import.unresolved': 'Location unresolved; this stop will be skipped.', + 'roadtrip.import.day': 'Day', + 'roadtrip.hazards.show': 'Show hazard areas', + 'roadtrip.hazards.current': 'Current warnings', + 'roadtrip.hazards.note': 'Warnings are not road closures. Routes stay unchanged.', + 'roadtrip.hazards.point': 'Location only; affected area unknown.', + 'roadtrip.hazards.loading': 'Loading warnings…', + 'roadtrip.hazards.unavailable': 'Unavailable', + 'roadtrip.hazards.offline': 'Offline: live warnings unavailable', + 'roadtrip.hazards.partial': 'Limited coverage', + 'roadtrip.stops.section': 'Service stops', + 'roadtrip.stops.inDays': 'Show in Days too', + 'roadtrip.stops.daysHint': 'Off keeps all service stops in Roadtrip only.', + 'roadtrip.window.dragHint': + 'While online, drag a day ending along the route or onto a place; right-click it to restore the automatic ending.', + 'roadtrip.window.resetBoundaries': 'Restore automatic day endings', + 'roadtrip.window.endHere': 'End the day here', + 'roadtrip.window.endHereHint': + 'You can end a day at a specific stop in its place details while Roadtrip mode and daily travel times are active.', + 'roadtrip.window.endAt': 'End the day', + 'roadtrip.window.onRoute': 'Along the route', + 'roadtrip.window.atStop': 'At the last place', + 'roadtrip.window.routeHint': 'Pause on the route at the end time.', + 'roadtrip.window.stopHint': 'Stop before the next place would exceed the end time.', + 'roadtrip.window.legTooLong': + 'A drive exceeds the daily travel window. Add a place along the way or choose to pause along the route.', + 'roadtrip.window.pointLabel': 'End of day {day} at {time}', + 'roadtrip.window.title': 'Daily travel times', + 'roadtrip.window.start': 'Day start time', + 'roadtrip.window.end': 'Day end time', + 'roadtrip.window.hint': 'Set both times to enable. Manual times take priority.', + 'roadtrip.window.invalid': 'The end time must be later than the start time.', + 'roadtrip.window.stop': 'End of day', + 'roadtrip.window.resume': 'Continue journey', + 'roadtrip.window.pointHint': 'This is your estimated overnight pause point.', + 'roadtrip.window.departure': 'Continue after the remaining visit at {time}', + 'roadtrip.window.showPoint': 'Show on map', + 'roadtrip.window.incomplete': 'Daily breaks cannot be calculated until all connecting routes are available.', + 'roadtrip.window.conflict': + 'A fixed time conflicts with the drive and daily breaks. Manual times are unchanged; automatic scheduling is paused.', + 'roadtrip.window.tooLong': 'These travel times would require more than 365 extra days. Choose a longer daily window.', 'roadtrip.mode.roadtrip': 'Road trip', 'roadtrip.mode.days': 'Days', 'roadtrip.mode.label': 'Plan view', @@ -41,8 +93,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.fullNote': 'A stop fills right up', 'roadtrip.limit.rangeEmpty': 'No range set', 'roadtrip.limit.rangeEmptyHint': 'Type a range, or fill in the figures below and TREK works it out.', - 'roadtrip.limit.specHint': 'Filled in, these decide the range above.', - 'roadtrip.limit.vehicleHint': 'Name the kind of car and you can state its figures instead of a range.', + 'roadtrip.limit.specHint': 'Vehicle details determine the range.', + 'roadtrip.limit.vehicleHint': 'Choose a vehicle to calculate its range.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'off', 'roadtrip.limit.blockNote': 'One block = {distance}', @@ -51,7 +103,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.echoDry': '{count} stretches with no reachable stop.', 'roadtrip.limit.echoEmpty': 'Open a road trip to see what these do to it.', 'roadtrip.limit.specToggle': 'Work it out from the car', - 'roadtrip.limit.hint': 'Counts driving time only. Time spent at stops and overnight legs are not included.', + 'roadtrip.limit.hint': 'Driving only, excluding stays and overnight legs.', 'roadtrip.limit.legOver': '{time} over your longest drive', 'roadtrip.limit.dayOver': '{time} over', 'roadtrip.limit.range': '{distance} since the last fill-up', @@ -59,16 +111,17 @@ const roadtrip: TranslationStrings = { 'roadtrip.line.section': 'Route line', 'roadtrip.line.connect': 'Connect the days', 'roadtrip.line.dayColors': 'A colour per day', - 'roadtrip.line.hint': 'Also routes the gap between two days, and counts it on the day it arrives.', + 'roadtrip.line.hint': 'Connecting drives count toward the arrival day.', 'roadtrip.avoid.section': 'Avoid where possible', 'roadtrip.avoid.toll': 'Toll roads', 'roadtrip.avoid.motorway': 'Motorways', 'roadtrip.avoid.ferry': 'Ferries', - 'roadtrip.avoid.hint': 'These are weighted down, not banned. Where a drive has no way around one, it still uses it and the day says so.', - 'roadtrip.avoid.unavailable': 'Not available: this instance routes through its own engine, which cannot leave a road class out.', + 'roadtrip.avoid.hint': 'Avoids where possible; exceptions are flagged.', + 'roadtrip.avoid.unavailable': 'Your routing provider does not support avoidance.', 'roadtrip.avoid.badge': '{count} avoided', 'roadtrip.avoid.missed': '{classes} unavoidable', - 'roadtrip.avoid.missedHint': 'This day has no route around it, so the drive uses it. Every other day still avoids what it can.', + 'roadtrip.avoid.missedHint': + 'This day has no route around it, so the drive uses it. Every other day still avoids what it can.', 'roadtrip.refuel.dry': 'Tank runs out here', 'roadtrip.refuel.after': 'after {distance}', 'roadtrip.limit.vehicleLabel': 'What you drive', @@ -110,7 +163,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} long', 'roadtrip.track.gap': '{distance} off this day', 'roadtrip.track.onDay': 'along this day', - 'roadtrip.track.explain': 'The drive is pulled onto the track with a handful of via points. Existing via points on those legs are replaced.', + 'roadtrip.track.explain': + 'The drive is pulled onto the track with a handful of via points. Existing via points on those legs are replaced.', 'roadtrip.track.action': 'Follow this track', 'roadtrip.track.working': 'Fitting the drive to the track, round {round}', 'roadtrip.track.done': '{count} via points placed. The drive stays within {distance} of the track.', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index b83ce71c4..bd2f21608 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -36,7 +36,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Own routing engine', 'settings.routingBaseHint': 'An OSRM instance of your own. Empty uses the public servers, which allow about one request a second — enough for a day, tight for a road trip. Takes effect after a server restart.', 'settings.valhallaBase': 'Own Valhalla instance', - 'settings.valhallaBaseHint': 'A Valhalla of your own. It is asked when a leg should avoid tolls, motorways or ferries — the public OSRM servers cannot answer that. Empty uses the public FOSSGIS Valhalla, unless an own routing engine is named above, in which case no second engine is asked at all. Takes effect after a server restart.', + 'settings.valhallaBaseHint': + 'TREK uses the public FOSSGIS Valhalla by default to avoid toll roads, motorways and ferries. Enter your own Valhalla URL here to use it instead. If only a custom routing instance is configured, the public Valhalla is not used. After entering a custom URL, restart the server and reload the page.', 'settings.mapHint': 'URL template for map tiles', 'settings.mapProvider': 'Map Provider', 'settings.mapProviderHint': 'Affects Trip Planner and Journey maps. Atlas always uses Leaflet.', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 393624e18..5678884b2 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -1,6 +1,61 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Buscar alojamiento', + 'roadtrip.stay.portalHint': + 'Comprueba el destino, las fechas y los huéspedes en el portal. En CHECK24, busca manualmente.', + 'roadtrip.charging.available': 'disponibles', + 'roadtrip.charging.unknown': 'No hay datos fiables', + 'roadtrip.charging.stale': 'Estado desactualizado', + 'roadtrip.charging.prices': 'Tarifas publicadas', + 'roadtrip.charging.note': + 'Tarifa publicada; pueden aplicarse impuestos, condiciones y cargos adicionales. El precio de tu tarjeta puede variar.', + 'roadtrip.charging.coverage': 'Datos abiertos; cobertura según región y operador.', + 'roadtrip.import.title': 'Importar ruta de Google Maps', + 'roadtrip.import.note': 'Las paradas se añaden al día elegido. TREK recalcula la ruta.', + 'roadtrip.import.unresolved': 'Ubicación no encontrada; se omitirá esta parada.', + 'roadtrip.import.day': 'Día', + 'roadtrip.hazards.show': 'Mostrar zonas de peligro', + 'roadtrip.hazards.current': 'Alertas actuales', + 'roadtrip.hazards.note': 'Las alertas no son cierres de carreteras. La ruta no cambia.', + 'roadtrip.hazards.point': 'Solo ubicación; área afectada desconocida.', + 'roadtrip.hazards.loading': 'Cargando alertas…', + 'roadtrip.hazards.unavailable': 'No disponible', + 'roadtrip.hazards.offline': 'Sin conexión: alertas no disponibles', + 'roadtrip.hazards.partial': 'Cobertura limitada', + 'roadtrip.stops.section': 'Paradas de servicio', + 'roadtrip.stops.inDays': 'Mostrar también en Días', + 'roadtrip.stops.daysHint': + 'Se aplica a todas las paradas de servicio del viaje. Al desactivarlo, solo aparecen en Roadtrip. Actívalo para mostrarlas de nuevo en Días.', + 'roadtrip.window.dragHint': + 'Con conexión, arrastra el fin del día por la ruta o hasta un lugar; haz clic derecho para restaurar el final automático.', + 'roadtrip.window.resetBoundaries': 'Restaurar finales de día automáticos', + 'roadtrip.window.endHere': 'Terminar el día aquí', + 'roadtrip.window.endHereHint': + 'Puedes terminar el día en una parada concreta desde sus detalles cuando el modo Roadtrip y los horarios diarios estén activos.', + 'roadtrip.window.endAt': 'Final del día', + 'roadtrip.window.onRoute': 'En la ruta', + 'roadtrip.window.atStop': 'En el último lugar', + 'roadtrip.window.routeHint': 'Pausar en la ruta al alcanzar la hora de finalización.', + 'roadtrip.window.stopHint': 'Quedarse en el último lugar si no se puede llegar al siguiente antes del final del día.', + 'roadtrip.window.legTooLong': + 'Un trayecto supera el horario diario. Añade un lugar por el camino o elige pausar en la ruta.', + 'roadtrip.window.pointLabel': 'Final del día {day} a las {time}', + 'roadtrip.window.title': 'Horario diario', + 'roadtrip.window.start': 'Inicio del día', + 'roadtrip.window.end': 'Fin del día', + 'roadtrip.window.hint': + 'Indica ambas horas para planificar pausas diarias. Vacía una para desactivarlas. Las horas manuales tienen prioridad. Los días se conectan automáticamente.', + 'roadtrip.window.invalid': 'La hora de fin debe ser posterior a la de inicio.', + 'roadtrip.window.stop': 'Fin del día', + 'roadtrip.window.resume': 'Continuar viaje', + 'roadtrip.window.pointHint': 'Este es el punto estimado de pausa durante la noche.', + 'roadtrip.window.departure': 'Continuar tras el resto de la visita a las {time}', + 'roadtrip.window.showPoint': 'Mostrar en el mapa', + 'roadtrip.window.incomplete': 'Las pausas diarias necesitan todas las rutas de conexión para calcularse.', + 'roadtrip.window.conflict': + 'Una hora fija entra en conflicto con el trayecto y las pausas. Las horas manuales se conservan; la planificación automática está en pausa.', + 'roadtrip.window.tooLong': 'Estos horarios requieren más de 365 días adicionales. Amplía el horario diario.', 'roadtrip.mode.roadtrip': 'Viaje por carretera', 'roadtrip.mode.days': 'Días', 'roadtrip.mode.label': 'Vista del plan', @@ -64,11 +119,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Peajes', 'roadtrip.avoid.motorway': 'Autopistas', 'roadtrip.avoid.ferry': 'Ferris', - 'roadtrip.avoid.hint': 'Se penalizan, no se prohíben. Cuando un trayecto no tiene forma de evitarlo, lo usa igual y el día lo indica.', - 'roadtrip.avoid.unavailable': 'No disponible: esta instancia usa su propio motor, que no puede omitir una clase de vía.', + 'roadtrip.avoid.hint': + 'Se penalizan, no se prohíben. Cuando un trayecto no tiene forma de evitarlo, lo usa igual y el día lo indica.', + 'roadtrip.avoid.unavailable': + 'No disponible: esta instancia usa su propio motor, que no puede omitir una clase de vía.', 'roadtrip.avoid.badge': '{count} evitados', 'roadtrip.avoid.missed': '{classes} inevitable(s)', - 'roadtrip.avoid.missedHint': 'Este día no tiene ninguna ruta que lo evite, así que el trayecto lo usa. Los demás días siguen evitando lo que pueden.', + 'roadtrip.avoid.missedHint': + 'Este día no tiene ninguna ruta que lo evite, así que el trayecto lo usa. Los demás días siguen evitando lo que pueden.', 'roadtrip.refuel.dry': 'Aquí se vacía el depósito', 'roadtrip.refuel.after': 'tras {distance}', 'roadtrip.limit.vehicleLabel': 'Qué conduces', @@ -110,11 +168,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} de largo', 'roadtrip.track.gap': 'a {distance} de este día', 'roadtrip.track.onDay': 'a lo largo de este día', - 'roadtrip.track.explain': 'El trayecto se ajusta a la traza con unos pocos puntos de paso. Los puntos existentes en esos tramos se reemplazan.', + 'roadtrip.track.explain': + 'El trayecto se ajusta a la traza con unos pocos puntos de paso. Los puntos existentes en esos tramos se reemplazan.', 'roadtrip.track.action': 'Seguir esta traza', 'roadtrip.track.working': 'Ajustando el trayecto a la traza, ronda {round}', 'roadtrip.track.done': '{count} puntos de paso colocados. El trayecto se mantiene a menos de {distance} de la traza.', - 'roadtrip.track.capped': '{count} puntos de paso colocados. En su punto más ancho, el trayecto queda a {distance} de la traza.', + 'roadtrip.track.capped': + '{count} puntos de paso colocados. En su punto más ancho, el trayecto queda a {distance} de la traza.', 'roadtrip.track.already': 'El trayecto ya seguía esta traza.', 'roadtrip.track.errorRoute': 'El servicio de rutas no respondió.', 'roadtrip.track.errorSave': 'No se pudieron guardar los puntos de paso.', @@ -129,7 +189,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Nada en la ruta coincide con «{name}».', 'roadtrip.poi.noneMatchFilters': 'Nada en el camino coincide con estos filtros.', 'roadtrip.poi.foundFiltered': '{count} de {total} en la ruta', - 'roadtrip.poi.truncated': 'En {count} tramos había más de lo que cabe en una respuesta: reduce el corredor para ver el resto.', + 'roadtrip.poi.truncated': + 'En {count} tramos había más de lo que cabe en una respuesta: reduce el corredor para ver el resto.', 'roadtrip.poi.search': 'Buscar', 'roadtrip.poi.searching': 'Buscando {done} de {total}', 'roadtrip.poi.capped': 'La ruta es larga: solo se buscó en el primer tramo.', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index bd918be0f..d8ab3e55c 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Motor de rutas propio', 'settings.routingBaseHint': 'Una instancia propia de OSRM. Vacío usa los servidores públicos, que permiten cerca de una petición por segundo: suficiente para un día, justo para una ruta larga. Surte efecto tras reiniciar el servidor.', 'settings.valhallaBase': 'Instancia de Valhalla propia', - 'settings.valhallaBaseHint': 'Una instancia de Valhalla propia. Se consulta cuando un tramo debe evitar peajes, autopistas o ferris: los servidores OSRM públicos no pueden responder a eso. Vacío usa el Valhalla público de FOSSGIS, salvo que arriba se indique un motor de rutas propio, en cuyo caso no se consulta ningún segundo motor. Surte efecto tras reiniciar el servidor.', + 'settings.valhallaBaseHint': + 'TREK usa por defecto la Valhalla pública de FOSSGIS para evitar peajes, autopistas y ferris. Introduce aquí la URL de tu propia Valhalla para usarla en su lugar. Si solo se configura una instancia de rutas propia, no se utiliza la Valhalla pública. Tras introducir una URL propia, reinicia el servidor y recarga la página.', 'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa', 'settings.mapProvider': 'Proveedor de mapa', 'settings.mapProviderHint': 'Afecta a los mapas de Trip Planner y Journey. Atlas siempre usa Leaflet.', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 56e401811..a60999a0f 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -1,6 +1,62 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Trouver un hébergement', + 'roadtrip.stay.portalHint': + 'Vérifiez la destination, les dates et les voyageurs sur le portail. Recherche manuelle sur CHECK24.', + 'roadtrip.charging.available': 'disponibles', + 'roadtrip.charging.unknown': 'Aucune donnée fiable disponible', + 'roadtrip.charging.stale': 'État périmé', + 'roadtrip.charging.prices': 'Tarifs publiés', + 'roadtrip.charging.note': + 'Tarif publié ; taxes, conditions et frais supplémentaires possibles. Le prix de votre carte peut différer.', + 'roadtrip.charging.coverage': 'Données ouvertes ; couverture variable selon la région et l’opérateur.', + 'roadtrip.import.title': 'Importer un itinéraire Google Maps', + 'roadtrip.import.note': 'Les étapes sont ajoutées au jour choisi. TREK recalcule le trajet.', + 'roadtrip.import.unresolved': 'Lieu introuvable ; cette étape sera ignorée.', + 'roadtrip.import.day': 'Jour', + 'roadtrip.hazards.show': 'Afficher les zones à risque', + 'roadtrip.hazards.current': 'Alertes actuelles', + 'roadtrip.hazards.note': 'Les alertes ne sont pas des fermetures de routes. Le trajet reste inchangé.', + 'roadtrip.hazards.point': 'Lieu uniquement, zone touchée inconnue.', + 'roadtrip.hazards.loading': 'Chargement des alertes…', + 'roadtrip.hazards.unavailable': 'Indisponible', + 'roadtrip.hazards.offline': 'Hors ligne : alertes indisponibles', + 'roadtrip.hazards.partial': 'Couverture limitée', + 'roadtrip.stops.section': 'Arrêts de service', + 'roadtrip.stops.inDays': 'Afficher aussi dans Jours', + 'roadtrip.stops.daysHint': + 'Concerne tous les arrêts de service du voyage. Si désactivé, ils restent uniquement dans Roadtrip. Activez cette option pour les afficher à nouveau dans Jours.', + 'roadtrip.window.dragHint': + 'En ligne, faites glisser la fin de journée sur le trajet ou sur un lieu ; un clic droit rétablit la fin automatique.', + 'roadtrip.window.resetBoundaries': 'Rétablir les fins de journée automatiques', + 'roadtrip.window.endHere': 'Terminer la journée ici', + 'roadtrip.window.endHereHint': + 'Vous pouvez terminer une journée à une étape précise dans ses détails lorsque le mode Roadtrip et les horaires quotidiens sont actifs.', + 'roadtrip.window.endAt': 'Fin de journée', + 'roadtrip.window.onRoute': 'Sur le trajet', + 'roadtrip.window.atStop': 'Au dernier lieu', + 'roadtrip.window.routeHint': 'Faire une pause sur le trajet à l’heure de fin.', + 'roadtrip.window.stopHint': 'Rester au dernier lieu si le suivant ne peut pas être atteint avant la fin de journée.', + 'roadtrip.window.legTooLong': + 'Un trajet dépasse la plage horaire quotidienne. Ajoutez un lieu en chemin ou choisissez une pause sur le trajet.', + 'roadtrip.window.pointLabel': 'Fin du jour {day} à {time}', + 'roadtrip.window.title': 'Horaires quotidiens', + 'roadtrip.window.start': 'Début de journée', + 'roadtrip.window.end': 'Fin de journée', + 'roadtrip.window.hint': + 'Renseignez les deux heures pour prévoir les pauses. Effacez-en une pour désactiver cette option. Les heures manuelles sont prioritaires. Les journées sont reliées automatiquement.', + 'roadtrip.window.invalid': 'La fin doit être postérieure au début de journée.', + 'roadtrip.window.stop': 'Fin de journée', + 'roadtrip.window.resume': 'Reprendre le trajet', + 'roadtrip.window.pointHint': 'Votre voyage devrait faire une pause ici pour la nuit.', + 'roadtrip.window.departure': 'Repartir après le reste de la visite à {time}', + 'roadtrip.window.showPoint': 'Voir sur la carte', + 'roadtrip.window.incomplete': 'Toutes les liaisons doivent être disponibles pour calculer les pauses quotidiennes.', + 'roadtrip.window.conflict': + 'Une heure fixe est incompatible avec le trajet et les pauses. Les heures manuelles sont conservées ; la planification automatique est suspendue.', + 'roadtrip.window.tooLong': + 'Ces horaires nécessitent plus de 365 jours supplémentaires. Élargissez la plage horaire quotidienne.', 'roadtrip.mode.roadtrip': 'Road trip', 'roadtrip.mode.days': 'Jours', 'roadtrip.mode.label': 'Vue du plan', @@ -64,11 +120,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Routes à péage', 'roadtrip.avoid.motorway': 'Autoroutes', 'roadtrip.avoid.ferry': 'Ferries', - 'roadtrip.avoid.hint': 'Ce sont des pondérations, pas des interdictions. Quand un trajet n’a aucun moyen de contourner, il l’emprunte quand même et la journée le signale.', - 'roadtrip.avoid.unavailable': 'Indisponible : cette instance utilise son propre moteur, qui ne sait pas écarter une classe de route.', + 'roadtrip.avoid.hint': + 'Ce sont des pondérations, pas des interdictions. Quand un trajet n’a aucun moyen de contourner, il l’emprunte quand même et la journée le signale.', + 'roadtrip.avoid.unavailable': + 'Indisponible : cette instance utilise son propre moteur, qui ne sait pas écarter une classe de route.', 'roadtrip.avoid.badge': '{count} évité(s)', 'roadtrip.avoid.missed': '{classes} inévitable(s)', - 'roadtrip.avoid.missedHint': 'Cette journée n’a aucun itinéraire de contournement, le trajet l’emprunte donc. Les autres journées évitent toujours ce qu’elles peuvent.', + 'roadtrip.avoid.missedHint': + 'Cette journée n’a aucun itinéraire de contournement, le trajet l’emprunte donc. Les autres journées évitent toujours ce qu’elles peuvent.', 'roadtrip.refuel.dry': 'Réservoir vide ici', 'roadtrip.refuel.after': 'après {distance}', 'roadtrip.limit.vehicleLabel': 'Ce que vous conduisez', @@ -110,7 +169,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} de long', 'roadtrip.track.gap': 'à {distance} de ce jour', 'roadtrip.track.onDay': 'le long de ce jour', - 'roadtrip.track.explain': 'Le trajet est ramené sur la trace à l’aide de quelques points de passage. Les points existants sur ces étapes sont remplacés.', + 'roadtrip.track.explain': + 'Le trajet est ramené sur la trace à l’aide de quelques points de passage. Les points existants sur ces étapes sont remplacés.', 'roadtrip.track.action': 'Suivre cette trace', 'roadtrip.track.working': 'Ajustement du trajet à la trace, tour {round}', 'roadtrip.track.done': '{count} points de passage placés. Le trajet reste à moins de {distance} de la trace.', @@ -129,7 +189,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Rien sur le trajet ne correspond à « {name} ».', 'roadtrip.poi.noneMatchFilters': 'Rien sur le trajet ne correspond à ces filtres.', 'roadtrip.poi.foundFiltered': '{count} sur {total} sur le trajet', - 'roadtrip.poi.truncated': 'Sur {count} tronçons, il y en avait plus que ce qu’une réponse peut contenir : réduisez le corridor pour voir le reste.', + 'roadtrip.poi.truncated': + 'Sur {count} tronçons, il y en avait plus que ce qu’une réponse peut contenir : réduisez le corridor pour voir le reste.', 'roadtrip.poi.search': 'Rechercher', 'roadtrip.poi.searching': 'Recherche {done} sur {total}', 'roadtrip.poi.capped': 'L’itinéraire est long : seule la première portion a été parcourue.', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index f71265138..a36a95eeb 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Moteur de calcul d’itinéraire propre', 'settings.routingBaseHint': 'Votre propre instance OSRM. Vide utilise les serveurs publics, limités à environ une requête par seconde : suffisant pour une journée, juste pour un road trip. Prend effet après un redémarrage du serveur.', 'settings.valhallaBase': 'Instance Valhalla propre', - 'settings.valhallaBaseHint': 'Votre propre instance Valhalla. Elle est interrogée quand une étape doit éviter les péages, les autoroutes ou les ferries — les serveurs OSRM publics ne savent pas répondre à cela. Vide utilise le Valhalla public de FOSSGIS, sauf si un moteur de calcul d’itinéraire propre est indiqué ci-dessus, auquel cas aucun second moteur n’est interrogé. Prend effet après un redémarrage du serveur.', + 'settings.valhallaBaseHint': + 'TREK utilise par défaut la Valhalla publique de FOSSGIS pour éviter les péages, autoroutes et ferries. Saisissez ici l’URL de votre propre Valhalla pour l’utiliser à la place. Si seule une instance de routage personnalisée est configurée, la Valhalla publique n’est pas utilisée. Après avoir saisi une URL personnalisée, redémarrez le serveur et rechargez la page.', 'settings.mapHint': "Modèle d'URL pour les tuiles de carte", 'settings.mapProvider': 'Fournisseur de carte', 'settings.mapProviderHint': 'Affecte les cartes Trip Planner et Journey. Atlas utilise toujours Leaflet.', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 62a33b696..a92bf714f 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -1,6 +1,63 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Βρείτε κατάλυμα', + 'roadtrip.stay.portalHint': + 'Ελέγξτε προορισμό, ημερομηνίες και επισκέπτες στην πύλη. Στο CHECK24 αναζητήστε χειροκίνητα.', + 'roadtrip.charging.available': 'διαθέσιμα', + 'roadtrip.charging.unknown': 'Δεν υπάρχουν αξιόπιστα δεδομένα', + 'roadtrip.charging.stale': 'Παρωχημένη κατάσταση', + 'roadtrip.charging.prices': 'Δημοσιευμένες χρεώσεις', + 'roadtrip.charging.note': + 'Δημοσιευμένη χρέωση· ενδέχεται να ισχύουν φόροι, όροι και πρόσθετα τέλη. Η τιμή της κάρτας σας μπορεί να διαφέρει.', + 'roadtrip.charging.coverage': 'Ανοικτά δεδομένα· η κάλυψη εξαρτάται από την περιοχή και τον πάροχο.', + 'roadtrip.import.title': 'Εισαγωγή διαδρομής Google Maps', + 'roadtrip.import.note': 'Οι στάσεις προστίθενται στην επιλεγμένη ημέρα. Το TREK υπολογίζει ξανά τη διαδρομή.', + 'roadtrip.import.unresolved': 'Η τοποθεσία δεν βρέθηκε· αυτή η στάση θα παραλειφθεί.', + 'roadtrip.import.day': 'Ημέρα', + 'roadtrip.hazards.show': 'Εμφάνιση επικίνδυνων περιοχών', + 'roadtrip.hazards.current': 'Τρέχουσες προειδοποιήσεις', + 'roadtrip.hazards.note': 'Οι προειδοποιήσεις δεν σημαίνουν κλειστούς δρόμους. Η διαδρομή δεν αλλάζει.', + 'roadtrip.hazards.point': 'Μόνο τοποθεσία· άγνωστη πληγείσα περιοχή.', + 'roadtrip.hazards.loading': 'Φόρτωση προειδοποιήσεων…', + 'roadtrip.hazards.unavailable': 'Μη διαθέσιμο', + 'roadtrip.hazards.offline': 'Εκτός σύνδεσης: μη διαθέσιμες προειδοποιήσεις', + 'roadtrip.hazards.partial': 'Περιορισμένη κάλυψη', + 'roadtrip.stops.section': 'Στάσεις εξυπηρέτησης', + 'roadtrip.stops.inDays': 'Εμφάνιση και στις Ημέρες', + 'roadtrip.stops.daysHint': + 'Ισχύει για όλες τις στάσεις εξυπηρέτησης του ταξιδιού. Αν απενεργοποιηθεί, εμφανίζονται μόνο στο Roadtrip. Ενεργοποιήστε το για να εμφανιστούν ξανά στις Ημέρες.', + 'roadtrip.window.dragHint': + 'Με σύνδεση, σύρετε το τέλος της ημέρας στη διαδρομή ή σε ένα μέρος· με δεξί κλικ επαναφέρετε το αυτόματο τέλος.', + 'roadtrip.window.resetBoundaries': 'Επαναφορά αυτόματων ορίων ημέρας', + 'roadtrip.window.endHere': 'Τέλος ημέρας εδώ', + 'roadtrip.window.endHereHint': + 'Μπορείτε να ορίσετε μια στάση ως τέλος ημέρας στις λεπτομέρειες του μέρους, όταν είναι ενεργά το Roadtrip και τα ημερήσια ωράρια ταξιδιού.', + 'roadtrip.window.endAt': 'Τέλος ημέρας', + 'roadtrip.window.onRoute': 'Στη διαδρομή', + 'roadtrip.window.atStop': 'Στο τελευταίο μέρος', + 'roadtrip.window.routeHint': 'Παύση στη διαδρομή όταν φτάσει η ώρα λήξης.', + 'roadtrip.window.stopHint': + 'Παραμονή στο τελευταίο μέρος αν το επόμενο δεν είναι προσβάσιμο πριν το τέλος της ημέρας.', + 'roadtrip.window.legTooLong': + 'Μια διαδρομή υπερβαίνει το ημερήσιο ωράριο. Προσθέστε ένα μέρος καθ’ οδόν ή επιλέξτε παύση στη διαδρομή.', + 'roadtrip.window.pointLabel': 'Τέλος ημέρας {day} στις {time}', + 'roadtrip.window.title': 'Καθημερινές ώρες ταξιδιού', + 'roadtrip.window.start': 'Έναρξη ημέρας', + 'roadtrip.window.end': 'Λήξη ημέρας', + 'roadtrip.window.hint': + 'Ορίστε και τις δύο ώρες για καθημερινές παύσεις. Αφήστε μία κενή για απενεργοποίηση. Οι χειροκίνητες ώρες έχουν προτεραιότητα. Οι ημέρες συνδέονται αυτόματα.', + 'roadtrip.window.invalid': 'Η ώρα λήξης πρέπει να είναι μετά την ώρα έναρξης.', + 'roadtrip.window.stop': 'Τέλος ημέρας', + 'roadtrip.window.resume': 'Συνέχεια ταξιδιού', + 'roadtrip.window.pointHint': 'Αυτό είναι το εκτιμώμενο σημείο της νυχτερινής σας στάσης.', + 'roadtrip.window.departure': 'Συνέχεια μετά την υπόλοιπη επίσκεψη στις {time}', + 'roadtrip.window.showPoint': 'Προβολή στον χάρτη', + 'roadtrip.window.incomplete': 'Απαιτούνται όλες οι συνδετικές διαδρομές για τον υπολογισμό των καθημερινών παύσεων.', + 'roadtrip.window.conflict': + 'Μια σταθερή ώρα συγκρούεται με την οδήγηση και τις παύσεις. Οι χειροκίνητες ώρες διατηρούνται· ο αυτόματος σχεδιασμός έχει παύσει.', + 'roadtrip.window.tooLong': + 'Αυτές οι ώρες απαιτούν πάνω από 365 επιπλέον ημέρες. Επιλέξτε μεγαλύτερο ημερήσιο διάστημα.', 'roadtrip.mode.roadtrip': 'Οδικό ταξίδι', 'roadtrip.mode.days': 'Ημέρες', 'roadtrip.mode.label': 'Προβολή σχεδίου', @@ -64,11 +121,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Δρόμοι με διόδια', 'roadtrip.avoid.motorway': 'Αυτοκινητόδρομοι', 'roadtrip.avoid.ferry': 'Φέρι μποτ', - 'roadtrip.avoid.hint': 'Πρόκειται για στάθμιση, όχι απαγόρευση. Όταν μια διαδρομή δεν έχει τρόπο να τα αποφύγει, τα χρησιμοποιεί και η ημέρα το δηλώνει.', - 'roadtrip.avoid.unavailable': 'Μη διαθέσιμο: αυτή η εγκατάσταση χρησιμοποιεί δική της μηχανή, που δεν μπορεί να παραλείψει κατηγορία δρόμου.', + 'roadtrip.avoid.hint': + 'Πρόκειται για στάθμιση, όχι απαγόρευση. Όταν μια διαδρομή δεν έχει τρόπο να τα αποφύγει, τα χρησιμοποιεί και η ημέρα το δηλώνει.', + 'roadtrip.avoid.unavailable': + 'Μη διαθέσιμο: αυτή η εγκατάσταση χρησιμοποιεί δική της μηχανή, που δεν μπορεί να παραλείψει κατηγορία δρόμου.', 'roadtrip.avoid.badge': '{count} αποφεύγονται', 'roadtrip.avoid.missed': '{classes} αναπόφευκτα', - 'roadtrip.avoid.missedHint': 'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.', + 'roadtrip.avoid.missedHint': + 'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.', 'roadtrip.refuel.dry': 'Εδώ αδειάζει το ρεζερβουάρ', 'roadtrip.refuel.after': 'μετά από {distance}', 'roadtrip.limit.vehicleLabel': 'Τι οδηγείς', @@ -110,11 +170,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': 'μήκος {distance}', 'roadtrip.track.gap': '{distance} από αυτή την ημέρα', 'roadtrip.track.onDay': 'κατά μήκος αυτής της ημέρας', - 'roadtrip.track.explain': 'Η διαδρομή τραβιέται πάνω στο ίχνος με μερικά ενδιάμεσα σημεία. Τα υπάρχοντα σημεία σε αυτά τα σκέλη αντικαθίστανται.', + 'roadtrip.track.explain': + 'Η διαδρομή τραβιέται πάνω στο ίχνος με μερικά ενδιάμεσα σημεία. Τα υπάρχοντα σημεία σε αυτά τα σκέλη αντικαθίστανται.', 'roadtrip.track.action': 'Ακολούθησε αυτό το ίχνος', 'roadtrip.track.working': 'Προσαρμογή διαδρομής στο ίχνος, γύρος {round}', 'roadtrip.track.done': 'Τοποθετήθηκαν {count} ενδιάμεσα σημεία. Η διαδρομή μένει εντός {distance} από το ίχνος.', - 'roadtrip.track.capped': 'Τοποθετήθηκαν {count} ενδιάμεσα σημεία. Στο πιο πλατύ σημείο η διαδρομή απέχει {distance} από το ίχνος.', + 'roadtrip.track.capped': + 'Τοποθετήθηκαν {count} ενδιάμεσα σημεία. Στο πιο πλατύ σημείο η διαδρομή απέχει {distance} από το ίχνος.', 'roadtrip.track.already': 'Η διαδρομή ακολουθούσε ήδη αυτό το ίχνος.', 'roadtrip.track.errorRoute': 'Η υπηρεσία δρομολόγησης δεν απάντησε.', 'roadtrip.track.errorSave': 'Δεν ήταν δυνατή η αποθήκευση των ενδιάμεσων σημείων.', @@ -129,7 +191,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Τίποτα στη διαδρομή δεν ταιριάζει με «{name}».', 'roadtrip.poi.noneMatchFilters': 'Τίποτα στη διαδρομή δεν ταιριάζει με αυτά τα φίλτρα.', 'roadtrip.poi.foundFiltered': '{count} από {total} στη διαδρομή', - 'roadtrip.poi.truncated': 'Σε {count} τμήματα υπήρχαν περισσότερα από όσα χωρούν σε μία απάντηση — στενέψτε τον διάδρομο για να δείτε τα υπόλοιπα.', + 'roadtrip.poi.truncated': + 'Σε {count} τμήματα υπήρχαν περισσότερα από όσα χωρούν σε μία απάντηση — στενέψτε τον διάδρομο για να δείτε τα υπόλοιπα.', 'roadtrip.poi.search': 'Αναζήτηση', 'roadtrip.poi.searching': 'Αναζήτηση {done} από {total}', 'roadtrip.poi.capped': 'Η διαδρομή είναι μεγάλη — αναζητήθηκε μόνο το πρώτο τμήμα.', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index 56b156ea7..61a125201 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -28,7 +28,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Δική σας μηχανή δρομολόγησης', 'settings.routingBaseHint': 'Δική σας εγκατάσταση OSRM. Κενό σημαίνει τους δημόσιους διακομιστές, που επιτρέπουν περίπου ένα αίτημα ανά δευτερόλεπτο: αρκεί για μία ημέρα, λίγο για ένα οδικό ταξίδι. Ισχύει μετά από επανεκκίνηση του διακομιστή.', 'settings.valhallaBase': 'Δική σας υπηρεσία Valhalla', - 'settings.valhallaBaseHint': 'Δική σας υπηρεσία Valhalla. Ρωτιέται όταν ένα σκέλος πρέπει να αποφύγει διόδια, αυτοκινητόδρομους ή φέρι — οι δημόσιοι διακομιστές OSRM δεν μπορούν να απαντήσουν σε αυτό. Κενό χρησιμοποιεί το δημόσιο Valhalla του FOSSGIS, εκτός αν παραπάνω δηλώνεται δική σας μηχανή δρομολόγησης, οπότε δεν ρωτιέται καμία δεύτερη μηχανή. Ισχύει μετά από επανεκκίνηση του διακομιστή.', + 'settings.valhallaBaseHint': + 'Το TREK χρησιμοποιεί από προεπιλογή τη δημόσια Valhalla της FOSSGIS για την αποφυγή διοδίων, αυτοκινητοδρόμων και πορθμείων. Εισαγάγετε εδώ τη διεύθυνση URL της δικής σας Valhalla για να τη χρησιμοποιήσετε αντί της δημόσιας. Αν έχει ρυθμιστεί μόνο ιδιωτική υπηρεσία δρομολόγησης, η δημόσια Valhalla δεν χρησιμοποιείται. Αφού εισαγάγετε ιδιωτική διεύθυνση URL, επανεκκινήστε τον διακομιστή και φορτώστε ξανά τη σελίδα.', 'settings.mapHint': 'Πρότυπο URL για πλακίδια χάρτη', 'settings.mapProvider': 'Πάροχος Χάρτη', 'settings.mapProviderHint': diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index c99c320de..25f768069 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -1,6 +1,61 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Szállás keresése', + 'roadtrip.stay.portalHint': + 'Ellenőrizd az úti célt, dátumokat és vendégeket a portálon. A CHECK24 oldalon keress kézzel.', + 'roadtrip.charging.available': 'szabad', + 'roadtrip.charging.unknown': 'Nincs megbízható adat', + 'roadtrip.charging.stale': 'Elavult állapot', + 'roadtrip.charging.prices': 'Közzétett díjak', + 'roadtrip.charging.note': 'Közzétett díj; adók, feltételek és felárak lehetnek. A töltőkártya ára eltérhet.', + 'roadtrip.charging.coverage': 'Nyílt adatok; a lefedettség régiónként és szolgáltatónként változik.', + 'roadtrip.import.title': 'Google Maps-útvonal importálása', + 'roadtrip.import.note': 'A megállók a kiválasztott naphoz kerülnek. A TREK újratervezi az útvonalat.', + 'roadtrip.import.unresolved': 'A hely nem található; ez a megálló kimarad.', + 'roadtrip.import.day': 'Nap', + 'roadtrip.hazards.show': 'Veszélyes területek megjelenítése', + 'roadtrip.hazards.current': 'Aktuális figyelmeztetések', + 'roadtrip.hazards.note': 'A figyelmeztetések nem útlezárások. Az útvonal változatlan marad.', + 'roadtrip.hazards.point': 'Csak helyszín; az érintett terület ismeretlen.', + 'roadtrip.hazards.loading': 'Figyelmeztetések betöltése…', + 'roadtrip.hazards.unavailable': 'Nem érhető el', + 'roadtrip.hazards.offline': 'Offline: aktuális figyelmeztetések nem érhetők el', + 'roadtrip.hazards.partial': 'Korlátozott lefedettség', + 'roadtrip.stops.section': 'Szervizmegállók', + 'roadtrip.stops.inDays': 'Megjelenítés a Napok nézetben is', + 'roadtrip.stops.daysHint': + 'Az utazás minden szervizmegállójára érvényes. Kikapcsolva csak a Roadtrip tervben maradnak. Bekapcsolva ismét megjelennek a Napok nézetben.', + 'roadtrip.window.dragHint': + 'Online húzza a nap végét az útvonalon vagy egy helyre; jobb kattintással visszaállíthatja az automatikus befejezést.', + 'roadtrip.window.resetBoundaries': 'Automatikus napvégek visszaállítása', + 'roadtrip.window.endHere': 'Nap befejezése itt', + 'roadtrip.window.endHereHint': + 'A hely részleteinél jelölhetsz ki megállót a nap végének, ha a Roadtrip mód és a napi utazási idők aktívak.', + 'roadtrip.window.endAt': 'Nap lezárása', + 'roadtrip.window.onRoute': 'Az útvonalon', + 'roadtrip.window.atStop': 'Az utolsó helyen', + 'roadtrip.window.routeHint': 'Megállás az útvonalon a befejezési idő elérésekor.', + 'roadtrip.window.stopHint': 'Maradjon az utolsó helyen, ha a következő nem érhető el a nap vége előtt.', + 'roadtrip.window.legTooLong': + 'Egy útszakasz hosszabb a napi időkeretnél. Adjon hozzá egy helyet útközben, vagy válasszon megállást az útvonalon.', + 'roadtrip.window.pointLabel': 'A(z) {day}. nap vége {time}-kor', + 'roadtrip.window.title': 'Napi utazási időszak', + 'roadtrip.window.start': 'Nap kezdete', + 'roadtrip.window.end': 'Nap vége', + 'roadtrip.window.hint': + 'A napi szünetekhez add meg mindkét időpontot. Egy mező törlése kikapcsolja az automatikát. A kézi időpontok elsőbbséget élveznek. A napok automatikusan összekapcsolódnak.', + 'roadtrip.window.invalid': 'A befejezésnek a kezdés után kell lennie.', + 'roadtrip.window.stop': 'Nap vége', + 'roadtrip.window.resume': 'Utazás folytatása', + 'roadtrip.window.pointHint': 'Ez az éjszakai pihenő becsült helye.', + 'roadtrip.window.departure': 'Továbbindulás a látogatás hátralévő része után: {time}', + 'roadtrip.window.showPoint': 'Megjelenítés a térképen', + 'roadtrip.window.incomplete': 'A napi szünetek kiszámításához minden összekötő útvonal szükséges.', + 'roadtrip.window.conflict': + 'Egy rögzített időpont ütközik az utazással és a szünetekkel. A kézi időpontok megmaradnak; az automatikus tervezés szünetel.', + 'roadtrip.window.tooLong': + 'Ezek az időpontok több mint 365 további napot igényelnének. Válassz hosszabb napi időszakot.', 'roadtrip.mode.roadtrip': 'Autós út', 'roadtrip.mode.days': 'Napok', 'roadtrip.mode.label': 'Terv nézet', @@ -68,7 +123,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.unavailable': 'Nem érhető el: ez a példány saját motort használ, amely nem tud kihagyni útosztályt.', 'roadtrip.avoid.badge': '{count} kerülve', 'roadtrip.avoid.missed': '{classes} nem kerülhető', - 'roadtrip.avoid.missedHint': 'Ezen a napon nincs kerülőút, így az útvonal ezt használja. A többi nap továbbra is kerüli, amit tud.', + 'roadtrip.avoid.missedHint': + 'Ezen a napon nincs kerülőút, így az útvonal ezt használja. A többi nap továbbra is kerüli, amit tud.', 'roadtrip.refuel.dry': 'Itt fogy ki a tank', 'roadtrip.refuel.after': '{distance} után', 'roadtrip.limit.vehicleLabel': 'Mivel utazol', @@ -110,11 +166,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} hosszú', 'roadtrip.track.gap': '{distance} távolságra ettől a naptól', 'roadtrip.track.onDay': 'e nap mentén', - 'roadtrip.track.explain': 'Az útvonalat néhány köztes pont húzza rá a nyomvonalra. Az érintett szakaszok meglévő köztes pontjai lecserélődnek.', + 'roadtrip.track.explain': + 'Az útvonalat néhány köztes pont húzza rá a nyomvonalra. Az érintett szakaszok meglévő köztes pontjai lecserélődnek.', 'roadtrip.track.action': 'Kövesse ezt a nyomvonalat', 'roadtrip.track.working': 'Az útvonal igazítása a nyomvonalhoz, {round}. kör', 'roadtrip.track.done': '{count} köztes pont elhelyezve. Az útvonal {distance} távolságon belül marad a nyomvonaltól.', - 'roadtrip.track.capped': '{count} köztes pont elhelyezve. A legtávolabbi ponton az útvonal {distance} távolságra van a nyomvonaltól.', + 'roadtrip.track.capped': + '{count} köztes pont elhelyezve. A legtávolabbi ponton az útvonal {distance} távolságra van a nyomvonaltól.', 'roadtrip.track.already': 'Az útvonal már ezt a nyomvonalat követte.', 'roadtrip.track.errorRoute': 'Az útvonaltervező szolgáltatás nem válaszolt.', 'roadtrip.track.errorSave': 'A köztes pontokat nem sikerült menteni.', @@ -129,7 +187,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Az útvonalon semmi sem egyezik ezzel: „{name}”.', 'roadtrip.poi.noneMatchFilters': 'Az útvonalon semmi sem felel meg ezeknek a szűrőknek.', 'roadtrip.poi.foundFiltered': '{count} / {total} az útvonalon', - 'roadtrip.poi.truncated': '{count} szakaszon több volt, mint amennyi egy válaszba fér — szűkítse a folyosót a többi megtekintéséhez.', + 'roadtrip.poi.truncated': + '{count} szakaszon több volt, mint amennyi egy válaszba fér — szűkítse a folyosót a többi megtekintéséhez.', 'roadtrip.poi.search': 'Keresés', 'roadtrip.poi.searching': 'Keresés {done}/{total}', 'roadtrip.poi.capped': 'Az útvonal hosszú – csak az első szakaszon kerestünk.', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 70b7937b0..16054699d 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Saját útvonaltervező', 'settings.routingBaseHint': 'Saját OSRM-példány. Üresen hagyva a nyilvános kiszolgálókat használja, amelyek másodpercenként körülbelül egy kérést engednek — egy napra elég, egy autós útra kevés. A kiszolgáló újraindítása után lép életbe.', 'settings.valhallaBase': 'Saját Valhalla-példány', - 'settings.valhallaBaseHint': 'Saját Valhalla-példány. Akkor kérdezzük meg, ha egy szakasznak kerülnie kell a fizetős utakat, az autópályákat vagy a kompokat — a nyilvános OSRM-kiszolgálók erre nem tudnak válaszolni. Üresen a FOSSGIS nyilvános Valhalláját használja, kivéve ha fent saját útvonaltervező szerepel; akkor egyáltalán nem kérdezünk második motort. A szerver újraindítása után lép életbe.', + 'settings.valhallaBaseHint': + 'A TREK alapértelmezés szerint a FOSSGIS nyilvános Valhalláját használja a fizetős utak, autópályák és kompok elkerüléséhez. Itt megadhatja saját Valhallája URL-jét, hogy azt használja helyette. Ha csak saját útvonaltervező van beállítva, a nyilvános Valhalla nem használatos. Saját URL megadása után indítsa újra a szervert, és töltse újra az oldalt.', 'settings.mapHint': 'URL sablon a térképcsempékhez', 'settings.mapProvider': 'Térkép szolgáltató', 'settings.mapProviderHint': 'A Trip Planner és Journey térképekre érvényes. Az Atlas mindig Leafletet használ.', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 9e0bd6d85..e44a1f254 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -1,6 +1,61 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Cari penginapan', + 'roadtrip.stay.portalHint': 'Periksa tujuan, tanggal, dan tamu di portal. Cari secara manual di CHECK24.', + 'roadtrip.charging.available': 'tersedia', + 'roadtrip.charging.unknown': 'Tidak ada data terpercaya', + 'roadtrip.charging.stale': 'Status kedaluwarsa', + 'roadtrip.charging.prices': 'Tarif yang dipublikasikan', + 'roadtrip.charging.note': + 'Tarif publik; pajak, syarat, dan biaya tambahan mungkin berlaku. Harga kartu pengisian Anda bisa berbeda.', + 'roadtrip.charging.coverage': 'Data terbuka; cakupan bergantung wilayah dan operator.', + 'roadtrip.import.title': 'Impor rute Google Maps', + 'roadtrip.import.note': 'Perhentian ditambahkan ke hari yang dipilih. TREK menghitung ulang rute.', + 'roadtrip.import.unresolved': 'Lokasi tidak ditemukan; perhentian ini akan dilewati.', + 'roadtrip.import.day': 'Hari', + 'roadtrip.hazards.show': 'Tampilkan area bahaya', + 'roadtrip.hazards.current': 'Peringatan terkini', + 'roadtrip.hazards.note': 'Peringatan bukan penutupan jalan. Rute tidak berubah.', + 'roadtrip.hazards.point': 'Hanya lokasi; area terdampak tidak diketahui.', + 'roadtrip.hazards.loading': 'Memuat peringatan…', + 'roadtrip.hazards.unavailable': 'Tidak tersedia', + 'roadtrip.hazards.offline': 'Luring: peringatan terkini tidak tersedia', + 'roadtrip.hazards.partial': 'Cakupan terbatas', + 'roadtrip.stops.section': 'Perhentian layanan', + 'roadtrip.stops.inDays': 'Tampilkan juga di Hari', + 'roadtrip.stops.daysHint': + 'Berlaku untuk semua perhentian layanan perjalanan ini. Jika dinonaktifkan, perhentian hanya muncul di Roadtrip. Aktifkan untuk menampilkannya kembali di Hari.', + 'roadtrip.window.dragHint': + 'Saat online, seret akhir hari di sepanjang rute atau ke suatu tempat; klik kanan untuk memulihkan akhir otomatis.', + 'roadtrip.window.resetBoundaries': 'Pulihkan akhir hari otomatis', + 'roadtrip.window.endHere': 'Akhiri hari di sini', + 'roadtrip.window.endHereHint': + 'Anda dapat mengakhiri hari di pemberhentian tertentu melalui detail tempat saat mode Roadtrip dan waktu perjalanan harian aktif.', + 'roadtrip.window.endAt': 'Akhir hari', + 'roadtrip.window.onRoute': 'Di sepanjang rute', + 'roadtrip.window.atStop': 'Di tempat terakhir', + 'roadtrip.window.routeHint': 'Berhenti di sepanjang rute saat waktu akhir tercapai.', + 'roadtrip.window.stopHint': 'Tetap di tempat terakhir jika tempat berikutnya tidak dapat dicapai sebelum akhir hari.', + 'roadtrip.window.legTooLong': + 'Satu perjalanan melebihi rentang waktu harian. Tambahkan tempat di sepanjang jalan atau pilih berhenti di rute.', + 'roadtrip.window.pointLabel': 'Akhir hari {day} pukul {time}', + 'roadtrip.window.title': 'Waktu perjalanan harian', + 'roadtrip.window.start': 'Awal hari', + 'roadtrip.window.end': 'Akhir hari', + 'roadtrip.window.hint': + 'Atur kedua waktu untuk merencanakan jeda harian. Kosongkan salah satunya untuk menonaktifkan. Waktu manual diprioritaskan. Hari dihubungkan otomatis.', + 'roadtrip.window.invalid': 'Waktu akhir harus setelah waktu mulai.', + 'roadtrip.window.stop': 'Akhir hari', + 'roadtrip.window.resume': 'Lanjutkan perjalanan', + 'roadtrip.window.pointHint': 'Ini perkiraan titik jeda perjalanan Anda untuk bermalam.', + 'roadtrip.window.departure': 'Lanjutkan setelah sisa kunjungan pada {time}', + 'roadtrip.window.showPoint': 'Tampilkan di peta', + 'roadtrip.window.incomplete': 'Semua rute penghubung diperlukan untuk menghitung jeda harian.', + 'roadtrip.window.conflict': + 'Waktu tetap bertentangan dengan perjalanan dan jeda. Waktu manual tidak berubah; perencanaan otomatis dijeda.', + 'roadtrip.window.tooLong': + 'Waktu ini memerlukan lebih dari 365 hari tambahan. Pilih rentang waktu harian yang lebih panjang.', 'roadtrip.mode.roadtrip': 'Perjalanan darat', 'roadtrip.mode.days': 'Hari', 'roadtrip.mode.label': 'Tampilan rencana', @@ -64,11 +119,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Jalan tol', 'roadtrip.avoid.motorway': 'Jalan bebas hambatan', 'roadtrip.avoid.ferry': 'Feri', - 'roadtrip.avoid.hint': 'Ini pembobotan, bukan larangan. Kalau satu perjalanan tidak punya jalan memutar, ia tetap melewatinya dan hari itu memberi tahu.', - 'roadtrip.avoid.unavailable': 'Tidak tersedia: instans ini memakai mesinnya sendiri, yang tidak bisa mengecualikan kelas jalan.', + 'roadtrip.avoid.hint': + 'Ini pembobotan, bukan larangan. Kalau satu perjalanan tidak punya jalan memutar, ia tetap melewatinya dan hari itu memberi tahu.', + 'roadtrip.avoid.unavailable': + 'Tidak tersedia: instans ini memakai mesinnya sendiri, yang tidak bisa mengecualikan kelas jalan.', 'roadtrip.avoid.badge': '{count} dihindari', 'roadtrip.avoid.missed': '{classes} tak terhindarkan', - 'roadtrip.avoid.missedHint': 'Hari ini tidak ada rute memutarnya, jadi perjalanan melewatinya. Hari lain tetap menghindari yang bisa dihindari.', + 'roadtrip.avoid.missedHint': + 'Hari ini tidak ada rute memutarnya, jadi perjalanan melewatinya. Hari lain tetap menghindari yang bisa dihindari.', 'roadtrip.refuel.dry': 'Tangki habis di sini', 'roadtrip.refuel.after': 'setelah {distance}', 'roadtrip.limit.vehicleLabel': 'Kamu mengemudi apa', @@ -110,11 +168,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': 'panjang {distance}', 'roadtrip.track.gap': '{distance} dari hari ini', 'roadtrip.track.onDay': 'sepanjang hari ini', - 'roadtrip.track.explain': 'Perjalanan ditarik ke jalur dengan beberapa titik lintas. Titik lintas yang ada di etape tersebut akan diganti.', + 'roadtrip.track.explain': + 'Perjalanan ditarik ke jalur dengan beberapa titik lintas. Titik lintas yang ada di etape tersebut akan diganti.', 'roadtrip.track.action': 'Ikuti jalur ini', 'roadtrip.track.working': 'Menyesuaikan perjalanan dengan jalur, putaran {round}', 'roadtrip.track.done': '{count} titik lintas ditempatkan. Perjalanan tetap dalam {distance} dari jalur.', - 'roadtrip.track.capped': '{count} titik lintas ditempatkan. Di titik terlebar, perjalanan berjarak {distance} dari jalur.', + 'roadtrip.track.capped': + '{count} titik lintas ditempatkan. Di titik terlebar, perjalanan berjarak {distance} dari jalur.', 'roadtrip.track.already': 'Perjalanan sudah mengikuti jalur ini.', 'roadtrip.track.errorRoute': 'Layanan rute tidak menjawab.', 'roadtrip.track.errorSave': 'Titik lintas tidak dapat disimpan.', @@ -129,7 +189,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Tidak ada di rute yang cocok dengan “{name}”.', 'roadtrip.poi.noneMatchFilters': 'Tidak ada di sepanjang rute yang cocok dengan filter ini.', 'roadtrip.poi.foundFiltered': '{count} dari {total} di rute', - 'roadtrip.poi.truncated': 'Di {count} ruas ada lebih banyak daripada yang muat dalam satu jawaban — persempit koridor untuk melihat sisanya.', + 'roadtrip.poi.truncated': + 'Di {count} ruas ada lebih banyak daripada yang muat dalam satu jawaban — persempit koridor untuk melihat sisanya.', 'roadtrip.poi.search': 'Cari', 'roadtrip.poi.searching': 'Mencari {done} dari {total}', 'roadtrip.poi.capped': 'Rute panjang — hanya bagian pertama yang dicari.', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 5d1914075..d955567d9 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Mesin rute sendiri', 'settings.routingBaseHint': 'Instans OSRM milik sendiri. Kosong berarti server publik, yang mengizinkan sekitar satu permintaan per detik — cukup untuk sehari, mepet untuk perjalanan darat. Berlaku setelah server dimulai ulang.', 'settings.valhallaBase': 'Instans Valhalla sendiri', - 'settings.valhallaBaseHint': 'Instans Valhalla sendiri. Ia ditanya ketika satu ruas harus menghindari tol, jalan bebas hambatan, atau feri — server OSRM publik tidak bisa menjawabnya. Kosong memakai Valhalla publik dari FOSSGIS, kecuali di atas sudah diisi mesin rute sendiri; maka tidak ada mesin kedua yang ditanya. Berlaku setelah server dijalankan ulang.', + 'settings.valhallaBaseHint': + 'Secara bawaan, TREK menggunakan Valhalla publik FOSSGIS untuk menghindari tol, jalan bebas hambatan, dan feri. Masukkan URL Valhalla Anda sendiri di sini untuk menggunakannya sebagai pengganti. Jika hanya instans perutean sendiri yang dikonfigurasi, Valhalla publik tidak digunakan. Setelah memasukkan URL sendiri, mulai ulang server dan muat ulang halaman.', 'settings.mapHint': 'Template URL untuk tile peta', 'settings.mapProvider': 'Penyedia peta', 'settings.mapProviderHint': 'Berlaku untuk peta Trip Planner dan Journey. Atlas selalu menggunakan Leaflet.', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 65c37104f..1b8f41cdc 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -1,6 +1,61 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Trova un alloggio', + 'roadtrip.stay.portalHint': 'Verifica destinazione, date e ospiti sul portale. Su CHECK24 cerca manualmente.', + 'roadtrip.charging.available': 'disponibili', + 'roadtrip.charging.unknown': 'Nessun dato affidabile', + 'roadtrip.charging.stale': 'Stato non aggiornato', + 'roadtrip.charging.prices': 'Tariffe pubblicate', + 'roadtrip.charging.note': + 'Tariffa pubblicata; verificare tasse, condizioni e supplementi. Il prezzo della tua tessera può variare.', + 'roadtrip.charging.coverage': 'Dati aperti; copertura variabile per regione e gestore.', + 'roadtrip.import.title': 'Importa percorso Google Maps', + 'roadtrip.import.note': 'Le tappe vengono aggiunte al giorno scelto. TREK ricalcola il percorso.', + 'roadtrip.import.unresolved': 'Posizione non trovata; questa tappa verrà saltata.', + 'roadtrip.import.day': 'Giorno', + 'roadtrip.hazards.show': 'Mostra zone di pericolo', + 'roadtrip.hazards.current': 'Avvisi attuali', + 'roadtrip.hazards.note': 'Gli avvisi non indicano strade chiuse. Il percorso non cambia.', + 'roadtrip.hazards.point': 'Solo posizione; area interessata sconosciuta.', + 'roadtrip.hazards.loading': 'Caricamento avvisi…', + 'roadtrip.hazards.unavailable': 'Non disponibile', + 'roadtrip.hazards.offline': 'Offline: avvisi non disponibili', + 'roadtrip.hazards.partial': 'Copertura limitata', + 'roadtrip.stops.section': 'Soste di servizio', + 'roadtrip.stops.inDays': 'Mostra anche in Giorni', + 'roadtrip.stops.daysHint': + 'Si applica a tutte le soste di servizio del viaggio. Se disattivato, restano solo in Roadtrip. Attivalo per mostrarle di nuovo in Giorni.', + 'roadtrip.window.dragHint': + 'Online, trascina la fine della giornata lungo il percorso o su un luogo; fai clic destro per ripristinare la fine automatica.', + 'roadtrip.window.resetBoundaries': 'Ripristina le fini giornata automatiche', + 'roadtrip.window.endHere': 'Termina la giornata qui', + 'roadtrip.window.endHereHint': + 'Puoi terminare la giornata in una tappa specifica dai suoi dettagli quando la modalità Roadtrip e gli orari giornalieri sono attivi.', + 'roadtrip.window.endAt': 'Fine giornata', + 'roadtrip.window.onRoute': 'Lungo il percorso', + 'roadtrip.window.atStop': 'Nell’ultimo luogo', + 'roadtrip.window.routeHint': 'Fermarsi lungo il percorso all’ora di fine giornata.', + 'roadtrip.window.stopHint': 'Restare nell’ultimo luogo se non si può raggiungere il successivo entro fine giornata.', + 'roadtrip.window.legTooLong': + 'Un tragitto supera la fascia oraria giornaliera. Aggiungi un luogo lungo il percorso o scegli una pausa lungo il tragitto.', + 'roadtrip.window.pointLabel': 'Fine del giorno {day} alle {time}', + 'roadtrip.window.title': 'Orari giornalieri', + 'roadtrip.window.start': 'Inizio giornata', + 'roadtrip.window.end': 'Fine giornata', + 'roadtrip.window.hint': + 'Imposta entrambi gli orari per pianificare le pause. Svuota un campo per disattivarle. Gli orari manuali hanno priorità. I giorni vengono collegati automaticamente.', + 'roadtrip.window.invalid': "L'orario finale deve essere successivo a quello iniziale.", + 'roadtrip.window.stop': 'Fine giornata', + 'roadtrip.window.resume': 'Riprendi il viaggio', + 'roadtrip.window.pointHint': 'Questo è il punto previsto per la pausa notturna.', + 'roadtrip.window.departure': 'Ripartire dopo il resto della visita alle {time}', + 'roadtrip.window.showPoint': 'Mostra sulla mappa', + 'roadtrip.window.incomplete': 'Per calcolare le pause giornaliere servono tutti i percorsi di collegamento.', + 'roadtrip.window.conflict': + 'Un orario fisso è incompatibile con il tragitto e le pause. Gli orari manuali restano invariati; la pianificazione automatica è sospesa.', + 'roadtrip.window.tooLong': + 'Questi orari richiederebbero oltre 365 giorni aggiuntivi. Scegli un intervallo giornaliero più lungo.', 'roadtrip.mode.roadtrip': 'Viaggio su strada', 'roadtrip.mode.days': 'Giorni', 'roadtrip.mode.label': 'Vista del piano', @@ -64,11 +119,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Strade a pedaggio', 'roadtrip.avoid.motorway': 'Autostrade', 'roadtrip.avoid.ferry': 'Traghetti', - 'roadtrip.avoid.hint': 'Vengono penalizzate, non vietate. Quando un percorso non ha alternative, le usa comunque e la giornata lo segnala.', - 'roadtrip.avoid.unavailable': 'Non disponibile: questa istanza usa il proprio motore, che non sa escludere una classe di strada.', + 'roadtrip.avoid.hint': + 'Vengono penalizzate, non vietate. Quando un percorso non ha alternative, le usa comunque e la giornata lo segnala.', + 'roadtrip.avoid.unavailable': + 'Non disponibile: questa istanza usa il proprio motore, che non sa escludere una classe di strada.', 'roadtrip.avoid.badge': '{count} evitati', 'roadtrip.avoid.missed': '{classes} inevitabili', - 'roadtrip.avoid.missedHint': 'Questa giornata non ha percorsi alternativi, quindi il viaggio li usa. Le altre giornate continuano a evitare quel che possono.', + 'roadtrip.avoid.missedHint': + 'Questa giornata non ha percorsi alternativi, quindi il viaggio li usa. Le altre giornate continuano a evitare quel che possono.', 'roadtrip.refuel.dry': 'Qui il serbatoio si esaurisce', 'roadtrip.refuel.after': 'dopo {distance}', 'roadtrip.limit.vehicleLabel': 'Cosa guidi', @@ -110,11 +168,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} di lunghezza', 'roadtrip.track.gap': 'a {distance} da questo giorno', 'roadtrip.track.onDay': 'lungo questo giorno', - 'roadtrip.track.explain': 'Il percorso viene portato sulla traccia con alcuni punti di passaggio. Quelli già presenti su quelle tratte vengono sostituiti.', + 'roadtrip.track.explain': + 'Il percorso viene portato sulla traccia con alcuni punti di passaggio. Quelli già presenti su quelle tratte vengono sostituiti.', 'roadtrip.track.action': 'Segui questa traccia', 'roadtrip.track.working': 'Adatto il percorso alla traccia, giro {round}', 'roadtrip.track.done': '{count} punti di passaggio posati. Il percorso resta entro {distance} dalla traccia.', - 'roadtrip.track.capped': '{count} punti di passaggio posati. Nel punto peggiore il percorso resta a {distance} dalla traccia.', + 'roadtrip.track.capped': + '{count} punti di passaggio posati. Nel punto peggiore il percorso resta a {distance} dalla traccia.', 'roadtrip.track.already': 'Il percorso seguiva già questa traccia.', 'roadtrip.track.errorRoute': 'Il servizio di calcolo percorso non ha risposto.', 'roadtrip.track.errorSave': 'Non è stato possibile salvare i punti di passaggio.', @@ -129,7 +189,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Lungo il percorso non c’è nulla che corrisponda a “{name}”.', 'roadtrip.poi.noneMatchFilters': 'Niente lungo il percorso corrisponde a questi filtri.', 'roadtrip.poi.foundFiltered': '{count} di {total} lungo il percorso', - 'roadtrip.poi.truncated': 'Su {count} tratti ce n’erano più di quanti ne stiano in una risposta: restringi il corridoio per vedere il resto.', + 'roadtrip.poi.truncated': + 'Su {count} tratti ce n’erano più di quanti ne stiano in una risposta: restringi il corridoio per vedere il resto.', 'roadtrip.poi.search': 'Cerca', 'roadtrip.poi.searching': 'Ricerca {done} di {total}', 'roadtrip.poi.capped': 'Il percorso è lungo: è stato cercato solo il primo tratto.', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index d60e09895..585b92183 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Motore di percorso proprio', 'settings.routingBaseHint': 'Una tua istanza OSRM. Vuoto usa i server pubblici, che consentono circa una richiesta al secondo: sufficiente per un giorno, poco per un viaggio in auto. Ha effetto dopo il riavvio del server.', 'settings.valhallaBase': 'Istanza Valhalla propria', - 'settings.valhallaBaseHint': 'Una istanza Valhalla propria. Viene interrogata quando una tappa deve evitare pedaggi, autostrade o traghetti: i server OSRM pubblici non sanno rispondere. Vuoto usa il Valhalla pubblico di FOSSGIS, a meno che sopra non sia indicato un motore di routing proprio, nel qual caso non viene interrogato alcun secondo motore. Ha effetto dopo il riavvio del server.', + 'settings.valhallaBaseHint': + 'TREK usa per impostazione predefinita la Valhalla pubblica di FOSSGIS per evitare pedaggi, autostrade e traghetti. Inserisci qui l’URL della tua Valhalla per usarla al suo posto. Se è configurata solo un’istanza di routing personalizzata, la Valhalla pubblica non viene usata. Dopo aver inserito un URL personalizzato, riavvia il server e ricarica la pagina.', 'settings.mapHint': 'Modello URL per i tile della mappa', 'settings.mapProvider': 'Provider mappa', 'settings.mapProviderHint': 'Influisce sulle mappe Trip Planner e Journey. Atlas usa sempre Leaflet.', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index 737707577..bf47acb55 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -1,6 +1,61 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': '宿泊施設を探す', + 'roadtrip.stay.portalHint': + '予約サイトで目的地、日付、宿泊人数を確認してください。CHECK24では手動で検索してください。', + 'roadtrip.charging.available': '空き', + 'roadtrip.charging.unknown': '信頼できるデータがありません', + 'roadtrip.charging.stale': '古い状態情報', + 'roadtrip.charging.prices': '公開料金', + 'roadtrip.charging.note': + '公開料金です。税金、条件、追加料金をご確認ください。充電カードの料金は異なる場合があります。', + 'roadtrip.charging.coverage': 'オープンデータ。地域や事業者によって対応範囲が異なります。', + 'roadtrip.import.title': 'Google Mapsのルートをインポート', + 'roadtrip.import.note': '立ち寄り先を選択した日に追加し、TREKでルートを再計算します。', + 'roadtrip.import.unresolved': '場所が見つからないため、この立ち寄り先はスキップされます。', + 'roadtrip.import.day': '日', + 'roadtrip.hazards.show': '危険地域を表示', + 'roadtrip.hazards.current': '現在の警報', + 'roadtrip.hazards.note': '警報は通行止めを意味しません。ルートは変更されません。', + 'roadtrip.hazards.point': '発生地点のみ。影響範囲は不明です。', + 'roadtrip.hazards.loading': '警報を読み込み中…', + 'roadtrip.hazards.unavailable': '利用できません', + 'roadtrip.hazards.offline': 'オフライン:最新の警報は利用できません', + 'roadtrip.hazards.partial': '対象範囲は限定的', + 'roadtrip.stops.section': 'サービス施設での停車', + 'roadtrip.stops.inDays': '日別にも表示', + 'roadtrip.stops.daysHint': + 'この旅行のすべてのサービス施設での停車に適用されます。オフにするとRoadtripの計画にのみ表示されます。オンにすると日別にも再表示されます。', + 'roadtrip.window.dragHint': + 'オンライン時は一日の終了地点をルート上や場所へドラッグできます。右クリックで自動の終了地点に戻せます。', + 'roadtrip.window.resetBoundaries': '一日の終了地点を自動に戻す', + 'roadtrip.window.endHere': 'ここで一日を終了', + 'roadtrip.window.endHereHint': + 'Roadtripモードと毎日の移動時間が有効な場合、場所の詳細から特定の立ち寄り先を一日の終点に設定できます。', + 'roadtrip.window.endAt': '1日の終了地点', + 'roadtrip.window.onRoute': 'ルートの途中', + 'roadtrip.window.atStop': '最後の場所', + 'roadtrip.window.routeHint': '終了時刻になったら、ルート上の到達地点で休止します。', + 'roadtrip.window.stopHint': '終了時刻までに次の場所へ到着できない場合は、最後の場所に留まります。', + 'roadtrip.window.legTooLong': + '1区間の移動時間が1日の時間枠を超えています。途中に場所を追加するか、ルートの途中で休止する設定を選んでください。', + 'roadtrip.window.pointLabel': '{day}日目の終了時刻は{time}', + 'roadtrip.window.title': '毎日の移動時間', + 'roadtrip.window.start': '一日の開始時刻', + 'roadtrip.window.end': '一日の終了時刻', + 'roadtrip.window.hint': + '両方の時刻を設定すると毎日の休憩を計画します。どちらかを空欄にすると無効になります。手動の時刻が優先され、各日のルートは自動で接続されます。', + 'roadtrip.window.invalid': '終了時刻は開始時刻より後にしてください。', + 'roadtrip.window.stop': '一日の終了', + 'roadtrip.window.resume': '移動を再開', + 'roadtrip.window.pointHint': 'ここが夜間の休止地点になる見込みです。', + 'roadtrip.window.departure': '残りの滞在後、{time}に出発', + 'roadtrip.window.showPoint': '地図で表示', + 'roadtrip.window.incomplete': '毎日の休憩を計算するには、すべての接続ルートが必要です。', + 'roadtrip.window.conflict': + '固定時刻が移動や休憩と両立しません。手動の時刻は保持され、自動計画は一時停止しています。', + 'roadtrip.window.tooLong': 'この設定では365日を超える追加日数が必要です。一日の移動時間を長くしてください。', 'roadtrip.mode.roadtrip': 'ロードトリップ', 'roadtrip.mode.days': '日ごと', 'roadtrip.mode.label': 'プラン表示', @@ -65,10 +120,12 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.motorway': '高速道路', 'roadtrip.avoid.ferry': 'フェリー', 'roadtrip.avoid.hint': '禁止ではなく、優先度を下げるだけです。迂回できない場合はそのまま通り、その日に表示されます。', - 'roadtrip.avoid.unavailable': '利用できません。このインスタンスは自前のエンジンを使っており、道路種別を除外できません。', + 'roadtrip.avoid.unavailable': + '利用できません。このインスタンスは自前のエンジンを使っており、道路種別を除外できません。', 'roadtrip.avoid.badge': '{count} 件を回避', 'roadtrip.avoid.missed': '{classes} は回避できず', - 'roadtrip.avoid.missedHint': 'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。', + 'roadtrip.avoid.missedHint': + 'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。', 'roadtrip.refuel.dry': 'ここで燃料切れ', 'roadtrip.refuel.after': '{distance} 後', 'roadtrip.limit.vehicleLabel': '何で走るか', @@ -110,7 +167,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '全長 {distance}', 'roadtrip.track.gap': 'この日から {distance}', 'roadtrip.track.onDay': 'この日に沿って', - 'roadtrip.track.explain': 'いくつかの経由地で走行をトラックに引き寄せます。その区間にある既存の経由地は置き換えられます。', + 'roadtrip.track.explain': + 'いくつかの経由地で走行をトラックに引き寄せます。その区間にある既存の経由地は置き換えられます。', 'roadtrip.track.action': 'このトラックをたどる', 'roadtrip.track.working': '走行をトラックに合わせています({round} 回目)', 'roadtrip.track.done': '経由地を {count} か所置きました。走行はトラックから {distance} 以内に収まります。', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 758d9c1f5..92254920e 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': '自前のルーティングサーバー', 'settings.routingBaseHint': '自前の OSRM インスタンス。空欄なら公開サーバーを使いますが、毎秒 1 リクエスト程度の制限があり、1 日分には足りてもロードトリップには足りません。サーバー再起動後に反映されます。', 'settings.valhallaBase': '自前の Valhalla インスタンス', - 'settings.valhallaBaseHint': '自前の Valhalla インスタンス。有料道路・高速道路・フェリーを避けたい区間で問い合わせます。公開の OSRM サーバーはこれに答えられません。空欄なら FOSSGIS の公開 Valhalla を使いますが、上で自前のルーティングサーバーを指定している場合は二つ目のエンジンを一切問い合わせません。サーバー再起動後に反映されます。', + 'settings.valhallaBaseHint': + 'TREKは、有料道路、高速道路、フェリーを避けるために、標準でFOSSGISの公開Valhallaを使用します。独自のValhallaを代わりに使う場合は、ここにURLを入力してください。独自のルーティングサーバーのみが設定されている場合、公開Valhallaは使用されません。独自のURLを入力した後は、サーバーを再起動し、ページを再読み込みしてください。', 'settings.mapHint': '地図タイルのURLテンプレート', 'settings.mapProvider': '地図プロバイダー', 'settings.mapProviderHint': '旅程プランナーと日記地図に影響します。Atlas は常に Leaflet を使用します。', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index ec892396e..ff6409177 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -1,6 +1,60 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': '숙소 찾기', + 'roadtrip.stay.portalHint': '포털에서 목적지, 날짜, 인원을 확인하세요. CHECK24에서는 직접 검색하세요.', + 'roadtrip.charging.available': '이용 가능', + 'roadtrip.charging.unknown': '신뢰할 수 있는 데이터 없음', + 'roadtrip.charging.stale': '오래된 상태', + 'roadtrip.charging.prices': '공개 요금', + 'roadtrip.charging.note': + '공개 요금입니다. 세금, 조건, 추가 요금이 적용될 수 있으며 충전 카드 요금은 다를 수 있습니다.', + 'roadtrip.charging.coverage': '공개 데이터로 지역과 운영사에 따라 범위가 다릅니다.', + 'roadtrip.import.title': 'Google Maps 경로 가져오기', + 'roadtrip.import.note': '경유지를 선택한 날짜에 추가합니다. TREK이 경로를 다시 계산합니다.', + 'roadtrip.import.unresolved': '위치를 찾을 수 없어 이 경유지를 건너뜁니다.', + 'roadtrip.import.day': '일', + 'roadtrip.hazards.show': '위험 지역 표시', + 'roadtrip.hazards.current': '현재 경보', + 'roadtrip.hazards.note': '경보는 도로 폐쇄를 의미하지 않습니다. 경로는 변경되지 않습니다.', + 'roadtrip.hazards.point': '발생 위치만 표시하며 영향 지역은 알 수 없습니다.', + 'roadtrip.hazards.loading': '경보 불러오는 중…', + 'roadtrip.hazards.unavailable': '사용 불가', + 'roadtrip.hazards.offline': '오프라인: 현재 경보를 사용할 수 없습니다', + 'roadtrip.hazards.partial': '제한된 범위', + 'roadtrip.stops.section': '편의시설 경유지', + 'roadtrip.stops.inDays': '일별 보기에도 표시', + 'roadtrip.stops.daysHint': + '이 여행의 모든 편의시설 경유지에 적용됩니다. 끄면 Roadtrip 계획에만 표시됩니다. 켜면 일별 보기에도 다시 표시됩니다.', + 'roadtrip.window.dragHint': + '온라인에서 하루 종료 지점을 경로나 장소로 드래그하고, 오른쪽 클릭으로 자동 종료를 복원할 수 있습니다.', + 'roadtrip.window.resetBoundaries': '자동 하루 종료 복원', + 'roadtrip.window.endHere': '여기서 하루 종료', + 'roadtrip.window.endHereHint': + 'Roadtrip 모드와 일일 이동 시간이 활성화된 경우 장소 상세에서 특정 경유지를 하루의 종점으로 설정할 수 있습니다.', + 'roadtrip.window.endAt': '하루 종료 지점', + 'roadtrip.window.onRoute': '경로 중간', + 'roadtrip.window.atStop': '마지막 장소', + 'roadtrip.window.routeHint': '종료 시간이 되면 경로의 도달 지점에서 멈춥니다.', + 'roadtrip.window.stopHint': '하루 종료 전 다음 장소에 도착할 수 없으면 마지막 장소에 머뭅니다.', + 'roadtrip.window.legTooLong': + '한 구간의 이동 시간이 하루 시간 범위를 초과합니다. 중간에 장소를 추가하거나 경로 중간에서 멈추도록 선택하세요.', + 'roadtrip.window.pointLabel': '{day}일차 종료 시간 {time}', + 'roadtrip.window.title': '일일 이동 시간', + 'roadtrip.window.start': '하루 시작 시간', + 'roadtrip.window.end': '하루 종료 시간', + 'roadtrip.window.hint': + '매일 휴식을 계획하려면 두 시간을 모두 설정하세요. 하나를 비우면 해제됩니다. 수동 시간이 우선하며 날짜별 경로는 자동으로 연결됩니다.', + 'roadtrip.window.invalid': '종료 시간은 시작 시간보다 늦어야 합니다.', + 'roadtrip.window.stop': '하루 일정 종료', + 'roadtrip.window.resume': '여행 계속', + 'roadtrip.window.pointHint': '여기가 예상되는 야간 휴식 지점입니다.', + 'roadtrip.window.departure': '남은 방문을 마친 후 {time}에 출발', + 'roadtrip.window.showPoint': '지도에서 보기', + 'roadtrip.window.incomplete': '매일 휴식을 계산하려면 모든 연결 경로가 필요합니다.', + 'roadtrip.window.conflict': + '고정 시간이 이동 및 휴식과 충돌합니다. 수동 시간은 유지되며 자동 계획이 일시 중지됩니다.', + 'roadtrip.window.tooLong': '이 시간 설정에는 365일이 넘는 추가 날짜가 필요합니다. 일일 이동 시간을 늘려 주세요.', 'roadtrip.mode.roadtrip': '로드트립', 'roadtrip.mode.days': '일자별', 'roadtrip.mode.label': '계획 보기', @@ -64,8 +118,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': '유료 도로', 'roadtrip.avoid.motorway': '고속도로', 'roadtrip.avoid.ferry': '페리', - 'roadtrip.avoid.hint': '금지가 아니라 가중치를 낮추는 것입니다. 우회로가 없으면 그대로 지나가고, 해당 날짜에 그렇게 표시됩니다.', - 'roadtrip.avoid.unavailable': '사용할 수 없습니다. 이 인스턴스는 자체 엔진으로 경로를 계산하며, 도로 종류를 제외하지 못합니다.', + 'roadtrip.avoid.hint': + '금지가 아니라 가중치를 낮추는 것입니다. 우회로가 없으면 그대로 지나가고, 해당 날짜에 그렇게 표시됩니다.', + 'roadtrip.avoid.unavailable': + '사용할 수 없습니다. 이 인스턴스는 자체 엔진으로 경로를 계산하며, 도로 종류를 제외하지 못합니다.', 'roadtrip.avoid.badge': '{count}개 회피', 'roadtrip.avoid.missed': '{classes} 회피 불가', 'roadtrip.avoid.missedHint': '이 날은 우회로가 없어 그대로 지나갑니다. 다른 날은 계속 피할 수 있는 것을 피합니다.', @@ -129,7 +185,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': '경로상에 “{name}”와(과) 일치하는 곳이 없습니다.', 'roadtrip.poi.noneMatchFilters': '이 필터에 맞는 곳이 경로에 없습니다.', 'roadtrip.poi.foundFiltered': '경로상 {total}곳 중 {count}곳', - 'roadtrip.poi.truncated': '{count}개 구간에서 한 번에 반환할 수 있는 수를 넘었습니다. 회랑을 좁히면 나머지를 볼 수 있습니다.', + 'roadtrip.poi.truncated': + '{count}개 구간에서 한 번에 반환할 수 있는 수를 넘었습니다. 회랑을 좁히면 나머지를 볼 수 있습니다.', 'roadtrip.poi.search': '검색', 'roadtrip.poi.searching': '검색 중 {done}/{total}', 'roadtrip.poi.capped': '경로가 길어 앞부분만 검색했습니다.', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 3c9e8ca0c..09b7cefd6 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': '자체 경로 서버', 'settings.routingBaseHint': '자체 OSRM 인스턴스입니다. 비워 두면 초당 약 한 번의 요청만 허용하는 공개 서버를 사용합니다. 하루치는 충분하지만 로드트립에는 빠듯합니다. 서버를 다시 시작해야 적용됩니다.', 'settings.valhallaBase': '자체 Valhalla 인스턴스', - 'settings.valhallaBaseHint': '자체 Valhalla 인스턴스. 구간에서 통행료, 고속도로, 페리를 피해야 할 때 이 서버에 묻습니다. 공개 OSRM 서버는 이에 답하지 못합니다. 비워 두면 FOSSGIS의 공개 Valhalla를 쓰지만, 위에 자체 라우팅 서버를 적어 두었다면 두 번째 엔진은 아예 묻지 않습니다. 서버를 다시 시작한 뒤에 적용됩니다.', + 'settings.valhallaBaseHint': + 'TREK은 기본적으로 유료 도로, 고속도로, 페리를 피하기 위해 FOSSGIS의 공개 Valhalla를 사용합니다. 대신 자체 Valhalla를 사용하려면 여기에 URL을 입력하세요. 자체 라우팅 서버만 설정된 경우 공개 Valhalla는 사용되지 않습니다. 자체 URL을 입력한 후 서버를 재시작하고 페이지를 새로고침하세요.', 'settings.mapHint': '지도 타일 URL 템플릿', 'settings.mapProvider': '지도 공급자', 'settings.mapProviderHint': '여행 플래너 및 Journey 지도에 영향을 줍니다. Atlas는 항상 Leaflet을 사용합니다.', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 346547515..fd3cb7e97 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -1,6 +1,61 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Accommodatie zoeken', + 'roadtrip.stay.portalHint': 'Controleer bestemming, datums en gasten op het portaal. Zoek handmatig op CHECK24.', + 'roadtrip.charging.available': 'beschikbaar', + 'roadtrip.charging.unknown': 'Geen betrouwbare gegevens', + 'roadtrip.charging.stale': 'Status verouderd', + 'roadtrip.charging.prices': 'Gepubliceerde tarieven', + 'roadtrip.charging.note': + 'Gepubliceerd tarief; belastingen, voorwaarden en toeslagen kunnen gelden. Je laadpasprijs kan afwijken.', + 'roadtrip.charging.coverage': 'Open data; dekking verschilt per regio en aanbieder.', + 'roadtrip.import.title': 'Google Maps-route importeren', + 'roadtrip.import.note': 'Stops worden aan de gekozen dag toegevoegd. TREK berekent de route opnieuw.', + 'roadtrip.import.unresolved': 'Locatie niet gevonden; deze stop wordt overgeslagen.', + 'roadtrip.import.day': 'Dag', + 'roadtrip.hazards.show': 'Gevarengebieden tonen', + 'roadtrip.hazards.current': 'Actuele waarschuwingen', + 'roadtrip.hazards.note': 'Waarschuwingen zijn geen wegafsluitingen. De route blijft ongewijzigd.', + 'roadtrip.hazards.point': 'Alleen locatie; getroffen gebied onbekend.', + 'roadtrip.hazards.loading': 'Waarschuwingen laden…', + 'roadtrip.hazards.unavailable': 'Niet beschikbaar', + 'roadtrip.hazards.offline': 'Offline: actuele waarschuwingen niet beschikbaar', + 'roadtrip.hazards.partial': 'Beperkte dekking', + 'roadtrip.stops.section': 'Servicestops', + 'roadtrip.stops.inDays': 'Ook in Dagen tonen', + 'roadtrip.stops.daysHint': + 'Geldt voor alle servicestops van deze reis. Uitgeschakeld blijven ze alleen in Roadtrip staan. Schakel dit in om ze weer in Dagen te tonen.', + 'roadtrip.window.dragHint': + 'Sleep online het dageinde langs de route of naar een plaats; klik met rechts om het automatische einde te herstellen.', + 'roadtrip.window.resetBoundaries': 'Automatische dageinden herstellen', + 'roadtrip.window.endHere': 'Dag hier beëindigen', + 'roadtrip.window.endHereHint': + 'Je kunt een stop als dageinde instellen in de plaatsdetails wanneer de Roadtrip-modus en dagelijkse reistijden actief zijn.', + 'roadtrip.window.endAt': 'Dag afsluiten', + 'roadtrip.window.onRoute': 'Onderweg', + 'roadtrip.window.atStop': 'Bij de laatste plek', + 'roadtrip.window.routeHint': 'Pauzeer onderweg zodra de eindtijd is bereikt.', + 'roadtrip.window.stopHint': 'Blijf bij de laatste plek als de volgende niet voor het einde van de dag bereikbaar is.', + 'roadtrip.window.legTooLong': + 'Een rit duurt langer dan het dagelijkse tijdvenster. Voeg onderweg een plek toe of kies voor een pauze onderweg.', + 'roadtrip.window.pointLabel': 'Einde van dag {day} om {time}', + 'roadtrip.window.title': 'Dagelijkse reistijden', + 'roadtrip.window.start': 'Begin van de dag', + 'roadtrip.window.end': 'Einde van de dag', + 'roadtrip.window.hint': + 'Vul beide tijden in om dagelijkse pauzes te plannen. Maak één veld leeg om dit uit te zetten. Handmatige tijden gaan voor. Dagen worden automatisch verbonden.', + 'roadtrip.window.invalid': 'De eindtijd moet na de begintijd liggen.', + 'roadtrip.window.stop': 'Einde van de dag', + 'roadtrip.window.resume': 'Reis hervatten', + 'roadtrip.window.pointHint': 'Hier pauzeert je reis naar verwachting voor de nacht.', + 'roadtrip.window.departure': 'Verder na het resterende bezoek om {time}', + 'roadtrip.window.showPoint': 'Toon op kaart', + 'roadtrip.window.incomplete': + 'Dagelijkse pauzes kunnen pas worden berekend als alle verbindingsroutes beschikbaar zijn.', + 'roadtrip.window.conflict': + 'Een vaste tijd past niet bij de rit en pauzes. Handmatige tijden blijven behouden; automatische planning is gepauzeerd.', + 'roadtrip.window.tooLong': 'Deze tijden vragen meer dan 365 extra dagen. Kies een langer dagelijks tijdvak.', 'roadtrip.mode.roadtrip': 'Roadtrip', 'roadtrip.mode.days': 'Dagen', 'roadtrip.mode.label': 'Planweergave', @@ -42,7 +97,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.rangeEmpty': 'Geen actieradius', 'roadtrip.limit.rangeEmptyHint': 'Vul een actieradius in, of vul de velden in en TREK rekent hem uit.', 'roadtrip.limit.specHint': 'Ingevuld bepalen deze velden de actieradius hierboven.', - 'roadtrip.limit.vehicleHint': 'Geef aan wat je rijdt, dan kun je de voertuiggegevens invullen in plaats van een actieradius.', + 'roadtrip.limit.vehicleHint': + 'Geef aan wat je rijdt, dan kun je de voertuiggegevens invullen in plaats van een actieradius.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'uit', 'roadtrip.limit.blockNote': 'Eén blok = {distance}', @@ -64,11 +120,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Tolwegen', 'roadtrip.avoid.motorway': 'Snelwegen', 'roadtrip.avoid.ferry': 'Veerboten', - 'roadtrip.avoid.hint': 'Ze krijgen minder gewicht, ze worden niet verboden. Waar een rit er niet omheen kan, gebruikt hij ze toch en de dag meldt dat.', - 'roadtrip.avoid.unavailable': 'Niet beschikbaar: deze installatie gebruikt haar eigen motor, die geen wegklasse kan weglaten.', + 'roadtrip.avoid.hint': + 'Ze krijgen minder gewicht, ze worden niet verboden. Waar een rit er niet omheen kan, gebruikt hij ze toch en de dag meldt dat.', + 'roadtrip.avoid.unavailable': + 'Niet beschikbaar: deze installatie gebruikt haar eigen motor, die geen wegklasse kan weglaten.', 'roadtrip.avoid.badge': '{count} vermeden', 'roadtrip.avoid.missed': '{classes} onvermijdelijk', - 'roadtrip.avoid.missedHint': 'Deze dag heeft er geen route omheen, dus de rit gebruikt het. Alle andere dagen vermijden nog steeds wat ze kunnen.', + 'roadtrip.avoid.missedHint': + 'Deze dag heeft er geen route omheen, dus de rit gebruikt het. Alle andere dagen vermijden nog steeds wat ze kunnen.', 'roadtrip.refuel.dry': 'Hier is de tank leeg', 'roadtrip.refuel.after': 'na {distance}', 'roadtrip.limit.vehicleLabel': 'Wat je rijdt', @@ -110,7 +169,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} lang', 'roadtrip.track.gap': '{distance} van deze dag', 'roadtrip.track.onDay': 'langs deze dag', - 'roadtrip.track.explain': 'De rit wordt met een handvol tussenpunten op de track getrokken. Bestaande tussenpunten op die etappes worden vervangen.', + 'roadtrip.track.explain': + 'De rit wordt met een handvol tussenpunten op de track getrokken. Bestaande tussenpunten op die etappes worden vervangen.', 'roadtrip.track.action': 'Deze track volgen', 'roadtrip.track.working': 'Rit wordt op de track gepast, ronde {round}', 'roadtrip.track.done': '{count} tussenpunten geplaatst. De rit blijft binnen {distance} van de track.', @@ -129,7 +189,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Niets onderweg komt overeen met “{name}”.', 'roadtrip.poi.noneMatchFilters': 'Niets onderweg voldoet aan deze filters.', 'roadtrip.poi.foundFiltered': '{count} van {total} onderweg', - 'roadtrip.poi.truncated': 'Op {count} stukken waren er meer dan in één antwoord passen — maak de corridor smaller om de rest te zien.', + 'roadtrip.poi.truncated': + 'Op {count} stukken waren er meer dan in één antwoord passen — maak de corridor smaller om de rest te zien.', 'roadtrip.poi.search': 'Zoeken', 'roadtrip.poi.searching': 'Zoeken {done} van {total}', 'roadtrip.poi.capped': 'De route is lang — alleen het eerste deel is doorzocht.', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index afe8ab0d5..1db115f8e 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -28,7 +28,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Eigen routeserver', 'settings.routingBaseHint': 'Een eigen OSRM-server. Leeg gebruikt de publieke servers, die ongeveer één verzoek per seconde toestaan — genoeg voor een dag, krap voor een roadtrip. Werkt na een herstart van de server.', 'settings.valhallaBase': 'Eigen Valhalla-instantie', - 'settings.valhallaBaseHint': 'Een eigen Valhalla-instantie. Die wordt geraadpleegd wanneer een traject tol, snelwegen of veerboten moet vermijden — dat kunnen de publieke OSRM-servers niet beantwoorden. Leeg gebruikt de publieke FOSSGIS-Valhalla, tenzij hierboven een eigen routeringsmotor staat; dan wordt er helemaal geen tweede motor geraadpleegd. Werkt pas na een herstart van de server.', + 'settings.valhallaBaseHint': + 'TREK gebruikt standaard de publieke Valhalla van FOSSGIS om tolwegen, snelwegen en veerboten te vermijden. Vul hier de URL van je eigen Valhalla in om die te gebruiken. Als alleen een eigen routeringsinstantie is ingesteld, wordt de publieke Valhalla niet gebruikt. Herstart na het invoeren van een eigen URL de server en laad de pagina opnieuw.', 'settings.mapHint': 'URL-sjabloon voor kaarttegels', 'settings.mapProvider': 'Kaartprovider', 'settings.mapProviderHint': 'Geldt voor Trip Planner en Journey kaarten. Atlas gebruikt altijd Leaflet.', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index e1d8f8200..e543abdbf 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -1,6 +1,60 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Znajdź nocleg', + 'roadtrip.stay.portalHint': 'Sprawdź cel, daty i gości na portalu. W CHECK24 wyszukaj ręcznie.', + 'roadtrip.charging.available': 'wolne', + 'roadtrip.charging.unknown': 'Brak wiarygodnych danych', + 'roadtrip.charging.stale': 'Nieaktualny status', + 'roadtrip.charging.prices': 'Opublikowane taryfy', + 'roadtrip.charging.note': + 'Opublikowana taryfa; możliwe podatki, warunki i dodatkowe opłaty. Cena Twojej karty może się różnić.', + 'roadtrip.charging.coverage': 'Otwarte dane; zasięg zależy od regionu i operatora.', + 'roadtrip.import.title': 'Importuj trasę z Google Maps', + 'roadtrip.import.note': 'Przystanki zostaną dodane do wybranego dnia. TREK przeliczy trasę.', + 'roadtrip.import.unresolved': 'Nie znaleziono miejsca; ten przystanek zostanie pominięty.', + 'roadtrip.import.day': 'Dzień', + 'roadtrip.hazards.show': 'Pokaż obszary zagrożeń', + 'roadtrip.hazards.current': 'Aktualne ostrzeżenia', + 'roadtrip.hazards.note': 'Ostrzeżenia nie oznaczają zamknięcia dróg. Trasa pozostaje bez zmian.', + 'roadtrip.hazards.point': 'Tylko lokalizacja; obszar zagrożenia nieznany.', + 'roadtrip.hazards.loading': 'Ładowanie ostrzeżeń…', + 'roadtrip.hazards.unavailable': 'Niedostępne', + 'roadtrip.hazards.offline': 'Offline: aktualne ostrzeżenia niedostępne', + 'roadtrip.hazards.partial': 'Ograniczony zasięg', + 'roadtrip.stops.section': 'Postoje serwisowe', + 'roadtrip.stops.inDays': 'Pokaż także w Dniach', + 'roadtrip.stops.daysHint': + 'Dotyczy wszystkich postojów serwisowych tej podróży. Po wyłączeniu pozostają tylko w Roadtrip. Włącz, aby ponownie pokazać je w Dniach.', + 'roadtrip.window.dragHint': + 'Online przeciągnij koniec dnia wzdłuż trasy lub na miejsce; kliknij prawym przyciskiem, aby przywrócić automatyczny koniec.', + 'roadtrip.window.resetBoundaries': 'Przywróć automatyczne końce dni', + 'roadtrip.window.endHere': 'Zakończ dzień tutaj', + 'roadtrip.window.endHereHint': + 'Możesz wybrać przystanek jako koniec dnia w szczegółach miejsca, gdy tryb Roadtrip i dzienne godziny podróży są aktywne.', + 'roadtrip.window.endAt': 'Koniec dnia', + 'roadtrip.window.onRoute': 'Na trasie', + 'roadtrip.window.atStop': 'W ostatnim miejscu', + 'roadtrip.window.routeHint': 'Zatrzymaj się na trasie o godzinie zakończenia dnia.', + 'roadtrip.window.stopHint': 'Zostań w ostatnim miejscu, jeśli nie można dotrzeć do następnego przed końcem dnia.', + 'roadtrip.window.legTooLong': + 'Jeden odcinek przekracza dzienny przedział czasu. Dodaj miejsce po drodze lub wybierz postój na trasie.', + 'roadtrip.window.pointLabel': 'Koniec dnia {day} o {time}', + 'roadtrip.window.title': 'Dzienne godziny podróży', + 'roadtrip.window.start': 'Początek dnia', + 'roadtrip.window.end': 'Koniec dnia', + 'roadtrip.window.hint': + 'Ustaw obie godziny, aby planować codzienne przerwy. Wyczyść jedną, aby wyłączyć automatykę. Ręczne godziny mają pierwszeństwo. Dni są łączone automatycznie.', + 'roadtrip.window.invalid': 'Godzina końca musi być późniejsza niż godzina początku.', + 'roadtrip.window.stop': 'Koniec dnia', + 'roadtrip.window.resume': 'Kontynuuj podróż', + 'roadtrip.window.pointHint': 'To przewidywane miejsce nocnej przerwy w podróży.', + 'roadtrip.window.departure': 'Dalsza podróż po pozostałej części wizyty o {time}', + 'roadtrip.window.showPoint': 'Pokaż na mapie', + 'roadtrip.window.incomplete': 'Obliczenie codziennych przerw wymaga wszystkich tras łączących.', + 'roadtrip.window.conflict': + 'Stała godzina koliduje z jazdą i przerwami. Ręczne godziny pozostają bez zmian; automatyczne planowanie jest wstrzymane.', + 'roadtrip.window.tooLong': 'Te godziny wymagają ponad 365 dodatkowych dni. Wybierz dłuższy dzienny przedział.', 'roadtrip.mode.roadtrip': 'Podróż samochodowa', 'roadtrip.mode.days': 'Dni', 'roadtrip.mode.label': 'Widok planu', @@ -64,11 +118,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Drogi płatne', 'roadtrip.avoid.motorway': 'Autostrady', 'roadtrip.avoid.ferry': 'Promy', - 'roadtrip.avoid.hint': 'To obniżenie wagi, nie zakaz. Gdy przejazd nie ma jak ominąć, i tak z tego korzysta, a dzień to pokazuje.', - 'roadtrip.avoid.unavailable': 'Niedostępne: ta instancja korzysta z własnego silnika, który nie potrafi pominąć klasy drogi.', + 'roadtrip.avoid.hint': + 'To obniżenie wagi, nie zakaz. Gdy przejazd nie ma jak ominąć, i tak z tego korzysta, a dzień to pokazuje.', + 'roadtrip.avoid.unavailable': + 'Niedostępne: ta instancja korzysta z własnego silnika, który nie potrafi pominąć klasy drogi.', 'roadtrip.avoid.badge': '{count} omijane', 'roadtrip.avoid.missed': '{classes} nie do ominięcia', - 'roadtrip.avoid.missedHint': 'Tego dnia nie ma trasy w objazd, więc przejazd z tego korzysta. Pozostałe dni nadal omijają, co się da.', + 'roadtrip.avoid.missedHint': + 'Tego dnia nie ma trasy w objazd, więc przejazd z tego korzysta. Pozostałe dni nadal omijają, co się da.', 'roadtrip.refuel.dry': 'Tu kończy się paliwo', 'roadtrip.refuel.after': 'po {distance}', 'roadtrip.limit.vehicleLabel': 'Czym jeździsz', @@ -110,7 +167,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} długości', 'roadtrip.track.gap': '{distance} od tego dnia', 'roadtrip.track.onDay': 'wzdłuż tego dnia', - 'roadtrip.track.explain': 'Trasa zostaje naciągnięta na ślad kilkoma punktami pośrednimi. Istniejące punkty na tych odcinkach zostaną zastąpione.', + 'roadtrip.track.explain': + 'Trasa zostaje naciągnięta na ślad kilkoma punktami pośrednimi. Istniejące punkty na tych odcinkach zostaną zastąpione.', 'roadtrip.track.action': 'Podążaj za tym śladem', 'roadtrip.track.working': 'Dopasowywanie trasy do śladu, runda {round}', 'roadtrip.track.done': 'Ustawiono {count} punktów pośrednich. Trasa trzyma się śladu w promieniu {distance}.', @@ -129,7 +187,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Nic po drodze nie pasuje do „{name}”.', 'roadtrip.poi.noneMatchFilters': 'Nic po drodze nie pasuje do tych filtrów.', 'roadtrip.poi.foundFiltered': '{count} z {total} po drodze', - 'roadtrip.poi.truncated': 'Na {count} odcinkach było więcej, niż mieści się w jednej odpowiedzi — zawęź korytarz, aby zobaczyć resztę.', + 'roadtrip.poi.truncated': + 'Na {count} odcinkach było więcej, niż mieści się w jednej odpowiedzi — zawęź korytarz, aby zobaczyć resztę.', 'roadtrip.poi.search': 'Szukaj', 'roadtrip.poi.searching': 'Szukanie {done} z {total}', 'roadtrip.poi.capped': 'Trasa jest długa — przeszukano tylko pierwszy odcinek.', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 2e65005ad..65b6c39f6 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Własny silnik tras', 'settings.routingBaseHint': 'Własna instancja OSRM. Puste używa serwerów publicznych, które pozwalają na około jedno zapytanie na sekundę — wystarczy na dzień, mało na road trip. Działa po restarcie serwera.', 'settings.valhallaBase': 'Własna instancja Valhalli', - 'settings.valhallaBaseHint': 'Własna instancja Valhalli. Pytana wtedy, gdy odcinek ma omijać opłaty, autostrady lub promy — publiczne serwery OSRM nie potrafią na to odpowiedzieć. Puste używa publicznej Valhalli FOSSGIS, chyba że powyżej wskazano własny silnik tras; wtedy żaden drugi silnik nie jest pytany. Działa po ponownym uruchomieniu serwera.', + 'settings.valhallaBaseHint': + 'TREK domyślnie używa publicznej Valhalli FOSSGIS do omijania opłat, autostrad i promów. Wpisz tutaj URL własnej Valhalli, aby używać jej zamiast publicznej. Jeśli skonfigurowano tylko własną instancję wyznaczania tras, publiczna Valhalla nie jest używana. Po wpisaniu własnego adresu URL uruchom ponownie serwer i odśwież stronę.', 'settings.mapHint': 'Szablon URL dla kafelków mapy', 'settings.mapProvider': 'Dostawca mapy', 'settings.mapProviderHint': 'Dotyczy map Trip Planner i Journey. Atlas zawsze używa Leaflet.', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index c2b383816..a559c5703 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -1,6 +1,61 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Найти жильё', + 'roadtrip.stay.portalHint': 'Проверьте место, даты и гостей на портале. В CHECK24 ищите вручную.', + 'roadtrip.charging.available': 'свободно', + 'roadtrip.charging.unknown': 'Нет надёжных данных', + 'roadtrip.charging.stale': 'Статус устарел', + 'roadtrip.charging.prices': 'Опубликованные тарифы', + 'roadtrip.charging.note': + 'Опубликованный тариф; возможны налоги, условия и доплаты. Цена по вашей карте может отличаться.', + 'roadtrip.charging.coverage': 'Открытые данные; охват зависит от региона и оператора.', + 'roadtrip.import.title': 'Импорт маршрута Google Maps', + 'roadtrip.import.note': 'Остановки добавляются в выбранный день. TREK пересчитает маршрут.', + 'roadtrip.import.unresolved': 'Место не найдено; эта остановка будет пропущена.', + 'roadtrip.import.day': 'День', + 'roadtrip.hazards.show': 'Показывать опасные зоны', + 'roadtrip.hazards.current': 'Текущие предупреждения', + 'roadtrip.hazards.note': 'Предупреждения не означают закрытие дорог. Маршрут не меняется.', + 'roadtrip.hazards.point': 'Только место события; затронутая зона неизвестна.', + 'roadtrip.hazards.loading': 'Загрузка предупреждений…', + 'roadtrip.hazards.unavailable': 'Недоступно', + 'roadtrip.hazards.offline': 'Нет сети: текущие предупреждения недоступны', + 'roadtrip.hazards.partial': 'Ограниченный охват', + 'roadtrip.stops.section': 'Сервисные остановки', + 'roadtrip.stops.inDays': 'Показывать также в разделе «Дни»', + 'roadtrip.stops.daysHint': + 'Применяется ко всем сервисным остановкам поездки. Если выключено, они остаются только в Roadtrip. Включите, чтобы снова показывать их в разделе «Дни».', + 'roadtrip.window.dragHint': + 'В сети перетащите конец дня по маршруту или на место; щёлкните правой кнопкой для возврата к автоматическому завершению.', + 'roadtrip.window.resetBoundaries': 'Восстановить автоматические окончания дней', + 'roadtrip.window.endHere': 'Завершить день здесь', + 'roadtrip.window.endHereHint': + 'В сведениях о месте можно завершить день на выбранной остановке, когда активны режим Roadtrip и ежедневное время поездки.', + 'roadtrip.window.endAt': 'Завершение дня', + 'roadtrip.window.onRoute': 'На маршруте', + 'roadtrip.window.atStop': 'В последнем месте', + 'roadtrip.window.routeHint': 'Остановиться на маршруте при наступлении времени окончания дня.', + 'roadtrip.window.stopHint': 'Остаться в последнем месте, если до следующего нельзя доехать до конца дня.', + 'roadtrip.window.legTooLong': + 'Один переезд превышает дневной интервал. Добавьте место по пути или выберите остановку на маршруте.', + 'roadtrip.window.pointLabel': 'Конец дня {day} в {time}', + 'roadtrip.window.title': 'Ежедневное время в пути', + 'roadtrip.window.start': 'Начало дня', + 'roadtrip.window.end': 'Конец дня', + 'roadtrip.window.hint': + 'Задайте оба времени для ежедневных перерывов. Очистите одно поле, чтобы отключить расчёт. Ручные значения имеют приоритет. Дни соединяются автоматически.', + 'roadtrip.window.invalid': 'Конец дня должен быть позже начала.', + 'roadtrip.window.stop': 'Конец дня', + 'roadtrip.window.resume': 'Продолжить поездку', + 'roadtrip.window.pointHint': 'Здесь предполагается ночная остановка в пути.', + 'roadtrip.window.departure': 'Продолжить после оставшейся части посещения в {time}', + 'roadtrip.window.showPoint': 'Показать на карте', + 'roadtrip.window.incomplete': 'Для расчёта ежедневных перерывов нужны все соединяющие маршруты.', + 'roadtrip.window.conflict': + 'Фиксированное время противоречит поездке и перерывам. Ручные значения сохранены; автоматическое планирование приостановлено.', + 'roadtrip.window.tooLong': + 'При этих настройках потребуется более 365 дополнительных дней. Увеличьте ежедневный интервал.', 'roadtrip.mode.roadtrip': 'Автопутешествие', 'roadtrip.mode.days': 'Дни', 'roadtrip.mode.label': 'Вид плана', @@ -64,11 +119,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Платные дороги', 'roadtrip.avoid.motorway': 'Автомагистрали', 'roadtrip.avoid.ferry': 'Паромы', - 'roadtrip.avoid.hint': 'Это понижение веса, а не запрет. Если у поездки нет обхода, она всё равно пройдёт там, и день это покажет.', - 'roadtrip.avoid.unavailable': 'Недоступно: этот экземпляр использует собственный движок, который не умеет исключать класс дорог.', + 'roadtrip.avoid.hint': + 'Это понижение веса, а не запрет. Если у поездки нет обхода, она всё равно пройдёт там, и день это покажет.', + 'roadtrip.avoid.unavailable': + 'Недоступно: этот экземпляр использует собственный движок, который не умеет исключать класс дорог.', 'roadtrip.avoid.badge': '{count} в обход', 'roadtrip.avoid.missed': '{classes} не обойти', - 'roadtrip.avoid.missedHint': 'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.', + 'roadtrip.avoid.missedHint': + 'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.', 'roadtrip.refuel.dry': 'Здесь кончится топливо', 'roadtrip.refuel.after': 'через {distance}', 'roadtrip.limit.vehicleLabel': 'На чём вы едете', @@ -110,11 +168,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': 'длина {distance}', 'roadtrip.track.gap': 'в {distance} от этого дня', 'roadtrip.track.onDay': 'вдоль этого дня', - 'roadtrip.track.explain': 'Маршрут притягивается к треку несколькими промежуточными точками. Существующие точки на этих участках заменяются.', + 'roadtrip.track.explain': + 'Маршрут притягивается к треку несколькими промежуточными точками. Существующие точки на этих участках заменяются.', 'roadtrip.track.action': 'Идти по этому треку', 'roadtrip.track.working': 'Подгоняем маршрут к треку, круг {round}', 'roadtrip.track.done': 'Поставлено промежуточных точек: {count}. Маршрут держится в пределах {distance} от трека.', - 'roadtrip.track.capped': 'Поставлено промежуточных точек: {count}. В худшем месте маршрут отходит от трека на {distance}.', + 'roadtrip.track.capped': + 'Поставлено промежуточных точек: {count}. В худшем месте маршрут отходит от трека на {distance}.', 'roadtrip.track.already': 'Маршрут уже шёл по этому треку.', 'roadtrip.track.errorRoute': 'Сервис маршрутов не ответил.', 'roadtrip.track.errorSave': 'Не удалось сохранить промежуточные точки.', @@ -129,7 +189,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'По пути ничего не соответствует «{name}».', 'roadtrip.poi.noneMatchFilters': 'На маршруте нет ничего, что подходит под эти фильтры.', 'roadtrip.poi.foundFiltered': '{count} из {total} по пути', - 'roadtrip.poi.truncated': 'На {count} участках нашлось больше, чем помещается в один ответ — сузьте коридор, чтобы увидеть остальное.', + 'roadtrip.poi.truncated': + 'На {count} участках нашлось больше, чем помещается в один ответ — сузьте коридор, чтобы увидеть остальное.', 'roadtrip.poi.search': 'Искать', 'roadtrip.poi.searching': 'Поиск {done} из {total}', 'roadtrip.poi.capped': 'Маршрут длинный — просмотрен только первый участок.', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 70c8c0dcc..8550d8243 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Собственный сервер маршрутов', 'settings.routingBaseHint': 'Собственный экземпляр OSRM. Пусто — используются публичные серверы с ограничением около одного запроса в секунду: на день хватает, для автопутешествия мало. Вступает в силу после перезапуска сервера.', 'settings.valhallaBase': 'Свой сервер Valhalla', - 'settings.valhallaBaseHint': 'Свой сервер Valhalla. К нему обращаются, когда участок должен обойти платные дороги, автомагистрали или паромы — публичные серверы OSRM на это ответить не могут. Пусто использует публичный Valhalla от FOSSGIS, если выше не указан свой маршрутизатор; тогда второй движок не запрашивается вовсе. Действует после перезапуска сервера.', + 'settings.valhallaBaseHint': + 'По умолчанию TREK использует публичную Valhalla FOSSGIS для объезда платных дорог, автомагистралей и паромов. Введите здесь URL собственной Valhalla, чтобы использовать её вместо публичной. Если настроен только собственный сервер маршрутизации, публичная Valhalla не используется. После ввода собственного URL перезапустите сервер и обновите страницу.', 'settings.mapHint': 'URL-шаблон для тайлов карты', 'settings.mapProvider': 'Провайдер карты', 'settings.mapProviderHint': 'Применяется к Trip Planner и Journey. Atlas всегда использует Leaflet.', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 8fccfd389..b17fcf481 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -1,6 +1,59 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Hitta boende', + 'roadtrip.stay.portalHint': 'Kontrollera resmål, datum och gäster på portalen. Sök manuellt på CHECK24.', + 'roadtrip.charging.available': 'lediga', + 'roadtrip.charging.unknown': 'Inga tillförlitliga uppgifter', + 'roadtrip.charging.stale': 'Inaktuell status', + 'roadtrip.charging.prices': 'Publicerade priser', + 'roadtrip.charging.note': 'Publicerat pris; skatter, villkor och tillägg kan gälla. Ditt laddkorts pris kan avvika.', + 'roadtrip.charging.coverage': 'Öppna data; täckning varierar efter region och operatör.', + 'roadtrip.import.title': 'Importera Google Maps-rutt', + 'roadtrip.import.note': 'Stoppen läggs till på vald dag. TREK beräknar rutten på nytt.', + 'roadtrip.import.unresolved': 'Platsen hittades inte; stoppet hoppas över.', + 'roadtrip.import.day': 'Dag', + 'roadtrip.hazards.show': 'Visa riskområden', + 'roadtrip.hazards.current': 'Aktuella varningar', + 'roadtrip.hazards.note': 'Varningar är inte vägavstängningar. Rutten ändras inte.', + 'roadtrip.hazards.point': 'Endast plats; berört område okänt.', + 'roadtrip.hazards.loading': 'Läser in varningar…', + 'roadtrip.hazards.unavailable': 'Inte tillgängligt', + 'roadtrip.hazards.offline': 'Offline: aktuella varningar saknas', + 'roadtrip.hazards.partial': 'Begränsad täckning', + 'roadtrip.stops.section': 'Servicestopp', + 'roadtrip.stops.inDays': 'Visa även i Dagar', + 'roadtrip.stops.daysHint': + 'Gäller alla servicestopp på resan. När detta är avstängt visas de endast i Roadtrip. Slå på för att visa dem i Dagar igen.', + 'roadtrip.window.dragHint': + 'Dra dagens slut längs rutten eller till en plats när du är online; högerklicka för att återställa det automatiska slutet.', + 'roadtrip.window.resetBoundaries': 'Återställ automatiska dagsslut', + 'roadtrip.window.endHere': 'Avsluta dagen här', + 'roadtrip.window.endHereHint': + 'Du kan avsluta dagen vid ett visst stopp i platsens detaljer när Roadtrip-läget och dagliga restider är aktiva.', + 'roadtrip.window.endAt': 'Avsluta dagen', + 'roadtrip.window.onRoute': 'Längs rutten', + 'roadtrip.window.atStop': 'Vid sista platsen', + 'roadtrip.window.routeHint': 'Pausa längs rutten när sluttiden nås.', + 'roadtrip.window.stopHint': 'Stanna vid sista platsen om nästa inte kan nås före dagens slut.', + 'roadtrip.window.legTooLong': + 'En körsträcka överskrider dagens tidsram. Lägg till en plats längs vägen eller välj att pausa längs rutten.', + 'roadtrip.window.pointLabel': 'Slut på dag {day} kl. {time}', + 'roadtrip.window.title': 'Dagliga restider', + 'roadtrip.window.start': 'Dagens start', + 'roadtrip.window.end': 'Dagens slut', + 'roadtrip.window.hint': + 'Ange båda tiderna för att planera dagliga pauser. Töm ett fält för att stänga av. Manuella tider har företräde. Dagarna kopplas ihop automatiskt.', + 'roadtrip.window.invalid': 'Sluttiden måste vara senare än starttiden.', + 'roadtrip.window.stop': 'Dagens slut', + 'roadtrip.window.resume': 'Fortsätt resan', + 'roadtrip.window.pointHint': 'Här beräknas resan göra uppehåll över natten.', + 'roadtrip.window.departure': 'Fortsätt efter resten av besöket klockan {time}', + 'roadtrip.window.showPoint': 'Visa på kartan', + 'roadtrip.window.incomplete': 'Alla anslutande rutter behövs för att beräkna dagliga pauser.', + 'roadtrip.window.conflict': + 'En fast tid krockar med körningen och pauserna. Manuella tider behålls; automatisk planering är pausad.', + 'roadtrip.window.tooLong': 'Dessa tider kräver fler än 365 extra dagar. Välj ett längre dagligt tidsintervall.', 'roadtrip.mode.roadtrip': 'Bilresa', 'roadtrip.mode.days': 'Dagar', 'roadtrip.mode.label': 'Planvy', @@ -64,11 +117,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Avgiftsvägar', 'roadtrip.avoid.motorway': 'Motorvägar', 'roadtrip.avoid.ferry': 'Färjor', - 'roadtrip.avoid.hint': 'De viktas ned, de förbjuds inte. När en körning inte kan ta sig runt använder den dem ändå, och dagen säger till.', - 'roadtrip.avoid.unavailable': 'Inte tillgängligt: den här installationen använder sin egen motor, som inte kan utesluta en vägklass.', + 'roadtrip.avoid.hint': + 'De viktas ned, de förbjuds inte. När en körning inte kan ta sig runt använder den dem ändå, och dagen säger till.', + 'roadtrip.avoid.unavailable': + 'Inte tillgängligt: den här installationen använder sin egen motor, som inte kan utesluta en vägklass.', 'roadtrip.avoid.badge': '{count} undviks', 'roadtrip.avoid.missed': '{classes} går inte att undvika', - 'roadtrip.avoid.missedHint': 'Den här dagen har ingen väg runt, så körningen använder det. Övriga dagar undviker fortfarande det de kan.', + 'roadtrip.avoid.missedHint': + 'Den här dagen har ingen väg runt, så körningen använder det. Övriga dagar undviker fortfarande det de kan.', 'roadtrip.refuel.dry': 'Här tar tanken slut', 'roadtrip.refuel.after': 'efter {distance}', 'roadtrip.limit.vehicleLabel': 'Vad du kör', @@ -110,7 +166,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} lång', 'roadtrip.track.gap': '{distance} från den här dagen', 'roadtrip.track.onDay': 'längs den här dagen', - 'roadtrip.track.explain': 'Körningen dras in på spåret med ett fåtal mellanpunkter. Befintliga mellanpunkter på de sträckorna ersätts.', + 'roadtrip.track.explain': + 'Körningen dras in på spåret med ett fåtal mellanpunkter. Befintliga mellanpunkter på de sträckorna ersätts.', 'roadtrip.track.action': 'Följ det här spåret', 'roadtrip.track.working': 'Anpassar körningen till spåret, runda {round}', 'roadtrip.track.done': '{count} mellanpunkter satta. Körningen håller sig inom {distance} från spåret.', @@ -129,7 +186,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Inget längs vägen matchar ”{name}”.', 'roadtrip.poi.noneMatchFilters': 'Inget längs vägen matchar de här filtren.', 'roadtrip.poi.foundFiltered': '{count} av {total} längs vägen', - 'roadtrip.poi.truncated': 'På {count} sträckor fanns fler än vad som får plats i ett svar – gör korridoren smalare för att se resten.', + 'roadtrip.poi.truncated': + 'På {count} sträckor fanns fler än vad som får plats i ett svar – gör korridoren smalare för att se resten.', 'roadtrip.poi.search': 'Sök', 'roadtrip.poi.searching': 'Söker {done} av {total}', 'roadtrip.poi.capped': 'Rutten är lång – bara första sträckan genomsöktes.', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index 0c5e07858..5b3fac127 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Egen ruttmotor', 'settings.routingBaseHint': 'En egen OSRM-instans. Tomt använder de publika servrarna, som tillåter ungefär en förfrågan per sekund – nog för en dag, knappt för en bilresa. Träder i kraft efter omstart av servern.', 'settings.valhallaBase': 'Egen Valhalla-instans', - 'settings.valhallaBaseHint': 'En egen Valhalla-instans. Den frågas när en etapp ska undvika vägavgifter, motorvägar eller färjor — de publika OSRM-servrarna kan inte svara på det. Tomt använder FOSSGIS publika Valhalla, om inte en egen ruttmotor angetts ovan; då frågas ingen andra motor alls. Börjar gälla efter en omstart av servern.', + 'settings.valhallaBaseHint': + 'TREK använder som standard FOSSGIS publika Valhalla för att undvika vägtullar, motorvägar och färjor. Ange URL:en till din egen Valhalla här för att använda den i stället. Om bara en egen ruttserver är konfigurerad används inte den publika Valhalla. Starta om servern och ladda om sidan efter att du angett en egen URL.', 'settings.mapHint': 'URL-mall för kartrutor', 'settings.mapProvider': 'Kartleverantör', 'settings.mapProviderHint': 'Påverkar resplaneraren och resedagbokens kartor. Atlas använder alltid Leaflet.', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 18127622f..ee5fc1608 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -1,6 +1,61 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Konaklama bul', + 'roadtrip.stay.portalHint': + 'Portalda varış yerini, tarihleri ve konukları kontrol edin. CHECK24 üzerinde elle arayın.', + 'roadtrip.charging.available': 'boş', + 'roadtrip.charging.unknown': 'Güvenilir veri yok', + 'roadtrip.charging.stale': 'Durum güncel değil', + 'roadtrip.charging.prices': 'Yayımlanan tarifeler', + 'roadtrip.charging.note': + 'Yayımlanan tarife; vergi, koşullar ve ek ücretler uygulanabilir. Şarj kartı fiyatınız farklı olabilir.', + 'roadtrip.charging.coverage': 'Açık veri; kapsam bölge ve operatöre göre değişir.', + 'roadtrip.import.title': 'Google Maps rotasını içe aktar', + 'roadtrip.import.note': 'Duraklar seçilen güne eklenir. TREK rotayı yeniden hesaplar.', + 'roadtrip.import.unresolved': 'Konum bulunamadı; bu durak atlanacak.', + 'roadtrip.import.day': 'Gün', + 'roadtrip.hazards.show': 'Tehlike bölgelerini göster', + 'roadtrip.hazards.current': 'Güncel uyarılar', + 'roadtrip.hazards.note': 'Uyarılar yol kapatmaları değildir. Rota değişmez.', + 'roadtrip.hazards.point': 'Yalnızca konum; etkilenen alan bilinmiyor.', + 'roadtrip.hazards.loading': 'Uyarılar yükleniyor…', + 'roadtrip.hazards.unavailable': 'Kullanılamıyor', + 'roadtrip.hazards.offline': 'Çevrimdışı: güncel uyarılar kullanılamıyor', + 'roadtrip.hazards.partial': 'Sınırlı kapsam', + 'roadtrip.stops.section': 'Hizmet durakları', + 'roadtrip.stops.inDays': 'Günler içinde de göster', + 'roadtrip.stops.daysHint': + 'Bu seyahatin tüm hizmet durakları için geçerlidir. Kapalıyken yalnızca Roadtrip planında kalırlar. Günler içinde yeniden göstermek için açın.', + 'roadtrip.window.dragHint': + 'Çevrimiçiyken gün sonunu rota boyunca veya bir yere sürükleyin; otomatik sonu geri yüklemek için sağ tıklayın.', + 'roadtrip.window.resetBoundaries': 'Otomatik gün sonlarını geri yükle', + 'roadtrip.window.endHere': 'Günü burada bitir', + 'roadtrip.window.endHereHint': + 'Roadtrip modu ve günlük seyahat saatleri etkinken yer ayrıntılarından belirli bir durağı gün sonu olarak seçebilirsiniz.', + 'roadtrip.window.endAt': 'Günü bitir', + 'roadtrip.window.onRoute': 'Rota üzerinde', + 'roadtrip.window.atStop': 'Son yerde', + 'roadtrip.window.routeHint': 'Bitiş saatine ulaşıldığında rota üzerinde mola ver.', + 'roadtrip.window.stopHint': 'Bir sonraki yere gün bitmeden ulaşılamıyorsa son yerde kal.', + 'roadtrip.window.legTooLong': + 'Bir sürüş günlük zaman aralığını aşıyor. Yol üzerine bir yer ekleyin veya rota üzerinde mola vermeyi seçin.', + 'roadtrip.window.pointLabel': '{day}. günün bitişi saat {time}', + 'roadtrip.window.title': 'Günlük yolculuk saatleri', + 'roadtrip.window.start': 'Gün başlangıcı', + 'roadtrip.window.end': 'Gün bitişi', + 'roadtrip.window.hint': + 'Günlük molaları planlamak için iki saati de ayarlayın. Kapatmak için birini temizleyin. Elle girilen saatler önceliklidir. Günler otomatik bağlanır.', + 'roadtrip.window.invalid': 'Bitiş saati başlangıç saatinden sonra olmalıdır.', + 'roadtrip.window.stop': 'Gün sonu', + 'roadtrip.window.resume': 'Yolculuğa devam et', + 'roadtrip.window.pointHint': 'Burası yolculuğunuzun tahmini gece molası noktasıdır.', + 'roadtrip.window.departure': 'Ziyaretin kalan kısmından sonra {time} saatinde devam et', + 'roadtrip.window.showPoint': 'Haritada göster', + 'roadtrip.window.incomplete': 'Günlük molaları hesaplamak için tüm bağlantı rotaları gereklidir.', + 'roadtrip.window.conflict': + 'Sabit bir saat sürüş ve molalarla çakışıyor. Elle girilen saatler korunur; otomatik planlama duraklatıldı.', + 'roadtrip.window.tooLong': 'Bu saatler 365 günden fazla ek süre gerektiriyor. Daha uzun bir günlük aralık seçin.', 'roadtrip.mode.roadtrip': 'Yol gezisi', 'roadtrip.mode.days': 'Günler', 'roadtrip.mode.label': 'Plan görünümü', @@ -64,11 +119,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Paralı yollar', 'roadtrip.avoid.motorway': 'Otoyollar', 'roadtrip.avoid.ferry': 'Feribotlar', - 'roadtrip.avoid.hint': 'Bunlar yasaklanmaz, ağırlığı düşürülür. Bir sürüşün etrafından dolaşma imkânı yoksa yine kullanır ve gün bunu söyler.', - 'roadtrip.avoid.unavailable': 'Kullanılamıyor: bu kurulum kendi motorunu kullanıyor ve bir yol sınıfını dışarıda bırakamıyor.', + 'roadtrip.avoid.hint': + 'Bunlar yasaklanmaz, ağırlığı düşürülür. Bir sürüşün etrafından dolaşma imkânı yoksa yine kullanır ve gün bunu söyler.', + 'roadtrip.avoid.unavailable': + 'Kullanılamıyor: bu kurulum kendi motorunu kullanıyor ve bir yol sınıfını dışarıda bırakamıyor.', 'roadtrip.avoid.badge': '{count} kaçınılıyor', 'roadtrip.avoid.missed': '{classes} kaçınılmaz', - 'roadtrip.avoid.missedHint': 'Bu günün etrafından dolaşan bir rota yok, bu yüzden sürüş oradan geçiyor. Diğer günler kaçınabildiğinden kaçınmayı sürdürüyor.', + 'roadtrip.avoid.missedHint': + 'Bu günün etrafından dolaşan bir rota yok, bu yüzden sürüş oradan geçiyor. Diğer günler kaçınabildiğinden kaçınmayı sürdürüyor.', 'roadtrip.refuel.dry': 'Depo burada bitiyor', 'roadtrip.refuel.after': '{distance} sonra', 'roadtrip.limit.vehicleLabel': 'Ne kullanıyorsun', @@ -110,7 +168,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': '{distance} uzunluğunda', 'roadtrip.track.gap': 'bu günden {distance} uzakta', 'roadtrip.track.onDay': 'bu gün boyunca', - 'roadtrip.track.explain': 'Sürüş, birkaç ara noktayla izin üzerine çekilir. Bu etaplardaki mevcut ara noktalar değiştirilir.', + 'roadtrip.track.explain': + 'Sürüş, birkaç ara noktayla izin üzerine çekilir. Bu etaplardaki mevcut ara noktalar değiştirilir.', 'roadtrip.track.action': 'Bu izi takip et', 'roadtrip.track.working': 'Sürüş ize uyarlanıyor, tur {round}', 'roadtrip.track.done': '{count} ara nokta yerleştirildi. Sürüş, izin {distance} yakınında kalıyor.', @@ -129,7 +188,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Yol boyunca “{name}” ile eşleşen bir şey yok.', 'roadtrip.poi.noneMatchFilters': 'Yol boyunca bu filtrelere uyan bir yer yok.', 'roadtrip.poi.foundFiltered': 'yol üzerinde {total} içinden {count}', - 'roadtrip.poi.truncated': '{count} bölümde bir yanıta sığandan fazlası vardı — geri kalanı görmek için koridoru daraltın.', + 'roadtrip.poi.truncated': + '{count} bölümde bir yanıta sığandan fazlası vardı — geri kalanı görmek için koridoru daraltın.', 'roadtrip.poi.search': 'Ara', 'roadtrip.poi.searching': 'Aranıyor {done}/{total}', 'roadtrip.poi.capped': 'Rota uzun — yalnızca ilk bölüm arandı.', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index ad9da7301..d88a99bfc 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Kendi rota motoru', 'settings.routingBaseHint': 'Kendi OSRM sunucunuz. Boş bırakılırsa saniyede yaklaşık bir istek veren genel sunucular kullanılır: bir gün için yeter, uzun yolculuk için az. Sunucu yeniden başlatıldığında etkin olur.', 'settings.valhallaBase': 'Kendi Valhalla sunucunuz', - 'settings.valhallaBaseHint': 'Kendi Valhalla sunucunuz. Bir etabın gişeleri, otoyolları veya feribotları es geçmesi gerektiğinde ona sorulur — açık OSRM sunucuları bunu yanıtlayamaz. Boş bırakılırsa FOSSGIS’in açık Valhalla’sı kullanılır; yukarıda kendi rota motorunuz yazılıysa ikinci bir motor hiç sorulmaz. Sunucu yeniden başlatıldıktan sonra geçerli olur.', + 'settings.valhallaBaseHint': + 'TREK, ücretli yolları, otoyolları ve feribotları önlemek için varsayılan olarak FOSSGIS’in herkese açık Valhalla hizmetini kullanır. Bunun yerine kendi Valhalla sunucunuzu kullanmak için URL’sini buraya girin. Yalnızca özel bir rota sunucusu yapılandırılmışsa herkese açık Valhalla kullanılmaz. Özel URL girdikten sonra sunucuyu yeniden başlatın ve sayfayı yenileyin.', 'settings.mapHint': 'Harita kutucukları için URL şablonu', 'settings.mapProvider': 'Harita Sağlayıcısı', 'settings.mapProviderHint': 'Seyahat planlayıcı ve Journey haritalarını etkiler. Atlas her zaman Leaflet kullanır.', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 930990fd0..2e9dcf94b 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -1,6 +1,60 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Знайти житло', + 'roadtrip.stay.portalHint': 'Перевірте місце, дати та гостей на порталі. У CHECK24 шукайте вручну.', + 'roadtrip.charging.available': 'вільні', + 'roadtrip.charging.unknown': 'Немає надійних даних', + 'roadtrip.charging.stale': 'Статус застарів', + 'roadtrip.charging.prices': 'Опубліковані тарифи', + 'roadtrip.charging.note': + 'Опублікований тариф; можливі податки, умови та доплати. Ціна за вашою карткою може відрізнятися.', + 'roadtrip.charging.coverage': 'Відкриті дані; охоплення залежить від регіону й оператора.', + 'roadtrip.import.title': 'Імпорт маршруту Google Maps', + 'roadtrip.import.note': 'Зупинки додаються до вибраного дня. TREK перерахує маршрут.', + 'roadtrip.import.unresolved': 'Місце не знайдено; цю зупинку буде пропущено.', + 'roadtrip.import.day': 'День', + 'roadtrip.hazards.show': 'Показувати небезпечні зони', + 'roadtrip.hazards.current': 'Поточні попередження', + 'roadtrip.hazards.note': 'Попередження не означають закриття доріг. Маршрут не змінюється.', + 'roadtrip.hazards.point': 'Лише місце події; уражена зона невідома.', + 'roadtrip.hazards.loading': 'Завантаження попереджень…', + 'roadtrip.hazards.unavailable': 'Недоступно', + 'roadtrip.hazards.offline': 'Офлайн: поточні попередження недоступні', + 'roadtrip.hazards.partial': 'Обмежене охоплення', + 'roadtrip.stops.section': 'Сервісні зупинки', + 'roadtrip.stops.inDays': 'Показувати також у розділі «Дні»', + 'roadtrip.stops.daysHint': + 'Застосовується до всіх сервісних зупинок подорожі. Якщо вимкнено, вони залишаються лише в Roadtrip. Увімкніть, щоб знову показувати їх у розділі «Дні».', + 'roadtrip.window.dragHint': + 'Онлайн перетягніть кінець дня вздовж маршруту або на місце; клацніть правою кнопкою, щоб відновити автоматичне завершення.', + 'roadtrip.window.resetBoundaries': 'Відновити автоматичні завершення днів', + 'roadtrip.window.endHere': 'Завершити день тут', + 'roadtrip.window.endHereHint': + 'У деталях місця можна завершити день на вибраній зупинці, коли активні режим Roadtrip і щоденний час подорожі.', + 'roadtrip.window.endAt': 'Завершення дня', + 'roadtrip.window.onRoute': 'На маршруті', + 'roadtrip.window.atStop': 'В останньому місці', + 'roadtrip.window.routeHint': 'Зупинитися на маршруті, коли настане час завершення дня.', + 'roadtrip.window.stopHint': 'Залишитися в останньому місці, якщо до наступного не можна дістатися до кінця дня.', + 'roadtrip.window.legTooLong': + 'Один переїзд перевищує денний інтервал. Додайте місце дорогою або виберіть зупинку на маршруті.', + 'roadtrip.window.pointLabel': 'Кінець дня {day} о {time}', + 'roadtrip.window.title': 'Щоденний час подорожі', + 'roadtrip.window.start': 'Початок дня', + 'roadtrip.window.end': 'Кінець дня', + 'roadtrip.window.hint': + 'Задайте обидва часи для щоденних перерв. Очистіть одне поле, щоб вимкнути розрахунок. Ручні значення мають пріоритет. Дні з’єднуються автоматично.', + 'roadtrip.window.invalid': 'Кінець дня має бути пізніше початку.', + 'roadtrip.window.stop': 'Кінець дня', + 'roadtrip.window.resume': 'Продовжити подорож', + 'roadtrip.window.pointHint': 'Тут передбачається нічна зупинка в дорозі.', + 'roadtrip.window.departure': 'Продовжити після решти відвідування о {time}', + 'roadtrip.window.showPoint': 'Показати на мапі', + 'roadtrip.window.incomplete': 'Для розрахунку щоденних перерв потрібні всі сполучні маршрути.', + 'roadtrip.window.conflict': + 'Фіксований час суперечить поїздці та перервам. Ручні значення збережено; автоматичне планування призупинено.', + 'roadtrip.window.tooLong': 'За цих налаштувань потрібно понад 365 додаткових днів. Збільште щоденний інтервал.', 'roadtrip.mode.roadtrip': 'Автоподорож', 'roadtrip.mode.days': 'Дні', 'roadtrip.mode.label': 'Вигляд плану', @@ -64,11 +118,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Платні дороги', 'roadtrip.avoid.motorway': 'Автомагістралі', 'roadtrip.avoid.ferry': 'Пороми', - 'roadtrip.avoid.hint': 'Це зниження ваги, а не заборона. Якщо в поїздки немає обходу, вона все одно пройде там, і день це покаже.', - 'roadtrip.avoid.unavailable': 'Недоступно: цей екземпляр використовує власний рушій, який не вміє виключати клас доріг.', + 'roadtrip.avoid.hint': + 'Це зниження ваги, а не заборона. Якщо в поїздки немає обходу, вона все одно пройде там, і день це покаже.', + 'roadtrip.avoid.unavailable': + 'Недоступно: цей екземпляр використовує власний рушій, який не вміє виключати клас доріг.', 'roadtrip.avoid.badge': '{count} в обхід', 'roadtrip.avoid.missed': '{classes} не обійти', - 'roadtrip.avoid.missedHint': 'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.', + 'roadtrip.avoid.missedHint': + 'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.', 'roadtrip.refuel.dry': 'Тут закінчиться пальне', 'roadtrip.refuel.after': 'через {distance}', 'roadtrip.limit.vehicleLabel': 'Чим ви їдете', @@ -110,11 +167,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': 'довжина {distance}', 'roadtrip.track.gap': 'за {distance} від цього дня', 'roadtrip.track.onDay': 'уздовж цього дня', - 'roadtrip.track.explain': 'Маршрут притягується до треку кількома проміжними точками. Наявні точки на цих ділянках буде замінено.', + 'roadtrip.track.explain': + 'Маршрут притягується до треку кількома проміжними точками. Наявні точки на цих ділянках буде замінено.', 'roadtrip.track.action': 'Іти за цим треком', 'roadtrip.track.working': 'Підганяємо маршрут до треку, коло {round}', 'roadtrip.track.done': 'Поставлено проміжних точок: {count}. Маршрут тримається в межах {distance} від треку.', - 'roadtrip.track.capped': 'Поставлено проміжних точок: {count}. У найширшому місці маршрут відходить від треку на {distance}.', + 'roadtrip.track.capped': + 'Поставлено проміжних точок: {count}. У найширшому місці маршрут відходить від треку на {distance}.', 'roadtrip.track.already': 'Маршрут уже йшов за цим треком.', 'roadtrip.track.errorRoute': 'Сервіс маршрутів не відповів.', 'roadtrip.track.errorSave': 'Не вдалося зберегти проміжні точки.', @@ -129,7 +188,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Дорогою ніщо не відповідає «{name}».', 'roadtrip.poi.noneMatchFilters': 'На маршруті немає нічого, що відповідає цим фільтрам.', 'roadtrip.poi.foundFiltered': '{count} з {total} дорогою', - 'roadtrip.poi.truncated': 'На {count} відрізках знайшлося більше, ніж уміщується в одну відповідь — звузьте коридор, щоб побачити решту.', + 'roadtrip.poi.truncated': + 'На {count} відрізках знайшлося більше, ніж уміщується в одну відповідь — звузьте коридор, щоб побачити решту.', 'roadtrip.poi.search': 'Шукати', 'roadtrip.poi.searching': 'Пошук {done} з {total}', 'roadtrip.poi.capped': 'Маршрут довгий — переглянуто лише перший відрізок.', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 250fde61e..62bcc7e8a 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -28,7 +28,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Власний сервер маршрутів', 'settings.routingBaseHint': 'Власний примірник OSRM. Порожньо — використовуються публічні сервери з обмеженням близько одного запиту на секунду: на день вистачає, для автоподорожі мало. Діє після перезапуску сервера.', 'settings.valhallaBase': 'Власний сервер Valhalla', - 'settings.valhallaBaseHint': 'Власний сервер Valhalla. До нього звертаються, коли ділянка має обійти платні дороги, автомагістралі або пороми — публічні сервери OSRM на це відповісти не можуть. Порожнє використовує публічний Valhalla від FOSSGIS, якщо вище не вказано власний маршрутизатор; тоді другий рушій не запитують узагалі. Діє після перезапуску сервера.', + 'settings.valhallaBaseHint': + 'За замовчуванням TREK використовує публічну Valhalla FOSSGIS для уникнення платних доріг, автомагістралей і поромів. Введіть тут URL власної Valhalla, щоб використовувати її замість публічної. Якщо налаштовано лише власний сервер маршрутизації, публічна Valhalla не використовується. Після введення власного URL перезапустіть сервер і оновіть сторінку.', 'settings.mapHint': 'URL-шаблон для тайлів карти', 'settings.mapProvider': 'Провайдер карти', 'settings.mapProviderHint': 'Застосовується до Trip Planner та Journey. Atlas завжди використовує Leaflet.', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 0dc5ac71b..f0eec0a96 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -1,6 +1,60 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': 'Tìm chỗ ở', + 'roadtrip.stay.portalHint': 'Kiểm tra điểm đến, ngày và khách trên cổng đặt chỗ. Tìm thủ công trên CHECK24.', + 'roadtrip.charging.available': 'còn trống', + 'roadtrip.charging.unknown': 'Không có dữ liệu đáng tin cậy', + 'roadtrip.charging.stale': 'Trạng thái đã cũ', + 'roadtrip.charging.prices': 'Biểu giá công bố', + 'roadtrip.charging.note': + 'Biểu giá công bố; có thể áp dụng thuế, điều kiện và phụ phí. Giá thẻ sạc của bạn có thể khác.', + 'roadtrip.charging.coverage': 'Dữ liệu mở; phạm vi phụ thuộc khu vực và nhà vận hành.', + 'roadtrip.import.title': 'Nhập tuyến đường Google Maps', + 'roadtrip.import.note': 'Các điểm dừng được thêm vào ngày đã chọn. TREK tính lại tuyến đường.', + 'roadtrip.import.unresolved': 'Không tìm thấy vị trí; điểm dừng này sẽ bị bỏ qua.', + 'roadtrip.import.day': 'Ngày', + 'roadtrip.hazards.show': 'Hiển thị vùng nguy hiểm', + 'roadtrip.hazards.current': 'Cảnh báo hiện tại', + 'roadtrip.hazards.note': 'Cảnh báo không có nghĩa là đường bị đóng. Tuyến đường không thay đổi.', + 'roadtrip.hazards.point': 'Chỉ có vị trí; chưa rõ vùng bị ảnh hưởng.', + 'roadtrip.hazards.loading': 'Đang tải cảnh báo…', + 'roadtrip.hazards.unavailable': 'Không khả dụng', + 'roadtrip.hazards.offline': 'Ngoại tuyến: không có cảnh báo hiện tại', + 'roadtrip.hazards.partial': 'Phạm vi hạn chế', + 'roadtrip.stops.section': 'Điểm dừng dịch vụ', + 'roadtrip.stops.inDays': 'Hiển thị cả trong Ngày', + 'roadtrip.stops.daysHint': + 'Áp dụng cho mọi điểm dừng dịch vụ của chuyến đi. Khi tắt, chúng chỉ xuất hiện trong Roadtrip. Bật để hiển thị lại trong Ngày.', + 'roadtrip.window.dragHint': + 'Khi trực tuyến, kéo điểm kết thúc ngày dọc tuyến đường hoặc đến một địa điểm; nhấp chuột phải để khôi phục kết thúc tự động.', + 'roadtrip.window.resetBoundaries': 'Khôi phục kết thúc ngày tự động', + 'roadtrip.window.endHere': 'Kết thúc ngày tại đây', + 'roadtrip.window.endHereHint': + 'Bạn có thể kết thúc ngày tại một điểm dừng trong chi tiết địa điểm khi chế độ Roadtrip và giờ di chuyển hằng ngày đang bật.', + 'roadtrip.window.endAt': 'Kết thúc ngày', + 'roadtrip.window.onRoute': 'Trên tuyến đường', + 'roadtrip.window.atStop': 'Tại địa điểm cuối', + 'roadtrip.window.routeHint': 'Tạm dừng trên tuyến đường khi đến giờ kết thúc.', + 'roadtrip.window.stopHint': 'Ở lại địa điểm cuối nếu không thể đến địa điểm tiếp theo trước giờ kết thúc ngày.', + 'roadtrip.window.legTooLong': + 'Một chặng lái xe vượt quá khung giờ hằng ngày. Thêm địa điểm dọc đường hoặc chọn tạm dừng trên tuyến đường.', + 'roadtrip.window.pointLabel': 'Kết thúc ngày {day} lúc {time}', + 'roadtrip.window.title': 'Giờ di chuyển hằng ngày', + 'roadtrip.window.start': 'Giờ bắt đầu ngày', + 'roadtrip.window.end': 'Giờ kết thúc ngày', + 'roadtrip.window.hint': + 'Đặt cả hai giờ để lên kế hoạch nghỉ mỗi ngày. Xóa một giờ để tắt. Giờ nhập thủ công được ưu tiên. Các ngày được nối tự động.', + 'roadtrip.window.invalid': 'Giờ kết thúc phải sau giờ bắt đầu.', + 'roadtrip.window.stop': 'Kết thúc ngày', + 'roadtrip.window.resume': 'Tiếp tục hành trình', + 'roadtrip.window.pointHint': 'Đây là điểm tạm dừng qua đêm dự kiến của hành trình.', + 'roadtrip.window.departure': 'Tiếp tục sau phần tham quan còn lại lúc {time}', + 'roadtrip.window.showPoint': 'Hiện trên bản đồ', + 'roadtrip.window.incomplete': 'Cần có tất cả tuyến đường nối để tính thời gian nghỉ hằng ngày.', + 'roadtrip.window.conflict': + 'Một giờ cố định xung đột với việc lái xe và nghỉ. Giờ thủ công được giữ nguyên; lập lịch tự động tạm dừng.', + 'roadtrip.window.tooLong': 'Các giờ này cần hơn 365 ngày bổ sung. Hãy chọn khoảng thời gian mỗi ngày dài hơn.', 'roadtrip.mode.roadtrip': 'Chuyến đi đường bộ', 'roadtrip.mode.days': 'Ngày', 'roadtrip.mode.label': 'Chế độ xem kế hoạch', @@ -64,11 +118,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.toll': 'Đường thu phí', 'roadtrip.avoid.motorway': 'Đường cao tốc', 'roadtrip.avoid.ferry': 'Phà', - 'roadtrip.avoid.hint': 'Đây là hạ trọng số, không phải cấm. Khi một chặng không có đường vòng, nó vẫn đi qua và ngày đó sẽ báo.', - 'roadtrip.avoid.unavailable': 'Không khả dụng: bản cài này dùng máy chủ định tuyến riêng, vốn không bỏ qua được một loại đường.', + 'roadtrip.avoid.hint': + 'Đây là hạ trọng số, không phải cấm. Khi một chặng không có đường vòng, nó vẫn đi qua và ngày đó sẽ báo.', + 'roadtrip.avoid.unavailable': + 'Không khả dụng: bản cài này dùng máy chủ định tuyến riêng, vốn không bỏ qua được một loại đường.', 'roadtrip.avoid.badge': 'tránh {count}', 'roadtrip.avoid.missed': '{classes} không tránh được', - 'roadtrip.avoid.missedHint': 'Ngày này không có tuyến đường vòng, nên chặng đi qua đó. Những ngày khác vẫn tránh những gì có thể.', + 'roadtrip.avoid.missedHint': + 'Ngày này không có tuyến đường vòng, nên chặng đi qua đó. Những ngày khác vẫn tránh những gì có thể.', 'roadtrip.refuel.dry': 'Hết xăng ở đây', 'roadtrip.refuel.after': 'sau {distance}', 'roadtrip.limit.vehicleLabel': 'Bạn lái xe gì', @@ -110,7 +167,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.track.length': 'dài {distance}', 'roadtrip.track.gap': 'cách ngày này {distance}', 'roadtrip.track.onDay': 'dọc theo ngày này', - 'roadtrip.track.explain': 'Hành trình được kéo về tuyến bằng một vài điểm trung gian. Các điểm hiện có trên những chặng đó sẽ bị thay thế.', + 'roadtrip.track.explain': + 'Hành trình được kéo về tuyến bằng một vài điểm trung gian. Các điểm hiện có trên những chặng đó sẽ bị thay thế.', 'roadtrip.track.action': 'Đi theo tuyến này', 'roadtrip.track.working': 'Đang khớp hành trình với tuyến, vòng {round}', 'roadtrip.track.done': 'Đã đặt {count} điểm trung gian. Hành trình bám tuyến trong phạm vi {distance}.', @@ -129,7 +187,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.noMatch': 'Không có gì trên đường khớp với “{name}”.', 'roadtrip.poi.noneMatchFilters': 'Không có gì trên đường phù hợp với các bộ lọc này.', 'roadtrip.poi.foundFiltered': '{count} trong {total} trên đường', - 'roadtrip.poi.truncated': 'Có {count} đoạn nhiều hơn mức một phản hồi chứa được — thu hẹp hành lang để xem phần còn lại.', + 'roadtrip.poi.truncated': + 'Có {count} đoạn nhiều hơn mức một phản hồi chứa được — thu hẹp hành lang để xem phần còn lại.', 'roadtrip.poi.search': 'Tìm kiếm', 'roadtrip.poi.searching': 'Đang tìm {done}/{total}', 'roadtrip.poi.capped': 'Lộ trình dài — chỉ đoạn đầu được tìm kiếm.', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index afa04b608..f3d2ad0d2 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': 'Máy chủ định tuyến riêng', 'settings.routingBaseHint': 'Máy chủ OSRM của riêng bạn. Để trống sẽ dùng máy chủ công cộng, cho phép khoảng một yêu cầu mỗi giây — đủ cho một ngày, chật cho chuyến đi dài. Có hiệu lực sau khi khởi động lại máy chủ.', 'settings.valhallaBase': 'Máy chủ Valhalla riêng', - 'settings.valhallaBaseHint': 'Máy chủ Valhalla riêng. Nó được hỏi khi một chặng cần tránh trạm thu phí, đường cao tốc hoặc phà — các máy chủ OSRM công cộng không trả lời được việc đó. Để trống sẽ dùng Valhalla công cộng của FOSSGIS, trừ khi ở trên đã ghi một máy chủ định tuyến riêng; khi đó không hỏi máy chủ thứ hai nào cả. Có hiệu lực sau khi khởi động lại máy chủ.', + 'settings.valhallaBaseHint': + 'Theo mặc định, TREK dùng Valhalla công cộng của FOSSGIS để tránh đường thu phí, đường cao tốc và phà. Nhập URL Valhalla riêng tại đây để dùng thay thế. Nếu chỉ cấu hình máy chủ định tuyến riêng, Valhalla công cộng sẽ không được dùng. Sau khi nhập URL riêng, hãy khởi động lại máy chủ và tải lại trang.', 'settings.mapHint': 'Mẫu URL cho ô bản đồ', 'settings.mapProvider': 'Nhà cung cấp bản đồ', 'settings.mapProviderHint': diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index ee5178316..90a4c6aa1 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -1,6 +1,54 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': '尋找住宿', + 'roadtrip.stay.portalHint': '請在平台核對目的地、日期和入住人數。CHECK24需手動搜尋。', + 'roadtrip.charging.available': '可用', + 'roadtrip.charging.unknown': '暫無可靠資料', + 'roadtrip.charging.stale': '狀態已過時', + 'roadtrip.charging.prices': '公布的費率', + 'roadtrip.charging.note': '公布的費率可能附帶稅費、條件和額外費用。您的充電卡價格可能不同。', + 'roadtrip.charging.coverage': '開放資料,涵蓋範圍因地區和營運商而異。', + 'roadtrip.import.title': '匯入 Google Maps 路線', + 'roadtrip.import.note': '停靠點將新增至所選日期。TREK 將重新計算路線。', + 'roadtrip.import.unresolved': '找不到位置;將略過此停靠點。', + 'roadtrip.import.day': '天', + 'roadtrip.hazards.show': '顯示危險區域', + 'roadtrip.hazards.current': '目前警報', + 'roadtrip.hazards.note': '警報不代表道路封閉。路線保持不變。', + 'roadtrip.hazards.point': '僅有事件位置,受影響區域未知。', + 'roadtrip.hazards.loading': '正在載入警報…', + 'roadtrip.hazards.unavailable': '無法使用', + 'roadtrip.hazards.offline': '離線:無法取得目前警報', + 'roadtrip.hazards.partial': '涵蓋範圍有限', + 'roadtrip.stops.section': '服務停靠點', + 'roadtrip.stops.inDays': '也顯示於每日行程', + 'roadtrip.stops.daysHint': + '適用於此旅程的所有服務停靠點。關閉後只會顯示在 Roadtrip 計畫中。開啟即可再次顯示於每日行程。', + 'roadtrip.window.dragHint': '連線時可沿路線拖動當天終點或將其拖到某個地點,按右鍵可恢復自動終點。', + 'roadtrip.window.resetBoundaries': '恢復自動每日終點', + 'roadtrip.window.endHere': '在此結束當天行程', + 'roadtrip.window.endHereHint': '啟用 Roadtrip 模式和每日出行時間後,可在地點詳情中將指定停留點設為當天行程終點。', + 'roadtrip.window.endAt': '一天的結束地點', + 'roadtrip.window.onRoute': '路線途中', + 'roadtrip.window.atStop': '最後一個地點', + 'roadtrip.window.routeHint': '到達結束時間時,在路線上暫停。', + 'roadtrip.window.stopHint': '若無法在當天結束前抵達下一個地點,就停留在最後一個地點。', + 'roadtrip.window.legTooLong': '某段車程超出每日時間範圍。請在途中新增地點,或選擇在路線途中暫停。', + 'roadtrip.window.pointLabel': '第 {day} 天於 {time} 結束', + 'roadtrip.window.title': '每日出行時間', + 'roadtrip.window.start': '一天開始時間', + 'roadtrip.window.end': '一天結束時間', + 'roadtrip.window.hint': '設定兩個時間以規劃每日休息。清空任一項即可關閉。手動時間優先,各天路線會自動連接。', + 'roadtrip.window.invalid': '結束時間必須晚於開始時間。', + 'roadtrip.window.stop': '當天行程結束', + 'roadtrip.window.resume': '繼續行程', + 'roadtrip.window.pointHint': '這裡是預計的夜間暫停地點。', + 'roadtrip.window.departure': '完成剩餘停留後於 {time} 繼續', + 'roadtrip.window.showPoint': '在地圖上顯示', + 'roadtrip.window.incomplete': '所有連接路線可用後才能計算每日休息。', + 'roadtrip.window.conflict': '固定時間與行車和休息安排衝突。手動時間保持不變,自動規劃已暫停。', + 'roadtrip.window.tooLong': '這些時間需要超過365個額外天數。請延長每日出行時段。', 'roadtrip.mode.roadtrip': '公路旅行', 'roadtrip.mode.days': '依日', 'roadtrip.mode.label': '計畫檢視', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 71c4a204b..6c2f6e5e4 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': '自建路徑規劃服務', 'settings.routingBaseHint': '你自己的 OSRM 實例。留空則使用公共伺服器,約每秒一個請求 — 單日夠用,長途自駕則吃緊。需重新啟動伺服器後生效。', 'settings.valhallaBase': '自建 Valhalla 執行個體', - 'settings.valhallaBaseHint': '自建的 Valhalla 執行個體。當某段路程需要避開收費站、高速公路或渡輪時會向它詢問,公開的 OSRM 伺服器無法回答這類問題。留空則使用 FOSSGIS 的公開 Valhalla;若上方已填寫自建路徑規劃服務,則完全不詢問第二個引擎。重新啟動伺服器後生效。', + 'settings.valhallaBaseHint': + 'TREK 預設使用 FOSSGIS 的公共 Valhalla 來避開收費道路、高速公路和渡輪。若要改用自己的 Valhalla,請在此輸入網址。如果只設定了自己的路線規劃伺服器,就不會使用公共 Valhalla。輸入自己的網址後,請重新啟動伺服器並重新載入頁面。', 'settings.mapHint': '地圖瓦片 URL 模板', 'settings.mapProvider': '地圖提供商', 'settings.mapProviderHint': '影響行程規劃和旅程地圖。Atlas 始終使用 Leaflet。', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index f5f99635f..1cc59fb0d 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -1,6 +1,54 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.stay.portals': '查找住宿', + 'roadtrip.stay.portalHint': '请在平台核对目的地、日期和入住人数。CHECK24需手动搜索。', + 'roadtrip.charging.available': '可用', + 'roadtrip.charging.unknown': '暂无可靠数据', + 'roadtrip.charging.stale': '状态已过时', + 'roadtrip.charging.prices': '公布的费率', + 'roadtrip.charging.note': '公布的费率可能附带税费、条件和额外费用。您的充电卡价格可能不同。', + 'roadtrip.charging.coverage': '开放数据,覆盖范围因地区和运营商而异。', + 'roadtrip.import.title': '导入 Google Maps 路线', + 'roadtrip.import.note': '停靠点将添加到所选日期。TREK 将重新计算路线。', + 'roadtrip.import.unresolved': '未找到位置;将跳过此停靠点。', + 'roadtrip.import.day': '天', + 'roadtrip.hazards.show': '显示危险区域', + 'roadtrip.hazards.current': '当前警报', + 'roadtrip.hazards.note': '警报不代表道路封闭。路线保持不变。', + 'roadtrip.hazards.point': '仅有事件位置,受影响区域未知。', + 'roadtrip.hazards.loading': '正在加载警报…', + 'roadtrip.hazards.unavailable': '不可用', + 'roadtrip.hazards.offline': '离线:无法获取当前警报', + 'roadtrip.hazards.partial': '覆盖有限', + 'roadtrip.stops.section': '服务停靠点', + 'roadtrip.stops.inDays': '也显示于每日行程', + 'roadtrip.stops.daysHint': + '适用于此旅程的所有服务停靠点。关闭后只会显示在 Roadtrip 计划中。开启即可再次显示于每日行程。', + 'roadtrip.window.dragHint': '在线时可沿路线拖动当天终点或将其拖到某个地点,右键点击可恢复自动终点。', + 'roadtrip.window.resetBoundaries': '恢复自动每日终点', + 'roadtrip.window.endHere': '在此结束当天行程', + 'roadtrip.window.endHereHint': '启用 Roadtrip 模式和每日出行时间后,可在地点详情中将指定停留点设为当天行程终点。', + 'roadtrip.window.endAt': '一天的结束地点', + 'roadtrip.window.onRoute': '路线途中', + 'roadtrip.window.atStop': '最后一个地点', + 'roadtrip.window.routeHint': '到达结束时间时,在路线上暂停。', + 'roadtrip.window.stopHint': '若无法在当天结束前抵达下一个地点,就停留在最后一个地点。', + 'roadtrip.window.legTooLong': '某段车程超出每日时间范围。请在途中添加地点,或选择在路线途中暂停。', + 'roadtrip.window.pointLabel': '第 {day} 天于 {time} 结束', + 'roadtrip.window.title': '每日出行时间', + 'roadtrip.window.start': '一天开始时间', + 'roadtrip.window.end': '一天结束时间', + 'roadtrip.window.hint': '设置两个时间以规划每日休息。清空任一项即可关闭。手动时间优先,各天路线会自动连接。', + 'roadtrip.window.invalid': '结束时间必须晚于开始时间。', + 'roadtrip.window.stop': '当天行程结束', + 'roadtrip.window.resume': '继续行程', + 'roadtrip.window.pointHint': '这里是预计的夜间暂停地点。', + 'roadtrip.window.departure': '完成剩余停留后于 {time} 继续', + 'roadtrip.window.showPoint': '在地图上显示', + 'roadtrip.window.incomplete': '所有连接路线可用后才能计算每日休息。', + 'roadtrip.window.conflict': '固定时间与行车和休息安排冲突。手动时间保持不变,自动规划已暂停。', + 'roadtrip.window.tooLong': '这些时间需要超过365个额外天数。请延长每日出行时段。', 'roadtrip.mode.roadtrip': '公路旅行', 'roadtrip.mode.days': '按天', 'roadtrip.mode.label': '计划视图', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index 4b1093e3b..a662db141 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -27,7 +27,8 @@ const settings: TranslationStrings = { 'settings.routingBase': '自建路径规划服务', 'settings.routingBaseHint': '你自己的 OSRM 实例。留空则使用公共服务器,约每秒一个请求 — 单日够用,长途自驾则吃紧。需重启服务器后生效。', 'settings.valhallaBase': '自建 Valhalla 实例', - 'settings.valhallaBaseHint': '自建的 Valhalla 实例。当某段路程需要避开收费站、高速公路或轮渡时会向它询问,公共 OSRM 服务器无法回答这类问题。留空则使用 FOSSGIS 的公共 Valhalla;若上方已填写自建路径规划服务,则完全不询问第二个引擎。重启服务器后生效。', + 'settings.valhallaBaseHint': + 'TREK 默认使用 FOSSGIS 的公共 Valhalla 来避开收费道路、高速公路和渡轮。若要改用自己的 Valhalla,请在此输入网址。如果只配置了自己的路线规划服务器,就不会使用公共 Valhalla。输入自己的网址后,请重启服务器并重新加载页面。', 'settings.mapHint': '地图瓦片 URL 模板', 'settings.mapProvider': '地图提供商', 'settings.mapProviderHint': '影响行程规划器和旅程地图。足迹始终使用 Leaflet。', diff --git a/shared/src/index.ts b/shared/src/index.ts index 36b0d7453..1e70a09c2 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -62,6 +62,7 @@ export * from './admin/admin.schema'; // Realtime WS event contract registry (event names + payload schemas) export * from './realtime/events.schema'; +export * from './roadtrip/day-boundary.schema'; // Sanitisation helpers — used by the client today, scoped here so the server // has them ready if rich-text input ever ships. @@ -75,5 +76,11 @@ export * from './i18n/languages'; export * from './plugin-permissions'; // Plugin settings contracts: the settings-field descriptor + admin instance-config wire shapes. export * from './plugins/plugins.schema'; +export * from './roadtrip/preferences.schema'; +export * from './roadtrip/hazards.schema'; +export * from './roadtrip/planning.schema'; +export * from './roadtrip/search.schema'; +export * from './roadtrip/google-import.schema'; +export * from './roadtrip/charging.schema'; export * from './vacay/school-holiday-catalog.schema'; diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index f01e90484..08f5bd5b2 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -29,23 +29,26 @@ export const hexColorSchema = z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6} * against — the escaping in the marker builders stops being the only thing * standing between the database and the DOM. */ -export const placeImageUrlSchema = z.string().max(2048).refine( - v => - v.startsWith('/uploads/') - || v.startsWith('/api/maps/place-photo/') - || /^data:image\/(png|jpe?g|webp|gif|avif);base64,/i.test(v) - || /^https:\/\//i.test(v), - { message: 'must be an uploaded path, a photo-proxy path, an inline image or an https URL' }, -); +export const placeImageUrlSchema = z + .string() + .max(2048) + .refine( + (v) => + v.startsWith('/uploads/') || + v.startsWith('/api/maps/place-photo/') || + /^data:image\/(png|jpe?g|webp|gif|avif);base64,/i.test(v) || + /^https:\/\//i.test(v), + { message: 'must be an uploaded path, a photo-proxy path, an inline image or an https URL' }, + ); /** * A place's homepage. It reaches window.open() on the client, where a * javascript: value would run in this origin rather than opening a page. */ -export const placeWebsiteSchema = z.string().max(500).refine( - v => /^https?:\/\//i.test(v), - { message: 'must be an http or https URL' }, -); +export const placeWebsiteSchema = z + .string() + .max(500) + .refine((v) => /^https?:\/\//i.test(v), { message: 'must be an http or https URL' }); /** * Embedded category as returned on a place — a trimmed projection of the @@ -91,7 +94,15 @@ export type PlaceRatingVote = z.infer; * to the column, where `isServiceStopType` then read it as false and the stop quietly * counted as a destination. */ -export const roadtripStopTypeSchema = z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']); +export const roadtripStopTypeSchema = z.enum([ + 'fuel', + 'charging', + 'rest_area', + 'campsite', + 'restaurant', + 'sights', + 'hotel', +]); export type RoadtripStopType = z.infer; /** diff --git a/shared/src/realtime/events.schema.spec.ts b/shared/src/realtime/events.schema.spec.ts index 378ef549b..7509572ab 100644 --- a/shared/src/realtime/events.schema.spec.ts +++ b/shared/src/realtime/events.schema.spec.ts @@ -15,6 +15,7 @@ import { describe, it, expect } from 'vitest'; * fixture map cannot silently shrink relative to the registry. */ const FIXTURES: Record> = { + 'roadtripPreferences:changed': { preferences: { roadtrip_day_start: '08:00' } }, 'place:created': { place: { id: 3, name: 'Louvre' } }, 'place:updated': { place: { id: 3, name: 'Louvre' } }, 'place:deleted': { placeId: 3 }, @@ -30,6 +31,9 @@ const FIXTURES: Record> = { 'day:reordered': { orderedIds: [2, 1] }, 'roadtripVia:changed': { dayId: 2, vias: [{ id: 9, lat: 53.5, lng: 9.9 }] }, 'roadtripTrack:changed': { dayId: 2, track: null }, + 'roadtripBoundary:changed': { + boundaries: [{ day_number: 1, from_assignment_id: 9, to_assignment_id: null, fraction: 1 }], + }, 'dayNote:created': { dayId: 2, note: { id: 5 } }, 'dayNote:updated': { dayId: 2, note: { id: 5 } }, 'dayNote:deleted': { noteId: 5, dayId: 2 }, @@ -140,12 +144,12 @@ const DRIFT_VARIANTS: Partial[]> }; describe('@trek/shared realtime event registry', () => { - it('WSEVT-REG-001: pins the authoritative inventory counts (71 trip + 32 user = 103)', () => { + it('WSEVT-REG-001: pins the authoritative inventory counts (73 trip + 32 user = 105)', () => { // 67th to 69th trip event: the three collab:link:* a shared link emits. // 70th and 71st: the road trip's vias and tracks, which used to be written silently. - expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(71); + expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(73); expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(32); - expect(TREK_WS_EVENT_NAMES).toHaveLength(103); + expect(TREK_WS_EVENT_NAMES).toHaveLength(105); }); it('WSEVT-REG-002: every name is domain:action shaped and outside the reserved plugin: namespace', () => { diff --git a/shared/src/realtime/events.schema.ts b/shared/src/realtime/events.schema.ts index fae358992..6ad9fa4f4 100644 --- a/shared/src/realtime/events.schema.ts +++ b/shared/src/realtime/events.schema.ts @@ -1,3 +1,6 @@ +import { roadtripDayBoundaryListSchema } from '../roadtrip/day-boundary.schema'; +import { roadtripPreferencesSchema } from '../roadtrip/preferences.schema'; + import { z } from 'zod'; /** @@ -110,6 +113,7 @@ export const TREK_WS_EVENTS = { scope: 'trip', payload: z.object({ dayId: id, track: entity.nullable() }), }, + 'roadtripBoundary:changed': { scope: 'trip', payload: roadtripDayBoundaryListSchema }, // ── Day notes ──────────────────────────────────────────────────────────── 'dayNote:created': { scope: 'trip', payload: z.object({ dayId: id, note: entity }) }, @@ -254,6 +258,8 @@ export const TREK_WS_EVENTS = { // ── Memories ───────────────────────────────────────────────────────────── 'memories:updated': { scope: 'trip', payload: z.object({ userId: id }) }, + 'roadtripPreferences:changed': { scope: 'trip', payload: z.object({ preferences: roadtripPreferencesSchema }) }, + // ── Notifications (user-scoped) ────────────────────────────────────────── 'notification:new': { scope: 'user', payload: z.object({ notification: entity }) }, 'notification:updated': { scope: 'user', payload: z.object({ notification: entity }) }, @@ -331,12 +337,14 @@ export const TREK_WS_EVENTS = { scope: 'user', payload: z.object({ journeyId: id, - peers: z.array(z.object({ - socketId: z.number(), - userId: id, - username: z.string(), - avatar: z.string().nullable().optional(), - })), + peers: z.array( + z.object({ + socketId: z.number(), + userId: id, + username: z.string(), + avatar: z.string().nullable().optional(), + }), + ), }), }, diff --git a/shared/src/roadtrip/accessSpur.ts b/shared/src/roadtrip/accessSpur.ts new file mode 100644 index 000000000..5bf212320 --- /dev/null +++ b/shared/src/roadtrip/accessSpur.ts @@ -0,0 +1,15 @@ +import type { SnappedWaypoint } from './planning-types'; + +export const ACCESS_SPUR_MIN_M = 30; + +export const ACCESS_LABEL_MIN_M = 250; + +export function spurFor(snapped: SnappedWaypoint | undefined | null): [[number, number], [number, number]] | null { + if (!snapped) return null; + if (!(snapped.meters >= ACCESS_SPUR_MIN_M)) return null; + return [snapped.asked, snapped.at]; +} + +export function spurWorthLabelling(meters: number | null | undefined): boolean { + return typeof meters === 'number' && meters >= ACCESS_LABEL_MIN_M; +} diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts new file mode 100644 index 000000000..03c1b5f68 --- /dev/null +++ b/shared/src/roadtrip/assemble.ts @@ -0,0 +1,295 @@ +import { spurFor } from './accessSpur'; +import { pointAtMeters } from './corridor'; +import type { RoadtripDayBoundary } from './day-boundary.schema'; +import { planDayWindow, type DayWindow } from './dayWindow'; +import { spillChains } from './nightSpill'; +import type { + PlanDay, + QuietDay, + RoadtripStop, + RoadtripDay, + RoadtripRoutes, + RoutedLeg, + RouteSegment, + SnappedWaypoint, + RouteAvoidClass, + DistanceUnit, +} from './planning-types'; +import { + computeSchedule, + parseClock, + deriveDriveWarnings, + refuelsRange, + isServiceStopType, + type DryPoint, + type DriveLimits, + type VehicleKind, +} from './roadtripModel'; +import { splitScheduledDays } from './splitScheduledDays'; + +const stopKey = (s: RoadtripStop): string => + `${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`; + +const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`; + +export function assembleRoadtrip({ + plan, + quietDays, + window, + distanceUnit, + allLegs, + snapByDay, + missedByDay, + loading, + limits, + vehicleKind, + connectDays, + boundaries, + labels, +}: { + plan: PlanDay[]; + quietDays: QuietDay[]; + window: DayWindow | null; + distanceUnit: DistanceUnit; + allLegs: Record; + snapByDay: Record>; + missedByDay: Record; + loading: boolean; + limits: DriveLimits; + vehicleKind: VehicleKind | null; + connectDays: boolean; + boundaries: RoadtripDayBoundary[]; + labels: { start: string; end: string }; +}): RoadtripRoutes { + const chains = spillChains(plan, quietDays, (a, b) => allLegs[legKey(a, b)]); + + const allSnaps: Record = {}; + for (const day of plan) Object.assign(allSnaps, snapByDay[day.dayId] ?? {}); + const storedLegFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)]; + const timed = window + ? planDayWindow( + [...plan, ...quietDays], + window, + storedLegFor, + distanceUnit, + { start: labels.start, end: labels.end }, + boundaries, + ) + : null; + const automaticSchedule = !!timed && timed.issue === null; + const displayChains = automaticSchedule + ? timed.chains + : timed?.issue + ? [...plan, ...quietDays] + .sort((a, b) => a.dayNumber - b.dayNumber) + .filter((d) => d.stops.length) + .map((d) => ({ + ...d, + spills: [], + schedule: computeSchedule( + d.stops.map((s) => ({ + anchor: s.time, + dwellMinutes: s.dwellMinutes, + departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440, + })), + d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration), + ), + })) + : splitScheduledDays(chains, [...plan, ...quietDays], storedLegFor); + const legFor = automaticSchedule ? timed.legFor : storedLegFor; + + const lines: [number, number][][] = []; + const lineDays: number[] = []; + const segments: RouteSegment[] = []; + const accessLines: RoadtripRoutes['accessLines'] = []; + + const out: RoadtripDay[] = []; + let carryKm: number | null = 0; + + let previousStop: RoadtripStop | undefined; + + let previousDayNumber: number | undefined; + for (const chain of displayChains) { + const routed = chain.stops.slice(0, -1).map((s, i) => legFor(s, chain.stops[i + 1]!)); + + const inboundAt = new Map(); + if (connectDays && !automaticSchedule) { + for (const spill of chain.spills) { + inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber }); + } + + 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); + } + + 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, index) => { + const snap = s.automaticNight ? undefined : allSnaps[stopKey(s)]; + const line = spurFor(snap); + if (line) accessLines.push({ line, meters: snap!.meters, stopKey: stopKey(s) }); + const entry = schedule.entries[index]; + const arrival = parseClock(entry?.arrival); + const dwellMinutes = + s.checkoutAt !== undefined && arrival !== null + ? Math.max(0, s.checkoutAt - ((chain.dayNumber + (entry?.dayOffset ?? 0)) * 1440 + arrival)) + : s.checkoutAt !== undefined + ? null + : s.dwellMinutes; + return { ...s, dwellMinutes, offRoadMeters: line ? snap!.meters : null }; + }); + + const drive = { + warnings: [] as ReturnType['warnings'], + day: null as ReturnType['day'], + }; + const dryPoints: (DryPoint & { lat: number; lng: number })[] = []; + const drivingLine: [number, number][] = []; + let drivenMeters = 0; + let drivingSeconds = 0; + for (let i = 0; i < stops.length; i++) { + const incoming = inboundAt.get(i); + if (incoming?.seg) { + const incomingDrive = deriveDriveWarnings([incoming.seg], [false, false], limits, carryKm); + carryKm = incomingDrive.carryKm; + drive.warnings.push(...incomingDrive.warnings.map((w) => ({ ...w, index: i }))); + for (const dry of incomingDrive.emptyAt) { + const at = pointAtMeters( + incoming.line.map(([lat, lng]) => ({ lat, lng })), + dry.drivenMeters, + ); + if (at) dryPoints.push({ ...dry, legIndex: -i - 1, inboundLine: incoming.line, lat: at.lat, lng: at.lng }); + } + if (!incoming.seg.mode || incoming.seg.mode === 'driving') drivingSeconds += incoming.seg.duration ?? 0; + } + const leg = routed[i]; + const outgoing = deriveDriveWarnings( + i < stops.length - 1 ? [leg?.seg] : [], + [refuelsRange(stops[i]!.stopType, vehicleKind), false], + limits, + carryKm, + [stops[i]!.fillPercent], + ); + carryKm = outgoing.carryKm; + drive.warnings.push(...outgoing.warnings.map((w) => ({ ...w, index: i + 1 }))); + for (const dry of outgoing.emptyAt) { + const at = pointAtMeters( + (leg?.line ?? []).map(([lat, lng]) => ({ lat, lng })), + dry.intoLegKm * 1000, + ); + if (at) + dryPoints.push({ + ...dry, + legIndex: i, + drivenMeters: drivenMeters + dry.intoLegKm * 1000, + lat: at.lat, + lng: at.lng, + }); + } + if (leg && (!leg.seg.mode || leg.seg.mode === 'driving')) { + drivingLine.push(...leg.line); + drivenMeters += leg.seg.distance ?? 0; + drivingSeconds += leg.seg.duration ?? 0; + } + } + const drivingMinutes = Math.round(drivingSeconds / 60); + if (limits.dayMinutes && drivingMinutes > limits.dayMinutes) { + drive.day = { code: 'dayDriving', minutes: drivingMinutes, limitMinutes: limits.dayMinutes }; + } + const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine; + out.push({ + automaticSchedule, + 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 || !!d.spills?.length || d.stops.some((s) => s.automaticNight)); + const originalStops = [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).flatMap((day) => day.stops); + const boundaryPath = originalStops.slice(0, -1).flatMap((from, position) => { + const to = originalStops[position + 1]!; + const leg = storedLegFor(from, to); + return leg && (!leg.seg.mode || leg.seg.mode === 'driving') && from.assignmentId > 0 && to.assignmentId > 0 + ? [{ from, to, position, line: leg.line }] + : []; + }); + return { + boundaryPath, + validateBoundaries: (next: RoadtripDayBoundary[]) => + window + ? planDayWindow( + [...plan, ...quietDays], + window, + storedLegFor, + distanceUnit, + { start: labels.start, end: labels.end }, + next, + ).issue + : 'conflict', + dayWindowIssue: timed?.issue ?? null, + 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), + + totalStops: drives.reduce( + (s, d) => s + d.stops.filter((st) => !st.automaticNight && !isServiceStopType(st.stopType)).length, + 0, + ), + + quietDays: out + .filter((d) => d.stops.length < 2 && !d.spills?.length && !d.stops.some((s) => s.automaticNight)) + .map((d) => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })), + loading, + }; +} diff --git a/shared/src/roadtrip/charging.schema.ts b/shared/src/roadtrip/charging.schema.ts new file mode 100644 index 000000000..df9369a56 --- /dev/null +++ b/shared/src/roadtrip/charging.schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const chargingInfoSchema = z.object({ + checkedAt: z.string(), + status: z.enum(['ok', 'unknown', 'ambiguous', 'unavailable']), + station: z.string().nullable(), + source: z.string().nullable(), + sourceUrl: z.string().nullable(), + license: z.string().nullable(), + updatedAt: z.string().nullable(), + stale: z.boolean(), + available: z.number().int().nonnegative().nullable(), + total: z.number().int().nonnegative(), + unknown: z.number().int().nonnegative(), + tariffs: z.array( + z.object({ + currency: z.string(), + updatedAt: z.string(), + components: z.array( + z.object({ + kind: z.string(), + price: z.number().nonnegative(), + taxIncluded: z.boolean(), + conditional: z.boolean(), + afterSeconds: z.number().nullable(), + }), + ), + }), + ), + pricesUnavailable: z.boolean(), +}); +export type ChargingInfo = z.infer; diff --git a/shared/src/roadtrip/continueSchedule.spec.ts b/shared/src/roadtrip/continueSchedule.spec.ts new file mode 100644 index 000000000..8adfc6f52 --- /dev/null +++ b/shared/src/roadtrip/continueSchedule.spec.ts @@ -0,0 +1,31 @@ +import { continueSchedule } from './continueSchedule'; +import type { RoadtripStop } from './planning-types'; +import type { Schedule } from './roadtripModel'; + +import { describe, expect, it } from 'vitest'; + +const stops = [0, 60, 60, 60].map((dwellMinutes) => ({ dwellMinutes }) as RoadtripStop); +const schedule = (): Schedule => ({ + entries: [ + { arrival: '18:15', departure: '18:15', dayOffset: 0, anchored: false }, + ...[1, 2, 3].map(() => ({ arrival: null, departure: null, dayOffset: 0, anchored: false })), + ], + warnings: [], +}); +describe('continuing after a moved day group', () => { + it('carries Munich departure into the original day stops and over midnight', () => { + const original = schedule(); + const completed = continueSchedule(stops, original, [237 * 60, 198 * 60, 206 * 60], 2); + expect(completed.entries.map((entry) => entry.arrival)).toEqual(['18:15', '22:12', '02:30', '06:56']); + expect(completed.entries[2]!.dayOffset).toBe(1); + expect(original.entries[1]!.arrival).toBeNull(); + }); + it('keeps manual times and stops at an unknown route duration', () => { + const original = schedule(); + original.entries[2] = { arrival: '07:00', departure: '08:00', dayOffset: 1, anchored: true }; + const completed = continueSchedule(stops, original, [undefined, 3600, 3600], 2); + expect(completed.entries[1]!.arrival).toBeNull(); + expect(completed.entries[2]).toEqual(original.entries[2]); + expect(completed.entries[3]!.arrival).toBe('09:00'); + }); +}); diff --git a/shared/src/roadtrip/continueSchedule.ts b/shared/src/roadtrip/continueSchedule.ts new file mode 100644 index 000000000..c9cda06ac --- /dev/null +++ b/shared/src/roadtrip/continueSchedule.ts @@ -0,0 +1,36 @@ +import type { RoadtripStop } from './planning-types'; +import { formatClock, parseClock, type Schedule } from './roadtripModel'; + +export function continueSchedule( + stops: RoadtripStop[], + schedule: Schedule, + legs: (number | undefined)[], + dayNumber: number, +): Schedule { + const entries = schedule.entries.map((entry) => ({ ...entry })); + const warnings = [...schedule.warnings]; + for (let i = 1; i < stops.length; i++) { + const entry = entries[i]!; + if (entry.arrival !== null) continue; + const previous = entries[i - 1]!; + const departure = parseClock(previous.departure); + const duration = legs[i - 1]; + if (departure === null || duration === undefined) continue; + const previousArrival = parseClock(previous.arrival); + const departureAt = + stops[i - 1]!.checkoutAt !== undefined + ? stops[i - 1]!.checkoutAt! - dayNumber * 1440 + : previous.dayOffset * 1440 + departure + (previousArrival !== null && departure < previousArrival ? 1440 : 0); + const arrivalAt = departureAt + Math.round(duration / 60); + const stop = stops[i]!; + const leaveAt = + stop.checkoutAt === undefined + ? arrivalAt + (stop.dwellMinutes ?? 0) + : Math.max(arrivalAt, stop.checkoutAt - dayNumber * 1440); + entry.arrival = formatClock(arrivalAt); + entry.departure = formatClock(leaveAt); + entry.dayOffset = Math.floor(arrivalAt / 1440); + if (entry.dayOffset > previous.dayOffset) warnings.push({ index: i, code: 'overnight' }); + } + return { entries, warnings }; +} diff --git a/client/src/components/Roadtrip/corridor.test.ts b/shared/src/roadtrip/corridor.spec.ts similarity index 52% rename from client/src/components/Roadtrip/corridor.test.ts rename to shared/src/roadtrip/corridor.spec.ts index c05633492..31adf93a6 100644 --- a/client/src/components/Roadtrip/corridor.test.ts +++ b/shared/src/roadtrip/corridor.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from 'vitest' import { boxAround, corridorTiles, @@ -9,131 +8,147 @@ import { sliceAtMeters, simplifyLine, type LatLng, -} from './corridor' +} from './corridor'; -const BERLIN: LatLng = { lat: 52.52, lng: 13.405 } -const DRESDEN: LatLng = { lat: 51.05, lng: 13.74 } -const PRAGUE: LatLng = { lat: 50.088, lng: 14.42 } +import { describe, it, expect } from 'vitest'; + +const BERLIN: LatLng = { lat: 52.52, lng: 13.405 }; +const DRESDEN: LatLng = { lat: 51.05, lng: 13.74 }; +const PRAGUE: LatLng = { lat: 50.088, lng: 14.42 }; describe('haversineKm', () => { it('measures a known distance', () => { // Berlin → Dresden is about 165 km as the crow flies. - expect(haversineKm(BERLIN, DRESDEN)).toBeGreaterThan(160) - expect(haversineKm(BERLIN, DRESDEN)).toBeLessThan(170) - }) + expect(haversineKm(BERLIN, DRESDEN)).toBeGreaterThan(160); + expect(haversineKm(BERLIN, DRESDEN)).toBeLessThan(170); + }); it('is zero for the same point', () => { - expect(haversineKm(BERLIN, BERLIN)).toBe(0) - }) -}) + expect(haversineKm(BERLIN, BERLIN)).toBe(0); + }); +}); describe('distanceToSegmentKm', () => { - const a: LatLng = { lat: 52, lng: 13 } - const b: LatLng = { lat: 52, lng: 14 } + const a: LatLng = { lat: 52, lng: 13 }; + const b: LatLng = { lat: 52, lng: 14 }; it('measures perpendicular distance to the line', () => { - const p: LatLng = { lat: 52.09, lng: 13.5 } + const p: LatLng = { lat: 52.09, lng: 13.5 }; // 0.09° of latitude is about 10 km, whatever the longitude does. - expect(distanceToSegmentKm(p, a, b)).toBeGreaterThan(9) - expect(distanceToSegmentKm(p, a, b)).toBeLessThan(11) - }) + expect(distanceToSegmentKm(p, a, b)).toBeGreaterThan(9); + expect(distanceToSegmentKm(p, a, b)).toBeLessThan(11); + }); it('measures to the end, not to the infinite line, for a point beyond it', () => { - const beyond: LatLng = { lat: 52, lng: 16 } - const d = distanceToSegmentKm(beyond, a, b) + const beyond: LatLng = { lat: 52, lng: 16 }; + const d = distanceToSegmentKm(beyond, a, b); // Two degrees of longitude past the end at 52° north — roughly 137 km. - expect(d).toBeGreaterThan(120) - expect(d).toBeLessThan(150) - }) + expect(d).toBeGreaterThan(120); + expect(d).toBeLessThan(150); + }); it('falls back to point distance for a zero-length segment', () => { - expect(distanceToSegmentKm(DRESDEN, a, a)).toBeCloseTo(haversineKm(DRESDEN, a), 5) - }) -}) + expect(distanceToSegmentKm(DRESDEN, a, a)).toBeCloseTo(haversineKm(DRESDEN, a), 5); + }); +}); describe('projectOntoRoute', () => { - const line = [BERLIN, DRESDEN, PRAGUE] + const line = [BERLIN, DRESDEN, PRAGUE]; it('reports how far off the route a point is', () => { - const onRoute = projectOntoRoute({ lat: 51.8, lng: 13.5 }, line) - expect(onRoute!.offRouteKm).toBeLessThan(15) - const wayOff = projectOntoRoute({ lat: 53.55, lng: 10.0 }, line) - expect(wayOff!.offRouteKm).toBeGreaterThan(150) - }) + const onRoute = projectOntoRoute({ lat: 51.8, lng: 13.5 }, line); + expect(onRoute!.offRouteKm).toBeLessThan(15); + const wayOff = projectOntoRoute({ lat: 53.55, lng: 10.0 }, line); + expect(wayOff!.offRouteKm).toBeGreaterThan(150); + }); it('orders points by how far into the drive they come', () => { - const early = projectOntoRoute({ lat: 52.3, lng: 13.45 }, line)! - const late = projectOntoRoute({ lat: 50.4, lng: 14.2 }, line)! - expect(early.alongKm).toBeLessThan(late.alongKm) - }) + const early = projectOntoRoute({ lat: 52.3, lng: 13.45 }, line)!; + const late = projectOntoRoute({ lat: 50.4, lng: 14.2 }, line)!; + expect(early.alongKm).toBeLessThan(late.alongKm); + }); it('tells two points on the same long straight apart', () => { // The petrol stations along one motorway segment must not all report the same // position in the drive, which is what taking the segment midpoint produced. - const straight = [{ lat: 52, lng: 13 }, { lat: 52, lng: 15 }] - const early = projectOntoRoute({ lat: 52.01, lng: 13.3 }, straight)! - const late = projectOntoRoute({ lat: 52.01, lng: 14.7 }, straight)! - expect(late.alongKm - early.alongKm).toBeGreaterThan(80) - }) + const straight = [ + { lat: 52, lng: 13 }, + { lat: 52, lng: 15 }, + ]; + const early = projectOntoRoute({ lat: 52.01, lng: 13.3 }, straight)!; + const late = projectOntoRoute({ lat: 52.01, lng: 14.7 }, straight)!; + expect(late.alongKm - early.alongKm).toBeGreaterThan(80); + }); it('returns nothing for a line that is not one', () => { - expect(projectOntoRoute(BERLIN, [])).toBeNull() - expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull() - }) -}) + expect(projectOntoRoute(BERLIN, [])).toBeNull(); + expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull(); + }); +}); describe('corridorTiles', () => { it('covers a short route with a single box', () => { - const tiles = corridorTiles([BERLIN, { lat: 52.4, lng: 13.5 }], 5) - expect(tiles).toHaveLength(1) - expect(tiles[0].south).toBeLessThan(52.4) - expect(tiles[0].north).toBeGreaterThan(52.52) - }) + const tiles = corridorTiles([BERLIN, { lat: 52.4, lng: 13.5 }], 5); + expect(tiles).toHaveLength(1); + expect(tiles[0]!.south).toBeLessThan(52.4); + expect(tiles[0]!.north).toBeGreaterThan(52.52); + }); it('splits a long route into boxes the server will not clamp', () => { - const tiles = corridorTiles([BERLIN, DRESDEN, PRAGUE], 10) - expect(tiles.length).toBeGreaterThan(1) + const tiles = corridorTiles([BERLIN, DRESDEN, PRAGUE], 10); + expect(tiles.length).toBeGreaterThan(1); for (const t of tiles) { - expect(t.north - t.south).toBeLessThanOrEqual(0.45) - expect(t.east - t.west).toBeLessThanOrEqual(0.45) + expect(t.north - t.south).toBeLessThanOrEqual(0.45); + expect(t.east - t.west).toBeLessThanOrEqual(0.45); } - }) + }); it('pads longitude more the further north it goes', () => { - const south = corridorTiles([{ lat: 0, lng: 0 }], 10)[0] - const north = corridorTiles([{ lat: 60, lng: 0 }], 10)[0] - expect(north.east - north.west).toBeGreaterThan(south.east - south.west) - }) + const south = corridorTiles([{ lat: 0, lng: 0 }], 10)[0]!; + const north = corridorTiles([{ lat: 60, lng: 0 }], 10)[0]!; + expect(north.east - north.west).toBeGreaterThan(south.east - south.west); + }); it('covers the middle of a long straight leg, not only its two ends', () => { // A motorway with no bend in it reduces to two points, 250 km apart. Tiling walks // point to point, so without splitting the step everything between them goes // unsearched while the search still reports itself complete. - const straight = [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }] - const tiles = corridorTiles(straight, 10) + const straight = [ + { lat: 53.55, lng: 9.99 }, + { lat: 52.52, lng: 13.4 }, + ]; + const tiles = corridorTiles(straight, 10); - expect(tiles.length).toBeGreaterThan(4) + expect(tiles.length).toBeGreaterThan(4); // A box somewhere over the middle of the drive, which is what was missing. - const midLat = 53.03 - const midLng = 11.7 - expect(tiles.some(t => t.south <= midLat && t.north >= midLat && t.west <= midLng && t.east >= midLng)).toBe(true) - }) + const midLat = 53.03; + const midLng = 11.7; + expect(tiles.some((t) => t.south <= midLat && t.north >= midLat && t.west <= midLng && t.east >= midLng)).toBe( + true, + ); + }); it('leaves no gap between consecutive boxes along a route', () => { - const tiles = corridorTiles([{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], 10) + const tiles = corridorTiles( + [ + { lat: 53.55, lng: 9.99 }, + { lat: 52.52, lng: 13.4 }, + ], + 10, + ); for (let i = 1; i < tiles.length; i++) { - const a = tiles[i - 1] - const b = tiles[i] + const a = tiles[i - 1]!; + const b = tiles[i]!; // Overlapping or touching in both axes: a hole here is a stretch nobody looks at. - expect(Math.min(a.north, b.north) - Math.max(a.south, b.south)).toBeGreaterThanOrEqual(0) - expect(Math.min(a.east, b.east) - Math.max(a.west, b.west)).toBeGreaterThanOrEqual(0) + expect(Math.min(a.north, b.north) - Math.max(a.south, b.south)).toBeGreaterThanOrEqual(0); + expect(Math.min(a.east, b.east) - Math.max(a.west, b.west)).toBeGreaterThanOrEqual(0); } - }) + }); it('has nothing to cover for an empty route', () => { - expect(corridorTiles([], 10)).toEqual([]) - }) -}) + expect(corridorTiles([], 10)).toEqual([]); + }); +}); describe('simplifyLine', () => { it('keeps the ends and drops what sits on the line between them', () => { @@ -141,123 +156,130 @@ describe('simplifyLine', () => { { lat: 52, lng: 13 }, { lat: 52, lng: 13.5 }, { lat: 52, lng: 14 }, - ] - expect(simplifyLine(straight, 1)).toEqual([straight[0], straight[2]]) - }) + ]; + expect(simplifyLine(straight, 1)).toEqual([straight[0]!, straight[2]!]); + }); it('keeps a point that actually bends the route', () => { const bent = [ { lat: 52, lng: 13 }, { lat: 52.5, lng: 13.5 }, { lat: 52, lng: 14 }, - ] - expect(simplifyLine(bent, 1)).toHaveLength(3) - }) + ]; + expect(simplifyLine(bent, 1)).toHaveLength(3); + }); it('leaves a two-point line alone', () => { - expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN]) - }) -}) + expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN]); + }); +}); describe('pointAtMeters', () => { // A straight run due east along one parallel, so the arithmetic is checkable by hand: // at 52° north a degree of longitude is about 68.5 km. - const WEST: LatLng = { lat: 52, lng: 13 } - const EAST: LatLng = { lat: 52, lng: 14 } - const LEG = haversineKm(WEST, EAST) * 1000 + const WEST: LatLng = { lat: 52, lng: 13 }; + const EAST: LatLng = { lat: 52, lng: 14 }; + const LEG = haversineKm(WEST, EAST) * 1000; it('lands halfway along a single segment', () => { - const mid = pointAtMeters([WEST, EAST], LEG / 2)! - expect(mid.lat).toBeCloseTo(52, 5) - expect(mid.lng).toBeCloseTo(13.5, 3) - }) + const mid = pointAtMeters([WEST, EAST], LEG / 2)!; + expect(mid.lat).toBeCloseTo(52, 5); + expect(mid.lng).toBeCloseTo(13.5, 3); + }); it('walks past the first segment into the second', () => { - const line = [WEST, EAST, { lat: 52, lng: 15 }] - const at = pointAtMeters(line, LEG * 1.5)! + const line = [WEST, EAST, { lat: 52, lng: 15 }]; + const at = pointAtMeters(line, LEG * 1.5)!; // One and a half segments in, so halfway through the second. - expect(at.lng).toBeGreaterThan(14) - expect(at.lng).toBeLessThan(15) - }) + expect(at.lng).toBeGreaterThan(14); + expect(at.lng).toBeLessThan(15); + }); it('returns the last point past the end rather than nothing', () => { // A tank that runs out after the day's final stop still ran out somewhere; the // caller decides what to do about that, so this must not answer null. - expect(pointAtMeters([WEST, EAST], LEG * 10)).toEqual(EAST) - }) + expect(pointAtMeters([WEST, EAST], LEG * 10)).toEqual(EAST); + }); it('answers the first point for zero, and nothing for an empty line', () => { - expect(pointAtMeters([WEST, EAST], 0)).toEqual(WEST) - expect(pointAtMeters([WEST, EAST], -5)).toEqual(WEST) - expect(pointAtMeters([], 100)).toBeNull() - expect(pointAtMeters([WEST], 100)).toEqual(WEST) - }) + expect(pointAtMeters([WEST, EAST], 0)).toEqual(WEST); + expect(pointAtMeters([WEST, EAST], -5)).toEqual(WEST); + expect(pointAtMeters([], 100)).toBeNull(); + expect(pointAtMeters([WEST], 100)).toEqual(WEST); + }); it('survives a repeated vertex without dividing by zero', () => { // A route line concatenated from several runs does contain these. - const at = pointAtMeters([WEST, WEST, EAST], LEG / 2)! - expect(at.lng).toBeCloseTo(13.5, 3) - }) -}) + const at = pointAtMeters([WEST, WEST, EAST], LEG / 2)!; + expect(at.lng).toBeCloseTo(13.5, 3); + }); +}); describe('boxAround', () => { it('reaches the asked radius on all four sides', () => { - const box = boxAround(BERLIN, 10) + const box = boxAround(BERLIN, 10); // North and south are the easy ones: a degree of latitude is constant. - expect(haversineKm(BERLIN, { lat: box.north, lng: BERLIN.lng })).toBeCloseTo(10, 0) - expect(haversineKm(BERLIN, { lat: BERLIN.lat, lng: box.east })).toBeCloseTo(10, 0) - }) + expect(haversineKm(BERLIN, { lat: box.north, lng: BERLIN.lng })).toBeCloseTo(10, 0); + expect(haversineKm(BERLIN, { lat: BERLIN.lat, lng: box.east })).toBeCloseTo(10, 0); + }); it('widens the longitude span the further north it sits', () => { // The same radius is more degrees of longitude in Tromsø than in Berlin. - const berlin = boxAround(BERLIN, 10) - const tromso = boxAround({ lat: 69.65, lng: 18.96 }, 10) - expect(tromso.east - tromso.west).toBeGreaterThan(berlin.east - berlin.west) - }) -}) + const berlin = boxAround(BERLIN, 10); + const tromso = boxAround({ lat: 69.65, lng: 18.96 }, 10); + expect(tromso.east - tromso.west).toBeGreaterThan(berlin.east - berlin.west); + }); +}); describe('sliceAtMeters', () => { // A degree of latitude is about 111 km, so this line is roughly 111 km long and every // figure below can be read off it without a calculator. - const line: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 53, lng: 13 }] + const line: LatLng[] = [ + { lat: 52, lng: 13 }, + { lat: 53, lng: 13 }, + ]; it('cuts a stretch out of the middle and interpolates both ends', () => { - const out = sliceAtMeters(line, 30_000, 60_000) - expect(out).toHaveLength(2) - expect(haversineKm(out[0], out[1]) * 1000).toBeGreaterThan(29_000) - expect(haversineKm(out[0], out[1]) * 1000).toBeLessThan(31_000) - }) + const out = sliceAtMeters(line, 30_000, 60_000); + expect(out).toHaveLength(2); + expect(haversineKm(out[0]!, out[1]!) * 1000).toBeGreaterThan(29_000); + expect(haversineKm(out[0]!, out[1]!) * 1000).toBeLessThan(31_000); + }); it('two adjacent slices meet exactly, so the parts rebuild the whole', () => { // The reason this exists: a day's line is its legs end to end, and a night drive // takes its own leg to the next card. A gap at the join would draw as a break in // the road. - const first = sliceAtMeters(line, 0, 40_000) - const second = sliceAtMeters(line, 40_000, 80_000) - const end = first[first.length - 1] - expect(haversineKm(end, second[0])).toBeLessThan(0.001) - }) + const first = sliceAtMeters(line, 0, 40_000); + const second = sliceAtMeters(line, 40_000, 80_000); + const end = first[first.length - 1]!; + expect(haversineKm(end, second[0]!)).toBeLessThan(0.001); + }); it('clamps to the line rather than running past its end', () => { - const out = sliceAtMeters(line, 0, 999_000_000) - expect(out[out.length - 1]).toEqual(line[line.length - 1]) - }) + const out = sliceAtMeters(line, 0, 999_000_000); + expect(out[out.length - 1]!).toEqual(line[line.length - 1]!); + }); it('is empty for a zero-length or backwards range', () => { - expect(sliceAtMeters(line, 50_000, 50_000)).toEqual([]) - expect(sliceAtMeters(line, 60_000, 30_000)).toEqual([]) - }) + expect(sliceAtMeters(line, 50_000, 50_000)).toEqual([]); + expect(sliceAtMeters(line, 60_000, 30_000)).toEqual([]); + }); it('is empty for a line that is not one', () => { - expect(sliceAtMeters([], 0, 100)).toEqual([]) - expect(sliceAtMeters([{ lat: 52, lng: 13 }], 0, 100)).toEqual([]) - }) + expect(sliceAtMeters([], 0, 100)).toEqual([]); + expect(sliceAtMeters([{ lat: 52, lng: 13 }], 0, 100)).toEqual([]); + }); it('survives a repeated vertex without producing NaN', () => { // A concatenated route line really does contain these, and dividing by a zero-length // segment is how a route full of NaN reaches the map. - const doubled: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 52, lng: 13 }, { lat: 53, lng: 13 }] - const out = sliceAtMeters(doubled, 10_000, 20_000) - expect(out.every(p => Number.isFinite(p.lat) && Number.isFinite(p.lng))).toBe(true) - }) -}) + const doubled: LatLng[] = [ + { lat: 52, lng: 13 }, + { lat: 52, lng: 13 }, + { lat: 53, lng: 13 }, + ]; + const out = sliceAtMeters(doubled, 10_000, 20_000); + expect(out.every((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng))).toBe(true); + }); +}); diff --git a/shared/src/roadtrip/corridor.ts b/shared/src/roadtrip/corridor.ts new file mode 100644 index 000000000..eb922c163 --- /dev/null +++ b/shared/src/roadtrip/corridor.ts @@ -0,0 +1,226 @@ +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; + +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))); +} + +export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number { + return projectOnSegment(p, a, b).distanceKm; +} + +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; + + if (lenSq < DEGENERATE_SEGMENT_KM2) return { distanceKm: haversineKm(p, a), t: 0 }; + + 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 { + offRouteKm: number; + + alongKm: number; +} + +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 }; +} + +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 => { + 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, + }; + }; + + 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; +} + +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]; +} + +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) { + 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]!; +} + +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; +} + +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; + + 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 : []; +} + +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, + }; +} diff --git a/shared/src/roadtrip/day-boundary.schema.ts b/shared/src/roadtrip/day-boundary.schema.ts new file mode 100644 index 000000000..8be316b0d --- /dev/null +++ b/shared/src/roadtrip/day-boundary.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const roadtripDayBoundarySchema = z + .object({ + day_number: z.number().int().min(1).max(366), + from_assignment_id: z.number().int().positive(), + to_assignment_id: z.number().int().positive().nullable(), + fraction: z.number().min(0).max(1), + }) + .refine((b) => b.to_assignment_id !== b.from_assignment_id, 'A boundary needs two different stops'); + +export const roadtripDayBoundaryListSchema = z.object({ + boundaries: z.array(roadtripDayBoundarySchema), +}); +export type RoadtripDayBoundary = z.infer; diff --git a/shared/src/roadtrip/dayWindow.spec.ts b/shared/src/roadtrip/dayWindow.spec.ts new file mode 100644 index 000000000..1cc422e76 --- /dev/null +++ b/shared/src/roadtrip/dayWindow.spec.ts @@ -0,0 +1,392 @@ +import { dayWindow, planDayWindow, roadtripInsertion } from './dayWindow'; +import type { RoadtripStop, RoutedLeg } from './planning-types'; + +import { describe, expect, it } from 'vitest'; + +const hours = { start: 480, end: 1080 }; +const labels = { start: 'Continue', end: 'End of day' }; +const stop = (id: number, patch: Partial = {}): RoadtripStop => ({ + assignmentId: id, + placeId: id, + ownerDayId: 1, + ownerIndex: id - 1, + lat: 0, + lng: id - 1, + name: `Stop ${id}`, + time: null, + dwellMinutes: 0, + legMode: null, + incomingLegMode: null, + stopType: null, + ...patch, +}); +const day = (number: number, stops: RoadtripStop[] = []) => ({ + dayId: number, + dayNumber: number, + date: `2026-09-${String(number + 10).padStart(2, '0')}`, + title: null, + stops, +}); +const leg = (minutes: number, from: RoadtripStop, to: RoadtripStop): RoutedLeg => ({ + line: [ + [from.lat, from.lng], + [to.lat, to.lng], + ], + vias: [], + seg: { + from: [from.lat, from.lng], + to: [to.lat, to.lng], + mid: [0, (from.lng + to.lng) / 2], + distance: minutes * 1000, + duration: minutes * 60, + mode: 'driving', + distanceText: '', + drivingText: '', + walkingText: '', + }, +}); +const calculate = (stops: RoadtripStop[], minutes: number[] = [720], extra = [day(2)]) => + planDayWindow( + [day(1, stops), ...extra], + hours, + (a, b) => leg(minutes[stops.indexOf(a)]! ?? 60, a, b), + 'metric', + labels, + ); + +describe('daily travel window', () => { + it('pulls an unpinned visit back into day one and ends after its full stay', () => { + const stops = [stop(1, { time: '07:00' }), stop(2, { dwellMinutes: 90 }), stop(3)]; + const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(720, a, b); + const automatic = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels); + expect(automatic.chains[0]!.stops).not.toContain(stops[1]!); + const manual = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels, [ + { day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }, + ]); + expect(manual.issue).toBeNull(); + expect(manual.chains[0]!.stops).toContain(stops[1]!); + expect(manual.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['07:00', '19:00', '20:30']); + expect(manual.chains[1]!.schedule.entries[0]!.arrival).toBe('08:00'); + expect(stops[0]!.time).toBe('07:00'); + }); + + it.each([0.25, 0.9])('moves the cut in either direction and preserves the whole drive (%s)', (fraction) => { + const stops = [stop(1), stop(2)]; + const manual = planDayWindow([day(1, stops)], hours, (a, b) => leg(720, a, b), 'metric', labels, [ + { day_number: 1, from_assignment_id: 1, to_assignment_id: 2, fraction }, + ]); + expect(manual.issue).toBeNull(); + const end = manual.chains[0]!.stops[1]!; + expect(end.lng).toBeCloseTo(fraction); + expect(end.automaticNight).toMatchObject({ manual: true, position: fraction }); + const start = manual.chains[1]!.stops[0]!; + expect(manual.legFor(stops[0]!, end)!.seg.duration + manual.legFor(start, stops[1]!)!.seg.duration).toBeCloseTo( + 720 * 60, + ); + }); + + it('does not pull a visit with a fixed time out of its stored day', () => { + const pinned = stop(2, { ownerDayId: 2, time: '12:00' }); + const manual = planDayWindow( + [day(1, [stop(1)]), day(2, [pinned])], + hours, + (a, b) => leg(60, a, b), + 'metric', + labels, + [{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }], + ); + expect(manual.issue).toBe('conflict'); + expect(pinned.time).toBe('12:00'); + }); + + it('rejects crossed or disconnected manual boundaries', () => { + const stops = [stop(1), stop(2), stop(3)]; + for (const boundaries of [ + [{ day_number: 1, from_assignment_id: 1, to_assignment_id: 3, fraction: 0.5 }], + [ + { day_number: 1, from_assignment_id: 3, to_assignment_id: null, fraction: 1 }, + { day_number: 2, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }, + ], + ]) + expect(planDayWindow([day(1, stops)], hours, (a, b) => leg(60, a, b), 'metric', labels, boundaries).issue).toBe( + 'conflict', + ); + }); + + it('mixes an explicit stop end with automatic cuts on later drives', () => { + const visited = stop(2, { endDay: true, dwellMinutes: 60 }); + const planned = calculate([stop(1), visited, stop(3)], [120, 720], []); + expect(planned.issue).toBeNull(); + expect(planned.chains.map((d) => d.schedule.entries.map((e) => e.arrival))).toEqual([ + ['08:00', '10:00', '11:00'], + ['08:00', '18:00'], + ['08:00', '10:00'], + ]); + expect(planned.chains[0]!.stops[2]!).toMatchObject({ lat: visited.lat, lng: visited.lng }); + expect(visited.endDay).toBe(true); + }); + + it('removing an explicit end restores the automatic schedule without adding a final night', () => { + const planned = calculate([stop(1), stop(2, { endDay: false }), stop(3, { endDay: true })], [120, 60], []); + expect(planned.chains).toHaveLength(1); + expect(planned.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['08:00', '10:00', '11:00']); + }); + + it('keeps manual times when an explicit end would move their day', () => { + const pinned = stop(2, { time: '12:00' }); + const planned = calculate([stop(1, { endDay: true }), pinned], [60], []); + expect(planned.issue).toBe('conflict'); + expect(pinned.time).toBe('12:00'); + }); + + it('honors an earlier manual arrival on the following stored day', () => { + const first = stop(1, { endDay: true }); + const next = stop(2, { time: '07:00', ownerDayId: 2 }); + const planned = planDayWindow([day(1, [first]), day(2, [next])], hours, (a, b) => leg(60, a, b), 'metric', labels); + expect(planned.issue).toBeNull(); + expect(planned.chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['06:00', '07:00']); + expect(next.time).toBe('07:00'); + }); + + it('requires two valid increasing HH:mm values', () => { + expect(dayWindow('08:00', '18:00')).toEqual(hours); + expect(dayWindow('00:00', '00:01')).toEqual({ start: 0, end: 1 }); + for (const pair of [ + [null, '18:00'], + ['08:00', ''], + ['8:00', '18:00'], + ['24:00', '25:00'], + ['18:00', '08:00'], + ['08:00', '08:00'], + [800, 1800], + ]) { + expect(dayWindow(...(pair as [unknown, unknown]))).toBeNull(); + } + }); + + it('splits the drive at day end and resumes at the same point next morning', () => { + const first = stop(1); + const last = stop(2, { lng: 12 }); + const planned = calculate([first, last]); + expect(planned.issue).toBeNull(); + const today = planned.chains[0]!; + const tomorrow = planned.chains[1]!; + const end = today.stops[1]!; + const start = tomorrow.stops[0]!; + expect(today.schedule.entries[0]!.arrival).toBe('08:00'); + expect(today.schedule.entries[1]!.arrival).toBe('18:00'); + expect(end.lng).toBeCloseTo(10); + expect([start.lat, start.lng]).toEqual([end.lat, end.lng]); + expect(tomorrow.schedule.entries.map((e) => e.arrival)).toEqual(['08:00', '10:00']); + expect(tomorrow.stops[1]!).toBe(last); + expect(tomorrow.spills[0]!).toMatchObject({ automatic: true, fromDayNumber: 1, at: 1 }); + const before = planned.legFor(first, end)!; + const after = planned.legFor(start, last)!; + expect(before.seg.duration + after.seg.duration).toBe(720 * 60); + expect(before.seg.distance + after.seg.distance).toBe(720000); + expect(before.line[before.line.length - 1]!).toEqual(after.line[0]!); + expect(first.time).toBeNull(); + expect(last.ownerDayId).toBe(1); + }); + + it('ends at the last visited place after its stay when the next drive will not fit', () => { + const first = stop(1); + const visited = stop(2, { dwellMinutes: 60 }); + const next = stop(3); + const route = [first, visited, next]; + const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(a === first ? 480 : 120, a, b); + const planned = planDayWindow([day(1, route)], { ...hours, endMode: 'stop' }, lookup, 'metric', labels); + expect(planned.issue).toBeNull(); + expect(planned.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['08:00', '16:00', '17:00']); + expect(planned.chains[0]!.stops[2]!).toMatchObject({ lat: visited.lat, lng: visited.lng }); + expect(planned.chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['08:00', '10:00']); + expect(planned.legFor(planned.chains[1]!.stops[0]!, next)?.seg.duration).toBe(7200); + expect(visited.dwellMinutes).toBe(60); + expect(next.ownerDayId).toBe(1); + const alongRoute = planDayWindow([day(1, route)], hours, lookup, 'metric', labels); + expect(alongRoute.chains[0]!.schedule.entries[2]!.arrival).toBe('18:00'); + expect(alongRoute.chains[1]!.schedule.entries[1]!.arrival).toBe('09:00'); + }); + + it('reaches a last place exactly at the cutoff and still respects manual times', () => { + const first = stop(1, { time: '07:00' }); + const next = stop(2); + const calculateAtStop = (minutes: number) => + planDayWindow( + [day(1, [first, next])], + { ...hours, endMode: 'stop' }, + (a, b) => leg(minutes, a, b), + 'metric', + labels, + ); + expect(calculateAtStop(660).chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['07:00', '18:00']); + next.time = '19:00'; + expect(calculateAtStop(720).chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['07:00', '19:00']); + expect(first.time).toBe('07:00'); + }); + + it('reports an indivisible drive that cannot fit rather than adding endless empty days', () => { + const planned = planDayWindow( + [day(1, [stop(1), stop(2)])], + { ...hours, endMode: 'stop' }, + (a, b) => leg(720, a, b), + 'metric', + labels, + ); + expect(planned.issue).toBe('legTooLong'); + expect(planned.chains).toEqual([]); + expect(dayWindow('08:00', '18:00', 'stop')).toEqual({ ...hours, endMode: 'stop' }); + expect(dayWindow('08:00', '18:00', 'unknown')).toEqual(hours); + }); + + it('starts at a manually pinned 07:00 despite an automatic 08:00 start', () => { + const first = stop(1, { time: '07:00' }); + const planned = calculate([first, stop(2, { lng: 12 })]); + expect(planned.chains[0]!.schedule.entries[0]!).toMatchObject({ arrival: '07:00', anchored: true }); + expect(planned.chains[0]!.stops[1]!.lng).toBeCloseTo(11); + expect(planned.chains[1]!.schedule.entries[1]!.arrival).toBe('09:00'); + expect(first.time).toBe('07:00'); + }); + + it('accounts for stays and recalculates the pause position without mutating places', () => { + const first = stop(1, { dwellMinutes: 120 }); + const destination = stop(2, { lng: 12 }); + const planned = calculate([first, destination]); + expect(planned.chains[0]!.stops[1]!.lng).toBeCloseTo(8); + expect(planned.chains[1]!.schedule.entries[1]!.arrival).toBe('12:00'); + const shortened = calculate([{ ...first, dwellMinutes: 0 }, destination]); + expect(shortened.chains[0]!.stops[1]!.lng).toBeCloseTo(10); + const shortDrive = calculate([first, destination], [120]); + expect(shortDrive.chains.flatMap((d) => d.stops).some((s) => s.automaticNight)).toBe(false); + expect(first.dwellMinutes).toBe(120); + }); + + it('does not move an arrival exactly at day end to tomorrow', () => { + const last = stop(2); + const planned = calculate([stop(1), last], [600]); + expect(planned.chains[0]!.stops).toHaveLength(2); + expect(planned.chains[0]!.schedule.entries[1]!.arrival).toBe('18:00'); + expect(planned.chains[1]!.stops).toEqual([]); + }); + + it('continues across multiple nights and creates preview dates beyond the trip', () => { + const planned = calculate([stop(1), stop(2, { lng: 30 })], [1800], []); + expect(planned.chains.map((d) => d.date)).toEqual(['2026-09-11', '2026-09-12', '2026-09-13']); + expect(planned.chains[2]!.schedule.entries[1]!.arrival).toBe('18:00'); + expect(planned.chains[2]!.dayId).toBeLessThan(0); + expect(planned.chains.flatMap((d) => d.stops).filter((s) => !s.automaticNight)).toHaveLength(2); + }); + + it('keeps fixed appointments and reports an impossible schedule without partial relocation', () => { + const fixed = stop(2, { time: '10:00' }); + const impossible = calculate([stop(1), fixed], [180]); + expect(impossible.issue).toBe('conflict'); + expect(impossible.chains).toEqual([]); + expect(fixed.time).toBe('10:00'); + const possible = calculate([stop(1), fixed], [60]); + expect(possible.chains[0]!.schedule.entries[1]!).toMatchObject({ arrival: '10:00', anchored: true }); + }); + + it('allows a manual late appointment to override the automatic end', () => { + const fixed = stop(2, { time: '19:00' }); + const planned = calculate([stop(1), fixed], [660]); + expect(planned.issue).toBeNull(); + expect(planned.chains[0]!.schedule.entries[1]!.arrival).toBe('19:00'); + expect(planned.chains[0]!.stops).toHaveLength(2); + }); + + it('keeps a pinned next-day start at 07:00 and leaves the pause point early enough', () => { + const a = stop(1); + const b = stop(2, { ownerDayId: 2, ownerIndex: 0, time: '07:00' }); + const planned = planDayWindow([day(1, [a]), day(2, [b])], hours, (from, to) => leg(60, from, to), 'metric', labels); + expect(planned.issue).toBeNull(); + expect(planned.chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['06:00', '07:00']); + }); + + it('does not silently move a fixed appointment onto a later date after a previous spill', () => { + const planned = calculate([stop(1), stop(2), stop(3, { time: '17:00' })], [720, 60]); + expect(planned.issue).toBe('conflict'); + }); + + it('pauses a long visit at its location and carries the remaining stay into tomorrow', () => { + const first = stop(1, { dwellMinutes: 660 }); + const planned = calculate([first, stop(2)], [60]); + expect(planned.chains[0]!.stops[1]!).toMatchObject({ lat: first.lat, lng: first.lng }); + expect(planned.chains[0]!.schedule.entries[0]!.departure).toBe('18:00'); + expect(planned.chains[1]!.schedule.entries[0]!.departure).toBe('09:00'); + expect(planned.chains[1]!.schedule.entries[1]!.arrival).toBe('10:00'); + expect(first.dwellMinutes).toBe(660); + }); + + it('does not place a night pause halfway through a ferry crossing', () => { + const a = stop(1); + const b = stop(2); + const ferry = leg(720, a, b); + ferry.seg.mode = 'ferry'; + const planned = planDayWindow([day(1, [a, b])], hours, () => ferry, 'metric', labels); + expect(planned.chains[0]!.stops).toHaveLength(2); + expect(planned.chains[0]!.schedule.entries[1]!.arrival).toBe('20:00'); + }); + + it('does not invent times or points for missing routes', () => { + const planned = planDayWindow([day(1, [stop(1), stop(2)])], hours, () => undefined, 'metric', labels); + expect(planned.issue).toBe('incomplete'); + expect(planned.chains).toEqual([]); + expect(planDayWindow([], hours, () => undefined, 'metric', labels).chains).toEqual([]); + }); + + it('bounds extreme driving and stay durations', () => { + expect(calculate([stop(1), stop(2)], [600 * 367]).issue).toBe('tooLong'); + expect(calculate([stop(1, { dwellMinutes: 600 * 367 }), stop(2)], [1]).issue).toBe('tooLong'); + }); + + it('maps insertions on generated days back to real stored stops', () => { + const planned = calculate([stop(1), stop(2)]); + expect(roadtripInsertion(planned.chains[1]!, 0)).toEqual({ dayId: 1, position: 1 }); + expect(roadtripInsertion(planned.chains[1]!, 1)).toEqual({ dayId: 1, position: 1 }); + expect(roadtripInsertion({ stops: [] }, 0)).toBeNull(); + expect(roadtripInsertion(planned.chains[1]!, 2)).toEqual({ dayId: 1, position: 2 }); + }); + + it('rounds fractional route minutes across the hour boundary', () => { + const planned = calculate([stop(1), stop(2)], [59.8]); + expect(planned.chains[0]!.schedule.entries[1]!.arrival).toBe('09:00'); + }); + + it('keeps a zero-distance timed leg at its location when split', () => { + const a = stop(1); + const b = stop(2, { lng: a.lng }); + const timed = leg(720, a, b); + timed.seg.distance = 0; + timed.line = []; + const planned = planDayWindow([day(1, [a, b])], hours, () => timed, 'metric', labels); + expect(planned.issue).toBeNull(); + expect(planned.chains[1]!.stops[0]!).toMatchObject({ lat: a.lat, lng: a.lng }); + expect(planned.chains[1]!.schedule.entries[1]!.arrival).toBe('10:00'); + }); +}); + +describe('booked checkout', () => { + it('waits at the hotel until noon on the checkout day', () => { + const hotel = stop(2, { checkoutAt: 2 * 1440 + 720, dwellMinutes: 30 }); + const stops = [stop(1), hotel, stop(3)]; + const plan = calculate(stops, [60, 60]); + expect(plan.issue).toBeNull(); + expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:00'); + expect(plan.chains[0]!.schedule.entries[1]!.departure).toBe('12:00'); + expect(plan.chains[1]!.schedule.entries[0]!.arrival).toBe('12:00'); + expect(plan.chains[1]!.schedule.entries[1]!.arrival).toBe('13:00'); + }); + it('honours an early checkout and multiple nights', () => { + const stops = [stop(1, { checkoutAt: 3 * 1440 + 420 }), stop(2)]; + const plan = calculate(stops, [60], [day(2), day(3)]); + expect(plan.issue).toBeNull(); + expect(plan.chains[2]!.schedule.entries[0]!.arrival).toBe('07:00'); + expect(plan.chains[2]!.schedule.entries[1]!.arrival).toBe('08:00'); + }); + it('does not move time backwards when arrival is after checkout', () => { + const stops = [stop(1, { time: '13:00', checkoutAt: 1440 + 720 }), stop(2)]; + const plan = calculate(stops, [60]); + expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('14:00'); + }); +}); diff --git a/shared/src/roadtrip/dayWindow.ts b/shared/src/roadtrip/dayWindow.ts new file mode 100644 index 000000000..046ad25b3 --- /dev/null +++ b/shared/src/roadtrip/dayWindow.ts @@ -0,0 +1,355 @@ +import { lineMetres, projectOntoRoute, sliceAtMeters } from './corridor'; +import type { RoadtripDayBoundary } from './day-boundary.schema'; +import type { SpillChain } from './nightSpill'; +import type { RoadtripDay, RoadtripStop, RoutedLeg } from './planning-types'; +import type { DistanceUnit } from './planning-types'; +import { formatClock, parseClock } from './roadtripModel'; +import { formatDurationShort } from './roadtripModel'; +import { formatDistance } from './units'; + +export interface DayWindow { + start: number; + end: number; + endMode?: 'route' | 'stop'; +} + +export interface AutomaticNight { + phase: 'start' | 'end'; + fromDayNumber: number; + position?: number; + manual?: boolean; +} + +type PlanDay = Pick; +type LegLookup = (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined; + +export function dayWindow(start: unknown, end: unknown, endMode?: unknown): DayWindow | null { + const minutes = (clock: unknown): number | null => { + if (typeof clock !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(clock)) return null; + return Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3)); + }; + const from = minutes(start); + const to = minutes(end); + return from !== null && to !== null && to > from + ? { start: from, end: to, ...(endMode === 'stop' ? ({ endMode } as const) : {}) } + : null; +} + +function portion(leg: RoutedLeg, from: number, to: number, unit: DistanceUnit): RoutedLeg { + if (from === 0 && to === 1) return leg; + const coordinates = leg.line.length ? leg.line : [leg.seg.from, leg.seg.to]; + const spine = coordinates.map(([lat, lng]) => ({ lat, lng })); + const metres = lineMetres(spine); + const line = + metres === 0 + ? coordinates + : sliceAtMeters(spine, from * metres, to * metres).map((p) => [p.lat, p.lng] as [number, number]); + const distance = leg.seg.distance * (to - from); + const duration = leg.seg.duration * (to - from); + const durationText = formatDurationShort(duration); + const distanceText = formatDistance(distance / 1000, unit); + return { + seg: { + ...leg.seg, + distance, + duration, + distanceText, + durationText, + drivingText: `${distanceText} · ${durationText}`, + walkingText: `${distanceText} · ${durationText}`, + from: line[0]!, + to: line[line.length - 1]!, + mid: line[Math.floor(line.length / 2)]!, + }, + line, + vias: leg.vias.filter((v) => { + const along = (projectOntoRoute(v, spine)?.alongKm ?? 0) * 1000; + return along >= from * metres && (to === 1 ? along <= metres : along < to * metres); + }), + }; +} + +function stationary(stop: RoadtripStop): RoutedLeg { + const at: [number, number] = [stop.lat, stop.lng]; + return { + line: [], + vias: [], + seg: { + from: at, + to: at, + mid: at, + distance: 0, + duration: 0, + mode: 'driving', + walkingText: '', + drivingText: '', + distanceText: '', + }, + }; +} + +export interface WindowPlan { + chains: SpillChain[]; + legFor: LegLookup; + issue: 'incomplete' | 'conflict' | 'tooLong' | 'legTooLong' | null; +} + +export function planDayWindow( + days: PlanDay[], + window: DayWindow, + lookup: LegLookup, + unit: DistanceUnit, + labels: { start: string; end: string }, + boundaries: RoadtripDayBoundary[] = [], +): WindowPlan { + const ordered = [...days].sort((a, b) => a.dayNumber - b.dayNumber); + const stops = ordered.flatMap((day) => day.stops.map((stop) => ({ stop, day }))); + const failed = (issue: WindowPlan['issue']): WindowPlan => ({ chains: [], legFor: lookup, issue }); + if (!stops.length) return { chains: [], legFor: lookup, issue: null }; + const positions = new Map(stops.map(({ stop }, i) => [stop.assignmentId, i])); + const targets = new Map(); + let lastTarget = -1; + for (const boundary of [...boundaries].sort((a, b) => a.day_number - b.day_number)) { + const from = positions.get(boundary.from_assignment_id); + const to = boundary.to_assignment_id === null ? null : positions.get(boundary.to_assignment_id); + if (from === undefined || (to !== null && to !== from + 1)) return failed('conflict'); + const target = from + (to === null ? 0 : boundary.fraction); + if (target <= lastTarget) return failed('conflict'); + targets.set(boundary.day_number, target); + lastTarget = target; + } + const legs = stops.slice(0, -1).map((s, i) => lookup(s.stop, stops[i + 1]!.stop)); + if ( + legs.some( + (l) => !l || !Number.isFinite(l.seg.duration) || l.seg.duration < 0 || (l.line.length < 2 && l.seg.distance > 0), + ) + ) { + return failed('incomplete'); + } + + const chains = new Map(); + const pieces = new Map>(); + const firstDay = stops[0]!.day.dayNumber; + let number = firstDay; + let clock = window.start; + let issue: WindowPlan['issue'] = null; + let position = 0; + + const chainAt = (n: number): SpillChain => { + const existing = chains.get(n); + if (existing) return existing; + const stored = ordered.find((d) => d.dayNumber === n); + const dated = ordered.find((d) => d.date); + let date = stored?.date ?? null; + if (!stored && dated?.date) { + const dayDate = new Date(`${dated.date}T12:00:00Z`); + dayDate.setUTCDate(dayDate.getUTCDate() + n - dated.dayNumber); + date = dayDate.toISOString().slice(0, 10); + } + const chain: SpillChain = { + dayId: stored?.dayId ?? -1000000000 - n, + dayNumber: n, + date, + title: stored?.title ?? null, + stops: [], + schedule: { entries: [], warnings: [] }, + spills: [], + }; + chains.set(n, chain); + return chain; + }; + const putLeg = (from: RoadtripStop, to: RoadtripStop, leg: RoutedLeg) => { + if (!pieces.has(from)) pieces.set(from, new Map()); + pieces.get(from)!.set(to, leg); + }; + const append = (stop: RoadtripStop, arrival: number, departure = arrival) => { + const chain = chainAt(number); + chain.stops.push(stop); + chain.schedule.entries.push({ + arrival: formatClock(Math.round(arrival)), + departure: formatClock(Math.round(departure)), + anchored: !stop.automaticNight && stop.time !== null, + dayOffset: Math.floor(arrival / 1440), + }); + }; + const night = (at: RoadtripStop, final = false): RoadtripStop => { + const chain = chainAt(number); + const last = chain.stops[chain.stops.length - 1]!; + const end: RoadtripStop = { + ...at, + assignmentId: -2000000000 - number * 2, + name: labels.end, + time: null, + dwellMinutes: 0, + checkoutAt: undefined, + stopType: null, + fillPercent: null, + automaticNight: { phase: 'end', fromDayNumber: number, position, manual: targets.has(number) }, + }; + append(end, clock); + if (last) putLeg(last, end, stationary(at)); + if (final) return end; + number += 1; + if (number - firstDay > 365) issue = 'tooLong'; + clock = window.start; + const start: RoadtripStop = { + ...end, + assignmentId: end.assignmentId - 1, + name: labels.start, + automaticNight: { ...end.automaticNight!, phase: 'start' }, + }; + append(start, clock); + return start; + }; + + let previous: RoadtripStop | undefined; + for (let i = 0; i < stops.length; i++) { + const { stop, day } = stops[i]!; + const pin = parseClock(stop.time); + if (previous && number < day.dayNumber && !targets.has(number)) { + if (window.endMode !== 'stop') clock = Math.max(clock, window.end); + previous = night(previous); + while (number < day.dayNumber && !issue) { + clock = window.end; + previous = night(previous); + } + } else if (!previous) { + number = day.dayNumber; + clock = pin ?? window.start; + } + if (issue) return failed(issue); + if (pin !== null && day.dayNumber !== number) return failed('conflict'); + + if (previous) { + const leg = legs[i - 1]!; + const minutes = leg.seg.duration / 60; + if (pin !== null && chainAt(number).stops.every((s) => s.automaticNight)) { + clock = Math.min(clock, pin - minutes); + if (clock < 0) return failed('conflict'); + const startEntry = chainAt(number).schedule.entries[0]!; + startEntry.arrival = formatClock(Math.round(clock)); + startEntry.departure = formatClock(Math.round(clock)); + } + if (pin !== null && clock + minutes > pin + 1) return failed('conflict'); + + let fraction = 0; + const split = pin === null && (leg.seg.mode === 'driving' || leg.seg.mode === undefined); + if (split && !targets.has(number) && window.endMode === 'stop' && clock + minutes > window.end + 0.000001) { + if (minutes > window.end - window.start) return failed('legTooLong'); + previous = night(previous); + if (issue) return failed(issue); + } + while (fraction < 1) { + const target = targets.get(number); + if (target !== undefined && target < i - 1 + fraction - 0.000001) return failed('conflict'); + const manualCut = target !== undefined && target < i && target >= i - 1 + fraction; + if (!manualCut && (target !== undefined || !split || clock + minutes * (1 - fraction) <= window.end + 0.000001)) + break; + const available = manualCut ? minutes * (target - (i - 1 + fraction)) : Math.max(0, window.end - clock); + if (available === 0) { + previous = night(previous); + } else { + const until = Math.min(1, fraction + available / minutes); + const part = portion(leg, fraction, until, unit); + const at = part.line[part.line.length - 1]!; + const point = { ...previous, lat: at[0], lng: at[1] }; + clock += available; + position = i - 1 + until; + const before = previous; + previous = night(point); + const previousStops = chainAt(number - 1).stops; + const ending = previousStops[previousStops.length - 1]!; + putLeg(before, ending, part); + fraction = until; + } + if (issue) return failed(issue); + } + const remainder = portion(leg, fraction, 1, unit); + clock += minutes * (1 - fraction); + putLeg(previous, stop, remainder); + } + position = i; + if (pin !== null && number !== day.dayNumber) return failed('conflict'); + const target = targets.get(number); + if (target !== undefined && target < position) return failed('conflict'); + if (pin !== null) clock = pin; + append(stop, clock); + previous = stop; + + if (stop.checkoutAt !== undefined) { + const checkoutDay = Math.floor(stop.checkoutAt / 1440); + const checkoutTime = stop.checkoutAt % 1440; + const arrivalAt = number * 1440 + clock; + if (stop.checkoutAt > arrivalAt) { + const entries = chainAt(number).schedule.entries; + entries[entries.length - 1]!.departure = formatClock(checkoutTime); + while (number < checkoutDay) { + previous = night(previous); + if (issue) return failed(issue); + } + clock = checkoutTime; + if (previous.automaticNight?.phase === 'start') { + const entries = chainAt(number).schedule.entries; + entries[entries.length - 1]!.arrival = formatClock(clock); + entries[entries.length - 1]!.departure = formatClock(clock); + } + } + continue; + } + let dwell = Math.max(0, stop.dwellMinutes ?? 0); + while (dwell > 0) { + const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock); + const spend = Math.min(dwell, remaining); + clock += spend; + dwell -= spend; + const entries = chainAt(number).schedule.entries; + entries[entries.length - 1]!.departure = formatClock(Math.round(clock)); + if (dwell > 0) { + previous = night(previous); + if (issue) return failed(issue); + } + } + if (target === position || (!targets.has(number) && stop.endDay && i < stops.length - 1)) { + previous = night(previous, i === stops.length - 1); + if (issue) return failed(issue); + } + } + + if ([...targets.keys()].some((day) => !chains.get(day)?.stops.some((stop) => stop.automaticNight?.phase === 'end'))) + return failed('conflict'); + + for (const chain of chains.values()) { + for (let i = 0; i < chain.stops.length; i++) { + const stop = chain.stops[i]!; + if (stop.automaticNight) continue; + const owner = ordered.find((d) => d.dayId === stop.ownerDayId); + if (!owner || owner.dayNumber === chain.dayNumber) continue; + const last = chain.spills[chain.spills.length - 1]!; + if (last?.fromDayNumber === owner.dayNumber && last.at + last.count === i) last.count += 1; + else + chain.spills.push({ + automatic: true, + at: i, + count: 1, + fromDayNumber: owner.dayNumber, + departure: null, + leg: undefined, + line: [], + fromStop: undefined, + }); + } + } + for (const day of ordered) chainAt(day.dayNumber); + return { + chains: [...chains.values()].sort((a, b) => a.dayNumber - b.dayNumber), + legFor: (from, to) => pieces.get(from)?.get(to), + issue: null, + }; +} + +export function roadtripInsertion(day: Pick, index: number) { + const stop = day.stops[Math.min(Math.max(0, index), day.stops.length - 1)]!; + return stop + ? { dayId: stop.ownerDayId, position: stop.ownerIndex + (stop.automaticNight || index >= day.stops.length ? 1 : 0) } + : null; +} diff --git a/shared/src/roadtrip/google-import.schema.ts b/shared/src/roadtrip/google-import.schema.ts new file mode 100644 index 000000000..21c73b165 --- /dev/null +++ b/shared/src/roadtrip/google-import.schema.ts @@ -0,0 +1,27 @@ +import { idSchema } from '../common/primitives.schema'; + +import { z } from 'zod'; + +export const googleRoutePreviewRequestSchema = z.object({ url: z.string().url().max(12000) }); +export const googleRouteStopSchema = z.object({ + name: z.string().min(1).max(200), + lat: z.number().min(-90).max(90), + lng: z.number().min(-180).max(180), +}); +export const googleRoutePreviewSchema = z.object({ + stops: z + .array( + z.object({ + name: z.string(), + lat: z.number().nullable(), + lng: z.number().nullable(), + }), + ) + .max(30), +}); +export const googleRouteImportSchema = z.object({ + dayId: idSchema, + stops: z.array(googleRouteStopSchema).min(2).max(30), +}); +export type GoogleRouteImport = z.infer; +export type GoogleRoutePreview = z.infer; diff --git a/shared/src/roadtrip/hazards.schema.spec.ts b/shared/src/roadtrip/hazards.schema.spec.ts new file mode 100644 index 000000000..63257fd98 --- /dev/null +++ b/shared/src/roadtrip/hazards.schema.spec.ts @@ -0,0 +1,8 @@ +import { hazardGeometrySchema } from './hazards.schema'; + +import { expect, it } from 'vitest'; + +it('rejects coordinates outside the map and incomplete polygon rings', () => { + expect(hazardGeometrySchema.safeParse({ type: 'Point', coordinates: [181, 50] }).success).toBe(false); + expect(hazardGeometrySchema.safeParse({ type: 'Polygon', coordinates: [[[10, 50]]] }).success).toBe(false); +}); diff --git a/shared/src/roadtrip/hazards.schema.ts b/shared/src/roadtrip/hazards.schema.ts new file mode 100644 index 000000000..0036d8543 --- /dev/null +++ b/shared/src/roadtrip/hazards.schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +const position = z.tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]); +const ring = z.array(position).min(4).max(50000); +export const hazardGeometrySchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('Point'), coordinates: position }), + z.object({ type: z.literal('Polygon'), coordinates: z.array(ring).min(1).max(500) }), + z.object({ type: z.literal('MultiPolygon'), coordinates: z.array(z.array(ring).min(1).max(500)).max(500) }), +]); +export const roadtripHazardSchema = z.object({ + id: z.string(), + source: z.enum(['DWD', 'GDACS']), + title: z.string().max(2000), + description: z.string().max(20000), + updatedAt: z.string().datetime(), + validUntil: z.string().datetime().nullable(), + url: z.string().url(), + geometry: hazardGeometrySchema, + alertScore: z.number().finite().nonnegative().optional(), +}); +export const roadtripHazardsSchema = z.object({ + fetchedAt: z.string().datetime(), + hazards: z.array(roadtripHazardSchema).max(1000), + sources: z.array(z.object({ source: z.enum(['DWD', 'GDACS']), status: z.enum(['ok', 'partial', 'unavailable']) })), +}); +export type RoadtripHazard = z.infer; +export type RoadtripHazards = z.infer; diff --git a/client/src/components/Roadtrip/nightSpill.test.ts b/shared/src/roadtrip/nightSpill.spec.ts similarity index 61% rename from client/src/components/Roadtrip/nightSpill.test.ts rename to shared/src/roadtrip/nightSpill.spec.ts index 4773bcfa3..3093959c1 100644 --- a/client/src/components/Roadtrip/nightSpill.test.ts +++ b/shared/src/roadtrip/nightSpill.spec.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from 'vitest' -import { spillChains } from './nightSpill' -import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes' -import type { RouteSegment } from '../../types' +import { spillChains } from './nightSpill'; +import type { QuietDay, RoadtripDay, RoadtripStop } from './planning-types'; +import type { RouteSegment } from './planning-types'; -type PlanDay = Pick +import { describe, it, expect } from 'vitest'; + +type PlanDay = Pick; function stop(over: Partial & { assignmentId: number; name: string }): RoadtripStop { return { @@ -18,7 +19,7 @@ function stop(over: Partial & { assignmentId: number; name: string incomingLegMode: null, stopType: null, ...over, - } + }; } /** A day of the plan, with every stop's stored position filled in for it. */ @@ -29,31 +30,47 @@ function day(dayId: number, dayNumber: number, stops: RoadtripStop[]): PlanDay { date: null, title: null, stops: stops.map((s, i) => ({ ...s, ownerDayId: dayId, ownerIndex: i })), - } + }; } const seg = (minutes: number, km = 100): RouteSegment => - ({ distance: km * 1000, duration: minutes * 60, distanceText: `${km} km`, durationText: `${minutes} min`, mode: 'driving' }) as RouteSegment + ({ + distance: km * 1000, + duration: minutes * 60, + distanceText: `${km} km`, + durationText: `${minutes} min`, + mode: 'driving', + }) as RouteSegment; /** Every leg the same length, which is all most of these cases need. */ -const everyLeg = (minutes: number, km = 100) => () => ({ seg: seg(minutes, km), line: [[0, 0], [1, 1]] as [number, number][] }) +const everyLeg = + (minutes: number, km = 100) => + () => ({ + seg: seg(minutes, km), + line: [ + [0, 0], + [1, 1], + ] as [number, number][], + }); /** No leg routed at all, which is what an unrouted or offline trip looks like. */ -const noLegs = () => undefined +const noLegs = () => undefined; describe('spillChains', () => { it('FE-NIGHTSPILL-001: leaves an ordinary day exactly where it is', () => { - const plan = [day(1, 1, [ - stop({ assignmentId: 1, name: 'Hamburg', time: '09:00', dwellMinutes: 60 }), - stop({ assignmentId: 2, name: 'Berlin' }), - ])] + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '09:00', dwellMinutes: 60 }), + stop({ assignmentId: 2, name: 'Berlin' }), + ]), + ]; - const chains = spillChains(plan, [], everyLeg(180)) + const chains = spillChains(plan, [], everyLeg(180)); - expect(chains).toHaveLength(1) - expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Berlin']) - expect(chains[0].spills).toEqual([]) - }) + expect(chains).toHaveLength(1); + expect(chains[0]!.stops.map((s) => s.name)).toEqual(['Hamburg', 'Berlin']); + expect(chains[0]!.spills).toEqual([]); + }); it('FE-NIGHTSPILL-002: hands the stops reached after midnight to the next day', () => { // 21:00 + 1 h 30 stay = 22:30, then a three hour drive arrives at 01:30 tomorrow. @@ -66,13 +83,13 @@ describe('spillChains', () => { stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }), stop({ assignmentId: 4, name: 'Leipzig' }), ]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(180)) + const chains = spillChains(plan, [], everyLeg(180)); - expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg']) - expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Wittenberg', 'Leipzig']) - }) + expect(chains[0]!.stops.map((s) => s.name)).toEqual(['Hamburg']); + expect(chains[1]!.stops.map((s) => s.name)).toEqual(['Neuruppin', 'Wittenberg', 'Leipzig']); + }); it('FE-NIGHTSPILL-003: the spill carries the night drive and the time it set off', () => { const plan = [ @@ -84,18 +101,18 @@ describe('spillChains', () => { stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }), stop({ assignmentId: 4, name: 'Leipzig' }), ]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(180, 240)) + const chains = spillChains(plan, [], everyLeg(180, 240)); - expect(chains[1].spills).toHaveLength(1) - const spill = chains[1].spills[0] - expect(spill).toMatchObject({ at: 0, count: 1, fromDayNumber: 1, departure: '22:30' }) + expect(chains[1]!.spills).toHaveLength(1); + const spill = chains[1]!.spills[0]!; + expect(spill).toMatchObject({ at: 0, count: 1, fromDayNumber: 1, departure: '22:30' }); // The leg is the drive that crossed midnight, not the one after it: without it the // card would gain a stop and none of the distance that reaches it. - expect(spill.leg?.distance).toBe(240000) - expect(spill.line.length).toBeGreaterThan(1) - }) + expect(spill.leg?.distance).toBe(240000); + expect(spill.line.length).toBeGreaterThan(1); + }); it('FE-NIGHTSPILL-004: a stop that moved keeps the day the server stores it on', () => { const plan = [ @@ -104,30 +121,32 @@ describe('spillChains', () => { stop({ assignmentId: 2, name: 'Neuruppin' }), ]), day(8, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(180)) + const chains = spillChains(plan, [], everyLeg(180)); // Drawn under day 2, stored on day 1 — which is what every callback still names. - const moved = chains[1].stops.find(s => s.name === 'Neuruppin') - expect(moved?.ownerDayId).toBe(7) - expect(moved?.ownerIndex).toBe(1) - }) + const moved = chains[1]!.stops.find((s) => s.name === 'Neuruppin')!; + expect(moved?.ownerDayId).toBe(7); + expect(moved?.ownerIndex).toBe(1); + }); it('FE-NIGHTSPILL-005: the last day of a trip keeps its own crossing, having nowhere to send it', () => { - const plan = [day(1, 1, [ - stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), - stop({ assignmentId: 2, name: 'Neuruppin' }), - ])] + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), + stop({ assignmentId: 2, name: 'Neuruppin' }), + ]), + ]; - const chains = spillChains(plan, [], everyLeg(180)) + const chains = spillChains(plan, [], everyLeg(180)); - expect(chains).toHaveLength(1) - expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin']) - expect(chains[0].spills).toEqual([]) + expect(chains).toHaveLength(1); + expect(chains[0]!.stops.map((s) => s.name)).toEqual(['Hamburg', 'Neuruppin']); + expect(chains[0]!.spills).toEqual([]); // And it still says so: the arrival is a day past the one the card is under. - expect(chains[0].schedule.entries[1].dayOffset).toBe(1) - }) + expect(chains[0]!.schedule.entries[1]!.dayOffset).toBe(1); + }); it('FE-NIGHTSPILL-006: a pinned time resolved to yesterday does not tear the day apart', () => { // The case that broke it: 07:00 then 23:00. `computeSchedule` resolves the second pin @@ -143,15 +162,15 @@ describe('spillChains', () => { stop({ assignmentId: 3, name: 'Leipzig' }), ]), day(2, 2, [stop({ assignmentId: 4, name: 'Dresden', time: '12:00' })]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(50)) + const chains = spillChains(plan, [], everyLeg(50)); // Cobbelsdorf and Wittenberg both stay: nothing between them crosses midnight. - expect(chains[0].stops.map(s => s.name)).toEqual(['Cobbelsdorf', 'Wittenberg']) + expect(chains[0]!.stops.map((s) => s.name)).toEqual(['Cobbelsdorf', 'Wittenberg']); // Leipzig is reached at 02:50, after the clock went back past 23:00, so it moves. - expect(chains[1].stops.map(s => s.name)).toEqual(['Leipzig', 'Dresden']) - }) + expect(chains[1]!.stops.map((s) => s.name)).toEqual(['Leipzig', 'Dresden']); + }); it('FE-NIGHTSPILL-007: two nights land on two different days', () => { // 20:00, then two twelve-hour drives: 08:00 the next day, 20:00 that evening, and @@ -165,14 +184,14 @@ describe('spillChains', () => { ]), day(2, 2, []), day(3, 3, []), - ] + ]; - const chains = spillChains(plan, [], everyLeg(12 * 60)) + const chains = spillChains(plan, [], everyLeg(12 * 60)); - expect(chains[0].stops.map(s => s.name)).toEqual(['A']) - expect(chains[1].stops.map(s => s.name)).toEqual(['B', 'C']) - expect(chains[2].stops.map(s => s.name)).toEqual(['D']) - }) + expect(chains[0]!.stops.map((s) => s.name)).toEqual(['A']); + expect(chains[1]!.stops.map((s) => s.name)).toEqual(['B', 'C']); + expect(chains[2]!.stops.map((s) => s.name)).toEqual(['D']); + }); it('FE-NIGHTSPILL-008: a day whose legs never routed moves nothing', () => { const plan = [ @@ -181,28 +200,30 @@ describe('spillChains', () => { stop({ assignmentId: 2, name: 'Neuruppin' }), ]), day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]), - ] + ]; - const chains = spillChains(plan, [], noLegs) + const chains = spillChains(plan, [], noLegs); // No drive, no arrival, no crossing — and a guess would be worse than nothing. - expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin']) - expect(chains[1].spills).toEqual([]) - }) + expect(chains[0]!.stops.map((s) => s.name)).toEqual(['Hamburg', 'Neuruppin']); + expect(chains[1]!.spills).toEqual([]); + }); it('FE-NIGHTSPILL-009: a day that carries no drive of its own still receives one', () => { - const plan = [day(1, 1, [ - stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), - stop({ assignmentId: 2, name: 'Neuruppin' }), - ])] - const quiet: QuietDay[] = [{ dayId: 2, dayNumber: 2, date: null, title: null, stops: [] }] + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), + stop({ assignmentId: 2, name: 'Neuruppin' }), + ]), + ]; + const quiet: QuietDay[] = [{ dayId: 2, dayNumber: 2, date: null, title: null, stops: [] }]; - const chains = spillChains(plan, quiet, everyLeg(180)) + const chains = spillChains(plan, quiet, everyLeg(180)); - expect(chains).toHaveLength(2) - expect(chains[1].dayId).toBe(2) - expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin']) - }) + expect(chains).toHaveLength(2); + expect(chains[1]!.dayId).toBe(2); + expect(chains[1]!.stops.map((s) => s.name)).toEqual(['Neuruppin']); + }); it('FE-NIGHTSPILL-010: the crossing is not marked twice on a stop that moved', () => { const plan = [ @@ -211,15 +232,15 @@ describe('spillChains', () => { stop({ assignmentId: 2, name: 'Neuruppin' }), ]), day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(180)) + const chains = spillChains(plan, [], everyLeg(180)); // The block around it is what says the night happened; a band as well would say it // twice. And it is not "the next day" once it is drawn under the next day's date. - expect(chains[1].schedule.warnings.some(w => w.code === 'overnight')).toBe(false) - expect(chains[1].schedule.entries[0].dayOffset).toBe(0) - }) + expect(chains[1]!.schedule.warnings.some((w) => w.code === 'overnight')).toBe(false); + expect(chains[1]!.schedule.entries[0]!.dayOffset).toBe(0); + }); it('FE-NIGHTSPILL-011: a finding about a stop travels with it, renumbered', () => { // Wittenberg is pinned two hours before the drive can get there, which is a "late" @@ -230,14 +251,14 @@ describe('spillChains', () => { stop({ assignmentId: 2, name: 'Neuruppin', time: '00:30' }), ]), day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(180)) + const chains = spillChains(plan, [], everyLeg(180)); - const late = chains[1].schedule.warnings.filter(w => w.code === 'late') - expect(late).toHaveLength(1) - expect(late[0].index).toBe(0) - }) + const late = chains[1]!.schedule.warnings.filter((w) => w.code === 'late'); + expect(late).toHaveLength(1); + expect(late[0]!.index).toBe(0); + }); it('FE-NIGHTSPILL-012: two stops crossing together are one block, not two', () => { const plan = [ @@ -247,12 +268,12 @@ describe('spillChains', () => { stop({ assignmentId: 3, name: 'Potsdam' }), ]), day(2, 2, [stop({ assignmentId: 4, name: 'Wittenberg', time: '10:00' })]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(180)) + const chains = spillChains(plan, [], everyLeg(180)); - expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Potsdam', 'Wittenberg']) - expect(chains[1].spills).toHaveLength(1) - expect(chains[1].spills[0]).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 }) - }) -}) + expect(chains[1]!.stops.map((s) => s.name)).toEqual(['Neuruppin', 'Potsdam', 'Wittenberg']); + expect(chains[1]!.spills).toHaveLength(1); + expect(chains[1]!.spills[0]!).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 }); + }); +}); diff --git a/shared/src/roadtrip/nightSpill.ts b/shared/src/roadtrip/nightSpill.ts new file mode 100644 index 000000000..b1d0175f1 --- /dev/null +++ b/shared/src/roadtrip/nightSpill.ts @@ -0,0 +1,163 @@ +import type { QuietDay, RoadtripDay, RoadtripStop } from './planning-types'; +import type { RouteSegment } from './planning-types'; +import { computeSchedule, parseClock, type Schedule } from './roadtripModel'; + +export interface SpillMark { + automatic?: boolean; + + at: number; + + count: number; + + fromDayNumber: number; + + departure: string | null; + + leg: RouteSegment | undefined; + + fromStop: RoadtripStop | undefined; + + line: [number, number][]; +} + +export interface SpillChain { + dayId: number; + dayNumber: number; + date: string | null; + title: string | null; + + stops: RoadtripStop[]; + + schedule: Schedule; + + spills: SpillMark[]; +} + +type PlanDay = Pick; + +type LegLookup = (from: RoadtripStop, to: RoadtripStop) => { seg: RouteSegment; line: [number, number][] } | undefined; + +interface Placed { + stop: RoadtripStop; + arrival: string | null; + departure: string | null; + + dayOffset: number; + + codes: ('late' | 'overnight' | 'leg' | 'range')[]; + lateMinutes: number | null; + + fromDayNumber: number | null; + departedAt: string | null; + leg: RouteSegment | undefined; + line: [number, number][]; + from: RoadtripStop | undefined; +} + +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(); + for (const d of all) landing.set(d.dayNumber, []); + + for (const d of all) { + 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, + departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440, + })), + legs.map((l) => l?.duration), + ); + + 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 = d.stops.some((s) => s.checkoutAt !== undefined) ? (entry?.dayOffset ?? day) : day; + const marks = schedule.warnings.filter((w) => w.index === i); + + 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, + + dayOffset: moved ? 0 : offset, + + 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; + + 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; +} diff --git a/shared/src/roadtrip/planning-types.ts b/shared/src/roadtrip/planning-types.ts new file mode 100644 index 000000000..75e4e3736 --- /dev/null +++ b/shared/src/roadtrip/planning-types.ts @@ -0,0 +1,171 @@ +import type { RoadtripDayBoundary } from './day-boundary.schema'; +import type { WindowPlan } from './dayWindow'; +import type { AutomaticNight } from './dayWindow'; +import type { SpillMark } from './nightSpill'; +import type { Schedule, ScheduleWarning, DayWarning, DryPoint } from './roadtripModel'; + +export interface RoadtripStop { + automaticNight?: AutomaticNight; + assignmentId: number; + + ownerDayId: number; + ownerIndex: number; + placeId: number; + name: string; + lat: number; + lng: number; + + time: string | null; + + dwellMinutes: number | null; + checkoutAt?: number; + endDay?: boolean; + + legMode: string | null; + incomingLegMode: string | null; + + stopType: string | null; + + fillPercent?: number | null; + + offRoadMeters?: number | null; +} + +export interface RoutedLeg { + seg: RouteSegment; + line: [number, number][]; + + snapped?: { from: SnappedPoint | null; to: SnappedPoint | null }; + + vias: RouteVia[]; +} + +export interface SnappedPoint { + lat: number; + lng: number; + + offRoadMeters: number; +} + +export interface RouteSegment { + mid: [number, number]; + from: [number, number]; + to: [number, number]; + distance: number; + duration: number; + walkingText: string; + drivingText: string; + distanceText: string; + durationText?: string; + + noteText?: string; + + mode?: string; +} + +export interface RouteVia { + hoverCard?: boolean; + nightPause?: { + day: number; + atPlace: boolean; + position?: number; + manual?: boolean; + minPosition?: number; + maxPosition?: number; + }; + lat: number; + lng: number; + label?: string; + tone: 'default' | 'success' | 'warn' | 'danger'; + dwellSeconds?: number; +} + +export interface SnappedWaypoint { + asked: [number, number]; + + at: [number, number]; + + meters: number; +} + +export type DistanceUnit = 'metric' | 'imperial'; +export type RouteAvoidClass = 'motorway' | 'toll' | 'ferry'; +export interface RoadtripDay { + automaticSchedule?: boolean; + dayId: number; + dayNumber: number; + date: string | null; + title: string | null; + stops: RoadtripStop[]; + + legs: (RouteSegment | undefined)[]; + + schedule: Schedule; + + legVias: RouteVia[][]; + + avoidMissed?: RouteAvoidClass[]; + + dryPoints?: (DryPoint & { lat: number; lng: number })[]; + + drivingGeometry?: [number, number][]; + + geometry: [number, number][]; + distance: number; + duration: number; + + driveWarnings: ScheduleWarning[]; + + dayWarning: DayWarning | null; + + spills?: SpillMark[]; +} + +export type PlanDay = Pick; + +export interface AccessSpur { + line: [[number, number], [number, number]]; + meters: number; + + stopKey: string; +} + +export interface QuietDay { + dayId: number; + dayNumber: number; + date: string | null; + title: string | null; + + stops: RoadtripStop[]; +} + +export interface RoadtripRoutes { + boundaryPath?: DayBoundaryLeg[]; + validateBoundaries?: (boundaries: RoadtripDayBoundary[]) => WindowPlan['issue']; + dayWindowIssue?: 'incomplete' | 'conflict' | 'tooLong' | 'legTooLong' | null; + days: RoadtripDay[]; + + quietDays: QuietDay[]; + + lines: [number, number][][]; + + lineDays: number[]; + + accessLines: AccessSpur[]; + + vias: RouteVia[]; + + segments: RouteSegment[]; + totalDistance: number; + totalDuration: number; + totalStops: number; + + loading: boolean; +} + +export interface DayBoundaryLeg { + from: RoadtripStop; + to: RoadtripStop; + position: number; + line: [number, number][]; +} diff --git a/shared/src/roadtrip/planning.schema.spec.ts b/shared/src/roadtrip/planning.schema.spec.ts new file mode 100644 index 000000000..804cc28dc --- /dev/null +++ b/shared/src/roadtrip/planning.schema.spec.ts @@ -0,0 +1,47 @@ +import { roadtripPlanRequestSchema, roadtripCorridorRequestSchema, roadtripGpxImportSchema } from './planning.schema'; +import { roadtripPreferencesUpdateSchema } from './preferences.schema'; + +import { describe, expect, it } from 'vitest'; + +describe('Roadtrip MCP contracts', () => { + it('defaults to a compact read-only calculation', () => { + expect(roadtripPlanRequestSchema.parse({ tripId: 3 })).toEqual({ tripId: 3, includeGeometry: false }); + expect(roadtripPlanRequestSchema.safeParse({ tripId: -1 }).success).toBe(false); + }); + it.each([ + { roadtrip_day_start: '24:00' }, + { roadtrip_fill_percent: 101 }, + { routing_base_url: 'http://localhost' }, + {}, + { roadtrip_range_km: -1 }, + { roadtrip_avoid: 'unknown' }, + ])('rejects invalid driving preferences %j', (settings) => { + expect(roadtripPreferencesUpdateSchema.safeParse(settings).success).toBe(false); + }); + it('allows clearing settings and preserves manual controls', () => { + expect( + roadtripPreferencesUpdateSchema.parse({ + roadtrip_day_start: '', + roadtrip_range_km: 0, + roadtrip_day_end_mode: 'stop', + }), + ).toMatchObject({ roadtrip_day_start: '' }); + }); + it('bounds corridor queries', () => { + expect(roadtripCorridorRequestSchema.parse({ tripId: 1, dayNumber: 1, category: 'charging' })).toMatchObject({ + offset: 0, + widthKm: 5, + }); + expect( + roadtripCorridorRequestSchema.safeParse({ tripId: 1, dayNumber: 1, category: 'charging', widthKm: 500 }).success, + ).toBe(false); + }); + it('bounds GPX input and defaults all supported import types', () => { + expect(roadtripGpxImportSchema.parse({ tripId: 1, gpx: '' })).toMatchObject({ + importTracks: true, + importRoutes: true, + importWaypoints: true, + }); + expect(roadtripGpxImportSchema.safeParse({ tripId: 1, gpx: 'x'.repeat(1000001) }).success).toBe(false); + }); +}); diff --git a/shared/src/roadtrip/planning.schema.ts b/shared/src/roadtrip/planning.schema.ts new file mode 100644 index 000000000..60dc974a6 --- /dev/null +++ b/shared/src/roadtrip/planning.schema.ts @@ -0,0 +1,44 @@ +import { idSchema } from '../common/primitives.schema'; +import { roadtripPreferencesSchema } from './preferences.schema'; + +import { z } from 'zod'; + +export const roadtripPlanRequestSchema = z.object({ + tripId: idSchema, + includeGeometry: z.boolean().optional().default(false), + settings: roadtripPreferencesSchema.optional().describe('Preview these trip preferences without saving them'), +}); +export const roadtripCorridorRequestSchema = z.object({ + tripId: idSchema, + dayNumber: z.number().int().positive().max(366), + category: z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights', 'hotel']), + widthKm: z.number().positive().max(10).default(5), + offset: z.number().int().min(0).max(1000).default(0), + name: z.string().max(100).optional(), + socket: z.string().max(50).optional(), + minKw: z.number().nonnegative().max(10000).optional(), + fromKm: z + .number() + .nonnegative() + .max(40000) + .optional() + .describe('Only matches at or after this distance along the day route'), + toKm: z + .number() + .nonnegative() + .max(40000) + .optional() + .describe('Only matches at or before this distance along the day route'), +}); +export type RoadtripPlanRequest = z.infer; +export type RoadtripCorridorRequest = z.infer; + +export const roadtripGpxImportSchema = z.object({ + tripId: idSchema, + gpx: z.string().min(1).max(1000000).describe('GPX XML text, up to one million characters'), + name: z.string().min(1).max(200).default('Imported track'), + importWaypoints: z.boolean().default(true), + importRoutes: z.boolean().default(true), + importTracks: z.boolean().default(true), +}); +export type RoadtripGpxImport = z.infer; diff --git a/shared/src/roadtrip/planning.ts b/shared/src/roadtrip/planning.ts new file mode 100644 index 000000000..4d21da7f5 --- /dev/null +++ b/shared/src/roadtrip/planning.ts @@ -0,0 +1,8 @@ +export * from './planning-types'; +export * from './roadtripModel'; +export * from './corridor'; +export * from './nightSpill'; +export * from './dayWindow'; +export * from './vehicleRange'; +export * from './assemble'; +export * from './routeRun'; diff --git a/shared/src/roadtrip/preferences.schema.ts b/shared/src/roadtrip/preferences.schema.ts new file mode 100644 index 000000000..cfad5b4e7 --- /dev/null +++ b/shared/src/roadtrip/preferences.schema.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +const clock = z.union([z.literal(''), z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/)]); +const amount = z.number().finite().nonnegative(); + +export const roadtripPreferencesSchema = z + .object({ + roadtrip_leg_minutes: amount.max(1440).optional(), + roadtrip_day_minutes: amount.max(1440).optional(), + roadtrip_day_start: clock.optional(), + roadtrip_day_end: clock.optional(), + roadtrip_day_end_mode: z.enum(['route', 'stop']).optional(), + roadtrip_range_km: amount.max(40000).optional(), + roadtrip_vehicle: z.enum(['', 'combustion', 'electric']).optional(), + roadtrip_fill_percent: amount.max(100).optional(), + roadtrip_tank_litres: amount.max(10000).optional(), + roadtrip_litres_per_100: amount.max(1000).optional(), + roadtrip_battery_kwh: amount.max(10000).optional(), + roadtrip_kwh_per_100: amount.max(1000).optional(), + roadtrip_battery_degradation: amount.max(100).optional(), + roadtrip_connect_days: z.boolean().optional(), + roadtrip_service_stops_in_days: z.boolean().optional(), + roadtrip_day_colors: z.boolean().optional(), + roadtrip_show_hazards: z.boolean().optional(), + roadtrip_avoid: z + .string() + .max(32) + .refine( + (value) => value === '' || value.split(',').every((part) => ['toll', 'motorway', 'ferry'].includes(part)), + 'Expected a comma-separated selection of toll, motorway, ferry', + ) + .optional(), + }) + .strict(); + +export const roadtripPreferencesUpdateSchema = roadtripPreferencesSchema.refine( + (value) => Object.keys(value).length > 0, + 'Provide at least one driving preference', +); +export type RoadtripPreferences = z.infer; +export const ROADTRIP_PREFERENCE_KEYS = Object.keys(roadtripPreferencesSchema.shape) as (keyof RoadtripPreferences)[]; + +export const roadtripPreferencesResponseSchema = z.object({ + tripId: z.number().int().positive(), + preferences: roadtripPreferencesSchema, +}); diff --git a/shared/src/roadtrip/roadtrip.schema.ts b/shared/src/roadtrip/roadtrip.schema.ts index 8efe3b05e..70ac40984 100644 --- a/shared/src/roadtrip/roadtrip.schema.ts +++ b/shared/src/roadtrip/roadtrip.schema.ts @@ -74,10 +74,14 @@ export type RoadtripViaUpdateRequest = z.infer; @@ -95,11 +99,15 @@ export type RoadtripViaReanchorRequest = z.infer; diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/shared/src/roadtrip/roadtripModel.spec.ts similarity index 69% rename from client/src/components/Roadtrip/roadtripModel.test.ts rename to shared/src/roadtrip/roadtripModel.spec.ts index 1dc6b7a30..f0a1db319 100644 --- a/client/src/components/Roadtrip/roadtripModel.test.ts +++ b/shared/src/roadtrip/roadtripModel.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from 'vitest' import { computeSchedule, formatClock, @@ -12,68 +11,70 @@ import { splitIntoRuns, sumLegSeconds, refuelStopTypeFor, -} from './roadtripModel' +} from './roadtripModel'; + +import { describe, it, expect } from 'vitest'; describe('formatDurationShort', () => { it('prints minutes below an hour', () => { - expect(formatDurationShort(45 * 60)).toBe('45 min') - expect(formatDurationShort(0)).toBe('0 min') - }) + expect(formatDurationShort(45 * 60)).toBe('45 min'); + expect(formatDurationShort(0)).toBe('0 min'); + }); it('drops the zero minutes on a whole hour', () => { - expect(formatDurationShort(3600)).toBe('1 h') - expect(formatDurationShort(2 * 3600)).toBe('2 h') - }) + expect(formatDurationShort(3600)).toBe('1 h'); + expect(formatDurationShort(2 * 3600)).toBe('2 h'); + }); it('prints hours and minutes together', () => { - expect(formatDurationShort(2 * 3600 + 10 * 60)).toBe('2 h 10 min') - }) + expect(formatDurationShort(2 * 3600 + 10 * 60)).toBe('2 h 10 min'); + }); it('carries instead of printing sixty minutes', () => { // 1 h 59 min 40 s rounds the minutes to 60, which must become 2 h. - expect(formatDurationShort(3600 + 59 * 60 + 40)).toBe('2 h') - }) + expect(formatDurationShort(3600 + 59 * 60 + 40)).toBe('2 h'); + }); it('treats nonsense as nothing rather than throwing', () => { - expect(formatDurationShort(Number.NaN)).toBe('0 min') - expect(formatDurationShort(-90)).toBe('0 min') - }) -}) + expect(formatDurationShort(Number.NaN)).toBe('0 min'); + expect(formatDurationShort(-90)).toBe('0 min'); + }); +}); describe('parseClock', () => { it('reads wall-clock times', () => { - expect(parseClock('09:45')).toBe(9 * 60 + 45) - expect(parseClock('9:05')).toBe(9 * 60 + 5) - expect(parseClock('00:00')).toBe(0) - }) + expect(parseClock('09:45')).toBe(9 * 60 + 45); + expect(parseClock('9:05')).toBe(9 * 60 + 5); + expect(parseClock('00:00')).toBe(0); + }); it('ignores anything trailing, as stored times sometimes carry seconds', () => { - expect(parseClock('14:30:00')).toBe(14 * 60 + 30) - }) + expect(parseClock('14:30:00')).toBe(14 * 60 + 30); + }); it('rejects what is not a time', () => { - expect(parseClock(null)).toBeNull() - expect(parseClock('')).toBeNull() - expect(parseClock('later')).toBeNull() - expect(parseClock('25:00')).toBeNull() - expect(parseClock('12:75')).toBeNull() - }) -}) + expect(parseClock(null)).toBeNull(); + expect(parseClock('')).toBeNull(); + expect(parseClock('later')).toBeNull(); + expect(parseClock('25:00')).toBeNull(); + expect(parseClock('12:75')).toBeNull(); + }); +}); describe('formatClock', () => { it('pads both halves', () => { - expect(formatClock(9 * 60 + 5)).toBe('09:05') - expect(formatClock(0)).toBe('00:00') - }) + expect(formatClock(9 * 60 + 5)).toBe('09:05'); + expect(formatClock(0)).toBe('00:00'); + }); it('wraps past midnight instead of printing a 25th hour', () => { - expect(formatClock(25 * 60)).toBe('01:00') - expect(formatClock(-30)).toBe('23:30') - }) -}) + expect(formatClock(25 * 60)).toBe('01:00'); + expect(formatClock(-30)).toBe('23:30'); + }); +}); describe('computeSchedule', () => { - const hours = (h: number): number => h * 3600 + const hours = (h: number): number => h * 3600; it('walks arrival and departure forward from the first pinned time', () => { // The example from discussion #1797: leave at 8:00, drive 1 h 45, stay 2 h, drive 1 h 10. @@ -84,11 +85,11 @@ describe('computeSchedule', () => { { anchor: null, dwellMinutes: 45 }, ], [hours(1.75), hours(1) + 10 * 60], - ) - expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '08:00', anchored: true }) - expect(entries[1]).toMatchObject({ arrival: '09:45', departure: '11:45', anchored: false }) - expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false }) - }) + ); + expect(entries[0]!).toMatchObject({ arrival: '08:00', departure: '08:00', anchored: true }); + expect(entries[1]!).toMatchObject({ arrival: '09:45', departure: '11:45', anchored: false }); + expect(entries[2]!).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false }); + }); it('works back from the first pinned time to say when to set off', () => { // The museum opens at ten and we want an hour at the stop before it, an hour of @@ -100,10 +101,10 @@ describe('computeSchedule', () => { { anchor: '10:00', dwellMinutes: 30 }, ], [hours(1)], - ) - expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '09:00', anchored: false }) - expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true }) - }) + ); + expect(entries[0]!).toMatchObject({ arrival: '08:00', departure: '09:00', anchored: false }); + expect(entries[1]!).toMatchObject({ arrival: '10:00', anchored: true }); + }); it('stops working back at a leg that never routed rather than inventing one', () => { const { entries } = computeSchedule( @@ -113,12 +114,12 @@ describe('computeSchedule', () => { { anchor: '10:00', dwellMinutes: 30 }, ], [undefined, hours(1)], - ) - expect(entries[0]).toMatchObject({ arrival: null, departure: null }) + ); + expect(entries[0]!).toMatchObject({ arrival: null, departure: null }); // Leaves at 09:00 to arrive at 10:00, and its own half hour puts it there at 08:30. - expect(entries[1]).toMatchObject({ arrival: '08:30', departure: '09:00' }) - expect(entries[2]).toMatchObject({ arrival: '10:00', anchored: true }) - }) + expect(entries[1]!).toMatchObject({ arrival: '08:30', departure: '09:00' }); + expect(entries[2]!).toMatchObject({ arrival: '10:00', anchored: true }); + }); it('keeps the earliest stop on day zero when working back crosses midnight', () => { // Pinned at one in the morning with three hours of driving before it: the stop before @@ -130,11 +131,11 @@ describe('computeSchedule', () => { { anchor: '01:00', dwellMinutes: 0 }, ], [hours(3)], - ) - expect(entries[0]).toMatchObject({ arrival: '22:00', dayOffset: 0 }) - expect(entries[1]).toMatchObject({ arrival: '01:00', dayOffset: 1, anchored: true }) - expect(warnings).toContainEqual({ index: 1, code: 'overnight' }) - }) + ); + expect(entries[0]!).toMatchObject({ arrival: '22:00', dayOffset: 0 }); + expect(entries[1]!).toMatchObject({ arrival: '01:00', dayOffset: 1, anchored: true }); + expect(warnings).toContainEqual({ index: 1, code: 'overnight' }); + }); it('restarts the cascade at a pinned stop instead of pushing it', () => { // The drive would arrive at 09:00, but the museum ticket says 11:00: everything @@ -146,10 +147,10 @@ describe('computeSchedule', () => { { anchor: null, dwellMinutes: 0 }, ], [hours(1), hours(1)], - ) - expect(entries[1].arrival).toBe('11:00') - expect(entries[2].arrival).toBe('13:00') - }) + ); + expect(entries[1]!.arrival).toBe('11:00'); + expect(entries[2]!.arrival).toBe('13:00'); + }); it('ROADTRIP-MODEL-082: a night drive onto a pinned time after midnight is on time', () => { // Leave at 20:00, drive six hours, check in at 02:00. The anchor used to be @@ -164,13 +165,13 @@ describe('computeSchedule', () => { { anchor: '02:00', dwellMinutes: 0 }, ], [hours(6)], - ) + ); - expect(warnings.filter(w => w.code === 'late')).toEqual([]) - expect(entries[1]).toMatchObject({ arrival: '02:00', dayOffset: 1, anchored: true }) + expect(warnings.filter((w) => w.code === 'late')).toEqual([]); + expect(entries[1]!).toMatchObject({ arrival: '02:00', dayOffset: 1, anchored: true }); // The day still turned over, so the rail keeps its carry badge. - expect(warnings.some(w => w.code === 'overnight' && w.index === 1)).toBe(true) - }) + expect(warnings.some((w) => w.code === 'overnight' && w.index === 1)).toBe(true); + }); it('ROADTRIP-MODEL-083: a ferry that really is missed is still reported late', () => { // The other half: picking the nearest occurrence must not swallow a genuine @@ -182,9 +183,9 @@ describe('computeSchedule', () => { { anchor: '01:30', dwellMinutes: 0 }, ], [hours(5)], - ) - expect(warnings.find(w => w.code === 'late')).toMatchObject({ index: 1, minutes: 30 }) - }) + ); + expect(warnings.find((w) => w.code === 'late')!).toMatchObject({ index: 1, minutes: 30 }); + }); it('ROADTRIP-MODEL-084: a pinned stop after an untimed leg keeps the day it is on', () => { // Nothing to be late against, but the day carried so far still applies — @@ -196,9 +197,9 @@ describe('computeSchedule', () => { { anchor: '09:00', dwellMinutes: 0 }, ], [hours(6), undefined], - ) - expect(entries[2]).toMatchObject({ arrival: '09:00', dayOffset: 1 }) - }) + ); + expect(entries[2]!).toMatchObject({ arrival: '09:00', dayOffset: 1 }); + }); it('reports a stop the drive cannot reach in time', () => { const { warnings } = computeSchedule( @@ -207,9 +208,9 @@ describe('computeSchedule', () => { { anchor: '09:00', dwellMinutes: 0 }, ], [hours(3)], - ) - expect(warnings).toEqual([{ index: 1, code: 'late', minutes: 120 }]) - }) + ); + expect(warnings).toEqual([{ index: 1, code: 'late', minutes: 120 }]); + }); it('does not cry about a minute of rounding', () => { const { warnings } = computeSchedule( @@ -218,9 +219,9 @@ describe('computeSchedule', () => { { anchor: '09:00', dwellMinutes: 0 }, ], [hours(1) + 30], - ) - expect(warnings).toEqual([]) - }) + ); + expect(warnings).toEqual([]); + }); it('breaks the chain at a leg that never routed', () => { const { entries } = computeSchedule( @@ -230,11 +231,11 @@ describe('computeSchedule', () => { { anchor: null, dwellMinutes: 0 }, ], [undefined, hours(1)], - ) - expect(entries[0].arrival).toBe('08:00') - expect(entries[1].arrival).toBeNull() - expect(entries[2].arrival).toBeNull() - }) + ); + expect(entries[0]!.arrival).toBe('08:00'); + expect(entries[1]!.arrival).toBeNull(); + expect(entries[2]!.arrival).toBeNull(); + }); it('flags the day rolling past midnight and keeps counting', () => { const { entries, warnings } = computeSchedule( @@ -243,15 +244,15 @@ describe('computeSchedule', () => { { anchor: null, dwellMinutes: 0 }, ], [hours(4)], - ) - expect(entries[1].arrival).toBe('02:00') - expect(entries[1].dayOffset).toBe(1) - expect(warnings).toEqual([{ index: 1, code: 'overnight' }]) - }) + ); + expect(entries[1]!.arrival).toBe('02:00'); + expect(entries[1]!.dayOffset).toBe(1); + expect(warnings).toEqual([{ index: 1, code: 'overnight' }]); + }); it('handles an empty chain', () => { - expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] }) - }) + expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] }); + }); it('treats a missing dwell as no time spent', () => { const { entries } = computeSchedule( @@ -260,109 +261,109 @@ describe('computeSchedule', () => { { anchor: null, dwellMinutes: null }, ], [hours(1)], - ) - expect(entries[0].departure).toBe('08:00') - expect(entries[1].arrival).toBe('09:00') - }) -}) + ); + expect(entries[0]!.departure).toBe('08:00'); + expect(entries[1]!.arrival).toBe('09:00'); + }); +}); describe('splitIntoRuns', () => { - const drive = (): string => 'driving' + const drive = (): string => 'driving'; it('asks for a whole day travelled one way in a single run', () => { - const runs = splitIntoRuns(['a', 'b', 'c', 'd'], drive) - expect(runs).toEqual([{ stops: ['a', 'b', 'c', 'd'], mode: 'driving' }]) - }) + const runs = splitIntoRuns(['a', 'b', 'c', 'd'], drive); + expect(runs).toEqual([{ stops: ['a', 'b', 'c', 'd'], mode: 'driving' }]); + }); it('splits where the travel mode changes, and repeats the stop on both sides', () => { // Drive to b, walk to c, drive on to d: the walk is its own request, and b and c // each belong to two runs because they are the ends of neighbouring legs. - const modes = ['driving', 'walking', 'driving'] - const runs = splitIntoRuns(['a', 'b', 'c', 'd'], (from) => modes[['a', 'b', 'c'].indexOf(from)]) + const modes = ['driving', 'walking', 'driving']; + const runs = splitIntoRuns(['a', 'b', 'c', 'd'], (from) => modes[['a', 'b', 'c'].indexOf(from)]!); expect(runs).toEqual([ { stops: ['a', 'b'], mode: 'driving' }, { stops: ['b', 'c'], mode: 'walking' }, { stops: ['c', 'd'], mode: 'driving' }, - ]) - }) + ]); + }); it('keeps consecutive legs of the same mode together across a change and back', () => { - const modes = ['driving', 'driving', 'walking', 'walking'] - const runs = splitIntoRuns(['a', 'b', 'c', 'd', 'e'], (from) => modes[['a', 'b', 'c', 'd'].indexOf(from)]) + const modes = ['driving', 'driving', 'walking', 'walking']; + const runs = splitIntoRuns(['a', 'b', 'c', 'd', 'e'], (from) => modes[['a', 'b', 'c', 'd'].indexOf(from)]!); expect(runs).toEqual([ { stops: ['a', 'b', 'c'], mode: 'driving' }, { stops: ['c', 'd', 'e'], mode: 'walking' }, - ]) - }) + ]); + }); it('has nothing to ask for when there is no leg', () => { - expect(splitIntoRuns([], drive)).toEqual([]) - expect(splitIntoRuns(['a'], drive)).toEqual([]) - }) -}) + expect(splitIntoRuns([], drive)).toEqual([]); + expect(splitIntoRuns(['a'], drive)).toEqual([]); + }); +}); describe('sumLegSeconds', () => { it('adds only the legs that routed', () => { - expect(sumLegSeconds([600, undefined, 1200])).toBe(1800) - expect(sumLegSeconds([])).toBe(0) - }) -}) + expect(sumLegSeconds([600, undefined, 1200])).toBe(1800); + expect(sumLegSeconds([])).toBe(0); + }); +}); describe('insertIndexForAlong', () => { // Hamburg 0, Lueneburg 50, Berlin 290 — the day's stops as distances driven. - const stops = [0, 50, 290] + const stops = [0, 50, 290]; it('puts a hit between the two stops it falls between', () => { - expect(insertIndexForAlong(stops, 120)).toBe(2) - expect(insertIndexForAlong(stops, 20)).toBe(1) - }) + expect(insertIndexForAlong(stops, 120)).toBe(2); + expect(insertIndexForAlong(stops, 20)).toBe(1); + }); it('never lands before the stop the day starts from', () => { - expect(insertIndexForAlong(stops, 0)).toBe(1) + expect(insertIndexForAlong(stops, 0)).toBe(1); // A corridor is wider than the road, so a hit can project just behind the start. - expect(insertIndexForAlong(stops, -3)).toBe(1) - }) + expect(insertIndexForAlong(stops, -3)).toBe(1); + }); it('never lands after the stop the day ends at', () => { - expect(insertIndexForAlong(stops, 290)).toBe(2) - expect(insertIndexForAlong(stops, 400)).toBe(2) - }) + expect(insertIndexForAlong(stops, 290)).toBe(2); + expect(insertIndexForAlong(stops, 400)).toBe(2); + }); it('puts a hit exactly on a stop after it, not before', () => { - expect(insertIndexForAlong(stops, 50)).toBe(2) - }) + expect(insertIndexForAlong(stops, 50)).toBe(2); + }); it('has nowhere to insert on a day that is not a drive', () => { - expect(insertIndexForAlong([], 10)).toBe(0) - expect(insertIndexForAlong([0], 10)).toBe(1) - }) -}) + expect(insertIndexForAlong([], 10)).toBe(0); + expect(insertIndexForAlong([0], 10)).toBe(1); + }); +}); describe('legIndexForAlong', () => { // Against the legs' own lengths rather than by projecting the stops as well: leg // distances come from the router and are exact, while two stops close together project // onto each other's stretch and would put a charging halt the wrong side of a town. - const legs = [10_000, 25_000, 40_000] + const legs = [10_000, 25_000, 40_000]; it('FE-ROADTRIP-MODEL-050: a point inside a leg belongs to that leg', () => { - expect(legIndexForAlong(legs, 0)).toBe(0) - expect(legIndexForAlong(legs, 9_999)).toBe(0) - expect(legIndexForAlong(legs, 10_000)).toBe(1) - expect(legIndexForAlong(legs, 24_999)).toBe(1) - expect(legIndexForAlong(legs, 25_000)).toBe(2) - }) + expect(legIndexForAlong(legs, 0)).toBe(0); + expect(legIndexForAlong(legs, 9_999)).toBe(0); + expect(legIndexForAlong(legs, 10_000)).toBe(1); + expect(legIndexForAlong(legs, 24_999)).toBe(1); + expect(legIndexForAlong(legs, 25_000)).toBe(2); + }); it('FE-ROADTRIP-MODEL-051: a point past the end lands in the last leg, not nowhere', () => { // A rounding edge, not a missing leg: the projection and the router measure the same // line two different ways and disagree by metres at the very end. - expect(legIndexForAlong(legs, 40_000)).toBe(2) - expect(legIndexForAlong(legs, 99_999)).toBe(2) - }) + expect(legIndexForAlong(legs, 40_000)).toBe(2); + expect(legIndexForAlong(legs, 99_999)).toBe(2); + }); it('FE-ROADTRIP-MODEL-052: a day with no routed leg has nowhere to put anything', () => { - expect(legIndexForAlong([], 100)).toBe(-1) - }) -}) + expect(legIndexForAlong([], 100)).toBe(-1); + }); +}); describe('deriveDriveWarnings', () => { // Worked through the way a traveller reads it: Hamburg, A1, HEM, Berlin. The badge on @@ -373,14 +374,14 @@ describe('deriveDriveWarnings', () => { [false, false, false, false], { legMinutes: null, dayMinutes: null, rangeKm: 100 }, 0, - ) + ); // 21.5 + 103.4 on arrival at HEM, and then it keeps counting, because nothing on this // trip fills up. expect(out.warnings).toEqual([ { index: 2, code: 'range', sinceKm: 125 }, { index: 3, code: 'range', sinceKm: 370 }, - ]) - }) + ]); + }); it('FE-ROADTRIP-MODEL-070: arriving at a petrol station is the plan, not a warning', () => { // Same trip, but HEM is marked as fuel. Reaching it with an empty tank is exactly @@ -391,19 +392,19 @@ describe('deriveDriveWarnings', () => { [false, false, true, false], { legMinutes: null, dayMinutes: null, rangeKm: 100 }, 0, - ) - expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 245 }]) + ); + expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 245 }]); // 245 and not 370: HEM filled the tank, so Berlin counts from there. - }) + }); - const noLimits = { legMinutes: null, dayMinutes: null, rangeKm: null } - const leg = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000 }) + const noLimits = { legMinutes: null, dayMinutes: null, rangeKm: null }; + const leg = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000 }); it('FE-ROADTRIP-MODEL-060: no limits set means nothing to report', () => { - const out = deriveDriveWarnings([leg(600, 900)], [false, false], noLimits, 0) - expect(out.warnings).toEqual([]) - expect(out.day).toBeNull() - }) + const out = deriveDriveWarnings([leg(600, 900)], [false, false], noLimits, 0); + expect(out.warnings).toEqual([]); + expect(out.day).toBeNull(); + }); it('FE-ROADTRIP-MODEL-061: a leg over the limit reports how far over, on the stop it arrives at', () => { // On arrival, not on departure. Both findings are running totals that only become @@ -414,9 +415,9 @@ describe('deriveDriveWarnings', () => { [false, false, false], { ...noLimits, legMinutes: 180 }, 0, - ) - expect(out.warnings).toEqual([{ index: 2, code: 'leg', overMinutes: 60 }]) - }) + ); + expect(out.warnings).toEqual([{ index: 2, code: 'leg', overMinutes: 60 }]); + }); it('FE-ROADTRIP-MODEL-062: the day figure is the sum of the legs, and only that', () => { const out = deriveDriveWarnings( @@ -424,9 +425,9 @@ describe('deriveDriveWarnings', () => { [false, false, false], { ...noLimits, dayMinutes: 360 }, 0, - ) - expect(out.day).toEqual({ code: 'dayDriving', minutes: 400, limitMinutes: 360 }) - }) + ); + expect(out.day).toEqual({ code: 'dayDriving', minutes: 400, limitMinutes: 360 }); + }); it('FE-ROADTRIP-MODEL-063: the figure keeps counting until something actually refuels', () => { // A warning is not a fill-up. Zeroing the budget at one made every figure after the @@ -437,13 +438,13 @@ describe('deriveDriveWarnings', () => { [false, false, false, false, false], { ...noLimits, rangeKm: 600 }, 0, - ) + ); expect(out.warnings).toEqual([ { index: 2, code: 'range', sinceKm: 700 }, { index: 3, code: 'range', sinceKm: 1000 }, { index: 4, code: 'range', sinceKm: 1400 }, - ]) - }) + ]); + }); it('FE-ROADTRIP-MODEL-071: adding a fuel stop is what stops the run of warnings', () => { // The same drive with a charger at stop 2. Nothing is flagged there, because reaching @@ -454,58 +455,48 @@ describe('deriveDriveWarnings', () => { [false, false, true, false, false], { ...noLimits, rangeKm: 600 }, 0, - ) - expect(out.warnings).toEqual([{ index: 4, code: 'range', sinceKm: 700 }]) - }) + ); + expect(out.warnings).toEqual([{ index: 4, code: 'range', sinceKm: 700 }]); + }); it('FE-ROADTRIP-MODEL-064: filling up starts the budget over, resting does not', () => { - const stops = [false, true, false] - const out = deriveDriveWarnings( - [leg(60, 500), leg(60, 500)], - stops, - { ...noLimits, rangeKm: 600 }, - 0, - ) + const stops = [false, true, false]; + const out = deriveDriveWarnings([leg(60, 500), leg(60, 500)], stops, { ...noLimits, rangeKm: 600 }, 0); // The tank is filled at stop 1, so the second 500 km starts from zero and neither leg // trips the limit. Without the refuel it would be 1000 km on a 600 km range. - expect(out.warnings).toEqual([]) - }) + expect(out.warnings).toEqual([]); + }); it('FE-ROADTRIP-MODEL-065: only fuel and charging refuel', () => { - expect(refuelsRange('fuel')).toBe(true) - expect(refuelsRange('charging')).toBe(true) + expect(refuelsRange('fuel')).toBe(true); + expect(refuelsRange('charging')).toBe(true); // A two-hour lunch fills no tank, and TREK does not know whether the restaurant has a // charger in its car park. - expect(refuelsRange('restaurant')).toBe(false) - expect(refuelsRange('rest_area')).toBe(false) - expect(refuelsRange('campsite')).toBe(false) - expect(refuelsRange(null)).toBe(false) - }) + expect(refuelsRange('restaurant')).toBe(false); + expect(refuelsRange('rest_area')).toBe(false); + expect(refuelsRange('campsite')).toBe(false); + expect(refuelsRange(null)).toBe(false); + }); it('FE-ROADTRIP-MODEL-066: the budget carries into the next day, because a tank does not empty overnight', () => { - const first = deriveDriveWarnings([leg(60, 400)], [false, false], { ...noLimits, rangeKm: 600 }, 0) - expect(first.carryKm).toBe(400) + const first = deriveDriveWarnings([leg(60, 400)], [false, false], { ...noLimits, rangeKm: 600 }, 0); + expect(first.carryKm).toBe(400); - const second = deriveDriveWarnings([leg(60, 300)], [false, false], { ...noLimits, rangeKm: 600 }, first.carryKm) - expect(second.warnings).toEqual([{ index: 1, code: 'range', sinceKm: 700 }]) - }) + const second = deriveDriveWarnings([leg(60, 300)], [false, false], { ...noLimits, rangeKm: 600 }, first.carryKm); + expect(second.warnings).toEqual([{ index: 1, code: 'range', sinceKm: 700 }]); + }); it('FE-ROADTRIP-MODEL-067: an unrouted leg gives the budget up rather than guessing', () => { - const out = deriveDriveWarnings( - [undefined, leg(60, 500)], - [false, false, false], - { ...noLimits, rangeKm: 100 }, - 0, - ) - expect(out.warnings).toEqual([]) - expect(out.carryKm).toBeNull() - }) + const out = deriveDriveWarnings([undefined, leg(60, 500)], [false, false, false], { ...noLimits, rangeKm: 100 }, 0); + expect(out.warnings).toEqual([]); + expect(out.carryKm).toBeNull(); + }); it('FE-ROADTRIP-MODEL-068: filling up at the last stop of the day still counts', () => { - const out = deriveDriveWarnings([leg(60, 400)], [false, true], { ...noLimits, rangeKm: 600 }, 0) - expect(out.carryKm).toBe(0) - }) -}) + const out = deriveDriveWarnings([leg(60, 400)], [false, true], { ...noLimits, rangeKm: 600 }, 0); + expect(out.carryKm).toBe(0); + }); +}); describe('sectionAnchors', () => { it('FE-ROADTRIP-MODEL-080: every stop, and the middle of every leg between them', () => { @@ -517,20 +508,18 @@ describe('sectionAnchors', () => { { kind: 'stop', index: 1, alongKm: 100 }, { kind: 'leg', index: 1, alongKm: 175 }, { kind: 'stop', index: 2, alongKm: 250 }, - ]) - }) + ]); + }); it('FE-ROADTRIP-MODEL-081: a single stop has no leg to sit in the middle of', () => { - expect(sectionAnchors([42])).toEqual([{ kind: 'stop', index: 0, alongKm: 42 }]) - expect(sectionAnchors([])).toEqual([]) - }) -}) + expect(sectionAnchors([42])).toEqual([{ kind: 'stop', index: 0, alongKm: 42 }]); + expect(sectionAnchors([])).toEqual([]); + }); +}); describe('deriveDriveWarnings — what counts as driving', () => { - const drive = (minutes: number, km: number) => - ({ duration: minutes * 60, distance: km * 1000, mode: 'driving' }) - const onFoot = (minutes: number, km: number) => - ({ duration: minutes * 60, distance: km * 1000, mode: 'walking' }) + const drive = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000, mode: 'driving' }); + const onFoot = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000, mode: 'walking' }); it('FE-ROADTRIP-MODEL-085: a walk to a viewpoint is not over the longest drive allowed', () => { // A day may legitimately mix modes — splitIntoRuns exists for exactly that — @@ -541,9 +530,9 @@ describe('deriveDriveWarnings — what counts as driving', () => { [false, false, false], { legMinutes: 90, dayMinutes: null, rangeKm: null }, 0, - ) - expect(out.warnings).toEqual([]) - }) + ); + expect(out.warnings).toEqual([]); + }); it('FE-ROADTRIP-MODEL-086: walked kilometres are not on the tank', () => { // Nor does walking fill it: the budget is left exactly as the drive left it. @@ -552,11 +541,11 @@ describe('deriveDriveWarnings — what counts as driving', () => { [false, false, false, false], { legMinutes: null, dayMinutes: null, rangeKm: 100 }, 0, - ) + ); // 80 then 110 — the 60 walked kilometres never entered the budget. - expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 110 }]) - expect(out.carryKm).toBe(110) - }) + expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 110 }]); + expect(out.carryKm).toBe(110); + }); it('FE-ROADTRIP-MODEL-087: a walk does not count toward the driving time of the day', () => { const out = deriveDriveWarnings( @@ -564,9 +553,9 @@ describe('deriveDriveWarnings — what counts as driving', () => { [false, false, false], { legMinutes: null, dayMinutes: 90, rangeKm: null }, 0, - ) - expect(out.day).toBeNull() - }) + ); + expect(out.day).toBeNull(); + }); it('FE-ROADTRIP-MODEL-088: a leg with no mode is still a drive', () => { // Every leg was a drive before the field existed, so an absent mode counts. @@ -576,16 +565,14 @@ describe('deriveDriveWarnings — what counts as driving', () => { [false, false], { legMinutes: 90, dayMinutes: null, rangeKm: null }, 0, - ) - expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }]) - }) -}) + ); + expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }]); + }); +}); describe('deriveDriveWarnings — where the tank actually runs dry', () => { - const drive = (minutes: number, km: number) => - ({ duration: minutes * 60, distance: km * 1000, mode: 'driving' }) - const ferry = (minutes: number, km: number) => - ({ duration: minutes * 60, distance: km * 1000, mode: 'ferry' }) + const drive = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000, mode: 'driving' }); + const ferry = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000, mode: 'ferry' }); it('FE-ROADTRIP-MODEL-089: the dry point sits where the fuel ends, not where somebody notices', () => { // 300 km of range, two legs of 200. The warning lands on stop 2, which is 400 km in; @@ -596,12 +583,10 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [false, false, false], { legMinutes: null, dayMinutes: null, rangeKm: 300 }, 0, - ) - expect(out.warnings).toEqual([{ index: 2, code: 'range', sinceKm: 400 }]) - expect(out.emptyAt).toEqual([ - { legIndex: 1, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 }, - ]) - }) + ); + expect(out.warnings).toEqual([{ index: 2, code: 'range', sinceKm: 400 }]); + expect(out.emptyAt).toEqual([{ legIndex: 1, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 }]); + }); it('FE-ROADTRIP-MODEL-090: one dry point per tank, however many warnings the stretch produces', () => { // A long run with nothing on it warns at every stop on purpose, because a warning is @@ -612,11 +597,11 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [false, false, false, false], { legMinutes: null, dayMinutes: null, rangeKm: 600 }, 0, - ) - expect(out.warnings).toHaveLength(2) - expect(out.emptyAt).toHaveLength(1) - expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, drivenMeters: 600000 }) - }) + ); + expect(out.warnings).toHaveLength(2); + expect(out.emptyAt).toHaveLength(1); + expect(out.emptyAt[0]!).toMatchObject({ legIndex: 1, drivenMeters: 600000 }); + }); it('FE-ROADTRIP-MODEL-091: filling up starts a new tank, and the next one runs dry again', () => { const out = deriveDriveWarnings( @@ -624,11 +609,11 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [false, false, true, false], { legMinutes: null, dayMinutes: null, rangeKm: 600 }, 0, - ) + ); // Empty once before the fuel stop, then once more after it. - expect(out.emptyAt).toHaveLength(1) - expect(out.emptyAt[0].drivenMeters).toBe(600000) - }) + expect(out.emptyAt).toHaveLength(1); + expect(out.emptyAt[0]!.drivenMeters).toBe(600000); + }); it('FE-ROADTRIP-MODEL-092: a ferry carries the car without burning a drop', () => { // The trap this field exists for. The budget skips a ferry, so the dry point must be @@ -639,11 +624,9 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [false, false, false, false], { legMinutes: null, dayMinutes: null, rangeKm: 300 }, 0, - ) - expect(out.emptyAt).toEqual([ - { legIndex: 2, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 }, - ]) - }) + ); + expect(out.emptyAt).toEqual([{ legIndex: 2, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 }]); + }); it('FE-ROADTRIP-MODEL-093: a tank carried over midnight runs dry earlier the next day', () => { const out = deriveDriveWarnings( @@ -651,12 +634,10 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [false, false, false], { legMinutes: null, dayMinutes: null, rangeKm: 300 }, 250, - ) + ); // Only 50 km left on arrival, so it empties a quarter into the first leg. - expect(out.emptyAt).toEqual([ - { legIndex: 0, intoLegKm: 50, drivenMeters: 50000, sinceKm: 300 }, - ]) - }) + expect(out.emptyAt).toEqual([{ legIndex: 0, intoLegKm: 50, drivenMeters: 50000, sinceKm: 300 }]); + }); it('FE-ROADTRIP-MODEL-094: no range limit and no crossing produce no dry point at all', () => { const noLimit = deriveDriveWarnings( @@ -664,17 +645,17 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [false, false], { legMinutes: null, dayMinutes: null, rangeKm: null }, 0, - ) - expect(noLimit.emptyAt).toEqual([]) + ); + expect(noLimit.emptyAt).toEqual([]); const withinRange = deriveDriveWarnings( [drive(60, 100)], [false, false], { legMinutes: null, dayMinutes: null, rangeKm: 300 }, 0, - ) - expect(withinRange.emptyAt).toEqual([]) - }) + ); + expect(withinRange.emptyAt).toEqual([]); + }); it('FE-ROADTRIP-MODEL-095: an unrouted leg gives the tank up rather than guessing where it ends', () => { // The budget goes null and stays null, so there is no honest dry point to offer. @@ -683,53 +664,52 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [false, false, false, false], { legMinutes: null, dayMinutes: null, rangeKm: 300 }, 0, - ) - expect(out.emptyAt).toEqual([]) - expect(out.carryKm).toBeNull() - }) -}) + ); + expect(out.emptyAt).toEqual([]); + expect(out.carryKm).toBeNull(); + }); +}); describe('refuelsRange — what fills which tank', () => { it('FE-ROADTRIP-MODEL-096: with no vehicle named, either kind fills up', () => { // What TREK did before the setting existed, and the right answer for somebody who // never opened the dialog. Anything else would quietly change their warnings. - expect(refuelsRange('fuel')).toBe(true) - expect(refuelsRange('charging')).toBe(true) - expect(refuelsRange('fuel', null)).toBe(true) - expect(refuelsRange('charging', null)).toBe(true) - }) + expect(refuelsRange('fuel')).toBe(true); + expect(refuelsRange('charging')).toBe(true); + expect(refuelsRange('fuel', null)).toBe(true); + expect(refuelsRange('charging', null)).toBe(true); + }); it('FE-ROADTRIP-MODEL-097: a petrol station does not charge a battery', () => { // The bug this exists for: an electric car pausing at a petrol station had its // battery refilled on paper, the warnings went quiet for the rest of the day, and // the driver was told nothing. - expect(refuelsRange('charging', 'electric')).toBe(true) - expect(refuelsRange('fuel', 'electric')).toBe(false) - }) + expect(refuelsRange('charging', 'electric')).toBe(true); + expect(refuelsRange('fuel', 'electric')).toBe(false); + }); it('FE-ROADTRIP-MODEL-098: and a charger does not fill a tank', () => { - expect(refuelsRange('fuel', 'combustion')).toBe(true) - expect(refuelsRange('charging', 'combustion')).toBe(false) - }) + expect(refuelsRange('fuel', 'combustion')).toBe(true); + expect(refuelsRange('charging', 'combustion')).toBe(false); + }); it('FE-ROADTRIP-MODEL-099: nothing else refuels anything, whatever is driven', () => { for (const vehicle of [null, 'combustion', 'electric'] as const) { - expect(refuelsRange('rest_area', vehicle)).toBe(false) - expect(refuelsRange('restaurant', vehicle)).toBe(false) - expect(refuelsRange(null, vehicle)).toBe(false) + expect(refuelsRange('rest_area', vehicle)).toBe(false); + expect(refuelsRange('restaurant', vehicle)).toBe(false); + expect(refuelsRange(null, vehicle)).toBe(false); } - }) + }); it('FE-ROADTRIP-MODEL-100: the search looks for what the vehicle actually takes', () => { - expect(refuelStopTypeFor('combustion')).toEqual(['fuel']) - expect(refuelStopTypeFor('electric')).toEqual(['charging']) - expect(refuelStopTypeFor(null)).toEqual(['fuel', 'charging']) - }) -}) + expect(refuelStopTypeFor('combustion')).toEqual(['fuel']); + expect(refuelStopTypeFor('electric')).toEqual(['charging']); + expect(refuelStopTypeFor(null)).toEqual(['fuel', 'charging']); + }); +}); describe('deriveDriveWarnings — filling only part way', () => { - const drive = (minutes: number, km: number) => - ({ duration: minutes * 60, distance: km * 1000, mode: 'driving' }) + const drive = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000, mode: 'driving' }); it('FE-ROADTRIP-MODEL-101: a stop that fills to 80 % leaves a fifth already used', () => { // Nobody charges to 100 % on the road: the last fifth takes as long as the first @@ -739,24 +719,24 @@ describe('deriveDriveWarnings — filling only part way', () => { [false, true, false], { legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 }, 0, - ) + ); // After the stop the budget restarts at 100 km rather than 0, so the tank is dry // 400 km into the second leg. Filled all the way it would have gone the whole 450. - expect(out.emptyAt).toHaveLength(1) - expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 }) - }) + expect(out.emptyAt).toHaveLength(1); + expect(out.emptyAt[0]!).toMatchObject({ legIndex: 1, intoLegKm: 400 }); + }); it('FE-ROADTRIP-MODEL-102: filling all the way is what absent, zero and 100 all mean', () => { - const legs = [drive(60, 100), drive(60, 450)] - const refuels = [false, true, false] - const full = { legMinutes: null, dayMinutes: null, rangeKm: 500 } + const legs = [drive(60, 100), drive(60, 450)]; + const refuels = [false, true, false]; + const full = { legMinutes: null, dayMinutes: null, rangeKm: 500 }; for (const fillPercent of [undefined, null, 0, 100]) { - const out = deriveDriveWarnings(legs, refuels, { ...full, fillPercent }, 0) + const out = deriveDriveWarnings(legs, refuels, { ...full, fillPercent }, 0); // A full tank after the stop covers the remaining 450 km without a finding. - expect(out.emptyAt).toEqual([]) - expect(out.warnings).toEqual([]) + expect(out.emptyAt).toEqual([]); + expect(out.warnings).toEqual([]); } - }) + }); it('FE-ROADTRIP-MODEL-103: with no range set, the fill level changes nothing', () => { const out = deriveDriveWarnings( @@ -764,10 +744,10 @@ describe('deriveDriveWarnings — filling only part way', () => { [true, false], { legMinutes: null, dayMinutes: null, rangeKm: null, fillPercent: 50 }, 0, - ) - expect(out.warnings).toEqual([]) - expect(out.emptyAt).toEqual([]) - }) + ); + expect(out.warnings).toEqual([]); + expect(out.emptyAt).toEqual([]); + }); it('FE-ROADTRIP-MODEL-104: a stop that says how full it fills is read against itself', () => { // The motorway charger tops up to 60 %; the traveller's own default is 80. Reading @@ -778,12 +758,12 @@ describe('deriveDriveWarnings — filling only part way', () => { { legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 }, 0, [null, 60, null], - ) + ); // 60 % of 500 km is 300, so the tank is dry 300 km into the second leg rather than // the 400 the traveller's own figure would have given. - expect(out.emptyAt).toHaveLength(1) - expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 300 }) - }) + expect(out.emptyAt).toHaveLength(1); + expect(out.emptyAt[0]!).toMatchObject({ legIndex: 1, intoLegKm: 300 }); + }); it('FE-ROADTRIP-MODEL-105: a stop with no opinion still follows the traveller', () => { // Same day, same stop, nothing said about it: the default has to keep applying, or @@ -794,9 +774,9 @@ describe('deriveDriveWarnings — filling only part way', () => { { legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 }, 0, [null, null, null], - ) - expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 }) - }) + ); + expect(out.emptyAt[0]!).toMatchObject({ legIndex: 1, intoLegKm: 400 }); + }); it('FE-ROADTRIP-MODEL-106: a stop can also say it fills right up', () => { // The charger at the hotel: the car stands there all night, so this one goes to 100 @@ -807,7 +787,38 @@ describe('deriveDriveWarnings — filling only part way', () => { { legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 }, 0, [null, 100, null], - ) - expect(out.emptyAt).toEqual([]) - }) -}) + ); + expect(out.emptyAt).toEqual([]); + }); +}); + +describe('checkout without daily travel times', () => { + it('keeps the car at the hotel until the next day', () => { + const schedule = computeSchedule( + [ + { anchor: '16:00', dwellMinutes: 30, departureAt: 1440 + 720 }, + { anchor: null, dwellMinutes: 0 }, + ], + [3600], + ); + expect(schedule.entries[0]!.departure).toBe('12:00'); + expect(schedule.entries[1]!.arrival).toBe('13:00'); + expect(schedule.entries[1]!.dayOffset).toBe(1); + }); +}); + +it('uses checkout as departure even without an arrival or daily start', () => { + const schedule = computeSchedule( + [ + { anchor: null, dwellMinutes: 60 }, + { anchor: null, dwellMinutes: 60, departureAt: 1440 + 480 }, + { anchor: null, dwellMinutes: 0 }, + ], + [3600, 7200], + ); + expect(schedule.entries[0]!.arrival).toBeNull(); + expect(schedule.entries[1]!.arrival).toBeNull(); + expect(schedule.entries[1]!.departure).toBe('08:00'); + expect(schedule.entries[2]!.arrival).toBe('10:00'); + expect(schedule.entries[2]!.dayOffset).toBe(1); +}); diff --git a/shared/src/roadtrip/roadtripModel.ts b/shared/src/roadtrip/roadtripModel.ts new file mode 100644 index 000000000..4dc3fd878 --- /dev/null +++ b/shared/src/roadtrip/roadtripModel.ts @@ -0,0 +1,478 @@ +import type { RouteAvoidClass } from './planning-types'; + +export const SERVICE_STOP_TYPES = [ + 'fuel', + 'charging', + 'rest_area', + 'campsite', + 'restaurant', + 'sights', + 'hotel', +] as const; + +export const SERVICE_COLORS: Record = { + fuel: '#E8590C', + charging: '#CA8A04', + rest_area: '#3B82F6', + campsite: '#16A34A', + restaurant: '#EF4444', + sights: '#EC4899', + hotel: '#2563EB', +}; + +export function serviceColor(stopType: string | null | undefined): string { + return SERVICE_COLORS[stopType ?? ''] ?? '#64748B'; +} + +export function isServiceStopType(stopType: string | null | undefined): boolean { + return !!stopType && (SERVICE_STOP_TYPES as readonly string[]).includes(stopType); +} + +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); + + if (m === 60) return `${h + 1} h`; + if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h`; + return `${m} min`; +} + +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; +} + +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 { + departureAt?: number; + anchor: string | null; + + dwellMinutes: number | null; +} + +export interface ScheduleWarning { + index: number; + code: 'late' | 'overnight' | 'leg' | 'range'; + + minutes?: number; + + overMinutes?: number; + + sinceKm?: number; +} + +export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const; + +export type VehicleKind = 'combustion' | 'electric'; + +export function refuelStopTypeFor(vehicle: VehicleKind | null | undefined): readonly string[] { + if (vehicle === 'combustion') return ['fuel']; + if (vehicle === 'electric') return ['charging']; + return REFUELLING_STOP_TYPES; +} + +export function refuelsRange(stopType: string | null | undefined, vehicle?: VehicleKind | null): boolean { + return refuelStopTypeFor(vehicle).includes(stopType ?? ''); +} + +export interface DriveLimits { + fillPercent?: number | null; + + legMinutes: number | null; + + dayMinutes: number | null; + + rangeKm: number | null; +} + +export interface DayWarning { + code: 'dayDriving'; + + minutes: number; + limitMinutes: number; +} + +export interface ScheduleEntry { + arrival: string | null; + departure: string | null; + + anchored: boolean; + + dayOffset: number; +} + +export interface Schedule { + entries: ScheduleEntry[]; + warnings: ScheduleWarning[]; +} + +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 }; + + if (cursor === null) return { arrival: anchor + dayOffset * DAY_MINUTES, lateBy: null }; + + 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[] = []; + + const arrivals: (number | null)[] = new Array(stops.length).fill(null); + const anchored: boolean[] = new Array(stops.length).fill(false); + + 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) { + const leg = legSeconds[i]; + if (stop.departureAt !== undefined && leg !== undefined) cursor = stop.departureAt + Math.round(leg / 60); + 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 + : (stop.departureAt === undefined ? arrival + (stop.dwellMinutes ?? 0) : Math.max(arrival, stop.departureAt)) + + Math.round(leg / 60); + } + + const firstKnown = arrivals.findIndex((a) => a !== null); + for (let i = firstKnown - 1; i >= 0; i--) { + if (stops[i]!.departureAt !== undefined) break; + 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); + } + + const earliest = arrivals.reduce((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: stops[i]!.departureAt === undefined ? null : formatClock(stops[i]!.departureAt!), + anchored: false, + dayOffset: 0, + }); + continue; + } + const arrival = raw + shift; + const offset = Math.floor(arrival / DAY_MINUTES); + + if (offset > lastOffset) warnings.push({ index: i, code: 'overnight' }); + lastOffset = offset; + entries.push({ + arrival: formatClock(arrival), + departure: formatClock( + stops[i]!.departureAt === undefined + ? arrival + (stops[i]!.dwellMinutes ?? 0) + : Math.max(arrival, stops[i]!.departureAt! + shift), + ), + anchored: anchored[i]!, + dayOffset: offset, + }); + } + + return { entries, warnings }; +} + +export function splitIntoRuns(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; +} + +export function sumLegSeconds(legSeconds: (number | undefined)[]): number { + return legSeconds.reduce((sum, s) => sum + (s ?? 0), 0); +} + +export interface DryPoint { + inboundLine?: [number, number][]; + + legIndex: number; + + intoLegKm: number; + + drivenMeters: number; + + sinceKm: number; +} + +export function deriveDriveWarnings( + legs: ({ duration?: number; distance?: number; mode?: string } | undefined)[], + + refuelsAt: boolean[], + limits: DriveLimits, + + carryKm: number | null, + + 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; + + let drivenMeters = 0; + + 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]; + + const at = i + 1; + + 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; + + 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), + }); + } + + if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]!) { + warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) }); + } + drivenMeters += metres; + } + } + + 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; +} + +export interface SectionAnchor { + kind: 'stop' | 'leg'; + + 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); +} + +export interface AnchoredVia { + id: number; + after_order_index: number; + lat: number; + lng: number; +} + +export interface ReanchoredVia { + id: number; + after_order_index: number; +} + +export interface Reanchoring { + vias: ReanchoredVia[]; + remove: number[]; +} + +const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] }; + +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 }; +} + +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: [] }; +} + +export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring { + if (!vias.length) return EMPTY_REANCHORING; + + 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; + }); +} + +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); + + 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; + + 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)); + + 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 }; +} + +export function isEmptyReanchoring(r: Reanchoring): boolean { + return r.vias.length === 0 && r.remove.length === 0; +} + +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)); +} + +export const AVOIDABLE: readonly RouteAvoidClass[] = ['toll', 'motorway', 'ferry']; + +export function serializeAvoid(classes: readonly RouteAvoidClass[]): string { + return AVOIDABLE.filter((cls) => classes.includes(cls)).join(','); +} diff --git a/shared/src/roadtrip/routeRun.ts b/shared/src/roadtrip/routeRun.ts new file mode 100644 index 000000000..854fe7aef --- /dev/null +++ b/shared/src/roadtrip/routeRun.ts @@ -0,0 +1,53 @@ +import { lineMetres, sliceAtMeters, projectOntoRoute } from './corridor'; +import type { RoadtripStop, RouteSegment, RouteVia, RoutedLeg } from './planning-types'; +import { legIndexForAlong } from './roadtripModel'; + +export const roadtripStopKey = (stop: RoadtripStop): string => + `${stop.lat.toFixed(5)},${stop.lng.toFixed(5)},${stop.legMode ?? ''},${stop.incomingLegMode ?? ''}`; +export const roadtripLegKey = (from: RoadtripStop, to: RoadtripStop): string => + `${roadtripStopKey(from)}>${roadtripStopKey(to)}`; + +export function foldRouteRun( + stops: RoadtripStop[], + stopAt: number[], + route: { coordinates: [number, number][]; legs: RouteSegment[]; vias?: RouteVia[] }, + mode: string, +): Record { + const spine = route.coordinates.map(([lat, lng]) => ({ lat, lng })); + const cuts: { key: string; from: number; to: number; seg: RouteSegment }[] = []; + let travelled = 0; + for (let index = 0; index < stops.length - 1; index++) { + const parts = route.legs.slice(stopAt[index], stopAt[index + 1]); + if (!parts.length) continue; + const merged = + parts.length === 1 + ? parts[0]! + : { + ...parts[0]!, + distance: parts.reduce((sum, leg) => sum + leg.distance, 0), + duration: parts.reduce((sum, leg) => sum + leg.duration, 0), + }; + const from = travelled; + travelled += merged.distance; + cuts.push({ key: roadtripLegKey(stops[index]!, stops[index + 1]!), from, to: travelled, seg: { ...merged, mode } }); + } + const scale = travelled > 0 ? lineMetres(spine) / travelled : 0; + const legs: Record = {}; + for (const cut of cuts) + legs[cut.key] = { + seg: cut.seg, + line: sliceAtMeters(spine, cut.from * scale, cut.to * scale).map((p) => [p.lat, p.lng]), + vias: [], + }; + for (const via of route.vias ?? []) { + if (!via.label && via.dwellSeconds == null) continue; + const along = (projectOntoRoute(via, spine)?.alongKm ?? 0) * 1000; + const index = legIndexForAlong( + cuts.map((cut) => cut.to), + along, + ); + const cut = cuts[index]; + if (cut) legs[cut.key]!.vias.push(via); + } + return legs; +} diff --git a/shared/src/roadtrip/search.schema.spec.ts b/shared/src/roadtrip/search.schema.spec.ts new file mode 100644 index 000000000..c01b7ad38 --- /dev/null +++ b/shared/src/roadtrip/search.schema.spec.ts @@ -0,0 +1,14 @@ +import { roadtripSearchAreaSchema } from './search.schema'; + +import { describe, expect, it } from 'vitest'; + +describe('Roadtrip search area', () => { + const request = { categories: ['fuel'], bbox: { south: 48, north: 49, west: 10, east: 11 } }; + it('validates categories and ordered geographic bounds', () => { + expect(roadtripSearchAreaSchema.safeParse(request).success).toBe(true); + expect(roadtripSearchAreaSchema.safeParse({ ...request, categories: ['unknown'] }).success).toBe(false); + expect(roadtripSearchAreaSchema.safeParse({ ...request, bbox: { ...request.bbox, south: 50 } }).success).toBe( + false, + ); + }); +}); diff --git a/shared/src/roadtrip/search.schema.ts b/shared/src/roadtrip/search.schema.ts new file mode 100644 index 000000000..239c7a39d --- /dev/null +++ b/shared/src/roadtrip/search.schema.ts @@ -0,0 +1,52 @@ +import { roadtripCorridorRequestSchema } from './planning.schema'; + +import { z } from 'zod'; + +export const roadtripSearchAreaSchema = z.object({ + categories: z.array(roadtripCorridorRequestSchema.shape.category).min(1).max(7), + bbox: z + .object({ + south: z.number().min(-90).max(90), + north: z.number().min(-90).max(90), + west: z.number().min(-180).max(180), + east: z.number().min(-180).max(180), + }) + .refine((b) => b.south < b.north && b.west < b.east, 'Invalid search area'), + lang: z.string().max(20).optional(), +}); +export type RoadtripSearchArea = z.infer; + +export const roadtripSearchPoiSchema = z.object({ + osm_id: z.string(), + name: z.string(), + lat: z.number(), + lng: z.number(), + category: z.string(), + poi_type: z.string(), + source: z.string(), + address: z.string().nullable(), + website: z.string().nullable(), + phone: z.string().nullable(), + opening_hours: z.string().nullable(), + cuisine: z.string().nullable(), + brand: z.string().nullable().optional(), + brand_wikidata: z.string().nullable().optional(), + charging: z + .object({ + sockets: z.array(z.object({ type: z.string(), count: z.number().nullable(), kw: z.number().nullable() })), + capacity: z.number().nullable(), + fee: z.boolean().nullable(), + }) + .nullable() + .optional(), + rating: z.number().nullable().optional(), + pluginId: z.string().optional(), +}); +export const roadtripSearchAreaResponseSchema = z.object({ + pois: z.array(roadtripSearchPoiSchema), + sources: z.array(z.string()), + failedSources: z.array(z.string()), + truncated: z.boolean(), + clamped: z.boolean(), +}); +export type RoadtripSearchAreaResponse = z.infer; diff --git a/shared/src/roadtrip/splitScheduledDays.spec.ts b/shared/src/roadtrip/splitScheduledDays.spec.ts new file mode 100644 index 000000000..a0cbb89d0 --- /dev/null +++ b/shared/src/roadtrip/splitScheduledDays.spec.ts @@ -0,0 +1,66 @@ +import type { SpillChain } from './nightSpill'; +import type { RoadtripStop, RoutedLeg } from './planning-types'; +import { splitScheduledDays } from './splitScheduledDays'; + +import { describe, expect, it } from 'vitest'; + +const chain = (): SpillChain => ({ + dayId: 2, + dayNumber: 2, + date: '2026-09-12', + title: null, + stops: [0, 1, 2].map((i) => ({ placeId: i + 1, dwellMinutes: 60, time: null }) as RoadtripStop), + schedule: { + entries: [ + { arrival: '22:12', departure: '23:12', anchored: false, dayOffset: 0 }, + ...[1, 2].map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), + ], + warnings: [], + }, + spills: [], +}); +const lookup = (): RoutedLeg => + ({ + seg: { + duration: 198 * 60, + distance: 300000, + from: [0, 0], + to: [1, 1], + mid: [0.5, 0.5], + walkingText: '', + drivingText: '', + distanceText: '300 km', + }, + line: [ + [0, 0], + [1, 1], + ], + vias: [], + }) as RoutedLeg; +describe('scheduled day overflow', () => { + it('creates a display day for the stops after midnight', () => { + const source = chain(); + const days = splitScheduledDays([source], [source], lookup); + expect(days.map((day) => day.dayNumber)).toEqual([2, 3]); + expect(days[1]!.dayId).toBe(-1000000003); + expect(days[1]!.date).toBe('2026-09-13'); + expect(days[1]!.schedule.entries.map((entry) => entry.arrival)).toEqual(['02:30', '06:48']); + expect(days[1]!.schedule.entries.every((entry) => entry.dayOffset === 0)).toBe(true); + expect(days[1]!.spills[0]).toMatchObject({ fromDayNumber: 2, at: 0, count: 2, departure: '23:12' }); + expect(source.stops).toHaveLength(3); + }); + it('uses an existing empty day and keeps an earlier spill group', () => { + const source = chain(); + source.spills = [ + { at: 0, count: 1, fromDayNumber: 1, departure: '18:00', leg: undefined, line: [], fromStop: undefined }, + ]; + const days = splitScheduledDays( + [source], + [source, { dayId: 99, dayNumber: 3, date: '2026-09-13', title: 'Rome', stops: [] }], + lookup, + ); + expect(days[1]!.dayId).toBe(99); + expect(days[0]!.spills[0]!.fromDayNumber).toBe(1); + expect(days[1]!.spills[0]!.fromDayNumber).toBe(2); + }); +}); diff --git a/shared/src/roadtrip/splitScheduledDays.ts b/shared/src/roadtrip/splitScheduledDays.ts new file mode 100644 index 000000000..fa3e1d6c3 --- /dev/null +++ b/shared/src/roadtrip/splitScheduledDays.ts @@ -0,0 +1,75 @@ +import { continueSchedule } from './continueSchedule'; +import type { SpillChain, SpillMark } from './nightSpill'; +import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types'; + +export function splitScheduledDays( + chains: SpillChain[], + days: PlanDay[], + legFor: (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined, +): SpillChain[] { + const grouped = new Map(); + for (const chain of chains) { + const schedule = continueSchedule( + chain.stops, + chain.schedule, + chain.stops.slice(0, -1).map((stop, i) => legFor(stop, chain.stops[i + 1]!)?.seg.duration), + chain.dayNumber, + ); + chain.stops.forEach((stop, i) => { + const entry = schedule.entries[i]!; + const offset = Math.max(0, entry.dayOffset); + const number = chain.dayNumber + offset; + let target = grouped.get(number); + if (!target) { + const stored = days.find((day) => day.dayNumber === number); + let date = stored?.date ?? null; + if (!date && chain.date) { + const shifted = new Date(chain.date + 'T12:00:00Z'); + shifted.setUTCDate(shifted.getUTCDate() + offset); + date = shifted.toISOString().slice(0, 10); + } + target = { + dayId: stored?.dayId ?? -1000000000 - number, + dayNumber: number, + date, + title: stored?.title ?? null, + stops: [], + schedule: { entries: [], warnings: [] }, + spills: [], + }; + grouped.set(number, target); + } + const index = target.stops.length; + target.stops.push(stop); + target.schedule.entries.push({ ...entry, dayOffset: 0 }); + target.schedule.warnings.push( + ...schedule.warnings + .filter((warning) => warning.index === i && !(offset && warning.code === 'overnight')) + .map((warning) => ({ ...warning, index })), + ); + const inherited = chain.spills.find((spill) => i >= spill.at && i < spill.at + spill.count); + const fromDayNumber = offset ? chain.dayNumber : inherited?.fromDayNumber; + if (fromDayNumber !== undefined) { + const last = target.spills[target.spills.length - 1]; + if (last && last.fromDayNumber === fromDayNumber && last.at + last.count === index) last.count += 1; + else { + const from = i > 0 ? chain.stops[i - 1] : inherited?.fromStop; + const leg = from ? legFor(from, stop) : undefined; + const mark: SpillMark = offset + ? { + at: index, + count: 1, + fromDayNumber, + departure: schedule.entries[i - 1]?.departure ?? inherited?.departure ?? null, + fromStop: from, + leg: leg?.seg, + line: leg?.line ?? [], + } + : { ...inherited!, at: index, count: 1 }; + target.spills.push(mark); + } + } + }); + } + return [...grouped.values()].sort((a, b) => a.dayNumber - b.dayNumber); +} diff --git a/shared/src/roadtrip/units.ts b/shared/src/roadtrip/units.ts new file mode 100644 index 000000000..8599916f1 --- /dev/null +++ b/shared/src/roadtrip/units.ts @@ -0,0 +1,32 @@ +import type { DistanceUnit } from './planning-types'; + +const KM_TO_MI = 0.621371; +const M_TO_FT = 3.28084; + +export function getDistanceUnitLabel(unit: DistanceUnit): 'km' | 'mi' { + return unit === 'imperial' ? 'mi' : 'km'; +} + +export function formatElevation(meters: number, unit: DistanceUnit): string { + const safe = Number.isFinite(meters) ? meters : 0; + return unit === 'imperial' ? `${Math.round(safe * M_TO_FT)} ft` : `${Math.round(safe)} m`; +} + +export function convertDistance(km: number, unit: DistanceUnit): number { + const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0; + return unit === 'imperial' ? safeKm * KM_TO_MI : safeKm; +} + +export function formatDistance(km: number, unit: DistanceUnit): string { + const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0; + + if (unit === 'metric' && safeKm < 1) { + return `${Math.round(safeKm * 1000)} m`; + } + const value = convertDistance(safeKm, unit); + const label = getDistanceUnitLabel(unit); + const rounded = Math.round(value * 10) / 10; + + const text = value > 0 && rounded === 0 ? '<0.1' : String(rounded); + return `${text} ${label}`; +} diff --git a/client/src/components/Roadtrip/vehicleRange.test.ts b/shared/src/roadtrip/vehicleRange.spec.ts similarity index 74% rename from client/src/components/Roadtrip/vehicleRange.test.ts rename to shared/src/roadtrip/vehicleRange.spec.ts index eaabec5e1..84eeaf91b 100644 --- a/client/src/components/Roadtrip/vehicleRange.test.ts +++ b/shared/src/roadtrip/vehicleRange.spec.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest' -import { rangeFromSpec, effectiveRangeKm, showSpec, storeSpec, specUnit } from './vehicleRange' +import { rangeFromSpec, effectiveRangeKm, showSpec, storeSpec, specUnit } from './vehicleRange'; + +import { describe, it, expect } from 'vitest'; /** * FE-ROADTRIP-VEHRANGE-001..013 — a range stated as the parts it is made of. @@ -11,119 +12,119 @@ import { rangeFromSpec, effectiveRangeKm, showSpec, storeSpec, specUnit } from ' describe('rangeFromSpec', () => { it('FE-ROADTRIP-VEHRANGE-001: a tank divided by a consumption is a range', () => { - expect(rangeFromSpec('combustion', { tankLitres: 55, litresPer100: 7 })).toBe(786) - }) + expect(rangeFromSpec('combustion', { tankLitres: 55, litresPer100: 7 })).toBe(786); + }); it('FE-ROADTRIP-VEHRANGE-002: a battery divided by a consumption is a range', () => { - expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16 })).toBe(363) - }) + expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16 })).toBe(363); + }); it('FE-ROADTRIP-VEHRANGE-003: age comes off the battery before the division', () => { // 58 kWh minus a tenth is 52.2, and 52.2 at 16 per hundred is 326.25 km. - expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 10 })).toBe(326) - }) + expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 10 })).toBe(326); + }); it('FE-ROADTRIP-VEHRANGE-004: half a pair is not a range', () => { // Guessing the missing half would put the warnings at the wrong place while // looking exact, which is worse than saying nothing. - expect(rangeFromSpec('combustion', { tankLitres: 55 })).toBeNull() - expect(rangeFromSpec('combustion', { litresPer100: 7 })).toBeNull() - expect(rangeFromSpec('electric', { batteryKwh: 58 })).toBeNull() - }) + expect(rangeFromSpec('combustion', { tankLitres: 55 })).toBeNull(); + expect(rangeFromSpec('combustion', { litresPer100: 7 })).toBeNull(); + expect(rangeFromSpec('electric', { batteryKwh: 58 })).toBeNull(); + }); it('FE-ROADTRIP-VEHRANGE-005: figures belong to the kind of car they came from', () => { // A tank size on an electric car is a leftover from before the picker changed, // not a battery. - expect(rangeFromSpec('electric', { tankLitres: 55, litresPer100: 7 })).toBeNull() - expect(rangeFromSpec('combustion', { batteryKwh: 58, kwhPer100: 16 })).toBeNull() - expect(rangeFromSpec(null, { tankLitres: 55, litresPer100: 7 })).toBeNull() - }) + expect(rangeFromSpec('electric', { tankLitres: 55, litresPer100: 7 })).toBeNull(); + expect(rangeFromSpec('combustion', { batteryKwh: 58, kwhPer100: 16 })).toBeNull(); + expect(rangeFromSpec(null, { tankLitres: 55, litresPer100: 7 })).toBeNull(); + }); it('FE-ROADTRIP-VEHRANGE-006: a battery reported as entirely gone is a typo, not a car', () => { // Capped at 90 %, because zero usable capacity would put the dry point at the // first metre of the trip and divide by nothing. - expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 100 })).toBe(36) - }) + expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 100 })).toBe(36); + }); it('FE-ROADTRIP-VEHRANGE-007: nonsense in a field is the same as an empty field', () => { - expect(rangeFromSpec('combustion', { tankLitres: 0, litresPer100: 7 })).toBeNull() - expect(rangeFromSpec('combustion', { tankLitres: -55, litresPer100: 7 })).toBeNull() - expect(rangeFromSpec('combustion', { tankLitres: Number.NaN, litresPer100: 7 })).toBeNull() - }) -}) + expect(rangeFromSpec('combustion', { tankLitres: 0, litresPer100: 7 })).toBeNull(); + expect(rangeFromSpec('combustion', { tankLitres: -55, litresPer100: 7 })).toBeNull(); + expect(rangeFromSpec('combustion', { tankLitres: Number.NaN, litresPer100: 7 })).toBeNull(); + }); +}); describe('effectiveRangeKm', () => { it('FE-ROADTRIP-VEHRANGE-008: the parts win over a typed round number', () => { // Somebody who gave a tank size and a consumption said something more exact // than "about 500", and the dialog shows them the result. - expect(effectiveRangeKm('combustion', { tankLitres: 55, litresPer100: 7 }, 500)).toBe(786) - }) + expect(effectiveRangeKm('combustion', { tankLitres: 55, litresPer100: 7 }, 500)).toBe(786); + }); it('FE-ROADTRIP-VEHRANGE-009: an incomplete pair hands the typed number straight back', () => { - expect(effectiveRangeKm('combustion', { tankLitres: 55 }, 500)).toBe(500) - expect(effectiveRangeKm(null, {}, 500)).toBe(500) - }) + expect(effectiveRangeKm('combustion', { tankLitres: 55 }, 500)).toBe(500); + expect(effectiveRangeKm(null, {}, 500)).toBe(500); + }); it('FE-ROADTRIP-VEHRANGE-010: no figures anywhere is no limit', () => { - expect(effectiveRangeKm(null, {}, 0)).toBeNull() - expect(effectiveRangeKm('electric', {}, undefined)).toBeNull() - }) -}) + expect(effectiveRangeKm(null, {}, 0)).toBeNull(); + expect(effectiveRangeKm('electric', {}, undefined)).toBeNull(); + }); +}); describe('unit round trip', () => { it('FE-ROADTRIP-VEHRANGE-011: metric figures are stored exactly as typed', () => { - expect(storeSpec('tankLitres', 55, false)).toBe(55) - expect(showSpec('tankLitres', 55, false)).toBe(55) - expect(showSpec('litresPer100', 7.4, false)).toBe(7.4) - }) + expect(storeSpec('tankLitres', 55, false)).toBe(55); + expect(showSpec('tankLitres', 55, false)).toBe(55); + expect(showSpec('litresPer100', 7.4, false)).toBe(7.4); + }); it('FE-ROADTRIP-VEHRANGE-012: gallons go in and come back out as the same gallons', () => { // 15 US gallons is 56.78 litres, and storage is litres — the same rule the // range itself follows, where storage is always kilometres. - const stored = storeSpec('tankLitres', 15, true) - expect(stored).toBe(56.78) - expect(showSpec('tankLitres', stored, true)).toBe(15) - }) + const stored = storeSpec('tankLitres', 15, true); + expect(stored).toBe(56.78); + expect(showSpec('tankLitres', stored, true)).toBe(15); + }); it('FE-ROADTRIP-VEHRANGE-013: miles per gallon is the inverse, and survives the trip', () => { // The one field that is not a scale factor: 30 mpg is 7.84 L/100 km, and a // straight multiplication would have stored a car four times as thirsty. - const stored = storeSpec('litresPer100', 30, true) - expect(stored).toBe(7.84) - expect(showSpec('litresPer100', stored, true)).toBe(30) - expect(specUnit('litresPer100', true)).toBe('mpg') - expect(specUnit('litresPer100', false)).toBe('L/100 km') - }) + const stored = storeSpec('litresPer100', 30, true); + expect(stored).toBe(7.84); + expect(showSpec('litresPer100', stored, true)).toBe(30); + expect(specUnit('litresPer100', true)).toBe('mpg'); + expect(specUnit('litresPer100', false)).toBe('L/100 km'); + }); it('FE-ROADTRIP-VEHRANGE-014: electric consumption is quoted per 100 miles', () => { // What the EPA prints on the window sticker, so the figure is one a US // traveller can copy rather than convert. - expect(showSpec('kwhPer100', 16, true)).toBe(25.7) - expect(specUnit('kwhPer100', true)).toBe('kWh/100 mi') + expect(showSpec('kwhPer100', 16, true)).toBe(25.7); + expect(specUnit('kwhPer100', true)).toBe('kWh/100 mi'); // A capacity is a capacity everywhere. - expect(specUnit('batteryKwh', true)).toBe('kWh') - expect(showSpec('batteryKwh', 58, true)).toBe(58) - }) + expect(specUnit('batteryKwh', true)).toBe('kWh'); + expect(showSpec('batteryKwh', 58, true)).toBe(58); + }); it('FE-ROADTRIP-VEHRANGE-016: a consumption typed a hundred times too high does not silence the typed range', () => { // A consumption of 500 rather than 5.0 makes the parts round to nothing. Zero used // to count as an answer, so the `??` in effectiveRangeKm never reached the 500 km // the traveller had typed by hand, and every range warning went quiet with nothing // on screen to say which field did it. - expect(rangeFromSpec('combustion', { tankLitres: 1, litresPer100: 500 })).toBeNull() - expect(effectiveRangeKm('combustion', { tankLitres: 1, litresPer100: 500 }, 500)).toBe(500) + expect(rangeFromSpec('combustion', { tankLitres: 1, litresPer100: 500 })).toBeNull(); + expect(effectiveRangeKm('combustion', { tankLitres: 1, litresPer100: 500 }, 500)).toBe(500); // Same shape on the other kind of car, where a decimal point is just as easy to lose. - expect(rangeFromSpec('electric', { batteryKwh: 0.5, kwhPer100: 200 })).toBeNull() - expect(effectiveRangeKm('electric', { batteryKwh: 0.5, kwhPer100: 200 }, 420)).toBe(420) + expect(rangeFromSpec('electric', { batteryKwh: 0.5, kwhPer100: 200 })).toBeNull(); + expect(effectiveRangeKm('electric', { batteryKwh: 0.5, kwhPer100: 200 }, 420)).toBe(420); // One kilometre is still a figure, so the cut is below it and not at some round guess. - expect(rangeFromSpec('combustion', { tankLitres: 1, litresPer100: 100 })).toBe(1) - }) + expect(rangeFromSpec('combustion', { tankLitres: 1, litresPer100: 100 })).toBe(1); + }); it('FE-ROADTRIP-VEHRANGE-015: an empty field stays empty rather than becoming a zero', () => { - expect(showSpec('tankLitres', undefined, false)).toBeUndefined() - expect(showSpec('tankLitres', 0, true)).toBeUndefined() - expect(storeSpec('tankLitres', 0, true)).toBe(0) - }) -}) + expect(showSpec('tankLitres', undefined, false)).toBeUndefined(); + expect(showSpec('tankLitres', 0, true)).toBeUndefined(); + expect(storeSpec('tankLitres', 0, true)).toBe(0); + }); +}); diff --git a/shared/src/roadtrip/vehicleRange.ts b/shared/src/roadtrip/vehicleRange.ts new file mode 100644 index 000000000..51ff05fcd --- /dev/null +++ b/shared/src/roadtrip/vehicleRange.ts @@ -0,0 +1,110 @@ +import type { VehicleKind } from './roadtripModel'; + +export interface VehicleSpec { + tankLitres?: number | null; + + litresPer100?: number | null; + + batteryKwh?: number | null; + + kwhPer100?: number | null; + + degradationPercent?: number | null; +} + +function positive(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null; +} + +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; + + 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; +} + +export function effectiveRangeKm( + vehicle: VehicleKind | null | undefined, + spec: VehicleSpec, + typedKm: number | null | undefined, +): number | null { + return rangeFromSpec(vehicle, spec) ?? positive(typedKm); +} + +export type SpecKey = keyof VehicleSpec; + +const LITRES_PER_GALLON = 3.785411784; + +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 '%'; + } +} + +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; + + 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; +} + +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)); +} + +export function storeSpec(key: SpecKey, shown: number, imperial: boolean): number { + if (!(shown > 0)) return 0; + return round(convert(key, shown, imperial, true), 2); +} diff --git a/shared/tsdown.config.ts b/shared/tsdown.config.ts index c1e5ade88..f7edfd224 100644 --- a/shared/tsdown.config.ts +++ b/shared/tsdown.config.ts @@ -1,8 +1,8 @@ -import { defineConfig } from 'tsdown' +import { defineConfig } from 'tsdown'; export default defineConfig({ // Root barrel + i18n metadata barrel + one entry per locale (lazy-load chunks) - entry: ['src/index.ts', 'src/i18n/index.ts', 'src/i18n/*/index.ts'], + entry: ['src/index.ts', 'src/roadtrip/planning.ts', 'src/i18n/index.ts', 'src/i18n/*/index.ts'], format: ['cjs', 'esm'], dts: true, clean: true, @@ -10,4 +10,4 @@ export default defineConfig({ neverBundle: ['zod'], }, target: false, -}) +}); diff --git a/wiki/Admin-Panel-Overview.md b/wiki/Admin-Panel-Overview.md index a331754c7..727c0ee26 100644 --- a/wiki/Admin-Panel-Overview.md +++ b/wiki/Admin-Panel-Overview.md @@ -30,6 +30,14 @@ The Admin Panel is divided into tabs. Most tabs are always visible; a few appear ![Admin panel on the User Defaults tab, setting instance-wide defaults for colour mode, temperature unit, distance unit, time format, currency and blurred booking codes](assets/AdminUserDefaults.png) +### Routing services + +On desktop and mobile, **User Defaults** includes optional **Custom routing instance** and **Custom Valhalla instance** fields in the map section. Changes save when you leave the field; **reset** restores the built-in default. These fields are hidden on managed instances. + +With both fields empty, TREK uses public OSRM servers for routing and the public FOSSGIS Valhalla for avoiding toll roads, motorways and ferries. Configuring only a custom routing instance disables the public Valhalla fallback. After entering a custom server URL, restart TREK and reload the page. + +> **AI / MCP:** These fields configure browser routing services and do not change stored trip data. + ### Linking to a tab directly Every panel is reachable by URL, so a bookmark, an onboarding mail or a support reply can point at the one tab it is about instead of at the top of a page with thirteen of them: diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index b17888cfd..df086f90a 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -185,6 +185,27 @@ Requires `journey:read` or `journey:write` scope. --- +### Roadtrip + +The Roadtrip addon must be enabled. These tools work without an open browser. The external assistant chooses places based on the traveller's interests, uses the existing trip, day, place and assignment tools to save them, and recalculates to check the result. + +| Tool | Purpose | Scope | +|---|---|---| +| `get_roadtrip_context` | Saved days, visits, coordinates, stays, pinned times, vehicle preferences, route profiles, vias, tracks and manual boundaries | trips:read | +| `calculate_roadtrip` | Calculated days, arrivals, departures, automatic pauses, driving warnings and range warnings; optional geometry | trips:read | +| `get_roadtrip_settings` | Shared driving preferences for the specified trip | trips:read | +| `update_roadtrip_settings` | Patch shared trip driving preferences, preserving other settings | trips:write | +| `search_roadtrip_corridor` | Fuel, charging, rest areas, campsites, food, sights or hotels along a day | trips:read | +| `update_route_via` | Move an existing routing handle and optionally change its outgoing leg | trips:write | + +Driving preferences include daily times, day-ending mode, leg/day driving limits, fuel or electric vehicle specifications, fallback range, fill percentage, avoidance and route display. They belong to the specified trip and apply equally to all its travellers. Both settings tools require tripId. Changing driving preferences requires day-edit permission, as do changes to visits, vias and endings. Fixed visit times retain priority over automatic times. Turning daily travel times off preserves saved endings but stops applying them. + +Calculation distances are metres, route durations seconds, and stays minutes. Settings use kilometres, litres, kWh, consumption per 100 km and percentages regardless of display units. Zero clears a numeric limit; an empty daily time disables the automatic window. Vehicle specifications take precedence over fallback range when complete. The optional calculation settings are a preview and are never saved. Calculations support up to 150 visits and 100 waypoints per routing run (30 for plugin profiles). Routing calls are paced and cached. Missing coordinates, provider failures and schedule conflicts are reported explicitly; incomplete totals must not be presented as a complete itinerary. Avoidance is a routing preference, and `avoidMissed` identifies requested classes that could not be avoided, including when Valhalla falls back to OSRM. + +Corridor results include source attribution, distance along/from the route, failed areas and truncated areas. Follow `nextOffset` for remaining search rectangles. Filters include name or brand, socket type, minimum known charging power and `fromKm`/`toKm`. Unknown charging power remains unknown. Search never adds places automatically. Use the returned place information with `create_and_assign_place`, then move or reorder the assignment and re-anchor vias as needed. Recalculate after editing. + +Existing `list_route_vias`, `add_route_via`, `add_route_vias`, `reanchor_route_vias` and `remove_route_via` manage scenic detours and followed tracks. `list_day_boundaries`, `set_day_boundary` and `set_assignment_end_day` manage manual endings. The Places tool `import_trip_gpx` accepts GPX XML up to one million characters, uses the standard importer and requires places:write. It imports waypoints, routes and tracks without assigning them to days; `export_trip_gpx` exports the trip. Stay durations and place-level time defaults can be cleared with null through `update_place`. + ## Addon-gated resources Resources provide read-only access via `trek://` URIs. The following resources require their addon to be enabled. @@ -214,9 +235,17 @@ Resources provide read-only access via `trek://` URIs. The following resources r --- +Roadtrip also exposes `get_roadtrip_hazards` (`tripId`, trips read scope). It returns current DWD/GDACS notices with geometry, source timestamps and availability. This does not reroute the trip. The shared `roadtrip_show_hazards` setting controls the online map overlay. + ## Related - [MCP-Tools-and-Resources](MCP-Tools-and-Resources) - [MCP-Scopes](MCP-Scopes) - [MCP-Prompts](MCP-Prompts) - [MCP-Setup](MCP-Setup) + +Roadtrip corridor searches include installed search-provider plugins. Providers receive the category and search bounds, and the host filters hits to the route. The response lists successful and failed sources; a failed provider does not discard the remaining results. + +Google Maps directions links can be reviewed with preview_google_maps_route and saved with import_google_maps_route. Import creates places and visits together in the supplied order, with trip access and place/day permission checks. Unresolved stops must be omitted from the confirmed input. The Google road geometry is not copied. + +get_roadtrip_charging_info accepts tripId and placeId. It requires trip access and the Roadtrip addon. It reports matching failures, unavailable feeds, freshness, known free capacity and unknown status counts separately. Published tariff components include currency, tax handling and conditions; they do not estimate a user's roaming price. Data source: [MobiData BW OCPDB](https://api.mobidata-bw.de/), with per-source attribution from its public source registry. diff --git a/wiki/Trip-Planner-Overview.md b/wiki/Trip-Planner-Overview.md index 957116dc0..f70b928c6 100644 --- a/wiki/Trip-Planner-Overview.md +++ b/wiki/Trip-Planner-Overview.md @@ -45,6 +45,30 @@ The tab bar sits directly below the main navigation bar. The active tab is saved in `sessionStorage` per trip, so switching between trips preserves your last position. +## Roadtrip daily start and end times + +Nearby station search results group into count badges when zoomed out. Click a badge to zoom into its stations. Stations that still overlap at close zoom appear in a selectable list. Planned stops, including photo markers, also group into count badges when zoomed out. Day endings remain separate. This works with all supported map providers in Roadtrip mode. + +In the Roadtrip view, open **Driving settings** and enter a **Day start** and **Day end** in HH:mm format. Both values enable automatic daily scheduling and connect the drives between days. Clear either field to return to the existing schedule. These preferences belong to the trip. All travellers see the same daily schedule, vehicle settings and driving limits. Members with permission to edit days can change them. Existing trips inherit their owner's previous values once; later changes affect only that trip. + +Driving pauses at the end time and resumes from the same location at the next day's start time. Stops and visit durations determine where each pause falls. Editing, adding or removing stops recalculates the pauses and subsequent arrivals. If a visit crosses the end time, the remaining visit continues there the next morning before driving resumes. + +Choose **Along the route** to pause at the point reached at the cutoff, or **At the last place** to stay after the last visit when the next place cannot be reached before the cutoff. For example, arriving at 16:00 and staying for one hour ends the day at 17:00 if the next drive takes two hours and the cutoff is 18:00. That drive starts from the same place the next morning. If a single drive cannot fit within a full daily window, a message asks you to add an intermediate place or choose pauses along the route. The final destination does not generate an extra drive to fill the remaining time. + +The map and daily cards show the pause location without creating a place, accommodation or booking. The location within a drive is an estimate based on the elapsed share of the leg's duration along its route geometry. The next real stop is reached after the remaining drive, so its arrival can be later than the daily start time. Transport legs such as ferry crossings continue to their destination without a pause in the middle. + +Manual times take precedence. A first stop set to 07:00 still starts at 07:00 even with an automatic 08:00 start. A fixed late appointment can extend that day. If fixed appointments cannot be reached without changing their time or stored day, a visible message pauses automatic scheduling. Missing routes also show a message until a complete schedule can be calculated. + +The daily layout is calculated for the Roadtrip view. Stored day assignments stay intact, with **From day 1** labels identifying stops carried forward. Additional preview days appear when needed, including beyond the trip's existing days. + +To mix both behaviors, select a stop and enable **End the day here** in its place details. This ends the day after that visit and its stay, even when the daily cutoff has not been reached. The next drive starts the following morning. The switch is available only in Roadtrip mode with valid daily travel times. Turning those times off keeps each saved choice but stops applying it. The choice belongs to that particular visit, so another visit to the same place is independent. Disable the switch to follow the default again. The editable **STAY** duration appears beside this switch and opens the same editor as the sidebar. + +While online, drag an end-of-day map label along the driving route or onto a visit. A visit that automatically moved into the following day can become the previous day's final stop, even if its full stay ends after the automatic cutoff. Fixed visit times remain protected. Right-click a dragged label to restore its automatic ending, or restore all dragged endings in Driving settings. Focused labels also support the arrow keys and Delete. These overrides belong to the trip, survive reloads and are copied with the trip. They take effect only while daily travel times are enabled. + +The map marks pauses with a moon and day number beside a place or above the route. These markers disappear when zoomed out beyond level 6. + +> **AI / MCP:** `get_roadtrip_context` reads saved days, visits, pins, stays, vias, followed tracks and day endings. `calculate_roadtrip` calculates arrivals, day splits, driving limits and vehicle-range warnings on the server, using the same planning logic as the browser. No open TREK tab is needed. Pauses remain calculated markers, never places or bookings. `get_roadtrip_settings` and `update_roadtrip_settings` read and change the shared driving preferences for the specified trip; changes update connected views for all its travellers. Place and assignment tools edit stops, stay durations, times and ordering. `set_assignment_end_day` and `set_day_boundary` set or clear individual day endings. `search_roadtrip_corridor` finds stops along a calculated day, with name, charging and route-section filters. `import_trip_gpx` imports tracks; the via tools edit their routing handles. See [MCP-Addon-Tools](MCP-Addon-Tools) for units, scopes and incomplete results. + ## Mobile Layout On screens narrower than 768 px, TREK does not squeeze the three-pane layout — it opens a dedicated mobile trip screen instead: a day-chip rail under the top bar, a switch between the day plan and a full-screen map, and a bottom dock for the other tabs. Tablets and desktops (768 px and up) get the three-pane layout described above. @@ -67,6 +91,10 @@ When you first open a trip, a brief loading screen appears while the planner dat | Weather forecasts | [Weather-Forecasts](Weather-Forecasts) | | Reservations and bookings | [Reservations-and-Bookings](Reservations-and-Bookings) | +Driving settings includes **Show hazard areas**, off by default and shared by the trip. In online Roadtrip mode, the map loads current DWD warnings for Germany and worldwide GDACS notices. Click an area or event point for its source report and update time. A point alone does not describe the affected area. Feeds refresh every ten minutes; unavailable or incomplete sources are labelled. These are current notices, not forecasts for travel dates or confirmed road closures, and they do not change routing. GDACS supplies a limited recent-event feed, with affected-area geometry loaded for up to twelve flood, wildfire or cyclone events. Coverage is not exhaustive. + +> **AI / MCP:** Use `get_roadtrip_hazards` to read notices and source availability. Set `roadtrip_show_hazards` through `update_roadtrip_settings` to change the shared map setting. + ## Related Pages - [Places-and-Search](Places-and-Search) @@ -75,3 +103,35 @@ When you first open a trip, a brief loading screen appears while the planner dat - [Weather-Forecasts](Weather-Forecasts) - [Reservations-and-Bookings](Reservations-and-Bookings) - [Admin-Addons](Admin-Addons) + +Driving settings also controls **Show in Days too** under **Service stops**. It is on by default and applies to every service stop in the trip, including existing stops. Turn it off to keep service stops exclusively in the Roadtrip view and omit them from the normal Days view and its route. Turning it on restores their visibility without creating duplicates. The setting is shared with fellow travellers and can also be changed through `update_roadtrip_settings` using `roadtrip_service_stops_in_days`. + +**Looking for** in Roadtrip also searches installed place-search plugins. Plugin results show their source and can be added like other stops. Search remains online-only and runs on request. Results outside the chosen corridor are removed, and failed sources are shown beside the remaining results. + +> **AI / MCP:** `search_roadtrip_corridor` uses the same combined sources and reports `failedSources`. + +In Roadtrip, open the three-dot menu beside the day selector to import a Google Maps directions link. Preview the ordered stops, choose a day, then confirm. Resolved stops are appended to that day; unresolved locations are marked and skipped. TREK calculates its own route between the stops. Import needs an online connection and permission to edit places and days. + +> **AI / MCP:** Use preview_google_maps_route to inspect a link, then import_google_maps_route to append the reviewed stops to a trip day. + +Roadtrip charging stops show compact availability and published energy-price badges. The place detail panel adds source attribution, timestamps and tariff conditions. Data comes from the public MobiData BW OCPDB aggregation, including participating German operators and Swiss data, without an API key or account. Coverage varies; an unknown or stale status does not mean a charger is free. Prices are published source tariffs, not personal charging-card quotes. Data refreshes while Roadtrip is visible and is not advertised as live offline. + +> **AI / MCP:** get_roadtrip_charging_info reads the same availability, tariff components, freshness and source information for a saved charging stop. + +GDACS warning popups show the current episode score when supplied, falling back to the overall event score. The compact scale indicates GDACS humanitarian impact, not whether roads are passable. + +### Accommodation portals in Roadtrip + +The Add as a stop dialog offers trivago and CHECK24 for hotels, or PiNCAMP and Pitchup for campsites. The portal opens in a new tab without adding a stop or making a booking. trivago receives the search text; PiNCAMP receives a geographic area extending roughly 20 km from the stop; Pitchup receives the stop coordinates. All three receive the trip dates when both days have dates and departure is after arrival. Review the destination, dates and guest count on the portal. CHECK24 requires a manual search. Check-in and check-out use the shared TREK time picker. + +All Looking for categories, including accommodation, are added as Roadtrip service stops. In Roadtrip mode, Edit in a planned place's details opens the stop dialog with its saved duration and overnight dates and times. Saving updates the existing stop; More details still opens the full place editor. The shared stop-type contract also accepts hotel through MCP. + +A booked check-out time controls departure from an overnight stop on its check-out day. STAY is calculated from arrival until check-out, including multiple nights, and updates with the route. Explicit arrival times remain authoritative. Clicking STAY for a booking with check-out opens the overnight editor. Browser planning and MCP use the same scheduling logic. + +The overnight dialog prefills check-in from the calculated arrival when available; existing manual check-in values take priority. Before adding a corridor result, the suggested arrival is estimated along the current routed leg. Without any known arrival, checkout still anchors the onward drive, but STAY remains unknown instead of displaying the old default duration. + +Ordinary places retain the full place editor in Roadtrip mode. The compact stop editor is used for service stops and accommodation. When visits move into another day, their calculated departure continues into that day's following stops, including midnight offsets. + +Stops reached after midnight move to the next calculated Roadtrip day with a FROM DAY label. This also creates a display day when no saved day exists yet and retains arrival times for a day containing only one moved stop. + +Range warnings also locate an empty battery or tank on connections between days. The warning offers reachable stations on that connection and inserts a selected stop before its destination.