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/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..5cd630914 --- /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])) : [], [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/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index 5c10577c7..49b59e18e 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -248,9 +248,9 @@ describe('MapView', () => { expect(screen.getAllByTestId('polyline').length).toBe(3) }) - it('FE-COMP-MAPVIEW-010: MarkerClusterGroup is rendered', () => { + it.each([false, true])('FE-COMP-MAPVIEW-010: place clustering stays enabled with roadtrip=%s', (roadtrip) => { const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })] - render() + render() expect(screen.getByTestId('cluster-group')).toBeTruthy() }) @@ -680,6 +680,27 @@ describe('MapView plugin route vias', () => { expect(markersWithZ('800')[0].textContent).toContain('Supercharger · 1 h 30 min') }) + it('hides night badges at wide zoom and shows the full description when zoomed in', () => { + mapMock.getZoom.mockReturnValue(5) + const label = 'Tagesende von Tag 1 um 18:00 Uhr' + render() + 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..ff69bcc27 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -44,6 +44,12 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes' import { safeHexColor } from '../../utils/safeColor' import { escapeHtml } from '@trek/shared' import type { Day, Reservation, RouteVia } from '../../types' +import type { MapHoverInfo } from './mapHover' +import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker' +import NightPauseTooltip from './NightPauseTooltip' +import ClusteredPois from './ClusteredPois' +import { NightPauseDrag } from './NightPauseDrag' +import type { DayBoundaryControls } from './dayBoundaryDrag' import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' import { resolveTrackColor, hasManualTrackColor } from './trackColors' import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults' @@ -98,6 +104,26 @@ function formatViaDwell(seconds: number): string { return h > 0 ? `${h} h ${m} min` : `${m} min` } +function nightPauseIcon(via: RouteVia): L.DivIcon { + const key = `night:${via.nightPause!.day}:${via.nightPause!.atPlace}` + const cached = viaIconCache.get(key) + if (cached) return cached + const icon = L.divIcon({ className: 'night-pause-marker', html: nightPauseMarker(via), iconSize: [0, 0], iconAnchor: [0, 0] }) + viaIconCache.set(key, icon) + return icon +} + +function RouteViaMarker({ via, controls, eventHandlers, children }: { + via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode +}) { + const [marker, setMarker] = useState(null) + return + {children} + {via.nightPause && } + +} + /** * Create a round photo-circle marker. * Shows image_url if available, otherwise category icon in colored circle. @@ -226,15 +252,16 @@ function createPoiIcon(category: string, brandWikidata?: string | null) { // away under a stationary cursor, so the browser never fires mouseout — and // mouseover/mousemove during the pan animation would immediately re-set the // tooltip we just cleared (#1404). -function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: boolean }; onMoveStart: () => void }) { +function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { current: boolean }; onMoveStart: () => void; onZoom: (zoom: number) => void }) { const map = useMap() useEffect(() => { const start = () => { movingRef.current = true; onMoveStart() } - const end = () => { movingRef.current = false } + const end = () => { movingRef.current = false; onZoom(map.getZoom()) } + onZoom(map.getZoom()) map.on('movestart zoomstart', start) map.on('moveend zoomend', end) return () => { map.off('movestart zoomstart', start); map.off('moveend zoomend', end) } - }, [map, movingRef, onMoveStart]) + }, [map, movingRef, onMoveStart, onZoom]) return null } @@ -638,6 +665,7 @@ export const MapView = memo(function MapView({ onViewportChange, tripId, routeVias = [], + dayBoundaryControls, accessLines = [], onPoiDropOnRoute, onRouteClick, @@ -712,11 +740,12 @@ export const MapView = memo(function MapView({ }) // Hover state for the single tooltip overlay (replaces per-marker ) - 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 +949,7 @@ export const MapView = memo(function MapView({ ), [gpxTracks, hasCasingPane]) const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice + && (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia)) const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation() @@ -1001,32 +1031,25 @@ export const MapView = memo(function MapView({ - + - {/* 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 +1153,25 @@ export const MapView = memo(function MapView({ roadRoutes={transportRoutes} /> - {poiMarkers} + {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) : ''} )} - + ))} @@ -1162,7 +1191,10 @@ export const MapView = memo(function MapView({
- {TooltipOverlay && ( + {TooltipOverlay && hoveredPlace.routeVia?.nightPause && ( + + )} + {TooltipOverlay && !hoveredPlace.routeVia?.nightPause && (
{ expect(glMarkers.created[1].element.innerHTML).toContain('#6b7280') }) + it('clusters roadtrip stations and zooms before offering overlapping stations individually', async () => { + loadOnAttach() + const poi: Poi = { + osm_id: 'n1', name: 'Station A', lat: 48.854, lng: 2.332, category: 'charging_station', + poi_type: 'charging_station', address: null, website: null, phone: null, + opening_hours: null, cuisine: null, source: 'openstreetmap', + } + const other = { ...poi, osm_id: 'n2', name: 'Station B' } + const onPoiClick = vi.fn() + render() + await act(async () => {}) + const cluster = glMarkers.created.find(pin => pin.element.querySelector('[data-poi-cluster="2"]'))! + expect(cluster).toBeDefined() + act(() => { cluster.element.click() }) + expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), { padding: 70, maxZoom: 18 }) + expect(onPoiClick).not.toHaveBeenCalled() + vi.mocked(glMap.getZoom).mockReturnValueOnce(18) + act(() => { cluster.element.click() }) + const calls = vi.mocked(glPopup.setDOMContent).mock.calls + const list = calls[calls.length - 1][0] + act(() => { list.querySelectorAll('button')[1].click() }) + expect(onPoiClick).toHaveBeenCalledWith(other) + }) + it('FE-COMP-MAPVIEWGL-043: plugin markers render as tone dots with a text-only popup', async () => { loadOnAttach() const markers: PluginMapMarker[] = [ diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 0e830a51f..8aa2aac05 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,11 @@ import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlM import LocationButton from './LocationButton' import { useGeolocation } from '../../hooks/useGeolocation' import type { Day, Place, Reservation, RouteVia } from '../../types' +import type { MapHoverInfo } from './mapHover' +import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker' +import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters' +import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag' +import NightPauseTooltip from './NightPauseTooltip' import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' import { resolveTrackColor, hasManualTrackColor } from './trackColors' import { buildPoiPopupHtml } from './placePopup' @@ -120,6 +126,7 @@ interface Props { tripId?: number | string // Charging stops / rest areas a plugin route places on the drawn day route. routeVias?: RouteVia[] + dayBoundaryControls?: DayBoundaryControls /** The dashed last bit to a place the road network does not reach. */ accessLines?: { line: [[number, number], [number, number]]; meters: number }[] route?: [number, number][][] | null @@ -197,7 +204,7 @@ interface Props { onMapReady?: (map: any | null) => void } -/** How eagerly place markers merge into a cluster, outside road trip mode. */ +/** How eagerly place markers merge into a cluster. */ const CLUSTER_RADIUS = 30 const CLUSTER_MAX_ZOOM = 10 @@ -609,6 +616,7 @@ export function MapViewGL({ dayPlaces = NO_PLACES, tripId, routeVias = NO_ROUTE_VIAS, + dayBoundaryControls, accessLines = NO_ACCESS_LINES, route = null, routeColors = null, @@ -651,6 +659,7 @@ export function MapViewGL({ gl, onMapReady, }: Props) { + const { t } = useTranslation() const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') @@ -675,7 +684,7 @@ export function MapViewGL({ const [mapReady, setMapReady] = useState(false) // Hover tooltip — a cursor-following name/category/address card, matching the // Leaflet map's overlay exactly (no anchored popup, no photo thumbnail). - const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null) + const [hoverPlace, setHoverPlace] = useState(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 @@ -1781,18 +1790,6 @@ export function MapViewGL({ return } - // Road trip mode does not cluster at all: the whole drive has to stay readable - // zoomed right out, and a day's stops merging into one dot is exactly the shape it - // is being looked at for. The source is emptied rather than reconfigured — the - // cluster options can only be set when a GeoJSON source is created, and dropping and - // re-adding a source under three layers to change a radius is not worth it when the - // markers are drawn from `places` anyway. - if (clusterLoosely) { - source.setData({ type: 'FeatureCollection', features: [] }) - reconcileMarkers(validPlaces) - return - } - source.setData(buildPlaceClusterData(places) as any) const placesById = new Map(validPlaces.map(place => [place.id, place])) let raf: number | null = null @@ -1833,29 +1830,63 @@ export function MapViewGL({ } }, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely]) - // Reconcile OSM "explore" POI markers (imperative, kept separate from the - // planned-place markers so they don't cluster or get confused with them). + // Search results stay separate from the planned itinerary. useEffect(() => { const map = mapRef.current if (!map || !mapReady) return - popupRef.current?.remove() // same orphan-popup guard as the place markers - poiMarkersRef.current.forEach(m => m.remove()) - poiMarkersRef.current = [] - poiCleanupRef.current.forEach(off => off()) - poiCleanupRef.current = [] - for (const poi of (pois as Poi[])) { - const el = createPoiMarkerElement(poi.category, poi.brand_wikidata) - el.addEventListener('mouseenter', () => { - popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map) - }) - el.addEventListener('mouseleave', () => { popupRef.current?.remove() }) - el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) }) - // Dragging a hit onto the drive puts it where it is passed, rather than where the - // corridor happened to project it. Only wired when someone is listening for it. - if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id)) - poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat)) + const redraw = () => { + popupRef.current?.remove() + poiMarkersRef.current.forEach(m => m.remove()) + poiMarkersRef.current = [] + poiCleanupRef.current.forEach(off => off()) + poiCleanupRef.current = [] + const groups = clusterLoosely ? clusterPois(pois, poi => map.project([poi.lng, poi.lat])) : pois.map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng })) + for (const group of groups) { + if (group.pois.length > 1) { + const el = document.createElement('button') + el.type = 'button' + el.style.cssText = 'width:38px;height:38px;border:0;padding:0;background:transparent;' + el.setAttribute('aria-label', t('roadtrip.poi.found', { count: group.pois.length })) + el.innerHTML = poiClusterMarkup(group.pois.length) + el.addEventListener('click', event => { + event.stopPropagation() + if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) { + popupRef.current?.setLngLat([group.lng, group.lat]).setDOMContent(poiClusterList(group.pois, poi => { + popupRef.current?.remove(); onPoiClickRef.current?.(poi) + })).addTo(map) + } else { + const bounds = new gl.LngLatBounds() + group.pois.forEach(poi => bounds.extend([poi.lng, poi.lat])) + map.fitBounds(bounds, { padding: 70, maxZoom: POI_CLUSTER_DETAIL_ZOOM }) + } + }) + poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, group.lng, group.lat)) + continue + } + const poi = group.pois[0] + const el = createPoiMarkerElement(poi.category, poi.brand_wikidata) + el.addEventListener('mouseenter', () => { + popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map) + }) + el.addEventListener('mouseleave', () => { popupRef.current?.remove() }) + el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) }) + // Dragging a hit onto the drive puts it where it is passed, rather than where the + // corridor happened to project it. Only wired when someone is listening for it. + if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id)) + poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat)) + } } - }, [pois, mapReady, glProvider]) + redraw() + if (clusterLoosely) map.on('moveend', redraw) + return () => { + map.off('moveend', redraw) + poiCleanupRef.current.forEach(off => off()) + poiCleanupRef.current = [] + poiMarkersRef.current.forEach(pin => pin.remove()) + poiMarkersRef.current = [] + popupRef.current?.remove() + } + }, [pois, mapReady, glProvider, clusterLoosely, t]) // Fetch plugin map contributions (markers + layers) per trip. Fail-safe: an // error or missing tripId just means no plugin overlays, the core map is fine. @@ -1887,11 +1918,30 @@ export function MapViewGL({ if (!map || !mapReady) return routeViaMarkersRef.current.forEach(m => m.remove()) routeViaMarkersRef.current = [] + const pauses: HTMLElement[] = [] + const unbind: (() => void)[] = [] for (const v of routeVias) { const el = document.createElement('div') el.style.cssText = 'width:13px;height:13px;cursor:pointer;' const color = PLUGIN_TONE_COLORS[v.tone] ?? PLUGIN_TONE_COLORS.default el.innerHTML = `` + if (v.nightPause) { + el.style.cssText = 'width:0;height:0;overflow:visible;cursor:pointer;' + el.innerHTML = nightPauseMarker(v) + el.setAttribute('aria-label', v.label ?? '') + pauses.push(el) + } + if (v.hoverCard) { + const show = (ev: MouseEvent) => { + if (hoverDisabledRef.current || camMovingRef.current) return + hoverIdRef.current = null + setHoverPlace({ name: v.label, routeVia: v }) + setHoverPos({ x: ev.clientX, y: ev.clientY }) + } + el.addEventListener('mouseenter', show) + el.addEventListener('mousemove', show) + el.addEventListener('mouseleave', () => { setHoverPlace(null); setHoverPos(null) }) + } if (v.label || v.dwellSeconds != null) { const text = [v.label, v.dwellSeconds != null ? formatViaDwellGl(v.dwellSeconds) : null].filter(Boolean).join(' · ') el.addEventListener('click', (ev) => { @@ -1899,9 +1949,30 @@ export function MapViewGL({ popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map) }) } - routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat)) + const pin = attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat) + routeViaMarkersRef.current.push(pin) + if (v.nightPause && dayBoundaryControls) unbind.push(bindDayBoundaryDrag(el, v, dayBoundaryControls, { + project: (lat, lng) => { + const point = map.project([lng, lat]) + const rect = map.getContainer().getBoundingClientRect() + return { x: point.x + rect.left, y: point.y + rect.top } + }, + setPosition: (lat, lng) => { pin.setLngLat([lng, lat]) }, + lock: () => { + const enabled = map.dragPan.isEnabled() + map.dragPan.disable() + return () => { if (enabled) map.dragPan.enable() } + }, + })) } - }, [routeVias, mapReady, glProvider]) + const updateZoom = () => { + const visible = map.getZoom() >= NIGHT_PAUSE_MIN_ZOOM + for (const el of pauses) el.style.display = visible ? '' : 'none' + } + updateZoom() + map.on('zoomend', updateZoom) + return () => { map.off('zoomend', updateZoom); unbind.forEach(dispose => dispose()) } + }, [routeVias, mapReady, glProvider, dayBoundaryControls]) // Reconcile plugin markers (imperative, same lifecycle as the POI markers). useEffect(() => { @@ -2230,7 +2301,10 @@ export function MapViewGL({
{/* 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..11a37d291 --- /dev/null +++ b/client/src/components/Map/dayBoundaryDrag.test.ts @@ -0,0 +1,48 @@ +import { expect, it, vi } from 'vitest' +import { bindDayBoundaryDrag } from './dayBoundaryDrag' +import type { RoadtripStop } from '../Roadtrip/useRoadtripRoutes' + +function setup() { + const el = document.createElement('span') + document.body.append(el) + const save = vi.fn(async () => true), setPosition = vi.fn(), unlock = vi.fn() + const stop = (assignmentId: number, lng: number) => ({ assignmentId, lat: 0, lng }) as RoadtripStop + const dispose = bindDayBoundaryDrag(el, { lat: 0, lng: 0.5, tone: 'default', nightPause: { day: 1, atPlace: false, manual: true, position: 0.5 } }, { + path: [{ from: stop(1, 0), to: stop(2, 1), position: 0, line: [[0, 0], [0, 1]] }], move: save, hint: 'Move', + }, { project: (lat, lng) => ({ x: lng * 1000, y: lat * 1000 }), setPosition, lock: () => unlock }) + const pointer = (node: EventTarget, type: string, x: number) => node.dispatchEvent(new MouseEvent(type, { clientX: x, clientY: 0, bubbles: true, cancelable: true, button: 0 })) + return { el, save, setPosition, unlock, dispose, pointer } +} + +it('saves the snapped visit once on release and restores map dragging', async () => { + const s = setup() + s.pointer(s.el, 'pointerdown', 500) + s.pointer(document, 'pointermove', 990) + expect(s.save).not.toHaveBeenCalled() + s.pointer(document, 'pointerup', 990) + expect(s.save).toHaveBeenCalledWith(1, { day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }) + expect(s.unlock).toHaveBeenCalledOnce() + s.dispose() +}) + +it('escape cancels without saving and removes document handlers', () => { + const s = setup() + s.pointer(s.el, 'pointerdown', 500) + s.pointer(document, 'pointermove', 300) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + s.pointer(document, 'pointerup', 300) + expect(s.save).not.toHaveBeenCalled() + expect(s.setPosition).toHaveBeenLastCalledWith(0, 0.5) + expect(s.unlock).toHaveBeenCalledOnce() + s.dispose() +}) + +it('a right click clears the manual override and a click alone does not save', async () => { + const s = setup() + s.pointer(s.el, 'pointerdown', 500) + s.pointer(document, 'pointerup', 500) + expect(s.save).not.toHaveBeenCalled() + s.el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })) + expect(s.save).toHaveBeenCalledWith(1, null) + s.dispose() +}) diff --git a/client/src/components/Map/dayBoundaryDrag.ts b/client/src/components/Map/dayBoundaryDrag.ts new file mode 100644 index 000000000..69dddd788 --- /dev/null +++ b/client/src/components/Map/dayBoundaryDrag.ts @@ -0,0 +1,109 @@ +import type { RoadtripDayBoundary } from '@trek/shared' +import type { RouteVia } from '../../types' +import { nearestDayBoundary, type DayBoundaryLeg, type DayBoundaryTarget, type ProjectPoint } from '../Roadtrip/dayBoundaryPath' + +export interface DayBoundaryControls { + path: DayBoundaryLeg[] + move: (day: number, boundary: RoadtripDayBoundary | null) => Promise + hint: string +} + +interface DragMap { + project: ProjectPoint + setPosition: (lat: number, lng: number) => void + lock: () => () => void +} + +export function bindDayBoundaryDrag(el: HTMLElement, via: RouteVia, controls: DayBoundaryControls, map: DragMap) { + const pause = via.nightPause! + const handle = el.querySelector('[data-night-pause]') ?? el + handle.style.cursor = 'grab' + handle.style.touchAction = 'none' + handle.tabIndex = 0 + handle.setAttribute('role', 'button') + handle.setAttribute('aria-label', via.label ?? '') + handle.setAttribute('aria-description', controls.hint) + let origin: { x: number; y: number } | null = null + let target: DayBoundaryTarget | null = null + let unlock: (() => void) | null = null + let moved = false + let saving = false + let disposed = false + const restore = () => map.setPosition(via.lat, via.lng) + const detach = () => { + document.removeEventListener('pointermove', move) + document.removeEventListener('pointerup', up) + document.removeEventListener('pointercancel', cancel) + document.removeEventListener('keydown', escape) + unlock?.() + unlock = null + origin = null + handle.style.cursor = 'grab' + } + const cancel = () => { detach(); restore() } + const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); cancel() } } + const commit = async (boundary: RoadtripDayBoundary | null) => { + saving = true + handle.setAttribute('aria-busy', 'true') + try { if (!await controls.move(pause.day, boundary) && !disposed) restore() } + finally { saving = false; handle.removeAttribute('aria-busy') } + } + const move = (event: PointerEvent) => { + if (!origin) return + if (!moved && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) < 4) return + moved = true + event.preventDefault() + target = nearestDayBoundary(controls.path, pause.day, { x: event.clientX, y: event.clientY }, map.project, pause.minPosition, pause.maxPosition) + if (target) map.setPosition(target.lat, target.lng) + } + const up = () => { + const next = moved ? target : null + detach() + if (next) void commit(next.boundary) + else restore() + } + const down = (event: PointerEvent) => { + if (event.button !== 0 || saving) return + event.stopPropagation() + event.preventDefault() + origin = { x: event.clientX, y: event.clientY } + target = null + moved = false + unlock = map.lock() + handle.style.cursor = 'grabbing' + document.addEventListener('pointermove', move, { passive: false }) + document.addEventListener('pointerup', up) + document.addEventListener('pointercancel', cancel) + document.addEventListener('keydown', escape) + } + const click = (event: Event) => { + if (moved || saving) { event.preventDefault(); event.stopImmediatePropagation() } + } + const reset = (event: Event) => { + event.preventDefault() + event.stopPropagation() + if (!saving && pause.manual) void commit(null) + } + const key = (event: KeyboardEvent) => { + if (event.key === 'Delete' || event.key === 'Backspace') return reset(event) + if (!['ArrowLeft', 'ArrowRight'].includes(event.key) || saving) return + event.preventDefault() + event.stopPropagation() + const position = (pause.position ?? 0) + (event.key === 'ArrowLeft' ? -0.05 : 0.05) + const leg = controls.path.find(leg => position >= leg.position && position <= leg.position + 1) + if (!leg || position <= (pause.minPosition ?? -1) || position >= (pause.maxPosition ?? Infinity)) return + void commit({ day_number: pause.day, from_assignment_id: leg.from.assignmentId, to_assignment_id: leg.to.assignmentId, fraction: position - leg.position }) + } + handle.addEventListener('pointerdown', down) + el.addEventListener('click', click, true) + handle.addEventListener('contextmenu', reset) + handle.addEventListener('keydown', key) + return () => { + disposed = true + detach() + handle.removeEventListener('pointerdown', down) + el.removeEventListener('click', click, true) + handle.removeEventListener('contextmenu', reset) + handle.removeEventListener('keydown', key) + } +} 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..65ca98092 --- /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/poiClusters.test.ts b/client/src/components/Map/poiClusters.test.ts new file mode 100644 index 000000000..a54b64787 --- /dev/null +++ b/client/src/components/Map/poiClusters.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Poi } from './poiCategories' +import { clusterPois, poiClusterList } from './poiClusters' + +const station = (id: string, lng: number): Poi => ({ + osm_id: id, name: id, lat: 48, lng, category: 'charging_station', + poi_type: 'charging_station', address: null, website: null, phone: null, + opening_hours: null, cuisine: null, source: 'openstreetmap', +}) +const project = (poi: Poi) => ({ x: poi.lng, y: poi.lat }) + +describe('station clusters', () => { + it('groups across grid edges, counts unique stations and preserves the originals', () => { + const a = station('a', 47), b = station('b', 49), c = station('c', 150) + const groups = clusterPois([c, b, a, a], project) + expect(groups.map(group => group.pois.length)).toEqual([2, 1]) + expect(groups[0]).toMatchObject({ lat: 48, lng: 48 }) + expect(groups[0].pois[0]).toBe(a) + expect(a.lng).toBe(47) + expect(clusterPois([a, b, c], project)).toEqual(groups) + }) + + it('separates stations as their projected distance grows on zoom', () => { + const pois = [station('a', 1), station('b', 2)] + expect(clusterPois(pois, project)).toHaveLength(1) + expect(clusterPois(pois, poi => ({ x: poi.lng * 100, y: 0 }))).toHaveLength(2) + }) + + it('keeps the center near the date line', () => { + const groups = clusterPois([station('a', 179), station('b', -179)], () => ({ x: 0, y: 0 })) + expect(groups[0].lng).toBe(180) + }) + + it('offers coincident stations individually without interpreting their names as HTML', () => { + const poi = { ...station('a', 1), name: '' } + 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) + }) +}) diff --git a/client/src/components/Map/poiClusters.ts b/client/src/components/Map/poiClusters.ts new file mode 100644 index 000000000..e0f5f1f6f --- /dev/null +++ b/client/src/components/Map/poiClusters.ts @@ -0,0 +1,55 @@ +import type { Poi } from './poiCategories' + +export const POI_CLUSTER_RADIUS = 48 +export const POI_CLUSTER_DETAIL_ZOOM = 18 +export interface PoiCluster { pois: Poi[]; lat: number; lng: number } + +export function clusterPois(pois: Poi[], project: (poi: Poi) => { x: number; y: number }): PoiCluster[] { + const groups: (PoiCluster & { x: number; y: number })[] = [] + const cells = new Map() + const unique = new Map(pois.map(poi => [poi.osm_id, poi])) + for (const poi of [...unique.values()].sort((a, b) => a.osm_id.localeCompare(b.osm_id))) { + const point = project(poi) + const cx = Math.floor(point.x / POI_CLUSTER_RADIUS), cy = Math.floor(point.y / POI_CLUSTER_RADIUS) + let closest: typeof groups[number] | undefined + let distance = POI_CLUSTER_RADIUS + for (let x = cx - 1; x <= cx + 1; x++) for (let y = cy - 1; y <= cy + 1; y++) { + for (const index of cells.get(`${x},${y}`) ?? []) { + const group = groups[index] + const gap = Math.hypot(point.x - group.x, point.y - group.y) + if (gap < distance) { closest = group; distance = gap } + } + } + if (closest) closest.pois.push(poi) + else { + const key = `${cx},${cy}` + const indices = cells.get(key) ?? [] + indices.push(groups.length) + cells.set(key, indices) + groups.push({ pois: [poi], lat: poi.lat, lng: poi.lng, ...point }) + } + } + return groups.map(group => ({ + pois: group.pois, + lat: group.pois.reduce((sum, poi) => sum + poi.lat, 0) / group.pois.length, + lng: group.lng + group.pois.reduce((sum, poi) => sum + ((poi.lng - group.lng + 540) % 360 - 180), 0) / group.pois.length, + })) +} + +export function poiClusterMarkup(count: number): string { + return `${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/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 504f4361a..6ee88edea 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -35,6 +35,8 @@ import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge import { NavigationMenu } from '../shared/NavigationMenu' import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState' import { convertHoursLine } from './placeHoursFormat' +import type { EndDayControlProps } from '../Roadtrip/EndDayControl' +import VisitControls, { type RoadtripStayControl } from '../Roadtrip/VisitControls' const detailsCache = new Map() @@ -133,6 +135,8 @@ interface TripMember { } interface PlaceInspectorProps { + roadtripEndDay?: EndDayControlProps + roadtripStay?: RoadtripStayControl place: Place | null categories: Category[] /** 'trip' (default) keeps every existing trip-planner behaviour byte-identical; @@ -179,7 +183,7 @@ export default function PlaceInspector({ onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment, files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate, leftWidth = 0, rightWidth = 0, - collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, + collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, }: PlaceInspectorProps) { // Plugins that declared a place-detail slot mount at the bottom of this panel, // scoped to the open place (trip mode only). Inline-filter like the other sites. @@ -417,6 +421,7 @@ export default function PlaceInspector({ )} {/* Description / Summary */} + {(roadtripEndDay || roadtripStay) && } {(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/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..0615c8ac0 --- /dev/null +++ b/client/src/components/Roadtrip/DayWindowFields.tsx @@ -0,0 +1,70 @@ +import { useState, useRef } from 'react' +import { Sunrise, Sunset } from 'lucide-react' +import { useTranslation } from '../../i18n/TranslationContext' +import { dayWindow } from './dayWindow' +import CustomTimePicker from '../shared/CustomTimePicker' + +export default function DayWindowFields({ start, end, endMode = 'route', onSave }: { + start?: string + end?: string + endMode?: 'route' | 'stop' + onSave?: (key: string, value: string) => void +}) { + const { t } = useTranslation() + const [draft, setDraft] = useState<{ start?: string; end?: string }>({}) + const sent = useRef({ start, end }) + const shownStart = draft.start ?? start ?? '' + const shownEnd = draft.end ?? end ?? '' + const invalid = !!shownStart && !!shownEnd && !dayWindow(shownStart, shownEnd) + const change = (field: 'start' | 'end', value: string) => { + setDraft(prev => ({ ...prev, [field]: value })) + if (value !== '' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return + if (value !== (sent.current[field] ?? '')) { + sent.current[field] = value + onSave?.(`roadtrip_day_${field}`, value) + } + } + return ( +
+ {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' }} + /> +
+ ) + })} +

+ {t(invalid ? 'roadtrip.window.invalid' : 'roadtrip.window.hint')} +

+
+ {t('roadtrip.window.endAt')} + {t('roadtrip.window.endAt')} +
+ {(['route', 'stop'] as const).map(mode => ( + + ))} +
+

{t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)}

+

{t('roadtrip.window.endHereHint')}

+ {!!dayWindow(shownStart, shownEnd) &&

{t('roadtrip.window.dragHint')}

} +
+
+ ) +} 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..c1c0cf048 --- /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/RoadtripLimitsCard.test.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx index 7fcb318bb..0ef283d90 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) diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx index 26180eb69..7f67bfc39 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react' import { - Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, + Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check, Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette, } from 'lucide-react' import Modal from '../shared/Modal' @@ -14,6 +14,8 @@ import { valhallaAvailable } from '../Map/valhallaRoute' import ToggleSwitch from '../Settings/ToggleSwitch' import type { DistanceUnit, RouteAvoidClass } from '../../types' import { FS } from './typeScale' +import DayWindowFields from './DayWindowFields' +import { dayWindow } from './dayWindow' /** * Everything that decides when the rail speaks up about the driving. @@ -248,7 +250,8 @@ const SPEC_ROWS: Record Promise /** * Persists one setting. Absent leaves the dialog read-only. * @@ -265,6 +268,7 @@ export default function RoadtripLimitsCard({ onSave }: { const legMinutes = settings.roadtrip_leg_minutes const dayMinutes = settings.roadtrip_day_minutes + const automaticWindow = dayWindow(settings.roadtrip_day_start, settings.roadtrip_day_end) const rangeKm = settings.roadtrip_range_km // Parsed rather than trusted: a per-user setting gets no server-side validation, and @@ -335,7 +339,8 @@ export default function RoadtripLimitsCard({ onSave }: { // imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a // third too early for ever after. The same rule runs through the vehicle's own figures, // where showSpec and storeSpec do it per field. - const rangeShown = planningKm ? Math.round(convertDistance(planningKm, distanceUnit)) : undefined + const rangeValue = computedKm ?? rangeKm + const rangeShown = rangeValue ? Math.round(convertDistance(rangeValue, distanceUnit)) : undefined const setRange = (shown: number) => { const km = imperial ? shown / 0.621371 : shown onSave?.('roadtrip_range_km', Math.round(km)) @@ -347,6 +352,7 @@ export default function RoadtripLimitsCard({ onSave }: { // imperial traveller reads miles here and types miles in the dialog, while what is // stored stays kilometres either way. const badges = [ + automaticWindow ? { key: 'window', Icon: CalendarClock, text: `${settings.roadtrip_day_start} · ${settings.roadtrip_day_end}` } : null, legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null, dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null, // The planning range, not the typed one: when the vehicle's figures win, the badge @@ -426,7 +432,7 @@ export default function RoadtripLimitsCard({ onSave }: { What answers the seam is not the count of columns but what is in them: each section is now a panel of its own with a visible edge, every row inside it ends on the same right edge, and the panels are stacked to one rhythm. The - left column is the trip, the right one is the car. */} + left column holds trip settings, the right holds the car and route appearance. */}
@@ -450,6 +456,11 @@ export default function RoadtripLimitsCard({ onSave }: { /> + + + {onResetDayBoundaries && } + + ))} +
- +
+ + {/* 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. */}
- - - {/* 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.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 22e9d271d..2e089b53c 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -27,8 +27,10 @@ import type { RouteVia } from '../../types' import { FS } from './typeScale' import type { RouteSegment } from '../../types' import EmptyState from '../shared/EmptyState' +import AutomaticDayStop from './AutomaticDayStop' interface RoadtripSidebarProps { + onFocusPoint?: (lat: number, lng: number) => void /** Legs and totals for the whole trip, computed once in the planner hook. */ routes: RoadtripRoutes selectedAssignmentId?: number | null @@ -1203,13 +1205,7 @@ function SpillBlock({ spill, children }: { boxShadow: `inset 0 1px 0 color-mix(in srgb, var(--info) 20%, transparent)`, }} > - {/* Moonlight from the left, falling on the mascot. A radial wash rather than a - second background colour: it has no edge of its own, so the head reads as lit - rather than as another box stacked on the first. */} -
+
+
@@ -1294,7 +1290,8 @@ function SpillBlock({ spill, children }: { * move, a stay edit or a refuel offer still names the day the server knows it by. See * `nightSpill.ts`. */ -function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle }: { +function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: { + onFocusPoint?: RoadtripSidebarProps['onFocusPoint'] day: RoadtripDay /** Folded down to the header, and off the map with it. */ collapsed?: boolean @@ -1355,6 +1352,20 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on * still stored on the day it set off from (`nightSpill.ts`). */ const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => { + if (stop.automaticNight) return ( +
  • + + {i < last && day.legs[i]?.distance !== 0 ? : null} + {refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === i).map(dry => ( + onAskRefuel?.(day.dayId, dry)} + onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} /> + )) : null} + {i < last ? (day.legVias[i] ?? []).map((via, vi) => ( + + )) : null} +
  • + ) const service = isServiceStopType(stop.stopType) if (!service) counted += 1 // Every finding at this index is read on its own. Taking the first match let an @@ -1424,10 +1435,10 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} /> )} - {i < last ? ( + {i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? ( onAskAlternatives(day.dayId, i) : undefined} + onAskAlternatives={onAskAlternatives && !day.stops[i + 1].automaticNight ? () => onAskAlternatives(day.dayId, i) : undefined} alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i} /> ) : null} @@ -1556,7 +1567,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on ) : null} - {t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })} + {t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })} {/* The other half of "where possible". The setting is a weighting, so a day with no untolled crossing comes back on the toll road — and the only thing @@ -1714,7 +1725,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: { export default function RoadtripSidebar({ routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel, - collapsedDayIds, onToggleDay, + collapsedDayIds, onToggleDay, onFocusPoint, }: RoadtripSidebarProps): React.ReactElement { const { t } = useTranslation() // One drag state for the whole rail rather than one per day: a stop that cannot leave @@ -1751,12 +1762,18 @@ export default function RoadtripSidebar({
    + {routes.dayWindowIssue ? ( +

    + {t(`roadtrip.window.${routes.dayWindowIssue}`)} +

    + ) : null}
    {routes.days.map(day => ( ({ 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..edea40dc8 --- /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/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.test.ts b/client/src/components/Roadtrip/dayWindow.test.ts new file mode 100644 index 000000000..8983d04d7 --- /dev/null +++ b/client/src/components/Roadtrip/dayWindow.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from 'vitest' +import { dayWindow, planDayWindow, roadtripInsertion } from './dayWindow' +import type { RoadtripStop, RoutedLeg } from './useRoadtripRoutes' + +const hours = { start: 480, end: 1080 } +const labels = { start: 'Continue', end: 'End of day' } +const stop = (id: number, patch: Partial = {}): RoadtripStop => ({ + assignmentId: id, placeId: id, ownerDayId: 1, ownerIndex: id - 1, + lat: 0, lng: id - 1, name: `Stop ${id}`, time: null, dwellMinutes: 0, + legMode: null, incomingLegMode: null, stopType: null, ...patch, +}) +const day = (number: number, stops: RoadtripStop[] = []) => ({ + dayId: number, dayNumber: number, date: `2026-09-${String(number + 10).padStart(2, '0')}`, title: null, stops, +}) +const leg = (minutes: number, from: RoadtripStop, to: RoadtripStop): RoutedLeg => ({ + line: [[from.lat, from.lng], [to.lat, to.lng]], vias: [], + seg: { + from: [from.lat, from.lng], to: [to.lat, to.lng], mid: [0, (from.lng + to.lng) / 2], + distance: minutes * 1000, duration: minutes * 60, mode: 'driving', + distanceText: '', drivingText: '', walkingText: '', + }, +}) +const calculate = (stops: RoadtripStop[], minutes: number[] = [720], extra = [day(2)]) => + planDayWindow([day(1, stops), ...extra], hours, (a, b) => leg(minutes[stops.indexOf(a)] ?? 60, a, b), 'metric', labels) + +describe('daily travel window', () => { + it('pulls an unpinned visit back into day one and ends after its full stay', () => { + const stops = [stop(1, { time: '07:00' }), stop(2, { dwellMinutes: 90 }), stop(3)] + const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(720, a, b) + const automatic = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels) + expect(automatic.chains[0].stops).not.toContain(stops[1]) + const manual = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels, + [{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }]) + expect(manual.issue).toBeNull() + expect(manual.chains[0].stops).toContain(stops[1]) + expect(manual.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '19:00', '20:30']) + expect(manual.chains[1].schedule.entries[0].arrival).toBe('08:00') + expect(stops[0].time).toBe('07:00') + }) + + it.each([0.25, 0.9])('moves the cut in either direction and preserves the whole drive (%s)', fraction => { + const stops = [stop(1), stop(2)] + const manual = planDayWindow([day(1, stops)], hours, (a, b) => leg(720, a, b), 'metric', labels, + [{ day_number: 1, from_assignment_id: 1, to_assignment_id: 2, fraction }]) + expect(manual.issue).toBeNull() + const end = manual.chains[0].stops[1] + expect(end.lng).toBeCloseTo(fraction) + expect(end.automaticNight).toMatchObject({ manual: true, position: fraction }) + const start = manual.chains[1].stops[0] + expect(manual.legFor(stops[0], end)!.seg.duration + manual.legFor(start, stops[1])!.seg.duration).toBeCloseTo(720 * 60) + }) + + it('does not pull a visit with a fixed time out of its stored day', () => { + const pinned = stop(2, { ownerDayId: 2, time: '12:00' }) + const manual = planDayWindow([day(1, [stop(1)]), day(2, [pinned])], hours, (a, b) => leg(60, a, b), 'metric', labels, + [{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }]) + expect(manual.issue).toBe('conflict') + expect(pinned.time).toBe('12:00') + }) + + it('rejects crossed or disconnected manual boundaries', () => { + const stops = [stop(1), stop(2), stop(3)] + for (const boundaries of [ + [{ day_number: 1, from_assignment_id: 1, to_assignment_id: 3, fraction: 0.5 }], + [{ day_number: 1, from_assignment_id: 3, to_assignment_id: null, fraction: 1 }, { day_number: 2, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }], + ]) expect(planDayWindow([day(1, stops)], hours, (a, b) => leg(60, a, b), 'metric', labels, boundaries).issue).toBe('conflict') + }) + + it('mixes an explicit stop end with automatic cuts on later drives', () => { + const visited = stop(2, { endDay: true, dwellMinutes: 60 }) + const planned = calculate([stop(1), visited, stop(3)], [120, 720], []) + expect(planned.issue).toBeNull() + expect(planned.chains.map(d => d.schedule.entries.map(e => e.arrival))).toEqual([ + ['08:00', '10:00', '11:00'], ['08:00', '18:00'], ['08:00', '10:00'], + ]) + expect(planned.chains[0].stops[2]).toMatchObject({ lat: visited.lat, lng: visited.lng }) + expect(visited.endDay).toBe(true) + }) + + it('removing an explicit end restores the automatic schedule without adding a final night', () => { + const planned = calculate([stop(1), stop(2, { endDay: false }), stop(3, { endDay: true })], [120, 60], []) + expect(planned.chains).toHaveLength(1) + expect(planned.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00', '11:00']) + }) + + it('keeps manual times when an explicit end would move their day', () => { + const pinned = stop(2, { time: '12:00' }) + const planned = calculate([stop(1, { endDay: true }), pinned], [60], []) + expect(planned.issue).toBe('conflict') + expect(pinned.time).toBe('12:00') + }) + + it('honors an earlier manual arrival on the following stored day', () => { + const first = stop(1, { endDay: true }) + const next = stop(2, { time: '07:00', ownerDayId: 2 }) + const planned = planDayWindow([day(1, [first]), day(2, [next])], hours, (a, b) => leg(60, a, b), 'metric', labels) + expect(planned.issue).toBeNull() + expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['06:00', '07:00']) + expect(next.time).toBe('07:00') + }) + + it('requires two valid increasing HH:mm values', () => { + expect(dayWindow('08:00', '18:00')).toEqual(hours) + expect(dayWindow('00:00', '00:01')).toEqual({ start: 0, end: 1 }) + for (const pair of [[null, '18:00'], ['08:00', ''], ['8:00', '18:00'], ['24:00', '25:00'], ['18:00', '08:00'], ['08:00', '08:00'], [800, 1800]]) { + expect(dayWindow(...pair as [unknown, unknown])).toBeNull() + } + }) + + it('splits the drive at day end and resumes at the same point next morning', () => { + const first = stop(1) + const last = stop(2, { lng: 12 }) + const planned = calculate([first, last]) + expect(planned.issue).toBeNull() + const [today, tomorrow] = planned.chains + const end = today.stops[1] + const start = tomorrow.stops[0] + expect(today.schedule.entries[0].arrival).toBe('08:00') + expect(today.schedule.entries[1].arrival).toBe('18:00') + expect(end.lng).toBeCloseTo(10) + expect([start.lat, start.lng]).toEqual([end.lat, end.lng]) + expect(tomorrow.schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00']) + expect(tomorrow.stops[1]).toBe(last) + expect(tomorrow.spills[0]).toMatchObject({ automatic: true, fromDayNumber: 1, at: 1 }) + const before = planned.legFor(first, end)! + const after = planned.legFor(start, last)! + expect(before.seg.duration + after.seg.duration).toBe(720 * 60) + expect(before.seg.distance + after.seg.distance).toBe(720000) + expect(before.line[before.line.length - 1]).toEqual(after.line[0]) + expect(first.time).toBeNull() + expect(last.ownerDayId).toBe(1) + }) + + it('ends at the last visited place after its stay when the next drive will not fit', () => { + const first = stop(1) + const visited = stop(2, { dwellMinutes: 60 }) + const next = stop(3) + const route = [first, visited, next] + const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(a === first ? 480 : 120, a, b) + const planned = planDayWindow([day(1, route)], { ...hours, endMode: 'stop' }, lookup, 'metric', labels) + expect(planned.issue).toBeNull() + expect(planned.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '16:00', '17:00']) + expect(planned.chains[0].stops[2]).toMatchObject({ lat: visited.lat, lng: visited.lng }) + expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00']) + expect(planned.legFor(planned.chains[1].stops[0], next)?.seg.duration).toBe(7200) + expect(visited.dwellMinutes).toBe(60) + expect(next.ownerDayId).toBe(1) + const alongRoute = planDayWindow([day(1, route)], hours, lookup, 'metric', labels) + expect(alongRoute.chains[0].schedule.entries[2].arrival).toBe('18:00') + expect(alongRoute.chains[1].schedule.entries[1].arrival).toBe('09:00') + }) + + it('reaches a last place exactly at the cutoff and still respects manual times', () => { + const first = stop(1, { time: '07:00' }) + const next = stop(2) + const calculateAtStop = (minutes: number) => planDayWindow([day(1, [first, next])], { ...hours, endMode: 'stop' }, + (a, b) => leg(minutes, a, b), 'metric', labels) + expect(calculateAtStop(660).chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '18:00']) + next.time = '19:00' + expect(calculateAtStop(720).chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '19:00']) + expect(first.time).toBe('07:00') + }) + + it('reports an indivisible drive that cannot fit rather than adding endless empty days', () => { + const planned = planDayWindow([day(1, [stop(1), stop(2)])], { ...hours, endMode: 'stop' }, + (a, b) => leg(720, a, b), 'metric', labels) + expect(planned.issue).toBe('legTooLong') + expect(planned.chains).toEqual([]) + expect(dayWindow('08:00', '18:00', 'stop')).toEqual({ ...hours, endMode: 'stop' }) + expect(dayWindow('08:00', '18:00', 'unknown')).toEqual(hours) + }) + + it('starts at a manually pinned 07:00 despite an automatic 08:00 start', () => { + const first = stop(1, { time: '07:00' }) + const planned = calculate([first, stop(2, { lng: 12 })]) + expect(planned.chains[0].schedule.entries[0]).toMatchObject({ arrival: '07:00', anchored: true }) + expect(planned.chains[0].stops[1].lng).toBeCloseTo(11) + expect(planned.chains[1].schedule.entries[1].arrival).toBe('09:00') + expect(first.time).toBe('07:00') + }) + + it('accounts for stays and recalculates the pause position without mutating places', () => { + const first = stop(1, { dwellMinutes: 120 }) + const destination = stop(2, { lng: 12 }) + const planned = calculate([first, destination]) + expect(planned.chains[0].stops[1].lng).toBeCloseTo(8) + expect(planned.chains[1].schedule.entries[1].arrival).toBe('12:00') + const shortened = calculate([{ ...first, dwellMinutes: 0 }, destination]) + expect(shortened.chains[0].stops[1].lng).toBeCloseTo(10) + const shortDrive = calculate([first, destination], [120]) + expect(shortDrive.chains.flatMap(d => d.stops).some(s => s.automaticNight)).toBe(false) + expect(first.dwellMinutes).toBe(120) + }) + + it('does not move an arrival exactly at day end to tomorrow', () => { + const last = stop(2) + const planned = calculate([stop(1), last], [600]) + expect(planned.chains[0].stops).toHaveLength(2) + expect(planned.chains[0].schedule.entries[1].arrival).toBe('18:00') + expect(planned.chains[1].stops).toEqual([]) + }) + + it('continues across multiple nights and creates preview dates beyond the trip', () => { + const planned = calculate([stop(1), stop(2, { lng: 30 })], [1800], []) + expect(planned.chains.map(d => d.date)).toEqual(['2026-09-11', '2026-09-12', '2026-09-13']) + expect(planned.chains[2].schedule.entries[1].arrival).toBe('18:00') + expect(planned.chains[2].dayId).toBeLessThan(0) + expect(planned.chains.flatMap(d => d.stops).filter(s => !s.automaticNight)).toHaveLength(2) + }) + + it('keeps fixed appointments and reports an impossible schedule without partial relocation', () => { + const fixed = stop(2, { time: '10:00' }) + const impossible = calculate([stop(1), fixed], [180]) + expect(impossible.issue).toBe('conflict') + expect(impossible.chains).toEqual([]) + expect(fixed.time).toBe('10:00') + const possible = calculate([stop(1), fixed], [60]) + expect(possible.chains[0].schedule.entries[1]).toMatchObject({ arrival: '10:00', anchored: true }) + }) + + it('allows a manual late appointment to override the automatic end', () => { + const fixed = stop(2, { time: '19:00' }) + const planned = calculate([stop(1), fixed], [660]) + expect(planned.issue).toBeNull() + expect(planned.chains[0].schedule.entries[1].arrival).toBe('19:00') + expect(planned.chains[0].stops).toHaveLength(2) + }) + + it('keeps a pinned next-day start at 07:00 and leaves the pause point early enough', () => { + const a = stop(1) + const b = stop(2, { ownerDayId: 2, ownerIndex: 0, time: '07:00' }) + const planned = planDayWindow([day(1, [a]), day(2, [b])], hours, (from, to) => leg(60, from, to), 'metric', labels) + expect(planned.issue).toBeNull() + expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['06:00', '07:00']) + }) + + it('does not silently move a fixed appointment onto a later date after a previous spill', () => { + const planned = calculate([stop(1), stop(2), stop(3, { time: '17:00' })], [720, 60]) + expect(planned.issue).toBe('conflict') + }) + + it('pauses a long visit at its location and carries the remaining stay into tomorrow', () => { + const first = stop(1, { dwellMinutes: 660 }) + const planned = calculate([first, stop(2)], [60]) + expect(planned.chains[0].stops[1]).toMatchObject({ lat: first.lat, lng: first.lng }) + expect(planned.chains[0].schedule.entries[0].departure).toBe('18:00') + expect(planned.chains[1].schedule.entries[0].departure).toBe('09:00') + expect(planned.chains[1].schedule.entries[1].arrival).toBe('10:00') + expect(first.dwellMinutes).toBe(660) + }) + + it('does not place a night pause halfway through a ferry crossing', () => { + const a = stop(1) + const b = stop(2) + const ferry = leg(720, a, b) + ferry.seg.mode = 'ferry' + const planned = planDayWindow([day(1, [a, b])], hours, () => ferry, 'metric', labels) + expect(planned.chains[0].stops).toHaveLength(2) + expect(planned.chains[0].schedule.entries[1].arrival).toBe('20:00') + }) + + it('does not invent times or points for missing routes', () => { + const planned = planDayWindow([day(1, [stop(1), stop(2)])], hours, () => undefined, 'metric', labels) + expect(planned.issue).toBe('incomplete') + expect(planned.chains).toEqual([]) + expect(planDayWindow([], hours, () => undefined, 'metric', labels).chains).toEqual([]) + }) + + it('bounds extreme driving and stay durations', () => { + expect(calculate([stop(1), stop(2)], [600 * 367]).issue).toBe('tooLong') + expect(calculate([stop(1, { dwellMinutes: 600 * 367 }), stop(2)], [1]).issue).toBe('tooLong') + }) + + it('maps insertions on generated days back to real stored stops', () => { + const planned = calculate([stop(1), stop(2)]) + expect(roadtripInsertion(planned.chains[1], 0)).toEqual({ dayId: 1, position: 1 }) + expect(roadtripInsertion(planned.chains[1], 1)).toEqual({ dayId: 1, position: 1 }) + expect(roadtripInsertion({ stops: [] }, 0)).toBeNull() + expect(roadtripInsertion(planned.chains[1], 2)).toEqual({ dayId: 1, position: 2 }) + }) + + it('rounds fractional route minutes across the hour boundary', () => { + const planned = calculate([stop(1), stop(2)], [59.8]) + expect(planned.chains[0].schedule.entries[1].arrival).toBe('09:00') + }) + + it('keeps a zero-distance timed leg at its location when split', () => { + const a = stop(1) + const b = stop(2, { lng: a.lng }) + const timed = leg(720, a, b) + timed.seg.distance = 0 + timed.line = [] + const planned = planDayWindow([day(1, [a, b])], hours, () => timed, 'metric', labels) + expect(planned.issue).toBeNull() + expect(planned.chains[1].stops[0]).toMatchObject({ lat: a.lat, lng: a.lng }) + expect(planned.chains[1].schedule.entries[1].arrival).toBe('10:00') + }) +}) diff --git a/client/src/components/Roadtrip/dayWindow.ts b/client/src/components/Roadtrip/dayWindow.ts new file mode 100644 index 000000000..c8213ce03 --- /dev/null +++ b/client/src/components/Roadtrip/dayWindow.ts @@ -0,0 +1,283 @@ +import { formatClock, parseClock } from './roadtripModel' +import { lineMetres, projectOntoRoute, sliceAtMeters } from './corridor' +import type { SpillChain } from './nightSpill' +import type { RoadtripDay, RoadtripStop, RoutedLeg } from './useRoadtripRoutes' +import { formatDistance } from '../../utils/units' +import { formatDurationShort } from './roadtripModel' +import type { DistanceUnit } from '../../types' +import type { RoadtripDayBoundary } from '@trek/shared' + +export interface DayWindow { + start: number + end: number + endMode?: 'route' | 'stop' +} + +export interface AutomaticNight { + phase: 'start' | 'end' + fromDayNumber: number + position?: number + manual?: boolean +} + +type PlanDay = Pick +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, stopType: null, fillPercent: null, + automaticNight: { phase: 'end', fromDayNumber: number, position, manual: targets.has(number) }, + } + append(end, clock) + if (last) putLeg(last, end, stationary(at)) + if (final) return end + number += 1 + if (number - firstDay > 365) issue = 'tooLong' + clock = window.start + const start: RoadtripStop = { + ...end, assignmentId: end.assignmentId - 1, name: labels.start, + automaticNight: { ...end.automaticNight!, phase: 'start' }, + } + append(start, clock) + return start + } + + let previous: RoadtripStop | undefined + for (let i = 0; i < stops.length; i++) { + const { stop, day } = stops[i] + const pin = parseClock(stop.time) + if (previous && number < day.dayNumber && !targets.has(number)) { + if (window.endMode !== 'stop') clock = Math.max(clock, window.end) + previous = night(previous) + while (number < day.dayNumber && !issue) { + clock = window.end + previous = night(previous) + } + } else if (!previous) { + number = day.dayNumber + clock = pin ?? window.start + } + if (issue) return failed(issue) + if (pin !== null && day.dayNumber !== number) return failed('conflict') + + if (previous) { + const leg = legs[i - 1]! + const minutes = leg.seg.duration / 60 + if (pin !== null && chainAt(number).stops.every(s => s.automaticNight)) { + clock = Math.min(clock, pin - minutes) + if (clock < 0) return failed('conflict') + const startEntry = chainAt(number).schedule.entries[0] + startEntry.arrival = formatClock(Math.round(clock)) + startEntry.departure = formatClock(Math.round(clock)) + } + if (pin !== null && clock + minutes > pin + 1) return failed('conflict') + + let fraction = 0 + const split = pin === null && (leg.seg.mode === 'driving' || leg.seg.mode === undefined) + if (split && !targets.has(number) && window.endMode === 'stop' && clock + minutes > window.end + 0.000001) { + if (minutes > window.end - window.start) return failed('legTooLong') + previous = night(previous) + if (issue) return failed(issue) + } + while (fraction < 1) { + const target = targets.get(number) + if (target !== undefined && target < i - 1 + fraction - 0.000001) return failed('conflict') + const manualCut = target !== undefined && target < i && target >= i - 1 + fraction + if (!manualCut && (target !== undefined || !split || clock + minutes * (1 - fraction) <= window.end + 0.000001)) break + const available = manualCut ? minutes * (target - (i - 1 + fraction)) : Math.max(0, window.end - clock) + if (available === 0) { + previous = night(previous) + } else { + const until = Math.min(1, fraction + available / minutes) + const part = portion(leg, fraction, until, unit) + const at = part.line[part.line.length - 1] + const point = { ...previous, lat: at[0], lng: at[1] } + clock += available + position = i - 1 + until + const before = previous + previous = night(point) + const previousStops = chainAt(number - 1).stops + const ending = previousStops[previousStops.length - 1] + putLeg(before, ending, part) + fraction = until + } + if (issue) return failed(issue) + } + const remainder = portion(leg, fraction, 1, unit) + clock += minutes * (1 - fraction) + putLeg(previous, stop, remainder) + } + position = i + if (pin !== null && number !== day.dayNumber) return failed('conflict') + const target = targets.get(number) + if (target !== undefined && target < position) return failed('conflict') + if (pin !== null) clock = pin + append(stop, clock) + previous = stop + + let dwell = Math.max(0, stop.dwellMinutes ?? 0) + while (dwell > 0) { + const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock) + const spend = Math.min(dwell, remaining) + clock += spend + dwell -= spend + const entries = chainAt(number).schedule.entries + entries[entries.length - 1].departure = formatClock(Math.round(clock)) + if (dwell > 0) { + previous = night(previous) + if (issue) return failed(issue) + } + } + if (target === position || !targets.has(number) && stop.endDay && i < stops.length - 1) { + previous = night(previous, i === stops.length - 1) + if (issue) return failed(issue) + } + } + + if ([...targets.keys()].some(day => !chains.get(day)?.stops.some(stop => stop.automaticNight?.phase === 'end'))) return failed('conflict') + + for (const chain of chains.values()) { + for (let i = 0; i < chain.stops.length; i++) { + const stop = chain.stops[i] + if (stop.automaticNight) continue + const owner = ordered.find(d => d.dayId === stop.ownerDayId) + if (!owner || owner.dayNumber === chain.dayNumber) continue + const last = chain.spills[chain.spills.length - 1] + if (last?.fromDayNumber === owner.dayNumber && last.at + last.count === i) last.count += 1 + else chain.spills.push({ + automatic: true, + at: i, count: 1, fromDayNumber: owner.dayNumber, departure: null, + leg: undefined, line: [], fromStop: undefined, + }) + } + } + for (const day of ordered) chainAt(day.dayNumber) + return { + chains: [...chains.values()].sort((a, b) => a.dayNumber - b.dayNumber), + legFor: (from, to) => pieces.get(from)?.get(to), + issue: null, + } +} + +export function roadtripInsertion(day: Pick, 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/client/src/components/Roadtrip/nightSpill.ts b/client/src/components/Roadtrip/nightSpill.ts index 70f935eed..bfe1dc56b 100644 --- a/client/src/components/Roadtrip/nightSpill.ts +++ b/client/src/components/Roadtrip/nightSpill.ts @@ -25,6 +25,7 @@ import type { RouteSegment } from '../../types' /** A stretch of one card's chain that was driven onto it from an earlier day. */ export interface SpillMark { + automatic?: boolean /** Where it starts in the card's `stops`. */ at: number /** How many stops it covers. */ 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/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/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts index 5850e09b4..462c4c516 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)] diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 85fed61f9..64e6d09be 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -10,10 +10,15 @@ import { useSettingsStore } from '../../store/settingsStore' import { useVehicleRange } from './useVehicleRange' import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types' import { spurFor } from './accessSpur' -import type { RoadtripVia } from '@trek/shared' +import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared' +import { dayWindow, planDayWindow, type AutomaticNight, type WindowPlan } from './dayWindow' +import type { DayBoundaryLeg } from './dayBoundaryPath' +import { useTranslation } from '../../i18n/TranslationContext' +import { computeSchedule } from './roadtripModel' /** One stop on a day's drive — an assignment whose place actually has coordinates. */ export interface RoadtripStop { + automaticNight?: AutomaticNight assignmentId: number /** * The day this stop is STORED on, and its position there. @@ -34,6 +39,7 @@ export interface RoadtripStop { time: string | null /** How long the visit is planned to take. `places.duration_minutes` has carried this for years. */ dwellMinutes: number | null + endDay?: boolean /** Mode of the leg LEAVING this stop; null inherits the day default. */ legMode: string | null incomingLegMode: string | null @@ -59,6 +65,7 @@ export interface RoadtripStop { } export interface RoadtripDay { + automaticSchedule?: boolean dayId: number dayNumber: number date: string | null @@ -151,6 +158,9 @@ export interface QuietDay { } export interface RoadtripRoutes { + boundaryPath?: DayBoundaryLeg[] + validateBoundaries?: (boundaries: RoadtripDayBoundary[]) => WindowPlan['issue'] + dayWindowIssue?: 'incomplete' | 'conflict' | 'tooLong' | 'legTooLong' | null days: RoadtripDay[] /** * Days with fewer than two stops, in trip order. @@ -220,7 +230,7 @@ const sleep = (ms: number, signal: AbortSignal): Promise => * than added later, because this shape is written in one place and read in another and * three separate changes to the same two lines is three merge conflicts. */ -interface RoutedLeg { +export interface RoutedLeg { seg: RouteSegment line: [number, number][] /** @@ -287,6 +297,7 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): Roadtrip lng: p.lng, time: a.assignment_time ?? p.place_time ?? null, dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null, + endDay: a.end_day === true, legMode: a.leg_transport_mode ?? null, incomingLegMode: a.incoming_leg_transport_mode ?? null, stopType: p.stop_type ?? null, @@ -318,7 +329,9 @@ export function useRoadtripRoutes( * chose rather than the one it prefers. */ viasByDay: Record = {}, + boundaries: RoadtripDayBoundary[] = [], ): RoadtripRoutes { + const { t } = useTranslation() const routeProfile = fallbackProfile || 'driving' // Leg text is pre-formatted in the chosen unit, so a km↔mi switch has to re-fetch. const distanceUnit = useSettingsStore(s => s.settings.distance_unit) @@ -367,7 +380,12 @@ export function useRoadtripRoutes( * the trip one continuous drive — every one of those gaps is routed, drawn, and counted * towards the day it ARRIVES on, which is the same rule a night drive already follows. */ - const connectDays = useSettingsStore(s => !!s.settings.roadtrip_connect_days) + const connectSetting = useSettingsStore(s => !!s.settings.roadtrip_connect_days) + const startTime = useSettingsStore(s => s.settings.roadtrip_day_start) + const endTime = useSettingsStore(s => s.settings.roadtrip_day_end) + const endMode = useSettingsStore(s => s.settings.roadtrip_day_end_mode) + const window = useMemo(() => dayWindow(startTime, endTime, endMode), [startTime, endTime, endMode]) + const connectDays = connectSetting || window !== null const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting]) // What the car is and how far it goes on one fill, assembled in one place because the // rail needs the same answer to say what a given fill buys at a given stop. @@ -671,7 +689,10 @@ export function useRoadtripRoutes( if (have && have.shape === seamShape(from, viasByDay)) return out.push({ from, to, dayId }) } - for (const chain of chains) { + const routingChains = window + ? [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).filter(d => d.stops.length) + : chains + for (const chain of routingChains) { for (let i = 0; i < chain.stops.length - 1; i++) { const from = chain.stops[i] const to = chain.stops[i + 1] @@ -683,14 +704,14 @@ export function useRoadtripRoutes( // And, when the traveller asks for one continuous drive, the road from each card's // last stop to the next card's first — the one gap a day-at-a-time routing leaves. if (connectDays) { - for (let d = 0; d < chains.length - 1; d++) { - const from = chains[d].stops[chains[d].stops.length - 1] - const to = chains[d + 1].stops[0] - if (from && to) want(from, to, chains[d + 1].dayId) + for (let d = 0; d < routingChains.length - 1; d++) { + const from = routingChains[d].stops[routingChains[d].stops.length - 1] + const to = routingChains[d + 1].stops[0] + if (from && to) want(from, to, routingChains[d + 1].dayId) } } return out - }, [chains, legsByDay, seamLegs, viasByDay, connectDays]) + }, [chains, plan, quietDays, window, legsByDay, seamLegs, viasByDay, connectDays]) const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';') useEffect(() => { @@ -761,7 +782,22 @@ export function useRoadtripRoutes( // places is the road between them whichever date it is driven on. const allSnaps: Record = {} for (const day of plan) Object.assign(allSnaps, snapByDay[day.dayId] ?? {}) - const legFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)] + const storedLegFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)] + const timed = window ? planDayWindow( + [...plan, ...quietDays], window, storedLegFor, distanceUnit, + { start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') }, + boundaries, + ) : null + const automaticSchedule = !!timed && timed.issue === null + const displayChains = automaticSchedule ? timed.chains : timed?.issue + ? [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).filter(d => d.stops.length).map(d => ({ + ...d, spills: [], schedule: computeSchedule( + d.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })), + d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1])?.seg.duration), + ), + })) + : chains + const legFor = automaticSchedule ? timed.legFor : storedLegFor const lines: [number, number][][] = [] const lineDays: number[] = [] @@ -777,7 +813,7 @@ export function useRoadtripRoutes( let previousStop: RoadtripStop | undefined /** And which day that was, so the road out of it is drawn as that day's. */ let previousDayNumber: number | undefined - for (const chain of chains) { + for (const chain of displayChains) { const routed = chain.stops.slice(0, -1).map((s, i) => legFor(s, chain.stops[i + 1])) /** * The drives that reach this card rather than leave from it, and only when the @@ -799,7 +835,7 @@ export function useRoadtripRoutes( * look like it begins at a stop that is not on it. */ const inboundAt = new Map() - if (connectDays) { + if (connectDays && !automaticSchedule) { for (const spill of chain.spills) { inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber }) } @@ -839,7 +875,7 @@ export function useRoadtripRoutes( const schedule = chain.schedule const legVias = routed.map(l => l?.vias ?? []) const stops = chain.stops.map(s => { - const snap = allSnaps[stopKey(s)] + const snap = s.automaticNight ? undefined : allSnaps[stopKey(s)] const line = spurFor(snap) if (line) accessLines.push({ line, meters: snap.meters, stopKey: stopKey(s) }) return { ...s, offRoadMeters: line ? snap.meters : null } @@ -884,6 +920,7 @@ export function useRoadtripRoutes( // ferry or a walk pays for a second copy. const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine out.push({ + automaticSchedule, dayId: chain.dayId, dayNumber: chain.dayNumber, date: chain.date, @@ -895,8 +932,19 @@ export function useRoadtripRoutes( dayWarning: drive.day, }) } - const drives = out.filter(d => d.stops.length > 1) + const drives = out.filter(d => d.stops.length > 1 || d.stops.some(s => s.automaticNight)) + const originalStops = [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).flatMap(day => day.stops) + const boundaryPath = originalStops.slice(0, -1).flatMap((from, position) => { + const to = originalStops[position + 1] + const leg = storedLegFor(from, to) + return leg && (!leg.seg.mode || leg.seg.mode === 'driving') && from.assignmentId > 0 && to.assignmentId > 0 + ? [{ from, to, position, line: leg.line }] : [] + }) return { + boundaryPath, + validateBoundaries: (next: RoadtripDayBoundary[]) => window ? planDayWindow([...plan, ...quietDays], window, storedLegFor, distanceUnit, + { start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') }, next).issue : 'conflict', + dayWindowIssue: timed?.issue ?? null, days: drives, lines, lineDays, @@ -909,13 +957,13 @@ export function useRoadtripRoutes( // holding a single stop draws no card and contributes nothing else here either. // Service stops are not stops in this sense — a charger on the way is part of the // drive, and counting it here would make the head disagree with the day cards. - totalStops: drives.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0), + totalStops: drives.reduce((s, d) => s + d.stops.filter(st => !st.automaticNight && !isServiceStopType(st.stopType)).length, 0), // A day left with one stop or none draws no drive, so it stays what it was: an // outline that appears while a stop is in flight and is not worth a row otherwise. quietDays: out - .filter(d => d.stops.length < 2) + .filter(d => d.stops.length < 2 && !d.stops.some(s => s.automaticNight)) .map(d => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })), loading, } - }, [plan, chains, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays]) -} \ No newline at end of file + }, [plan, quietDays, window, distanceUnit, t, chains, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries]) +} 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/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 44d99e41d..d3d68fd4a 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -267,9 +267,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null { setRoadtripStopKind, setRoadtripStopFill, saveRoadtripLimit, - roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, - routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, - stayDraft, setStayDraft, setRoadtripStay, + roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, resetDayBoundaries, + routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, dayBoundaryControls, + stayDraft, setStayDraft, setRoadtripStay, roadtripEndDay, roadtripStay, highlightedAlternative, setHighlightedAlternative, moveRoadtripStopToDay, dropPoiOnRoute, @@ -407,7 +407,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null { dayPlaces={dayPlaces} route={roadtripActive ? roadtripMapLines : route} routeColors={roadtripActive ? roadtripLineColors : 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 @@ -543,6 +544,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { handlePlaceClick(placeId, assignmentId)} onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined} @@ -685,7 +687,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {/* Under the search, because the limits are read while looking at what the drive is doing rather than set up front. */}
    - +
    ) : ( @@ -769,6 +771,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {selectedPlace && !isMobile && ( setSelectedPlaceId(null)}>
    e.stopPropagation()}> 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 +188,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 +329,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 })] }) diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 9aa3dcfbb..43f737bd3 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -22,7 +22,11 @@ import { useAuthStore } from '../../store/authStore' import { useResizablePanels } from '../../hooks/useResizablePanels' import { useTripWebSocket } from '../../hooks/useTripWebSocket' import { useRouteCalculation } from '../../hooks/useRouteCalculation' -import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes' +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 { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor' import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias' import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch' @@ -540,6 +544,14 @@ 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 dailyTimesActive = !!dayWindow(settings.roadtrip_day_start, settings.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( @@ -548,6 +560,7 @@ export function useTripPlanner() { assignments, routeProfile, roadtripVias.byDay, + dayBoundaries.boundaries, ) // Lives here rather than in the panel because the map draws what it finds. const roadtripCorridor = useRoadtripCorridor(roadtripRoutes) @@ -707,20 +720,24 @@ 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), + ...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. @@ -830,11 +847,11 @@ 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]) @@ -1044,6 +1061,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. * @@ -1177,11 +1208,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. */ @@ -1196,8 +1249,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) @@ -1221,7 +1274,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. // @@ -1231,7 +1284,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 @@ -1402,7 +1455,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. */ @@ -1435,10 +1488,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]) @@ -1968,6 +2022,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: selectedPlace.duration_minutes ?? null, onEdit: can('place_edit', trip) ? () => setStayDraft({ 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(() => { @@ -2025,10 +2089,10 @@ export function useTripPlanner() { setRoadtripStopKind, setRoadtripStopFill, saveRoadtripLimit, - roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, + 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, 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/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..946e1b0a2 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 { 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 { @@ -194,6 +196,9 @@ export const mutationQueue = { }) // 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/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/server/src/db/migrations.ts b/server/src/db/migrations.ts index d9746f80a..71bfe1960 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4534,6 +4534,22 @@ function runMigrations(db: Database.Database): void { db.exec('ALTER TABLE places ADD COLUMN fill_percent INTEGER'); } }, + () => { + 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) + )`); + }, ]; 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/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/roadtrip.module.ts b/server/src/nest/roadtrip/roadtrip.module.ts index 5cdfeecbd..9e224c026 100644 --- a/server/src/nest/roadtrip/roadtrip.module.ts +++ b/server/src/nest/roadtrip/roadtrip.module.ts @@ -7,14 +7,17 @@ import { PermissionsModule } from '../permissions/permissions.module'; import { AuthModule } from '../auth/auth.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { AddonsModule } from '../addons/addons.module'; +import { DayBoundariesController } from './day-boundaries.controller'; +import { DayBoundariesService } from './day-boundaries.service'; +import { DayBoundariesMcp } from './day-boundaries.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], + controllers: [RoadtripController, DayBoundariesController], + providers: [RoadtripService, RoadtripMcp, DayBoundariesService, DayBoundariesMcp], 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..e3d122ebd 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,21 @@ 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); } } + 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/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/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/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/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..06f4c5e7f 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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/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..ccf67f845 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..07d2813ed 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -1,6 +1,35 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..7eea0b6f5 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..75adcd85f 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -1,6 +1,35 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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 an der erreichten Stelle auf der Strecke pausieren.', + 'roadtrip.window.stopHint': 'Am letzten Ort bleiben, wenn der nächste vor Tagesende nicht mehr erreichbar ist.', + '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 setzen, um tägliche Pausen zu planen. Ein leeres Feld schaltet dies aus. Manuelle Zeiten haben Vorrang. Tage werden automatisch verbunden.', + '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', 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..3b574354c 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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 along the route when the end time is reached.', + 'roadtrip.window.stopHint': 'Stay at the last place if the next one cannot be reached before 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 plan daily breaks. Clear either to turn this off. Manual times take priority. Days are connected automatically.', + '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', 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..f10478b16 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..9f0f04d7a 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -1,6 +1,35 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..81cf55ae4 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -1,6 +1,36 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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/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..c14441c8b 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -1,6 +1,35 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..7c8aa65dd 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -1,6 +1,35 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..6f64e64a4 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -1,6 +1,35 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..c8c2bff9d 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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': 'プラン表示', 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..890d97a0d 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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/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..e4d24d9a6 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -1,6 +1,35 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..b9a195697 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..49e72bccf 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -1,6 +1,35 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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/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..4980f6204 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..c0bc9b547 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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ü', 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..b59dfba12 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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/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..0dab1f535 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -1,6 +1,34 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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', 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..e68b07f47 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -1,6 +1,30 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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..ad6231027 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -1,6 +1,30 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + '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 867b4290f..63366a524 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. diff --git a/shared/src/realtime/events.schema.spec.ts b/shared/src/realtime/events.schema.spec.ts index 378ef549b..39505def3 100644 --- a/shared/src/realtime/events.schema.spec.ts +++ b/shared/src/realtime/events.schema.spec.ts @@ -30,6 +30,7 @@ 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 +141,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 (72 trip + 32 user = 104)', () => { // 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(72); expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(32); - expect(TREK_WS_EVENT_NAMES).toHaveLength(103); + expect(TREK_WS_EVENT_NAMES).toHaveLength(104); }); 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..7f15ab9d5 100644 --- a/shared/src/realtime/events.schema.ts +++ b/shared/src/realtime/events.schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { roadtripDayBoundaryListSchema } from '../roadtrip/day-boundary.schema'; /** * WS event contract registry — the single source of truth for every realtime @@ -110,6 +111,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 }) }, diff --git a/shared/src/roadtrip/day-boundary.schema.ts b/shared/src/roadtrip/day-boundary.schema.ts new file mode 100644 index 000000000..88397c55d --- /dev/null +++ b/shared/src/roadtrip/day-boundary.schema.ts @@ -0,0 +1,13 @@ +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/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/Trip-Planner-Overview.md b/wiki/Trip-Planner-Overview.md index 957116dc0..6f1457284 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 are personal driving preferences and apply to your Roadtrip views. + +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:** Pauses are calculated in the Roadtrip view and are not stored as places or bookings. `set_assignment_end_day` sets or clears the boolean `end_day` on a specific visit; it takes effect only with daily travel times enabled. `list_day_boundaries` and `set_day_boundary` read, set or clear dragged day endings, anchored to assignment IDs and a fraction of the route. Place and assignment tools continue to read the original assignments and manually entered times. + ## 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.