mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
feat(roadtrip): share trip preferences and improve service stops
This commit is contained in:
@@ -18,7 +18,7 @@ export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
|
||||
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 groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng]), zoom) : [], [pois, enabled, map, zoom])
|
||||
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
|
||||
if (!enabled) return <>{children}</>
|
||||
return <>{groups.map(group => {
|
||||
|
||||
@@ -1391,6 +1391,7 @@ describe('MapViewGL', () => {
|
||||
|
||||
it('clusters roadtrip stations and zooms before offering overlapping stations individually', async () => {
|
||||
loadOnAttach()
|
||||
vi.mocked(glMap.getZoom).mockReturnValue(8)
|
||||
const poi: Poi = {
|
||||
osm_id: 'n1', name: 'Station A', lat: 48.854, lng: 2.332, category: 'charging_station',
|
||||
poi_type: 'charging_station', address: null, website: null, phone: null,
|
||||
@@ -1411,6 +1412,7 @@ describe('MapViewGL', () => {
|
||||
const list = calls[calls.length - 1][0]
|
||||
act(() => { list.querySelectorAll('button')[1].click() })
|
||||
expect(onPoiClick).toHaveBeenCalledWith(other)
|
||||
vi.mocked(glMap.getZoom).mockReturnValue(10)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-043: plugin markers render as tone dots with a text-only popup', async () => {
|
||||
|
||||
@@ -1840,7 +1840,7 @@ export function MapViewGL({
|
||||
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 }))
|
||||
const groups = clusterLoosely ? clusterPois(pois, poi => map.project([poi.lng, poi.lat]), map.getZoom()) : pois.map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng }))
|
||||
for (const group of groups) {
|
||||
if (group.pois.length > 1) {
|
||||
const el = document.createElement('button')
|
||||
|
||||
@@ -10,6 +10,12 @@ const station = (id: string, lng: number): Poi => ({
|
||||
const project = (poi: Poi) => ({ x: poi.lng, y: poi.lat })
|
||||
|
||||
describe('station clusters', () => {
|
||||
it('only clusters in the distant overview, even for coincident stations', () => {
|
||||
const pois = [station('a', 1), station('b', 1)]
|
||||
expect(clusterPois(pois, project, 8)).toHaveLength(1)
|
||||
expect(clusterPois(pois, project, 9)).toHaveLength(2)
|
||||
expect(clusterPois(pois, project, 14)).toHaveLength(2)
|
||||
})
|
||||
it('groups across grid edges, counts unique stations and preserves the originals', () => {
|
||||
const a = station('a', 47), b = station('b', 49), c = station('c', 150)
|
||||
const groups = clusterPois([c, b, a, a], project)
|
||||
@@ -41,3 +47,9 @@ describe('station clusters', () => {
|
||||
expect(select).toHaveBeenCalledWith(poi)
|
||||
})
|
||||
})
|
||||
|
||||
it('combines nearby small groups into one overview cluster', () => {
|
||||
const pois = [station('a', 0), station('b', 3), station('c', 20), station('d', 23), station('e', 40), station('f', 43)]
|
||||
expect(clusterPois(pois, project, 8).map(group => group.pois.length)).toEqual([6])
|
||||
expect(clusterPois(pois, project, 9)).toHaveLength(6)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
export const POI_CLUSTER_RADIUS = 48
|
||||
export const POI_CLUSTER_MAX_ZOOM = 8
|
||||
export const POI_CLUSTER_DETAIL_ZOOM = 18
|
||||
export interface PoiCluster { pois: Poi[]; lat: number; lng: number }
|
||||
|
||||
export function clusterPois(pois: Poi[], project: (poi: Poi) => { x: number; y: number }): PoiCluster[] {
|
||||
export function clusterPois(pois: Poi[], project: (poi: Poi) => { x: number; y: number }, zoom = 0): PoiCluster[] {
|
||||
const groups: (PoiCluster & { x: number; y: number })[] = []
|
||||
const cells = new Map<string, number[]>()
|
||||
const unique = new Map(pois.map(poi => [poi.osm_id, poi]))
|
||||
if (zoom > POI_CLUSTER_MAX_ZOOM) return [...unique.values()].map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng }))
|
||||
for (const poi of [...unique.values()].sort((a, b) => a.osm_id.localeCompare(b.osm_id))) {
|
||||
const point = project(poi)
|
||||
const cx = Math.floor(point.x / POI_CLUSTER_RADIUS), cy = Math.floor(point.y / POI_CLUSTER_RADIUS)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { isServiceStopType } from '../Roadtrip/roadtripModel'
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
@@ -180,6 +182,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false)
|
||||
const tripActions = useRef(useTripStore.getState()).current
|
||||
const can = useCanDo()
|
||||
const canEditDays = can('day_edit', trip)
|
||||
@@ -409,7 +412,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}
|
||||
|
||||
const getDayAssignments = (dayId) =>
|
||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
(assignments[String(dayId)] || []).filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type)).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
|
||||
// Compute initial day_plan_position for a transport based on time
|
||||
const computeTransportPosition = (r, da) => {
|
||||
@@ -485,7 +488,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
return map
|
||||
// getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [days, assignments, dayNotes, reservations, transportPosVersion])
|
||||
}, [days, assignments, dayNotes, reservations, transportPosVersion, mirrorServiceStops])
|
||||
|
||||
// Days whose inline route legs should be computed & shown. Desktop: the selected
|
||||
// day while the Route toggle is on. Mobile: each expanded day the user tapped
|
||||
@@ -710,7 +713,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// Unified reorder: assigns positions to ALL item types based on new visual order
|
||||
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
|
||||
// Capture previous place order for undo
|
||||
const prevAssignmentIds = getDayAssignments(dayId).map(a => a.id)
|
||||
const prevAssignmentIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
|
||||
// …and, per booking, the fields this call is about to overwrite, so a failed write
|
||||
// can put the visible order back instead of leaving a phantom one behind the error
|
||||
// toast. Restoring the whole array instead would also drop what a collaborator's
|
||||
@@ -957,7 +960,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const da = getDayAssignments(dayId)
|
||||
if (da.length < 3) return
|
||||
|
||||
const prevIds = da.map(a => a.id)
|
||||
const prevIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
|
||||
|
||||
// Separate fixed (stay at their index) and movable assignments. A place is
|
||||
// fixed if it's locked OR has a set time — timed places are anchored by their
|
||||
|
||||
@@ -276,3 +276,16 @@ describe('RoadtripLimitsCard', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||
}))
|
||||
|
||||
it('shares service stops with Days by default and lets the trip turn it off', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
const toggle = screen.getByRole('button', { name: 'Show in Days too' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(toggle)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_service_stops_in_days', false)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Check,
|
||||
@@ -250,7 +251,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, onResetDayBoundaries }: {
|
||||
export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loading = false }: {
|
||||
loading?: boolean
|
||||
onResetDayBoundaries?: () => Promise<void>
|
||||
/**
|
||||
* Persists one setting. Absent leaves the dialog read-only.
|
||||
@@ -261,9 +263,10 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries }: {
|
||||
onSave?: (key: string, value: number | string | boolean) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
const settings = useRoadtripSettings(s => s)
|
||||
const unit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [open, setOpen] = useState(false)
|
||||
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
||||
const distanceUnit: DistanceUnit = unit === 'imperial' ? 'imperial' : 'metric'
|
||||
const imperial = distanceUnit === 'imperial'
|
||||
|
||||
const legMinutes = settings.roadtrip_leg_minutes
|
||||
@@ -380,6 +383,8 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={loading}
|
||||
aria-busy={loading}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{/* No icon tile on the trigger: the badges below already carry a clock, a
|
||||
@@ -387,7 +392,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries }: {
|
||||
they need to sit on one line. */}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate text-body font-semibold text-content">
|
||||
{t('roadtrip.limit.title')}
|
||||
{loading ? t('common.loading') : t('roadtrip.limit.title')}
|
||||
</span>
|
||||
{badges.length ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
@@ -636,6 +641,13 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries }: {
|
||||
onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel icon={Signpost} title={t('roadtrip.stops.section')} note={t('roadtrip.stops.daysHint')}>
|
||||
<AvoidRow icon={Link2} label={t('roadtrip.stops.inDays')}
|
||||
on={settings.roadtrip_service_stops_in_days !== false}
|
||||
disabled={!onSave}
|
||||
onToggle={() => onSave?.('roadtrip_service_stops_in_days', settings.roadtrip_service_stops_in_days === false)}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
CarFront, Footprints, Bike, Zap, AlertTriangle,
|
||||
@@ -771,7 +772,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
|
||||
const { t } = useTranslation()
|
||||
// Read here rather than threaded down: both stop shapes need the same two, and the
|
||||
// badge is the only thing in the rail that depends on them.
|
||||
const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent)
|
||||
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent)
|
||||
const { vehicleKind } = useVehicleRange()
|
||||
const kind = STOP_KIND_BY_KEY[stop.stopType ?? '']
|
||||
const Icon = kind?.Icon ?? ParkingSquare
|
||||
@@ -1020,7 +1021,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
||||
const { t } = useTranslation()
|
||||
// Read here rather than threaded down: both stop shapes need the same two, and the
|
||||
// badge is the only thing in the rail that depends on them.
|
||||
const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent)
|
||||
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent)
|
||||
const { vehicleKind } = useVehicleRange()
|
||||
return (
|
||||
<button
|
||||
@@ -1325,7 +1326,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
// The colour the map draws this day in, or none at all while the map is drawing one
|
||||
// blue line for the whole trip.
|
||||
const dayColorsOn = useSettingsStore(s => !!s.settings.roadtrip_day_colors)
|
||||
const dayColorsOn = useRoadtripSettings(s => !!s.roadtrip_day_colors)
|
||||
const tint = dayColorsOn ? dayColor(day.dayNumber).line : null
|
||||
const last = day.stops.length - 1
|
||||
/**
|
||||
|
||||
@@ -110,3 +110,7 @@ describe('StopFillPicker', () => {
|
||||
expect(onPick).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||
}))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import React from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
@@ -40,7 +41,7 @@ export default function StopFillPicker({ anchor, current, onPick, onClose }: {
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const unit: DistanceUnit = useSettingsStore(s => s.settings.distance_unit) === 'imperial' ? 'imperial' : 'metric'
|
||||
const fallback = useSettingsStore(s => s.settings.roadtrip_fill_percent)
|
||||
const fallback = useRoadtripSettings(s => s.roadtrip_fill_percent)
|
||||
const { rangeKm } = useVehicleRange()
|
||||
|
||||
// Zero, absent and 100 all mean the same thing in the settings, so they read as full
|
||||
|
||||
@@ -10,8 +10,8 @@ const { pois, offline, stored } = vi.hoisted(() => ({
|
||||
vi.mock('../../api/client', () => ({ mapsApi: { pois } }))
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
|
||||
vi.mock('../../sync/networkMode', () => ({ isEffectivelyOffline: offline }))
|
||||
vi.mock('../../store/settingsStore', () => ({
|
||||
useSettingsStore: (select: (state: typeof stored) => unknown) => select(stored),
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (settings: typeof stored.settings) => unknown) => select(stored.settings),
|
||||
}))
|
||||
|
||||
import { useRefuelSearch } from './useRefuelSearch'
|
||||
@@ -280,3 +280,20 @@ describe('useRefuelSearch', () => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('searches farther back when nearby chargers cannot be reached', async () => {
|
||||
pois.mockResolvedValueOnce(answer([station('too-late', 58)]))
|
||||
.mockResolvedValueOnce(answer([station('earlier', 25)]))
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) })
|
||||
expect(pois).toHaveBeenCalledTimes(2)
|
||||
expect(result.current.results.map(p => p.osm_id)).toEqual(['earlier'])
|
||||
expect(result.current.outcome).toBe('found')
|
||||
})
|
||||
it('does not offer stations behind the last refuelling stop', async () => {
|
||||
pois.mockResolvedValue(answer([station('behind', 20)]))
|
||||
const { result } = renderHook(() => useRefuelSearch())
|
||||
await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, [], 30) })
|
||||
expect(result.current.results).toEqual([])
|
||||
expect(pois).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { refuelStopTypeFor, type VehicleKind } from './roadtripModel'
|
||||
import { boxAround, pointAtMeters, type LatLng } from './corridor'
|
||||
import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } from './refuelSuggestion'
|
||||
|
||||
/**
|
||||
* Somewhere to fill up before the tank runs out, asked for once, when asked for.
|
||||
*
|
||||
* Deliberately NOT automatic, for the same reason the corridor search next door is not:
|
||||
* every run is a real request against a shared service. It is also not the corridor
|
||||
* search itself, although it looks like a smaller version of it. Three reasons, all
|
||||
* about behaviour rather than tidiness:
|
||||
*
|
||||
* - that search is bound to the day the panel has selected, so it cannot answer a
|
||||
* warning on day five while the panel shows day one;
|
||||
* - starting it clears the panel's results and the traveller's own name filter;
|
||||
* - it asks up to sixteen boxes along a whole day. This question needs one small circle.
|
||||
*
|
||||
* One request, one day, no shared state touched.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How far around the search point to look, in kilometres.
|
||||
*
|
||||
@@ -41,6 +25,8 @@ const LOOK_KM = 14
|
||||
* reach, so it is pulled back far enough that the useful half becomes the whole of it.
|
||||
*/
|
||||
const LOOK_BACK_KM = 12
|
||||
const SEARCH_STEP_KM = 20
|
||||
const MAX_SEARCHES = 8
|
||||
|
||||
/** Nothing on offer, as one stable array, so an idle search never redraws the map. */
|
||||
const NONE: RefuelCandidate[] = []
|
||||
@@ -60,8 +46,8 @@ export interface RefuelSearch {
|
||||
* reference changes.
|
||||
*/
|
||||
offered: RefuelCandidate[]
|
||||
/** Runs the one request. `key` identifies the dry point, so only one is open at a time. */
|
||||
ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[]) => Promise<void>
|
||||
/** Searches backwards until an available stop is found. `key` identifies the dry point, so only one is open at a time. */
|
||||
ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[], fromAlongKm?: number) => Promise<void>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
@@ -69,7 +55,7 @@ export function useRefuelSearch(): RefuelSearch {
|
||||
const { locale } = useTranslation()
|
||||
// What to look for. Somebody who said they drive an electric car has no use for a
|
||||
// petrol station in the list, and the other way round; with nothing said, both.
|
||||
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
|
||||
const vehicle = useRoadtripSettings(s => s.roadtrip_vehicle)
|
||||
const [openFor, setOpenFor] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [outcome, setOutcome] = useState<RefuelOutcome | null>(null)
|
||||
@@ -91,6 +77,7 @@ export function useRefuelSearch(): RefuelSearch {
|
||||
line: LatLng[],
|
||||
dryAlongKm: number,
|
||||
existing: LatLng[],
|
||||
fromAlongKm = 0,
|
||||
) => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
@@ -99,6 +86,7 @@ export function useRefuelSearch(): RefuelSearch {
|
||||
setResults([])
|
||||
setOutcome(null)
|
||||
|
||||
setLoading(false)
|
||||
// Offline the answer would be a list of places that cannot be saved: the place write
|
||||
// queues offline but the day assignment does not, so accepting one would leave an
|
||||
// orphan. Say so instead of offering it.
|
||||
@@ -115,12 +103,25 @@ export function useRefuelSearch(): RefuelSearch {
|
||||
// Pulled back along the road, because only what lies before the dry point can be
|
||||
// reached. `at` stays the dry point itself: it is what the band names and what the
|
||||
// map is asked to show.
|
||||
const centre = pointAtMeters(line, Math.max(0, (dryAlongKm - LOOK_BACK_KM) * 1000)) ?? at
|
||||
const answer = await mapsApi.pois(wanted, boxAround(centre, LOOK_KM), locale, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing })
|
||||
setResults(candidates)
|
||||
setOutcome(outcomeOf(candidates, answer))
|
||||
let incomplete = false
|
||||
for (let attempt = 0; attempt < MAX_SEARCHES; attempt++) {
|
||||
const along = Math.max(fromAlongKm, dryAlongKm - LOOK_BACK_KM - attempt * SEARCH_STEP_KM)
|
||||
const centre = pointAtMeters(line, along * 1000) ?? at
|
||||
const answer = await mapsApi.pois(wanted, boxAround(centre, LOOK_KM), locale, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
incomplete ||= !!answer.truncated
|
||||
const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing }).filter(p => p.alongKm >= fromAlongKm)
|
||||
if (candidates.length) {
|
||||
setResults(candidates)
|
||||
setOutcome('found')
|
||||
return
|
||||
}
|
||||
if (along === fromAlongKm) {
|
||||
setOutcome(outcomeOf([], { truncated: incomplete }))
|
||||
return
|
||||
}
|
||||
}
|
||||
setOutcome('incomplete')
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setOutcome('failed')
|
||||
} finally {
|
||||
|
||||
@@ -721,3 +721,7 @@ describe('useRoadtripRoutes', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||
}))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
|
||||
@@ -144,9 +145,9 @@ export function useRoadtripRoutes(
|
||||
const [loading, setLoading] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes)
|
||||
const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes)
|
||||
const fillPercent = useSettingsStore(s => s.settings.roadtrip_fill_percent)
|
||||
const legMinutes = useRoadtripSettings(s => s.roadtrip_leg_minutes, tripId)
|
||||
const dayMinutes = useRoadtripSettings(s => s.roadtrip_day_minutes, tripId)
|
||||
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent, tripId)
|
||||
/**
|
||||
* Road classes to weight away, as the settings row stores them: a comma list.
|
||||
*
|
||||
@@ -155,7 +156,7 @@ export function useRoadtripRoutes(
|
||||
* any value — and an unknown word here would become a costing option the router does
|
||||
* not have.
|
||||
*/
|
||||
const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid)
|
||||
const avoidSetting = useRoadtripSettings(s => s.roadtrip_avoid, tripId)
|
||||
/**
|
||||
* Whether the gaps between days are driven too.
|
||||
*
|
||||
@@ -164,16 +165,16 @@ export function useRoadtripRoutes(
|
||||
* the trip one continuous drive — every one of those gaps is routed, drawn, and counted
|
||||
* towards the day it ARRIVES on, which is the same rule a night drive already follows.
|
||||
*/
|
||||
const 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 connectSetting = useRoadtripSettings(s => !!s.roadtrip_connect_days, tripId)
|
||||
const startTime = useRoadtripSettings(s => s.roadtrip_day_start, tripId)
|
||||
const endTime = useRoadtripSettings(s => s.roadtrip_day_end, tripId)
|
||||
const endMode = useRoadtripSettings(s => s.roadtrip_day_end_mode, tripId)
|
||||
const window = useMemo(() => dayWindow(startTime, endTime, endMode), [startTime, endTime, endMode])
|
||||
const connectDays = connectSetting || window !== null
|
||||
const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting])
|
||||
// What the car is and how far it goes on one fill, assembled in one place because the
|
||||
// rail needs the same answer to say what a given fill buys at a given stop.
|
||||
const { vehicleKind, rangeKm: planningRangeKm } = useVehicleRange()
|
||||
const { vehicleKind, rangeKm: planningRangeKm } = useVehicleRange(tripId)
|
||||
const avoidKey = avoid.join(',')
|
||||
// Zero and absent both mean "no limit": zero is a legal thing to type and says the
|
||||
// same thing, so it is folded here rather than guarded at every reading.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Settings } from '../../types'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
@@ -197,3 +197,7 @@ describe('useVehicleRange', () => {
|
||||
expect(result.current).not.toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||
}))
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { useMemo } from 'react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { effectiveRangeKm } from './vehicleRange'
|
||||
import type { VehicleKind } from './roadtripModel'
|
||||
|
||||
/**
|
||||
* How far the traveller's car goes on one fill, and what kind of car it is.
|
||||
*
|
||||
* One place rather than two, because the answer is assembled from seven settings and two
|
||||
* readers need it: the routes hook, which turns it into the range budget every warning is
|
||||
* measured against, and the rail, which tells a traveller what a given fill buys at a
|
||||
* given stop. Assembling it twice is the hand-mirrored state this codebase refuses
|
||||
* everywhere else, and the two copies would drift the first time a field is added.
|
||||
*
|
||||
* Each setting is read as its own primitive so the memo compares numbers. An object
|
||||
* rebuilt on every render would give the result a new identity every time, and what
|
||||
* depends on it is the effect that recalculates every day's driving.
|
||||
*/
|
||||
export function useVehicleRange(): { vehicleKind: VehicleKind | null; rangeKm: number | null } {
|
||||
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
|
||||
// Validated rather than trusted: a per-user setting gets no server-side check, and an
|
||||
// unknown word here would silently stop both kinds of stop counting as a fill-up.
|
||||
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
|
||||
const tankLitres = useSettingsStore(s => s.settings.roadtrip_tank_litres)
|
||||
const litresPer100 = useSettingsStore(s => s.settings.roadtrip_litres_per_100)
|
||||
const batteryKwh = useSettingsStore(s => s.settings.roadtrip_battery_kwh)
|
||||
const kwhPer100 = useSettingsStore(s => s.settings.roadtrip_kwh_per_100)
|
||||
const degradationPercent = useSettingsStore(s => s.settings.roadtrip_battery_degradation)
|
||||
export function useVehicleRange(tripId?: number | string | null): { vehicleKind: VehicleKind | null; rangeKm: number | null } {
|
||||
const rangeKm = useRoadtripSettings(s => s.roadtrip_range_km, tripId)
|
||||
const vehicle = useRoadtripSettings(s => s.roadtrip_vehicle, tripId)
|
||||
const tankLitres = useRoadtripSettings(s => s.roadtrip_tank_litres, tripId)
|
||||
const litresPer100 = useRoadtripSettings(s => s.roadtrip_litres_per_100, tripId)
|
||||
const batteryKwh = useRoadtripSettings(s => s.roadtrip_battery_kwh, tripId)
|
||||
const kwhPer100 = useRoadtripSettings(s => s.roadtrip_kwh_per_100, tripId)
|
||||
const degradationPercent = useRoadtripSettings(s => s.roadtrip_battery_degradation, tripId)
|
||||
|
||||
const vehicleKind: VehicleKind | null =
|
||||
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RoadtripPreferences } from '@trek/shared';
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types';
|
||||
|
||||
@@ -160,6 +161,7 @@ function initialDbName(): string {
|
||||
}
|
||||
|
||||
class TrekOfflineDb extends Dexie {
|
||||
roadtripPreferences!: Table<{ tripId: number; preferences: RoadtripPreferences }, number>;
|
||||
trips!: Table<Trip, number>;
|
||||
days!: Table<Day, number>;
|
||||
places!: Table<Place, number>;
|
||||
@@ -221,6 +223,7 @@ class TrekOfflineDb extends Dexie {
|
||||
// v5: places from the TREK Places index for the trip's area, so search
|
||||
// answers offline. `searchName` is indexed because that is what an offline
|
||||
// query filters on; `tripId` so the set drops with its trip.
|
||||
this.version(6).stores({ roadtripPreferences: 'tripId' });
|
||||
this.version(5).stores({
|
||||
areaPlaces: 'gers, tripId, searchName',
|
||||
});
|
||||
@@ -448,8 +451,10 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
offlineDb.syncMeta,
|
||||
offlineDb.blobCache,
|
||||
offlineDb.areaPlaces,
|
||||
offlineDb.roadtripPreferences,
|
||||
],
|
||||
async () => {
|
||||
await offlineDb.roadtripPreferences.delete(tripId);
|
||||
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.places.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.packingItems.where('trip_id').equals(tripId).delete();
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useRoadtripPreferencesSync } from './useRoadtripPreferencesSync'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useRoadtripSettings } from './useRoadtripSettings'
|
||||
import { useRoadtripPreferencesStore } from '../store/roadtripPreferencesStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { addListener, removeListener } from '../api/websocket'
|
||||
|
||||
vi.mock('../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() }))
|
||||
vi.mock('../repo/roadtripPreferencesRepo', () => ({ cachedRoadtripPreferences: vi.fn(async () => ({})) }))
|
||||
vi.mock('../db/offlineDb', () => ({ offlineDb: { roadtripPreferences: { put: vi.fn(async () => {}) } } }))
|
||||
|
||||
describe('Roadtrip settings from MCP', () => {
|
||||
it('merges valid user events, refuses unrelated settings and unsubscribes', () => {
|
||||
const before = useSettingsStore.getState().settings
|
||||
const hook = renderHook(() => useRoadtripPreferencesSync())
|
||||
const calls = vi.mocked(addListener).mock.calls
|
||||
const listener = calls[calls.length - 1][0]
|
||||
act(() => listener({ type: 'roadtripPreferences:changed', preferences: { roadtrip_day_start: '07:00' } }))
|
||||
expect(useSettingsStore.getState().settings.roadtrip_day_start).toBe('07:00')
|
||||
expect(useSettingsStore.getState().settings.distance_unit).toBe(before.distance_unit)
|
||||
act(() => listener({ type: 'roadtripPreferences:changed', preferences: { roadtrip_day_start: '99:00' } }))
|
||||
expect(useSettingsStore.getState().settings.roadtrip_day_start).toBe('07:00')
|
||||
describe('shared Roadtrip settings', () => {
|
||||
it('scopes events and reads by trip and signed-in account', () => {
|
||||
useAuthStore.setState({ user: { id: 1 } as never })
|
||||
useRoadtripPreferencesStore.setState({ byTrip: {} })
|
||||
const hook = renderHook(() => {
|
||||
useRoadtripPreferencesSync()
|
||||
return useRoadtripSettings(s => s.roadtrip_day_start, 10)
|
||||
})
|
||||
const listener = vi.mocked(addListener).mock.calls[vi.mocked(addListener).mock.calls.length - 1][0]
|
||||
act(() => listener({ type: 'roadtripPreferences:changed', tripId: '10', preferences: { roadtrip_day_start: '07:00' } }))
|
||||
expect(hook.result.current).toBe('07:00')
|
||||
act(() => listener({ type: 'roadtripPreferences:changed', tripId: '11', preferences: { roadtrip_day_start: '09:00' } }))
|
||||
expect(hook.result.current).toBe('07:00')
|
||||
act(() => listener({ type: 'roadtripPreferences:changed', tripId: '10', preferences: { roadtrip_day_start: '99:00' } }))
|
||||
expect(hook.result.current).toBe('07:00')
|
||||
act(() => useAuthStore.setState({ user: { id: 2 } as never }))
|
||||
expect(hook.result.current).toBeUndefined()
|
||||
hook.unmount()
|
||||
expect(removeListener).toHaveBeenCalledWith(listener)
|
||||
useSettingsStore.setState({ settings: before })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { useEffect } from 'react'
|
||||
import { roadtripPreferencesSchema } from '@trek/shared'
|
||||
import { addListener, removeListener } from '../api/websocket'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { publishRoadtripPreferences } from '../store/roadtripPreferencesStore'
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { cachedRoadtripPreferences } from '../repo/roadtripPreferencesRepo'
|
||||
|
||||
export function useRoadtripPreferencesSync() {
|
||||
const userId = useAuthStore(s => s.user?.id)
|
||||
useEffect(() => {
|
||||
if (!userId) return
|
||||
let disposed = false
|
||||
const listener = (event: Record<string, unknown>) => {
|
||||
if (event.type !== 'roadtripPreferences:changed') return
|
||||
const tripId = Number(event.tripId)
|
||||
const parsed = roadtripPreferencesSchema.safeParse(event.preferences)
|
||||
if (parsed.success) useSettingsStore.setState(state => ({ settings: { ...state.settings, ...parsed.data } }))
|
||||
if (!parsed.success || !Number.isSafeInteger(tripId) || tripId < 1) return
|
||||
publishRoadtripPreferences(userId, tripId, parsed.data)
|
||||
void offlineDb.roadtripPreferences.put({ tripId, preferences: parsed.data }).then(() => cachedRoadtripPreferences(tripId)).then(preferences => {
|
||||
if (!disposed) publishRoadtripPreferences(userId, tripId, preferences)
|
||||
}).catch(() => {})
|
||||
}
|
||||
addListener(listener)
|
||||
return () => removeListener(listener)
|
||||
}, [])
|
||||
return () => { disposed = true; removeListener(listener) }
|
||||
}, [userId])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { liveQuery } from 'dexie'
|
||||
import { onNetworkModeChange } from '../sync/networkMode'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RoadtripPreferences } from '@trek/shared'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { EMPTY_ROADTRIP_PREFERENCES, publishRoadtripPreferences, useRoadtripPreferencesStore } from '../store/roadtripPreferencesStore'
|
||||
import { cachedRoadtripPreferences, roadtripPreferencesRepo } from '../repo/roadtripPreferencesRepo'
|
||||
|
||||
export function useRoadtripSettings<T>(select: (settings: RoadtripPreferences) => T, requestedTripId?: number | string | null): T {
|
||||
const activeTripId = useTripStore(s => s.trip?.id)
|
||||
const userId = useAuthStore(s => s.user?.id)
|
||||
const key = `${userId}:${requestedTripId ?? activeTripId}`
|
||||
return useRoadtripPreferencesStore(s => select(s.byTrip[key] ?? EMPTY_ROADTRIP_PREFERENCES))
|
||||
}
|
||||
|
||||
export function useLoadRoadtripSettings(tripId: number, enabled: boolean) {
|
||||
const userId = useAuthStore(s => s.user?.id)
|
||||
const [loadedKey, setLoadedKey] = useState('')
|
||||
const [failedKey, setFailedKey] = useState('')
|
||||
const key = `${userId}:${tripId}`
|
||||
useEffect(() => {
|
||||
if (!enabled || !userId || !Number.isFinite(tripId)) return
|
||||
let cancelled = false
|
||||
const load = () => {
|
||||
roadtripPreferencesRepo.read(tripId).then(preferences => {
|
||||
if (cancelled) return
|
||||
publishRoadtripPreferences(userId, tripId, preferences)
|
||||
setLoadedKey(key)
|
||||
setFailedKey('')
|
||||
}).catch(() => { if (!cancelled) setFailedKey(key) })
|
||||
}
|
||||
const cacheSubscription = liveQuery(() => cachedRoadtripPreferences(tripId).catch(() => null)).subscribe(preferences => {
|
||||
if (!cancelled && preferences) publishRoadtripPreferences(userId, tripId, preferences)
|
||||
})
|
||||
load()
|
||||
const unsubscribe = onNetworkModeChange(load)
|
||||
window.addEventListener('online', load)
|
||||
return () => { cancelled = true; cacheSubscription.unsubscribe(); unsubscribe(); window.removeEventListener('online', load) }
|
||||
}, [tripId, userId, enabled, key])
|
||||
return { ready: loadedKey === key, failed: failedKey === key }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useRoadtripSettings } from './useRoadtripSettings'
|
||||
import { isServiceStopType } from '../components/Roadtrip/roadtripModel'
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -24,6 +26,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
// Charging stops / rest areas a plugin route places on the drawn line.
|
||||
const [routeVias, setRouteVias] = useState<RouteVia[]>([])
|
||||
const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false)
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||
// Recompute when the selected day's whole-day default mode changes (#1281) —
|
||||
@@ -45,7 +48,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
// Read directly from store (not a render-phase ref) so callers after optimistic
|
||||
// updates or non-optimistic deletes always see the latest assignments.
|
||||
const currentAssignments = useTripStore.getState().assignments || {}
|
||||
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const da = (currentAssignments[String(dayId)] || []).filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type)).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const allReservations = useTripStore.getState().reservations || []
|
||||
const allDays = useTripStore.getState().days || []
|
||||
const dayOrder = (id: number | null | undefined): number | null => {
|
||||
@@ -275,7 +278,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
||||
if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) }
|
||||
}
|
||||
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode])
|
||||
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops])
|
||||
|
||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||
@@ -299,7 +302,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode])
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops])
|
||||
|
||||
return { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
|
||||
setRoadtripStopKind,
|
||||
setRoadtripStopFill,
|
||||
saveRoadtripLimit,
|
||||
saveRoadtripLimit, roadtripSettingsLoading, storedAssignments,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, resetDayBoundaries,
|
||||
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, dayBoundaryControls,
|
||||
stayDraft, setStayDraft, setRoadtripStay, roadtripEndDay, roadtripStay,
|
||||
@@ -572,7 +572,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
days={days}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
assignments={storedAssignments}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
@@ -687,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} onResetDayBoundaries={resetDayBoundaries} />
|
||||
<RoadtripLimitsCard loading={roadtripSettingsLoading} onSave={saveRoadtripLimit} onResetDayBoundaries={resetDayBoundaries} />
|
||||
</div>
|
||||
</LazyPanel>
|
||||
) : (
|
||||
@@ -869,7 +869,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-074
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
@@ -859,15 +860,15 @@ describe('useTripPlanner road trip: one stop at a time', () => {
|
||||
|
||||
it('FE-TP-ROAD-033: a driving limit goes straight to the settings, and a failure is said', async () => {
|
||||
oneStop()
|
||||
const updateSettings = vi.fn(async () => {})
|
||||
useSettingsStore.setState({ updateSettings } as never)
|
||||
const updateSettings = vi.mocked(roadtripPreferencesRepo.update)
|
||||
updateSettings.mockResolvedValue({})
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_leg_minutes', 180) })
|
||||
expect(updateSettings).toHaveBeenCalledWith({ roadtrip_max_leg_minutes: 180 })
|
||||
await act(async () => { await result.current.saveRoadtripLimit?.('roadtrip_leg_minutes', 180) })
|
||||
expect(updateSettings).toHaveBeenCalledWith(42, { roadtrip_leg_minutes: 180 })
|
||||
|
||||
updateSettings.mockRejectedValue(new Error('nope'))
|
||||
await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_leg_minutes', 90) })
|
||||
await act(async () => { await result.current.saveRoadtripLimit?.('roadtrip_leg_minutes', 90) })
|
||||
expect(toasts.some(t => t.type === 'error')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1492,6 +1493,7 @@ describe('useTripPlanner road trip: somewhere to fill up', () => {
|
||||
stops: [drawn(1201, 53, 10, 5, 1), drawn(1202, 52, 10, 5, 2), drawn(1203, 51, 10, 6, 0)],
|
||||
geometry: [[53, 10], [52, 10], [51, 10]],
|
||||
drivingGeometry: [[53, 10], [52, 10], [51, 10]],
|
||||
legs: [{ mode: 'driving', distance: 111000 }, { mode: 'driving', distance: 111000 }],
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -1599,3 +1601,36 @@ describe('useTripPlanner road trip: somewhere to fill up', () => {
|
||||
expect(result.current.stopDraft).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||
useLoadRoadtripSettings: () => ({ ready: true, failed: false }),
|
||||
}))
|
||||
vi.mock('../../repo/roadtripPreferencesRepo', () => ({ roadtripPreferencesRepo: { update: vi.fn(async () => ({})) } }))
|
||||
|
||||
it('keeps exclusive service stops in Roadtrip, hides them in Days and restores them without duplicates', async () => {
|
||||
const normal = buildPlace({ id: 101, name: 'Berlin' })
|
||||
const charging = buildPlace({ id: 102, name: 'Charger', stop_type: 'charging' })
|
||||
const visits = [buildAssignment({ id: 11, day_id: 5, place: normal }), buildAssignment({ id: 12, day_id: 5, place: charging })]
|
||||
seedTrip({ places: [normal, charging], days: [buildDay({ id: 5 })], assignments: { '5': visits } })
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_service_stops_in_days: false } }))
|
||||
const { result } = await renderRoadtrip()
|
||||
expect(result.current.assignments['5']).toHaveLength(2)
|
||||
act(() => result.current.toggleRoadtripMode())
|
||||
expect(result.current.assignments['5'].map(a => a.id)).toEqual([11])
|
||||
expect(result.current.places.map(p => p.id)).toEqual([101])
|
||||
expect(useTripStore.getState().assignments['5']).toHaveLength(2)
|
||||
act(() => useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_service_stops_in_days: true } })))
|
||||
expect(result.current.assignments['5']).toHaveLength(2)
|
||||
expect(result.current.places).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('preserves exclusive service stops when reordering the visible Days stops', async () => {
|
||||
const visits = [stopAt(11, 5, 0), stopAt(12, 5, 1, { place: buildPlace({ stop_type: 'charging' }) }), stopAt(13, 5, 2)]
|
||||
seedTrip({ days: [buildDay({ id: 5 })], assignments: { '5': visits } })
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_service_stops_in_days: false } }))
|
||||
const { result } = await renderRoadtrip()
|
||||
act(() => result.current.toggleRoadtripMode())
|
||||
await act(async () => result.current.handleReorder(5, [13, 11]))
|
||||
expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 5, [13, 12, 11])
|
||||
})
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useLoadRoadtripSettings, useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
|
||||
import { publishRoadtripPreferences } from '../../store/roadtripPreferencesStore'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -41,7 +44,7 @@ import {
|
||||
reanchorAfterInsert,
|
||||
reanchorAfterRemove,
|
||||
reanchorByStopOrder,
|
||||
reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel'
|
||||
isServiceStopType, refuelStopTypeFor, reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel'
|
||||
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
@@ -49,7 +52,7 @@ import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import { useIsTouch } from '../../hooks/useIsTouch'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation, Settings } from '../../types'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
||||
@@ -82,15 +85,16 @@ export function useTripPlanner() {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { settings, updateSettings } = useSettingsStore()
|
||||
const { settings } = useSettingsStore()
|
||||
const roadtripSettings = useRoadtripSettings(s => s, tripId)
|
||||
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
|
||||
const allPlugins = usePluginStore(s => s.plugins)
|
||||
const pluginsLoaded = usePluginStore(s => s.loaded)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const days = useTripStore(s => s.days)
|
||||
const places = useTripStore(s => s.places)
|
||||
const assignments = useTripStore(s => s.assignments)
|
||||
const allPlaces = useTripStore(s => s.places)
|
||||
const storedAssignments = useTripStore(s => s.assignments)
|
||||
const packingItems = useTripStore(s => s.packingItems)
|
||||
const todoItems = useTripStore(s => s.todoItems)
|
||||
const categories = useTripStore(s => s.categories)
|
||||
@@ -121,6 +125,10 @@ export function useTripPlanner() {
|
||||
// someone planning a road trip stays in it across reloads without it leaking into
|
||||
// their next, non-driving trip.
|
||||
const [roadtripMode, setRoadtripMode] = useState<boolean>(() => sessionStorage.getItem(`trip-roadtrip-${tripId}`) === '1')
|
||||
const assignments = useMemo(() => roadtripMode || roadtripSettings.roadtrip_service_stops_in_days !== false ? storedAssignments : Object.fromEntries(
|
||||
Object.entries(storedAssignments).map(([dayId, visits]) => [dayId, visits.filter(visit => !isServiceStopType(visit.place?.stop_type))]),
|
||||
), [roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, storedAssignments])
|
||||
const places = useMemo(() => roadtripMode || roadtripSettings.roadtrip_service_stops_in_days !== false ? allPlaces : allPlaces.filter(place => !isServiceStopType(place.stop_type)), [roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, allPlaces])
|
||||
const toggleRoadtripMode = useCallback(() => {
|
||||
setRoadtripMode(prev => {
|
||||
const next = !prev
|
||||
@@ -544,7 +552,11 @@ 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 roadtripPreferencesState = useLoadRoadtripSettings(tripId, !!enabledAddons.roadtrip)
|
||||
useEffect(() => {
|
||||
if (roadtripPreferencesState.failed) toast.error(t('common.error'))
|
||||
}, [roadtripPreferencesState.failed, toast, t])
|
||||
const dailyTimesActive = !!dayWindow(roadtripSettings.roadtrip_day_start, roadtripSettings.roadtrip_day_end)
|
||||
const dayBoundaries = useDayBoundaries(tripId, roadtripActive && dailyTimesActive, assignments)
|
||||
useEffect(() => { if (dayBoundaries.stale) toast.error(t('trip.toast.loadError')) }, [dayBoundaries.stale, toast, t])
|
||||
const resetDayBoundaries = dayBoundaries.editable && dayBoundaries.boundaries.length && can('day_edit', trip) ? async () => {
|
||||
@@ -556,7 +568,7 @@ export function useTripPlanner() {
|
||||
const refuel = useRefuelSearch()
|
||||
const roadtripRoutes = useRoadtripRoutes(
|
||||
tripId,
|
||||
roadtripActive ? days : EMPTY_DAYS,
|
||||
roadtripActive && roadtripPreferencesState.ready ? days : EMPTY_DAYS,
|
||||
assignments,
|
||||
routeProfile,
|
||||
roadtripVias.byDay,
|
||||
@@ -858,22 +870,26 @@ export function useTripPlanner() {
|
||||
|
||||
const roadtripLineColors = useMemo(
|
||||
() => {
|
||||
if (!settings.roadtrip_day_colors) return undefined
|
||||
if (!roadtripSettings.roadtrip_day_colors) return undefined
|
||||
const hidden = new Set(
|
||||
roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber),
|
||||
)
|
||||
return roadtripRoutes.lineDays.filter(n => !hidden.has(n)).map(n => dayColor(n))
|
||||
},
|
||||
[settings.roadtrip_day_colors, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays],
|
||||
[roadtripSettings.roadtrip_day_colors, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays],
|
||||
)
|
||||
|
||||
const saveRoadtripLimit = useCallback(async (key: string, value: number | string | boolean) => {
|
||||
try {
|
||||
await updateSettings({ [key]: value } as Partial<Settings>)
|
||||
if (!can('day_edit', trip) || !roadtripPreferencesState.ready) return
|
||||
const userId = useAuthStore.getState().user?.id
|
||||
if (!userId) return
|
||||
const preferences = await roadtripPreferencesRepo.update(tripId, { [key]: value })
|
||||
publishRoadtripPreferences(userId, tripId, preferences)
|
||||
} catch {
|
||||
toast.error(t('places.saveError'))
|
||||
}
|
||||
}, [updateSettings, toast, t])
|
||||
}, [tripId, trip, can, roadtripPreferencesState.ready, toast, t])
|
||||
|
||||
const saveStopDraft = useCallback(async ({ stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number }) => {
|
||||
if (!stopDraft) return
|
||||
@@ -1421,14 +1437,24 @@ export function useTripPlanner() {
|
||||
if (!day) return
|
||||
const line = (day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng }))
|
||||
if (line.length < 2) return
|
||||
const vehicle = roadtripSettings.roadtrip_vehicle
|
||||
const refuelTypes = refuelStopTypeFor(vehicle === 'electric' || vehicle === 'combustion' ? vehicle : null)
|
||||
let fromAlongKm = 0
|
||||
let drivenKm = 0
|
||||
for (let i = 0; i <= dry.legIndex; i++) {
|
||||
if (refuelTypes.includes(day.stops[i]?.stopType as 'fuel' | 'charging')) fromAlongKm = drivenKm
|
||||
const leg = day.legs[i]
|
||||
if (leg?.mode === 'driving') drivenKm += (leg.distance ?? 0) / 1000
|
||||
}
|
||||
void refuel.ask(
|
||||
`${dayId}:${dry.legIndex}`,
|
||||
{ lat: dry.lat, lng: dry.lng },
|
||||
line,
|
||||
dry.drivenMeters / 1000,
|
||||
day.stops.map(stop => ({ lat: stop.lat, lng: stop.lng })),
|
||||
fromAlongKm,
|
||||
)
|
||||
}, [roadtripRoutes.days, refuel])
|
||||
}, [roadtripRoutes.days, refuel, roadtripSettings.roadtrip_vehicle])
|
||||
|
||||
/**
|
||||
* Accepting one hands it to the same popup a corridor hit goes through.
|
||||
@@ -1446,7 +1472,13 @@ export function useTripPlanner() {
|
||||
// Before the stop the tank would have run out on, which is the leg the dry point
|
||||
// names. A station reached after the day's last stop is tomorrow's problem, and
|
||||
// clamping it onto the final leg would re-route the arrival through it.
|
||||
const at = Math.min(dry.legIndex + 1, day.stops.length - 1)
|
||||
let drivenKm = 0
|
||||
const stationLeg = day.legs.findIndex(leg => {
|
||||
if (leg?.mode !== 'driving') return false
|
||||
drivenKm += (leg.distance ?? 0) / 1000
|
||||
return poi.alongKm <= drivenKm
|
||||
})
|
||||
const at = Math.min((stationLeg >= 0 ? stationLeg : dry.legIndex) + 1, day.stops.length - 1)
|
||||
// That index counts along the CARD, and after a night drive a card is not one stored
|
||||
// day: its first stops belong to yesterday. The new stop goes in front of the one it
|
||||
// was measured against, so it is that stop's own day and position that place it —
|
||||
@@ -1767,11 +1799,14 @@ export function useTripPlanner() {
|
||||
// pinned behind instead of being shifted arithmetically. Without this the
|
||||
// day's detours stayed on their old numbers and the drive quietly took a
|
||||
// different road, persisted and visible to every collaborator.
|
||||
const visible = new Set(orderedIds)
|
||||
let nextVisible = 0
|
||||
const completeOrder = prevIds.map(id => visible.has(id) ? orderedIds[nextVisible++] : id)
|
||||
const stopIdsBefore = roadtripStopsOf(dayId).map(a => a.id)
|
||||
const stopIdsAfter = orderedIds.filter(id => stopIdsBefore.includes(id))
|
||||
const stopIdsAfter = completeOrder.filter(id => stopIdsBefore.includes(id))
|
||||
const plan = reanchorByStopOrder(roadtripVias.byDay[dayId] ?? [], stopIdsBefore, stopIdsAfter)
|
||||
try {
|
||||
tripActions.reorderAssignments(tripId, dayId, orderedIds)
|
||||
tripActions.reorderAssignments(tripId, dayId, completeOrder)
|
||||
.then(async () => {
|
||||
if (plan.vias.length || plan.remove.length) await roadtripVias.reanchor(dayId, plan)
|
||||
const capturedDayId = dayId
|
||||
@@ -2069,7 +2104,7 @@ export function useTripPlanner() {
|
||||
|
||||
return {
|
||||
tripId, navigate, toast, t, language, settings, placesPhotosEnabled,
|
||||
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
|
||||
trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
@@ -2088,7 +2123,8 @@ export function useTripPlanner() {
|
||||
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
|
||||
setRoadtripStopKind,
|
||||
setRoadtripStopFill,
|
||||
saveRoadtripLimit,
|
||||
roadtripSettingsLoading: !roadtripPreferencesState.ready && !roadtripPreferencesState.failed,
|
||||
saveRoadtripLimit: roadtripPreferencesState.ready && can('day_edit', trip) ? saveRoadtripLimit : undefined,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, dayBoundaryControls, resetDayBoundaries,
|
||||
refuel, askRefuel, acceptRefuel,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'fake-indexeddb/auto'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { roadtripPreferencesRepo } from './roadtripPreferencesRepo'
|
||||
import { offlineDb, clearAll } from '../db/offlineDb'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { mutationQueue } from '../sync/mutationQueue'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => true) }))
|
||||
vi.mock('../sync/authGate', () => ({ isAuthed: () => true }))
|
||||
vi.mock('../api/client', () => ({ apiClient: { get: vi.fn(), put: vi.fn(), request: vi.fn() } }))
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll()
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true)
|
||||
await offlineDb.roadtripPreferences.put({ tripId: 9, preferences: { roadtrip_day_start: '08:00', roadtrip_day_end: '18:00', roadtrip_range_km: 120 } })
|
||||
})
|
||||
|
||||
describe('trip driving preferences offline cache', () => {
|
||||
it('overlays queued changes and replays into the same trip', async () => {
|
||||
await roadtripPreferencesRepo.update(9, { roadtrip_range_km: 200 })
|
||||
expect((await roadtripPreferencesRepo.read(9)).roadtrip_range_km).toBe(200)
|
||||
expect((await offlineDb.roadtripPreferences.get(9))?.preferences.roadtrip_range_km).toBe(120)
|
||||
expect(await offlineDb.mutationQueue.toArray()).toEqual([expect.objectContaining({ tripId: 9, url: '/trips/9/roadtrip/preferences', method: 'PUT', body: { roadtrip_range_km: 200 } })])
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
vi.mocked(apiClient.request).mockResolvedValue({ data: { tripId: 9, preferences: { roadtrip_range_km: 200 } } })
|
||||
await mutationQueue.flush()
|
||||
expect(await offlineDb.mutationQueue.count()).toBe(0)
|
||||
expect((await offlineDb.roadtripPreferences.get(9))?.preferences.roadtrip_range_km).toBe(200)
|
||||
expect(await offlineDb.roadtripPreferences.get(10)).toBeUndefined()
|
||||
})
|
||||
it('refuses an uncached trip and an invalid merged day window', async () => {
|
||||
await expect(roadtripPreferencesRepo.read(10)).rejects.toThrow('not available offline')
|
||||
await expect(roadtripPreferencesRepo.update(9, { roadtrip_day_end: '07:00' })).rejects.toThrow('Day end')
|
||||
expect(await offlineDb.mutationQueue.count()).toBe(0)
|
||||
})
|
||||
it('keeps cached preferences on a rejected online update', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
vi.mocked(apiClient.put).mockRejectedValue(new Error('Denied'))
|
||||
await expect(roadtripPreferencesRepo.update(9, { roadtrip_range_km: 999 })).rejects.toThrow('Denied')
|
||||
expect((await offlineDb.roadtripPreferences.get(9))?.preferences.roadtrip_range_km).toBe(120)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { roadtripPreferencesResponseSchema, roadtripPreferencesUpdateSchema, type RoadtripPreferences } from '@trek/shared'
|
||||
import { apiClient } from '../api/client'
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { generateUUID, mutationQueue } from '../sync/mutationQueue'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
|
||||
export async function cachedRoadtripPreferences(tripId: number, excludeMutation?: string): Promise<RoadtripPreferences> {
|
||||
const cached = await offlineDb.roadtripPreferences.get(tripId)
|
||||
if (!cached) throw new Error('Driving settings are not available offline.')
|
||||
const pending = await offlineDb.mutationQueue.where('tripId').equals(tripId).sortBy('createdAt')
|
||||
return pending.reduce((preferences, mutation) => {
|
||||
if (mutation.resource !== 'roadtripPreferences' || mutation.id === excludeMutation || !['pending', 'syncing'].includes(mutation.status)) return preferences
|
||||
return { ...preferences, ...roadtripPreferencesUpdateSchema.parse(mutation.body) }
|
||||
}, cached.preferences)
|
||||
}
|
||||
|
||||
const writes = new Map<number, Promise<RoadtripPreferences>>()
|
||||
|
||||
export const roadtripPreferencesRepo = {
|
||||
async read(tripId: number): Promise<RoadtripPreferences> {
|
||||
const cacheName = offlineDb.name
|
||||
return onlineThenCache(async () => {
|
||||
const fetched = await apiClient.get(`/trips/${tripId}/roadtrip/preferences`)
|
||||
if (offlineDb.name !== cacheName) throw new Error('Account changed.')
|
||||
const saved = roadtripPreferencesResponseSchema.parse(fetched.data)
|
||||
await offlineDb.roadtripPreferences.put(saved)
|
||||
return cachedRoadtripPreferences(tripId)
|
||||
}, () => cachedRoadtripPreferences(tripId))
|
||||
},
|
||||
|
||||
update(tripId: number, patch: RoadtripPreferences): Promise<RoadtripPreferences> {
|
||||
const cacheName = offlineDb.name
|
||||
const save = (writes.get(tripId) ?? Promise.resolve({})).catch(() => ({})).then(async () => {
|
||||
if (offlineDb.name !== cacheName) throw new Error('Account changed.')
|
||||
const validated = roadtripPreferencesUpdateSchema.parse(patch)
|
||||
if (!isEffectivelyOffline()) {
|
||||
const saved = await apiClient.put(`/trips/${tripId}/roadtrip/preferences`, validated)
|
||||
if (offlineDb.name !== cacheName) throw new Error('Account changed.')
|
||||
await offlineDb.roadtripPreferences.put(roadtripPreferencesResponseSchema.parse(saved.data))
|
||||
} else {
|
||||
const next = { ...await cachedRoadtripPreferences(tripId), ...validated }
|
||||
if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) throw new Error('Day end must be later than day start.')
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(), tripId, method: 'PUT', url: `/trips/${tripId}/roadtrip/preferences`,
|
||||
body: validated, resource: 'roadtripPreferences', entityId: tripId,
|
||||
})
|
||||
}
|
||||
return cachedRoadtripPreferences(tripId)
|
||||
})
|
||||
writes.set(tripId, save)
|
||||
void save.finally(() => { if (writes.get(tripId) === save) writes.delete(tripId) }).catch(() => {})
|
||||
return save
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { create } from 'zustand'
|
||||
import type { RoadtripPreferences } from '@trek/shared'
|
||||
|
||||
export const EMPTY_ROADTRIP_PREFERENCES: RoadtripPreferences = {}
|
||||
|
||||
export const useRoadtripPreferencesStore = create<{
|
||||
byTrip: Record<string, RoadtripPreferences>
|
||||
}>(() => ({ byTrip: {} }))
|
||||
|
||||
export function publishRoadtripPreferences(userId: number, tripId: number, preferences: RoadtripPreferences) {
|
||||
useRoadtripPreferencesStore.setState(state => ({
|
||||
byTrip: { ...state.byTrip, [`${userId}:${tripId}`]: preferences },
|
||||
}))
|
||||
}
|
||||
@@ -13,7 +13,7 @@ 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 { roadtripPreferencesResponseSchema, assignmentSchema } from '@trek/shared'
|
||||
import { cacheAssignment } from '../db/cacheAssignment'
|
||||
|
||||
// Map Dexie table names used in `resource` field → actual Dexie tables.
|
||||
@@ -195,6 +195,10 @@ export const mutationQueue = {
|
||||
headers,
|
||||
})
|
||||
|
||||
if (mutation.resource === 'roadtripPreferences') {
|
||||
const saved = roadtripPreferencesResponseSchema.parse(response.data)
|
||||
await offlineDb.roadtripPreferences.put(saved)
|
||||
}
|
||||
// Apply canonical server response to Dexie
|
||||
if (mutation.resource === 'assignments') {
|
||||
await cacheAssignment(assignmentSchema.parse(response.data.assignment))
|
||||
|
||||
@@ -357,3 +357,5 @@ describe('tripSyncManager.syncAll — background tile pass', () => {
|
||||
expect(prefetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../repo/roadtripPreferencesRepo', () => ({ roadtripPreferencesRepo: { read: vi.fn(async () => ({})) } }))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { roadtripPreferencesRepo } from '../repo/roadtripPreferencesRepo'
|
||||
/**
|
||||
* Trip sync manager — seeds Dexie with trip data for offline use.
|
||||
*
|
||||
@@ -114,6 +115,9 @@ function isVideo(file: TripFile): boolean {
|
||||
async function syncTrip(tripId: number): Promise<void> {
|
||||
const bundle = await tripsApi.bundle(tripId) as TripBundle
|
||||
|
||||
await roadtripPreferencesRepo.read(tripId).catch((error: unknown) => {
|
||||
if ((error as { response?: { status?: number } }).response?.status !== 404) throw error
|
||||
})
|
||||
await upsertTrip(bundle.trip)
|
||||
await upsertDays(bundle.days)
|
||||
await upsertPlaces(bundle.places)
|
||||
|
||||
@@ -435,3 +435,5 @@ describe('tripSyncManager.syncAll — logout while syncing', () => {
|
||||
expect(await offlineDb.trips.get(secondId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock('../../../src/repo/roadtripPreferencesRepo', () => ({ roadtripPreferencesRepo: { read: vi.fn(async () => ({})) } }))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared';
|
||||
import { readEnv } from '../app-config';
|
||||
import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto';
|
||||
|
||||
@@ -4550,6 +4551,11 @@ function runMigrations(db: Database.Database): void {
|
||||
PRIMARY KEY (trip_id, day_number)
|
||||
)`);
|
||||
},
|
||||
() => {
|
||||
db.exec('CREATE TABLE IF NOT EXISTS roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (trip_id, key))');
|
||||
const inherit = db.prepare('INSERT OR IGNORE INTO roadtrip_preferences (trip_id, key, value) SELECT t.id, s.key, s.value FROM trips t JOIN settings s ON s.user_id = t.user_id WHERE s.key = ? AND s.value IS NOT NULL');
|
||||
for (const key of ROADTRIP_PREFERENCE_KEYS) inherit.run(key);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -74,7 +74,7 @@ export class RoadtripPlanService {
|
||||
return {
|
||||
days,
|
||||
visits,
|
||||
settings: this.preferences.read(userId),
|
||||
settings: this.preferences.read(tripId),
|
||||
profiles: this.router.profiles(),
|
||||
vias: this.roadtrip.listForTrip(tripId),
|
||||
tracks: this.roadtrip.tracksForTrip(tripId),
|
||||
|
||||
@@ -27,7 +27,7 @@ export class RoadtripPlanningMcp {
|
||||
@Tool({
|
||||
name: 'get_roadtrip_context',
|
||||
description:
|
||||
'Read the saved roadtrip days, visits, pinned times, stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes your personal driving preferences. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings affect your account; visits and manual boundaries affect the shared trip.',
|
||||
'Read the saved roadtrip days, visits, pinned times, stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
|
||||
inputSchema: { tripId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
|
||||
import { Body, Controller, Get, Param, Put, UseGuards } from '@nestjs/common';
|
||||
import { roadtripPreferencesUpdateSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
class PreferencesDto extends createZodDto(roadtripPreferencesUpdateSchema) {}
|
||||
|
||||
@Controller('api/trips/:tripId/roadtrip/preferences')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
|
||||
export class RoadtripPreferencesController {
|
||||
constructor(private readonly preferences: RoadtripPreferencesService) {}
|
||||
|
||||
@Get()
|
||||
read(@Param('tripId') tripId: string) {
|
||||
return { tripId: Number(tripId), preferences: this.preferences.read(Number(tripId)) };
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequirePermission('day_edit')
|
||||
update(@Param('tripId') tripId: string, @Body() patch: PreferencesDto) {
|
||||
return { tripId: Number(tripId), preferences: this.preferences.update(Number(tripId), patch) };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import {
|
||||
McpController,
|
||||
Tool,
|
||||
@@ -11,9 +12,13 @@ import {
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
|
||||
import { roadtripPreferencesUpdateSchema, type RoadtripPreferences } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const when = addonGate(ADDON_IDS.ROADTRIP);
|
||||
|
||||
@McpController()
|
||||
@@ -22,32 +27,37 @@ export class RoadtripPreferencesMcp {
|
||||
private readonly preferences: RoadtripPreferencesService,
|
||||
private readonly auth: AuthService,
|
||||
readonly addons: AddonsService,
|
||||
private readonly db: DatabaseService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'get_roadtrip_settings',
|
||||
description:
|
||||
'Read your personal driving preferences, including daily start and end times, day-ending behavior, vehicle specifications, range, fill percentage, driving limits, avoided road classes and route display options. These preferences apply to all your trips, not to other travellers. Missing numbers mean no limit, missing daily times disable automatic scheduling, missing vehicle means unspecified, and missing end mode means route. Range and consumption always use kilometres, litres and kWh. Instance URLs and credentials are not exposed.',
|
||||
inputSchema: {},
|
||||
'Read the shared driving preferences for one trip, including daily start and end times, day-ending behavior, vehicle specifications, range, fill percentage, driving limits, avoided road classes and route display options. Every member of this trip sees the same preferences. Missing numbers mean no limit, missing daily times disable automatic scheduling, missing vehicle means unspecified, and missing end mode means route. Range and consumption always use kilometres, litres and kWh. Instance URLs and credentials are not exposed.',
|
||||
inputSchema: { tripId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'settings', mode: 'read' },
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
when,
|
||||
})
|
||||
async read(_input: Record<string, never>, ctx: McpContext) {
|
||||
return ok({ settings: this.preferences.read(ctx.userId), scope: 'current_user' });
|
||||
async read({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
return ok({ tripId, settings: this.preferences.read(tripId), scope: 'trip' });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'update_roadtrip_settings',
|
||||
description:
|
||||
'Change personal driving preferences for all your trips. Read get_roadtrip_settings first for relative changes. Daily times use HH:mm; clear either with an empty string to disable automatic daily scheduling. Fixed visit times retain priority. Numeric zero clears a limit. Vehicle specifications take precedence over manual range when complete; clear the relevant specifications to use a manual range. Avoid classes are a comma-separated selection of toll,motorway,ferry. Unspecified fields remain unchanged. End mode route pauses at the cutoff; stop ends at the last reachable visit. Per-visit end_day and dragged boundaries are edited with set_assignment_end_day and set_day_boundary. This never changes instance routing URLs or credentials.',
|
||||
inputSchema: { settings: roadtripPreferencesUpdateSchema },
|
||||
'Change the shared driving preferences for this trip. Changes apply to all its travellers and require permission to edit days. Read get_roadtrip_settings first for relative changes. Daily times use HH:mm; clear either with an empty string to disable automatic daily scheduling. Fixed visit times retain priority. Numeric zero clears a limit. Vehicle specifications take precedence over manual range when complete; clear the relevant specifications to use a manual range. Avoid classes are a comma-separated selection of toll,motorway,ferry. Unspecified fields remain unchanged. End mode route pauses at the cutoff; stop ends at the last reachable visit. Per-visit end_day and dragged boundaries are edited with set_assignment_end_day and set_day_boundary. This never changes instance routing URLs or credentials.',
|
||||
inputSchema: { tripId: z.number().int().positive(), settings: roadtripPreferencesUpdateSchema },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'settings', mode: 'write' },
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
when,
|
||||
})
|
||||
async update({ settings }: { settings: RoadtripPreferences }, ctx: McpContext) {
|
||||
async update({ tripId, settings }: { tripId: number; settings: RoadtripPreferences }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
return ok({ settings: this.preferences.update(ctx.userId, settings), scope: 'current_user' });
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
return ok({ tripId, settings: this.preferences.update(tripId, settings), scope: 'trip' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ROADTRIP_PREFERENCE_KEYS,
|
||||
@@ -11,12 +11,22 @@ import {
|
||||
@Injectable()
|
||||
export class RoadtripPreferencesService {
|
||||
constructor(
|
||||
private readonly settings: SettingsService,
|
||||
private readonly db: DatabaseService,
|
||||
private readonly realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
read(userId: number): RoadtripPreferences {
|
||||
const settings = this.settings.getUserSettings(userId);
|
||||
read(tripId: number): RoadtripPreferences {
|
||||
const settings: Record<string, unknown> = {};
|
||||
for (const row of this.db.all<{ key: string; value: string }>(
|
||||
'SELECT key, value FROM roadtrip_preferences WHERE trip_id = ?',
|
||||
tripId,
|
||||
)) {
|
||||
try {
|
||||
settings[row.key] = JSON.parse(row.value);
|
||||
} catch {
|
||||
settings[row.key] = row.value;
|
||||
}
|
||||
}
|
||||
const preferences: Record<string, unknown> = {};
|
||||
for (const key of ROADTRIP_PREFERENCE_KEYS) {
|
||||
const parsed = roadtripPreferencesSchema.shape[key].safeParse(settings[key]);
|
||||
@@ -25,15 +35,24 @@ export class RoadtripPreferencesService {
|
||||
return roadtripPreferencesSchema.parse(preferences);
|
||||
}
|
||||
|
||||
update(userId: number, patch: RoadtripPreferences): RoadtripPreferences {
|
||||
update(tripId: number, patch: RoadtripPreferences): RoadtripPreferences {
|
||||
const validated = roadtripPreferencesUpdateSchema.parse(patch);
|
||||
const next = { ...this.read(userId), ...validated };
|
||||
if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) {
|
||||
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
|
||||
}
|
||||
this.settings.bulkUpsertSettings(userId, validated);
|
||||
const saved = this.read(userId);
|
||||
this.realtime.broadcastToUser(userId, { type: 'roadtripPreferences:changed', preferences: saved });
|
||||
const saved = this.db.transaction(() => {
|
||||
const next = { ...this.read(tripId), ...validated };
|
||||
if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) {
|
||||
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
|
||||
}
|
||||
for (const [key, value] of Object.entries(validated)) {
|
||||
this.db.run(
|
||||
'INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES (?, ?, ?) ON CONFLICT(trip_id, key) DO UPDATE SET value = excluded.value',
|
||||
tripId,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
return this.read(tripId);
|
||||
});
|
||||
this.realtime.broadcast(String(tripId), 'roadtripPreferences:changed', { preferences: saved });
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,51 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RoadtripController } from './roadtrip.controller';
|
||||
import { RoadtripService } from './roadtrip.service';
|
||||
import { RoadtripMcp } from './roadtrip.mcp';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { MapsModule } from '../maps/maps.module';
|
||||
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PluginsRuntimeModule } from '../plugins/plugins-runtime.module';
|
||||
import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { DayBoundariesController } from './day-boundaries.controller';
|
||||
import { DayBoundariesService } from './day-boundaries.service';
|
||||
import { DayBoundariesMcp } from './day-boundaries.mcp';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
|
||||
import { RoadtripPreferencesMcp } from './roadtrip-preferences.mcp';
|
||||
import { RoadtripRouterService } from './roadtrip-router.service';
|
||||
import { DayBoundariesController } from './day-boundaries.controller';
|
||||
import { DayBoundariesMcp } from './day-boundaries.mcp';
|
||||
import { DayBoundariesService } from './day-boundaries.service';
|
||||
import { RoadtripPlanService } from './roadtrip-plan.service';
|
||||
import { RoadtripPlanningMcp } from './roadtrip-planning.mcp';
|
||||
import { PluginsRuntimeModule } from '../plugins/plugins-runtime.module';
|
||||
import { MapsModule } from '../maps/maps.module';
|
||||
import { RoadtripPreferencesController } from './roadtrip-preferences.controller';
|
||||
import { RoadtripPreferencesMcp } from './roadtrip-preferences.mcp';
|
||||
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
|
||||
import { RoadtripRouterService } from './roadtrip-router.service';
|
||||
import { RoadtripController } from './roadtrip.controller';
|
||||
import { RoadtripMcp } from './roadtrip.mcp';
|
||||
import { RoadtripService } from './roadtrip.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/** 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, SettingsModule, PluginsRuntimeModule, MapsModule],
|
||||
controllers: [RoadtripController, DayBoundariesController],
|
||||
providers: [RoadtripService, RoadtripMcp, DayBoundariesService, DayBoundariesMcp, RoadtripPreferencesService, RoadtripPreferencesMcp, RoadtripRouterService, RoadtripPlanService, RoadtripPlanningMcp],
|
||||
imports: [
|
||||
McpSharedModule,
|
||||
PermissionsModule,
|
||||
AuthModule,
|
||||
AddonsModule,
|
||||
RealtimeModule,
|
||||
SettingsModule,
|
||||
PluginsRuntimeModule,
|
||||
MapsModule,
|
||||
],
|
||||
controllers: [RoadtripPreferencesController, RoadtripController, DayBoundariesController],
|
||||
providers: [
|
||||
RoadtripService,
|
||||
RoadtripMcp,
|
||||
DayBoundariesService,
|
||||
DayBoundariesMcp,
|
||||
RoadtripPreferencesService,
|
||||
RoadtripPreferencesMcp,
|
||||
RoadtripRouterService,
|
||||
RoadtripPlanService,
|
||||
RoadtripPlanningMcp,
|
||||
],
|
||||
exports: [RoadtripService],
|
||||
})
|
||||
export class RoadtripModule {}
|
||||
|
||||
@@ -624,6 +624,7 @@ export class TripsService {
|
||||
}
|
||||
}
|
||||
|
||||
this.db.prepare('INSERT INTO roadtrip_preferences (trip_id, key, value) SELECT ?, key, value FROM roadtrip_preferences WHERE trip_id = ?').run(newTripId, sourceTripId);
|
||||
const oldBoundaries = this.db.prepare('SELECT * FROM roadtrip_day_boundaries WHERE trip_id = ?').all(sourceTripId) as {
|
||||
day_number: number; from_assignment_id: number; to_assignment_id: number | null; fraction: number;
|
||||
}[];
|
||||
|
||||
@@ -11,14 +11,19 @@
|
||||
* The addon ships disabled, so "off" is the state an existing install upgrades
|
||||
* into. That is what makes it worth a test rather than a comment.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -30,6 +35,9 @@ const { db } = vi.hoisted(() => {
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
|
||||
avatar TEXT);`);
|
||||
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, end_date TEXT);');
|
||||
tmp.exec(
|
||||
'CREATE TABLE roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(trip_id, key));',
|
||||
);
|
||||
tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);');
|
||||
tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
|
||||
day_number INTEGER, date TEXT, title TEXT, notes TEXT);`);
|
||||
@@ -52,11 +60,15 @@ const { db } = vi.hoisted(() => {
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db,
|
||||
canAccessTrip: (tripId: number | string, userId: number) =>
|
||||
db.prepare(`
|
||||
db
|
||||
.prepare(
|
||||
`
|
||||
SELECT t.id, t.user_id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId),
|
||||
`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: () => false,
|
||||
getPlaceWithTags: () => null,
|
||||
closeDb: () => {},
|
||||
@@ -64,16 +76,12 @@ vi.mock('../../src/db/database', () => ({
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
|
||||
const ADDON_ID = 'roadtrip';
|
||||
|
||||
function setAddon(enabled: boolean): void {
|
||||
db.prepare('INSERT INTO addons (id, enabled) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled')
|
||||
.run(ADDON_ID, enabled ? 1 : 0);
|
||||
db.prepare(
|
||||
'INSERT INTO addons (id, enabled) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled',
|
||||
).run(ADDON_ID, enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
|
||||
@@ -117,15 +125,71 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
|
||||
setAddon(true);
|
||||
});
|
||||
|
||||
afterAll(async () => { await app?.close(); });
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
const cookie = () => sessionCookie(1);
|
||||
|
||||
describe('shared trip driving preferences', () => {
|
||||
it('shares values with members and isolates other trips', async () => {
|
||||
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (5, 2)').run();
|
||||
try {
|
||||
await request(server)
|
||||
.put('/api/trips/5/roadtrip/preferences')
|
||||
.set('Cookie', cookie())
|
||||
.send({ roadtrip_range_km: 120, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' })
|
||||
.expect(200);
|
||||
const member = await request(server)
|
||||
.get('/api/trips/5/roadtrip/preferences')
|
||||
.set('Cookie', sessionCookie(2))
|
||||
.expect(200);
|
||||
expect(member.body.preferences.roadtrip_range_km).toBe(120);
|
||||
await request(server)
|
||||
.put('/api/trips/5/roadtrip/preferences')
|
||||
.set('Cookie', sessionCookie(2))
|
||||
.send({ roadtrip_range_km: 160 })
|
||||
.expect(200);
|
||||
const owner = await request(server)
|
||||
.get('/api/trips/5/roadtrip/preferences')
|
||||
.set('Cookie', cookie())
|
||||
.expect(200);
|
||||
expect(owner.body.preferences.roadtrip_range_km).toBe(160);
|
||||
const other = await request(server)
|
||||
.get('/api/trips/6/roadtrip/preferences')
|
||||
.set('Cookie', sessionCookie(2))
|
||||
.expect(200);
|
||||
expect(other.body.preferences).toEqual({});
|
||||
checkPermission.mockReturnValue(false);
|
||||
await request(server)
|
||||
.put('/api/trips/5/roadtrip/preferences')
|
||||
.set('Cookie', sessionCookie(2))
|
||||
.send({ roadtrip_range_km: 999 })
|
||||
.expect(403);
|
||||
expect(
|
||||
db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_range_km'").get(),
|
||||
).toEqual({ value: '160' });
|
||||
} finally {
|
||||
db.prepare('DELETE FROM trip_members WHERE trip_id = 5 AND user_id = 2').run();
|
||||
}
|
||||
});
|
||||
it('refuses strangers and invalid daily windows', async () => {
|
||||
await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', sessionCookie(2)).expect(404);
|
||||
await request(server)
|
||||
.put('/api/trips/5/roadtrip/preferences')
|
||||
.set('Cookie', cookie())
|
||||
.send({ roadtrip_day_start: '18:00', roadtrip_day_end: '08:00' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the addon gate', () => {
|
||||
// Six routes, all of them. `@RequireAddon` is metadata and inert without the
|
||||
// guard, and the guard is not global — so this block is the only thing that
|
||||
// fails if somebody drops AddonGuard from the chain again.
|
||||
const routes: [string, string, unknown?][] = [
|
||||
['get', '/api/trips/5/roadtrip/preferences'],
|
||||
['put', '/api/trips/5/roadtrip/preferences', { roadtrip_range_km: 120 }],
|
||||
['get', '/api/trips/5/roadtrip/vias'],
|
||||
['get', '/api/trips/5/roadtrip/days/3/vias'],
|
||||
['post', '/api/trips/5/roadtrip/days/3/vias', { after_order_index: 0, lat: 53, lng: 10 }],
|
||||
@@ -137,7 +201,8 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
|
||||
it('ROADTRIP-E2E-001: answers 404 on every route while the addon is off', async () => {
|
||||
setAddon(false);
|
||||
for (const [method, url, body] of routes) {
|
||||
const req = (request(server) as never as Record<string, (u: string) => request.Test>)[method](url)
|
||||
const req = (request(server) as never as Record<string, (u: string) => request.Test>)
|
||||
[method](url)
|
||||
.set('Cookie', cookie());
|
||||
const res = await (body ? req.send(body as object) : req);
|
||||
expect(res.status, `${method.toUpperCase()} ${url}`).toBe(404);
|
||||
@@ -224,8 +289,9 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
|
||||
expect(moved.status).toBe(200);
|
||||
expect(moved.body.via).toMatchObject({ lat: 54, lng: 11 });
|
||||
|
||||
expect((await request(server).delete(`/api/trips/5/roadtrip/days/3/vias/${id}`).set('Cookie', cookie())).status)
|
||||
.toBe(200);
|
||||
expect(
|
||||
(await request(server).delete(`/api/trips/5/roadtrip/days/3/vias/${id}`).set('Cookie', cookie())).status,
|
||||
).toBe(200);
|
||||
const after = await request(server).get('/api/trips/5/roadtrip/days/3/vias').set('Cookie', cookie());
|
||||
expect(after.body.vias).toEqual([]);
|
||||
});
|
||||
@@ -235,7 +301,10 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
|
||||
.post('/api/trips/5/roadtrip/days/3/vias/batch')
|
||||
.set('Cookie', cookie())
|
||||
.send({
|
||||
vias: [{ after_order_index: 0, lat: 53, lng: 10 }, { after_order_index: 0, lat: 53.5, lng: 10.5 }],
|
||||
vias: [
|
||||
{ after_order_index: 0, lat: 53, lng: 10 },
|
||||
{ after_order_index: 0, lat: 53.5, lng: 10.5 },
|
||||
],
|
||||
track: { place_id: 10, stray_km: 1.5 },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('trip driving preferences migration', () => {
|
||||
it('inherits owner values once, tolerates nulls and preserves trip overrides on replay', () => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
createTables(db);
|
||||
runMigrations(db);
|
||||
db.exec(
|
||||
"INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x'), (2, 'member', 'member@test', 'x')",
|
||||
);
|
||||
db.exec("INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'First'), (11, 2, 'Second')");
|
||||
db.exec(
|
||||
"INSERT INTO settings (user_id, key, value) VALUES (1, 'roadtrip_range_km', '120'), (2, 'roadtrip_range_km', '300'), (1, 'roadtrip_day_start', '\"08:00\"'), (1, 'roadtrip_day_end', NULL), (1, 'routing_base_url', '\"https://private.test\"')",
|
||||
);
|
||||
const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version;
|
||||
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
|
||||
runMigrations(db);
|
||||
expect(db.prepare('SELECT key, value FROM roadtrip_preferences WHERE trip_id = 10 ORDER BY key').all()).toEqual([
|
||||
{ key: 'roadtrip_day_start', value: '"08:00"' },
|
||||
{ key: 'roadtrip_range_km', value: '120' },
|
||||
]);
|
||||
expect(db.prepare('SELECT value FROM roadtrip_preferences WHERE trip_id = 11').get()).toEqual({ value: '300' });
|
||||
db.exec("UPDATE roadtrip_preferences SET value = '200' WHERE trip_id = 10 AND key = 'roadtrip_range_km'");
|
||||
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
|
||||
runMigrations(db);
|
||||
expect(
|
||||
db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 10 AND key = 'roadtrip_range_km'").get(),
|
||||
).toEqual({ value: '200' });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
const ctx = { userId: 5 } as McpContext;
|
||||
function setup() {
|
||||
let settings: Record<string, unknown> = {
|
||||
const settings: Record<string, unknown> = {
|
||||
roadtrip_day_start: '08:00',
|
||||
roadtrip_day_end: '10:00',
|
||||
roadtrip_vehicle: 'electric',
|
||||
@@ -24,12 +24,18 @@ function setup() {
|
||||
};
|
||||
const store = {
|
||||
getUserSettings: vi.fn(() => settings),
|
||||
bulkUpsertSettings: vi.fn((_id: number, patch: Record<string, unknown>) => {
|
||||
settings = { ...settings, ...patch };
|
||||
}),
|
||||
};
|
||||
const realtime = { broadcastToUser: vi.fn() };
|
||||
const preferences = new RoadtripPreferencesService(store as never, realtime as never);
|
||||
const realtime = { broadcast: vi.fn() };
|
||||
const tripSettings = new Map<number, Record<string, unknown>>([[10, { ...settings }]]);
|
||||
const preferenceDb = {
|
||||
all: (_sql: string, tripId: number) =>
|
||||
Object.entries(tripSettings.get(tripId) ?? {}).map(([key, value]) => ({ key, value: JSON.stringify(value) })),
|
||||
run: vi.fn((_sql: string, tripId: number, key: string, value: string) =>
|
||||
tripSettings.set(tripId, { ...tripSettings.get(tripId), [key]: JSON.parse(value) }),
|
||||
),
|
||||
transaction: (fn: () => unknown) => fn(),
|
||||
};
|
||||
const preferences = new RoadtripPreferencesService(preferenceDb as never, realtime as never);
|
||||
const days = [{ id: 1, day_number: 1, title: null, date: '2026-09-11', default_transport_mode: 'driving' }];
|
||||
const visits = [1, 2, 3].map((id) => ({
|
||||
id,
|
||||
@@ -82,25 +88,24 @@ function setup() {
|
||||
roadtrip as never,
|
||||
boundaries as never,
|
||||
);
|
||||
return { preferences, store, realtime, db, plans, router, visits, days, boundaries };
|
||||
return { preferenceDb, preferences, store, realtime, db, plans, router, visits, days, boundaries };
|
||||
}
|
||||
|
||||
describe('roadtrip preferences', () => {
|
||||
it('reads only public driving preferences and never endpoint URLs or credentials', () => {
|
||||
const s = setup();
|
||||
expect(s.preferences.read(5)).toMatchObject({ roadtrip_range_km: 100 });
|
||||
expect(JSON.stringify(s.preferences.read(5))).not.toMatch(/secret|private.example|routing_base_url/);
|
||||
expect(s.preferences.read(10)).toMatchObject({ roadtrip_range_km: 100 });
|
||||
expect(JSON.stringify(s.preferences.read(10))).not.toMatch(/secret|private.example|routing_base_url/);
|
||||
});
|
||||
it('validates the complete window before an atomic write and broadcasts only to its owner', () => {
|
||||
it('validates the complete window before an atomic write and broadcasts to every member of its trip', () => {
|
||||
const s = setup();
|
||||
expect(() => s.preferences.update(5, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).toThrow();
|
||||
expect(s.store.bulkUpsertSettings).not.toHaveBeenCalled();
|
||||
s.preferences.update(5, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' });
|
||||
expect(s.realtime.broadcastToUser).toHaveBeenCalledWith(5, {
|
||||
type: 'roadtripPreferences:changed',
|
||||
preferences: s.preferences.read(5),
|
||||
expect(() => s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).toThrow();
|
||||
expect(s.preferenceDb.run).not.toHaveBeenCalled();
|
||||
s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' });
|
||||
expect(s.realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripPreferences:changed', {
|
||||
preferences: s.preferences.read(10),
|
||||
});
|
||||
expect(s.preferences.read(5).roadtrip_range_km).toBe(100);
|
||||
expect(s.preferences.read(10).roadtrip_range_km).toBe(100);
|
||||
});
|
||||
it.each([
|
||||
{ llm_api_key: 'x' },
|
||||
@@ -114,12 +119,18 @@ describe('roadtrip preferences', () => {
|
||||
});
|
||||
it('allows clearing daily times and limits and refuses demo writes', async () => {
|
||||
const s = setup();
|
||||
s.preferences.update(5, { roadtrip_day_start: '', roadtrip_range_km: 0 });
|
||||
expect(s.preferences.read(5).roadtrip_day_start).toBe('');
|
||||
const mcp = new RoadtripPreferencesMcp(s.preferences, { isDemoUser: () => true } as never, {} as never);
|
||||
s.store.bulkUpsertSettings.mockClear();
|
||||
await mcp.update({ settings: { roadtrip_range_km: 300 } }, ctx);
|
||||
expect(s.store.bulkUpsertSettings).not.toHaveBeenCalled();
|
||||
s.preferences.update(10, { roadtrip_day_start: '', roadtrip_range_km: 0 });
|
||||
expect(s.preferences.read(10).roadtrip_day_start).toBe('');
|
||||
const mcp = new RoadtripPreferencesMcp(
|
||||
s.preferences,
|
||||
{ isDemoUser: () => true } as never,
|
||||
{} as never,
|
||||
s.db as never,
|
||||
{} as never,
|
||||
);
|
||||
s.preferenceDb.run.mockClear();
|
||||
await mcp.update({ tripId: 10, settings: { roadtrip_range_km: 300 } }, ctx);
|
||||
expect(s.preferenceDb.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,8 +149,8 @@ describe('browser-independent roadtrip calculation', () => {
|
||||
it('previews settings without saving them', async () => {
|
||||
const s = setup();
|
||||
await s.plans.calculate(10, 5, { roadtrip_day_end: '20:00' });
|
||||
expect(s.preferences.read(5).roadtrip_day_end).toBe('10:00');
|
||||
expect(s.store.bulkUpsertSettings).not.toHaveBeenCalled();
|
||||
expect(s.preferences.read(10).roadtrip_day_end).toBe('10:00');
|
||||
expect(s.preferenceDb.run).not.toHaveBeenCalled();
|
||||
});
|
||||
it('checks trip access before reading or routing', async () => {
|
||||
const s = setup();
|
||||
@@ -212,7 +223,7 @@ describe('Roadtrip MCP registration and search', () => {
|
||||
const addons = { isAddonEnabled: vi.fn(() => true) };
|
||||
const registry = createTestRegistry(
|
||||
[
|
||||
new RoadtripPreferencesMcp(s.preferences, {} as never, addons as never),
|
||||
new RoadtripPreferencesMcp(s.preferences, {} as never, addons as never, s.db as never, {} as never),
|
||||
new RoadtripPlanningMcp(s.plans, {} as never, addons as never),
|
||||
],
|
||||
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
|
||||
@@ -223,8 +234,9 @@ describe('Roadtrip MCP registration and search', () => {
|
||||
names.push(name);
|
||||
},
|
||||
};
|
||||
registry.attach(registrar as never, { ...ctx, scopes: ['settings:read'] });
|
||||
expect(names).toEqual(['get_roadtrip_settings']);
|
||||
registry.attach(registrar as never, { ...ctx, scopes: ['trips:read'] });
|
||||
expect(names).toContain('get_roadtrip_settings');
|
||||
expect(names).not.toContain('update_roadtrip_settings');
|
||||
names.length = 0;
|
||||
addons.isAddonEnabled.mockReturnValue(false);
|
||||
registry.attach(registrar as never, { ...ctx, scopes: null });
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "عند الاتصال بالإنترنت، اسحب نهاية اليوم على المسار أو إلى مكان؛ انقر بزر الفأرة الأيمن لاستعادة النهاية التلقائية.",
|
||||
'roadtrip.window.resetBoundaries': "استعادة نهايات الأيام التلقائية",
|
||||
'roadtrip.stops.section': 'توقفات الخدمة',
|
||||
'roadtrip.stops.inDays': 'إظهارها في الأيام أيضًا',
|
||||
'roadtrip.stops.daysHint':
|
||||
'يسري على جميع توقفات الخدمة في هذه الرحلة. عند الإيقاف تظهر فقط في مخطط Roadtrip. أعد التفعيل لإظهارها في الأيام.',
|
||||
'roadtrip.window.dragHint':
|
||||
'عند الاتصال بالإنترنت، اسحب نهاية اليوم على المسار أو إلى مكان؛ انقر بزر الفأرة الأيمن لاستعادة النهاية التلقائية.',
|
||||
'roadtrip.window.resetBoundaries': 'استعادة نهايات الأيام التلقائية',
|
||||
'roadtrip.window.endHere': 'إنهاء اليوم هنا',
|
||||
'roadtrip.window.endHereHint':
|
||||
'يمكنك إنهاء اليوم عند محطة محددة من تفاصيل المكان عندما يكون وضع Roadtrip وأوقات السفر اليومية مفعّلين.',
|
||||
@@ -96,7 +101,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.unavailable': 'غير متاح: هذه النسخة تستخدم محركها الخاص، وهو لا يستطيع استبعاد فئة طرق.',
|
||||
'roadtrip.avoid.badge': 'تجنُّب {count}',
|
||||
'roadtrip.avoid.missed': 'لا مفر من {classes}',
|
||||
'roadtrip.avoid.missedHint': 'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.',
|
||||
'roadtrip.refuel.dry': 'ينفد الخزان هنا',
|
||||
'roadtrip.refuel.after': 'بعد {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'بماذا تقود',
|
||||
@@ -138,7 +144,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': 'بطول {distance}',
|
||||
'roadtrip.track.gap': 'على بعد {distance} من هذا اليوم',
|
||||
'roadtrip.track.onDay': 'على امتداد هذا اليوم',
|
||||
'roadtrip.track.explain': 'يُسحب المسار إلى التتبع بواسطة بضع نقاط عبور. تُستبدل نقاط العبور الموجودة على تلك المراحل.',
|
||||
'roadtrip.track.explain':
|
||||
'يُسحب المسار إلى التتبع بواسطة بضع نقاط عبور. تُستبدل نقاط العبور الموجودة على تلك المراحل.',
|
||||
'roadtrip.track.action': 'اتبع هذا المسار',
|
||||
'roadtrip.track.working': 'يجري ملاءمة المسار مع التتبع، الجولة {round}',
|
||||
'roadtrip.track.done': 'تم وضع {count} نقطة عبور. يبقى المسار ضمن {distance} من التتبع.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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.stops.section': 'Paradas de serviço',
|
||||
'roadtrip.stops.inDays': 'Mostrar também em Dias',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Aplica-se a todas as paradas de serviço desta viagem. Desativado, elas ficam apenas no Roadtrip. Ative para mostrá-las novamente em Dias.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Online, arraste o fim do dia pela rota ou até um lugar; clique com o botão direito para restaurar o fim automático.',
|
||||
'roadtrip.window.resetBoundaries': 'Restaurar fins de dia automáticos',
|
||||
'roadtrip.window.endHere': 'Encerrar o dia aqui',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Você pode encerrar o dia em uma parada específica nos detalhes do local quando o modo Roadtrip e os horários diários estiverem ativos.',
|
||||
@@ -92,11 +97,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Pedágios',
|
||||
'roadtrip.avoid.motorway': 'Rodovias',
|
||||
'roadtrip.avoid.ferry': 'Balsas',
|
||||
'roadtrip.avoid.hint': 'Isso é uma penalização, não uma proibição. Quando o trajeto não tem como desviar, ele usa mesmo assim e o dia avisa.',
|
||||
'roadtrip.avoid.unavailable': 'Indisponível: esta instância usa o próprio motor, que não consegue deixar de fora uma classe de via.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Isso é uma penalização, não uma proibição. Quando o trajeto não tem como desviar, ele usa mesmo assim e o dia avisa.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Indisponível: esta instância usa o próprio motor, que não consegue deixar de fora uma classe de via.',
|
||||
'roadtrip.avoid.badge': '{count} evitados',
|
||||
'roadtrip.avoid.missed': '{classes} inevitável',
|
||||
'roadtrip.avoid.missedHint': 'Este dia não tem rota que desvie, então o trajeto usa. Os outros dias continuam evitando o que dá.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Este dia não tem rota que desvie, então o trajeto usa. Os outros dias continuam evitando o que dá.',
|
||||
'roadtrip.refuel.dry': 'Aqui o tanque acaba',
|
||||
'roadtrip.refuel.after': 'após {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'O que você dirige',
|
||||
@@ -138,11 +146,13 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} de comprimento',
|
||||
'roadtrip.track.gap': 'a {distance} deste dia',
|
||||
'roadtrip.track.onDay': 'ao longo deste dia',
|
||||
'roadtrip.track.explain': 'O percurso é puxado para a trilha com alguns pontos de passagem. Os pontos existentes nessas etapas são substituídos.',
|
||||
'roadtrip.track.explain':
|
||||
'O percurso é puxado para a trilha com alguns pontos de passagem. Os pontos existentes nessas etapas são substituídos.',
|
||||
'roadtrip.track.action': 'Seguir esta trilha',
|
||||
'roadtrip.track.working': 'Ajustando o percurso à trilha, rodada {round}',
|
||||
'roadtrip.track.done': '{count} pontos de passagem colocados. O percurso fica a menos de {distance} da trilha.',
|
||||
'roadtrip.track.capped': '{count} pontos de passagem colocados. No ponto mais largo, o percurso fica a {distance} da trilha.',
|
||||
'roadtrip.track.capped':
|
||||
'{count} pontos de passagem colocados. No ponto mais largo, o percurso fica a {distance} da trilha.',
|
||||
'roadtrip.track.already': 'O percurso já seguia esta trilha.',
|
||||
'roadtrip.track.errorRoute': 'O serviço de rotas não respondeu.',
|
||||
'roadtrip.track.errorSave': 'Não foi possível salvar os pontos de passagem.',
|
||||
@@ -157,7 +167,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Nada no trajeto corresponde a “{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Nada no caminho corresponde a esses filtros.',
|
||||
'roadtrip.poi.foundFiltered': '{count} de {total} no trajeto',
|
||||
'roadtrip.poi.truncated': 'Em {count} trechos havia mais do que cabe em uma resposta — estreite o corredor para ver o restante.',
|
||||
'roadtrip.poi.truncated':
|
||||
'Em {count} trechos havia mais do que cabe em uma resposta — estreite o corredor para ver o restante.',
|
||||
'roadtrip.poi.search': 'Buscar',
|
||||
'roadtrip.poi.searching': 'Buscando {done} de {total}',
|
||||
'roadtrip.poi.capped': 'A rota é longa — apenas o primeiro trecho foi pesquisado.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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.stops.section': 'Parades de servei',
|
||||
'roadtrip.stops.inDays': 'Mostra també a Dies',
|
||||
'roadtrip.stops.daysHint':
|
||||
'S’aplica a totes les parades de servei del viatge. Si es desactiva, només apareixen a Roadtrip. Activa-ho per tornar-les a mostrar a Dies.',
|
||||
'roadtrip.window.dragHint':
|
||||
'En línia, arrossega el final del dia per la ruta o fins a un lloc; fes clic amb el botó dret per restaurar el final automàtic.',
|
||||
'roadtrip.window.resetBoundaries': 'Restaura els finals de dia automàtics',
|
||||
'roadtrip.window.endHere': 'Acaba el dia aquí',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Pots acabar el dia en una parada concreta des dels seus detalls quan el mode Roadtrip i els horaris diaris estiguin actius.',
|
||||
@@ -93,11 +98,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Peatges',
|
||||
'roadtrip.avoid.motorway': 'Autopistes',
|
||||
'roadtrip.avoid.ferry': 'Ferris',
|
||||
'roadtrip.avoid.hint': 'Es penalitzen, no es prohibeixen. Quan un trajecte no té manera d’evitar-ho, ho fa servir igualment i el dia ho indica.',
|
||||
'roadtrip.avoid.unavailable': 'No disponible: aquesta instància fa servir el seu propi motor, que no pot ometre una classe de via.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Es penalitzen, no es prohibeixen. Quan un trajecte no té manera d’evitar-ho, ho fa servir igualment i el dia ho indica.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'No disponible: aquesta instància fa servir el seu propi motor, que no pot ometre una classe de via.',
|
||||
'roadtrip.avoid.badge': '{count} evitats',
|
||||
'roadtrip.avoid.missed': '{classes} inevitable',
|
||||
'roadtrip.avoid.missedHint': 'Aquest dia no té cap ruta que ho eviti, així que el trajecte ho fa servir. La resta de dies continuen evitant el que poden.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Aquest dia no té cap ruta que ho eviti, així que el trajecte ho fa servir. La resta de dies continuen evitant el que poden.',
|
||||
'roadtrip.refuel.dry': 'Aquí s’esgota el dipòsit',
|
||||
'roadtrip.refuel.after': 'després de {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Què condueixes',
|
||||
@@ -139,11 +147,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} de llarg',
|
||||
'roadtrip.track.gap': 'a {distance} d’aquest dia',
|
||||
'roadtrip.track.onDay': 'al llarg d’aquest dia',
|
||||
'roadtrip.track.explain': 'El trajecte s’ajusta a la traça amb uns quants punts de pas. Els punts existents en aquests trams se substitueixen.',
|
||||
'roadtrip.track.explain':
|
||||
'El trajecte s’ajusta a la traça amb uns quants punts de pas. Els punts existents en aquests trams se substitueixen.',
|
||||
'roadtrip.track.action': 'Segueix aquesta traça',
|
||||
'roadtrip.track.working': 'Ajustant el trajecte a la traça, ronda {round}',
|
||||
'roadtrip.track.done': 'S’han col·locat {count} punts de pas. El trajecte es manté a menys de {distance} de la traça.',
|
||||
'roadtrip.track.capped': 'S’han col·locat {count} punts de pas. En el punt més ample, el trajecte queda a {distance} de la traça.',
|
||||
'roadtrip.track.done':
|
||||
'S’han col·locat {count} punts de pas. El trajecte es manté a menys de {distance} de la traça.',
|
||||
'roadtrip.track.capped':
|
||||
'S’han col·locat {count} punts de pas. En el punt més ample, el trajecte queda a {distance} de la traça.',
|
||||
'roadtrip.track.already': 'El trajecte ja seguia aquesta traça.',
|
||||
'roadtrip.track.errorRoute': 'El servei de rutes no ha respost.',
|
||||
'roadtrip.track.errorSave': 'No s’han pogut desar els punts de pas.',
|
||||
@@ -158,7 +169,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Res del trajecte no coincideix amb «{name}».',
|
||||
'roadtrip.poi.noneMatchFilters': 'Res del camí no coincideix amb aquests filtres.',
|
||||
'roadtrip.poi.foundFiltered': '{count} de {total} al trajecte',
|
||||
'roadtrip.poi.truncated': 'En {count} trams hi havia més del que cap en una resposta: estreny el corredor per veure la resta.',
|
||||
'roadtrip.poi.truncated':
|
||||
'En {count} trams hi havia més del que cap en una resposta: estreny el corredor per veure la resta.',
|
||||
'roadtrip.poi.search': 'Cerca',
|
||||
'roadtrip.poi.searching': 'Cercant {done} de {total}',
|
||||
'roadtrip.poi.capped': 'La ruta és llarga: només s’ha cercat el primer tram.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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.stops.section': 'Servisní zastávky',
|
||||
'roadtrip.stops.inDays': 'Zobrazit také ve Dnech',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Platí pro všechny servisní zastávky této cesty. Po vypnutí zůstanou pouze v plánu Roadtrip. Zapnutím je znovu zobrazíte ve Dnech.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Online můžete konec dne přetáhnout po trase nebo na místo; pravým tlačítkem obnovíte automatický konec.',
|
||||
'roadtrip.window.resetBoundaries': 'Obnovit automatické konce dnů',
|
||||
'roadtrip.window.endHere': 'Ukončit den zde',
|
||||
'roadtrip.window.endHereHint':
|
||||
'V podrobnostech místa můžete určit zastávku jako konec dne, když je aktivní režim Roadtrip a denní časy cestování.',
|
||||
@@ -93,10 +98,12 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.motorway': 'Dálnice',
|
||||
'roadtrip.avoid.ferry': 'Trajekty',
|
||||
'roadtrip.avoid.hint': 'Jde o penalizaci, ne o zákaz. Když se jízda nemá jak vyhnout, použije to a den to řekne.',
|
||||
'roadtrip.avoid.unavailable': 'Není k dispozici: tato instance jezdí přes vlastní engine, který neumí vynechat třídu silnic.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Není k dispozici: tato instance jezdí přes vlastní engine, který neumí vynechat třídu silnic.',
|
||||
'roadtrip.avoid.badge': '{count} vynecháno',
|
||||
'roadtrip.avoid.missed': '{classes} nelze objet',
|
||||
'roadtrip.avoid.missedHint': 'Tento den nemá objízdnou trasu, takže tudy jede. Ostatní dny se dál vyhýbají, čemu můžou.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Tento den nemá objízdnou trasu, takže tudy jede. Ostatní dny se dál vyhýbají, čemu můžou.',
|
||||
'roadtrip.refuel.dry': 'Tady dojde palivo',
|
||||
'roadtrip.refuel.after': 'po {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Čím jezdíš',
|
||||
@@ -138,7 +145,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': 'délka {distance}',
|
||||
'roadtrip.track.gap': '{distance} od tohoto dne',
|
||||
'roadtrip.track.onDay': 'podél tohoto dne',
|
||||
'roadtrip.track.explain': 'Jízda se pomocí několika průjezdních bodů přitáhne ke stopě. Stávající body na těchto úsecích budou nahrazeny.',
|
||||
'roadtrip.track.explain':
|
||||
'Jízda se pomocí několika průjezdních bodů přitáhne ke stopě. Stávající body na těchto úsecích budou nahrazeny.',
|
||||
'roadtrip.track.action': 'Sledovat tuto stopu',
|
||||
'roadtrip.track.working': 'Přizpůsobuji jízdu stopě, kolo {round}',
|
||||
'roadtrip.track.done': 'Umístěno {count} průjezdních bodů. Jízda se drží do {distance} od stopy.',
|
||||
@@ -157,7 +165,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Na trase nic neodpovídá „{name}“.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Nic na trase neodpovídá těmto filtrům.',
|
||||
'roadtrip.poi.foundFiltered': '{count} z {total} na trase',
|
||||
'roadtrip.poi.truncated': 'Na {count} úsecích jich bylo více, než se vejde do jedné odpovědi — zužte koridor, abyste viděli zbytek.',
|
||||
'roadtrip.poi.truncated':
|
||||
'Na {count} úsecích jich bylo více, než se vejde do jedné odpovědi — zužte koridor, abyste viděli zbytek.',
|
||||
'roadtrip.poi.search': 'Hledat',
|
||||
'roadtrip.poi.searching': 'Hledání {done} z {total}',
|
||||
'roadtrip.poi.capped': 'Trasa je dlouhá — prohledán byl jen první úsek.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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.stops.section': 'Service Stops',
|
||||
'roadtrip.stops.inDays': 'Auch in Days anzeigen',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Gilt für alle Service Stops dieser Reise. Ausgeschaltet bleiben sie ausschließlich im Roadtrip-Plan; beim Einschalten erscheinen sie wieder in Days.',
|
||||
'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.',
|
||||
@@ -93,11 +98,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Mautstraßen',
|
||||
'roadtrip.avoid.motorway': 'Autobahnen',
|
||||
'roadtrip.avoid.ferry': 'Fähren',
|
||||
'roadtrip.avoid.hint': 'Das wird nur schwächer gewichtet, nicht verboten. Wo eine Fahrt keinen Weg drumherum hat, nutzt sie es trotzdem, und der Tag sagt es.',
|
||||
'roadtrip.avoid.unavailable': 'Nicht verfügbar: diese Instanz routet über ihre eigene Maschine, die keine Straßenklasse auslassen kann.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Das wird nur schwächer gewichtet, nicht verboten. Wo eine Fahrt keinen Weg drumherum hat, nutzt sie es trotzdem, und der Tag sagt es.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Nicht verfügbar: diese Instanz routet über ihre eigene Maschine, die keine Straßenklasse auslassen kann.',
|
||||
'roadtrip.avoid.badge': '{count} gemieden',
|
||||
'roadtrip.avoid.missed': '{classes} unvermeidbar',
|
||||
'roadtrip.avoid.missedHint': 'An diesem Tag führt kein Weg daran vorbei, also fährt die Route darüber. Alle anderen Tage meiden weiterhin, was sie können.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'An diesem Tag führt kein Weg daran vorbei, also fährt die Route darüber. Alle anderen Tage meiden weiterhin, was sie können.',
|
||||
'roadtrip.refuel.dry': 'Hier ist der Tank leer',
|
||||
'roadtrip.refuel.after': 'nach {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Womit du fährst',
|
||||
@@ -139,11 +147,13 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} lang',
|
||||
'roadtrip.track.gap': '{distance} von diesem Tag entfernt',
|
||||
'roadtrip.track.onDay': 'entlang dieses Tages',
|
||||
'roadtrip.track.explain': 'Die Fahrt wird mit einigen Zwischenpunkten auf die Spur gezogen. Vorhandene Zwischenpunkte dieser Etappen werden ersetzt.',
|
||||
'roadtrip.track.explain':
|
||||
'Die Fahrt wird mit einigen Zwischenpunkten auf die Spur gezogen. Vorhandene Zwischenpunkte dieser Etappen werden ersetzt.',
|
||||
'roadtrip.track.action': 'Dieser Spur folgen',
|
||||
'roadtrip.track.working': 'Fahrt wird an die Spur angepasst, Runde {round}',
|
||||
'roadtrip.track.done': '{count} Zwischenpunkte gesetzt. Die Fahrt bleibt bis auf {distance} an der Spur.',
|
||||
'roadtrip.track.capped': '{count} Zwischenpunkte gesetzt. An der weitesten Stelle bleibt die Fahrt {distance} von der Spur entfernt.',
|
||||
'roadtrip.track.capped':
|
||||
'{count} Zwischenpunkte gesetzt. An der weitesten Stelle bleibt die Fahrt {distance} von der Spur entfernt.',
|
||||
'roadtrip.track.already': 'Die Fahrt folgte dieser Spur bereits.',
|
||||
'roadtrip.track.errorRoute': 'Der Routendienst hat nicht geantwortet.',
|
||||
'roadtrip.track.errorSave': 'Die Zwischenpunkte konnten nicht gespeichert werden.',
|
||||
@@ -158,7 +168,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Unterwegs passt nichts zu „{name}“.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Nichts auf dem Weg passt zu diesen Filtern.',
|
||||
'roadtrip.poi.foundFiltered': '{count} von {total} unterwegs',
|
||||
'roadtrip.poi.truncated': 'Auf {count} Abschnitten gab es mehr, als in eine Antwort passt — den Korridor schmaler machen, um den Rest zu sehen.',
|
||||
'roadtrip.poi.truncated':
|
||||
'Auf {count} Abschnitten gab es mehr, als in eine Antwort passt — den Korridor schmaler machen, um den Rest zu sehen.',
|
||||
'roadtrip.poi.search': 'Suchen',
|
||||
'roadtrip.poi.searching': 'Suche {done} von {total}',
|
||||
'roadtrip.poi.capped': 'Die Route ist lang — nur der erste Abschnitt wurde durchsucht.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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.stops.section': 'Service stops',
|
||||
'roadtrip.stops.inDays': 'Show in Days too',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Applies to all service stops in this trip. When off, they stay exclusively in the Roadtrip plan. Turn on to show them in Days again.',
|
||||
'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.',
|
||||
@@ -92,11 +97,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Toll roads',
|
||||
'roadtrip.avoid.motorway': 'Motorways',
|
||||
'roadtrip.avoid.ferry': 'Ferries',
|
||||
'roadtrip.avoid.hint': 'These are weighted down, not banned. Where a drive has no way around one, it still uses it and the day says so.',
|
||||
'roadtrip.avoid.unavailable': 'Not available: this instance routes through its own engine, which cannot leave a road class out.',
|
||||
'roadtrip.avoid.hint':
|
||||
'These are weighted down, not banned. Where a drive has no way around one, it still uses it and the day says so.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Not available: this instance routes through its own engine, which cannot leave a road class out.',
|
||||
'roadtrip.avoid.badge': '{count} avoided',
|
||||
'roadtrip.avoid.missed': '{classes} unavoidable',
|
||||
'roadtrip.avoid.missedHint': 'This day has no route around it, so the drive uses it. Every other day still avoids what it can.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'This day has no route around it, so the drive uses it. Every other day still avoids what it can.',
|
||||
'roadtrip.refuel.dry': 'Tank runs out here',
|
||||
'roadtrip.refuel.after': 'after {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'What you drive',
|
||||
@@ -138,7 +146,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} long',
|
||||
'roadtrip.track.gap': '{distance} off this day',
|
||||
'roadtrip.track.onDay': 'along this day',
|
||||
'roadtrip.track.explain': 'The drive is pulled onto the track with a handful of via points. Existing via points on those legs are replaced.',
|
||||
'roadtrip.track.explain':
|
||||
'The drive is pulled onto the track with a handful of via points. Existing via points on those legs are replaced.',
|
||||
'roadtrip.track.action': 'Follow this track',
|
||||
'roadtrip.track.working': 'Fitting the drive to the track, round {round}',
|
||||
'roadtrip.track.done': '{count} via points placed. The drive stays within {distance} of the track.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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.stops.section': 'Paradas de servicio',
|
||||
'roadtrip.stops.inDays': 'Mostrar también en Días',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Se aplica a todas las paradas de servicio del viaje. Al desactivarlo, solo aparecen en Roadtrip. Actívalo para mostrarlas de nuevo en Días.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Con conexión, arrastra el fin del día por la ruta o hasta un lugar; haz clic derecho para restaurar el final automático.',
|
||||
'roadtrip.window.resetBoundaries': 'Restaurar finales de día automáticos',
|
||||
'roadtrip.window.endHere': 'Terminar el día aquí',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Puedes terminar el día en una parada concreta desde sus detalles cuando el modo Roadtrip y los horarios diarios estén activos.',
|
||||
@@ -92,11 +97,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Peajes',
|
||||
'roadtrip.avoid.motorway': 'Autopistas',
|
||||
'roadtrip.avoid.ferry': 'Ferris',
|
||||
'roadtrip.avoid.hint': 'Se penalizan, no se prohíben. Cuando un trayecto no tiene forma de evitarlo, lo usa igual y el día lo indica.',
|
||||
'roadtrip.avoid.unavailable': 'No disponible: esta instancia usa su propio motor, que no puede omitir una clase de vía.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Se penalizan, no se prohíben. Cuando un trayecto no tiene forma de evitarlo, lo usa igual y el día lo indica.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'No disponible: esta instancia usa su propio motor, que no puede omitir una clase de vía.',
|
||||
'roadtrip.avoid.badge': '{count} evitados',
|
||||
'roadtrip.avoid.missed': '{classes} inevitable(s)',
|
||||
'roadtrip.avoid.missedHint': 'Este día no tiene ninguna ruta que lo evite, así que el trayecto lo usa. Los demás días siguen evitando lo que pueden.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Este día no tiene ninguna ruta que lo evite, así que el trayecto lo usa. Los demás días siguen evitando lo que pueden.',
|
||||
'roadtrip.refuel.dry': 'Aquí se vacía el depósito',
|
||||
'roadtrip.refuel.after': 'tras {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Qué conduces',
|
||||
@@ -138,11 +146,13 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} de largo',
|
||||
'roadtrip.track.gap': 'a {distance} de este día',
|
||||
'roadtrip.track.onDay': 'a lo largo de este día',
|
||||
'roadtrip.track.explain': 'El trayecto se ajusta a la traza con unos pocos puntos de paso. Los puntos existentes en esos tramos se reemplazan.',
|
||||
'roadtrip.track.explain':
|
||||
'El trayecto se ajusta a la traza con unos pocos puntos de paso. Los puntos existentes en esos tramos se reemplazan.',
|
||||
'roadtrip.track.action': 'Seguir esta traza',
|
||||
'roadtrip.track.working': 'Ajustando el trayecto a la traza, ronda {round}',
|
||||
'roadtrip.track.done': '{count} puntos de paso colocados. El trayecto se mantiene a menos de {distance} de la traza.',
|
||||
'roadtrip.track.capped': '{count} puntos de paso colocados. En su punto más ancho, el trayecto queda a {distance} de la traza.',
|
||||
'roadtrip.track.capped':
|
||||
'{count} puntos de paso colocados. En su punto más ancho, el trayecto queda a {distance} de la traza.',
|
||||
'roadtrip.track.already': 'El trayecto ya seguía esta traza.',
|
||||
'roadtrip.track.errorRoute': 'El servicio de rutas no respondió.',
|
||||
'roadtrip.track.errorSave': 'No se pudieron guardar los puntos de paso.',
|
||||
@@ -157,7 +167,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Nada en la ruta coincide con «{name}».',
|
||||
'roadtrip.poi.noneMatchFilters': 'Nada en el camino coincide con estos filtros.',
|
||||
'roadtrip.poi.foundFiltered': '{count} de {total} en la ruta',
|
||||
'roadtrip.poi.truncated': 'En {count} tramos había más de lo que cabe en una respuesta: reduce el corredor para ver el resto.',
|
||||
'roadtrip.poi.truncated':
|
||||
'En {count} tramos había más de lo que cabe en una respuesta: reduce el corredor para ver el resto.',
|
||||
'roadtrip.poi.search': 'Buscar',
|
||||
'roadtrip.poi.searching': 'Buscando {done} de {total}',
|
||||
'roadtrip.poi.capped': 'La ruta es larga: solo se buscó en el primer tramo.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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.stops.section': 'Arrêts de service',
|
||||
'roadtrip.stops.inDays': 'Afficher aussi dans Jours',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Concerne tous les arrêts de service du voyage. Si désactivé, ils restent uniquement dans Roadtrip. Activez cette option pour les afficher à nouveau dans Jours.',
|
||||
'roadtrip.window.dragHint':
|
||||
'En ligne, faites glisser la fin de journée sur le trajet ou sur un lieu ; un clic droit rétablit la fin automatique.',
|
||||
'roadtrip.window.resetBoundaries': 'Rétablir les fins de journée automatiques',
|
||||
'roadtrip.window.endHere': 'Terminer la journée ici',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Vous pouvez terminer une journée à une étape précise dans ses détails lorsque le mode Roadtrip et les horaires quotidiens sont actifs.',
|
||||
@@ -93,11 +98,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Routes à péage',
|
||||
'roadtrip.avoid.motorway': 'Autoroutes',
|
||||
'roadtrip.avoid.ferry': 'Ferries',
|
||||
'roadtrip.avoid.hint': 'Ce sont des pondérations, pas des interdictions. Quand un trajet n’a aucun moyen de contourner, il l’emprunte quand même et la journée le signale.',
|
||||
'roadtrip.avoid.unavailable': 'Indisponible : cette instance utilise son propre moteur, qui ne sait pas écarter une classe de route.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Ce sont des pondérations, pas des interdictions. Quand un trajet n’a aucun moyen de contourner, il l’emprunte quand même et la journée le signale.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Indisponible : cette instance utilise son propre moteur, qui ne sait pas écarter une classe de route.',
|
||||
'roadtrip.avoid.badge': '{count} évité(s)',
|
||||
'roadtrip.avoid.missed': '{classes} inévitable(s)',
|
||||
'roadtrip.avoid.missedHint': 'Cette journée n’a aucun itinéraire de contournement, le trajet l’emprunte donc. Les autres journées évitent toujours ce qu’elles peuvent.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Cette journée n’a aucun itinéraire de contournement, le trajet l’emprunte donc. Les autres journées évitent toujours ce qu’elles peuvent.',
|
||||
'roadtrip.refuel.dry': 'Réservoir vide ici',
|
||||
'roadtrip.refuel.after': 'après {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Ce que vous conduisez',
|
||||
@@ -139,7 +147,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} de long',
|
||||
'roadtrip.track.gap': 'à {distance} de ce jour',
|
||||
'roadtrip.track.onDay': 'le long de ce jour',
|
||||
'roadtrip.track.explain': 'Le trajet est ramené sur la trace à l’aide de quelques points de passage. Les points existants sur ces étapes sont remplacés.',
|
||||
'roadtrip.track.explain':
|
||||
'Le trajet est ramené sur la trace à l’aide de quelques points de passage. Les points existants sur ces étapes sont remplacés.',
|
||||
'roadtrip.track.action': 'Suivre cette trace',
|
||||
'roadtrip.track.working': 'Ajustement du trajet à la trace, tour {round}',
|
||||
'roadtrip.track.done': '{count} points de passage placés. Le trajet reste à moins de {distance} de la trace.',
|
||||
@@ -158,7 +167,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Rien sur le trajet ne correspond à « {name} ».',
|
||||
'roadtrip.poi.noneMatchFilters': 'Rien sur le trajet ne correspond à ces filtres.',
|
||||
'roadtrip.poi.foundFiltered': '{count} sur {total} sur le trajet',
|
||||
'roadtrip.poi.truncated': 'Sur {count} tronçons, il y en avait plus que ce qu’une réponse peut contenir : réduisez le corridor pour voir le reste.',
|
||||
'roadtrip.poi.truncated':
|
||||
'Sur {count} tronçons, il y en avait plus que ce qu’une réponse peut contenir : réduisez le corridor pour voir le reste.',
|
||||
'roadtrip.poi.search': 'Rechercher',
|
||||
'roadtrip.poi.searching': 'Recherche {done} sur {total}',
|
||||
'roadtrip.poi.capped': 'L’itinéraire est long : seule la première portion a été parcourue.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "Με σύνδεση, σύρετε το τέλος της ημέρας στη διαδρομή ή σε ένα μέρος· με δεξί κλικ επαναφέρετε το αυτόματο τέλος.",
|
||||
'roadtrip.window.resetBoundaries': "Επαναφορά αυτόματων ορίων ημέρας",
|
||||
'roadtrip.stops.section': 'Στάσεις εξυπηρέτησης',
|
||||
'roadtrip.stops.inDays': 'Εμφάνιση και στις Ημέρες',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Ισχύει για όλες τις στάσεις εξυπηρέτησης του ταξιδιού. Αν απενεργοποιηθεί, εμφανίζονται μόνο στο Roadtrip. Ενεργοποιήστε το για να εμφανιστούν ξανά στις Ημέρες.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Με σύνδεση, σύρετε το τέλος της ημέρας στη διαδρομή ή σε ένα μέρος· με δεξί κλικ επαναφέρετε το αυτόματο τέλος.',
|
||||
'roadtrip.window.resetBoundaries': 'Επαναφορά αυτόματων ορίων ημέρας',
|
||||
'roadtrip.window.endHere': 'Τέλος ημέρας εδώ',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Μπορείτε να ορίσετε μια στάση ως τέλος ημέρας στις λεπτομέρειες του μέρους, όταν είναι ενεργά το Roadtrip και τα ημερήσια ωράρια ταξιδιού.',
|
||||
@@ -94,11 +99,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Δρόμοι με διόδια',
|
||||
'roadtrip.avoid.motorway': 'Αυτοκινητόδρομοι',
|
||||
'roadtrip.avoid.ferry': 'Φέρι μποτ',
|
||||
'roadtrip.avoid.hint': 'Πρόκειται για στάθμιση, όχι απαγόρευση. Όταν μια διαδρομή δεν έχει τρόπο να τα αποφύγει, τα χρησιμοποιεί και η ημέρα το δηλώνει.',
|
||||
'roadtrip.avoid.unavailable': 'Μη διαθέσιμο: αυτή η εγκατάσταση χρησιμοποιεί δική της μηχανή, που δεν μπορεί να παραλείψει κατηγορία δρόμου.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Πρόκειται για στάθμιση, όχι απαγόρευση. Όταν μια διαδρομή δεν έχει τρόπο να τα αποφύγει, τα χρησιμοποιεί και η ημέρα το δηλώνει.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Μη διαθέσιμο: αυτή η εγκατάσταση χρησιμοποιεί δική της μηχανή, που δεν μπορεί να παραλείψει κατηγορία δρόμου.',
|
||||
'roadtrip.avoid.badge': '{count} αποφεύγονται',
|
||||
'roadtrip.avoid.missed': '{classes} αναπόφευκτα',
|
||||
'roadtrip.avoid.missedHint': 'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.',
|
||||
'roadtrip.refuel.dry': 'Εδώ αδειάζει το ρεζερβουάρ',
|
||||
'roadtrip.refuel.after': 'μετά από {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Τι οδηγείς',
|
||||
@@ -140,11 +148,13 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': 'μήκος {distance}',
|
||||
'roadtrip.track.gap': '{distance} από αυτή την ημέρα',
|
||||
'roadtrip.track.onDay': 'κατά μήκος αυτής της ημέρας',
|
||||
'roadtrip.track.explain': 'Η διαδρομή τραβιέται πάνω στο ίχνος με μερικά ενδιάμεσα σημεία. Τα υπάρχοντα σημεία σε αυτά τα σκέλη αντικαθίστανται.',
|
||||
'roadtrip.track.explain':
|
||||
'Η διαδρομή τραβιέται πάνω στο ίχνος με μερικά ενδιάμεσα σημεία. Τα υπάρχοντα σημεία σε αυτά τα σκέλη αντικαθίστανται.',
|
||||
'roadtrip.track.action': 'Ακολούθησε αυτό το ίχνος',
|
||||
'roadtrip.track.working': 'Προσαρμογή διαδρομής στο ίχνος, γύρος {round}',
|
||||
'roadtrip.track.done': 'Τοποθετήθηκαν {count} ενδιάμεσα σημεία. Η διαδρομή μένει εντός {distance} από το ίχνος.',
|
||||
'roadtrip.track.capped': 'Τοποθετήθηκαν {count} ενδιάμεσα σημεία. Στο πιο πλατύ σημείο η διαδρομή απέχει {distance} από το ίχνος.',
|
||||
'roadtrip.track.capped':
|
||||
'Τοποθετήθηκαν {count} ενδιάμεσα σημεία. Στο πιο πλατύ σημείο η διαδρομή απέχει {distance} από το ίχνος.',
|
||||
'roadtrip.track.already': 'Η διαδρομή ακολουθούσε ήδη αυτό το ίχνος.',
|
||||
'roadtrip.track.errorRoute': 'Η υπηρεσία δρομολόγησης δεν απάντησε.',
|
||||
'roadtrip.track.errorSave': 'Δεν ήταν δυνατή η αποθήκευση των ενδιάμεσων σημείων.',
|
||||
@@ -159,7 +169,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Τίποτα στη διαδρομή δεν ταιριάζει με «{name}».',
|
||||
'roadtrip.poi.noneMatchFilters': 'Τίποτα στη διαδρομή δεν ταιριάζει με αυτά τα φίλτρα.',
|
||||
'roadtrip.poi.foundFiltered': '{count} από {total} στη διαδρομή',
|
||||
'roadtrip.poi.truncated': 'Σε {count} τμήματα υπήρχαν περισσότερα από όσα χωρούν σε μία απάντηση — στενέψτε τον διάδρομο για να δείτε τα υπόλοιπα.',
|
||||
'roadtrip.poi.truncated':
|
||||
'Σε {count} τμήματα υπήρχαν περισσότερα από όσα χωρούν σε μία απάντηση — στενέψτε τον διάδρομο για να δείτε τα υπόλοιπα.',
|
||||
'roadtrip.poi.search': 'Αναζήτηση',
|
||||
'roadtrip.poi.searching': 'Αναζήτηση {done} από {total}',
|
||||
'roadtrip.poi.capped': 'Η διαδρομή είναι μεγάλη — αναζητήθηκε μόνο το πρώτο τμήμα.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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.stops.section': 'Szervizmegállók',
|
||||
'roadtrip.stops.inDays': 'Megjelenítés a Napok nézetben is',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Az utazás minden szervizmegállójára érvényes. Kikapcsolva csak a Roadtrip tervben maradnak. Bekapcsolva ismét megjelennek a Napok nézetben.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Online húzza a nap végét az útvonalon vagy egy helyre; jobb kattintással visszaállíthatja az automatikus befejezést.',
|
||||
'roadtrip.window.resetBoundaries': 'Automatikus napvégek visszaállítása',
|
||||
'roadtrip.window.endHere': 'Nap befejezése itt',
|
||||
'roadtrip.window.endHereHint':
|
||||
'A hely részleteinél jelölhetsz ki megállót a nap végének, ha a Roadtrip mód és a napi utazási idők aktívak.',
|
||||
@@ -97,7 +102,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.unavailable': 'Nem érhető el: ez a példány saját motort használ, amely nem tud kihagyni útosztályt.',
|
||||
'roadtrip.avoid.badge': '{count} kerülve',
|
||||
'roadtrip.avoid.missed': '{classes} nem kerülhető',
|
||||
'roadtrip.avoid.missedHint': 'Ezen a napon nincs kerülőút, így az útvonal ezt használja. A többi nap továbbra is kerüli, amit tud.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Ezen a napon nincs kerülőút, így az útvonal ezt használja. A többi nap továbbra is kerüli, amit tud.',
|
||||
'roadtrip.refuel.dry': 'Itt fogy ki a tank',
|
||||
'roadtrip.refuel.after': '{distance} után',
|
||||
'roadtrip.limit.vehicleLabel': 'Mivel utazol',
|
||||
@@ -139,11 +145,13 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} hosszú',
|
||||
'roadtrip.track.gap': '{distance} távolságra ettől a naptól',
|
||||
'roadtrip.track.onDay': 'e nap mentén',
|
||||
'roadtrip.track.explain': 'Az útvonalat néhány köztes pont húzza rá a nyomvonalra. Az érintett szakaszok meglévő köztes pontjai lecserélődnek.',
|
||||
'roadtrip.track.explain':
|
||||
'Az útvonalat néhány köztes pont húzza rá a nyomvonalra. Az érintett szakaszok meglévő köztes pontjai lecserélődnek.',
|
||||
'roadtrip.track.action': 'Kövesse ezt a nyomvonalat',
|
||||
'roadtrip.track.working': 'Az útvonal igazítása a nyomvonalhoz, {round}. kör',
|
||||
'roadtrip.track.done': '{count} köztes pont elhelyezve. Az útvonal {distance} távolságon belül marad a nyomvonaltól.',
|
||||
'roadtrip.track.capped': '{count} köztes pont elhelyezve. A legtávolabbi ponton az útvonal {distance} távolságra van a nyomvonaltól.',
|
||||
'roadtrip.track.capped':
|
||||
'{count} köztes pont elhelyezve. A legtávolabbi ponton az útvonal {distance} távolságra van a nyomvonaltól.',
|
||||
'roadtrip.track.already': 'Az útvonal már ezt a nyomvonalat követte.',
|
||||
'roadtrip.track.errorRoute': 'Az útvonaltervező szolgáltatás nem válaszolt.',
|
||||
'roadtrip.track.errorSave': 'A köztes pontokat nem sikerült menteni.',
|
||||
@@ -158,7 +166,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Az útvonalon semmi sem egyezik ezzel: „{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Az útvonalon semmi sem felel meg ezeknek a szűrőknek.',
|
||||
'roadtrip.poi.foundFiltered': '{count} / {total} az útvonalon',
|
||||
'roadtrip.poi.truncated': '{count} szakaszon több volt, mint amennyi egy válaszba fér — szűkítse a folyosót a többi megtekintéséhez.',
|
||||
'roadtrip.poi.truncated':
|
||||
'{count} szakaszon több volt, mint amennyi egy válaszba fér — szűkítse a folyosót a többi megtekintéséhez.',
|
||||
'roadtrip.poi.search': 'Keresés',
|
||||
'roadtrip.poi.searching': 'Keresés {done}/{total}',
|
||||
'roadtrip.poi.capped': 'Az útvonal hosszú – csak az első szakaszon kerestünk.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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.stops.section': 'Perhentian layanan',
|
||||
'roadtrip.stops.inDays': 'Tampilkan juga di Hari',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Berlaku untuk semua perhentian layanan perjalanan ini. Jika dinonaktifkan, perhentian hanya muncul di Roadtrip. Aktifkan untuk menampilkannya kembali di Hari.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Saat online, seret akhir hari di sepanjang rute atau ke suatu tempat; klik kanan untuk memulihkan akhir otomatis.',
|
||||
'roadtrip.window.resetBoundaries': 'Pulihkan akhir hari otomatis',
|
||||
'roadtrip.window.endHere': 'Akhiri hari di sini',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Anda dapat mengakhiri hari di pemberhentian tertentu melalui detail tempat saat mode Roadtrip dan waktu perjalanan harian aktif.',
|
||||
@@ -93,11 +98,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Jalan tol',
|
||||
'roadtrip.avoid.motorway': 'Jalan bebas hambatan',
|
||||
'roadtrip.avoid.ferry': 'Feri',
|
||||
'roadtrip.avoid.hint': 'Ini pembobotan, bukan larangan. Kalau satu perjalanan tidak punya jalan memutar, ia tetap melewatinya dan hari itu memberi tahu.',
|
||||
'roadtrip.avoid.unavailable': 'Tidak tersedia: instans ini memakai mesinnya sendiri, yang tidak bisa mengecualikan kelas jalan.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Ini pembobotan, bukan larangan. Kalau satu perjalanan tidak punya jalan memutar, ia tetap melewatinya dan hari itu memberi tahu.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Tidak tersedia: instans ini memakai mesinnya sendiri, yang tidak bisa mengecualikan kelas jalan.',
|
||||
'roadtrip.avoid.badge': '{count} dihindari',
|
||||
'roadtrip.avoid.missed': '{classes} tak terhindarkan',
|
||||
'roadtrip.avoid.missedHint': 'Hari ini tidak ada rute memutarnya, jadi perjalanan melewatinya. Hari lain tetap menghindari yang bisa dihindari.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Hari ini tidak ada rute memutarnya, jadi perjalanan melewatinya. Hari lain tetap menghindari yang bisa dihindari.',
|
||||
'roadtrip.refuel.dry': 'Tangki habis di sini',
|
||||
'roadtrip.refuel.after': 'setelah {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Kamu mengemudi apa',
|
||||
@@ -139,11 +147,13 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': 'panjang {distance}',
|
||||
'roadtrip.track.gap': '{distance} dari hari ini',
|
||||
'roadtrip.track.onDay': 'sepanjang hari ini',
|
||||
'roadtrip.track.explain': 'Perjalanan ditarik ke jalur dengan beberapa titik lintas. Titik lintas yang ada di etape tersebut akan diganti.',
|
||||
'roadtrip.track.explain':
|
||||
'Perjalanan ditarik ke jalur dengan beberapa titik lintas. Titik lintas yang ada di etape tersebut akan diganti.',
|
||||
'roadtrip.track.action': 'Ikuti jalur ini',
|
||||
'roadtrip.track.working': 'Menyesuaikan perjalanan dengan jalur, putaran {round}',
|
||||
'roadtrip.track.done': '{count} titik lintas ditempatkan. Perjalanan tetap dalam {distance} dari jalur.',
|
||||
'roadtrip.track.capped': '{count} titik lintas ditempatkan. Di titik terlebar, perjalanan berjarak {distance} dari jalur.',
|
||||
'roadtrip.track.capped':
|
||||
'{count} titik lintas ditempatkan. Di titik terlebar, perjalanan berjarak {distance} dari jalur.',
|
||||
'roadtrip.track.already': 'Perjalanan sudah mengikuti jalur ini.',
|
||||
'roadtrip.track.errorRoute': 'Layanan rute tidak menjawab.',
|
||||
'roadtrip.track.errorSave': 'Titik lintas tidak dapat disimpan.',
|
||||
@@ -158,7 +168,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Tidak ada di rute yang cocok dengan “{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Tidak ada di sepanjang rute yang cocok dengan filter ini.',
|
||||
'roadtrip.poi.foundFiltered': '{count} dari {total} di rute',
|
||||
'roadtrip.poi.truncated': 'Di {count} ruas ada lebih banyak daripada yang muat dalam satu jawaban — persempit koridor untuk melihat sisanya.',
|
||||
'roadtrip.poi.truncated':
|
||||
'Di {count} ruas ada lebih banyak daripada yang muat dalam satu jawaban — persempit koridor untuk melihat sisanya.',
|
||||
'roadtrip.poi.search': 'Cari',
|
||||
'roadtrip.poi.searching': 'Mencari {done} dari {total}',
|
||||
'roadtrip.poi.capped': 'Rute panjang — hanya bagian pertama yang dicari.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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.stops.section': 'Soste di servizio',
|
||||
'roadtrip.stops.inDays': 'Mostra anche in Giorni',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Si applica a tutte le soste di servizio del viaggio. Se disattivato, restano solo in Roadtrip. Attivalo per mostrarle di nuovo in Giorni.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Online, trascina la fine della giornata lungo il percorso o su un luogo; fai clic destro per ripristinare la fine automatica.',
|
||||
'roadtrip.window.resetBoundaries': 'Ripristina le fini giornata automatiche',
|
||||
'roadtrip.window.endHere': 'Termina la giornata qui',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Puoi terminare la giornata in una tappa specifica dai suoi dettagli quando la modalità Roadtrip e gli orari giornalieri sono attivi.',
|
||||
@@ -93,11 +98,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Strade a pedaggio',
|
||||
'roadtrip.avoid.motorway': 'Autostrade',
|
||||
'roadtrip.avoid.ferry': 'Traghetti',
|
||||
'roadtrip.avoid.hint': 'Vengono penalizzate, non vietate. Quando un percorso non ha alternative, le usa comunque e la giornata lo segnala.',
|
||||
'roadtrip.avoid.unavailable': 'Non disponibile: questa istanza usa il proprio motore, che non sa escludere una classe di strada.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Vengono penalizzate, non vietate. Quando un percorso non ha alternative, le usa comunque e la giornata lo segnala.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Non disponibile: questa istanza usa il proprio motore, che non sa escludere una classe di strada.',
|
||||
'roadtrip.avoid.badge': '{count} evitati',
|
||||
'roadtrip.avoid.missed': '{classes} inevitabili',
|
||||
'roadtrip.avoid.missedHint': 'Questa giornata non ha percorsi alternativi, quindi il viaggio li usa. Le altre giornate continuano a evitare quel che possono.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Questa giornata non ha percorsi alternativi, quindi il viaggio li usa. Le altre giornate continuano a evitare quel che possono.',
|
||||
'roadtrip.refuel.dry': 'Qui il serbatoio si esaurisce',
|
||||
'roadtrip.refuel.after': 'dopo {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Cosa guidi',
|
||||
@@ -139,11 +147,13 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} di lunghezza',
|
||||
'roadtrip.track.gap': 'a {distance} da questo giorno',
|
||||
'roadtrip.track.onDay': 'lungo questo giorno',
|
||||
'roadtrip.track.explain': 'Il percorso viene portato sulla traccia con alcuni punti di passaggio. Quelli già presenti su quelle tratte vengono sostituiti.',
|
||||
'roadtrip.track.explain':
|
||||
'Il percorso viene portato sulla traccia con alcuni punti di passaggio. Quelli già presenti su quelle tratte vengono sostituiti.',
|
||||
'roadtrip.track.action': 'Segui questa traccia',
|
||||
'roadtrip.track.working': 'Adatto il percorso alla traccia, giro {round}',
|
||||
'roadtrip.track.done': '{count} punti di passaggio posati. Il percorso resta entro {distance} dalla traccia.',
|
||||
'roadtrip.track.capped': '{count} punti di passaggio posati. Nel punto peggiore il percorso resta a {distance} dalla traccia.',
|
||||
'roadtrip.track.capped':
|
||||
'{count} punti di passaggio posati. Nel punto peggiore il percorso resta a {distance} dalla traccia.',
|
||||
'roadtrip.track.already': 'Il percorso seguiva già questa traccia.',
|
||||
'roadtrip.track.errorRoute': 'Il servizio di calcolo percorso non ha risposto.',
|
||||
'roadtrip.track.errorSave': 'Non è stato possibile salvare i punti di passaggio.',
|
||||
@@ -158,7 +168,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Lungo il percorso non c’è nulla che corrisponda a “{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Niente lungo il percorso corrisponde a questi filtri.',
|
||||
'roadtrip.poi.foundFiltered': '{count} di {total} lungo il percorso',
|
||||
'roadtrip.poi.truncated': 'Su {count} tratti ce n’erano più di quanti ne stiano in una risposta: restringi il corridoio per vedere il resto.',
|
||||
'roadtrip.poi.truncated':
|
||||
'Su {count} tratti ce n’erano più di quanti ne stiano in una risposta: restringi il corridoio per vedere il resto.',
|
||||
'roadtrip.poi.search': 'Cerca',
|
||||
'roadtrip.poi.searching': 'Ricerca {done} di {total}',
|
||||
'roadtrip.poi.capped': 'Il percorso è lungo: è stato cercato solo il primo tratto.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "オンライン時は一日の終了地点をルート上や場所へドラッグできます。右クリックで自動の終了地点に戻せます。",
|
||||
'roadtrip.window.resetBoundaries': "一日の終了地点を自動に戻す",
|
||||
'roadtrip.stops.section': 'サービス施設での停車',
|
||||
'roadtrip.stops.inDays': '日別にも表示',
|
||||
'roadtrip.stops.daysHint':
|
||||
'この旅行のすべてのサービス施設での停車に適用されます。オフにするとRoadtripの計画にのみ表示されます。オンにすると日別にも再表示されます。',
|
||||
'roadtrip.window.dragHint':
|
||||
'オンライン時は一日の終了地点をルート上や場所へドラッグできます。右クリックで自動の終了地点に戻せます。',
|
||||
'roadtrip.window.resetBoundaries': '一日の終了地点を自動に戻す',
|
||||
'roadtrip.window.endHere': 'ここで一日を終了',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Roadtripモードと毎日の移動時間が有効な場合、場所の詳細から特定の立ち寄り先を一日の終点に設定できます。',
|
||||
@@ -93,10 +98,12 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.motorway': '高速道路',
|
||||
'roadtrip.avoid.ferry': 'フェリー',
|
||||
'roadtrip.avoid.hint': '禁止ではなく、優先度を下げるだけです。迂回できない場合はそのまま通り、その日に表示されます。',
|
||||
'roadtrip.avoid.unavailable': '利用できません。このインスタンスは自前のエンジンを使っており、道路種別を除外できません。',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'利用できません。このインスタンスは自前のエンジンを使っており、道路種別を除外できません。',
|
||||
'roadtrip.avoid.badge': '{count} 件を回避',
|
||||
'roadtrip.avoid.missed': '{classes} は回避できず',
|
||||
'roadtrip.avoid.missedHint': 'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。',
|
||||
'roadtrip.refuel.dry': 'ここで燃料切れ',
|
||||
'roadtrip.refuel.after': '{distance} 後',
|
||||
'roadtrip.limit.vehicleLabel': '何で走るか',
|
||||
@@ -138,7 +145,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '全長 {distance}',
|
||||
'roadtrip.track.gap': 'この日から {distance}',
|
||||
'roadtrip.track.onDay': 'この日に沿って',
|
||||
'roadtrip.track.explain': 'いくつかの経由地で走行をトラックに引き寄せます。その区間にある既存の経由地は置き換えられます。',
|
||||
'roadtrip.track.explain':
|
||||
'いくつかの経由地で走行をトラックに引き寄せます。その区間にある既存の経由地は置き換えられます。',
|
||||
'roadtrip.track.action': 'このトラックをたどる',
|
||||
'roadtrip.track.working': '走行をトラックに合わせています({round} 回目)',
|
||||
'roadtrip.track.done': '経由地を {count} か所置きました。走行はトラックから {distance} 以内に収まります。',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "온라인에서 하루 종료 지점을 경로나 장소로 드래그하고, 오른쪽 클릭으로 자동 종료를 복원할 수 있습니다.",
|
||||
'roadtrip.window.resetBoundaries': "자동 하루 종료 복원",
|
||||
'roadtrip.stops.section': '편의시설 경유지',
|
||||
'roadtrip.stops.inDays': '일별 보기에도 표시',
|
||||
'roadtrip.stops.daysHint':
|
||||
'이 여행의 모든 편의시설 경유지에 적용됩니다. 끄면 Roadtrip 계획에만 표시됩니다. 켜면 일별 보기에도 다시 표시됩니다.',
|
||||
'roadtrip.window.dragHint':
|
||||
'온라인에서 하루 종료 지점을 경로나 장소로 드래그하고, 오른쪽 클릭으로 자동 종료를 복원할 수 있습니다.',
|
||||
'roadtrip.window.resetBoundaries': '자동 하루 종료 복원',
|
||||
'roadtrip.window.endHere': '여기서 하루 종료',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Roadtrip 모드와 일일 이동 시간이 활성화된 경우 장소 상세에서 특정 경유지를 하루의 종점으로 설정할 수 있습니다.',
|
||||
@@ -92,8 +97,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': '유료 도로',
|
||||
'roadtrip.avoid.motorway': '고속도로',
|
||||
'roadtrip.avoid.ferry': '페리',
|
||||
'roadtrip.avoid.hint': '금지가 아니라 가중치를 낮추는 것입니다. 우회로가 없으면 그대로 지나가고, 해당 날짜에 그렇게 표시됩니다.',
|
||||
'roadtrip.avoid.unavailable': '사용할 수 없습니다. 이 인스턴스는 자체 엔진으로 경로를 계산하며, 도로 종류를 제외하지 못합니다.',
|
||||
'roadtrip.avoid.hint':
|
||||
'금지가 아니라 가중치를 낮추는 것입니다. 우회로가 없으면 그대로 지나가고, 해당 날짜에 그렇게 표시됩니다.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'사용할 수 없습니다. 이 인스턴스는 자체 엔진으로 경로를 계산하며, 도로 종류를 제외하지 못합니다.',
|
||||
'roadtrip.avoid.badge': '{count}개 회피',
|
||||
'roadtrip.avoid.missed': '{classes} 회피 불가',
|
||||
'roadtrip.avoid.missedHint': '이 날은 우회로가 없어 그대로 지나갑니다. 다른 날은 계속 피할 수 있는 것을 피합니다.',
|
||||
@@ -157,7 +164,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': '경로상에 “{name}”와(과) 일치하는 곳이 없습니다.',
|
||||
'roadtrip.poi.noneMatchFilters': '이 필터에 맞는 곳이 경로에 없습니다.',
|
||||
'roadtrip.poi.foundFiltered': '경로상 {total}곳 중 {count}곳',
|
||||
'roadtrip.poi.truncated': '{count}개 구간에서 한 번에 반환할 수 있는 수를 넘었습니다. 회랑을 좁히면 나머지를 볼 수 있습니다.',
|
||||
'roadtrip.poi.truncated':
|
||||
'{count}개 구간에서 한 번에 반환할 수 있는 수를 넘었습니다. 회랑을 좁히면 나머지를 볼 수 있습니다.',
|
||||
'roadtrip.poi.search': '검색',
|
||||
'roadtrip.poi.searching': '검색 중 {done}/{total}',
|
||||
'roadtrip.poi.capped': '경로가 길어 앞부분만 검색했습니다.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "Sleep online het dageinde langs de route of naar een plaats; klik met rechts om het automatische einde te herstellen.",
|
||||
'roadtrip.window.resetBoundaries': "Automatische dageinden herstellen",
|
||||
'roadtrip.stops.section': 'Servicestops',
|
||||
'roadtrip.stops.inDays': 'Ook in Dagen tonen',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Geldt voor alle servicestops van deze reis. Uitgeschakeld blijven ze alleen in Roadtrip staan. Schakel dit in om ze weer in Dagen te tonen.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Sleep online het dageinde langs de route of naar een plaats; klik met rechts om het automatische einde te herstellen.',
|
||||
'roadtrip.window.resetBoundaries': 'Automatische dageinden herstellen',
|
||||
'roadtrip.window.endHere': 'Dag hier beëindigen',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Je kunt een stop als dageinde instellen in de plaatsdetails wanneer de Roadtrip-modus en dagelijkse reistijden actief zijn.',
|
||||
@@ -71,7 +76,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.rangeEmpty': 'Geen actieradius',
|
||||
'roadtrip.limit.rangeEmptyHint': 'Vul een actieradius in, of vul de velden in en TREK rekent hem uit.',
|
||||
'roadtrip.limit.specHint': 'Ingevuld bepalen deze velden de actieradius hierboven.',
|
||||
'roadtrip.limit.vehicleHint': 'Geef aan wat je rijdt, dan kun je de voertuiggegevens invullen in plaats van een actieradius.',
|
||||
'roadtrip.limit.vehicleHint':
|
||||
'Geef aan wat je rijdt, dan kun je de voertuiggegevens invullen in plaats van een actieradius.',
|
||||
'roadtrip.limit.minutes': 'min',
|
||||
'roadtrip.limit.off': 'uit',
|
||||
'roadtrip.limit.blockNote': 'Eén blok = {distance}',
|
||||
@@ -93,11 +99,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Tolwegen',
|
||||
'roadtrip.avoid.motorway': 'Snelwegen',
|
||||
'roadtrip.avoid.ferry': 'Veerboten',
|
||||
'roadtrip.avoid.hint': 'Ze krijgen minder gewicht, ze worden niet verboden. Waar een rit er niet omheen kan, gebruikt hij ze toch en de dag meldt dat.',
|
||||
'roadtrip.avoid.unavailable': 'Niet beschikbaar: deze installatie gebruikt haar eigen motor, die geen wegklasse kan weglaten.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Ze krijgen minder gewicht, ze worden niet verboden. Waar een rit er niet omheen kan, gebruikt hij ze toch en de dag meldt dat.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Niet beschikbaar: deze installatie gebruikt haar eigen motor, die geen wegklasse kan weglaten.',
|
||||
'roadtrip.avoid.badge': '{count} vermeden',
|
||||
'roadtrip.avoid.missed': '{classes} onvermijdelijk',
|
||||
'roadtrip.avoid.missedHint': 'Deze dag heeft er geen route omheen, dus de rit gebruikt het. Alle andere dagen vermijden nog steeds wat ze kunnen.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Deze dag heeft er geen route omheen, dus de rit gebruikt het. Alle andere dagen vermijden nog steeds wat ze kunnen.',
|
||||
'roadtrip.refuel.dry': 'Hier is de tank leeg',
|
||||
'roadtrip.refuel.after': 'na {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Wat je rijdt',
|
||||
@@ -139,7 +148,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} lang',
|
||||
'roadtrip.track.gap': '{distance} van deze dag',
|
||||
'roadtrip.track.onDay': 'langs deze dag',
|
||||
'roadtrip.track.explain': 'De rit wordt met een handvol tussenpunten op de track getrokken. Bestaande tussenpunten op die etappes worden vervangen.',
|
||||
'roadtrip.track.explain':
|
||||
'De rit wordt met een handvol tussenpunten op de track getrokken. Bestaande tussenpunten op die etappes worden vervangen.',
|
||||
'roadtrip.track.action': 'Deze track volgen',
|
||||
'roadtrip.track.working': 'Rit wordt op de track gepast, ronde {round}',
|
||||
'roadtrip.track.done': '{count} tussenpunten geplaatst. De rit blijft binnen {distance} van de track.',
|
||||
@@ -158,7 +168,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Niets onderweg komt overeen met “{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Niets onderweg voldoet aan deze filters.',
|
||||
'roadtrip.poi.foundFiltered': '{count} van {total} onderweg',
|
||||
'roadtrip.poi.truncated': 'Op {count} stukken waren er meer dan in één antwoord passen — maak de corridor smaller om de rest te zien.',
|
||||
'roadtrip.poi.truncated':
|
||||
'Op {count} stukken waren er meer dan in één antwoord passen — maak de corridor smaller om de rest te zien.',
|
||||
'roadtrip.poi.search': 'Zoeken',
|
||||
'roadtrip.poi.searching': 'Zoeken {done} van {total}',
|
||||
'roadtrip.poi.capped': 'De route is lang — alleen het eerste deel is doorzocht.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "Online przeciągnij koniec dnia wzdłuż trasy lub na miejsce; kliknij prawym przyciskiem, aby przywrócić automatyczny koniec.",
|
||||
'roadtrip.window.resetBoundaries': "Przywróć automatyczne końce dni",
|
||||
'roadtrip.stops.section': 'Postoje serwisowe',
|
||||
'roadtrip.stops.inDays': 'Pokaż także w Dniach',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Dotyczy wszystkich postojów serwisowych tej podróży. Po wyłączeniu pozostają tylko w Roadtrip. Włącz, aby ponownie pokazać je w Dniach.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Online przeciągnij koniec dnia wzdłuż trasy lub na miejsce; kliknij prawym przyciskiem, aby przywrócić automatyczny koniec.',
|
||||
'roadtrip.window.resetBoundaries': 'Przywróć automatyczne końce dni',
|
||||
'roadtrip.window.endHere': 'Zakończ dzień tutaj',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Możesz wybrać przystanek jako koniec dnia w szczegółach miejsca, gdy tryb Roadtrip i dzienne godziny podróży są aktywne.',
|
||||
@@ -92,11 +97,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Drogi płatne',
|
||||
'roadtrip.avoid.motorway': 'Autostrady',
|
||||
'roadtrip.avoid.ferry': 'Promy',
|
||||
'roadtrip.avoid.hint': 'To obniżenie wagi, nie zakaz. Gdy przejazd nie ma jak ominąć, i tak z tego korzysta, a dzień to pokazuje.',
|
||||
'roadtrip.avoid.unavailable': 'Niedostępne: ta instancja korzysta z własnego silnika, który nie potrafi pominąć klasy drogi.',
|
||||
'roadtrip.avoid.hint':
|
||||
'To obniżenie wagi, nie zakaz. Gdy przejazd nie ma jak ominąć, i tak z tego korzysta, a dzień to pokazuje.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Niedostępne: ta instancja korzysta z własnego silnika, który nie potrafi pominąć klasy drogi.',
|
||||
'roadtrip.avoid.badge': '{count} omijane',
|
||||
'roadtrip.avoid.missed': '{classes} nie do ominięcia',
|
||||
'roadtrip.avoid.missedHint': 'Tego dnia nie ma trasy w objazd, więc przejazd z tego korzysta. Pozostałe dni nadal omijają, co się da.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Tego dnia nie ma trasy w objazd, więc przejazd z tego korzysta. Pozostałe dni nadal omijają, co się da.',
|
||||
'roadtrip.refuel.dry': 'Tu kończy się paliwo',
|
||||
'roadtrip.refuel.after': 'po {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Czym jeździsz',
|
||||
@@ -138,7 +146,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} długości',
|
||||
'roadtrip.track.gap': '{distance} od tego dnia',
|
||||
'roadtrip.track.onDay': 'wzdłuż tego dnia',
|
||||
'roadtrip.track.explain': 'Trasa zostaje naciągnięta na ślad kilkoma punktami pośrednimi. Istniejące punkty na tych odcinkach zostaną zastąpione.',
|
||||
'roadtrip.track.explain':
|
||||
'Trasa zostaje naciągnięta na ślad kilkoma punktami pośrednimi. Istniejące punkty na tych odcinkach zostaną zastąpione.',
|
||||
'roadtrip.track.action': 'Podążaj za tym śladem',
|
||||
'roadtrip.track.working': 'Dopasowywanie trasy do śladu, runda {round}',
|
||||
'roadtrip.track.done': 'Ustawiono {count} punktów pośrednich. Trasa trzyma się śladu w promieniu {distance}.',
|
||||
@@ -157,7 +166,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Nic po drodze nie pasuje do „{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Nic po drodze nie pasuje do tych filtrów.',
|
||||
'roadtrip.poi.foundFiltered': '{count} z {total} po drodze',
|
||||
'roadtrip.poi.truncated': 'Na {count} odcinkach było więcej, niż mieści się w jednej odpowiedzi — zawęź korytarz, aby zobaczyć resztę.',
|
||||
'roadtrip.poi.truncated':
|
||||
'Na {count} odcinkach było więcej, niż mieści się w jednej odpowiedzi — zawęź korytarz, aby zobaczyć resztę.',
|
||||
'roadtrip.poi.search': 'Szukaj',
|
||||
'roadtrip.poi.searching': 'Szukanie {done} z {total}',
|
||||
'roadtrip.poi.capped': 'Trasa jest długa — przeszukano tylko pierwszy odcinek.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "В сети перетащите конец дня по маршруту или на место; щёлкните правой кнопкой для возврата к автоматическому завершению.",
|
||||
'roadtrip.window.resetBoundaries': "Восстановить автоматические окончания дней",
|
||||
'roadtrip.stops.section': 'Сервисные остановки',
|
||||
'roadtrip.stops.inDays': 'Показывать также в разделе «Дни»',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Применяется ко всем сервисным остановкам поездки. Если выключено, они остаются только в Roadtrip. Включите, чтобы снова показывать их в разделе «Дни».',
|
||||
'roadtrip.window.dragHint':
|
||||
'В сети перетащите конец дня по маршруту или на место; щёлкните правой кнопкой для возврата к автоматическому завершению.',
|
||||
'roadtrip.window.resetBoundaries': 'Восстановить автоматические окончания дней',
|
||||
'roadtrip.window.endHere': 'Завершить день здесь',
|
||||
'roadtrip.window.endHereHint':
|
||||
'В сведениях о месте можно завершить день на выбранной остановке, когда активны режим Roadtrip и ежедневное время поездки.',
|
||||
@@ -93,11 +98,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Платные дороги',
|
||||
'roadtrip.avoid.motorway': 'Автомагистрали',
|
||||
'roadtrip.avoid.ferry': 'Паромы',
|
||||
'roadtrip.avoid.hint': 'Это понижение веса, а не запрет. Если у поездки нет обхода, она всё равно пройдёт там, и день это покажет.',
|
||||
'roadtrip.avoid.unavailable': 'Недоступно: этот экземпляр использует собственный движок, который не умеет исключать класс дорог.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Это понижение веса, а не запрет. Если у поездки нет обхода, она всё равно пройдёт там, и день это покажет.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Недоступно: этот экземпляр использует собственный движок, который не умеет исключать класс дорог.',
|
||||
'roadtrip.avoid.badge': '{count} в обход',
|
||||
'roadtrip.avoid.missed': '{classes} не обойти',
|
||||
'roadtrip.avoid.missedHint': 'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.',
|
||||
'roadtrip.refuel.dry': 'Здесь кончится топливо',
|
||||
'roadtrip.refuel.after': 'через {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'На чём вы едете',
|
||||
@@ -139,11 +147,13 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': 'длина {distance}',
|
||||
'roadtrip.track.gap': 'в {distance} от этого дня',
|
||||
'roadtrip.track.onDay': 'вдоль этого дня',
|
||||
'roadtrip.track.explain': 'Маршрут притягивается к треку несколькими промежуточными точками. Существующие точки на этих участках заменяются.',
|
||||
'roadtrip.track.explain':
|
||||
'Маршрут притягивается к треку несколькими промежуточными точками. Существующие точки на этих участках заменяются.',
|
||||
'roadtrip.track.action': 'Идти по этому треку',
|
||||
'roadtrip.track.working': 'Подгоняем маршрут к треку, круг {round}',
|
||||
'roadtrip.track.done': 'Поставлено промежуточных точек: {count}. Маршрут держится в пределах {distance} от трека.',
|
||||
'roadtrip.track.capped': 'Поставлено промежуточных точек: {count}. В худшем месте маршрут отходит от трека на {distance}.',
|
||||
'roadtrip.track.capped':
|
||||
'Поставлено промежуточных точек: {count}. В худшем месте маршрут отходит от трека на {distance}.',
|
||||
'roadtrip.track.already': 'Маршрут уже шёл по этому треку.',
|
||||
'roadtrip.track.errorRoute': 'Сервис маршрутов не ответил.',
|
||||
'roadtrip.track.errorSave': 'Не удалось сохранить промежуточные точки.',
|
||||
@@ -158,7 +168,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'По пути ничего не соответствует «{name}».',
|
||||
'roadtrip.poi.noneMatchFilters': 'На маршруте нет ничего, что подходит под эти фильтры.',
|
||||
'roadtrip.poi.foundFiltered': '{count} из {total} по пути',
|
||||
'roadtrip.poi.truncated': 'На {count} участках нашлось больше, чем помещается в один ответ — сузьте коридор, чтобы увидеть остальное.',
|
||||
'roadtrip.poi.truncated':
|
||||
'На {count} участках нашлось больше, чем помещается в один ответ — сузьте коридор, чтобы увидеть остальное.',
|
||||
'roadtrip.poi.search': 'Искать',
|
||||
'roadtrip.poi.searching': 'Поиск {done} из {total}',
|
||||
'roadtrip.poi.capped': 'Маршрут длинный — просмотрен только первый участок.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "Dra dagens slut längs rutten eller till en plats när du är online; högerklicka för att återställa det automatiska slutet.",
|
||||
'roadtrip.window.resetBoundaries': "Återställ automatiska dagsslut",
|
||||
'roadtrip.stops.section': 'Servicestopp',
|
||||
'roadtrip.stops.inDays': 'Visa även i Dagar',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Gäller alla servicestopp på resan. När detta är avstängt visas de endast i Roadtrip. Slå på för att visa dem i Dagar igen.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Dra dagens slut längs rutten eller till en plats när du är online; högerklicka för att återställa det automatiska slutet.',
|
||||
'roadtrip.window.resetBoundaries': 'Återställ automatiska dagsslut',
|
||||
'roadtrip.window.endHere': 'Avsluta dagen här',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Du kan avsluta dagen vid ett visst stopp i platsens detaljer när Roadtrip-läget och dagliga restider är aktiva.',
|
||||
@@ -92,11 +97,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Avgiftsvägar',
|
||||
'roadtrip.avoid.motorway': 'Motorvägar',
|
||||
'roadtrip.avoid.ferry': 'Färjor',
|
||||
'roadtrip.avoid.hint': 'De viktas ned, de förbjuds inte. När en körning inte kan ta sig runt använder den dem ändå, och dagen säger till.',
|
||||
'roadtrip.avoid.unavailable': 'Inte tillgängligt: den här installationen använder sin egen motor, som inte kan utesluta en vägklass.',
|
||||
'roadtrip.avoid.hint':
|
||||
'De viktas ned, de förbjuds inte. När en körning inte kan ta sig runt använder den dem ändå, och dagen säger till.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Inte tillgängligt: den här installationen använder sin egen motor, som inte kan utesluta en vägklass.',
|
||||
'roadtrip.avoid.badge': '{count} undviks',
|
||||
'roadtrip.avoid.missed': '{classes} går inte att undvika',
|
||||
'roadtrip.avoid.missedHint': 'Den här dagen har ingen väg runt, så körningen använder det. Övriga dagar undviker fortfarande det de kan.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Den här dagen har ingen väg runt, så körningen använder det. Övriga dagar undviker fortfarande det de kan.',
|
||||
'roadtrip.refuel.dry': 'Här tar tanken slut',
|
||||
'roadtrip.refuel.after': 'efter {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Vad du kör',
|
||||
@@ -138,7 +146,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} lång',
|
||||
'roadtrip.track.gap': '{distance} från den här dagen',
|
||||
'roadtrip.track.onDay': 'längs den här dagen',
|
||||
'roadtrip.track.explain': 'Körningen dras in på spåret med ett fåtal mellanpunkter. Befintliga mellanpunkter på de sträckorna ersätts.',
|
||||
'roadtrip.track.explain':
|
||||
'Körningen dras in på spåret med ett fåtal mellanpunkter. Befintliga mellanpunkter på de sträckorna ersätts.',
|
||||
'roadtrip.track.action': 'Följ det här spåret',
|
||||
'roadtrip.track.working': 'Anpassar körningen till spåret, runda {round}',
|
||||
'roadtrip.track.done': '{count} mellanpunkter satta. Körningen håller sig inom {distance} från spåret.',
|
||||
@@ -157,7 +166,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Inget längs vägen matchar ”{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Inget längs vägen matchar de här filtren.',
|
||||
'roadtrip.poi.foundFiltered': '{count} av {total} längs vägen',
|
||||
'roadtrip.poi.truncated': 'På {count} sträckor fanns fler än vad som får plats i ett svar – gör korridoren smalare för att se resten.',
|
||||
'roadtrip.poi.truncated':
|
||||
'På {count} sträckor fanns fler än vad som får plats i ett svar – gör korridoren smalare för att se resten.',
|
||||
'roadtrip.poi.search': 'Sök',
|
||||
'roadtrip.poi.searching': 'Söker {done} av {total}',
|
||||
'roadtrip.poi.capped': 'Rutten är lång – bara första sträckan genomsöktes.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "Çevrimiçiyken gün sonunu rota boyunca veya bir yere sürükleyin; otomatik sonu geri yüklemek için sağ tıklayın.",
|
||||
'roadtrip.window.resetBoundaries': "Otomatik gün sonlarını geri yükle",
|
||||
'roadtrip.stops.section': 'Hizmet durakları',
|
||||
'roadtrip.stops.inDays': 'Günler içinde de göster',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Bu seyahatin tüm hizmet durakları için geçerlidir. Kapalıyken yalnızca Roadtrip planında kalırlar. Günler içinde yeniden göstermek için açın.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Çevrimiçiyken gün sonunu rota boyunca veya bir yere sürükleyin; otomatik sonu geri yüklemek için sağ tıklayın.',
|
||||
'roadtrip.window.resetBoundaries': 'Otomatik gün sonlarını geri yükle',
|
||||
'roadtrip.window.endHere': 'Günü burada bitir',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Roadtrip modu ve günlük seyahat saatleri etkinken yer ayrıntılarından belirli bir durağı gün sonu olarak seçebilirsiniz.',
|
||||
@@ -92,11 +97,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Paralı yollar',
|
||||
'roadtrip.avoid.motorway': 'Otoyollar',
|
||||
'roadtrip.avoid.ferry': 'Feribotlar',
|
||||
'roadtrip.avoid.hint': 'Bunlar yasaklanmaz, ağırlığı düşürülür. Bir sürüşün etrafından dolaşma imkânı yoksa yine kullanır ve gün bunu söyler.',
|
||||
'roadtrip.avoid.unavailable': 'Kullanılamıyor: bu kurulum kendi motorunu kullanıyor ve bir yol sınıfını dışarıda bırakamıyor.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Bunlar yasaklanmaz, ağırlığı düşürülür. Bir sürüşün etrafından dolaşma imkânı yoksa yine kullanır ve gün bunu söyler.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Kullanılamıyor: bu kurulum kendi motorunu kullanıyor ve bir yol sınıfını dışarıda bırakamıyor.',
|
||||
'roadtrip.avoid.badge': '{count} kaçınılıyor',
|
||||
'roadtrip.avoid.missed': '{classes} kaçınılmaz',
|
||||
'roadtrip.avoid.missedHint': 'Bu günün etrafından dolaşan bir rota yok, bu yüzden sürüş oradan geçiyor. Diğer günler kaçınabildiğinden kaçınmayı sürdürüyor.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Bu günün etrafından dolaşan bir rota yok, bu yüzden sürüş oradan geçiyor. Diğer günler kaçınabildiğinden kaçınmayı sürdürüyor.',
|
||||
'roadtrip.refuel.dry': 'Depo burada bitiyor',
|
||||
'roadtrip.refuel.after': '{distance} sonra',
|
||||
'roadtrip.limit.vehicleLabel': 'Ne kullanıyorsun',
|
||||
@@ -138,7 +146,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': '{distance} uzunluğunda',
|
||||
'roadtrip.track.gap': 'bu günden {distance} uzakta',
|
||||
'roadtrip.track.onDay': 'bu gün boyunca',
|
||||
'roadtrip.track.explain': 'Sürüş, birkaç ara noktayla izin üzerine çekilir. Bu etaplardaki mevcut ara noktalar değiştirilir.',
|
||||
'roadtrip.track.explain':
|
||||
'Sürüş, birkaç ara noktayla izin üzerine çekilir. Bu etaplardaki mevcut ara noktalar değiştirilir.',
|
||||
'roadtrip.track.action': 'Bu izi takip et',
|
||||
'roadtrip.track.working': 'Sürüş ize uyarlanıyor, tur {round}',
|
||||
'roadtrip.track.done': '{count} ara nokta yerleştirildi. Sürüş, izin {distance} yakınında kalıyor.',
|
||||
@@ -157,7 +166,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Yol boyunca “{name}” ile eşleşen bir şey yok.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Yol boyunca bu filtrelere uyan bir yer yok.',
|
||||
'roadtrip.poi.foundFiltered': 'yol üzerinde {total} içinden {count}',
|
||||
'roadtrip.poi.truncated': '{count} bölümde bir yanıta sığandan fazlası vardı — geri kalanı görmek için koridoru daraltın.',
|
||||
'roadtrip.poi.truncated':
|
||||
'{count} bölümde bir yanıta sığandan fazlası vardı — geri kalanı görmek için koridoru daraltın.',
|
||||
'roadtrip.poi.search': 'Ara',
|
||||
'roadtrip.poi.searching': 'Aranıyor {done}/{total}',
|
||||
'roadtrip.poi.capped': 'Rota uzun — yalnızca ilk bölüm arandı.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "Онлайн перетягніть кінець дня вздовж маршруту або на місце; клацніть правою кнопкою, щоб відновити автоматичне завершення.",
|
||||
'roadtrip.window.resetBoundaries': "Відновити автоматичні завершення днів",
|
||||
'roadtrip.stops.section': 'Сервісні зупинки',
|
||||
'roadtrip.stops.inDays': 'Показувати також у розділі «Дні»',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Застосовується до всіх сервісних зупинок подорожі. Якщо вимкнено, вони залишаються лише в Roadtrip. Увімкніть, щоб знову показувати їх у розділі «Дні».',
|
||||
'roadtrip.window.dragHint':
|
||||
'Онлайн перетягніть кінець дня вздовж маршруту або на місце; клацніть правою кнопкою, щоб відновити автоматичне завершення.',
|
||||
'roadtrip.window.resetBoundaries': 'Відновити автоматичні завершення днів',
|
||||
'roadtrip.window.endHere': 'Завершити день тут',
|
||||
'roadtrip.window.endHereHint':
|
||||
'У деталях місця можна завершити день на вибраній зупинці, коли активні режим Roadtrip і щоденний час подорожі.',
|
||||
@@ -92,11 +97,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Платні дороги',
|
||||
'roadtrip.avoid.motorway': 'Автомагістралі',
|
||||
'roadtrip.avoid.ferry': 'Пороми',
|
||||
'roadtrip.avoid.hint': 'Це зниження ваги, а не заборона. Якщо в поїздки немає обходу, вона все одно пройде там, і день це покаже.',
|
||||
'roadtrip.avoid.unavailable': 'Недоступно: цей екземпляр використовує власний рушій, який не вміє виключати клас доріг.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Це зниження ваги, а не заборона. Якщо в поїздки немає обходу, вона все одно пройде там, і день це покаже.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Недоступно: цей екземпляр використовує власний рушій, який не вміє виключати клас доріг.',
|
||||
'roadtrip.avoid.badge': '{count} в обхід',
|
||||
'roadtrip.avoid.missed': '{classes} не обійти',
|
||||
'roadtrip.avoid.missedHint': 'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.',
|
||||
'roadtrip.refuel.dry': 'Тут закінчиться пальне',
|
||||
'roadtrip.refuel.after': 'через {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Чим ви їдете',
|
||||
@@ -138,11 +146,13 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': 'довжина {distance}',
|
||||
'roadtrip.track.gap': 'за {distance} від цього дня',
|
||||
'roadtrip.track.onDay': 'уздовж цього дня',
|
||||
'roadtrip.track.explain': 'Маршрут притягується до треку кількома проміжними точками. Наявні точки на цих ділянках буде замінено.',
|
||||
'roadtrip.track.explain':
|
||||
'Маршрут притягується до треку кількома проміжними точками. Наявні точки на цих ділянках буде замінено.',
|
||||
'roadtrip.track.action': 'Іти за цим треком',
|
||||
'roadtrip.track.working': 'Підганяємо маршрут до треку, коло {round}',
|
||||
'roadtrip.track.done': 'Поставлено проміжних точок: {count}. Маршрут тримається в межах {distance} від треку.',
|
||||
'roadtrip.track.capped': 'Поставлено проміжних точок: {count}. У найширшому місці маршрут відходить від треку на {distance}.',
|
||||
'roadtrip.track.capped':
|
||||
'Поставлено проміжних точок: {count}. У найширшому місці маршрут відходить від треку на {distance}.',
|
||||
'roadtrip.track.already': 'Маршрут уже йшов за цим треком.',
|
||||
'roadtrip.track.errorRoute': 'Сервіс маршрутів не відповів.',
|
||||
'roadtrip.track.errorSave': 'Не вдалося зберегти проміжні точки.',
|
||||
@@ -157,7 +167,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Дорогою ніщо не відповідає «{name}».',
|
||||
'roadtrip.poi.noneMatchFilters': 'На маршруті немає нічого, що відповідає цим фільтрам.',
|
||||
'roadtrip.poi.foundFiltered': '{count} з {total} дорогою',
|
||||
'roadtrip.poi.truncated': 'На {count} відрізках знайшлося більше, ніж уміщується в одну відповідь — звузьте коридор, щоб побачити решту.',
|
||||
'roadtrip.poi.truncated':
|
||||
'На {count} відрізках знайшлося більше, ніж уміщується в одну відповідь — звузьте коридор, щоб побачити решту.',
|
||||
'roadtrip.poi.search': 'Шукати',
|
||||
'roadtrip.poi.searching': 'Пошук {done} з {total}',
|
||||
'roadtrip.poi.capped': 'Маршрут довгий — переглянуто лише перший відрізок.',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "Khi trực tuyến, kéo điểm kết thúc ngày dọc tuyến đường hoặc đến một địa điểm; nhấp chuột phải để khôi phục kết thúc tự động.",
|
||||
'roadtrip.window.resetBoundaries': "Khôi phục kết thúc ngày tự động",
|
||||
'roadtrip.stops.section': 'Điểm dừng dịch vụ',
|
||||
'roadtrip.stops.inDays': 'Hiển thị cả trong Ngày',
|
||||
'roadtrip.stops.daysHint':
|
||||
'Áp dụng cho mọi điểm dừng dịch vụ của chuyến đi. Khi tắt, chúng chỉ xuất hiện trong Roadtrip. Bật để hiển thị lại trong Ngày.',
|
||||
'roadtrip.window.dragHint':
|
||||
'Khi trực tuyến, kéo điểm kết thúc ngày dọc tuyến đường hoặc đến một địa điểm; nhấp chuột phải để khôi phục kết thúc tự động.',
|
||||
'roadtrip.window.resetBoundaries': 'Khôi phục kết thúc ngày tự động',
|
||||
'roadtrip.window.endHere': 'Kết thúc ngày tại đây',
|
||||
'roadtrip.window.endHereHint':
|
||||
'Bạn có thể kết thúc ngày tại một điểm dừng trong chi tiết địa điểm khi chế độ Roadtrip và giờ di chuyển hằng ngày đang bật.',
|
||||
@@ -92,11 +97,14 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.avoid.toll': 'Đường thu phí',
|
||||
'roadtrip.avoid.motorway': 'Đường cao tốc',
|
||||
'roadtrip.avoid.ferry': 'Phà',
|
||||
'roadtrip.avoid.hint': 'Đây là hạ trọng số, không phải cấm. Khi một chặng không có đường vòng, nó vẫn đi qua và ngày đó sẽ báo.',
|
||||
'roadtrip.avoid.unavailable': 'Không khả dụng: bản cài này dùng máy chủ định tuyến riêng, vốn không bỏ qua được một loại đường.',
|
||||
'roadtrip.avoid.hint':
|
||||
'Đây là hạ trọng số, không phải cấm. Khi một chặng không có đường vòng, nó vẫn đi qua và ngày đó sẽ báo.',
|
||||
'roadtrip.avoid.unavailable':
|
||||
'Không khả dụng: bản cài này dùng máy chủ định tuyến riêng, vốn không bỏ qua được một loại đường.',
|
||||
'roadtrip.avoid.badge': 'tránh {count}',
|
||||
'roadtrip.avoid.missed': '{classes} không tránh được',
|
||||
'roadtrip.avoid.missedHint': 'Ngày này không có tuyến đường vòng, nên chặng đi qua đó. Những ngày khác vẫn tránh những gì có thể.',
|
||||
'roadtrip.avoid.missedHint':
|
||||
'Ngày này không có tuyến đường vòng, nên chặng đi qua đó. Những ngày khác vẫn tránh những gì có thể.',
|
||||
'roadtrip.refuel.dry': 'Hết xăng ở đây',
|
||||
'roadtrip.refuel.after': 'sau {distance}',
|
||||
'roadtrip.limit.vehicleLabel': 'Bạn lái xe gì',
|
||||
@@ -138,7 +146,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.track.length': 'dài {distance}',
|
||||
'roadtrip.track.gap': 'cách ngày này {distance}',
|
||||
'roadtrip.track.onDay': 'dọc theo ngày này',
|
||||
'roadtrip.track.explain': 'Hành trình được kéo về tuyến bằng một vài điểm trung gian. Các điểm hiện có trên những chặng đó sẽ bị thay thế.',
|
||||
'roadtrip.track.explain':
|
||||
'Hành trình được kéo về tuyến bằng một vài điểm trung gian. Các điểm hiện có trên những chặng đó sẽ bị thay thế.',
|
||||
'roadtrip.track.action': 'Đi theo tuyến này',
|
||||
'roadtrip.track.working': 'Đang khớp hành trình với tuyến, vòng {round}',
|
||||
'roadtrip.track.done': 'Đã đặt {count} điểm trung gian. Hành trình bám tuyến trong phạm vi {distance}.',
|
||||
@@ -157,7 +166,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.poi.noMatch': 'Không có gì trên đường khớp với “{name}”.',
|
||||
'roadtrip.poi.noneMatchFilters': 'Không có gì trên đường phù hợp với các bộ lọc này.',
|
||||
'roadtrip.poi.foundFiltered': '{count} trong {total} trên đường',
|
||||
'roadtrip.poi.truncated': 'Có {count} đoạn nhiều hơn mức một phản hồi chứa được — thu hẹp hành lang để xem phần còn lại.',
|
||||
'roadtrip.poi.truncated':
|
||||
'Có {count} đoạn nhiều hơn mức một phản hồi chứa được — thu hẹp hành lang để xem phần còn lại.',
|
||||
'roadtrip.poi.search': 'Tìm kiếm',
|
||||
'roadtrip.poi.searching': 'Đang tìm {done}/{total}',
|
||||
'roadtrip.poi.capped': 'Lộ trình dài — chỉ đoạn đầu được tìm kiếm.',
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "連線時可沿路線拖動當天終點或將其拖到某個地點,按右鍵可恢復自動終點。",
|
||||
'roadtrip.window.resetBoundaries': "恢復自動每日終點",
|
||||
'roadtrip.stops.section': '服務停靠點',
|
||||
'roadtrip.stops.inDays': '也顯示於每日行程',
|
||||
'roadtrip.stops.daysHint':
|
||||
'適用於此旅程的所有服務停靠點。關閉後只會顯示在 Roadtrip 計畫中。開啟即可再次顯示於每日行程。',
|
||||
'roadtrip.window.dragHint': '連線時可沿路線拖動當天終點或將其拖到某個地點,按右鍵可恢復自動終點。',
|
||||
'roadtrip.window.resetBoundaries': '恢復自動每日終點',
|
||||
'roadtrip.window.endHere': '在此結束當天行程',
|
||||
'roadtrip.window.endHereHint': '啟用 Roadtrip 模式和每日出行時間後,可在地點詳情中將指定停留點設為當天行程終點。',
|
||||
'roadtrip.window.endAt': '一天的結束地點',
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.window.dragHint': "在线时可沿路线拖动当天终点或将其拖到某个地点,右键点击可恢复自动终点。",
|
||||
'roadtrip.window.resetBoundaries': "恢复自动每日终点",
|
||||
'roadtrip.stops.section': '服务停靠点',
|
||||
'roadtrip.stops.inDays': '也显示于每日行程',
|
||||
'roadtrip.stops.daysHint':
|
||||
'适用于此旅程的所有服务停靠点。关闭后只会显示在 Roadtrip 计划中。开启即可再次显示于每日行程。',
|
||||
'roadtrip.window.dragHint': '在线时可沿路线拖动当天终点或将其拖到某个地点,右键点击可恢复自动终点。',
|
||||
'roadtrip.window.resetBoundaries': '恢复自动每日终点',
|
||||
'roadtrip.window.endHere': '在此结束当天行程',
|
||||
'roadtrip.window.endHereHint': '启用 Roadtrip 模式和每日出行时间后,可在地点详情中将指定停留点设为当天行程终点。',
|
||||
'roadtrip.window.endAt': '一天的结束地点',
|
||||
|
||||
@@ -144,11 +144,11 @@ const DRIFT_VARIANTS: Partial<Record<TrekWsEventName, Record<string, unknown>[]>
|
||||
};
|
||||
|
||||
describe('@trek/shared realtime event registry', () => {
|
||||
it('WSEVT-REG-001: pins the authoritative inventory counts (72 trip + 33 user = 105)', () => {
|
||||
it('WSEVT-REG-001: pins the authoritative inventory counts (73 trip + 32 user = 105)', () => {
|
||||
// 67th to 69th trip event: the three collab:link:* a shared link emits.
|
||||
// 70th and 71st: the road trip's vias and tracks, which used to be written silently.
|
||||
expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(72);
|
||||
expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(33);
|
||||
expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(73);
|
||||
expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(32);
|
||||
expect(TREK_WS_EVENT_NAMES).toHaveLength(105);
|
||||
});
|
||||
|
||||
|
||||
@@ -258,8 +258,9 @@ export const TREK_WS_EVENTS = {
|
||||
// ── Memories ─────────────────────────────────────────────────────────────
|
||||
'memories:updated': { scope: 'trip', payload: z.object({ userId: id }) },
|
||||
|
||||
'roadtripPreferences:changed': { scope: 'trip', payload: z.object({ preferences: roadtripPreferencesSchema }) },
|
||||
|
||||
// ── Notifications (user-scoped) ──────────────────────────────────────────
|
||||
'roadtripPreferences:changed': { scope: 'user', payload: z.object({ preferences: roadtripPreferencesSchema }) },
|
||||
'notification:new': { scope: 'user', payload: z.object({ notification: entity }) },
|
||||
'notification:updated': { scope: 'user', payload: z.object({ notification: entity }) },
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from 'zod';
|
||||
export const roadtripPlanRequestSchema = z.object({
|
||||
tripId: idSchema,
|
||||
includeGeometry: z.boolean().optional().default(false),
|
||||
settings: roadtripPreferencesSchema.optional().describe('Preview these personal preferences without saving them'),
|
||||
settings: roadtripPreferencesSchema.optional().describe('Preview these trip preferences without saving them'),
|
||||
});
|
||||
export const roadtripCorridorRequestSchema = z.object({
|
||||
tripId: idSchema,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const roadtripPreferencesSchema = z
|
||||
roadtrip_kwh_per_100: amount.max(1000).optional(),
|
||||
roadtrip_battery_degradation: amount.max(100).optional(),
|
||||
roadtrip_connect_days: z.boolean().optional(),
|
||||
roadtrip_service_stops_in_days: z.boolean().optional(),
|
||||
roadtrip_day_colors: z.boolean().optional(),
|
||||
roadtrip_avoid: z
|
||||
.string()
|
||||
@@ -37,3 +38,8 @@ export const roadtripPreferencesUpdateSchema = roadtripPreferencesSchema.refine(
|
||||
);
|
||||
export type RoadtripPreferences = z.infer<typeof roadtripPreferencesSchema>;
|
||||
export const ROADTRIP_PREFERENCE_KEYS = Object.keys(roadtripPreferencesSchema.shape) as (keyof RoadtripPreferences)[];
|
||||
|
||||
export const roadtripPreferencesResponseSchema = z.object({
|
||||
tripId: z.number().int().positive(),
|
||||
preferences: roadtripPreferencesSchema,
|
||||
});
|
||||
|
||||
@@ -193,12 +193,12 @@ The Roadtrip addon must be enabled. These tools work without an open browser. Th
|
||||
|---|---|---|
|
||||
| `get_roadtrip_context` | Saved days, visits, coordinates, stays, pinned times, vehicle preferences, route profiles, vias, tracks and manual boundaries | trips:read |
|
||||
| `calculate_roadtrip` | Calculated days, arrivals, departures, automatic pauses, driving warnings and range warnings; optional geometry | trips:read |
|
||||
| `get_roadtrip_settings` | Personal driving preferences | settings:read |
|
||||
| `update_roadtrip_settings` | Patch personal driving preferences, preserving other settings | settings:write |
|
||||
| `get_roadtrip_settings` | Shared driving preferences for the specified trip | trips:read |
|
||||
| `update_roadtrip_settings` | Patch shared trip driving preferences, preserving other settings | trips:write |
|
||||
| `search_roadtrip_corridor` | Fuel, charging, rest areas, campsites, food, sights or hotels along a day | trips:read |
|
||||
| `update_route_via` | Move an existing routing handle and optionally change its outgoing leg | trips:write |
|
||||
|
||||
Driving preferences include daily times, day-ending mode, leg/day driving limits, fuel or electric vehicle specifications, fallback range, fill percentage, avoidance and route display. They belong to the requesting account and apply across its trips. Manual endings belong to the shared trip. Only authorized trip editors can change visits, vias or endings. Fixed visit times retain priority over automatic times. Turning daily travel times off preserves saved endings but stops applying them.
|
||||
Driving preferences include daily times, day-ending mode, leg/day driving limits, fuel or electric vehicle specifications, fallback range, fill percentage, avoidance and route display. They belong to the specified trip and apply equally to all its travellers. Both settings tools require tripId. Changing driving preferences requires day-edit permission, as do changes to visits, vias and endings. Fixed visit times retain priority over automatic times. Turning daily travel times off preserves saved endings but stops applying them.
|
||||
|
||||
Calculation distances are metres, route durations seconds, and stays minutes. Settings use kilometres, litres, kWh, consumption per 100 km and percentages regardless of display units. Zero clears a numeric limit; an empty daily time disables the automatic window. Vehicle specifications take precedence over fallback range when complete. The optional calculation settings are a preview and are never saved. Calculations support up to 150 visits and 100 waypoints per routing run (30 for plugin profiles). Routing calls are paced and cached. Missing coordinates, provider failures and schedule conflicts are reported explicitly; incomplete totals must not be presented as a complete itinerary. Avoidance is a routing preference, and `avoidMissed` identifies requested classes that could not be avoided, including when Valhalla falls back to OSRM.
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ The active tab is saved in `sessionStorage` per trip, so switching between trips
|
||||
|
||||
Nearby station search results group into count badges when zoomed out. Click a badge to zoom into its stations. Stations that still overlap at close zoom appear in a selectable list. Planned stops, including photo markers, also group into count badges when zoomed out. Day endings remain separate. This works with all supported map providers in Roadtrip mode.
|
||||
|
||||
In the Roadtrip view, open **Driving settings** and enter a **Day start** and **Day end** in HH:mm format. Both values enable automatic daily scheduling and connect the drives between days. Clear either field to return to the existing schedule. These are personal driving preferences and apply to your Roadtrip views.
|
||||
In the Roadtrip view, open **Driving settings** and enter a **Day start** and **Day end** in HH:mm format. Both values enable automatic daily scheduling and connect the drives between days. Clear either field to return to the existing schedule. These preferences belong to the trip. All travellers see the same daily schedule, vehicle settings and driving limits. Members with permission to edit days can change them. Existing trips inherit their owner's previous values once; later changes affect only that trip.
|
||||
|
||||
Driving pauses at the end time and resumes from the same location at the next day's start time. Stops and visit durations determine where each pause falls. Editing, adding or removing stops recalculates the pauses and subsequent arrivals. If a visit crosses the end time, the remaining visit continues there the next morning before driving resumes.
|
||||
|
||||
@@ -67,7 +67,7 @@ While online, drag an end-of-day map label along the driving route or onto a vis
|
||||
|
||||
The map marks pauses with a moon and day number beside a place or above the route. These markers disappear when zoomed out beyond level 6.
|
||||
|
||||
> **AI / MCP:** `get_roadtrip_context` reads saved days, visits, pins, stays, vias, followed tracks and day endings. `calculate_roadtrip` calculates arrivals, day splits, driving limits and vehicle-range warnings on the server, using the same planning logic as the browser. No open TREK tab is needed. Pauses remain calculated markers, never places or bookings. `get_roadtrip_settings` and `update_roadtrip_settings` read and change your personal driving preferences; these apply to all your trips and update connected views. Place and assignment tools edit stops, stay durations, times and ordering. `set_assignment_end_day` and `set_day_boundary` set or clear individual day endings. `search_roadtrip_corridor` finds stops along a calculated day, with name, charging and route-section filters. `import_trip_gpx` imports tracks; the via tools edit their routing handles. See [MCP-Addon-Tools](MCP-Addon-Tools) for units, scopes and incomplete results.
|
||||
> **AI / MCP:** `get_roadtrip_context` reads saved days, visits, pins, stays, vias, followed tracks and day endings. `calculate_roadtrip` calculates arrivals, day splits, driving limits and vehicle-range warnings on the server, using the same planning logic as the browser. No open TREK tab is needed. Pauses remain calculated markers, never places or bookings. `get_roadtrip_settings` and `update_roadtrip_settings` read and change the shared driving preferences for the specified trip; changes update connected views for all its travellers. Place and assignment tools edit stops, stay durations, times and ordering. `set_assignment_end_day` and `set_day_boundary` set or clear individual day endings. `search_roadtrip_corridor` finds stops along a calculated day, with name, charging and route-section filters. `import_trip_gpx` imports tracks; the via tools edit their routing handles. See [MCP-Addon-Tools](MCP-Addon-Tools) for units, scopes and incomplete results.
|
||||
|
||||
## Mobile Layout
|
||||
|
||||
@@ -99,3 +99,5 @@ When you first open a trip, a brief loading screen appears while the planner dat
|
||||
- [Weather-Forecasts](Weather-Forecasts)
|
||||
- [Reservations-and-Bookings](Reservations-and-Bookings)
|
||||
- [Admin-Addons](Admin-Addons)
|
||||
|
||||
Driving settings also controls **Show in Days too** under **Service stops**. It is on by default and applies to every service stop in the trip, including existing stops. Turn it off to keep service stops exclusively in the Roadtrip view and omit them from the normal Days view and its route. Turning it on restores their visibility without creating duplicates. The setting is shared with fellow travellers and can also be changed through `update_roadtrip_settings` using `roadtrip_service_stops_in_days`.
|
||||
|
||||
Reference in New Issue
Block a user