mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): add daily scheduling and configurable day endings
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import RoutingInstanceFields, { type RoutingDefaults } from './RoutingInstanceFields'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -34,7 +35,7 @@ const MAP_PRESETS = [
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
type Defaults = {
|
||||
type Defaults = RoutingDefaults & {
|
||||
temperature_unit?: string
|
||||
distance_unit?: DistanceUnit
|
||||
dark_mode?: string | boolean
|
||||
@@ -43,8 +44,6 @@ type Defaults = {
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -121,8 +120,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [routingBase, setRoutingBase] = useState('')
|
||||
const [valhallaBase, setValhallaBase] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,8 +129,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setRoutingBase(data.routing_base_url || '')
|
||||
setValhallaBase(data.valhalla_base_url || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -156,8 +151,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'routing_base_url') setRoutingBase('')
|
||||
if (key === 'valhalla_base_url') setValhallaBase('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -387,53 +380,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Instance configuration, not a taste, which is why it sits here rather
|
||||
than in a user's own Map settings. The origin has to appear in the CSP
|
||||
`connect-src` the server emits at boot, and that list is built from
|
||||
this default — a value on a personal settings row is read by the route
|
||||
calculator and then refused by the browser, so the whole app stops
|
||||
routing with no error it could report. The write route refuses one
|
||||
from a non-admin for the same reason. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.routingBase')}
|
||||
<ResetButton field="routing_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={routingBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
|
||||
onBlur={() => save({ routing_base_url: routingBase.trim() })}
|
||||
placeholder="https://osrm.example.org"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* The second engine, next to the first because it is the same kind of decision
|
||||
and carries the same restart caveat. Only ever asked to leave a road class
|
||||
out of one leg, which is the one question the OSRM above refuses. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.valhallaBase')}
|
||||
<ResetButton field="valhalla_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={valhallaBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValhallaBase(e.target.value)}
|
||||
onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })}
|
||||
placeholder="https://valhalla.example.org"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.valhallaBaseHint')}</p>
|
||||
</div>
|
||||
<div className="mt-3.5">
|
||||
<RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
export interface RoutingDefaults {
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
}
|
||||
|
||||
interface RoutingFieldsProps {
|
||||
defaults: RoutingDefaults
|
||||
onSave: (patch: RoutingDefaults) => Promise<void>
|
||||
onReset: (key: keyof RoutingDefaults) => Promise<void>
|
||||
hintClassName?: string
|
||||
}
|
||||
|
||||
const ROUTERS = [
|
||||
{ key: 'routing_base_url', label: 'settings.routingBase', hint: 'settings.routingBaseHint', placeholder: 'https://osrm.example.org' },
|
||||
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
|
||||
] as const
|
||||
|
||||
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 'text-caption leading-relaxed text-content-faint' }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const saved = defaults[router.key] ?? ''
|
||||
const commit = async () => {
|
||||
if (draft === null) return
|
||||
const value = draft.trim()
|
||||
if (value === saved) { setDraft(null); return }
|
||||
setSaving(true)
|
||||
try { await onSave({ [router.key]: value }) }
|
||||
finally { setDraft(null); setSaving(false) }
|
||||
}
|
||||
const reset = async () => {
|
||||
setSaving(true)
|
||||
try { await onReset(router.key) }
|
||||
finally { setDraft(null); setSaving(false) }
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 space-y-1.5" aria-busy={saving}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<label htmlFor={id} className="text-body font-medium text-content-secondary">{t(router.label)}</label>
|
||||
{defaults[router.key] !== undefined && (
|
||||
<button type="button" disabled={saving} onClick={reset} className="text-caption text-content-muted underline disabled:opacity-50">
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input id={id} type="url" inputMode="url" value={draft ?? saved} disabled={saving}
|
||||
onChange={e => setDraft(e.target.value)} onBlur={commit}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||
placeholder={router.placeholder} spellCheck={false} autoComplete="off" autoCapitalize="none"
|
||||
aria-describedby={`${id}-hint`}
|
||||
className="min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
/>
|
||||
<p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
|
||||
return <div className="flex flex-col gap-3">{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Marker, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import type { Poi } from './poiCategories'
|
||||
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
|
||||
|
||||
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
|
||||
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
|
||||
}) {
|
||||
const map = useMap()
|
||||
const { t } = useTranslation()
|
||||
const [zoom, setZoom] = useState(() => map.getZoom())
|
||||
const popup = useRef<L.Popup | null>(null)
|
||||
useEffect(() => () => { popup.current?.remove() }, [pois, enabled, zoom])
|
||||
useEffect(() => {
|
||||
const update = () => setZoom(map.getZoom())
|
||||
map.on('zoomend', update)
|
||||
return () => { map.off('zoomend', update) }
|
||||
}, [map])
|
||||
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng])) : [], [pois, enabled, map, zoom])
|
||||
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
|
||||
if (!enabled) return <>{children}</>
|
||||
return <>{groups.map(group => {
|
||||
if (group.pois.length === 1) return markers.get(group.pois[0].osm_id)
|
||||
const label = t('roadtrip.poi.found', { count: group.pois.length })
|
||||
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, group.lng]} alt={label}
|
||||
icon={L.divIcon({ className: '', html: poiClusterMarkup(group.pois.length), iconSize: [38, 38], iconAnchor: [19, 19] })}
|
||||
zIndexOffset={600} eventHandlers={{ click: () => {
|
||||
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
|
||||
popup.current?.remove()
|
||||
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, group.lng])
|
||||
.setContent(poiClusterList(group.pois, poi => { popup.current?.remove(); onPoiClick?.(poi) })).openOn(map)
|
||||
} else map.fitBounds(group.pois.map(poi => [poi.lat, poi.lng] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
|
||||
} }} />
|
||||
})}</>
|
||||
}
|
||||
@@ -248,9 +248,9 @@ describe('MapView', () => {
|
||||
expect(screen.getAllByTestId('polyline').length).toBe(3)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-010: MarkerClusterGroup is rendered', () => {
|
||||
it.each([false, true])('FE-COMP-MAPVIEW-010: place clustering stays enabled with roadtrip=%s', (roadtrip) => {
|
||||
const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} />)
|
||||
render(<MapView places={places} clusterLoosely={roadtrip} />)
|
||||
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -680,6 +680,27 @@ describe('MapView plugin route vias', () => {
|
||||
expect(markersWithZ('800')[0].textContent).toContain('Supercharger · 1 h 30 min')
|
||||
})
|
||||
|
||||
it('hides night badges at wide zoom and shows the full description when zoomed in', () => {
|
||||
mapMock.getZoom.mockReturnValue(5)
|
||||
const label = 'Tagesende von Tag 1 um 18:00 Uhr'
|
||||
render(<MapView routeVias={[via({ label, hoverCard: true, nightPause: { day: 1, atPlace: true } })]} />)
|
||||
expect(markersWithZ('800')).toHaveLength(0)
|
||||
mapMock.getZoom.mockReturnValue(6)
|
||||
act(() => { mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]() })
|
||||
const badge = markersWithZ('800')[0]
|
||||
expect(iconHtmlOf(badge)).toContain('data-night-pause="place"')
|
||||
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(label)
|
||||
mapMock.getZoom.mockReturnValue(5)
|
||||
act(() => {
|
||||
mapMock.on.mock.calls.find(([events]) => events === 'movestart zoomstart')![1]()
|
||||
mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]()
|
||||
})
|
||||
expect(markersWithZ('800')).toHaveLength(0)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
mapMock.getZoom.mockReturnValue(10)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-041: a dwell under an hour is shown in minutes alone', () => {
|
||||
render(<MapView routeVias={[via({ dwellSeconds: 1500 })]} />)
|
||||
expect(markersWithZ('800')[0].textContent).toContain('25 min')
|
||||
|
||||
@@ -44,6 +44,12 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Day, Reservation, RouteVia } from '../../types'
|
||||
import type { MapHoverInfo } from './mapHover'
|
||||
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
|
||||
import NightPauseTooltip from './NightPauseTooltip'
|
||||
import ClusteredPois from './ClusteredPois'
|
||||
import { NightPauseDrag } from './NightPauseDrag'
|
||||
import type { DayBoundaryControls } from './dayBoundaryDrag'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
|
||||
@@ -98,6 +104,26 @@ function formatViaDwell(seconds: number): string {
|
||||
return h > 0 ? `${h} h ${m} min` : `${m} min`
|
||||
}
|
||||
|
||||
function nightPauseIcon(via: RouteVia): L.DivIcon {
|
||||
const key = `night:${via.nightPause!.day}:${via.nightPause!.atPlace}`
|
||||
const cached = viaIconCache.get(key)
|
||||
if (cached) return cached
|
||||
const icon = L.divIcon({ className: 'night-pause-marker', html: nightPauseMarker(via), iconSize: [0, 0], iconAnchor: [0, 0] })
|
||||
viaIconCache.set(key, icon)
|
||||
return icon
|
||||
}
|
||||
|
||||
function RouteViaMarker({ via, controls, eventHandlers, children }: {
|
||||
via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode
|
||||
}) {
|
||||
const [marker, setMarker] = useState<L.Marker | null>(null)
|
||||
return <Marker ref={setMarker} position={[via.lat, via.lng]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
|
||||
alt={via.nightPause ? via.label : undefined} zIndexOffset={800} eventHandlers={eventHandlers}>
|
||||
{children}
|
||||
{via.nightPause && <NightPauseDrag marker={marker} via={via} controls={controls} />}
|
||||
</Marker>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a round photo-circle marker.
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
@@ -226,15 +252,16 @@ function createPoiIcon(category: string, brandWikidata?: string | null) {
|
||||
// away under a stationary cursor, so the browser never fires mouseout — and
|
||||
// mouseover/mousemove during the pan animation would immediately re-set the
|
||||
// tooltip we just cleared (#1404).
|
||||
function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: boolean }; onMoveStart: () => void }) {
|
||||
function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { current: boolean }; onMoveStart: () => void; onZoom: (zoom: number) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const start = () => { movingRef.current = true; onMoveStart() }
|
||||
const end = () => { movingRef.current = false }
|
||||
const end = () => { movingRef.current = false; onZoom(map.getZoom()) }
|
||||
onZoom(map.getZoom())
|
||||
map.on('movestart zoomstart', start)
|
||||
map.on('moveend zoomend', end)
|
||||
return () => { map.off('movestart zoomstart', start); map.off('moveend zoomend', end) }
|
||||
}, [map, movingRef, onMoveStart])
|
||||
}, [map, movingRef, onMoveStart, onZoom])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -638,6 +665,7 @@ export const MapView = memo(function MapView({
|
||||
onViewportChange,
|
||||
tripId,
|
||||
routeVias = [],
|
||||
dayBoundaryControls,
|
||||
accessLines = [],
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
@@ -712,11 +740,12 @@ export const MapView = memo(function MapView({
|
||||
})
|
||||
|
||||
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<MapHoverInfo | null>(null)
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [mapZoom, setMapZoom] = useState(0)
|
||||
const mapMovingRef = useRef(false)
|
||||
|
||||
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
|
||||
const handleMarkerHover = useCallback((place: MapHoverInfo, x: number, y: number) => {
|
||||
if (hoverDisabled || mapMovingRef.current) return
|
||||
setHoveredPlace(place)
|
||||
setTooltipPos({ x, y })
|
||||
@@ -920,6 +949,7 @@ export const MapView = memo(function MapView({
|
||||
), [gpxTracks, hasCasingPane])
|
||||
|
||||
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
|
||||
&& (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia))
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||
@@ -1001,32 +1031,25 @@ export const MapView = memo(function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
zoomed right out, and a day's stops merging into one dot is exactly the shape
|
||||
it is being looked at for. Rendered outside the group rather than with the
|
||||
clustering disabled by option, so nothing is left to a plugin's own idea of
|
||||
what "disabled" means. */}
|
||||
{clusterLoosely ? markers : (
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
)}
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
@@ -1130,19 +1153,25 @@ export const MapView = memo(function MapView({
|
||||
roadRoutes={transportRoutes}
|
||||
/>
|
||||
|
||||
{poiMarkers}
|
||||
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
|
||||
{/* Charging stops / rest areas a plugin route places on the drawn day route.
|
||||
Host-vetted data (server-normalized), rendered as plain tone dots. */}
|
||||
{(routeVias as RouteVia[]).map((v, i) => (
|
||||
<Marker key={`route-via-${i}`} position={[v.lat, v.lng]} icon={routeViaIcon(v.tone)} zIndexOffset={800}>
|
||||
{(v.label || v.dwellSeconds != null) && (
|
||||
<Tooltip direction="top" offset={[0, -8]}>
|
||||
{(routeVias as RouteVia[]).filter(v => !v.nightPause || mapZoom >= NIGHT_PAUSE_MIN_ZOOM).map((v, i) => (
|
||||
<RouteViaMarker key={v.nightPause ? `night-${v.nightPause.day}` : `route-via-${i}`} via={v} controls={dayBoundaryControls}
|
||||
eventHandlers={v.hoverCard ? {
|
||||
mouseover: e => handleMarkerHover({ name: v.label, routeVia: v }, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mousemove: e => handleMarkerHover({ name: v.label, routeVia: v }, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mouseout: handleMarkerHoverOut,
|
||||
} : undefined}
|
||||
>
|
||||
{(!v.hoverCard || isTouchDevice) && (v.label || v.dwellSeconds != null) && (
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
{v.label}
|
||||
{v.label && v.dwellSeconds != null ? ' · ' : ''}
|
||||
{v.dwellSeconds != null ? formatViaDwell(v.dwellSeconds) : ''}
|
||||
</Tooltip>
|
||||
)}
|
||||
</Marker>
|
||||
</RouteViaMarker>
|
||||
))}
|
||||
<PluginMapMarkers tripId={tripId} />
|
||||
<PluginMapLayers tripId={tripId} />
|
||||
@@ -1162,7 +1191,10 @@ export const MapView = memo(function MapView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{TooltipOverlay && (
|
||||
{TooltipOverlay && hoveredPlace.routeVia?.nightPause && (
|
||||
<NightPauseTooltip label={hoveredPlace.name ?? ''} x={tooltipPos.x} y={tooltipPos.y} />
|
||||
)}
|
||||
{TooltipOverlay && !hoveredPlace.routeVia?.nightPause && (
|
||||
<div data-testid="tooltip" style={{
|
||||
position: 'fixed',
|
||||
left: tooltipPos.x + 14,
|
||||
|
||||
@@ -1389,6 +1389,30 @@ describe('MapViewGL', () => {
|
||||
expect(glMarkers.created[1].element.innerHTML).toContain('#6b7280')
|
||||
})
|
||||
|
||||
it('clusters roadtrip stations and zooms before offering overlapping stations individually', async () => {
|
||||
loadOnAttach()
|
||||
const poi: Poi = {
|
||||
osm_id: 'n1', name: 'Station A', lat: 48.854, lng: 2.332, category: 'charging_station',
|
||||
poi_type: 'charging_station', address: null, website: null, phone: null,
|
||||
opening_hours: null, cuisine: null, source: 'openstreetmap',
|
||||
}
|
||||
const other = { ...poi, osm_id: 'n2', name: 'Station B' }
|
||||
const onPoiClick = vi.fn()
|
||||
render(<MapViewGL places={[]} fitKey={1} pois={[poi, other]} clusterLoosely onPoiClick={onPoiClick} />)
|
||||
await act(async () => {})
|
||||
const cluster = glMarkers.created.find(pin => pin.element.querySelector('[data-poi-cluster="2"]'))!
|
||||
expect(cluster).toBeDefined()
|
||||
act(() => { cluster.element.click() })
|
||||
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), { padding: 70, maxZoom: 18 })
|
||||
expect(onPoiClick).not.toHaveBeenCalled()
|
||||
vi.mocked(glMap.getZoom).mockReturnValueOnce(18)
|
||||
act(() => { cluster.element.click() })
|
||||
const calls = vi.mocked(glPopup.setDOMContent).mock.calls
|
||||
const list = calls[calls.length - 1][0]
|
||||
act(() => { list.querySelectorAll('button')[1].click() })
|
||||
expect(onPoiClick).toHaveBeenCalledWith(other)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-043: plugin markers render as tone dots with a text-only popup', async () => {
|
||||
loadOnAttach()
|
||||
const markers: PluginMapMarker[] = [
|
||||
|
||||
@@ -8,6 +8,7 @@ import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
@@ -24,6 +25,11 @@ import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlM
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Day, Place, Reservation, RouteVia } from '../../types'
|
||||
import type { MapHoverInfo } from './mapHover'
|
||||
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
|
||||
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
|
||||
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
|
||||
import NightPauseTooltip from './NightPauseTooltip'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
@@ -120,6 +126,7 @@ interface Props {
|
||||
tripId?: number | string
|
||||
// Charging stops / rest areas a plugin route places on the drawn day route.
|
||||
routeVias?: RouteVia[]
|
||||
dayBoundaryControls?: DayBoundaryControls
|
||||
/** The dashed last bit to a place the road network does not reach. */
|
||||
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
|
||||
route?: [number, number][][] | null
|
||||
@@ -197,7 +204,7 @@ interface Props {
|
||||
onMapReady?: (map: any | null) => void
|
||||
}
|
||||
|
||||
/** How eagerly place markers merge into a cluster, outside road trip mode. */
|
||||
/** How eagerly place markers merge into a cluster. */
|
||||
const CLUSTER_RADIUS = 30
|
||||
const CLUSTER_MAX_ZOOM = 10
|
||||
|
||||
@@ -609,6 +616,7 @@ export function MapViewGL({
|
||||
dayPlaces = NO_PLACES,
|
||||
tripId,
|
||||
routeVias = NO_ROUTE_VIAS,
|
||||
dayBoundaryControls,
|
||||
accessLines = NO_ACCESS_LINES,
|
||||
route = null,
|
||||
routeColors = null,
|
||||
@@ -651,6 +659,7 @@ export function MapViewGL({
|
||||
gl,
|
||||
onMapReady,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
@@ -675,7 +684,7 @@ export function MapViewGL({
|
||||
const [mapReady, setMapReady] = useState(false)
|
||||
// Hover tooltip — a cursor-following name/category/address card, matching the
|
||||
// Leaflet map's overlay exactly (no anchored popup, no photo thumbnail).
|
||||
const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null)
|
||||
const [hoverPlace, setHoverPlace] = useState<MapHoverInfo | null>(null)
|
||||
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const hoverIdRef = useRef<number | null>(null)
|
||||
// True while the camera is moving (flyTo after a click, pan, zoom). Marker
|
||||
@@ -1781,18 +1790,6 @@ export function MapViewGL({
|
||||
return
|
||||
}
|
||||
|
||||
// Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
|
||||
// is being looked at for. The source is emptied rather than reconfigured — the
|
||||
// cluster options can only be set when a GeoJSON source is created, and dropping and
|
||||
// re-adding a source under three layers to change a radius is not worth it when the
|
||||
// markers are drawn from `places` anyway.
|
||||
if (clusterLoosely) {
|
||||
source.setData({ type: 'FeatureCollection', features: [] })
|
||||
reconcileMarkers(validPlaces)
|
||||
return
|
||||
}
|
||||
|
||||
source.setData(buildPlaceClusterData(places) as any)
|
||||
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
||||
let raf: number | null = null
|
||||
@@ -1833,29 +1830,63 @@ export function MapViewGL({
|
||||
}
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
|
||||
|
||||
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
||||
// planned-place markers so they don't cluster or get confused with them).
|
||||
// Search results stay separate from the planned itinerary.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
poiCleanupRef.current.forEach(off => off())
|
||||
poiCleanupRef.current = []
|
||||
for (const poi of (pois as Poi[])) {
|
||||
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
// Dragging a hit onto the drive puts it where it is passed, rather than where the
|
||||
// corridor happened to project it. Only wired when someone is listening for it.
|
||||
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
|
||||
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
|
||||
const redraw = () => {
|
||||
popupRef.current?.remove()
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
poiCleanupRef.current.forEach(off => off())
|
||||
poiCleanupRef.current = []
|
||||
const groups = clusterLoosely ? clusterPois(pois, poi => map.project([poi.lng, poi.lat])) : pois.map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng }))
|
||||
for (const group of groups) {
|
||||
if (group.pois.length > 1) {
|
||||
const el = document.createElement('button')
|
||||
el.type = 'button'
|
||||
el.style.cssText = 'width:38px;height:38px;border:0;padding:0;background:transparent;'
|
||||
el.setAttribute('aria-label', t('roadtrip.poi.found', { count: group.pois.length }))
|
||||
el.innerHTML = poiClusterMarkup(group.pois.length)
|
||||
el.addEventListener('click', event => {
|
||||
event.stopPropagation()
|
||||
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
|
||||
popupRef.current?.setLngLat([group.lng, group.lat]).setDOMContent(poiClusterList(group.pois, poi => {
|
||||
popupRef.current?.remove(); onPoiClickRef.current?.(poi)
|
||||
})).addTo(map)
|
||||
} else {
|
||||
const bounds = new gl.LngLatBounds()
|
||||
group.pois.forEach(poi => bounds.extend([poi.lng, poi.lat]))
|
||||
map.fitBounds(bounds, { padding: 70, maxZoom: POI_CLUSTER_DETAIL_ZOOM })
|
||||
}
|
||||
})
|
||||
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, group.lng, group.lat))
|
||||
continue
|
||||
}
|
||||
const poi = group.pois[0]
|
||||
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
// Dragging a hit onto the drive puts it where it is passed, rather than where the
|
||||
// corridor happened to project it. Only wired when someone is listening for it.
|
||||
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
|
||||
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
|
||||
}
|
||||
}
|
||||
}, [pois, mapReady, glProvider])
|
||||
redraw()
|
||||
if (clusterLoosely) map.on('moveend', redraw)
|
||||
return () => {
|
||||
map.off('moveend', redraw)
|
||||
poiCleanupRef.current.forEach(off => off())
|
||||
poiCleanupRef.current = []
|
||||
poiMarkersRef.current.forEach(pin => pin.remove())
|
||||
poiMarkersRef.current = []
|
||||
popupRef.current?.remove()
|
||||
}
|
||||
}, [pois, mapReady, glProvider, clusterLoosely, t])
|
||||
|
||||
// Fetch plugin map contributions (markers + layers) per trip. Fail-safe: an
|
||||
// error or missing tripId just means no plugin overlays, the core map is fine.
|
||||
@@ -1887,11 +1918,30 @@ export function MapViewGL({
|
||||
if (!map || !mapReady) return
|
||||
routeViaMarkersRef.current.forEach(m => m.remove())
|
||||
routeViaMarkersRef.current = []
|
||||
const pauses: HTMLElement[] = []
|
||||
const unbind: (() => void)[] = []
|
||||
for (const v of routeVias) {
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'width:13px;height:13px;cursor:pointer;'
|
||||
const color = PLUGIN_TONE_COLORS[v.tone] ?? PLUGIN_TONE_COLORS.default
|
||||
el.innerHTML = `<span style="display:block;width:13px;height:13px;border-radius:50%;background:#fff;border:3.5px solid ${color};box-shadow:0 1px 4px rgba(0,0,0,0.35);box-sizing:border-box"></span>`
|
||||
if (v.nightPause) {
|
||||
el.style.cssText = 'width:0;height:0;overflow:visible;cursor:pointer;'
|
||||
el.innerHTML = nightPauseMarker(v)
|
||||
el.setAttribute('aria-label', v.label ?? '')
|
||||
pauses.push(el)
|
||||
}
|
||||
if (v.hoverCard) {
|
||||
const show = (ev: MouseEvent) => {
|
||||
if (hoverDisabledRef.current || camMovingRef.current) return
|
||||
hoverIdRef.current = null
|
||||
setHoverPlace({ name: v.label, routeVia: v })
|
||||
setHoverPos({ x: ev.clientX, y: ev.clientY })
|
||||
}
|
||||
el.addEventListener('mouseenter', show)
|
||||
el.addEventListener('mousemove', show)
|
||||
el.addEventListener('mouseleave', () => { setHoverPlace(null); setHoverPos(null) })
|
||||
}
|
||||
if (v.label || v.dwellSeconds != null) {
|
||||
const text = [v.label, v.dwellSeconds != null ? formatViaDwellGl(v.dwellSeconds) : null].filter(Boolean).join(' · ')
|
||||
el.addEventListener('click', (ev) => {
|
||||
@@ -1899,9 +1949,30 @@ export function MapViewGL({
|
||||
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
|
||||
})
|
||||
}
|
||||
routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat))
|
||||
const pin = attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat)
|
||||
routeViaMarkersRef.current.push(pin)
|
||||
if (v.nightPause && dayBoundaryControls) unbind.push(bindDayBoundaryDrag(el, v, dayBoundaryControls, {
|
||||
project: (lat, lng) => {
|
||||
const point = map.project([lng, lat])
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
return { x: point.x + rect.left, y: point.y + rect.top }
|
||||
},
|
||||
setPosition: (lat, lng) => { pin.setLngLat([lng, lat]) },
|
||||
lock: () => {
|
||||
const enabled = map.dragPan.isEnabled()
|
||||
map.dragPan.disable()
|
||||
return () => { if (enabled) map.dragPan.enable() }
|
||||
},
|
||||
}))
|
||||
}
|
||||
}, [routeVias, mapReady, glProvider])
|
||||
const updateZoom = () => {
|
||||
const visible = map.getZoom() >= NIGHT_PAUSE_MIN_ZOOM
|
||||
for (const el of pauses) el.style.display = visible ? '' : 'none'
|
||||
}
|
||||
updateZoom()
|
||||
map.on('zoomend', updateZoom)
|
||||
return () => { map.off('zoomend', updateZoom); unbind.forEach(dispose => dispose()) }
|
||||
}, [routeVias, mapReady, glProvider, dayBoundaryControls])
|
||||
|
||||
// Reconcile plugin markers (imperative, same lifecycle as the POI markers).
|
||||
useEffect(() => {
|
||||
@@ -2230,7 +2301,10 @@ export function MapViewGL({
|
||||
</div>
|
||||
{/* Hover tooltip — cursor-following name/category/address card, identical to
|
||||
the Leaflet map's overlay (no anchored popup, no photo). */}
|
||||
{!hoverDisabled && hoverPlace && hoverPos && !isMobile && (
|
||||
{!hoverDisabled && hoverPlace?.routeVia?.nightPause && hoverPos && !isMobile && routeVias.includes(hoverPlace.routeVia) && (
|
||||
<NightPauseTooltip label={hoverPlace.name ?? ''} x={hoverPos.x} y={hoverPos.y} />
|
||||
)}
|
||||
{!hoverDisabled && hoverPlace && !hoverPlace.routeVia?.nightPause && hoverPos && !isMobile && (!hoverPlace.routeVia || routeVias.includes(hoverPlace.routeVia)) && (
|
||||
<div data-testid="tooltip" style={{
|
||||
position: 'fixed',
|
||||
left: hoverPos.x + 14,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import type L from 'leaflet'
|
||||
import type { RouteVia } from '../../types'
|
||||
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
|
||||
|
||||
export function NightPauseDrag({ marker, via, controls }: { marker: L.Marker | null; via: RouteVia; controls?: DayBoundaryControls }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const el = marker?.getElement()
|
||||
if (!marker || !el || !controls) return
|
||||
return bindDayBoundaryDrag(el, via, controls, {
|
||||
project: (lat, lng) => {
|
||||
const point = map.latLngToContainerPoint([lat, lng])
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
return { x: point.x + rect.left, y: point.y + rect.top }
|
||||
},
|
||||
setPosition: (lat, lng) => { marker.setLatLng([lat, lng]) },
|
||||
lock: () => {
|
||||
const enabled = map.dragging.enabled()
|
||||
map.dragging.disable()
|
||||
return () => { if (enabled) map.dragging.enable() }
|
||||
},
|
||||
})
|
||||
}, [map, marker, via, controls])
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import NightPauseTooltip from './NightPauseTooltip'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('keeps the full pause description inside the viewport at its lower right edge', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 240, height: 56 } as DOMRect)
|
||||
const label = 'Tagesende von Tag 12 um 18:00 Uhr'
|
||||
render(<NightPauseTooltip label={label} x={window.innerWidth - 5} y={window.innerHeight - 5} />)
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
expect(tooltip).toHaveTextContent(label)
|
||||
expect(parseFloat(tooltip.style.left) + 240).toBeLessThanOrEqual(window.innerWidth - 12)
|
||||
expect(parseFloat(tooltip.style.top) + 56).toBeLessThanOrEqual(window.innerHeight - 12)
|
||||
expect(tooltip.style.overflowWrap).toBe('anywhere')
|
||||
})
|
||||
|
||||
it('keeps descriptions visible at the upper left edge', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 240, height: 56 } as DOMRect)
|
||||
render(<NightPauseTooltip label="End of day 1 at 18:00" x={-50} y={-50} />)
|
||||
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '12px', top: '12px' })
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export default function NightPauseTooltip({ label, x, y }: { label: string; x: number; y: number }) {
|
||||
const card = useRef<HTMLDivElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
const place = () => {
|
||||
const node = card.current!
|
||||
const { width, height } = node.getBoundingClientRect()
|
||||
const left = x + 14 + width > window.innerWidth - 12 ? x - width - 14 : x + 14
|
||||
node.style.left = `${Math.max(12, Math.min(left, window.innerWidth - width - 12))}px`
|
||||
node.style.top = `${Math.max(12, Math.min(y - 10, window.innerHeight - height - 12))}px`
|
||||
}
|
||||
place()
|
||||
window.addEventListener('resize', place)
|
||||
return () => window.removeEventListener('resize', place)
|
||||
}, [label, x, y])
|
||||
return createPortal(
|
||||
<div ref={card} role="tooltip" data-testid="tooltip"
|
||||
className="pointer-events-none fixed z-[var(--z-toast)] rounded-lg border border-edge-faint bg-surface-card px-3 py-2 font-semibold leading-relaxed text-content shadow-popover"
|
||||
style={{ left: x + 14, top: y - 10, maxWidth: 'min(16rem, calc(100vw - 24px))', overflowWrap: 'anywhere', fontFamily: 'var(--font-system)', fontSize: 12 }}
|
||||
>{label}</div>, document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
|
||||
import type { RoadtripStop } from '../Roadtrip/useRoadtripRoutes'
|
||||
|
||||
function setup() {
|
||||
const el = document.createElement('span')
|
||||
document.body.append(el)
|
||||
const save = vi.fn(async () => true), setPosition = vi.fn(), unlock = vi.fn()
|
||||
const stop = (assignmentId: number, lng: number) => ({ assignmentId, lat: 0, lng }) as RoadtripStop
|
||||
const dispose = bindDayBoundaryDrag(el, { lat: 0, lng: 0.5, tone: 'default', nightPause: { day: 1, atPlace: false, manual: true, position: 0.5 } }, {
|
||||
path: [{ from: stop(1, 0), to: stop(2, 1), position: 0, line: [[0, 0], [0, 1]] }], move: save, hint: 'Move',
|
||||
}, { project: (lat, lng) => ({ x: lng * 1000, y: lat * 1000 }), setPosition, lock: () => unlock })
|
||||
const pointer = (node: EventTarget, type: string, x: number) => node.dispatchEvent(new MouseEvent(type, { clientX: x, clientY: 0, bubbles: true, cancelable: true, button: 0 }))
|
||||
return { el, save, setPosition, unlock, dispose, pointer }
|
||||
}
|
||||
|
||||
it('saves the snapped visit once on release and restores map dragging', async () => {
|
||||
const s = setup()
|
||||
s.pointer(s.el, 'pointerdown', 500)
|
||||
s.pointer(document, 'pointermove', 990)
|
||||
expect(s.save).not.toHaveBeenCalled()
|
||||
s.pointer(document, 'pointerup', 990)
|
||||
expect(s.save).toHaveBeenCalledWith(1, { day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 })
|
||||
expect(s.unlock).toHaveBeenCalledOnce()
|
||||
s.dispose()
|
||||
})
|
||||
|
||||
it('escape cancels without saving and removes document handlers', () => {
|
||||
const s = setup()
|
||||
s.pointer(s.el, 'pointerdown', 500)
|
||||
s.pointer(document, 'pointermove', 300)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
s.pointer(document, 'pointerup', 300)
|
||||
expect(s.save).not.toHaveBeenCalled()
|
||||
expect(s.setPosition).toHaveBeenLastCalledWith(0, 0.5)
|
||||
expect(s.unlock).toHaveBeenCalledOnce()
|
||||
s.dispose()
|
||||
})
|
||||
|
||||
it('a right click clears the manual override and a click alone does not save', async () => {
|
||||
const s = setup()
|
||||
s.pointer(s.el, 'pointerdown', 500)
|
||||
s.pointer(document, 'pointerup', 500)
|
||||
expect(s.save).not.toHaveBeenCalled()
|
||||
s.el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
||||
expect(s.save).toHaveBeenCalledWith(1, null)
|
||||
s.dispose()
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { RoadtripDayBoundary } from '@trek/shared'
|
||||
import type { RouteVia } from '../../types'
|
||||
import { nearestDayBoundary, type DayBoundaryLeg, type DayBoundaryTarget, type ProjectPoint } from '../Roadtrip/dayBoundaryPath'
|
||||
|
||||
export interface DayBoundaryControls {
|
||||
path: DayBoundaryLeg[]
|
||||
move: (day: number, boundary: RoadtripDayBoundary | null) => Promise<boolean>
|
||||
hint: string
|
||||
}
|
||||
|
||||
interface DragMap {
|
||||
project: ProjectPoint
|
||||
setPosition: (lat: number, lng: number) => void
|
||||
lock: () => () => void
|
||||
}
|
||||
|
||||
export function bindDayBoundaryDrag(el: HTMLElement, via: RouteVia, controls: DayBoundaryControls, map: DragMap) {
|
||||
const pause = via.nightPause!
|
||||
const handle = el.querySelector<HTMLElement>('[data-night-pause]') ?? el
|
||||
handle.style.cursor = 'grab'
|
||||
handle.style.touchAction = 'none'
|
||||
handle.tabIndex = 0
|
||||
handle.setAttribute('role', 'button')
|
||||
handle.setAttribute('aria-label', via.label ?? '')
|
||||
handle.setAttribute('aria-description', controls.hint)
|
||||
let origin: { x: number; y: number } | null = null
|
||||
let target: DayBoundaryTarget | null = null
|
||||
let unlock: (() => void) | null = null
|
||||
let moved = false
|
||||
let saving = false
|
||||
let disposed = false
|
||||
const restore = () => map.setPosition(via.lat, via.lng)
|
||||
const detach = () => {
|
||||
document.removeEventListener('pointermove', move)
|
||||
document.removeEventListener('pointerup', up)
|
||||
document.removeEventListener('pointercancel', cancel)
|
||||
document.removeEventListener('keydown', escape)
|
||||
unlock?.()
|
||||
unlock = null
|
||||
origin = null
|
||||
handle.style.cursor = 'grab'
|
||||
}
|
||||
const cancel = () => { detach(); restore() }
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); cancel() } }
|
||||
const commit = async (boundary: RoadtripDayBoundary | null) => {
|
||||
saving = true
|
||||
handle.setAttribute('aria-busy', 'true')
|
||||
try { if (!await controls.move(pause.day, boundary) && !disposed) restore() }
|
||||
finally { saving = false; handle.removeAttribute('aria-busy') }
|
||||
}
|
||||
const move = (event: PointerEvent) => {
|
||||
if (!origin) return
|
||||
if (!moved && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) < 4) return
|
||||
moved = true
|
||||
event.preventDefault()
|
||||
target = nearestDayBoundary(controls.path, pause.day, { x: event.clientX, y: event.clientY }, map.project, pause.minPosition, pause.maxPosition)
|
||||
if (target) map.setPosition(target.lat, target.lng)
|
||||
}
|
||||
const up = () => {
|
||||
const next = moved ? target : null
|
||||
detach()
|
||||
if (next) void commit(next.boundary)
|
||||
else restore()
|
||||
}
|
||||
const down = (event: PointerEvent) => {
|
||||
if (event.button !== 0 || saving) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
origin = { x: event.clientX, y: event.clientY }
|
||||
target = null
|
||||
moved = false
|
||||
unlock = map.lock()
|
||||
handle.style.cursor = 'grabbing'
|
||||
document.addEventListener('pointermove', move, { passive: false })
|
||||
document.addEventListener('pointerup', up)
|
||||
document.addEventListener('pointercancel', cancel)
|
||||
document.addEventListener('keydown', escape)
|
||||
}
|
||||
const click = (event: Event) => {
|
||||
if (moved || saving) { event.preventDefault(); event.stopImmediatePropagation() }
|
||||
}
|
||||
const reset = (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!saving && pause.manual) void commit(null)
|
||||
}
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') return reset(event)
|
||||
if (!['ArrowLeft', 'ArrowRight'].includes(event.key) || saving) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const position = (pause.position ?? 0) + (event.key === 'ArrowLeft' ? -0.05 : 0.05)
|
||||
const leg = controls.path.find(leg => position >= leg.position && position <= leg.position + 1)
|
||||
if (!leg || position <= (pause.minPosition ?? -1) || position >= (pause.maxPosition ?? Infinity)) return
|
||||
void commit({ day_number: pause.day, from_assignment_id: leg.from.assignmentId, to_assignment_id: leg.to.assignmentId, fraction: position - leg.position })
|
||||
}
|
||||
handle.addEventListener('pointerdown', down)
|
||||
el.addEventListener('click', click, true)
|
||||
handle.addEventListener('contextmenu', reset)
|
||||
handle.addEventListener('keydown', key)
|
||||
return () => {
|
||||
disposed = true
|
||||
detach()
|
||||
handle.removeEventListener('pointerdown', down)
|
||||
el.removeEventListener('click', click, true)
|
||||
handle.removeEventListener('contextmenu', reset)
|
||||
handle.removeEventListener('keydown', key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteVia } from '../../types'
|
||||
|
||||
export interface MapHoverInfo {
|
||||
name?: string | null
|
||||
address?: string | null
|
||||
category_name?: string | null
|
||||
category_icon?: string | null
|
||||
category_color?: string | null
|
||||
routeVia?: RouteVia
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createElement } from 'react'
|
||||
import { Moon } from 'lucide-react'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type { RouteVia } from '../../types'
|
||||
|
||||
const moon = renderIconMarkup(createElement(Moon, { size: 13, strokeWidth: 1.8, 'aria-hidden': true }))
|
||||
export const NIGHT_PAUSE_MIN_ZOOM = 6
|
||||
|
||||
export function nightPauseMarker(via: RouteVia): string {
|
||||
const pause = via.nightPause!
|
||||
const position = pause.atPlace ? 'left:27px;top:-27px;' : 'left:0;top:-38px;transform:translateX(-50%);'
|
||||
const stem = pause.atPlace
|
||||
? 'left:20px;top:-7px;width:10px;height:1px;transform:rotate(-35deg);transform-origin:left center;'
|
||||
: 'left:-.5px;top:-14px;width:1px;height:14px;'
|
||||
return `<span aria-hidden="true" style="position:absolute;${stem}background:var(--text-muted);pointer-events:none;"></span><span data-night-pause="${pause.atPlace ? 'place' : 'route'}" style="position:absolute;${position}display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 8px;box-sizing:border-box;border:1px solid var(--border-primary);border-radius:9px;background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow-sm);font-family:var(--font-system);font-size:calc(11px * var(--fs-scale-caption,1));font-weight:600;line-height:1;white-space:nowrap;cursor:pointer;">${moon}<span>${escapeHtml(String(pause.day))}</span></span>`
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Poi } from './poiCategories'
|
||||
import { clusterPois, poiClusterList } from './poiClusters'
|
||||
|
||||
const station = (id: string, lng: number): Poi => ({
|
||||
osm_id: id, name: id, lat: 48, lng, category: 'charging_station',
|
||||
poi_type: 'charging_station', address: null, website: null, phone: null,
|
||||
opening_hours: null, cuisine: null, source: 'openstreetmap',
|
||||
})
|
||||
const project = (poi: Poi) => ({ x: poi.lng, y: poi.lat })
|
||||
|
||||
describe('station clusters', () => {
|
||||
it('groups across grid edges, counts unique stations and preserves the originals', () => {
|
||||
const a = station('a', 47), b = station('b', 49), c = station('c', 150)
|
||||
const groups = clusterPois([c, b, a, a], project)
|
||||
expect(groups.map(group => group.pois.length)).toEqual([2, 1])
|
||||
expect(groups[0]).toMatchObject({ lat: 48, lng: 48 })
|
||||
expect(groups[0].pois[0]).toBe(a)
|
||||
expect(a.lng).toBe(47)
|
||||
expect(clusterPois([a, b, c], project)).toEqual(groups)
|
||||
})
|
||||
|
||||
it('separates stations as their projected distance grows on zoom', () => {
|
||||
const pois = [station('a', 1), station('b', 2)]
|
||||
expect(clusterPois(pois, project)).toHaveLength(1)
|
||||
expect(clusterPois(pois, poi => ({ x: poi.lng * 100, y: 0 }))).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps the center near the date line', () => {
|
||||
const groups = clusterPois([station('a', 179), station('b', -179)], () => ({ x: 0, y: 0 }))
|
||||
expect(groups[0].lng).toBe(180)
|
||||
})
|
||||
|
||||
it('offers coincident stations individually without interpreting their names as HTML', () => {
|
||||
const poi = { ...station('a', 1), name: '<img src=x>' }
|
||||
const select = vi.fn()
|
||||
const list = poiClusterList([poi], select)
|
||||
expect(list.querySelector('img')).toBeNull()
|
||||
expect(list.textContent).toBe(poi.name)
|
||||
list.querySelector('button')!.click()
|
||||
expect(select).toHaveBeenCalledWith(poi)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
export const POI_CLUSTER_RADIUS = 48
|
||||
export const POI_CLUSTER_DETAIL_ZOOM = 18
|
||||
export interface PoiCluster { pois: Poi[]; lat: number; lng: number }
|
||||
|
||||
export function clusterPois(pois: Poi[], project: (poi: Poi) => { x: number; y: number }): PoiCluster[] {
|
||||
const groups: (PoiCluster & { x: number; y: number })[] = []
|
||||
const cells = new Map<string, number[]>()
|
||||
const unique = new Map(pois.map(poi => [poi.osm_id, poi]))
|
||||
for (const poi of [...unique.values()].sort((a, b) => a.osm_id.localeCompare(b.osm_id))) {
|
||||
const point = project(poi)
|
||||
const cx = Math.floor(point.x / POI_CLUSTER_RADIUS), cy = Math.floor(point.y / POI_CLUSTER_RADIUS)
|
||||
let closest: typeof groups[number] | undefined
|
||||
let distance = POI_CLUSTER_RADIUS
|
||||
for (let x = cx - 1; x <= cx + 1; x++) for (let y = cy - 1; y <= cy + 1; y++) {
|
||||
for (const index of cells.get(`${x},${y}`) ?? []) {
|
||||
const group = groups[index]
|
||||
const gap = Math.hypot(point.x - group.x, point.y - group.y)
|
||||
if (gap < distance) { closest = group; distance = gap }
|
||||
}
|
||||
}
|
||||
if (closest) closest.pois.push(poi)
|
||||
else {
|
||||
const key = `${cx},${cy}`
|
||||
const indices = cells.get(key) ?? []
|
||||
indices.push(groups.length)
|
||||
cells.set(key, indices)
|
||||
groups.push({ pois: [poi], lat: poi.lat, lng: poi.lng, ...point })
|
||||
}
|
||||
}
|
||||
return groups.map(group => ({
|
||||
pois: group.pois,
|
||||
lat: group.pois.reduce((sum, poi) => sum + poi.lat, 0) / group.pois.length,
|
||||
lng: group.lng + group.pois.reduce((sum, poi) => sum + ((poi.lng - group.lng + 540) % 360 - 180), 0) / group.pois.length,
|
||||
}))
|
||||
}
|
||||
|
||||
export function poiClusterMarkup(count: number): string {
|
||||
return `<span data-poi-cluster="${count}" style="box-sizing:border-box;display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:3px solid var(--bg-card);background:var(--accent);color:var(--accent-text);box-shadow:var(--shadow-md);font-family:var(--font-system);font-size:calc(13px * var(--fs-scale-caption,1));font-weight:600;cursor:pointer;">${count}</span>`
|
||||
}
|
||||
|
||||
export function poiClusterList(pois: Poi[], select: (poi: Poi) => void): HTMLElement {
|
||||
const list = document.createElement('div')
|
||||
list.className = 'flex max-h-64 flex-col gap-1 overflow-y-auto p-1'
|
||||
for (const poi of pois) {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.className = 'rounded-lg px-3 py-2 text-start text-caption font-medium text-content hover:bg-surface-hover focus-visible:bg-surface-hover'
|
||||
button.textContent = poi.name
|
||||
button.addEventListener('click', event => { event.stopPropagation(); select(poi) })
|
||||
list.append(button)
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge
|
||||
import { NavigationMenu } from '../shared/NavigationMenu'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine } from './placeHoursFormat'
|
||||
import type { EndDayControlProps } from '../Roadtrip/EndDayControl'
|
||||
import VisitControls, { type RoadtripStayControl } from '../Roadtrip/VisitControls'
|
||||
|
||||
const detailsCache = new Map()
|
||||
|
||||
@@ -133,6 +135,8 @@ interface TripMember {
|
||||
}
|
||||
|
||||
interface PlaceInspectorProps {
|
||||
roadtripEndDay?: EndDayControlProps
|
||||
roadtripStay?: RoadtripStayControl
|
||||
place: Place | null
|
||||
categories: Category[]
|
||||
/** 'trip' (default) keeps every existing trip-planner behaviour byte-identical;
|
||||
@@ -179,7 +183,7 @@ export default function PlaceInspector({
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate,
|
||||
leftWidth = 0, rightWidth = 0,
|
||||
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList,
|
||||
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay,
|
||||
}: PlaceInspectorProps) {
|
||||
// Plugins that declared a place-detail slot mount at the bottom of this panel,
|
||||
// scoped to the open place (trip mode only). Inline-filter like the other sites.
|
||||
@@ -417,6 +421,7 @@ export default function PlaceInspector({
|
||||
)}
|
||||
|
||||
{/* Description / Summary */}
|
||||
{(roadtripEndDay || roadtripStay) && <VisitControls endDay={roadtripEndDay} stay={roadtripStay} />}
|
||||
{(place.description || googleDetails?.summary) && (
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import AutomaticDayStop from './AutomaticDayStop'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
|
||||
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
describe('automatic pause row', () => {
|
||||
it('offers map focus and a custom hover without a native tooltip', async () => {
|
||||
const focus = vi.fn()
|
||||
render(<AutomaticDayStop
|
||||
stop={{ lat: 3, lng: 4, automaticNight: { phase: 'end', fromDayNumber: 1 } } as RoadtripStop}
|
||||
entry={{ arrival: '18:00', departure: '18:00', anchored: false, dayOffset: 0 }}
|
||||
onFocus={focus}
|
||||
/>)
|
||||
expect(screen.getByText('18:00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.window.pointHint')).not.toBeInTheDocument()
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0]).toHaveAccessibleName('roadtrip.window.stop 18:00')
|
||||
expect(buttons[0]).not.toHaveAttribute('title')
|
||||
fireEvent.mouseEnter(buttons[0])
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('roadtrip.window.pointHint')
|
||||
fireEvent.click(screen.getByText('18:00'))
|
||||
expect(focus).toHaveBeenCalledWith(3, 4)
|
||||
})
|
||||
|
||||
it('shows the next departure when a visit continues after the pause', () => {
|
||||
render(<AutomaticDayStop
|
||||
stop={{ automaticNight: { phase: 'start', fromDayNumber: 1 } } as RoadtripStop}
|
||||
entry={{ arrival: '08:00', departure: '09:00', anchored: false, dayOffset: 0 }}
|
||||
/>)
|
||||
expect(screen.getByText('roadtrip.window.resume')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('roadtrip.window.departure')).toHaveTextContent('09:00')
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ArrowRight, Moon, Sunrise } from 'lucide-react'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { ScheduleEntry } from './roadtripModel'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
export default function AutomaticDayStop({ stop, entry, onFocus }: {
|
||||
stop: RoadtripStop
|
||||
entry?: ScheduleEntry
|
||||
onFocus?: (lat: number, lng: number) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format === '12h')
|
||||
const ending = stop.automaticNight?.phase === 'end'
|
||||
const Icon = ending ? Moon : Sunrise
|
||||
const Row = onFocus ? 'button' : 'div'
|
||||
return (
|
||||
<Tooltip label={t('roadtrip.window.pointHint')} placement="top">
|
||||
<Row
|
||||
type={onFocus ? 'button' : undefined}
|
||||
onClick={onFocus ? () => onFocus(stop.lat, stop.lng) : undefined}
|
||||
className={`my-1 flex min-h-8 w-full items-center gap-2 rounded-md bg-surface-secondary px-2 py-1 text-start text-caption text-content-muted ${onFocus ? 'cursor-pointer transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent' : ''}`}
|
||||
>
|
||||
<Icon size={13} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 font-medium">{t(ending ? 'roadtrip.window.stop' : 'roadtrip.window.resume')}</span>{' '}
|
||||
<span className="shrink-0 whitespace-nowrap font-medium leading-6 tabular-nums" style={{ fontSize: FS.time }}>{entry?.arrival ? formatClockTime(entry.arrival, is12h) : ''}</span>
|
||||
{entry?.departure && entry.departure !== entry.arrival ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap font-medium leading-6 tabular-nums" style={{ fontSize: FS.time }} aria-label={t('roadtrip.window.departure', { time: formatClockTime(entry.departure, is12h) })}>
|
||||
<ArrowRight size={10} aria-hidden />
|
||||
<span>{formatClockTime(entry.departure, is12h)}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</Row>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import DayWindowFields from './DayWindowFields'
|
||||
|
||||
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
describe('daily travel time settings', () => {
|
||||
it('saves a complete custom time and preserves the other time', () => {
|
||||
const save = vi.fn()
|
||||
render(<DayWindowFields start="08:00" end="18:00" onSave={save} />)
|
||||
const start = screen.getByRole('textbox', { name: 'roadtrip.window.start' })
|
||||
fireEvent.change(start, { target: { value: '07:00' } })
|
||||
expect(save).toHaveBeenCalledWith('roadtrip_day_start', '07:00')
|
||||
expect(screen.getByLabelText('roadtrip.window.end')).toHaveValue('18:00')
|
||||
})
|
||||
|
||||
it('allows clearing a time to turn automatic planning off', () => {
|
||||
const save = vi.fn()
|
||||
render(<DayWindowFields start="08:00" end="18:00" onSave={save} />)
|
||||
const end = screen.getByLabelText('roadtrip.window.end')
|
||||
fireEvent.change(end, { target: { value: '' } })
|
||||
fireEvent.blur(end)
|
||||
expect(save).toHaveBeenCalledWith('roadtrip_day_end', '')
|
||||
})
|
||||
|
||||
it('announces a reversed window and does not offer disabled inputs as editable', () => {
|
||||
render(<DayWindowFields start="18:00" end="08:00" />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('roadtrip.window.invalid')
|
||||
expect(screen.getByLabelText('roadtrip.window.start')).toBeDisabled()
|
||||
expect(screen.getByLabelText('roadtrip.window.end')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Sunrise, Sunset } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { dayWindow } from './dayWindow'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
|
||||
export default function DayWindowFields({ start, end, endMode = 'route', onSave }: {
|
||||
start?: string
|
||||
end?: string
|
||||
endMode?: 'route' | 'stop'
|
||||
onSave?: (key: string, value: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [draft, setDraft] = useState<{ start?: string; end?: string }>({})
|
||||
const sent = useRef({ start, end })
|
||||
const shownStart = draft.start ?? start ?? ''
|
||||
const shownEnd = draft.end ?? end ?? ''
|
||||
const invalid = !!shownStart && !!shownEnd && !dayWindow(shownStart, shownEnd)
|
||||
const change = (field: 'start' | 'end', value: string) => {
|
||||
setDraft(prev => ({ ...prev, [field]: value }))
|
||||
if (value !== '' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return
|
||||
if (value !== (sent.current[field] ?? '')) {
|
||||
sent.current[field] = value
|
||||
onSave?.(`roadtrip_day_${field}`, value)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="sr-only">{t('roadtrip.window.title')}</legend>
|
||||
{(['start', 'end'] as const).map(field => {
|
||||
const Icon = field === 'start' ? Sunrise : Sunset
|
||||
return (
|
||||
<div key={field} className="flex min-h-9 items-center gap-3">
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{t(`roadtrip.window.${field}`)}</span>
|
||||
<CustomTimePicker
|
||||
value={field === 'start' ? shownStart : shownEnd}
|
||||
disabled={!onSave}
|
||||
aria-label={t(`roadtrip.window.${field}`)}
|
||||
aria-describedby="roadtrip-window-hint"
|
||||
aria-invalid={invalid || undefined}
|
||||
onChange={value => change(field, value)}
|
||||
style={{ width: '7rem' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p id="roadtrip-window-hint" role={invalid ? 'alert' : undefined} className={`text-caption ${invalid ? 'text-danger' : 'text-content-faint'}`}>
|
||||
{t(invalid ? 'roadtrip.window.invalid' : 'roadtrip.window.hint')}
|
||||
</p>
|
||||
<fieldset className="flex flex-col gap-2 border-t border-edge-faint pt-3">
|
||||
<legend className="sr-only">{t('roadtrip.window.endAt')}</legend>
|
||||
<span className="text-body text-content-secondary">{t('roadtrip.window.endAt')}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['route', 'stop'] as const).map(mode => (
|
||||
<label key={mode} className={`relative flex min-h-9 flex-1 cursor-pointer items-center rounded-lg border px-3 py-2 text-caption has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent ${endMode === mode ? 'border-accent bg-accent-subtle text-accent-on' : 'border-edge bg-surface-input text-content-secondary'}`}>
|
||||
<input type="radio" name="roadtrip-day-end-mode" value={mode} checked={endMode === mode} disabled={!onSave}
|
||||
onChange={() => onSave?.('roadtrip_day_end_mode', mode)}
|
||||
aria-describedby="roadtrip-end-mode-hint" className="sr-only" />
|
||||
{t(`roadtrip.window.${mode === 'route' ? 'onRoute' : 'atStop'}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p id="roadtrip-end-mode-hint" className="text-caption text-content-faint">{t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)}</p>
|
||||
<p className="text-caption text-content-faint">{t('roadtrip.window.endHereHint')}</p>
|
||||
{!!dayWindow(shownStart, shownEnd) && <p className="text-caption text-content-faint">{t('roadtrip.window.dragHint')}</p>}
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fireEvent, render, screen, act } from '@testing-library/react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import EndDayControl from './EndDayControl'
|
||||
|
||||
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
it('shows the saved state and blocks a second click while saving', async () => {
|
||||
let finish!: () => void
|
||||
const onToggle = vi.fn(() => new Promise<void>(resolve => { finish = resolve }))
|
||||
render(<EndDayControl active onToggle={onToggle} />)
|
||||
const toggle = screen.getByRole('button', { name: 'roadtrip.window.endHere' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toBeDisabled()
|
||||
fireEvent.click(toggle)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
await act(async () => finish())
|
||||
expect(toggle).toBeEnabled()
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { Moon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
export interface EndDayControlProps {
|
||||
active: boolean
|
||||
onToggle: () => Promise<void>
|
||||
}
|
||||
|
||||
export default function EndDayControl({ active, onToggle }: EndDayControlProps) {
|
||||
const { t } = useTranslation()
|
||||
const [pending, setPending] = useState(false)
|
||||
return (
|
||||
<fieldset disabled={pending} aria-busy={pending} className="flex min-w-0 items-center justify-between gap-2 rounded-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 disabled:opacity-60">
|
||||
<span className="flex flex-1 items-center gap-2 text-caption font-medium text-content"><Moon size={15} className={`shrink-0 ${active ? 'text-accent-on' : 'text-content-faint'}`} aria-hidden />{t('roadtrip.window.endHere')}</span>
|
||||
<ToggleSwitch on={active} label={t('roadtrip.window.endHere')} onToggle={async () => {
|
||||
if (pending) return
|
||||
setPending(true)
|
||||
try { await onToggle() } finally { setPending(false) }
|
||||
}} />
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,21 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('RoadtripLimitsCard', () => {
|
||||
it.each(['electric', 'petrol'])('saves manual range before fill changes for %s', kind => {
|
||||
useSettingsStore.setState({ settings: { roadtrip_vehicle: kind, roadtrip_range_km: 0, distance_unit: 'metric' } as never })
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
const range = screen.getByTestId('limit-range')
|
||||
fireEvent.change(range, { target: { value: '100' } })
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
fireEvent.blur(range)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_range_km', 100)
|
||||
const fill = screen.getByTestId('limit-fill')
|
||||
fireEvent.change(fill, { target: { value: '80' } })
|
||||
fireEvent.blur(fill)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_fill_percent', 80)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-001: typing a number writes nothing until the field is left', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship,
|
||||
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check,
|
||||
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
|
||||
} from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
@@ -14,6 +14,8 @@ import { valhallaAvailable } from '../Map/valhallaRoute'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import type { DistanceUnit, RouteAvoidClass } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
import DayWindowFields from './DayWindowFields'
|
||||
import { dayWindow } from './dayWindow'
|
||||
|
||||
/**
|
||||
* Everything that decides when the rail speaks up about the driving.
|
||||
@@ -248,7 +250,8 @@ const SPEC_ROWS: Record<VehicleKind, { key: SpecKey; setting: string; Icon: type
|
||||
*/
|
||||
const SPEC_SETTINGS = ['roadtrip_tank_litres', 'roadtrip_litres_per_100', 'roadtrip_battery_kwh', 'roadtrip_kwh_per_100', 'roadtrip_battery_degradation'] as const
|
||||
|
||||
export default function RoadtripLimitsCard({ onSave }: {
|
||||
export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries }: {
|
||||
onResetDayBoundaries?: () => Promise<void>
|
||||
/**
|
||||
* Persists one setting. Absent leaves the dialog read-only.
|
||||
*
|
||||
@@ -265,6 +268,7 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
|
||||
const legMinutes = settings.roadtrip_leg_minutes
|
||||
const dayMinutes = settings.roadtrip_day_minutes
|
||||
const automaticWindow = dayWindow(settings.roadtrip_day_start, settings.roadtrip_day_end)
|
||||
const rangeKm = settings.roadtrip_range_km
|
||||
|
||||
// Parsed rather than trusted: a per-user setting gets no server-side validation, and
|
||||
@@ -335,7 +339,8 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
|
||||
// third too early for ever after. The same rule runs through the vehicle's own figures,
|
||||
// where showSpec and storeSpec do it per field.
|
||||
const rangeShown = planningKm ? Math.round(convertDistance(planningKm, distanceUnit)) : undefined
|
||||
const rangeValue = computedKm ?? rangeKm
|
||||
const rangeShown = rangeValue ? Math.round(convertDistance(rangeValue, distanceUnit)) : undefined
|
||||
const setRange = (shown: number) => {
|
||||
const km = imperial ? shown / 0.621371 : shown
|
||||
onSave?.('roadtrip_range_km', Math.round(km))
|
||||
@@ -347,6 +352,7 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
// imperial traveller reads miles here and types miles in the dialog, while what is
|
||||
// stored stays kilometres either way.
|
||||
const badges = [
|
||||
automaticWindow ? { key: 'window', Icon: CalendarClock, text: `${settings.roadtrip_day_start} · ${settings.roadtrip_day_end}` } : null,
|
||||
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
|
||||
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
|
||||
// The planning range, not the typed one: when the vehicle's figures win, the badge
|
||||
@@ -426,7 +432,7 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
What answers the seam is not the count of columns but what is in them: each
|
||||
section is now a panel of its own with a visible edge, every row inside it
|
||||
ends on the same right edge, and the panels are stacked to one rhythm. The
|
||||
left column is the trip, the right one is the car. */}
|
||||
left column holds trip settings, the right holds the car and route appearance. */}
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Panel icon={Clock} title={t('roadtrip.limit.sectionDriving')} note={t('roadtrip.limit.hint')}>
|
||||
@@ -450,6 +456,11 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel icon={CalendarClock} title={t('roadtrip.window.title')}>
|
||||
<DayWindowFields start={settings.roadtrip_day_start} end={settings.roadtrip_day_end} endMode={settings.roadtrip_day_end_mode} onSave={onSave} />
|
||||
{onResetDayBoundaries && <button type="button" onClick={() => void onResetDayBoundaries()} className="text-start text-caption font-medium text-accent-on hover:underline">{t('roadtrip.window.resetBoundaries')}</button>}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
icon={Signpost}
|
||||
title={t('roadtrip.avoid.section')}
|
||||
@@ -466,19 +477,155 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
/>
|
||||
))}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel icon={Route} title={t('roadtrip.line.section')} note={t('roadtrip.line.hint')}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Panel
|
||||
icon={Car}
|
||||
title={t('roadtrip.limit.sectionVehicle')}
|
||||
note={vehicleKind ? t('roadtrip.limit.specHint') : t('roadtrip.limit.vehicleHint')}
|
||||
>
|
||||
{/* First, because it decides what everything under it means: with no
|
||||
vehicle named, a petrol station and a charger both fill the tank, which
|
||||
is arithmetic that is wrong for everybody who drives just one of them.
|
||||
|
||||
Radios in a segmented shell rather than a dropdown: three options that
|
||||
change the meaning of everything below them should be readable without
|
||||
being opened, and the arrow keys, the names and the roles then come
|
||||
from the browser instead of from a rebuilt listbox. */}
|
||||
<fieldset className="min-w-0">
|
||||
<legend className="sr-only">{t('roadtrip.limit.vehicleLabel')}</legend>
|
||||
<div className="grid grid-cols-3 gap-1 rounded-xl border border-edge bg-surface-tertiary p-1">
|
||||
{VEHICLES.map(({ key, labelKey, Icon }) => (
|
||||
<label key={key ?? 'unset'} className="min-w-0">
|
||||
<input
|
||||
type="radio"
|
||||
name="roadtrip-vehicle"
|
||||
className="peer sr-only"
|
||||
checked={(settings.roadtrip_vehicle || '') === (key ?? '')}
|
||||
onChange={() => {
|
||||
// Never writes the car's figures away: switching kind leaves
|
||||
// both sets stored, `rangeFromSpec` simply returns null for
|
||||
// the other one, and the typed range comes back as an editable
|
||||
// field. Deleting the other kind's values here would lose data
|
||||
// on a mis-click.
|
||||
setPreview(null)
|
||||
onSave?.('roadtrip_vehicle', key ?? '')
|
||||
}}
|
||||
/>
|
||||
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent">
|
||||
<Icon size={14} className="shrink-0" aria-hidden />
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<RangeStrip
|
||||
rangeKm={planningKm}
|
||||
fillPercent={shownFill}
|
||||
wearPercent={vehicleKind === 'electric' ? spec.degradationPercent ?? null : null}
|
||||
unit={distanceUnit}
|
||||
electric={vehicleKind === 'electric'}
|
||||
/>
|
||||
|
||||
{/* Read-only once the parts add up, because then it IS the parts. Leaving
|
||||
it editable would offer a second answer to a question that already has
|
||||
one, and the traveller would have no way of telling which the warnings
|
||||
were using. Clearing a field above hands the row straight back. */}
|
||||
<LimitRow
|
||||
icon={Route}
|
||||
// "on one tank" is the wrong noun for half the travellers who set this.
|
||||
label={t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel')}
|
||||
suffix={imperial ? 'mi' : 'km'}
|
||||
value={rangeShown}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
derived={computedKm ? t('roadtrip.limit.computed') : undefined}
|
||||
testId="limit-range"
|
||||
onDraft={v => setPreview({ key: 'range', value: v })}
|
||||
onChange={setRange}
|
||||
/>
|
||||
{/* Under the range, because it is a fraction OF it. Nobody charges to
|
||||
100 % on the road: the last fifth takes as long as the first four, so a
|
||||
stop counted as a full tank overstates everything after it by that
|
||||
fifth. */}
|
||||
<LimitRow
|
||||
icon={BatteryCharging}
|
||||
label={t('roadtrip.limit.fillLabel')}
|
||||
suffix="%"
|
||||
value={settings.roadtrip_fill_percent}
|
||||
placeholder={t('roadtrip.limit.fillFull')}
|
||||
testId="limit-fill"
|
||||
onDraft={v => setPreview({ key: 'fill', value: v > 100 ? 100 : v })}
|
||||
onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)}
|
||||
/>
|
||||
|
||||
{/* The car's own figures, folded away. Nothing here is required — somebody
|
||||
who knows their range types it above and never opens this — but it
|
||||
starts open the moment any of them is stored, because otherwise the
|
||||
range row is read-only with its cause out of sight. */}
|
||||
{vehicleKind ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSpecOpen(o => !o); setPreview(null) }}
|
||||
aria-expanded={specOpen}
|
||||
aria-controls="roadtrip-spec"
|
||||
className="-mx-1 flex items-center gap-1.5 self-start rounded-lg px-1 py-0.5 text-caption font-semibold text-accent-on transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className={`shrink-0 transition-transform ${specOpen ? 'rotate-90' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t('roadtrip.limit.specToggle')}
|
||||
</button>
|
||||
{/* `hidden` rather than unmounting: it really leaves the tab order,
|
||||
and a field that kept a draft while folded away would be a draft
|
||||
nobody can see. */}
|
||||
{/* The display class only while it is open. `[hidden]` sets
|
||||
`display: none` in the preflight, but a `flex` class beats it on
|
||||
specificity — so the attribute flipped, the semantics were right,
|
||||
and the panel stayed on screen. */}
|
||||
<div id="roadtrip-spec" hidden={!specOpen} className={specOpen ? 'flex flex-col gap-3' : ''}>
|
||||
{SPEC_ROWS[vehicleKind].map(({ key, setting, Icon, labelKey, step }) => (
|
||||
<LimitRow
|
||||
key={key}
|
||||
icon={Icon}
|
||||
label={t(labelKey)}
|
||||
suffix={specUnit(key, imperial)}
|
||||
step={step}
|
||||
value={showSpec(key, saved[key], imperial)}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
testId={`limit-${key}`}
|
||||
onDraft={v => setPreview({ key, value: v })}
|
||||
onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Panel icon={Route} title={t('roadtrip.line.section')} note={automaticWindow ? undefined : t('roadtrip.line.hint')}>
|
||||
{/* The road between one day's last stop and the next day's first is real
|
||||
driving that the rail has never drawn: days are routed one at a time,
|
||||
so that gap was never asked for. Off by default because it costs a
|
||||
routing request per join and changes every day's kilometres. */}
|
||||
<AvoidRow
|
||||
{automaticWindow ? (
|
||||
<div className="flex items-center gap-3" title={t('roadtrip.window.hint')}>
|
||||
<Link2 size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{t('roadtrip.line.connect')}</span>
|
||||
<Check size={16} className="text-accent-on" aria-label={t('roadtrip.window.hint')} />
|
||||
</div>
|
||||
) : <AvoidRow
|
||||
icon={Link2}
|
||||
label={t('roadtrip.line.connect')}
|
||||
on={!!settings.roadtrip_connect_days}
|
||||
disabled={false}
|
||||
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
|
||||
/>
|
||||
/>}
|
||||
{/* Which matters most once the line IS continuous: end to end it is one
|
||||
stroke, and a colour per day is what puts the days back into it. */}
|
||||
<AvoidRow
|
||||
@@ -490,134 +637,6 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
icon={Car}
|
||||
title={t('roadtrip.limit.sectionVehicle')}
|
||||
note={vehicleKind ? t('roadtrip.limit.specHint') : t('roadtrip.limit.vehicleHint')}
|
||||
>
|
||||
{/* First, because it decides what everything under it means: with no
|
||||
vehicle named, a petrol station and a charger both fill the tank, which
|
||||
is arithmetic that is wrong for everybody who drives just one of them.
|
||||
|
||||
Radios in a segmented shell rather than a dropdown: three options that
|
||||
change the meaning of everything below them should be readable without
|
||||
being opened, and the arrow keys, the names and the roles then come
|
||||
from the browser instead of from a rebuilt listbox. */}
|
||||
<fieldset className="min-w-0">
|
||||
<legend className="sr-only">{t('roadtrip.limit.vehicleLabel')}</legend>
|
||||
<div className="grid grid-cols-3 gap-1 rounded-xl border border-edge bg-surface-tertiary p-1">
|
||||
{VEHICLES.map(({ key, labelKey, Icon }) => (
|
||||
<label key={key ?? 'unset'} className="min-w-0">
|
||||
<input
|
||||
type="radio"
|
||||
name="roadtrip-vehicle"
|
||||
className="peer sr-only"
|
||||
checked={(settings.roadtrip_vehicle || '') === (key ?? '')}
|
||||
onChange={() => {
|
||||
// Never writes the car's figures away: switching kind leaves
|
||||
// both sets stored, `rangeFromSpec` simply returns null for
|
||||
// the other one, and the typed range comes back as an editable
|
||||
// field. Deleting the other kind's values here would lose data
|
||||
// on a mis-click.
|
||||
setPreview(null)
|
||||
onSave?.('roadtrip_vehicle', key ?? '')
|
||||
}}
|
||||
/>
|
||||
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent">
|
||||
<Icon size={14} className="shrink-0" aria-hidden />
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<RangeStrip
|
||||
rangeKm={planningKm}
|
||||
fillPercent={shownFill}
|
||||
wearPercent={vehicleKind === 'electric' ? spec.degradationPercent ?? null : null}
|
||||
unit={distanceUnit}
|
||||
electric={vehicleKind === 'electric'}
|
||||
/>
|
||||
|
||||
{/* Read-only once the parts add up, because then it IS the parts. Leaving
|
||||
it editable would offer a second answer to a question that already has
|
||||
one, and the traveller would have no way of telling which the warnings
|
||||
were using. Clearing a field above hands the row straight back. */}
|
||||
<LimitRow
|
||||
icon={Route}
|
||||
// "on one tank" is the wrong noun for half the travellers who set this.
|
||||
label={t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel')}
|
||||
suffix={imperial ? 'mi' : 'km'}
|
||||
value={rangeShown}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
derived={computedKm ? t('roadtrip.limit.computed') : undefined}
|
||||
testId="limit-range"
|
||||
onDraft={v => setPreview({ key: 'range', value: v })}
|
||||
onChange={setRange}
|
||||
/>
|
||||
{/* Under the range, because it is a fraction OF it. Nobody charges to
|
||||
100 % on the road: the last fifth takes as long as the first four, so a
|
||||
stop counted as a full tank overstates everything after it by that
|
||||
fifth. */}
|
||||
<LimitRow
|
||||
icon={BatteryCharging}
|
||||
label={t('roadtrip.limit.fillLabel')}
|
||||
suffix="%"
|
||||
value={settings.roadtrip_fill_percent}
|
||||
placeholder={t('roadtrip.limit.fillFull')}
|
||||
testId="limit-fill"
|
||||
onDraft={v => setPreview({ key: 'fill', value: v > 100 ? 100 : v })}
|
||||
onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)}
|
||||
/>
|
||||
|
||||
{/* The car's own figures, folded away. Nothing here is required — somebody
|
||||
who knows their range types it above and never opens this — but it
|
||||
starts open the moment any of them is stored, because otherwise the
|
||||
range row is read-only with its cause out of sight. */}
|
||||
{vehicleKind ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSpecOpen(o => !o); setPreview(null) }}
|
||||
aria-expanded={specOpen}
|
||||
aria-controls="roadtrip-spec"
|
||||
className="-mx-1 flex items-center gap-1.5 self-start rounded-lg px-1 py-0.5 text-caption font-semibold text-accent-on transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className={`shrink-0 transition-transform ${specOpen ? 'rotate-90' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t('roadtrip.limit.specToggle')}
|
||||
</button>
|
||||
{/* `hidden` rather than unmounting: it really leaves the tab order,
|
||||
and a field that kept a draft while folded away would be a draft
|
||||
nobody can see. */}
|
||||
{/* The display class only while it is open. `[hidden]` sets
|
||||
`display: none` in the preflight, but a `flex` class beats it on
|
||||
specificity — so the attribute flipped, the semantics were right,
|
||||
and the panel stayed on screen. */}
|
||||
<div id="roadtrip-spec" hidden={!specOpen} className={specOpen ? 'flex flex-col gap-3' : ''}>
|
||||
{SPEC_ROWS[vehicleKind].map(({ key, setting, Icon, labelKey, step }) => (
|
||||
<LimitRow
|
||||
key={key}
|
||||
icon={Icon}
|
||||
label={t(labelKey)}
|
||||
suffix={specUnit(key, imperial)}
|
||||
step={step}
|
||||
value={showSpec(key, saved[key], imperial)}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
testId={`limit-${key}`}
|
||||
onDraft={v => setPreview({ key, value: v })}
|
||||
onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -27,8 +27,10 @@ import type { RouteVia } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
import type { RouteSegment } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import AutomaticDayStop from './AutomaticDayStop'
|
||||
|
||||
interface RoadtripSidebarProps {
|
||||
onFocusPoint?: (lat: number, lng: number) => void
|
||||
/** Legs and totals for the whole trip, computed once in the planner hook. */
|
||||
routes: RoadtripRoutes
|
||||
selectedAssignmentId?: number | null
|
||||
@@ -1203,13 +1205,7 @@ function SpillBlock({ spill, children }: {
|
||||
boxShadow: `inset 0 1px 0 color-mix(in srgb, var(--info) 20%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{/* Moonlight from the left, falling on the mascot. A radial wash rather than a
|
||||
second background colour: it has no edge of its own, so the head reads as lit
|
||||
rather than as another box stacked on the first. */}
|
||||
<div
|
||||
className="px-2 pb-1 pt-2"
|
||||
style={{ backgroundImage: `radial-gradient(140px 64px at 22px 26px, color-mix(in srgb, var(--info) 12%, transparent), transparent 72%)` }}
|
||||
>
|
||||
<div className="px-2 pb-1 pt-2">
|
||||
<div className="flex items-center gap-2 text-info">
|
||||
<MDancingTrek scene="idle" mood="sleepy" size={26} />
|
||||
<span
|
||||
@@ -1231,7 +1227,7 @@ function SpillBlock({ spill, children }: {
|
||||
reason the block exists, and the kilometres on it are the ones the card's
|
||||
header now counts. Not clickable: alternatives are asked for on the day the
|
||||
leg is stored on, and offering the same leg twice would be two answers. */}
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<div className={spill.automatic ? 'hidden' : 'grid'} style={RAIL_GRID}>
|
||||
<span className="relative z-[1] flex flex-col items-center" aria-hidden>
|
||||
<span className="flex-1" style={RAIL_DASH} />
|
||||
</span>
|
||||
@@ -1294,7 +1290,8 @@ function SpillBlock({ spill, children }: {
|
||||
* move, a stay edit or a refuel offer still names the day the server knows it by. See
|
||||
* `nightSpill.ts`.
|
||||
*/
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle }: {
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
|
||||
onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
|
||||
day: RoadtripDay
|
||||
/** Folded down to the header, and off the map with it. */
|
||||
collapsed?: boolean
|
||||
@@ -1355,6 +1352,20 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
* still stored on the day it set off from (`nightSpill.ts`).
|
||||
*/
|
||||
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => {
|
||||
if (stop.automaticNight) return (
|
||||
<li key={stop.assignmentId}>
|
||||
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
|
||||
{i < last && day.legs[i]?.distance !== 0 ? <DriveBand leg={day.legs[i]} /> : null}
|
||||
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === i).map(dry => (
|
||||
<RefuelBand key={`dry-${dry.legIndex}`} dry={dry} dayId={day.dayId} refuel={refuel}
|
||||
onAsk={() => onAskRefuel?.(day.dayId, dry)}
|
||||
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
|
||||
)) : null}
|
||||
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
|
||||
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
|
||||
)) : null}
|
||||
</li>
|
||||
)
|
||||
const service = isServiceStopType(stop.stopType)
|
||||
if (!service) counted += 1
|
||||
// Every finding at this index is read on its own. Taking the first match let an
|
||||
@@ -1424,10 +1435,10 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
|
||||
/>
|
||||
)}
|
||||
{i < last ? (
|
||||
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
|
||||
<DriveBand
|
||||
leg={day.legs[i]}
|
||||
onAskAlternatives={onAskAlternatives ? () => onAskAlternatives(day.dayId, i) : undefined}
|
||||
onAskAlternatives={onAskAlternatives && !day.stops[i + 1].automaticNight ? () => onAskAlternatives(day.dayId, i) : undefined}
|
||||
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1556,7 +1567,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })}
|
||||
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })}
|
||||
</span>
|
||||
{/* The other half of "where possible". The setting is a weighting, so a day
|
||||
with no untolled crossing comes back on the toll road — and the only thing
|
||||
@@ -1714,7 +1725,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
|
||||
export default function RoadtripSidebar({
|
||||
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
|
||||
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
|
||||
collapsedDayIds, onToggleDay,
|
||||
collapsedDayIds, onToggleDay, onFocusPoint,
|
||||
}: RoadtripSidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
// One drag state for the whole rail rather than one per day: a stop that cannot leave
|
||||
@@ -1751,12 +1762,18 @@ export default function RoadtripSidebar({
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 pt-1">
|
||||
<div className="shrink-0">
|
||||
<TripSummary routes={routes} />
|
||||
{routes.dayWindowIssue ? (
|
||||
<p role="status" className="mx-3.5 mt-2 rounded-xl bg-warning-soft p-3 text-caption text-content">
|
||||
{t(`roadtrip.window.${routes.dayWindowIssue}`)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="roadtrip-rail-scroll flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto pb-3.5">
|
||||
{routes.days.map(day => (
|
||||
<DaySection
|
||||
key={day.dayId}
|
||||
day={day}
|
||||
onFocusPoint={onFocusPoint}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
onSelectStop={onSelectStop}
|
||||
onReorderStop={onReorderStop}
|
||||
@@ -1767,7 +1784,7 @@ export default function RoadtripSidebar({
|
||||
onEditStay={onEditStay}
|
||||
onSetStopKind={onSetStopKind}
|
||||
onSetStopFill={onSetStopFill}
|
||||
onFollowTrack={onFollowTrack}
|
||||
onFollowTrack={day.dayId < 0 ? undefined : onFollowTrack}
|
||||
viaCount={viaCounts?.[day.dayId] ?? 0}
|
||||
trackName={trackNames?.[day.dayId]}
|
||||
refuel={refuel}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import VisitControls from './VisitControls'
|
||||
|
||||
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
it('edits the shared stay without changing the day ending', () => {
|
||||
const edit = vi.fn(), toggle = vi.fn(async () => {})
|
||||
render(<VisitControls endDay={{ active: false, onToggle: toggle }} stay={{ minutes: 90, onEdit: edit }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 1 h 30 min' }))
|
||||
expect(edit).toHaveBeenCalledOnce()
|
||||
expect(toggle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps stay editing when daily travel times are off', () => {
|
||||
render(<VisitControls stay={{ minutes: null, onEdit: vi.fn() }} />)
|
||||
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 0 min' })).toBeEnabled()
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.window.endHere' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Clock, Pencil } from 'lucide-react'
|
||||
import EndDayControl, { type EndDayControlProps } from './EndDayControl'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
|
||||
export interface RoadtripStayControl {
|
||||
minutes: number | null
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
export default function VisitControls({ endDay, stay }: { endDay?: EndDayControlProps; stay?: RoadtripStayControl }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={`grid gap-2 ${endDay && stay ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{endDay && <EndDayControl {...endDay} />}
|
||||
{stay && <button type="button" onClick={stay.onEdit} disabled={!stay.onEdit}
|
||||
className="flex min-w-0 items-center justify-between gap-2 rounded-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 text-start enabled:hover:bg-surface-hover disabled:cursor-default">
|
||||
<span className="flex items-center gap-2 text-caption font-medium uppercase text-content"><Clock size={15} className="shrink-0 text-content-faint" aria-hidden />{t('roadtrip.stop.stayShort')}</span>
|
||||
{' '}<span className="flex shrink-0 items-center gap-2 text-caption font-medium text-content">
|
||||
{formatDurationShort((stay.minutes ?? 0) * 60)}{stay.onEdit && <Pencil size={12} className="text-content-faint" aria-hidden />}
|
||||
</span>
|
||||
</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { nearestDayBoundary, type DayBoundaryLeg } from './dayBoundaryPath'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
|
||||
const point = (assignmentId: number, lng: number) => ({ assignmentId, lat: 0, lng }) as RoadtripStop
|
||||
const path: DayBoundaryLeg[] = [{ from: point(10, 0), to: point(20, 1), position: 0, line: [[0, 0], [0, 0.5], [0, 1]] }]
|
||||
const project = (lat: number, lng: number) => ({ x: lng * 1000, y: lat * 1000 })
|
||||
|
||||
it('snaps to a visit before considering nearby road segments', () => {
|
||||
expect(nearestDayBoundary(path, 1, { x: 985, y: 2 }, project)?.boundary).toEqual({ day_number: 1, from_assignment_id: 20, to_assignment_id: null, fraction: 1 })
|
||||
})
|
||||
it('projects the pointer onto the full route with a distance fraction', () => {
|
||||
const target = nearestDayBoundary(path, 2, { x: 300, y: 100 }, project)!
|
||||
expect(target.boundary.fraction).toBeCloseTo(0.3)
|
||||
expect(target.lng).toBeCloseTo(0.3)
|
||||
expect(target.lat).toBe(0)
|
||||
})
|
||||
it('excludes the previous and next fixed day endings', () => {
|
||||
expect(nearestDayBoundary(path, 2, { x: 0, y: 0 }, project, 1, 2)).toBeNull()
|
||||
expect(nearestDayBoundary(path, 2, { x: 1000, y: 0 }, project, 0, 0.2)).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { RoadtripDayBoundary } from '@trek/shared'
|
||||
import type { RoadtripStop } from './useRoadtripRoutes'
|
||||
import { lineMetres } from './corridor'
|
||||
|
||||
export interface DayBoundaryLeg {
|
||||
from: RoadtripStop
|
||||
to: RoadtripStop
|
||||
position: number
|
||||
line: [number, number][]
|
||||
}
|
||||
export interface DayBoundaryTarget {
|
||||
boundary: RoadtripDayBoundary
|
||||
lat: number
|
||||
lng: number
|
||||
position: number
|
||||
}
|
||||
export type ProjectPoint = (lat: number, lng: number) => { x: number; y: number }
|
||||
|
||||
export function nearestDayBoundary(
|
||||
legs: DayBoundaryLeg[], day: number, point: { x: number; y: number }, project: ProjectPoint,
|
||||
min = -1, max = Infinity,
|
||||
): DayBoundaryTarget | null {
|
||||
let best: DayBoundaryTarget | null = null
|
||||
let distance = Infinity
|
||||
const offer = (target: DayBoundaryTarget, gap: number) => {
|
||||
if (target.position <= min + 0.000001 || target.position >= max - 0.000001 || gap >= distance) return
|
||||
best = target
|
||||
distance = gap
|
||||
}
|
||||
for (const leg of legs) {
|
||||
for (const [stop, position] of [[leg.from, leg.position], [leg.to, leg.position + 1]] as const) {
|
||||
const p = project(stop.lat, stop.lng)
|
||||
const gap = Math.hypot(p.x - point.x, p.y - point.y)
|
||||
if (gap <= 25) offer({ boundary: { day_number: day, from_assignment_id: stop.assignmentId, to_assignment_id: null, fraction: 1 }, lat: stop.lat, lng: stop.lng, position }, gap)
|
||||
}
|
||||
}
|
||||
if (best) return best
|
||||
for (const leg of legs) {
|
||||
const metres = lineMetres(leg.line.map(([lat, lng]) => ({ lat, lng })))
|
||||
if (!metres) continue
|
||||
let travelled = 0
|
||||
for (let i = 1; i < leg.line.length; i++) {
|
||||
const a = leg.line[i - 1], b = leg.line[i]
|
||||
const p = project(a[0], a[1]), q = project(b[0], b[1])
|
||||
const dx = q.x - p.x, dy = q.y - p.y
|
||||
const share = dx || dy ? Math.max(0, Math.min(1, ((point.x - p.x) * dx + (point.y - p.y) * dy) / (dx * dx + dy * dy))) : 0
|
||||
const length = lineMetres([{ lat: a[0], lng: a[1] }, { lat: b[0], lng: b[1] }])
|
||||
const fraction = (travelled + length * share) / metres
|
||||
offer({
|
||||
boundary: { day_number: day, from_assignment_id: leg.from.assignmentId, to_assignment_id: leg.to.assignmentId, fraction },
|
||||
lat: a[0] + (b[0] - a[0]) * share, lng: a[1] + (b[1] - a[1]) * share, position: leg.position + fraction,
|
||||
}, Math.hypot(p.x + dx * share - point.x, p.y + dy * share - point.y))
|
||||
travelled += length
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dayWindow, planDayWindow, roadtripInsertion } from './dayWindow'
|
||||
import type { RoadtripStop, RoutedLeg } from './useRoadtripRoutes'
|
||||
|
||||
const hours = { start: 480, end: 1080 }
|
||||
const labels = { start: 'Continue', end: 'End of day' }
|
||||
const stop = (id: number, patch: Partial<RoadtripStop> = {}): RoadtripStop => ({
|
||||
assignmentId: id, placeId: id, ownerDayId: 1, ownerIndex: id - 1,
|
||||
lat: 0, lng: id - 1, name: `Stop ${id}`, time: null, dwellMinutes: 0,
|
||||
legMode: null, incomingLegMode: null, stopType: null, ...patch,
|
||||
})
|
||||
const day = (number: number, stops: RoadtripStop[] = []) => ({
|
||||
dayId: number, dayNumber: number, date: `2026-09-${String(number + 10).padStart(2, '0')}`, title: null, stops,
|
||||
})
|
||||
const leg = (minutes: number, from: RoadtripStop, to: RoadtripStop): RoutedLeg => ({
|
||||
line: [[from.lat, from.lng], [to.lat, to.lng]], vias: [],
|
||||
seg: {
|
||||
from: [from.lat, from.lng], to: [to.lat, to.lng], mid: [0, (from.lng + to.lng) / 2],
|
||||
distance: minutes * 1000, duration: minutes * 60, mode: 'driving',
|
||||
distanceText: '', drivingText: '', walkingText: '',
|
||||
},
|
||||
})
|
||||
const calculate = (stops: RoadtripStop[], minutes: number[] = [720], extra = [day(2)]) =>
|
||||
planDayWindow([day(1, stops), ...extra], hours, (a, b) => leg(minutes[stops.indexOf(a)] ?? 60, a, b), 'metric', labels)
|
||||
|
||||
describe('daily travel window', () => {
|
||||
it('pulls an unpinned visit back into day one and ends after its full stay', () => {
|
||||
const stops = [stop(1, { time: '07:00' }), stop(2, { dwellMinutes: 90 }), stop(3)]
|
||||
const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(720, a, b)
|
||||
const automatic = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels)
|
||||
expect(automatic.chains[0].stops).not.toContain(stops[1])
|
||||
const manual = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels,
|
||||
[{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }])
|
||||
expect(manual.issue).toBeNull()
|
||||
expect(manual.chains[0].stops).toContain(stops[1])
|
||||
expect(manual.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '19:00', '20:30'])
|
||||
expect(manual.chains[1].schedule.entries[0].arrival).toBe('08:00')
|
||||
expect(stops[0].time).toBe('07:00')
|
||||
})
|
||||
|
||||
it.each([0.25, 0.9])('moves the cut in either direction and preserves the whole drive (%s)', fraction => {
|
||||
const stops = [stop(1), stop(2)]
|
||||
const manual = planDayWindow([day(1, stops)], hours, (a, b) => leg(720, a, b), 'metric', labels,
|
||||
[{ day_number: 1, from_assignment_id: 1, to_assignment_id: 2, fraction }])
|
||||
expect(manual.issue).toBeNull()
|
||||
const end = manual.chains[0].stops[1]
|
||||
expect(end.lng).toBeCloseTo(fraction)
|
||||
expect(end.automaticNight).toMatchObject({ manual: true, position: fraction })
|
||||
const start = manual.chains[1].stops[0]
|
||||
expect(manual.legFor(stops[0], end)!.seg.duration + manual.legFor(start, stops[1])!.seg.duration).toBeCloseTo(720 * 60)
|
||||
})
|
||||
|
||||
it('does not pull a visit with a fixed time out of its stored day', () => {
|
||||
const pinned = stop(2, { ownerDayId: 2, time: '12:00' })
|
||||
const manual = planDayWindow([day(1, [stop(1)]), day(2, [pinned])], hours, (a, b) => leg(60, a, b), 'metric', labels,
|
||||
[{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }])
|
||||
expect(manual.issue).toBe('conflict')
|
||||
expect(pinned.time).toBe('12:00')
|
||||
})
|
||||
|
||||
it('rejects crossed or disconnected manual boundaries', () => {
|
||||
const stops = [stop(1), stop(2), stop(3)]
|
||||
for (const boundaries of [
|
||||
[{ day_number: 1, from_assignment_id: 1, to_assignment_id: 3, fraction: 0.5 }],
|
||||
[{ day_number: 1, from_assignment_id: 3, to_assignment_id: null, fraction: 1 }, { day_number: 2, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }],
|
||||
]) expect(planDayWindow([day(1, stops)], hours, (a, b) => leg(60, a, b), 'metric', labels, boundaries).issue).toBe('conflict')
|
||||
})
|
||||
|
||||
it('mixes an explicit stop end with automatic cuts on later drives', () => {
|
||||
const visited = stop(2, { endDay: true, dwellMinutes: 60 })
|
||||
const planned = calculate([stop(1), visited, stop(3)], [120, 720], [])
|
||||
expect(planned.issue).toBeNull()
|
||||
expect(planned.chains.map(d => d.schedule.entries.map(e => e.arrival))).toEqual([
|
||||
['08:00', '10:00', '11:00'], ['08:00', '18:00'], ['08:00', '10:00'],
|
||||
])
|
||||
expect(planned.chains[0].stops[2]).toMatchObject({ lat: visited.lat, lng: visited.lng })
|
||||
expect(visited.endDay).toBe(true)
|
||||
})
|
||||
|
||||
it('removing an explicit end restores the automatic schedule without adding a final night', () => {
|
||||
const planned = calculate([stop(1), stop(2, { endDay: false }), stop(3, { endDay: true })], [120, 60], [])
|
||||
expect(planned.chains).toHaveLength(1)
|
||||
expect(planned.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00', '11:00'])
|
||||
})
|
||||
|
||||
it('keeps manual times when an explicit end would move their day', () => {
|
||||
const pinned = stop(2, { time: '12:00' })
|
||||
const planned = calculate([stop(1, { endDay: true }), pinned], [60], [])
|
||||
expect(planned.issue).toBe('conflict')
|
||||
expect(pinned.time).toBe('12:00')
|
||||
})
|
||||
|
||||
it('honors an earlier manual arrival on the following stored day', () => {
|
||||
const first = stop(1, { endDay: true })
|
||||
const next = stop(2, { time: '07:00', ownerDayId: 2 })
|
||||
const planned = planDayWindow([day(1, [first]), day(2, [next])], hours, (a, b) => leg(60, a, b), 'metric', labels)
|
||||
expect(planned.issue).toBeNull()
|
||||
expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['06:00', '07:00'])
|
||||
expect(next.time).toBe('07:00')
|
||||
})
|
||||
|
||||
it('requires two valid increasing HH:mm values', () => {
|
||||
expect(dayWindow('08:00', '18:00')).toEqual(hours)
|
||||
expect(dayWindow('00:00', '00:01')).toEqual({ start: 0, end: 1 })
|
||||
for (const pair of [[null, '18:00'], ['08:00', ''], ['8:00', '18:00'], ['24:00', '25:00'], ['18:00', '08:00'], ['08:00', '08:00'], [800, 1800]]) {
|
||||
expect(dayWindow(...pair as [unknown, unknown])).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('splits the drive at day end and resumes at the same point next morning', () => {
|
||||
const first = stop(1)
|
||||
const last = stop(2, { lng: 12 })
|
||||
const planned = calculate([first, last])
|
||||
expect(planned.issue).toBeNull()
|
||||
const [today, tomorrow] = planned.chains
|
||||
const end = today.stops[1]
|
||||
const start = tomorrow.stops[0]
|
||||
expect(today.schedule.entries[0].arrival).toBe('08:00')
|
||||
expect(today.schedule.entries[1].arrival).toBe('18:00')
|
||||
expect(end.lng).toBeCloseTo(10)
|
||||
expect([start.lat, start.lng]).toEqual([end.lat, end.lng])
|
||||
expect(tomorrow.schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00'])
|
||||
expect(tomorrow.stops[1]).toBe(last)
|
||||
expect(tomorrow.spills[0]).toMatchObject({ automatic: true, fromDayNumber: 1, at: 1 })
|
||||
const before = planned.legFor(first, end)!
|
||||
const after = planned.legFor(start, last)!
|
||||
expect(before.seg.duration + after.seg.duration).toBe(720 * 60)
|
||||
expect(before.seg.distance + after.seg.distance).toBe(720000)
|
||||
expect(before.line[before.line.length - 1]).toEqual(after.line[0])
|
||||
expect(first.time).toBeNull()
|
||||
expect(last.ownerDayId).toBe(1)
|
||||
})
|
||||
|
||||
it('ends at the last visited place after its stay when the next drive will not fit', () => {
|
||||
const first = stop(1)
|
||||
const visited = stop(2, { dwellMinutes: 60 })
|
||||
const next = stop(3)
|
||||
const route = [first, visited, next]
|
||||
const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(a === first ? 480 : 120, a, b)
|
||||
const planned = planDayWindow([day(1, route)], { ...hours, endMode: 'stop' }, lookup, 'metric', labels)
|
||||
expect(planned.issue).toBeNull()
|
||||
expect(planned.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '16:00', '17:00'])
|
||||
expect(planned.chains[0].stops[2]).toMatchObject({ lat: visited.lat, lng: visited.lng })
|
||||
expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00'])
|
||||
expect(planned.legFor(planned.chains[1].stops[0], next)?.seg.duration).toBe(7200)
|
||||
expect(visited.dwellMinutes).toBe(60)
|
||||
expect(next.ownerDayId).toBe(1)
|
||||
const alongRoute = planDayWindow([day(1, route)], hours, lookup, 'metric', labels)
|
||||
expect(alongRoute.chains[0].schedule.entries[2].arrival).toBe('18:00')
|
||||
expect(alongRoute.chains[1].schedule.entries[1].arrival).toBe('09:00')
|
||||
})
|
||||
|
||||
it('reaches a last place exactly at the cutoff and still respects manual times', () => {
|
||||
const first = stop(1, { time: '07:00' })
|
||||
const next = stop(2)
|
||||
const calculateAtStop = (minutes: number) => planDayWindow([day(1, [first, next])], { ...hours, endMode: 'stop' },
|
||||
(a, b) => leg(minutes, a, b), 'metric', labels)
|
||||
expect(calculateAtStop(660).chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '18:00'])
|
||||
next.time = '19:00'
|
||||
expect(calculateAtStop(720).chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '19:00'])
|
||||
expect(first.time).toBe('07:00')
|
||||
})
|
||||
|
||||
it('reports an indivisible drive that cannot fit rather than adding endless empty days', () => {
|
||||
const planned = planDayWindow([day(1, [stop(1), stop(2)])], { ...hours, endMode: 'stop' },
|
||||
(a, b) => leg(720, a, b), 'metric', labels)
|
||||
expect(planned.issue).toBe('legTooLong')
|
||||
expect(planned.chains).toEqual([])
|
||||
expect(dayWindow('08:00', '18:00', 'stop')).toEqual({ ...hours, endMode: 'stop' })
|
||||
expect(dayWindow('08:00', '18:00', 'unknown')).toEqual(hours)
|
||||
})
|
||||
|
||||
it('starts at a manually pinned 07:00 despite an automatic 08:00 start', () => {
|
||||
const first = stop(1, { time: '07:00' })
|
||||
const planned = calculate([first, stop(2, { lng: 12 })])
|
||||
expect(planned.chains[0].schedule.entries[0]).toMatchObject({ arrival: '07:00', anchored: true })
|
||||
expect(planned.chains[0].stops[1].lng).toBeCloseTo(11)
|
||||
expect(planned.chains[1].schedule.entries[1].arrival).toBe('09:00')
|
||||
expect(first.time).toBe('07:00')
|
||||
})
|
||||
|
||||
it('accounts for stays and recalculates the pause position without mutating places', () => {
|
||||
const first = stop(1, { dwellMinutes: 120 })
|
||||
const destination = stop(2, { lng: 12 })
|
||||
const planned = calculate([first, destination])
|
||||
expect(planned.chains[0].stops[1].lng).toBeCloseTo(8)
|
||||
expect(planned.chains[1].schedule.entries[1].arrival).toBe('12:00')
|
||||
const shortened = calculate([{ ...first, dwellMinutes: 0 }, destination])
|
||||
expect(shortened.chains[0].stops[1].lng).toBeCloseTo(10)
|
||||
const shortDrive = calculate([first, destination], [120])
|
||||
expect(shortDrive.chains.flatMap(d => d.stops).some(s => s.automaticNight)).toBe(false)
|
||||
expect(first.dwellMinutes).toBe(120)
|
||||
})
|
||||
|
||||
it('does not move an arrival exactly at day end to tomorrow', () => {
|
||||
const last = stop(2)
|
||||
const planned = calculate([stop(1), last], [600])
|
||||
expect(planned.chains[0].stops).toHaveLength(2)
|
||||
expect(planned.chains[0].schedule.entries[1].arrival).toBe('18:00')
|
||||
expect(planned.chains[1].stops).toEqual([])
|
||||
})
|
||||
|
||||
it('continues across multiple nights and creates preview dates beyond the trip', () => {
|
||||
const planned = calculate([stop(1), stop(2, { lng: 30 })], [1800], [])
|
||||
expect(planned.chains.map(d => d.date)).toEqual(['2026-09-11', '2026-09-12', '2026-09-13'])
|
||||
expect(planned.chains[2].schedule.entries[1].arrival).toBe('18:00')
|
||||
expect(planned.chains[2].dayId).toBeLessThan(0)
|
||||
expect(planned.chains.flatMap(d => d.stops).filter(s => !s.automaticNight)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps fixed appointments and reports an impossible schedule without partial relocation', () => {
|
||||
const fixed = stop(2, { time: '10:00' })
|
||||
const impossible = calculate([stop(1), fixed], [180])
|
||||
expect(impossible.issue).toBe('conflict')
|
||||
expect(impossible.chains).toEqual([])
|
||||
expect(fixed.time).toBe('10:00')
|
||||
const possible = calculate([stop(1), fixed], [60])
|
||||
expect(possible.chains[0].schedule.entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
|
||||
})
|
||||
|
||||
it('allows a manual late appointment to override the automatic end', () => {
|
||||
const fixed = stop(2, { time: '19:00' })
|
||||
const planned = calculate([stop(1), fixed], [660])
|
||||
expect(planned.issue).toBeNull()
|
||||
expect(planned.chains[0].schedule.entries[1].arrival).toBe('19:00')
|
||||
expect(planned.chains[0].stops).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps a pinned next-day start at 07:00 and leaves the pause point early enough', () => {
|
||||
const a = stop(1)
|
||||
const b = stop(2, { ownerDayId: 2, ownerIndex: 0, time: '07:00' })
|
||||
const planned = planDayWindow([day(1, [a]), day(2, [b])], hours, (from, to) => leg(60, from, to), 'metric', labels)
|
||||
expect(planned.issue).toBeNull()
|
||||
expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['06:00', '07:00'])
|
||||
})
|
||||
|
||||
it('does not silently move a fixed appointment onto a later date after a previous spill', () => {
|
||||
const planned = calculate([stop(1), stop(2), stop(3, { time: '17:00' })], [720, 60])
|
||||
expect(planned.issue).toBe('conflict')
|
||||
})
|
||||
|
||||
it('pauses a long visit at its location and carries the remaining stay into tomorrow', () => {
|
||||
const first = stop(1, { dwellMinutes: 660 })
|
||||
const planned = calculate([first, stop(2)], [60])
|
||||
expect(planned.chains[0].stops[1]).toMatchObject({ lat: first.lat, lng: first.lng })
|
||||
expect(planned.chains[0].schedule.entries[0].departure).toBe('18:00')
|
||||
expect(planned.chains[1].schedule.entries[0].departure).toBe('09:00')
|
||||
expect(planned.chains[1].schedule.entries[1].arrival).toBe('10:00')
|
||||
expect(first.dwellMinutes).toBe(660)
|
||||
})
|
||||
|
||||
it('does not place a night pause halfway through a ferry crossing', () => {
|
||||
const a = stop(1)
|
||||
const b = stop(2)
|
||||
const ferry = leg(720, a, b)
|
||||
ferry.seg.mode = 'ferry'
|
||||
const planned = planDayWindow([day(1, [a, b])], hours, () => ferry, 'metric', labels)
|
||||
expect(planned.chains[0].stops).toHaveLength(2)
|
||||
expect(planned.chains[0].schedule.entries[1].arrival).toBe('20:00')
|
||||
})
|
||||
|
||||
it('does not invent times or points for missing routes', () => {
|
||||
const planned = planDayWindow([day(1, [stop(1), stop(2)])], hours, () => undefined, 'metric', labels)
|
||||
expect(planned.issue).toBe('incomplete')
|
||||
expect(planned.chains).toEqual([])
|
||||
expect(planDayWindow([], hours, () => undefined, 'metric', labels).chains).toEqual([])
|
||||
})
|
||||
|
||||
it('bounds extreme driving and stay durations', () => {
|
||||
expect(calculate([stop(1), stop(2)], [600 * 367]).issue).toBe('tooLong')
|
||||
expect(calculate([stop(1, { dwellMinutes: 600 * 367 }), stop(2)], [1]).issue).toBe('tooLong')
|
||||
})
|
||||
|
||||
it('maps insertions on generated days back to real stored stops', () => {
|
||||
const planned = calculate([stop(1), stop(2)])
|
||||
expect(roadtripInsertion(planned.chains[1], 0)).toEqual({ dayId: 1, position: 1 })
|
||||
expect(roadtripInsertion(planned.chains[1], 1)).toEqual({ dayId: 1, position: 1 })
|
||||
expect(roadtripInsertion({ stops: [] }, 0)).toBeNull()
|
||||
expect(roadtripInsertion(planned.chains[1], 2)).toEqual({ dayId: 1, position: 2 })
|
||||
})
|
||||
|
||||
it('rounds fractional route minutes across the hour boundary', () => {
|
||||
const planned = calculate([stop(1), stop(2)], [59.8])
|
||||
expect(planned.chains[0].schedule.entries[1].arrival).toBe('09:00')
|
||||
})
|
||||
|
||||
it('keeps a zero-distance timed leg at its location when split', () => {
|
||||
const a = stop(1)
|
||||
const b = stop(2, { lng: a.lng })
|
||||
const timed = leg(720, a, b)
|
||||
timed.seg.distance = 0
|
||||
timed.line = []
|
||||
const planned = planDayWindow([day(1, [a, b])], hours, () => timed, 'metric', labels)
|
||||
expect(planned.issue).toBeNull()
|
||||
expect(planned.chains[1].stops[0]).toMatchObject({ lat: a.lat, lng: a.lng })
|
||||
expect(planned.chains[1].schedule.entries[1].arrival).toBe('10:00')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import { formatClock, parseClock } from './roadtripModel'
|
||||
import { lineMetres, projectOntoRoute, sliceAtMeters } from './corridor'
|
||||
import type { SpillChain } from './nightSpill'
|
||||
import type { RoadtripDay, RoadtripStop, RoutedLeg } from './useRoadtripRoutes'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import type { RoadtripDayBoundary } from '@trek/shared'
|
||||
|
||||
export interface DayWindow {
|
||||
start: number
|
||||
end: number
|
||||
endMode?: 'route' | 'stop'
|
||||
}
|
||||
|
||||
export interface AutomaticNight {
|
||||
phase: 'start' | 'end'
|
||||
fromDayNumber: number
|
||||
position?: number
|
||||
manual?: boolean
|
||||
}
|
||||
|
||||
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
|
||||
type LegLookup = (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined
|
||||
|
||||
export function dayWindow(start: unknown, end: unknown, endMode?: unknown): DayWindow | null {
|
||||
const minutes = (clock: unknown): number | null => {
|
||||
if (typeof clock !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(clock)) return null
|
||||
return Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3))
|
||||
}
|
||||
const from = minutes(start)
|
||||
const to = minutes(end)
|
||||
return from !== null && to !== null && to > from ? { start: from, end: to, ...(endMode === 'stop' ? { endMode } as const : {}) } : null
|
||||
}
|
||||
|
||||
function portion(leg: RoutedLeg, from: number, to: number, unit: DistanceUnit): RoutedLeg {
|
||||
if (from === 0 && to === 1) return leg
|
||||
const coordinates = leg.line.length ? leg.line : [leg.seg.from, leg.seg.to]
|
||||
const spine = coordinates.map(([lat, lng]) => ({ lat, lng }))
|
||||
const metres = lineMetres(spine)
|
||||
const line = metres === 0 ? coordinates : sliceAtMeters(spine, from * metres, to * metres).map(p => [p.lat, p.lng] as [number, number])
|
||||
const distance = leg.seg.distance * (to - from)
|
||||
const duration = leg.seg.duration * (to - from)
|
||||
const durationText = formatDurationShort(duration)
|
||||
const distanceText = formatDistance(distance / 1000, unit)
|
||||
return {
|
||||
seg: {
|
||||
...leg.seg, distance, duration, distanceText, durationText,
|
||||
drivingText: `${distanceText} · ${durationText}`,
|
||||
walkingText: `${distanceText} · ${durationText}`,
|
||||
from: line[0], to: line[line.length - 1], mid: line[Math.floor(line.length / 2)],
|
||||
},
|
||||
line,
|
||||
vias: leg.vias.filter(v => {
|
||||
const along = (projectOntoRoute(v, spine)?.alongKm ?? 0) * 1000
|
||||
return along >= from * metres && (to === 1 ? along <= metres : along < to * metres)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function stationary(stop: RoadtripStop): RoutedLeg {
|
||||
const at: [number, number] = [stop.lat, stop.lng]
|
||||
return {
|
||||
line: [], vias: [],
|
||||
seg: { from: at, to: at, mid: at, distance: 0, duration: 0, mode: 'driving', walkingText: '', drivingText: '', distanceText: '' },
|
||||
}
|
||||
}
|
||||
|
||||
export interface WindowPlan {
|
||||
chains: SpillChain[]
|
||||
legFor: LegLookup
|
||||
issue: 'incomplete' | 'conflict' | 'tooLong' | 'legTooLong' | null
|
||||
}
|
||||
|
||||
export function planDayWindow(
|
||||
days: PlanDay[], window: DayWindow, lookup: LegLookup, unit: DistanceUnit,
|
||||
labels: { start: string; end: string },
|
||||
boundaries: RoadtripDayBoundary[] = [],
|
||||
): WindowPlan {
|
||||
const ordered = [...days].sort((a, b) => a.dayNumber - b.dayNumber)
|
||||
const stops = ordered.flatMap(day => day.stops.map(stop => ({ stop, day })))
|
||||
const failed = (issue: WindowPlan['issue']): WindowPlan => ({ chains: [], legFor: lookup, issue })
|
||||
if (!stops.length) return { chains: [], legFor: lookup, issue: null }
|
||||
const positions = new Map(stops.map(({ stop }, i) => [stop.assignmentId, i]))
|
||||
const targets = new Map<number, number>()
|
||||
let lastTarget = -1
|
||||
for (const boundary of [...boundaries].sort((a, b) => a.day_number - b.day_number)) {
|
||||
const from = positions.get(boundary.from_assignment_id)
|
||||
const to = boundary.to_assignment_id === null ? null : positions.get(boundary.to_assignment_id)
|
||||
if (from === undefined || to !== null && to !== from + 1) return failed('conflict')
|
||||
const target = from + (to === null ? 0 : boundary.fraction)
|
||||
if (target <= lastTarget) return failed('conflict')
|
||||
targets.set(boundary.day_number, target)
|
||||
lastTarget = target
|
||||
}
|
||||
const legs = stops.slice(0, -1).map((s, i) => lookup(s.stop, stops[i + 1].stop))
|
||||
if (legs.some(l => !l || !Number.isFinite(l.seg.duration) || l.seg.duration < 0 || l.line.length < 2 && l.seg.distance > 0)) {
|
||||
return failed('incomplete')
|
||||
}
|
||||
|
||||
const chains = new Map<number, SpillChain>()
|
||||
const pieces = new Map<RoadtripStop, Map<RoadtripStop, RoutedLeg>>()
|
||||
const firstDay = stops[0].day.dayNumber
|
||||
let number = firstDay
|
||||
let clock = window.start
|
||||
let issue: WindowPlan['issue'] = null
|
||||
let position = 0
|
||||
|
||||
const chainAt = (n: number): SpillChain => {
|
||||
const existing = chains.get(n)
|
||||
if (existing) return existing
|
||||
const stored = ordered.find(d => d.dayNumber === n)
|
||||
const dated = ordered.find(d => d.date)
|
||||
let date = stored?.date ?? null
|
||||
if (!stored && dated?.date) {
|
||||
const dayDate = new Date(`${dated.date}T12:00:00Z`)
|
||||
dayDate.setUTCDate(dayDate.getUTCDate() + n - dated.dayNumber)
|
||||
date = dayDate.toISOString().slice(0, 10)
|
||||
}
|
||||
const chain: SpillChain = {
|
||||
dayId: stored?.dayId ?? -1000000000 - n,
|
||||
dayNumber: n, date, title: stored?.title ?? null,
|
||||
stops: [], schedule: { entries: [], warnings: [] }, spills: [],
|
||||
}
|
||||
chains.set(n, chain)
|
||||
return chain
|
||||
}
|
||||
const putLeg = (from: RoadtripStop, to: RoadtripStop, leg: RoutedLeg) => {
|
||||
if (!pieces.has(from)) pieces.set(from, new Map())
|
||||
pieces.get(from)!.set(to, leg)
|
||||
}
|
||||
const append = (stop: RoadtripStop, arrival: number, departure = arrival) => {
|
||||
const chain = chainAt(number)
|
||||
chain.stops.push(stop)
|
||||
chain.schedule.entries.push({
|
||||
arrival: formatClock(Math.round(arrival)), departure: formatClock(Math.round(departure)),
|
||||
anchored: !stop.automaticNight && stop.time !== null,
|
||||
dayOffset: Math.floor(arrival / 1440),
|
||||
})
|
||||
}
|
||||
const night = (at: RoadtripStop, final = false): RoadtripStop => {
|
||||
const chain = chainAt(number)
|
||||
const last = chain.stops[chain.stops.length - 1]
|
||||
const end: RoadtripStop = {
|
||||
...at, assignmentId: -2000000000 - number * 2,
|
||||
name: labels.end, time: null, dwellMinutes: 0, stopType: null, fillPercent: null,
|
||||
automaticNight: { phase: 'end', fromDayNumber: number, position, manual: targets.has(number) },
|
||||
}
|
||||
append(end, clock)
|
||||
if (last) putLeg(last, end, stationary(at))
|
||||
if (final) return end
|
||||
number += 1
|
||||
if (number - firstDay > 365) issue = 'tooLong'
|
||||
clock = window.start
|
||||
const start: RoadtripStop = {
|
||||
...end, assignmentId: end.assignmentId - 1, name: labels.start,
|
||||
automaticNight: { ...end.automaticNight!, phase: 'start' },
|
||||
}
|
||||
append(start, clock)
|
||||
return start
|
||||
}
|
||||
|
||||
let previous: RoadtripStop | undefined
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const { stop, day } = stops[i]
|
||||
const pin = parseClock(stop.time)
|
||||
if (previous && number < day.dayNumber && !targets.has(number)) {
|
||||
if (window.endMode !== 'stop') clock = Math.max(clock, window.end)
|
||||
previous = night(previous)
|
||||
while (number < day.dayNumber && !issue) {
|
||||
clock = window.end
|
||||
previous = night(previous)
|
||||
}
|
||||
} else if (!previous) {
|
||||
number = day.dayNumber
|
||||
clock = pin ?? window.start
|
||||
}
|
||||
if (issue) return failed(issue)
|
||||
if (pin !== null && day.dayNumber !== number) return failed('conflict')
|
||||
|
||||
if (previous) {
|
||||
const leg = legs[i - 1]!
|
||||
const minutes = leg.seg.duration / 60
|
||||
if (pin !== null && chainAt(number).stops.every(s => s.automaticNight)) {
|
||||
clock = Math.min(clock, pin - minutes)
|
||||
if (clock < 0) return failed('conflict')
|
||||
const startEntry = chainAt(number).schedule.entries[0]
|
||||
startEntry.arrival = formatClock(Math.round(clock))
|
||||
startEntry.departure = formatClock(Math.round(clock))
|
||||
}
|
||||
if (pin !== null && clock + minutes > pin + 1) return failed('conflict')
|
||||
|
||||
let fraction = 0
|
||||
const split = pin === null && (leg.seg.mode === 'driving' || leg.seg.mode === undefined)
|
||||
if (split && !targets.has(number) && window.endMode === 'stop' && clock + minutes > window.end + 0.000001) {
|
||||
if (minutes > window.end - window.start) return failed('legTooLong')
|
||||
previous = night(previous)
|
||||
if (issue) return failed(issue)
|
||||
}
|
||||
while (fraction < 1) {
|
||||
const target = targets.get(number)
|
||||
if (target !== undefined && target < i - 1 + fraction - 0.000001) return failed('conflict')
|
||||
const manualCut = target !== undefined && target < i && target >= i - 1 + fraction
|
||||
if (!manualCut && (target !== undefined || !split || clock + minutes * (1 - fraction) <= window.end + 0.000001)) break
|
||||
const available = manualCut ? minutes * (target - (i - 1 + fraction)) : Math.max(0, window.end - clock)
|
||||
if (available === 0) {
|
||||
previous = night(previous)
|
||||
} else {
|
||||
const until = Math.min(1, fraction + available / minutes)
|
||||
const part = portion(leg, fraction, until, unit)
|
||||
const at = part.line[part.line.length - 1]
|
||||
const point = { ...previous, lat: at[0], lng: at[1] }
|
||||
clock += available
|
||||
position = i - 1 + until
|
||||
const before = previous
|
||||
previous = night(point)
|
||||
const previousStops = chainAt(number - 1).stops
|
||||
const ending = previousStops[previousStops.length - 1]
|
||||
putLeg(before, ending, part)
|
||||
fraction = until
|
||||
}
|
||||
if (issue) return failed(issue)
|
||||
}
|
||||
const remainder = portion(leg, fraction, 1, unit)
|
||||
clock += minutes * (1 - fraction)
|
||||
putLeg(previous, stop, remainder)
|
||||
}
|
||||
position = i
|
||||
if (pin !== null && number !== day.dayNumber) return failed('conflict')
|
||||
const target = targets.get(number)
|
||||
if (target !== undefined && target < position) return failed('conflict')
|
||||
if (pin !== null) clock = pin
|
||||
append(stop, clock)
|
||||
previous = stop
|
||||
|
||||
let dwell = Math.max(0, stop.dwellMinutes ?? 0)
|
||||
while (dwell > 0) {
|
||||
const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock)
|
||||
const spend = Math.min(dwell, remaining)
|
||||
clock += spend
|
||||
dwell -= spend
|
||||
const entries = chainAt(number).schedule.entries
|
||||
entries[entries.length - 1].departure = formatClock(Math.round(clock))
|
||||
if (dwell > 0) {
|
||||
previous = night(previous)
|
||||
if (issue) return failed(issue)
|
||||
}
|
||||
}
|
||||
if (target === position || !targets.has(number) && stop.endDay && i < stops.length - 1) {
|
||||
previous = night(previous, i === stops.length - 1)
|
||||
if (issue) return failed(issue)
|
||||
}
|
||||
}
|
||||
|
||||
if ([...targets.keys()].some(day => !chains.get(day)?.stops.some(stop => stop.automaticNight?.phase === 'end'))) return failed('conflict')
|
||||
|
||||
for (const chain of chains.values()) {
|
||||
for (let i = 0; i < chain.stops.length; i++) {
|
||||
const stop = chain.stops[i]
|
||||
if (stop.automaticNight) continue
|
||||
const owner = ordered.find(d => d.dayId === stop.ownerDayId)
|
||||
if (!owner || owner.dayNumber === chain.dayNumber) continue
|
||||
const last = chain.spills[chain.spills.length - 1]
|
||||
if (last?.fromDayNumber === owner.dayNumber && last.at + last.count === i) last.count += 1
|
||||
else chain.spills.push({
|
||||
automatic: true,
|
||||
at: i, count: 1, fromDayNumber: owner.dayNumber, departure: null,
|
||||
leg: undefined, line: [], fromStop: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const day of ordered) chainAt(day.dayNumber)
|
||||
return {
|
||||
chains: [...chains.values()].sort((a, b) => a.dayNumber - b.dayNumber),
|
||||
legFor: (from, to) => pieces.get(from)?.get(to),
|
||||
issue: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function roadtripInsertion(day: Pick<RoadtripDay, 'stops'>, index: number) {
|
||||
const stop = day.stops[Math.min(Math.max(0, index), day.stops.length - 1)]
|
||||
return stop ? { dayId: stop.ownerDayId, position: stop.ownerIndex + (stop.automaticNight || index >= day.stops.length ? 1 : 0) } : null
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type { RouteSegment } from '../../types'
|
||||
|
||||
/** A stretch of one card's chain that was driven onto it from an earlier day. */
|
||||
export interface SpillMark {
|
||||
automatic?: boolean
|
||||
/** Where it starts in the card's `stops`. */
|
||||
at: number
|
||||
/** How many stops it covers. */
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useAutomaticDayPoints } from './useAutomaticDayPoints'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string, args?: { day: number; time: string }) => args ? `End of day ${args.day} at ${args.time}` : key }) }))
|
||||
|
||||
describe('automatic pause map points', () => {
|
||||
it('adds each pause once, respects collapsed days and discards obsolete focus', () => {
|
||||
const routes = {
|
||||
vias: [{ lat: 1, lng: 1, label: 'Charger', tone: 'success' }],
|
||||
days: [{
|
||||
dayId: 1, dayNumber: 1,
|
||||
stops: [
|
||||
{ lat: 2, lng: 3, automaticNight: { phase: 'end' } },
|
||||
{ lat: 2, lng: 3, automaticNight: { phase: 'start' } },
|
||||
{ lat: 4, lng: 5 },
|
||||
],
|
||||
schedule: { entries: [{ arrival: '18:00' }] },
|
||||
}],
|
||||
} as unknown as RoadtripRoutes
|
||||
const { result, rerender } = renderHook(
|
||||
({ current, collapsed }) => useAutomaticDayPoints(current, collapsed),
|
||||
{ initialProps: { current: routes, collapsed: new Set<number>() } },
|
||||
)
|
||||
expect(result.current.markers).toHaveLength(2)
|
||||
expect(result.current.markers[1]).toMatchObject({ lat: 2, lng: 3, hoverCard: true, nightPause: { day: 1, atPlace: false } })
|
||||
expect(result.current.markers[1].label).toBe('End of day 1 at 18:00')
|
||||
act(() => result.current.focusPoint(2, 3))
|
||||
expect(result.current.focusPoints).toEqual([[2, 3]])
|
||||
rerender({ current: routes, collapsed: new Set([1]) })
|
||||
expect(result.current.markers).toEqual(routes.vias)
|
||||
rerender({ current: { ...routes, days: [] }, collapsed: new Set() })
|
||||
expect(result.current.focusPoints).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import type { RouteVia } from '../../types'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
export function useAutomaticDayPoints(routes: RoadtripRoutes, collapsed: Set<number>) {
|
||||
const { t } = useTranslation()
|
||||
const [selection, setSelection] = useState<{ days: RoadtripRoutes['days']; points: [number, number][] } | null>(null)
|
||||
const focusPoint = useCallback((lat: number, lng: number) => {
|
||||
setSelection({ days: routes.days, points: [[lat, lng]] })
|
||||
}, [routes.days])
|
||||
const markers = useMemo<RouteVia[]>(() => {
|
||||
const places = new Set(routes.days.flatMap(day => day.stops.filter(stop => !stop.automaticNight).map(stop => `${stop.lat},${stop.lng}`)))
|
||||
const nights = routes.days.flatMap(day => day.stops.filter(stop => stop.automaticNight?.phase === 'end').map(stop => ({ day: day.dayNumber, ...stop.automaticNight! })))
|
||||
const ends = routes.days.filter(d => !collapsed.has(d.dayId)).flatMap(day => day.stops.flatMap((stop, i) =>
|
||||
stop.automaticNight?.phase === 'end' ? [{
|
||||
lat: stop.lat, lng: stop.lng, tone: 'default' as const, hoverCard: true,
|
||||
nightPause: { day: day.dayNumber, atPlace: places.has(`${stop.lat},${stop.lng}`),
|
||||
position: stop.automaticNight.position, manual: stop.automaticNight.manual,
|
||||
minPosition: nights.filter(n => n.day < day.dayNumber).slice(-1)[0]?.position,
|
||||
maxPosition: nights.find(n => n.day > day.dayNumber && n.manual)?.position },
|
||||
label: t('roadtrip.window.pointLabel', { day: day.dayNumber, time: day.schedule.entries[i]?.arrival ?? '' }),
|
||||
}] : [],
|
||||
))
|
||||
return [...routes.vias, ...ends]
|
||||
}, [routes.days, routes.vias, collapsed, t])
|
||||
return { markers, focusPoint, focusPoints: selection?.days === routes.days ? selection.points : null }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { roadtripDayBoundaryListSchema, type RoadtripDayBoundary } from '@trek/shared'
|
||||
import { apiClient } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
|
||||
const EMPTY: RoadtripDayBoundary[] = []
|
||||
|
||||
export function useDayBoundaries(tripId: number | string | null, active: boolean, assignments?: unknown) {
|
||||
const { offline } = useNetworkMode()
|
||||
const [stored, setStored] = useState<{ tripId: number | string | null; boundaries: RoadtripDayBoundary[] }>({ tripId: null, boundaries: EMPTY })
|
||||
const [stale, setStale] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const revision = useRef(0)
|
||||
const busy = useRef(false)
|
||||
const currentTrip = useRef(tripId)
|
||||
currentTrip.current = tripId
|
||||
useEffect(() => {
|
||||
if (!tripId || !active || offline) return
|
||||
let cancelled = false
|
||||
const version = ++revision.current
|
||||
apiClient.get(`/trips/${tripId}/roadtrip/day-boundaries`).then(saved => {
|
||||
if (cancelled || revision.current !== version) return
|
||||
setStored({ tripId, boundaries: roadtripDayBoundaryListSchema.parse(saved.data).boundaries })
|
||||
setStale(false)
|
||||
}).catch(() => { if (!cancelled) setStale(true) })
|
||||
return () => { cancelled = true }
|
||||
}, [tripId, active, offline, assignments])
|
||||
useEffect(() => {
|
||||
if (!tripId || !active) return
|
||||
const receive = (event: Record<string, unknown>) => {
|
||||
if (event.type !== 'roadtripBoundary:changed' || String(event.tripId) !== String(tripId)) return
|
||||
const parsed = roadtripDayBoundaryListSchema.safeParse(event)
|
||||
if (!parsed.success) return
|
||||
revision.current++
|
||||
setStored({ tripId, boundaries: parsed.data.boundaries })
|
||||
setStale(false)
|
||||
}
|
||||
addListener(receive)
|
||||
return () => removeListener(receive)
|
||||
}, [tripId, active])
|
||||
const save = useCallback(async (day: number, boundary: RoadtripDayBoundary | null) => {
|
||||
if (!tripId || offline || busy.current) return false
|
||||
busy.current = true
|
||||
setPending(true)
|
||||
const version = ++revision.current
|
||||
try {
|
||||
const saved = boundary
|
||||
? await apiClient.put(`/trips/${tripId}/roadtrip/day-boundaries`, { ...boundary, day_number: day })
|
||||
: await apiClient.delete(`/trips/${tripId}/roadtrip/day-boundaries/${day}`)
|
||||
if (currentTrip.current !== tripId) return false
|
||||
if (revision.current === version) setStored({ tripId, boundaries: roadtripDayBoundaryListSchema.parse(saved.data).boundaries })
|
||||
setStale(false)
|
||||
return true
|
||||
} finally {
|
||||
busy.current = false
|
||||
setPending(false)
|
||||
}
|
||||
}, [tripId, offline])
|
||||
return { boundaries: stored.tripId === tripId ? stored.boundaries : EMPTY, save, stale, pending, editable: active && !offline && !stale && stored.tripId === tripId }
|
||||
}
|
||||
@@ -82,6 +82,22 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('useFollowTrack', () => {
|
||||
it('clears the original leg when an automatic pause splits its displayed days', async () => {
|
||||
const [a, b] = day().stops
|
||||
const end = { ...a, assignmentId: -10, automaticNight: { phase: 'end' as const, fromDayNumber: 1 } }
|
||||
const start = { ...end, assignmentId: -11, automaticNight: { phase: 'start' as const, fromDayNumber: 1 } }
|
||||
const split = routes([
|
||||
day({ dayId: 1, automaticSchedule: true, stops: [a, end] }),
|
||||
day({ dayId: 2, dayNumber: 2, automaticSchedule: true, stops: [start, b] }),
|
||||
])
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], split, vias))
|
||||
act(() => result.current.open(1))
|
||||
await act(async () => result.current.clear())
|
||||
expect(vias.addMany).toHaveBeenCalledWith(1, [], [0], null)
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-001: nothing is parsed while the dialog is closed', () => {
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK, PLAIN], routes([day()]), viasStub()))
|
||||
expect(result.current.dayId).toBeNull()
|
||||
|
||||
@@ -100,7 +100,12 @@ export function useFollowTrack(
|
||||
const abort = useRef<AbortController | null>(null)
|
||||
|
||||
const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId])
|
||||
const stops = useMemo<LatLng[]>(() => (day?.stops ?? []).map(s => ({ lat: s.lat, lng: s.lng })), [day])
|
||||
const stops = useMemo<LatLng[]>(() => {
|
||||
const original = day?.automaticSchedule
|
||||
? routes.days.flatMap(d => d.stops).filter(s => !s.automaticNight && s.ownerDayId === dayId).sort((a, b) => a.ownerIndex - b.ownerIndex)
|
||||
: day?.stops ?? []
|
||||
return original.map(s => ({ lat: s.lat, lng: s.lng }))
|
||||
}, [day, routes.days, dayId])
|
||||
|
||||
/**
|
||||
* Parsed only while the dialog is open.
|
||||
|
||||
@@ -79,6 +79,61 @@ afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('automatic daily travel times', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' } })
|
||||
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), duration: 43200, legs: [{ ...routed(1).legs[0], duration: 43200 }] })
|
||||
})
|
||||
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
|
||||
|
||||
it('splits existing routes, keeps manual starts and recalculates without routing the pause', async () => {
|
||||
const days = [day(1, 1), day(2, 2)]
|
||||
const original = [{ id: 1, at: HAMBURG, time: '07:00' }, { id: 2, at: BERLIN }]
|
||||
const { result, rerender } = renderHook(({ stops }) => useRoadtripRoutes(7, days, map(1, stops)), {
|
||||
initialProps: { stops: original as StopSpec[] },
|
||||
})
|
||||
await waitFor(() => expect(result.current.dayWindowIssue).toBeNull())
|
||||
expect(result.current.days).toHaveLength(2)
|
||||
expect(result.current.totalStops).toBe(2)
|
||||
expect(result.current.totalDuration).toBe(43200)
|
||||
expect(result.current.days[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '18:00'])
|
||||
expect(result.current.days[1].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '09:00'])
|
||||
expect(result.current.days[1].stops[1]).toMatchObject({ assignmentId: 2, ownerDayId: 1, ownerIndex: 1 })
|
||||
const end = result.current.days[0].stops[1]
|
||||
rerender({ stops: [{ ...original[0], dwell: 120 }, original[1]] })
|
||||
await waitFor(() => expect(result.current.days[1].schedule.entries[1].arrival).toBe('11:00'))
|
||||
expect(result.current.days[0].stops[1].lng).not.toBe(end.lng)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } }))
|
||||
await waitFor(() => expect(result.current.days).toHaveLength(1))
|
||||
expect(result.current.days[0].schedule.entries[1].arrival).toBe('21:00')
|
||||
expect(result.current.days[0].stops.some(s => s.automaticNight)).toBe(false)
|
||||
})
|
||||
|
||||
it('routes the join between stored days and includes it exactly once', async () => {
|
||||
const days = [day(1, 1), day(2, 2)]
|
||||
const assignments = { ...map(1, [{ id: 1, at: HAMBURG }]), ...map(2, [{ id: 2, at: BERLIN }]) }
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
|
||||
await waitFor(() => expect(result.current.dayWindowIssue).toBeNull())
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
expect(calculateRouteWithLegs.mock.calls[0][0]).toEqual([{ lat: HAMBURG[0], lng: HAMBURG[1] }, { lat: BERLIN[0], lng: BERLIN[1] }])
|
||||
expect(result.current.days.map(d => d.dayNumber)).toEqual([1, 2, 3])
|
||||
expect(result.current.totalDuration).toBe(43200)
|
||||
expect(result.current.totalDistance).toBe(100000)
|
||||
expect(result.current.totalStops).toBe(2)
|
||||
})
|
||||
|
||||
it('exposes a fixed appointment conflict and keeps stored dates and times', async () => {
|
||||
const days = [day(1, 1), day(2, 2)]
|
||||
const assignments = map(1, [{ id: 1, at: HAMBURG, time: '07:00' }, { id: 2, at: BERLIN, time: '10:00' }])
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
|
||||
await waitFor(() => expect(result.current.dayWindowIssue).toBe('conflict'))
|
||||
expect(result.current.days).toHaveLength(1)
|
||||
expect(result.current.days[0].stops.map(s => s.time)).toEqual(['07:00', '10:00'])
|
||||
expect(result.current.days[0].stops.some(s => s.automaticNight)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRoadtripRoutes', () => {
|
||||
it('FE-ROADTRIP-ROUTES-001: routes a whole day in one request, not one per leg', async () => {
|
||||
const days = [day(1, 1)]
|
||||
|
||||
@@ -10,10 +10,15 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
|
||||
import { spurFor } from './accessSpur'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared'
|
||||
import { dayWindow, planDayWindow, type AutomaticNight, type WindowPlan } from './dayWindow'
|
||||
import type { DayBoundaryLeg } from './dayBoundaryPath'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { computeSchedule } from './roadtripModel'
|
||||
|
||||
/** One stop on a day's drive — an assignment whose place actually has coordinates. */
|
||||
export interface RoadtripStop {
|
||||
automaticNight?: AutomaticNight
|
||||
assignmentId: number
|
||||
/**
|
||||
* The day this stop is STORED on, and its position there.
|
||||
@@ -34,6 +39,7 @@ export interface RoadtripStop {
|
||||
time: string | null
|
||||
/** How long the visit is planned to take. `places.duration_minutes` has carried this for years. */
|
||||
dwellMinutes: number | null
|
||||
endDay?: boolean
|
||||
/** Mode of the leg LEAVING this stop; null inherits the day default. */
|
||||
legMode: string | null
|
||||
incomingLegMode: string | null
|
||||
@@ -59,6 +65,7 @@ export interface RoadtripStop {
|
||||
}
|
||||
|
||||
export interface RoadtripDay {
|
||||
automaticSchedule?: boolean
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
@@ -151,6 +158,9 @@ export interface QuietDay {
|
||||
}
|
||||
|
||||
export interface RoadtripRoutes {
|
||||
boundaryPath?: DayBoundaryLeg[]
|
||||
validateBoundaries?: (boundaries: RoadtripDayBoundary[]) => WindowPlan['issue']
|
||||
dayWindowIssue?: 'incomplete' | 'conflict' | 'tooLong' | 'legTooLong' | null
|
||||
days: RoadtripDay[]
|
||||
/**
|
||||
* Days with fewer than two stops, in trip order.
|
||||
@@ -220,7 +230,7 @@ const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
|
||||
* than added later, because this shape is written in one place and read in another and
|
||||
* three separate changes to the same two lines is three merge conflicts.
|
||||
*/
|
||||
interface RoutedLeg {
|
||||
export interface RoutedLeg {
|
||||
seg: RouteSegment
|
||||
line: [number, number][]
|
||||
/**
|
||||
@@ -287,6 +297,7 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): Roadtrip
|
||||
lng: p.lng,
|
||||
time: a.assignment_time ?? p.place_time ?? null,
|
||||
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
|
||||
endDay: a.end_day === true,
|
||||
legMode: a.leg_transport_mode ?? null,
|
||||
incomingLegMode: a.incoming_leg_transport_mode ?? null,
|
||||
stopType: p.stop_type ?? null,
|
||||
@@ -318,7 +329,9 @@ export function useRoadtripRoutes(
|
||||
* chose rather than the one it prefers.
|
||||
*/
|
||||
viasByDay: Record<number, RoadtripVia[]> = {},
|
||||
boundaries: RoadtripDayBoundary[] = [],
|
||||
): RoadtripRoutes {
|
||||
const { t } = useTranslation()
|
||||
const routeProfile = fallbackProfile || 'driving'
|
||||
// Leg text is pre-formatted in the chosen unit, so a km↔mi switch has to re-fetch.
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
@@ -367,7 +380,12 @@ export function useRoadtripRoutes(
|
||||
* the trip one continuous drive — every one of those gaps is routed, drawn, and counted
|
||||
* towards the day it ARRIVES on, which is the same rule a night drive already follows.
|
||||
*/
|
||||
const connectDays = useSettingsStore(s => !!s.settings.roadtrip_connect_days)
|
||||
const connectSetting = useSettingsStore(s => !!s.settings.roadtrip_connect_days)
|
||||
const startTime = useSettingsStore(s => s.settings.roadtrip_day_start)
|
||||
const endTime = useSettingsStore(s => s.settings.roadtrip_day_end)
|
||||
const endMode = useSettingsStore(s => s.settings.roadtrip_day_end_mode)
|
||||
const window = useMemo(() => dayWindow(startTime, endTime, endMode), [startTime, endTime, endMode])
|
||||
const connectDays = connectSetting || window !== null
|
||||
const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting])
|
||||
// What the car is and how far it goes on one fill, assembled in one place because the
|
||||
// rail needs the same answer to say what a given fill buys at a given stop.
|
||||
@@ -671,7 +689,10 @@ export function useRoadtripRoutes(
|
||||
if (have && have.shape === seamShape(from, viasByDay)) return
|
||||
out.push({ from, to, dayId })
|
||||
}
|
||||
for (const chain of chains) {
|
||||
const routingChains = window
|
||||
? [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).filter(d => d.stops.length)
|
||||
: chains
|
||||
for (const chain of routingChains) {
|
||||
for (let i = 0; i < chain.stops.length - 1; i++) {
|
||||
const from = chain.stops[i]
|
||||
const to = chain.stops[i + 1]
|
||||
@@ -683,14 +704,14 @@ export function useRoadtripRoutes(
|
||||
// And, when the traveller asks for one continuous drive, the road from each card's
|
||||
// last stop to the next card's first — the one gap a day-at-a-time routing leaves.
|
||||
if (connectDays) {
|
||||
for (let d = 0; d < chains.length - 1; d++) {
|
||||
const from = chains[d].stops[chains[d].stops.length - 1]
|
||||
const to = chains[d + 1].stops[0]
|
||||
if (from && to) want(from, to, chains[d + 1].dayId)
|
||||
for (let d = 0; d < routingChains.length - 1; d++) {
|
||||
const from = routingChains[d].stops[routingChains[d].stops.length - 1]
|
||||
const to = routingChains[d + 1].stops[0]
|
||||
if (from && to) want(from, to, routingChains[d + 1].dayId)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [chains, legsByDay, seamLegs, viasByDay, connectDays])
|
||||
}, [chains, plan, quietDays, window, legsByDay, seamLegs, viasByDay, connectDays])
|
||||
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -761,7 +782,22 @@ export function useRoadtripRoutes(
|
||||
// places is the road between them whichever date it is driven on.
|
||||
const allSnaps: Record<string, SnappedWaypoint> = {}
|
||||
for (const day of plan) Object.assign(allSnaps, snapByDay[day.dayId] ?? {})
|
||||
const legFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)]
|
||||
const storedLegFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)]
|
||||
const timed = window ? planDayWindow(
|
||||
[...plan, ...quietDays], window, storedLegFor, distanceUnit,
|
||||
{ start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') },
|
||||
boundaries,
|
||||
) : null
|
||||
const automaticSchedule = !!timed && timed.issue === null
|
||||
const displayChains = automaticSchedule ? timed.chains : timed?.issue
|
||||
? [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).filter(d => d.stops.length).map(d => ({
|
||||
...d, spills: [], schedule: computeSchedule(
|
||||
d.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
|
||||
d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1])?.seg.duration),
|
||||
),
|
||||
}))
|
||||
: chains
|
||||
const legFor = automaticSchedule ? timed.legFor : storedLegFor
|
||||
|
||||
const lines: [number, number][][] = []
|
||||
const lineDays: number[] = []
|
||||
@@ -777,7 +813,7 @@ export function useRoadtripRoutes(
|
||||
let previousStop: RoadtripStop | undefined
|
||||
/** And which day that was, so the road out of it is drawn as that day's. */
|
||||
let previousDayNumber: number | undefined
|
||||
for (const chain of chains) {
|
||||
for (const chain of displayChains) {
|
||||
const routed = chain.stops.slice(0, -1).map((s, i) => legFor(s, chain.stops[i + 1]))
|
||||
/**
|
||||
* The drives that reach this card rather than leave from it, and only when the
|
||||
@@ -799,7 +835,7 @@ export function useRoadtripRoutes(
|
||||
* look like it begins at a stop that is not on it.
|
||||
*/
|
||||
const inboundAt = new Map<number, { seg: RouteSegment | undefined; line: [number, number][]; drawnAs: number }>()
|
||||
if (connectDays) {
|
||||
if (connectDays && !automaticSchedule) {
|
||||
for (const spill of chain.spills) {
|
||||
inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber })
|
||||
}
|
||||
@@ -839,7 +875,7 @@ export function useRoadtripRoutes(
|
||||
const schedule = chain.schedule
|
||||
const legVias = routed.map(l => l?.vias ?? [])
|
||||
const stops = chain.stops.map(s => {
|
||||
const snap = allSnaps[stopKey(s)]
|
||||
const snap = s.automaticNight ? undefined : allSnaps[stopKey(s)]
|
||||
const line = spurFor(snap)
|
||||
if (line) accessLines.push({ line, meters: snap.meters, stopKey: stopKey(s) })
|
||||
return { ...s, offRoadMeters: line ? snap.meters : null }
|
||||
@@ -884,6 +920,7 @@ export function useRoadtripRoutes(
|
||||
// ferry or a walk pays for a second copy.
|
||||
const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine
|
||||
out.push({
|
||||
automaticSchedule,
|
||||
dayId: chain.dayId,
|
||||
dayNumber: chain.dayNumber,
|
||||
date: chain.date,
|
||||
@@ -895,8 +932,19 @@ export function useRoadtripRoutes(
|
||||
dayWarning: drive.day,
|
||||
})
|
||||
}
|
||||
const drives = out.filter(d => d.stops.length > 1)
|
||||
const drives = out.filter(d => d.stops.length > 1 || d.stops.some(s => s.automaticNight))
|
||||
const originalStops = [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).flatMap(day => day.stops)
|
||||
const boundaryPath = originalStops.slice(0, -1).flatMap((from, position) => {
|
||||
const to = originalStops[position + 1]
|
||||
const leg = storedLegFor(from, to)
|
||||
return leg && (!leg.seg.mode || leg.seg.mode === 'driving') && from.assignmentId > 0 && to.assignmentId > 0
|
||||
? [{ from, to, position, line: leg.line }] : []
|
||||
})
|
||||
return {
|
||||
boundaryPath,
|
||||
validateBoundaries: (next: RoadtripDayBoundary[]) => window ? planDayWindow([...plan, ...quietDays], window, storedLegFor, distanceUnit,
|
||||
{ start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') }, next).issue : 'conflict',
|
||||
dayWindowIssue: timed?.issue ?? null,
|
||||
days: drives,
|
||||
lines,
|
||||
lineDays,
|
||||
@@ -909,13 +957,13 @@ export function useRoadtripRoutes(
|
||||
// holding a single stop draws no card and contributes nothing else here either.
|
||||
// Service stops are not stops in this sense — a charger on the way is part of the
|
||||
// drive, and counting it here would make the head disagree with the day cards.
|
||||
totalStops: drives.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0),
|
||||
totalStops: drives.reduce((s, d) => s + d.stops.filter(st => !st.automaticNight && !isServiceStopType(st.stopType)).length, 0),
|
||||
// A day left with one stop or none draws no drive, so it stays what it was: an
|
||||
// outline that appears while a stop is in flight and is not worth a row otherwise.
|
||||
quietDays: out
|
||||
.filter(d => d.stops.length < 2)
|
||||
.filter(d => d.stops.length < 2 && !d.stops.some(s => s.automaticNight))
|
||||
.map(d => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })),
|
||||
loading,
|
||||
}
|
||||
}, [plan, chains, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays])
|
||||
}, [plan, quietDays, window, distanceUnit, t, chains, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries])
|
||||
}
|
||||
@@ -13,9 +13,12 @@ interface CustomTimePickerProps {
|
||||
/** Read-only surfaces keep the field, they just cannot type in it — same
|
||||
* affordance the native input they replaced offered (#2067). */
|
||||
disabled?: boolean
|
||||
'aria-label'?: string
|
||||
'aria-describedby'?: string
|
||||
'aria-invalid'?: boolean
|
||||
}
|
||||
|
||||
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {}, disabled = false }: CustomTimePickerProps) {
|
||||
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {}, disabled = false, ...aria }: CustomTimePickerProps) {
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputFocused, setInputFocused] = useState(false)
|
||||
@@ -129,6 +132,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
transition: 'border-color 0.15s',
|
||||
}}>
|
||||
<input
|
||||
{...aria}
|
||||
type="text"
|
||||
value={inputFocused ? value : formatClockTime(value, is12h)}
|
||||
onChange={handleInput}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { offlineDb } from './offlineDb'
|
||||
import type { Assignment } from '../types'
|
||||
|
||||
export async function cacheAssignment(assignment: Assignment): Promise<void> {
|
||||
await offlineDb.days.where('id').equals(assignment.day_id).modify(day => {
|
||||
day.assignments = (day.assignments ?? []).map(a => a.id === assignment.id ? { ...a, ...assignment } : a)
|
||||
})
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import MSegmented from '../../components/MSegmented'
|
||||
import { MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi'
|
||||
import { MSetSelectRow } from '../settings/MSettingsUi'
|
||||
import MSetPickerSheet from '../settings/MSetPickerSheet'
|
||||
import RoutingInstanceFields, { type RoutingDefaults } from '../../../components/Admin/RoutingInstanceFields'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -37,7 +38,7 @@ const MAP_PRESETS = [
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
type Defaults = {
|
||||
type Defaults = RoutingDefaults & {
|
||||
temperature_unit?: string
|
||||
distance_unit?: DistanceUnit
|
||||
dark_mode?: string | boolean
|
||||
@@ -317,6 +318,8 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
|
||||
</MAdminField>
|
||||
)}
|
||||
|
||||
{!managed && <RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} hintClassName="font-geist text-[0.625rem] leading-relaxed text-m-muted" />}
|
||||
|
||||
{/* Live tile preview */}
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-xl">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
|
||||
@@ -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 {
|
||||
<LazyPanel id="roadtrip-rail">
|
||||
<RoadtripSidebar
|
||||
routes={roadtripRoutes}
|
||||
onFocusPoint={focusRoadtripPoint}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
onSelectStop={(placeId, assignmentId) => 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. */}
|
||||
<div className="px-3.5 pb-3.5">
|
||||
<RoadtripLimitsCard onSave={saveRoadtripLimit} />
|
||||
<RoadtripLimitsCard onSave={saveRoadtripLimit} onResetDayBoundaries={resetDayBoundaries} />
|
||||
</div>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
@@ -769,6 +771,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
|
||||
{selectedPlace && !isMobile && (
|
||||
<PlaceInspector
|
||||
roadtripEndDay={roadtripEndDay}
|
||||
roadtripStay={roadtripStay}
|
||||
onEditTransport={openLinkedTransport}
|
||||
onEditReservation={openLinkedReservation}
|
||||
place={selectedPlace}
|
||||
@@ -811,6 +815,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<div className="bg-[rgba(0,0,0,0.3)]" style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 'var(--bottom-nav-h)' }} role="presentation" onClick={() => setSelectedPlaceId(null)}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh' }} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<PlaceInspector
|
||||
roadtripEndDay={roadtripEndDay}
|
||||
roadtripStay={roadtripStay}
|
||||
onEditTransport={openLinkedTransport}
|
||||
onEditReservation={openLinkedReservation}
|
||||
place={selectedPlace}
|
||||
|
||||
@@ -170,6 +170,7 @@ function makeActions() {
|
||||
moveAssignment: vi.fn(async () => undefined),
|
||||
removeAssignment: vi.fn(async () => undefined),
|
||||
reorderAssignments: vi.fn(async () => undefined),
|
||||
setAssignmentEndDay: vi.fn(async () => undefined),
|
||||
reorderDays: vi.fn(async () => undefined),
|
||||
insertDay: vi.fn(async () => undefined),
|
||||
updateDayTitle: vi.fn(async () => undefined),
|
||||
@@ -187,6 +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<Record<string, unknown>>).map((stop, i) => ({
|
||||
ownerDayId: day.dayId, ownerIndex: i, ...stop,
|
||||
})),
|
||||
}))
|
||||
if (rt.corridor.day) rt.corridor.day = rt.routes.days.find(d => d.dayId === rt.corridor.day?.dayId) ?? rt.corridor.day
|
||||
const rendered = renderHook(() => useTripPlanner(), { wrapper })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
await waitFor(() => expect(rendered.result.current.roadtripActive).toBe(true))
|
||||
@@ -311,6 +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 })] })
|
||||
|
||||
@@ -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<number>()
|
||||
for (const day of roadtripRoutes.days) {
|
||||
if (!collapsedRoadtripDays.has(day.dayId)) continue
|
||||
for (const stop of day.stops) hidden.add(stop.placeId)
|
||||
for (const stop of day.stops) if (!stop.automaticNight) hidden.add(stop.placeId)
|
||||
}
|
||||
for (const day of roadtripRoutes.days) {
|
||||
if (collapsedRoadtripDays.has(day.dayId)) continue
|
||||
for (const stop of day.stops) hidden.delete(stop.placeId)
|
||||
for (const stop of day.stops) if (!stop.automaticNight) hidden.delete(stop.placeId)
|
||||
}
|
||||
return mapPlaces.filter(p => !hidden.has(p.id))
|
||||
}, [mapPlaces, roadtripRoutes.days, collapsedRoadtripDays])
|
||||
@@ -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<DayBoundaryControls | undefined>(() => {
|
||||
if (!dayBoundaries.editable || !can('day_edit', trip) || !roadtripRoutes.boundaryPath?.length) return undefined
|
||||
return {
|
||||
path: roadtripRoutes.boundaryPath,
|
||||
hint: t('roadtrip.window.dragHint'),
|
||||
move: async (day, boundary) => {
|
||||
const next = dayBoundaries.boundaries.filter(b => b.day_number !== day)
|
||||
if (boundary) next.push(boundary)
|
||||
const issue = roadtripRoutes.validateBoundaries?.(next)
|
||||
if (boundary && issue) { toast.error(t(`roadtrip.window.${issue}`)); return false }
|
||||
try { return await dayBoundaries.save(day, boundary) }
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')); return false }
|
||||
},
|
||||
}
|
||||
}, [dayBoundaries.editable, dayBoundaries.boundaries, dayBoundaries.save, can, trip, roadtripRoutes, t, toast])
|
||||
const mapFocusPoints = useMemo<[number, number][]>(
|
||||
() => (refuel.offered.length
|
||||
? refuel.offered.map(p => [p.lat, p.lng] as [number, number])
|
||||
: alternativeFocusPoints),
|
||||
[refuel.offered, alternativeFocusPoints],
|
||||
: alternativeFocusPoints.length ? alternativeFocusPoints : automaticPoints.focusPoints ?? alternativeFocusPoints),
|
||||
[refuel.offered, alternativeFocusPoints, automaticPoints.focusPoints],
|
||||
)
|
||||
|
||||
/** Asks the router for other ways of driving one leg of one day. */
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'fake-indexeddb/auto'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { assignmentRepo } from './assignmentRepo'
|
||||
import { offlineDb, clearAll } from '../db/offlineDb'
|
||||
import { saveAssignmentEndDay } from '../api/assignmentEndDay'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { mutationQueue } from '../sync/mutationQueue'
|
||||
import { apiClient } from '../api/client'
|
||||
import type { Assignment, Day } from '../types'
|
||||
|
||||
vi.mock('../api/assignmentEndDay', () => ({ saveAssignmentEndDay: vi.fn() }))
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => true) }))
|
||||
vi.mock('../sync/authGate', () => ({ isAuthed: () => true }))
|
||||
vi.mock('../api/client', () => ({ apiClient: { request: vi.fn() } }))
|
||||
const assignment = { id: 7, day_id: 1, place_id: 2, order_index: 0, assignment_time: '07:00', place: { id: 2, name: 'Berlin' } } as Assignment
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll()
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true)
|
||||
await offlineDb.days.put({ id: 1, trip_id: 9, day_number: 1, assignments: [assignment] } as Day)
|
||||
})
|
||||
|
||||
describe('assignment day-end persistence', () => {
|
||||
it('stores the offline flag with a replayable write and preserves manual time', async () => {
|
||||
await assignmentRepo.setEndDay(9, assignment, true)
|
||||
expect((await offlineDb.days.get(1))?.assignments?.[0]).toMatchObject({ end_day: true, assignment_time: '07:00' })
|
||||
expect(await offlineDb.mutationQueue.toArray()).toEqual([expect.objectContaining({
|
||||
url: '/trips/9/assignments/7/end-day', method: 'PUT', body: { end_day: true }, resource: 'assignments',
|
||||
})])
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
vi.mocked(apiClient.request).mockResolvedValue({ data: { assignment: { ...assignment, end_day: true } } })
|
||||
await mutationQueue.flush()
|
||||
expect(await offlineDb.mutationQueue.count()).toBe(0)
|
||||
expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBe(true)
|
||||
})
|
||||
|
||||
it('saves and clears through the online API', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
vi.mocked(saveAssignmentEndDay).mockResolvedValue({ ...assignment, end_day: false })
|
||||
await assignmentRepo.setEndDay(9, assignment, false)
|
||||
expect(saveAssignmentEndDay).toHaveBeenCalledWith(9, 7, { end_day: false })
|
||||
expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the cached visit unchanged after a rejected save', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
vi.mocked(saveAssignmentEndDay).mockRejectedValue(new Error('Denied'))
|
||||
await expect(assignmentRepo.setEndDay(9, assignment, true)).rejects.toThrow('Denied')
|
||||
expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { saveAssignmentEndDay } from '../api/assignmentEndDay'
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { cacheAssignment } from '../db/cacheAssignment'
|
||||
import { generateUUID, mutationQueue } from '../sync/mutationQueue'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import type { Assignment } from '../types'
|
||||
|
||||
export const assignmentRepo = {
|
||||
async setEndDay(tripId: number | string, assignment: Assignment, endDay: boolean): Promise<Assignment> {
|
||||
if (!isEffectivelyOffline()) {
|
||||
const saved = await saveAssignmentEndDay(tripId, assignment.id, { end_day: endDay })
|
||||
await cacheAssignment(saved)
|
||||
return saved
|
||||
}
|
||||
const updated = { ...assignment, end_day: endDay }
|
||||
await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => {
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(), tripId: Number(tripId), method: 'PUT',
|
||||
url: `/trips/${tripId}/assignments/${assignment.id}/end-day`,
|
||||
body: { end_day: endDay }, resource: 'assignments', entityId: assignment.id,
|
||||
})
|
||||
await cacheAssignment(updated)
|
||||
})
|
||||
return updated
|
||||
},
|
||||
}
|
||||
@@ -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<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface AssignmentsSlice {
|
||||
setAssignmentEndDay: (tripId: number | string, dayId: number, assignmentId: number, endDay: boolean) => Promise<void>
|
||||
assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise<Assignment | undefined>
|
||||
removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise<void>
|
||||
reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise<void>
|
||||
@@ -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)))
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(<MAdminDefaultUserSettings />);
|
||||
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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[]
|
||||
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 || [],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<RoadtripDayBoundary>(
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -610,8 +610,8 @@ export class TripsService {
|
||||
`).all(sourceTripId) as any[];
|
||||
const assignmentMap = new Map<number, number | bigint>();
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -72,6 +72,23 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<AssignmentsService>)).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<AssignmentsService>)).move(user, '5', '9', { new_day_id: 4 }))).toEqual({ status: 404, body: { error: 'Target day not found' } });
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof assignmentTimeRequestSchema>;
|
||||
|
||||
export const assignmentEndDayRequestSchema = z.object({
|
||||
end_day: z.boolean(),
|
||||
});
|
||||
export type AssignmentEndDayRequest = z.infer<typeof assignmentEndDayRequestSchema>;
|
||||
|
||||
/**
|
||||
* PUT /:id/notes (#2163) — edit the per-assignment note after creation.
|
||||
* Deliberately uncapped like the create contract above: the REST create body
|
||||
|
||||
@@ -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': 'عرض الخطة',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Προβολή σχεδίου',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'プラン表示',
|
||||
|
||||
@@ -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 を使用します。',
|
||||
|
||||
@@ -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': '계획 보기',
|
||||
|
||||
@@ -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을 사용합니다.',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user