-
- {routes.days.length === 0 ? (
-
-
-
{t('roadtrip.empty.title')}
-
{t('roadtrip.empty.body')}
-
- ) : (
- routes.days.map(day => (
+ // The totals hold still while the days move under them: they are the answer to "how
+ // long is this trip", and an answer that scrolls away is one you have to go back for.
+
+
+
+
+
+ {routes.days.map(day => (
- ))
- )}
+ ))}
+ {routes.quietDays.map(day => (
+
+ ))}
+
)
}
diff --git a/client/src/components/Roadtrip/RoadtripStayModal.tsx b/client/src/components/Roadtrip/RoadtripStayModal.tsx
new file mode 100644
index 000000000..66c00a7d6
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripStayModal.tsx
@@ -0,0 +1,219 @@
+import React, { useEffect, useMemo, useState } from 'react'
+import { Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
+import Modal from '../shared/Modal'
+import { useTranslation } from '../../i18n/TranslationContext'
+import { useSettingsStore } from '../../store/settingsStore'
+import { formatDurationShort, formatClock, parseClock } from './roadtripModel'
+import { formatClockTime } from '../../utils/formatters'
+
+/**
+ * The lengths a stop usually takes, so the common answer is one tap.
+ *
+ * Kept short on purpose: a longer list reads as a form, and anything not on it is what
+ * the slider is for.
+ */
+const PRESETS = [15, 30, 45, 60, 90, 120]
+
+/** As far as the slider goes, in minutes. Beyond four hours it is a day, not a stop. */
+const MAX = 240
+/** The step the slider and the two buttons move in. */
+const STEP = 5
+
+/** The stop being given a length. */
+export interface StayDraft {
+ placeId: number
+ name: string
+ minutes: number | null
+ /** When the drive gets here, so the dialog can show what the stay pushes back. */
+ arrival?: string | null
+}
+
+interface RoadtripStayModalProps {
+ stop: StayDraft | null
+ onClose: () => void
+ /** Zero clears it: the rail reads "no stay" and "nothing planned" as the same thing. */
+ onSave: (placeId: number, minutes: number) => Promise
| void
+}
+
+/** The slider's own track and thumb, drawn from tokens so both themes get it right. */
+const SLIDER = [
+ 'h-1.5 w-full cursor-pointer appearance-none rounded-full bg-surface-tertiary',
+ '[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5',
+ '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full',
+ '[&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:shadow-card',
+ '[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[var(--bg-card)]',
+ '[&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:border-0',
+ '[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent',
+].join(' ')
+
+/**
+ * How long the traveller stays at one stop.
+ *
+ * A road-trip idea rather than a property of the place: it is what pushes every later
+ * arrival back, so it belongs beside the drive times rather than in the place form, which
+ * is why this opens from the rail and nowhere else.
+ *
+ * Built around the number instead of around a field. The chosen length is the largest
+ * thing on screen and updates as the slider moves, and under it the dialog answers the
+ * question the number is actually being chosen for: given when the drive arrives, when
+ * does it leave again. Typing minutes into a box could never show that.
+ *
+ * The value is `places.duration_minutes`, which the schedule has read for years and
+ * nothing has ever been able to write. Cleared as a zero rather than a null, because the
+ * update statement folds a null into "leave it alone" — a null could give a stop a stay
+ * but never take one away.
+ */
+export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripStayModalProps): React.ReactElement | null {
+ const { t } = useTranslation()
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+ const [minutes, setMinutes] = useState(0)
+ const [saving, setSaving] = useState(false)
+
+ // Reopened on a different stop, so it starts from that stop's own value rather than
+ // from whatever the last one was left on.
+ useEffect(() => { setMinutes(stop?.minutes ?? 0) }, [stop])
+
+ // What the stay does to the day, worked out live: the arrival is fixed by the drive,
+ // the departure is the only thing this dialog moves.
+ const times = useMemo(() => {
+ const arrival = parseClock(stop?.arrival)
+ if (arrival === null) return null
+ return {
+ arrive: formatClockTime(formatClock(arrival), is12h),
+ leave: formatClockTime(formatClock(arrival + minutes), is12h),
+ }
+ }, [stop?.arrival, minutes, is12h])
+
+ if (!stop) return null
+
+ const commit = async (value: number) => {
+ if (saving) return
+ setSaving(true)
+ try {
+ await onSave(stop.placeId, value)
+ onClose()
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const nudge = (delta: number) => setMinutes(m => Math.min(MAX, Math.max(0, m + delta)))
+
+ return (
+
+
+ {t('roadtrip.stop.stay')}
+
+ }>
+
+
{stop.name}
+
+ {/* The number, the way a timer shows one. Minus and plus flank it so a value can
+ be nudged without hunting for the slider's thumb. */}
+
+
nudge(-STEP)}
+ disabled={minutes === 0}
+ aria-label={`-${STEP}`}
+ className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
+ >
+
+
+
+ {minutes > 0 ? formatDurationShort(minutes * 60) : t('roadtrip.stay.none')}
+
+
nudge(STEP)}
+ disabled={minutes >= MAX}
+ aria-label={`+${STEP}`}
+ className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
+ >
+
+
+
+
+
setMinutes(Number(e.target.value))}
+ aria-label={t('roadtrip.stay.custom')}
+ className={SLIDER}
+ />
+
+ {/* What the stay costs the rest of the day. The arrival cannot move — the drive
+ decides it — so the arrow shows the one end this dialog does move. */}
+ {times ? (
+
+
+
+ {t('roadtrip.stay.arrive')}
+
+ {times.arrive}
+
+
+
+
+ {t('roadtrip.stay.leave')}
+
+ {times.leave}
+
+
+ ) : null}
+
+
+ {PRESETS.map(value => (
+ setMinutes(value)}
+ className={`rounded-full border px-3 py-1 text-caption font-medium tabular-nums transition-colors ${
+ minutes === value
+ ? 'border-accent bg-accent text-accent-text'
+ : 'border-edge bg-surface-card text-content-secondary hover:border-content-faint'
+ }`}
+ >
+ {formatDurationShort(value * 60)}
+
+ ))}
+
+
+
+
+ {/* Removing is the same write as setting one, so it sits with the other actions
+ rather than behind a confirmation. */}
+ void commit(0)}
+ className="rounded-lg px-3 py-2 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40"
+ >
+ {t('roadtrip.stay.none')}
+
+
+ {t('common.cancel')}
+
+ void commit(minutes)}
+ className="rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text transition-opacity disabled:opacity-40"
+ >
+ {t('common.save')}
+
+
+
+ )
+}
diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.test.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.test.tsx
new file mode 100644
index 000000000..e702c3872
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripStopPopup.test.tsx
@@ -0,0 +1,171 @@
+import React from 'react'
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
+import { TranslationProvider } from '../../i18n'
+import RoadtripStopPopup, { type RoadtripStopDraft } from './RoadtripStopPopup'
+import type { CorridorPoi } from './useCorridorPois'
+
+const wrap = (ui: React.ReactElement) => render({ui} )
+
+function poi(over: Partial = {}): CorridorPoi {
+ return {
+ osm_id: 'node:9',
+ name: 'Aral Autohof',
+ lat: 53.1,
+ lng: 10.2,
+ category: 'fuel',
+ poi_type: 'amenity=fuel',
+ address: null,
+ website: null,
+ phone: null,
+ opening_hours: null,
+ cuisine: null,
+ source: 'openstreetmap',
+ offRouteKm: 1.2,
+ alongKm: 120,
+ ...over,
+ } as CorridorPoi
+}
+
+const draft = (over: Partial = {}): RoadtripStopDraft => ({
+ poi: poi(),
+ dayId: 4,
+ position: 2,
+ dayNumber: 1,
+ ...over,
+})
+
+const noop = {
+ onClose: vi.fn(),
+ onSave: vi.fn(),
+ onMoreDetails: vi.fn(),
+}
+
+describe('RoadtripStopPopup', () => {
+ it('FE-ROADTRIP-STOPPOPUP-001: without a draft there is nothing to show', () => {
+ const { container } = wrap( )
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('FE-ROADTRIP-STOPPOPUP-002: says where the stop will land, counting from one', () => {
+ wrap( )
+
+ expect(screen.getByText('Aral Autohof')).toBeInTheDocument()
+ // Position 2 in the array is the third stop for a reader.
+ expect(screen.getByText('Day 3, as stop 3')).toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-STOPPOPUP-003: the kind the search found is preselected', () => {
+ wrap( )
+
+ expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'true')
+ expect(screen.getByRole('button', { name: /Charging/ })).toHaveAttribute('aria-pressed', 'false')
+ })
+
+ it('FE-ROADTRIP-STOPPOPUP-004: saving reports the kind and how long it takes', async () => {
+ const onSave = vi.fn()
+ wrap( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }))
+ // Fuel suggests ten minutes; nobody plans an hour for it.
+ await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 10 }))
+ })
+
+ it('FE-ROADTRIP-STOPPOPUP-005: picking another kind brings its own duration along', async () => {
+ const onSave = vi.fn()
+ wrap( )
+
+ // Charging is half an hour, not the ten minutes a tank takes.
+ fireEvent.click(screen.getByRole('button', { name: /Charging/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }))
+ await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'charging', dwellMinutes: 30 }))
+ })
+
+ it('FE-ROADTRIP-STOPPOPUP-006: a duration chosen by hand survives the save', async () => {
+ const onSave = vi.fn()
+ wrap( )
+
+ fireEvent.click(screen.getByRole('button', { name: /45 min/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }))
+ await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
+ })
+
+ it('FE-ROADTRIP-STOPPOPUP-007: unpicking the kind leaves an ordinary place', async () => {
+ const onSave = vi.fn()
+ wrap( )
+
+ fireEvent.click(screen.getByRole('button', { name: /Fuel/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }))
+ await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: null, dwellMinutes: 10 }))
+ })
+
+ /**
+ * Every category the corridor can search for, with the name its button carries.
+ *
+ * The keys are the contract with `CATEGORY_OSM_FILTERS` on the server, which labels
+ * each hit with the category its own OSM tag matched. A key the popup does not know
+ * preselects nothing, and the stop is saved as an ordinary place — which is exactly
+ * how food and sights used to end up as numbered destinations in the middle of a drive.
+ */
+ const CORRIDOR_KINDS: [string, string][] = [
+ ['fuel', 'Fuel'],
+ ['charging', 'Charging'],
+ ['rest_area', 'Rest area'],
+ ['campsite', 'Campsite'],
+ ['restaurant', 'Food'],
+ ['sights', 'Sights'],
+ ]
+
+ it.each(CORRIDOR_KINDS)('FE-ROADTRIP-STOPPOPUP-008: a %s hit preselects its own kind', (category, label) => {
+ const { unmount } = wrap( )
+
+ expect(screen.getByRole('button', { name: new RegExp(label) })).toHaveAttribute('aria-pressed', 'true')
+ // Every other kind is off, so the preselect cannot be reading the wrong row. Scoped
+ // to the kind buttons: the dwell presets use `aria-pressed` for their own state.
+ for (const [, other] of CORRIDOR_KINDS) {
+ if (other === label) continue
+ expect(screen.getByRole('button', { name: new RegExp(other) })).toHaveAttribute('aria-pressed', 'false')
+ }
+ unmount()
+ })
+
+ it('FE-ROADTRIP-STOPPOPUP-012: a kind nobody knows preselects nothing', () => {
+ wrap( )
+
+ expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'false')
+ expect(screen.getByRole('button', { name: /Campsite/ })).toHaveAttribute('aria-pressed', 'false')
+ })
+
+ it('FE-ROADTRIP-STOPPOPUP-009: warns when the same place is already on the trip', () => {
+ const { unmount } = wrap( )
+ expect(screen.getByText(/already on this trip/)).toBeInTheDocument()
+
+ unmount()
+ wrap( )
+ expect(screen.queryByText(/already on this trip/)).not.toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-STOPPOPUP-010: the way out to the full form is offered and reported', () => {
+ const onMoreDetails = vi.fn()
+ wrap( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'More details' }))
+ expect(onMoreDetails).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-ROADTRIP-STOPPOPUP-011: a save in flight cannot be fired twice', async () => {
+ let release: () => void = () => {}
+ const onSave = vi.fn(() => new Promise(resolve => { release = resolve }))
+ wrap( )
+
+ const add = screen.getByRole('button', { name: 'Add' })
+ fireEvent.click(add)
+ await waitFor(() => expect(add).toBeDisabled())
+ fireEvent.click(add)
+ expect(onSave).toHaveBeenCalledTimes(1)
+
+ // Settle it inside act, or React re-enables the button after the test has finished.
+ await act(async () => { release() })
+ expect(add).not.toBeDisabled()
+ })
+})
diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.tsx
new file mode 100644
index 000000000..97bd76a91
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripStopPopup.tsx
@@ -0,0 +1,188 @@
+import React, { useState } from 'react'
+import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, Hourglass, AlertTriangle, type LucideIcon } from 'lucide-react'
+import Modal from '../shared/Modal'
+import { useTranslation } from '../../i18n/TranslationContext'
+import { formatDurationShort, SERVICE_COLORS } from './roadtripModel'
+import type { CorridorPoi } from './useCorridorPois'
+import type { RoadtripStopType } from '@trek/shared'
+
+/**
+ * The four kinds of stop, with the icon and colour they already carry in the corridor
+ * search, and how long each one usually takes.
+ *
+ * Deliberately not TREK's place categories: those are the traveller's own editable list,
+ * shared across the whole instance. Refuelling is not a taste, it is a fact about the
+ * place, so it lives in `places.stop_type` and keeps the palette the search gave it.
+ *
+ * The colours are the road-trip palette from `poiCategories.ts` and are the same in both
+ * themes on purpose — a brand-neutral fuel blue reads as fuel either way.
+ */
+const STOP_KINDS: { key: RoadtripStopType; labelKey: string; Icon: LucideIcon; color: string; minutes: number }[] = [
+ { key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel, minutes: 10 },
+ { key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging, minutes: 30 },
+ { key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area, minutes: 20 },
+ { key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent, color: SERVICE_COLORS.campsite, minutes: 60 },
+ // The corridor can find these two as well, and anything picked off "along the route"
+ // is a pause on the way rather than a destination — so they get a kind like the rest.
+ { key: 'restaurant', labelKey: 'roadtrip.poi.food', Icon: Utensils, color: SERVICE_COLORS.restaurant, minutes: 45 },
+ { key: 'sights', labelKey: 'roadtrip.poi.sights', Icon: Camera, color: SERVICE_COLORS.sights, minutes: 30 },
+]
+
+/** How long to stand still, offered as the few answers anyone actually gives. */
+const DWELL_CHOICES = [5, 10, 20, 30, 45, 60]
+
+export interface RoadtripStopDraft {
+ poi: CorridorPoi
+ dayId: number
+ /** Where in the day's chain it goes, worked out from how far along the drive it sits. */
+ position: number
+ dayNumber: number
+}
+
+interface RoadtripStopPopupProps {
+ draft: RoadtripStopDraft | null
+ /** Names of stops already on this trip that came from the same OSM object. */
+ duplicateName?: string | null
+ onClose: () => void
+ onSave: (input: { stopType: RoadtripStopType | null; dwellMinutes: number }) => Promise | void
+ /** Opens the full place form instead, carrying what has been filled in so far. */
+ onMoreDetails: () => void
+}
+
+/**
+ * Adding something found along the drive, without the full place form.
+ *
+ * The form is right for a place you are planning a day around; it is wrong for a petrol
+ * station. Everything it asks for — category, price, photo, notes, files — is empty for a
+ * refuelling stop, and in road trip mode it also drops the place into the unplanned pool,
+ * which neither column shows. This asks the two questions that matter (what kind of stop,
+ * how long) and says where it will land, with a way out to the full form for the rest.
+ */
+export default function RoadtripStopPopup({
+ draft, duplicateName, onClose, onSave, onMoreDetails,
+}: RoadtripStopPopupProps): React.ReactElement | null {
+ const { t } = useTranslation()
+ const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category)
+ const [stopType, setStopType] = useState(suggested?.key ?? null)
+ const [dwell, setDwell] = useState(suggested?.minutes ?? 30)
+ const [saving, setSaving] = useState(false)
+
+ if (!draft) return null
+
+ const kind = STOP_KINDS.find(k => k.key === stopType)
+
+ const submit = async (): Promise => {
+ setSaving(true)
+ try {
+ await onSave({ stopType, dwellMinutes: dwell })
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+
+ {kind ? : }
+
+
+
{draft.poi.name}
+
+ {t('roadtrip.stop.landsOn', { day: draft.dayNumber, position: draft.position + 1 })}
+
+
+
+
+ {duplicateName ? (
+ // The full place form warns about duplicates; losing that warning would make
+ // this the easiest way to add the same petrol station twice.
+
+
+ {t('roadtrip.stop.duplicate', { name: duplicateName })}
+
+ ) : null}
+
+
+
+ {t('roadtrip.stop.kind')}
+
+
+ {STOP_KINDS.map(({ key, labelKey, Icon }) => {
+ const on = stopType === key
+ return (
+ {
+ setStopType(on ? null : key)
+ // Picking a kind is also picking how long it takes, until the user
+ // says otherwise — a charge is not a fuel stop.
+ if (!on) setDwell(STOP_KINDS.find(k => k.key === key)!.minutes)
+ }}
+ className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption transition-colors ${
+ on
+ ? 'border-transparent bg-accent font-semibold text-accent-text'
+ : 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
+ }`}
+ >
+
+ {t(labelKey)}
+
+ )
+ })}
+
+
+
+
+
+ {t('roadtrip.stop.stay')}
+
+
+ {DWELL_CHOICES.map(minutes => (
+ setDwell(minutes)}
+ className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-caption tabular-nums transition-colors ${
+ dwell === minutes
+ ? 'border-transparent bg-accent font-semibold text-accent-text'
+ : 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
+ }`}
+ >
+
+ {formatDurationShort(minutes * 60)}
+
+ ))}
+
+
+
+
+
+ {t('roadtrip.stop.moreDetails')}
+
+
+ {t('roadtrip.poi.add')}
+
+
+
+
+ )
+}
diff --git a/client/src/components/Roadtrip/alternativeColors.ts b/client/src/components/Roadtrip/alternativeColors.ts
new file mode 100644
index 000000000..0d0b25b5c
--- /dev/null
+++ b/client/src/components/Roadtrip/alternativeColors.ts
@@ -0,0 +1,28 @@
+/**
+ * How the offered routes are drawn, after Apple Maps.
+ *
+ * All of them blue, not one blue and the rest grey: they are all the same road network
+ * and all equally drivable, so the difference between them is emphasis, not category. The
+ * chosen one is the full-strength blue the route already uses; the others are a pale blue
+ * that stays visible on a grey basemap — Positron is almost entirely greys, and a grey
+ * line on it disappears, which is exactly what happened when these were grey.
+ *
+ * Fixed values rather than theme tokens: they have to match the blue the route itself is
+ * drawn in, which is fixed too, and a token that shifted with the colour scheme would
+ * break the pairing.
+ */
+
+/** The route as chosen — same blue as the drawn trip route. */
+export const ALT_PRIMARY = '#0a84ff'
+/** The other offers: unmistakably blue, unmistakably secondary. */
+export const ALT_SECONDARY = '#7eb8f0'
+/** Under both, so either reads on a pale map, a dark one or a satellite tile. */
+export const ALT_CASING = '#ffffff'
+
+/**
+ * The label colours, again after Apple: the chosen route's time is a filled blue pill,
+ * the alternatives' are near-black. Both carry white text.
+ */
+export const ALT_LABEL_PRIMARY_BG = '#0a84ff'
+export const ALT_LABEL_SECONDARY_BG = '#2b2f36'
+export const ALT_LABEL_TEXT = '#ffffff'
diff --git a/client/src/components/Roadtrip/alternativeOverlays.ts b/client/src/components/Roadtrip/alternativeOverlays.ts
new file mode 100644
index 000000000..ff9e82f0f
--- /dev/null
+++ b/client/src/components/Roadtrip/alternativeOverlays.ts
@@ -0,0 +1,125 @@
+import { formatDurationShort } from './roadtripModel'
+import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
+import type { RouteAlternative } from '../Map/RouteCalculator'
+
+/** One offered route, ready to draw: the line, its colour, and where its label sits. */
+export interface AlternativeOverlay {
+ index: number
+ coordinates: [number, number][]
+ color: string
+ /** The drive time, the way Apple Maps puts it on the road itself. */
+ label: string
+ /** The second line under it — "Fastest", "Current" — or empty for the plain ones. */
+ note: string
+ /** How long this way takes and how far it is, for the list beside the map. */
+ duration: number
+ distance: number
+ /** Seconds more than the quickest offer; negative never happens by construction. */
+ slowerThanQuickest: number
+ labelBg: string
+ /** Where to hang the label — a point on this route and on no other. */
+ at: { lat: number; lng: number }
+}
+
+/**
+ * Squared distance between two points, in degrees, with longitude scaled by latitude.
+ *
+ * Only ever compared against other values from this same function, so degrees are fine
+ * and a haversine would cost more for an answer nobody reads.
+ */
+function roughDist(a: [number, number], b: [number, number]): number {
+ const dLat = a[0] - b[0]
+ const dLng = (a[1] - b[1]) * Math.cos((a[0] * Math.PI) / 180)
+ return dLat * dLat + dLng * dLng
+}
+
+/**
+ * The point of `line` that lies furthest from every one of `others`.
+ *
+ * This is where a route is unmistakably itself. Anchoring a label at the geometric
+ * midpoint instead put it wherever that happened to fall — and on a pair of routes that
+ * split early and rejoin, the midpoint sits on the shared stretch, so the label appeared
+ * to be pinned to the wrong road.
+ *
+ * Sampled rather than exhaustive: alternatives differ over kilometres, and comparing two
+ * thousand-point lines in full costs milliseconds for a pixel of accuracy.
+ */
+function mostDistinctPoint(
+ line: [number, number][],
+ others: [number, number][][],
+): { lat: number; lng: number } | null {
+ if (!line.length) return null
+ if (!others.length) {
+ const mid = line[Math.floor(line.length / 2)]
+ return mid ? { lat: mid[0], lng: mid[1] } : null
+ }
+ const sample = (l: [number, number][]) => Math.max(1, Math.floor(l.length / 150))
+ let best: [number, number] | null = null
+ let bestScore = -1
+ for (let i = 0; i < line.length; i += sample(line)) {
+ // How far this point is from the NEAREST other route: a point close to any of them
+ // is not distinctive, however far it sits from the rest.
+ let nearestOfAll = Infinity
+ for (const other of others) {
+ let nearest = Infinity
+ const step = sample(other)
+ for (let j = 0; j < other.length; j += step) {
+ const d = roughDist(line[i], other[j])
+ if (d < nearest) nearest = d
+ }
+ if (nearest < nearestOfAll) nearestOfAll = nearest
+ }
+ if (nearestOfAll > bestScore) { bestScore = nearestOfAll; best = line[i] }
+ }
+ return best ? { lat: best[0], lng: best[1] } : null
+}
+
+/**
+ * The routes as the map should draw them, built once so every renderer agrees.
+ *
+ * Each label hangs where its own route is furthest from all the others, so a label always
+ * sits on a stretch only that route uses. Two routes that share their first and last
+ * thirds still get their labels on the middle third, where they actually differ.
+ */
+export function buildAlternativeOverlays(
+ routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
+ labels: { fastest: string; current: string; noMotorway: string; noToll: string },
+): AlternativeOverlay[] {
+ if (!routes?.length) return []
+ // A single answer is not a choice; drawing it would just double the route already there.
+ if (routes.length < 2) return []
+
+ // The quickest of what came back, which is what Apple calls out — not necessarily the
+ // first entry, since the road currently driven is put at the top when there is one.
+ const quickest = routes.reduce((best, r, i) => (r.duration < routes[best].duration ? i : best), 0)
+ const anyCurrent = routes.some(r => r.current)
+
+ return routes.map((route, index) => {
+ const others = routes.filter((_, i) => i !== index).map(r => r.coordinates)
+ const at = mostDistinctPoint(route.coordinates, others)
+ // Blue is the road you are on: the one currently driven, or the router's own pick
+ // when nothing has been bent. Everything else is the pale blue of an offer.
+ const primary = route.current || (!anyCurrent && index === quickest)
+ return {
+ index,
+ coordinates: route.coordinates,
+ color: primary ? ALT_PRIMARY : ALT_SECONDARY,
+ label: formatDurationShort(route.duration),
+ // What this way IS beats what it is not: a road offered because the motorway was
+ // left out of it says so, rather than being labelled by how much slower it is —
+ // that is the reason somebody would take it.
+ note: route.current
+ ? labels.current
+ : route.avoids === 'motorway'
+ ? labels.noMotorway
+ : route.avoids === 'toll'
+ ? labels.noToll
+ : index === quickest ? labels.fastest : '',
+ duration: route.duration,
+ distance: route.distance,
+ slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
+ labelBg: primary ? ALT_LABEL_PRIMARY_BG : ALT_LABEL_SECONDARY_BG,
+ at: at ?? { lat: 0, lng: 0 },
+ }
+ })
+}
diff --git a/client/src/components/Roadtrip/reanchorVias.test.ts b/client/src/components/Roadtrip/reanchorVias.test.ts
new file mode 100644
index 000000000..050a58615
--- /dev/null
+++ b/client/src/components/Roadtrip/reanchorVias.test.ts
@@ -0,0 +1,126 @@
+import { describe, it, expect } from 'vitest'
+import {
+ reanchorAfterInsert,
+ reanchorAfterRemove,
+ reanchorAfterReorder,
+ isEmptyReanchoring,
+ type AnchoredVia,
+} from './roadtripModel'
+
+/**
+ * A via lives at a position in the day's stop list, and that position is resolved fresh
+ * every time the day is routed. These tests pin what happens to it when the list moves
+ * under it — the arithmetic that decides whether a reshaped drive survives adding a
+ * petrol station to it.
+ */
+const via = (id: number, after: number, lat = 53, lng = 10): AnchoredVia =>
+ ({ id, after_order_index: after, lat, lng })
+
+/** The anchors as a plain map, so a test reads as "which via ended up where". */
+const anchors = (moved: { id: number; after_order_index: number }[]) =>
+ Object.fromEntries(moved.map(v => [v.id, v.after_order_index]))
+
+describe('reanchorAfterInsert', () => {
+ it('FE-REANCHOR-001: a stop added past a via leaves it alone', () => {
+ // Legs 0,1,2 — the via is on leg 0 and the new stop lands at position 3.
+ const plan = reanchorAfterInsert([via(1, 0)], 3, () => true)
+ expect(isEmptyReanchoring(plan)).toBe(true)
+ })
+
+ it('FE-REANCHOR-002: a stop added before a via pushes it one leg later', () => {
+ const plan = reanchorAfterInsert([via(1, 2), via(2, 3)], 1, () => true)
+ expect(anchors(plan.vias)).toEqual({ 1: 3, 2: 4 })
+ expect(plan.remove).toEqual([])
+ })
+
+ it('FE-REANCHOR-003: on the split leg, a via before the new stop keeps its leg', () => {
+ // The reported bug: Hamburg → [via] → Berlin, and a fuel stop lands between them at
+ // position 1. The via is passed before the fuel stop, so it belongs to the first half.
+ const plan = reanchorAfterInsert([via(1, 0)], 1, () => true)
+ expect(isEmptyReanchoring(plan)).toBe(true)
+ })
+
+ it('FE-REANCHOR-004: on the split leg, a via past the new stop moves to the second half', () => {
+ // Same day, but the via sits beyond the fuel stop. Left at 0 it would bend the short
+ // first half back on itself and leave the long second half unshaped — which is the
+ // route snapping back to the old road.
+ const plan = reanchorAfterInsert([via(1, 0)], 1, () => false)
+ expect(anchors(plan.vias)).toEqual({ 1: 1 })
+ })
+
+ it('FE-REANCHOR-005: two vias on one split leg are told apart, not moved as a block', () => {
+ const early = via(1, 1)
+ const late = via(2, 1)
+ const plan = reanchorAfterInsert([early, late], 2, v => v.id === early.id)
+ expect(anchors(plan.vias)).toEqual({ 2: 2 })
+ })
+
+ it('FE-REANCHOR-006: inserting at the very front splits nothing and shifts everything', () => {
+ const plan = reanchorAfterInsert([via(1, 0), via(2, 1)], 0, () => {
+ throw new Error('nothing is split when the new stop leads the day')
+ })
+ expect(anchors(plan.vias)).toEqual({ 1: 1, 2: 2 })
+ })
+
+ it('FE-REANCHOR-007: appending at the end leaves every existing leg where it was', () => {
+ // Four stops, legs 0..2, appended as the fifth.
+ const plan = reanchorAfterInsert([via(1, 0), via(2, 2)], 4, () => true)
+ expect(isEmptyReanchoring(plan)).toBe(true)
+ })
+
+ it('FE-REANCHOR-008: no vias means no request', () => {
+ expect(isEmptyReanchoring(reanchorAfterInsert([], 2, () => true))).toBe(true)
+ })
+})
+
+describe('reanchorAfterRemove', () => {
+ it('FE-REANCHOR-009: the legs either side of a removed stop become one', () => {
+ // Five stops, legs 0..3. Stop 2 goes; legs 1 and 2 merge into leg 1.
+ const plan = reanchorAfterRemove([via(1, 1), via(2, 2), via(3, 3)], 2, 5)
+ expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
+ expect(plan.remove).toEqual([])
+ })
+
+ it('FE-REANCHOR-010: removing the first stop drops the vias on the leg leaving it', () => {
+ const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 0, 4)
+ expect(plan.remove).toEqual([1])
+ expect(anchors(plan.vias)).toEqual({ 2: 1 })
+ })
+
+ it('FE-REANCHOR-011: removing the last stop drops the vias on the leg into it', () => {
+ // Four stops, legs 0..2. Stop 3 goes, so leg 2 has nothing to lead to.
+ const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 3, 4)
+ expect(plan.remove).toEqual([2])
+ expect(anchors(plan.vias)).toEqual({})
+ })
+
+ it('FE-REANCHOR-012: a day left with one stop has no drive, so no via has a leg', () => {
+ const plan = reanchorAfterRemove([via(1, 0)], 1, 2)
+ expect(plan.remove).toEqual([1])
+ expect(plan.vias).toEqual([])
+ })
+})
+
+describe('reanchorAfterReorder', () => {
+ it('FE-REANCHOR-013: moving a stop later pulls the vias between along with it', () => {
+ // Five stops, legs 0..3. Stop 1 moves to position 3.
+ const plan = reanchorAfterReorder([via(1, 0), via(2, 2), via(3, 3)], 1, 3, 5)
+ // Legs 0 and 1 merge into 0, then the stop is put back at 3, splitting leg 2.
+ expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
+ })
+
+ it('FE-REANCHOR-014: moving a stop back to where it was changes nothing', () => {
+ expect(isEmptyReanchoring(reanchorAfterReorder([via(1, 1)], 2, 2, 4))).toBe(true)
+ })
+
+ it('FE-REANCHOR-015: a via whose leg vanishes in the move is dropped, not left dangling', () => {
+ // Moving the first stop kills the leg leaving it, and nothing later re-creates that
+ // via's anchor — reporting it for deletion is the only honest answer.
+ const plan = reanchorAfterReorder([via(1, 0)], 0, 2, 4)
+ expect(plan.remove).toEqual([1])
+ })
+
+ it('FE-REANCHOR-016: no vias means no request', () => {
+ expect(isEmptyReanchoring(reanchorAfterReorder([], 0, 2, 4))).toBe(true)
+ })
+})
diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts
index 6170a8639..6de899e79 100644
--- a/client/src/components/Roadtrip/roadtripModel.test.ts
+++ b/client/src/components/Roadtrip/roadtripModel.test.ts
@@ -3,6 +3,7 @@ import {
computeSchedule,
formatClock,
formatDurationShort,
+ insertIndexForAlong,
parseClock,
splitIntoRuns,
sumLegSeconds,
@@ -218,3 +219,33 @@ describe('sumLegSeconds', () => {
expect(sumLegSeconds([])).toBe(0)
})
})
+
+describe('insertIndexForAlong', () => {
+ // Hamburg 0, Lueneburg 50, Berlin 290 — the day's stops as distances driven.
+ const stops = [0, 50, 290]
+
+ it('puts a hit between the two stops it falls between', () => {
+ expect(insertIndexForAlong(stops, 120)).toBe(2)
+ expect(insertIndexForAlong(stops, 20)).toBe(1)
+ })
+
+ it('never lands before the stop the day starts from', () => {
+ expect(insertIndexForAlong(stops, 0)).toBe(1)
+ // A corridor is wider than the road, so a hit can project just behind the start.
+ expect(insertIndexForAlong(stops, -3)).toBe(1)
+ })
+
+ it('never lands after the stop the day ends at', () => {
+ expect(insertIndexForAlong(stops, 290)).toBe(2)
+ expect(insertIndexForAlong(stops, 400)).toBe(2)
+ })
+
+ it('puts a hit exactly on a stop after it, not before', () => {
+ expect(insertIndexForAlong(stops, 50)).toBe(2)
+ })
+
+ it('has nowhere to insert on a day that is not a drive', () => {
+ expect(insertIndexForAlong([], 10)).toBe(0)
+ expect(insertIndexForAlong([0], 10)).toBe(1)
+ })
+})
diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts
index 93c69cec7..db7fbc103 100644
--- a/client/src/components/Roadtrip/roadtripModel.ts
+++ b/client/src/components/Roadtrip/roadtripModel.ts
@@ -4,6 +4,54 @@
* on its own and reused by the map, the rail and (later) the MCP tool.
*/
+/**
+ * The stop kinds that interrupt a drive rather than end it.
+ *
+ * Every kind the corridor search can find, and the list is meant to stay that way: what
+ * you pick off "along the route" is by definition something you come across on the way,
+ * so it belongs inside the leg it was found on rather than among the places the trip is
+ * for. They are drawn on the dashed line with their own icon, carry no number, and are
+ * left out of every stop count — four places with a charger, a rest stop and a bakery
+ * between them is a four-stop day.
+ *
+ * Extended together with the corridor's own categories (`RoadtripCorridorPanel`) and the
+ * enum in `@trek/shared`; a kind in one list and not the others is a stop that looks like
+ * a destination on one screen and a pause on the next.
+ */
+export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const
+
+/**
+ * What each kind of pause is coloured, on the rail, in the corridor list and on the map.
+ *
+ * Read as road signage rather than as a palette: a pump is petrol orange, a charger is
+ * the yellow of electricity, a rest area takes the blue every parking sign in Europe is
+ * printed in, a campsite is green. The last two are the colours the general place search
+ * already gives food and sights, so a restaurant found along the route looks like the
+ * restaurants found anywhere else.
+ *
+ * Deliberately literal and shared rather than tokenised: these mean the same thing in
+ * both themes the way a motorway sign does, and one definition is what stops the rail,
+ * the search result and the map pin from drifting into three different reds.
+ */
+export const SERVICE_COLORS: Record = {
+ fuel: '#E8590C',
+ charging: '#CA8A04',
+ rest_area: '#3B82F6',
+ campsite: '#16A34A',
+ restaurant: '#EF4444',
+ sights: '#EC4899',
+}
+
+/** The colour for a kind, or the neutral one for a kind nobody has a colour for yet. */
+export function serviceColor(stopType: string | null | undefined): string {
+ return SERVICE_COLORS[stopType ?? ''] ?? '#64748B'
+}
+
+/** Whether a stop breaks the drive. The rail's shape and every count turn on this. */
+export function isServiceStopType(stopType: string | null | undefined): boolean {
+ return !!stopType && (SERVICE_STOP_TYPES as readonly string[]).includes(stopType)
+}
+
/** Seconds → "2 h 10 min" / "45 min", matching the wording of the map's route connectors. */
export function formatDurationShort(seconds: number): string {
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
@@ -170,3 +218,184 @@ export function splitIntoRuns(stops: T[], modeOfLeg: (from: T, to: T) => stri
export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
return legSeconds.reduce((sum, s) => sum + (s ?? 0), 0)
}
+
+/**
+ * Where a place found along the drive belongs in the day's chain.
+ *
+ * Both numbers are distances travelled along the same routed line, so the comparison is
+ * "which stops has the car already passed when it reaches this one". A petrol station
+ * 120 km into the day goes after every stop nearer than that and before the rest,
+ * which is what makes adding one a single click rather than a drag afterwards.
+ *
+ * Clamped to sit inside the day: a corridor is wider than the road, so a hit can project
+ * slightly before the first stop or past the last, and neither "before where you set off"
+ * nor "after where you arrive" is a place a fuel stop can go.
+ */
+export function insertIndexForAlong(stopsAlongKm: number[], hitAlongKm: number): number {
+ if (stopsAlongKm.length < 2) return stopsAlongKm.length
+ let i = 0
+ while (i < stopsAlongKm.length && stopsAlongKm[i] <= hitAlongKm) i++
+ return Math.min(Math.max(i, 1), stopsAlongKm.length - 1)
+}
+
+/* ── Via re-anchoring ────────────────────────────────────────────────────────
+ *
+ * A via records which stop it follows as a POSITION in the day's stop list, and that
+ * position is resolved fresh every time the day is routed. So the moment the list
+ * changes shape — a stop inserted, removed, reordered, pushed to another day — every
+ * anchor at or past the change means a different leg than it did when it was written.
+ * The visible symptom is the route quietly snapping back to the road the user had
+ * steered it away from, with the via handle still sitting on the map where they left it.
+ *
+ * Nothing about that is repairable after the fact: once the stops have moved there is no
+ * record of which leg a via was drawn for. So the correction has to be computed against
+ * the list as it stands and applied with the same change.
+ *
+ * These are pure index arithmetic on purpose. The one judgement that needs geometry —
+ * which half of a split leg a via belongs to — is asked of the caller, because only the
+ * caller has the routed line to measure against.
+ */
+
+/**
+ * A via, reduced to what re-anchoring needs to know about it.
+ *
+ * The coordinates are part of it because the one decision that is not pure arithmetic —
+ * which half of a split leg a via belongs to — is answered by measuring where it sits on
+ * the road, and the caller is handed the whole via to measure.
+ */
+export interface AnchoredVia {
+ id: number
+ after_order_index: number
+ lat: number
+ lng: number
+}
+
+/** Where one via ends up. */
+export interface ReanchoredVia {
+ id: number
+ after_order_index: number
+}
+
+/**
+ * The corrected anchoring, and the vias that no longer have a leg at all.
+ *
+ * `vias` holds only what actually moved: sending the unchanged ones would be a larger
+ * write for no reason, and it keeps the diff readable when something goes wrong.
+ */
+export interface Reanchoring {
+ vias: ReanchoredVia[]
+ remove: number[]
+}
+
+const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] }
+
+/** Only what moved, so an operation that changes nothing costs no request. */
+function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring {
+ const moved: ReanchoredVia[] = []
+ const remove: number[] = []
+ for (const via of vias) {
+ const next = at(via.after_order_index)
+ if (next === null) remove.push(via.id)
+ else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
+ }
+ return { vias: moved, remove }
+}
+
+/**
+ * A stop was inserted at `position`, so everything from there on sits one place later.
+ *
+ * The leg the new stop lands in is SPLIT in two, and its vias have to be told apart: one
+ * before the new stop stays on the first half, one after it moves to the second. That is
+ * the whole reason a petrol station added in the middle of a reshaped drive used to undo
+ * the reshaping — the via went to the first half by accident of arithmetic, the second
+ * half got none, and the router took its own road again for the rest of the leg.
+ *
+ * `liesBeforeInsert` is asked only about vias on the split leg, and only when there is
+ * one: inserting at the very front or the very end splits nothing.
+ */
+export function reanchorAfterInsert(
+ vias: AnchoredVia[],
+ position: number,
+ liesBeforeInsert: (via: AnchoredVia) => boolean,
+): Reanchoring {
+ if (!vias.length) return EMPTY_REANCHORING
+ const split = position - 1
+ const moved: ReanchoredVia[] = []
+ for (const via of vias) {
+ const i = via.after_order_index
+ let next = i
+ if (i === split) next = liesBeforeInsert(via) ? i : i + 1
+ else if (i >= position) next = i + 1
+ if (next !== i) moved.push({ id: via.id, after_order_index: next })
+ }
+ return { vias: moved, remove: [] }
+}
+
+/**
+ * A stop was taken out at `position`, so the legs either side of it become one.
+ *
+ * A via on either half keeps bending the same drive, so both end up on the merged leg —
+ * dropping them would throw away a decision the user made about a road that still exists.
+ * The exception is the ends: remove the first stop and the leg leaving it is gone, remove
+ * the last and the leg into it is gone, and a via pinned to one of those has nowhere left
+ * to sit. Those are reported for deletion rather than left pointing past the day.
+ *
+ * `stopCount` is the number of stops BEFORE the removal.
+ */
+export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring {
+ if (!vias.length) return EMPTY_REANCHORING
+ // Two stops become one: the day has no drive left, so no via has a leg.
+ if (stopCount <= 2) return { vias: [], remove: vias.map(v => v.id) }
+ return collect(vias, i => {
+ if (position === 0) return i === 0 ? null : i - 1
+ if (position === stopCount - 1) return i === position - 1 ? null : i
+ if (i === position) return position - 1
+ return i > position ? i - 1 : i
+ })
+}
+
+/**
+ * A stop moved from `from` to `to` inside its own day.
+ *
+ * Composed from the two operations it actually is — taken out, put back — because that
+ * is the only reading under which the answer stays consistent with the other two. No
+ * geometry is asked for: a reorder changes which stops are neighbours, so the roads
+ * themselves are about to be different and there is nothing meaningful to measure a via
+ * against. Vias keep the earlier half of a split leg, which is the reading that leaves
+ * them where they were relative to the stop they were drawn after.
+ *
+ * `stopCount` is the number of stops BEFORE the move. `to` is the index in the list as
+ * it looks AFTER the stop has been taken out.
+ */
+export function reanchorAfterReorder(
+ vias: AnchoredVia[],
+ from: number,
+ to: number,
+ stopCount: number,
+): Reanchoring {
+ if (!vias.length || from === to) return EMPTY_REANCHORING
+ const afterRemove = reanchorAfterRemove(vias, from, stopCount)
+ const dropped = new Set(afterRemove.remove)
+ const movedTo = new Map(afterRemove.vias.map(v => [v.id, v.after_order_index] as const))
+ // The list the insert acts on is the one the removal left behind, so each via is
+ // carried through at whatever index it holds by then.
+ const shifted: AnchoredVia[] = vias
+ .filter(v => !dropped.has(v.id))
+ .map(v => ({ ...v, after_order_index: movedTo.get(v.id) ?? v.after_order_index }))
+ const afterInsert = reanchorAfterInsert(shifted, to, () => true)
+ const finalIndex = new Map(afterInsert.vias.map(v => [v.id, v.after_order_index] as const))
+
+ const result: ReanchoredVia[] = []
+ for (const via of vias) {
+ if (dropped.has(via.id)) continue
+ const mid = movedTo.get(via.id) ?? via.after_order_index
+ const end = finalIndex.get(via.id) ?? mid
+ if (end !== via.after_order_index) result.push({ id: via.id, after_order_index: end })
+ }
+ return { vias: result, remove: afterRemove.remove }
+}
+
+/** Whether a re-anchoring has anything to write at all. */
+export function isEmptyReanchoring(r: Reanchoring): boolean {
+ return r.vias.length === 0 && r.remove.length === 0
+}
diff --git a/client/src/components/Roadtrip/serviceMarker.ts b/client/src/components/Roadtrip/serviceMarker.ts
new file mode 100644
index 000000000..ebfa04c16
--- /dev/null
+++ b/client/src/components/Roadtrip/serviceMarker.ts
@@ -0,0 +1,64 @@
+import { createElement } from 'react'
+import { Fuel, Zap, Coffee, Tent, Utensils, Camera, type LucideIcon } from 'lucide-react'
+import { renderIconMarkup } from '../../utils/iconMarkup'
+import { isServiceStopType, serviceColor } from './roadtripModel'
+
+/** The same icons the rail puts on its discs, so one kind of stop looks like itself. */
+const SERVICE_ICON: Record = {
+ fuel: Fuel,
+ charging: Zap,
+ rest_area: Coffee,
+ campsite: Tent,
+ restaurant: Utensils,
+ sights: Camera,
+}
+
+/**
+ * Smaller than a place marker on purpose.
+ *
+ * A petrol station is not a destination — it is something the drive passes through — and
+ * a marker the same size as the places the trip is for made a day look like it had twice
+ * as many stops as it has. Selected grows a little, the same way a place marker does, so
+ * clicking one still confirms itself.
+ */
+export const SERVICE_MARKER_SIZE = 24
+export const SERVICE_MARKER_SIZE_SELECTED = 30
+const BORDER = 2
+
+/** The full box a service marker occupies, border included — what an anchor centres on. */
+export function serviceMarkerOuter(selected: boolean): number {
+ return (selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE) + BORDER * 2
+}
+
+/**
+ * The marker for a stop that interrupts the drive, or null for an ordinary place.
+ *
+ * One flat disc in the kind's own colour with a white icon — never the place's photo or
+ * its brand logo. A fuel stop used to come back from the place search carrying the
+ * operator's logo, so the map showed an Esso roundel where every other road-trip surface
+ * showed a pump: unreadable at marker size, different for every brand, and nothing at all
+ * for the ones with no logo on file.
+ *
+ * Built as markup rather than as a component because both renderers need a string: one
+ * feeds it to a Leaflet `divIcon`, the other to an element it attaches itself.
+ */
+export function serviceMarkerHtml(stopType: string | null | undefined, selected: boolean): string | null {
+ if (!isServiceStopType(stopType)) return null
+ const size = selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE
+ const Icon = SERVICE_ICON[stopType ?? ''] ?? Coffee
+ const svg = renderIconMarkup(createElement(Icon, {
+ size: Math.round(size * 0.56),
+ color: '#fff',
+ strokeWidth: 2.4,
+ }))
+ const ring = selected
+ ? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 12px rgba(0,0,0,0.3)'
+ : '0 2px 6px rgba(0,0,0,0.25)'
+ return `${svg}
`
+}
diff --git a/client/src/components/Roadtrip/typeScale.ts b/client/src/components/Roadtrip/typeScale.ts
new file mode 100644
index 000000000..1e57a4c33
--- /dev/null
+++ b/client/src/components/Roadtrip/typeScale.ts
@@ -0,0 +1,39 @@
+/**
+ * The road trip's type scale, pinned to the day plan's.
+ *
+ * Both road trip panels sit beside the day plan and the tab switcher flips between
+ * them, so a road trip whose names ran a size larger than the day plan's read as a
+ * different app.
+ * The two anchors are the day plan's own: a day title is 14px and a place name 12.5px
+ * (`DayPlanSidebar`), and everything here is derived from those. The design handoff was
+ * drawn a fifth larger throughout — its proportions are kept, its absolute sizes are not.
+ *
+ * Written as `calc(px * var(--fs-scale-))` rather than as a tier utility because
+ * the tiers land on 24/18/14/12px and this rail needs the steps between them; the
+ * multiplier is what keeps the user's per-tier text-size setting reaching them. A value
+ * and its unit always share one tier, so scaling one tier cannot break a pair apart.
+ */
+export const FS = {
+ /** A total in the head. */
+ total: 'calc(20px * var(--fs-scale-title, 1))',
+ /** The unit hanging off one. */
+ totalUnit: 'calc(11.5px * var(--fs-scale-title, 1))',
+ /** A day's name — the day plan's own size. */
+ dayTitle: 'calc(14px * var(--fs-scale-body, 1))',
+ /** A stop's name — the day plan's own size. */
+ name: 'calc(12.5px * var(--fs-scale-body, 1))',
+ /** A clock reading, and a service stop's dwell. */
+ time: 'calc(11px * var(--fs-scale-caption, 1))',
+ /** A drive band, a date, a line of explanation. */
+ meta: 'calc(10px * var(--fs-scale-caption, 1))',
+ /** The number in a marker. */
+ marker: 'calc(10.5px * var(--fs-scale-caption, 1))',
+ /** A badge, a caption over a total. */
+ label: 'calc(9px * var(--fs-scale-caption, 1))',
+ /** The word "Stay" — the smallest thing in the rail. */
+ micro: 'calc(8px * var(--fs-scale-caption, 1))',
+ /** The name of a panel — "Along the route". The largest text either panel carries. */
+ panelTitle: 'calc(15px * var(--fs-scale-subtitle, 1))',
+ /** A pill, a segment, a line of progress: anything you press but do not read as prose. */
+ control: 'calc(10.5px * var(--fs-scale-body, 1))',
+} as const
diff --git a/client/src/components/Roadtrip/useCorridorPois.ts b/client/src/components/Roadtrip/useCorridorPois.ts
index 2858bbf1d..dee812594 100644
--- a/client/src/components/Roadtrip/useCorridorPois.ts
+++ b/client/src/components/Roadtrip/useCorridorPois.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
@@ -19,7 +19,16 @@ export interface CorridorSearch {
capped: boolean
/** Boxes the place search could not answer — a partial result is still shown. */
failedAreas: number
+ /**
+ * Boxes where the server hit its own per-request ceiling and dropped the rest.
+ * Distinct from `capped`, which is about stretches never asked for at all: here the
+ * stretch was searched and the answer came back short. Without saying so, filtering
+ * the results cannot tell "nothing of that name on the way" from "cut off before it".
+ */
+ truncatedAreas: number
error: boolean
+ /** The thinned line every `alongKm` above is measured along. */
+ spine: LatLng[]
search: () => void
clear: () => void
}
@@ -61,10 +70,21 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
const [loading, setLoading] = useState(false)
const [capped, setCapped] = useState(false)
const [failedAreas, setFailedAreas] = useState(0)
+ const [truncatedAreas, setTruncatedAreas] = useState(0)
const [error, setError] = useState(false)
const abortRef = useRef(null)
const runIdRef = useRef(0)
+ /**
+ * The routed line, thinned. A corridor built from every vertex would make hundreds of
+ * overlapping boxes out of what a handful describe just as well.
+ *
+ * Shared rather than local to the search, because every `alongKm` in the results is a
+ * distance along THIS line. Anything comparing a hit against the day's own stops has to
+ * project them onto the same one, or the two sets of numbers do not mean the same thing.
+ */
+ const spine = useMemo(() => simplifyLine(line, Math.max(1, widthKm / 3)), [line, widthKm])
+
const clear = useCallback(() => {
abortRef.current?.abort()
setResults([])
@@ -72,6 +92,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
setLoading(false)
setCapped(false)
setFailedAreas(0)
+ setTruncatedAreas(0)
setError(false)
}, [])
@@ -82,19 +103,17 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
const search = useCallback(() => {
abortRef.current?.abort()
- if (line.length < 2 || categories.length === 0) return
+ if (spine.length < 2 || categories.length === 0) return
const controller = new AbortController()
abortRef.current = controller
const runId = ++runIdRef.current
- // Thin the routed geometry first: a corridor built from every vertex would make
- // hundreds of overlapping boxes out of what a handful describe just as well.
- const spine = simplifyLine(line, Math.max(1, widthKm / 3))
const allTiles = corridorTiles(spine, widthKm)
const tiles = allTiles.slice(0, MAX_TILES)
setCapped(allTiles.length > tiles.length)
setFailedAreas(0)
+ setTruncatedAreas(0)
setError(false)
setResults([])
setLoading(true)
@@ -109,6 +128,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
void (async () => {
const seen = new Map()
let failures = 0
+ let shortened = 0
let done = 0
let next = 0
@@ -120,6 +140,9 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
const collect = async (tile: Bbox): Promise => {
try {
const data = await mapsApi.pois(wanted, tile, locale, controller.signal)
+ // The server answers with at most a few hundred per box and says so. A hit
+ // dropped there is invisible here, so the count has to reach the surface.
+ if (data.truncated) setTruncatedAreas(++shortened)
for (const poi of data.pois) {
if (seen.has(poi.osm_id)) continue
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
@@ -166,9 +189,9 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
setError(failures === jobs.length && failures > 0)
setLoading(false)
})()
- }, [line, categories, widthKm, locale])
+ }, [spine, categories, widthKm, locale])
useEffect(() => () => abortRef.current?.abort(), [])
- return { results, progress, loading, capped, failedAreas, error, search, clear }
+ return { results, progress, loading, capped, failedAreas, truncatedAreas, error, spine, search, clear }
}
diff --git a/client/src/components/Roadtrip/useRoadtripCorridor.test.ts b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts
index 2cc90c11e..a97909745 100644
--- a/client/src/components/Roadtrip/useRoadtripCorridor.test.ts
+++ b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts
@@ -1,148 +1,145 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import type { LatLng } from './corridor'
-import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
+import type { CorridorPoi, CorridorSearch } from './useCorridorPois'
+import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
const { useCorridorPois } = vi.hoisted(() => ({ useCorridorPois: vi.fn() }))
vi.mock('./useCorridorPois', () => ({ useCorridorPois }))
-import { useRoadtripCorridor, CORRIDOR_CATEGORY_KEYS, CORRIDOR_WIDTHS_KM } from './useRoadtripCorridor'
+import { useRoadtripCorridor } from './useRoadtripCorridor'
-const stop = (id: number, lat: number, lng: number): RoadtripStop => ({
- assignmentId: id,
- placeId: id * 10,
- name: `Stop ${id}`,
- lat,
- lng,
- time: null,
- dwellMinutes: null,
- legMode: null,
- incomingLegMode: null,
-})
+function poi(over: Partial & { osm_id: string; name: string }): CorridorPoi {
+ return {
+ lat: 53, lng: 10, category: 'fuel', poi_type: 'fuel', address: null, website: null,
+ phone: null, opening_hours: null, cuisine: null, source: 'openstreetmap',
+ offRouteKm: 1, alongKm: 10, brand: null, brand_wikidata: null,
+ ...over,
+ } as CorridorPoi
+}
function day(over: Partial = {}): RoadtripDay {
return {
- dayId: 1,
- dayNumber: 1,
- date: null,
- title: null,
- stops: [stop(1, 53.55, 9.99), stop(2, 52.52, 13.4)],
- legs: [],
- schedule: { entries: [], warnings: [] },
- geometry: [],
- distance: 0,
- duration: 0,
+ dayId: 1, dayNumber: 1, date: null, title: null,
+ stops: [
+ { assignmentId: 1, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
+ { assignmentId: 2, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
+ ],
+ legs: [], schedule: { entries: [], warnings: [] },
+ geometry: [[53.5, 9.9], [53.0, 11.0], [52.5, 13.4]],
+ distance: 0, duration: 0,
...over,
}
}
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
- days,
- lines: [],
- segments: [],
- totalDistance: 0,
- totalDuration: 0,
- totalStops: 0,
- loading: false,
+ days, lines: [], segments: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
})
-/** What the corridor hook was handed on the last render. */
-const lastCall = (): { line: LatLng[]; categories: string[]; widthKm: number } => {
- const calls = useCorridorPois.mock.calls
- const call = calls[calls.length - 1]
- return { line: call[0], categories: call[1], widthKm: call[2] }
+/** The search state the mocked hook hands back, results included. */
+function searchWith(results: CorridorPoi[]): CorridorSearch {
+ return {
+ results,
+ progress: { done: 0, total: 0 },
+ loading: false,
+ capped: false,
+ failedAreas: 0,
+ truncatedAreas: 0,
+ error: false,
+ // The thinned line the day's geometry becomes; hits and stops are measured along it.
+ spine: [{ lat: 53.5, lng: 9.9 }, { lat: 53.0, lng: 11.0 }, { lat: 52.5, lng: 13.4 }],
+ search: vi.fn(),
+ clear: vi.fn(),
+ }
}
beforeEach(() => {
useCorridorPois.mockReset()
- useCorridorPois.mockReturnValue({
- results: [], progress: { done: 0, total: 0 }, loading: false,
- capped: false, failedAreas: 0, error: false, search: vi.fn(), clear: vi.fn(),
- })
})
describe('useRoadtripCorridor', () => {
- it('FE-ROADTRIP-CORRIDORSTATE-001: searches along the roads driven, not the line between stops', () => {
- // The motorway swings north of the straight line; searching the straight line finds
- // open country, which is the bug this replaced.
- const geometry: [number, number][] = [[53.55, 9.99], [53.6, 11.2], [52.52, 13.4]]
- renderHook(() => useRoadtripCorridor(routes([day({ geometry })])))
+ it('FE-ROADTRIP-CORRIDORSTATE-001: with no filter everything found is visible', () => {
+ const hits = [poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]
+ useCorridorPois.mockReturnValue(searchWith(hits))
- expect(lastCall().line).toEqual([
- { lat: 53.55, lng: 9.99 },
- { lat: 53.6, lng: 11.2 },
- { lat: 52.52, lng: 13.4 },
- ])
- })
-
- it('FE-ROADTRIP-CORRIDORSTATE-002: falls back to the stops until the day has routed', () => {
- renderHook(() => useRoadtripCorridor(routes([day({ geometry: [] })])))
-
- expect(lastCall().line).toEqual([
- { lat: 53.55, lng: 9.99 },
- { lat: 52.52, lng: 13.4 },
- ])
- })
-
- it('FE-ROADTRIP-CORRIDORSTATE-003: opens on the first day with a drive, before anything is picked', () => {
- const { result } = renderHook(() => useRoadtripCorridor(routes([day({ dayId: 4 }), day({ dayId: 5 })])))
-
- expect(result.current.dayId).toBe('4')
- expect(result.current.day?.dayId).toBe(4)
- })
-
- it('FE-ROADTRIP-CORRIDORSTATE-004: picking a day switches which drive is searched', () => {
- const second = day({ dayId: 5, geometry: [[48.1, 11.5], [47.8, 12.1]] })
- const { result } = renderHook(() => useRoadtripCorridor(routes([day({ dayId: 4 }), second])))
-
- act(() => { result.current.setDayId('5') })
-
- expect(result.current.day?.dayId).toBe(5)
- expect(lastCall().line).toEqual([{ lat: 48.1, lng: 11.5 }, { lat: 47.8, lng: 12.1 }])
- })
-
- it('FE-ROADTRIP-CORRIDORSTATE-005: follows along when the chosen day disappears', () => {
- const { result, rerender } = renderHook(
- ({ days }: { days: RoadtripDay[] }) => useRoadtripCorridor(routes(days)),
- { initialProps: { days: [day({ dayId: 4 }), day({ dayId: 5 })] } },
- )
- act(() => { result.current.setDayId('5') })
- expect(result.current.day?.dayId).toBe(5)
-
- rerender({ days: [day({ dayId: 4 })] })
- expect(result.current.dayId).toBe('4')
- })
-
- it('FE-ROADTRIP-CORRIDORSTATE-006: toggling a kind adds it and toggling again removes it', () => {
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
- expect(result.current.categories).toEqual(['fuel'])
- act(() => { result.current.toggleCategory('campsite') })
- expect(result.current.categories).toEqual(['fuel', 'campsite'])
-
- act(() => { result.current.toggleCategory('fuel') })
- expect(result.current.categories).toEqual(['campsite'])
- expect(lastCall().categories).toEqual(['campsite'])
+ expect(result.current.visible).toBe(hits)
})
- it('FE-ROADTRIP-CORRIDORSTATE-007: the width is handed straight to the search', () => {
+ it('FE-ROADTRIP-CORRIDORSTATE-002: the filter matches part of a name, ignoring case', () => {
+ useCorridorPois.mockReturnValue(searchWith([
+ poi({ osm_id: 'a', name: 'Aral Autohof' }),
+ poi({ osm_id: 'b', name: 'Shell Nord' }),
+ ]))
+
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
- expect(lastCall().widthKm).toBe(5)
+ act(() => result.current.setNameFilter('shell'))
- act(() => { result.current.setWidthKm(10) })
- expect(lastCall().widthKm).toBe(10)
+ expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
})
- it('FE-ROADTRIP-CORRIDORSTATE-008: a trip with no drive has nothing to search along', () => {
- const { result } = renderHook(() => useRoadtripCorridor(routes([])))
+ it('FE-ROADTRIP-CORRIDORSTATE-003: the brand counts as a name, because the server folds the operator into it', () => {
+ useCorridorPois.mockReturnValue(searchWith([
+ poi({ osm_id: 'a', name: 'Autohof Nord', brand: 'Shell' }),
+ poi({ osm_id: 'b', name: 'Raststätte Süd', brand: 'Aral' }),
+ ]))
- expect(result.current.day).toBeUndefined()
- expect(result.current.dayId).toBe('')
- expect(lastCall().line).toEqual([])
+ const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
+ act(() => result.current.setNameFilter('Shell'))
+
+ expect(result.current.visible.map(p => p.osm_id)).toEqual(['a'])
})
- it('FE-ROADTRIP-CORRIDORSTATE-009: the offered kinds and widths are the ones the panel draws', () => {
- expect(CORRIDOR_CATEGORY_KEYS).toEqual(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'])
- expect(CORRIDOR_WIDTHS_KM).toEqual([2, 5, 10])
+ it('FE-ROADTRIP-CORRIDORSTATE-004: whitespace alone is not a filter', () => {
+ const hits = [poi({ osm_id: 'a', name: 'Aral' })]
+ useCorridorPois.mockReturnValue(searchWith(hits))
+
+ const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
+ act(() => result.current.setNameFilter(' '))
+
+ expect(result.current.visible).toBe(hits)
+ })
+
+ it('FE-ROADTRIP-CORRIDORSTATE-005: starting a new search clears the old question', () => {
+ useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
+
+ const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
+ act(() => result.current.setNameFilter('aral'))
+ expect(result.current.nameFilter).toBe('aral')
+
+ // A search begins: keeping the term would hide the new hits behind the old query.
+ useCorridorPois.mockReturnValue({ ...searchWith([]), loading: true })
+ rerender()
+
+ expect(result.current.nameFilter).toBe('')
+ })
+
+ it('FE-ROADTRIP-CORRIDORSTATE-007: typing while the search is still running survives the next batch', () => {
+ // Hits arrive box by box and publish as they land. Clearing on every batch instead of
+ // on the start of a search wiped out whatever had been typed in the meantime.
+ useCorridorPois.mockReturnValue({ ...searchWith([poi({ osm_id: 'a', name: 'Aral' })]), loading: true })
+
+ const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
+ act(() => result.current.setNameFilter('shell'))
+
+ useCorridorPois.mockReturnValue({
+ ...searchWith([poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]),
+ loading: true,
+ })
+ rerender()
+
+ expect(result.current.nameFilter).toBe('shell')
+ expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
+ })
+
+ it('FE-ROADTRIP-CORRIDORSTATE-006: the visible list keeps its identity while nothing changes', () => {
+ useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
+
+ const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
+ const before = result.current.visible
+ rerender()
+
+ // Both map renderers rebuild every marker when this array's identity changes.
+ expect(result.current.visible).toBe(before)
})
})
diff --git a/client/src/components/Roadtrip/useRoadtripCorridor.ts b/client/src/components/Roadtrip/useRoadtripCorridor.ts
index 082427533..f140d06aa 100644
--- a/client/src/components/Roadtrip/useRoadtripCorridor.ts
+++ b/client/src/components/Roadtrip/useRoadtripCorridor.ts
@@ -1,6 +1,7 @@
-import { useCallback, useMemo, useState } from 'react'
-import { useCorridorPois, type CorridorSearch } from './useCorridorPois'
-import type { LatLng } from './corridor'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCorridorPois'
+import { insertIndexForAlong } from './roadtripModel'
+import { projectOntoRoute, type LatLng } from './corridor'
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
/** The road-trip categories, in the order the panel shows them. */
@@ -19,6 +20,21 @@ export interface RoadtripCorridor {
widthKm: number
setWidthKm: (km: number) => void
search: CorridorSearch
+ /** Narrows what was found by name or brand. Empty means everything. */
+ nameFilter: string
+ setNameFilter: (value: string) => void
+ /**
+ * What both the panel and the map show: the hits that match `nameFilter`.
+ * Read this, never `search.results`, or the two drift apart.
+ */
+ visible: CorridorPoi[]
+ /**
+ * Which position in the day's chain a hit belongs at, so adding one lands it in the
+ * order it will actually be driven past rather than at the end of the day.
+ */
+ insertIndexFor: (poi: CorridorPoi) => number
+ /** How far along the drive each of the day's stops sits, in the same units as a hit. */
+ stopsAlongKm: number[]
}
/**
@@ -32,6 +48,7 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
const [dayId, setDayId] = useState('')
const [categories, setCategories] = useState(['fuel'])
const [widthKm, setWidthKm] = useState(5)
+ const [nameFilter, setNameFilter] = useState('')
// Falls back to the first day with a drive, so the panel is useful before the user
// has picked anything — and follows along when that day disappears.
@@ -53,6 +70,56 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
setCategories(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]))
}, [])
+ // A filter kept across a new search would hide the fresh answer behind the old question.
+ // Cleared when a search STARTS, not whenever results change: hits arrive box by box and
+ // publish as they land, so resetting on every batch wiped out anything typed while the
+ // search was still running.
+ const searching = search.loading
+ const wasSearching = useRef(false)
+ useEffect(() => {
+ if (searching && !wasSearching.current) setNameFilter('')
+ wasSearching.current = searching
+ }, [searching])
+
+ /**
+ * Memoised on purpose, not filtered where it is drawn: both map renderers tear down and
+ * rebuild every POI marker whenever the array's identity changes, so a `.filter()` in a
+ * render body would make the pins flicker on each keystroke and on every unrelated
+ * re-render.
+ *
+ * Brand counts as a name here because the server folds `operator` into `brand`, and
+ * "Shell" is what someone types when the OSM name is "Shell Autohof Nord".
+ */
+ const visible = useMemo(() => {
+ const needle = nameFilter.trim().toLowerCase()
+ if (!needle) return search.results
+ return search.results.filter(p =>
+ p.name.toLowerCase().includes(needle) || (p.brand ?? '').toLowerCase().includes(needle))
+ }, [search.results, nameFilter])
+
+ /**
+ * Where a hit belongs in the day's chain, as an index among its stops.
+ *
+ * The stops are projected onto the same thinned line the hits were measured against, so
+ * both sides are distances along one drive and the comparison is simply "which stops has
+ * the car passed by then". A stop the projection cannot place (nowhere near the routed
+ * line) keeps its neighbours' order rather than jumping to the front.
+ */
+ const stopsAlongKm = useMemo(() => {
+ if (!day || search.spine.length < 2) return []
+ let last = 0
+ return day.stops.map(s => {
+ const hit = projectOntoRoute({ lat: s.lat, lng: s.lng }, search.spine)
+ if (hit) last = hit.alongKm
+ return last
+ })
+ }, [day, search.spine])
+
+ const insertIndexFor = useCallback(
+ (poi: CorridorPoi) => insertIndexForAlong(stopsAlongKm, poi.alongKm),
+ [stopsAlongKm],
+ )
+
return {
dayId: day ? String(day.dayId) : '',
setDayId,
@@ -62,5 +129,10 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
widthKm,
setWidthKm,
search,
+ nameFilter,
+ setNameFilter,
+ visible,
+ insertIndexFor,
+ stopsAlongKm,
}
}
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
index a652ee40c..59c6bf1ae 100644
--- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
@@ -2,8 +2,19 @@ import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { Assignment, AssignmentsMap, Day } from '../../types'
-const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
-vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
+// Hoisted together with the mock: the module factory runs before the file body, so a
+// class declared down there would not exist yet when the hook does its `instanceof`.
+const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
+ class RoutingRefusedError extends Error {
+ constructor(readonly status: number, readonly retryAfterMs: number | null) {
+ super('refused')
+ this.name = 'RoutingRefusedError'
+ }
+ get isRateLimit(): boolean { return this.status === 429 || this.status === 503 }
+ }
+ return { calculateRouteWithLegs: vi.fn(), RoutingRefusedError }
+})
+vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs, RoutingRefusedError }))
import { useRoadtripRoutes } from './useRoadtripRoutes'
@@ -18,6 +29,7 @@ interface StopSpec {
dwell?: number | null
legMode?: string | null
incoming?: string | null
+ stopType?: string | null
noCoords?: boolean
}
@@ -40,6 +52,7 @@ function assignment(spec: StopSpec, order: number): Assignment {
lng: spec.noCoords ? null : spec.at[1],
place_time: null,
duration_minutes: spec.dwell ?? null,
+ stop_type: spec.stopType ?? null,
},
} as unknown as Assignment
}
@@ -124,6 +137,24 @@ describe('useRoadtripRoutes', () => {
expect(result.current.totalStops).toBe(2)
})
+ it('FE-ROADTRIP-ROUTES-013: a charger on the way is part of the drive, not a stop', async () => {
+ const days = [day(1, 1)]
+ const stops: StopSpec[] = [
+ { id: 1, at: HAMBURG },
+ { id: 2, at: [53, 11], stopType: 'charging' },
+ { id: 3, at: BERLIN },
+ ]
+ calculateRouteWithLegs.mockResolvedValue(routed(2, [HAMBURG, [53, 11], BERLIN]))
+
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+
+ // Three assignments, three markers on the map, two places the trip is for. The
+ // charger still routes — it is only left out of the count the head shows.
+ expect(result.current.days[0].stops).toHaveLength(3)
+ expect(result.current.totalStops).toBe(2)
+ })
+
it('FE-ROADTRIP-ROUTES-005: sums the legs and hands the map one polyline per routed run', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
@@ -219,6 +250,108 @@ describe('useRoadtripRoutes', () => {
expect(result.current.totalStops).toBe(0)
})
+ it('FE-ROADTRIP-ROUTES-012: a stop whose id changes after saving keeps its legs and its clock', async () => {
+ // The optimistic insert: a stop added mid-day carries a temporary negative id until
+ // the server answers, then the real id replaces it without a coordinate moving. Since
+ // `planKey` is geometry only, no refetch follows — so legs filed under the id would be
+ // stranded, and a broken chain blanks every arrival after it.
+ calculateRouteWithLegs.mockResolvedValue(routed(2))
+ const days = [day(1, 1)]
+ const optimistic: StopSpec[] = [
+ { id: 1, at: HAMBURG, time: '09:00' },
+ { id: -1755000000, at: LUENEBURG },
+ { id: 2, at: BERLIN },
+ ]
+ const first = map(1, optimistic)
+
+ const { result, rerender } = renderHook(
+ ({ assignments }: { assignments: AssignmentsMap }) => useRoadtripRoutes(7, days, assignments),
+ { initialProps: { assignments: first } },
+ )
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ expect(result.current.days[0].legs.every(Boolean)).toBe(true)
+
+ const saved = JSON.parse(JSON.stringify(first)) as AssignmentsMap
+ saved['1'][1].id = 4711
+ rerender({ assignments: saved })
+ await waitFor(() => expect(result.current.loading).toBe(false))
+
+ expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
+ expect(result.current.days[0].legs.every(Boolean)).toBe(true)
+ expect(result.current.days[0].schedule.entries[2].arrival).not.toBeNull()
+ })
+
+ it('FE-ROADTRIP-ROUTES-013: a rate-limited host is given the time it asked for', async () => {
+ // The public OSRM instances answer 429 above roughly one request a second and say how
+ // long to wait. Retrying sooner than that is just a second refusal, so the host's
+ // Retry-After wins over the shorter backoff the hook would otherwise use.
+ vi.useFakeTimers()
+ calculateRouteWithLegs
+ .mockRejectedValueOnce(new RoutingRefusedError(429, 6000))
+ .mockResolvedValue(routed(1))
+
+ const days = [day(1, 1)]
+ const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
+
+ // The hook's own first backoff is 1500 ms; at that point it must still be waiting.
+ await act(async () => { await vi.advanceTimersByTimeAsync(2000) })
+ expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
+
+ await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
+ expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
+ expect(result.current.days[0].legs[0]).toBeDefined()
+ })
+
+ it('FE-ROADTRIP-ROUTES-014: a via joins the routing request between the stops it follows', async () => {
+ const days = [day(1, 1)]
+ const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
+ calculateRouteWithLegs.mockResolvedValue(routed(2))
+
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving', {
+ 1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }],
+ }))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+
+ // Three waypoints for two stops: the via is threaded in where it belongs.
+ const [waypoints] = calculateRouteWithLegs.mock.calls[0]
+ expect(waypoints).toHaveLength(3)
+ expect(waypoints[1]).toEqual({ lat: 53.2, lng: 10.4 })
+ })
+
+ it('FE-ROADTRIP-ROUTES-015: the two legs a via creates are shown as the one drive they are', async () => {
+ const days = [day(1, 1)]
+ const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
+ // The router answers per waypoint pair: two legs for one stop pair.
+ calculateRouteWithLegs.mockResolvedValue(routed(2))
+
+ const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving', {
+ 1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }],
+ }))
+ await waitFor(() => expect(result.current.loading).toBe(false))
+
+ // The rail shows one leg between two stops, carrying the whole drive.
+ expect(result.current.days[0].legs).toHaveLength(1)
+ expect(result.current.days[0].legs[0]).toMatchObject({ distance: 100000, duration: 3600 })
+ })
+
+ it('FE-ROADTRIP-ROUTES-016: moving a via re-asks the router, renaming a place still does not', async () => {
+ const days = [day(1, 1)]
+ const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
+ const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }] }
+
+ const { result, rerender } = renderHook(
+ ({ v }: { v: Record }) => useRoadtripRoutes(7, days, map(1, stops), 'driving', v),
+ { initialProps: { v: vias } },
+ )
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
+
+ // A via that moved is a different road, so the route has to be asked for again.
+ rerender({ v: { 1: [{ ...vias[1][0], lat: 53.9, lng: 10.9 }] } })
+ await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2))
+ })
+
it('FE-ROADTRIP-ROUTES-011: leaving the view aborts the request in flight', async () => {
const days = [day(1, 1)]
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts
index 410ee4aa6..fa2cb5b23 100644
--- a/client/src/components/Roadtrip/useRoadtripRoutes.ts
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'
-import { calculateRouteWithLegs } from '../Map/RouteCalculator'
+import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
import { resolveLegMode } from '../Planner/legMode'
-import { computeSchedule, splitIntoRuns, type Schedule } from './roadtripModel'
+import { computeSchedule, isServiceStopType, splitIntoRuns, type Schedule } from './roadtripModel'
import { useSettingsStore } from '../../store/settingsStore'
import type { Assignment, AssignmentsMap, Day, RouteSegment } from '../../types'
+import type { RoadtripVia } from '@trek/shared'
/** One stop on a day's drive — an assignment whose place actually has coordinates. */
export interface RoadtripStop {
@@ -19,6 +20,8 @@ export interface RoadtripStop {
/** Mode of the leg LEAVING this stop; null inherits the day default. */
legMode: string | null
incomingLegMode: string | null
+ /** fuel / charging / rest_area / campsite, or null for an ordinary place (#1797). */
+ stopType: string | null
}
export interface RoadtripDay {
@@ -42,8 +45,27 @@ export interface RoadtripDay {
duration: number
}
+/** A day the rail does not draw a drive for, but that a stop can still be moved onto. */
+export interface QuietDay {
+ dayId: number
+ dayNumber: number
+ date: string | null
+ title: string | null
+ /** Usually none or one — two would have made it a drive. */
+ stops: RoadtripStop[]
+}
+
export interface RoadtripRoutes {
days: RoadtripDay[]
+ /**
+ * Days with fewer than two stops, in trip order.
+ *
+ * They carry no drive, so they are not part of `days` and nothing is routed for them —
+ * but a stop has to be able to move onto one, and a day that is not on screen cannot be
+ * dropped on. Listed separately rather than folded into `days` so the totals and the
+ * routing keep meaning exactly what they meant before.
+ */
+ quietDays: QuietDay[]
/** One polyline per routed leg, in trip order — what the map draws in road trip mode. */
lines: [number, number][][]
/** The routed legs themselves, so the map can label each connector. */
@@ -84,6 +106,24 @@ interface RoutedLeg {
line: [number, number][]
}
+/**
+ * What makes a stop the same stop as far as routing is concerned. `planKey` is built from
+ * these, and so are the keys the routed legs are filed under, so the two can never drift
+ * apart.
+ *
+ * Deliberately not the assignment id. A stop added mid-day is written optimistically with
+ * a temporary negative id (`assignmentsSlice`) and swapped for the real one once the
+ * server answers, without its coordinates changing — so `planKey` stays identical, the
+ * effect does not run again, and a leg filed under the id would sit under a dead one
+ * forever. A missing leg breaks the schedule's chain (`computeSchedule` gives up its
+ * cursor), which would silently blank every arrival time after the new stop.
+ */
+const stopKey = (s: RoadtripStop): string =>
+ `${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`
+
+/** The drive from one stop to the next, identified the same way `planKey` identifies them. */
+const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
+
const asStop = (a: Assignment): RoadtripStop | null => {
const p = a.place
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
@@ -97,6 +137,7 @@ const asStop = (a: Assignment): RoadtripStop | null => {
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
legMode: a.leg_transport_mode ?? null,
incomingLegMode: a.incoming_leg_transport_mode ?? null,
+ stopType: p.stop_type ?? null,
}
}
@@ -118,11 +159,17 @@ export function useRoadtripRoutes(
assignments: AssignmentsMap,
/** Mode for legs that neither the stop nor the day pins down. */
fallbackProfile: string = 'driving',
+ /**
+ * Points the drive is made to pass through, per day (#1797). They join the routing
+ * request between the stops they follow, so the router draws the road the traveller
+ * chose rather than the one it prefers.
+ */
+ viasByDay: Record = {},
): RoadtripRoutes {
const routeProfile = fallbackProfile || 'driving'
// Leg text is pre-formatted in the chosen unit, so a km↔mi switch has to re-fetch.
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
- const [legsByDay, setLegsByDay] = useState>>({})
+ const [legsByDay, setLegsByDay] = useState>>({})
const [loading, setLoading] = useState(false)
const abortRef = useRef(null)
@@ -151,11 +198,36 @@ export function useRoadtripRoutes(
.filter(d => d.stops.length > 1)
}, [days, assignments])
+ const quietDays = useMemo(() => {
+ return [...days]
+ .sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
+ .map(d => ({
+ dayId: d.id,
+ dayNumber: d.day_number ?? 0,
+ date: d.date ?? null,
+ title: d.title ?? null,
+ stops: (assignments[String(d.id)] ?? [])
+ .slice()
+ .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
+ .map(asStop)
+ .filter((s): s is RoadtripStop => s !== null),
+ }))
+ .filter(d => d.stops.length < 2)
+ }, [days, assignments])
+
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
// editing its notes must not fire a routing round.
+ const viaKey = useMemo(
+ () => Object.entries(viasByDay)
+ .map(([dayId, vias]) => `${dayId}:${vias.map(v => `${v.after_order_index}@${v.lat.toFixed(5)},${v.lng.toFixed(5)}`).join('|')}`)
+ .sort()
+ .join(';'),
+ [viasByDay],
+ )
+
const planKey = useMemo(
- () => plan.map(d => `${d.dayId}:${d.stops.map(s => `${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`).join('|')}`).join(';'),
- [plan],
+ () => `${plan.map(d => `${d.dayId}:${d.stops.map(stopKey).join('|')}`).join(';')}#${viaKey}`,
+ [plan, viaKey],
)
useEffect(() => {
@@ -172,11 +244,11 @@ export function useRoadtripRoutes(
const dayDefault = (dayId: number): string =>
days.find(d => d.id === dayId)?.default_transport_mode || routeProfile
- const collected: Record> = {}
+ const collected: Record> = {}
const tasks: (() => Promise)[] = []
for (const day of plan) {
- const dayLegs: Record = {}
+ const dayLegs: Record = {}
collected[day.dayId] = dayLegs
const dfMode = dayDefault(day.dayId)
@@ -187,29 +259,59 @@ export function useRoadtripRoutes(
dfMode,
))
+ const dayVias = viasByDay[day.dayId] ?? []
+
for (const { stops: run, mode } of runs) {
tasks.push(async () => {
+ // Waypoints are the stops with this day's vias threaded in between them, so the
+ // router draws the road the traveller picked. `stopAt` remembers which waypoint
+ // each stop became, because the answer has a leg per waypoint PAIR and the rail
+ // wants one leg per stop pair.
+ const waypoints: { lat: number; lng: number }[] = []
+ const stopAt: number[] = []
+ run.forEach((stop, i) => {
+ stopAt.push(waypoints.length)
+ waypoints.push({ lat: stop.lat, lng: stop.lng })
+ if (i === run.length - 1) return
+ const dayIndex = day.stops.indexOf(stop)
+ dayVias
+ .filter(v => v.after_order_index === dayIndex)
+ .sort((a, b) => a.sequence - b.sequence)
+ .forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
+ })
+
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
if (controller.signal.aborted) return
try {
const r = await calculateRouteWithLegs(
- run.map(s => ({ lat: s.lat, lng: s.lng })),
+ waypoints,
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId },
)
- // legs[i] is the drive from run[i] to run[i+1]; the geometry comes back for
- // the run as a whole, which is what the map draws.
- r.legs.forEach((leg, i) => {
+ // Fold the router's per-waypoint legs back onto the stop pairs: a stop pair
+ // with a via between it comes back as two legs, and the rail shows one.
+ for (let i = 0; i < run.length - 1; i++) {
const from = run[i]
- if (from) dayLegs[from.assignmentId] = { seg: { ...leg, mode }, line: i === 0 ? r.coordinates : [] }
- })
+ const to = run[i + 1]
+ if (!from || !to) continue
+ const parts = r.legs.slice(stopAt[i], stopAt[i + 1])
+ if (!parts.length) continue
+ const merged = parts.length === 1 ? parts[0] : {
+ ...parts[0],
+ distance: parts.reduce((sum, l) => sum + (l.distance ?? 0), 0),
+ duration: parts.reduce((sum, l) => sum + (l.duration ?? 0), 0),
+ }
+ dayLegs[legKey(from, to)] = { seg: { ...merged, mode }, line: i === 0 ? r.coordinates : [] }
+ }
return
- } catch {
+ } catch (err) {
// Almost always a rate limit on the shared routing host. Back off and try
// again; a run that still won't route (island hop, dead router) simply stays
// blank, and the totals say so by being partial rather than wrong.
const delay = RETRY_DELAYS_MS[attempt]
if (delay === undefined) return
- await sleep(delay, controller.signal)
+ // When the host says how long to wait, waiting less is just a second refusal.
+ const asked = err instanceof RoutingRefusedError && err.isRateLimit ? err.retryAfterMs : null
+ await sleep(Math.max(delay, asked ?? 0), controller.signal)
}
}
})
@@ -246,7 +348,7 @@ export function useRoadtripRoutes(
const segments: RouteSegment[] = []
const out = plan.map(day => {
const dayLegs = legsByDay[day.dayId] ?? {}
- const routed = day.stops.slice(0, -1).map(s => dayLegs[s.assignmentId])
+ const routed = day.stops.slice(0, -1).map((s, i) => dayLegs[legKey(s, day.stops[i + 1])])
for (const leg of routed) {
if (!leg) continue
if (leg.line.length > 1) lines.push(leg.line)
@@ -270,8 +372,11 @@ export function useRoadtripRoutes(
segments,
totalDistance: out.reduce((s, d) => s + d.distance, 0),
totalDuration: out.reduce((s, d) => s + d.duration, 0),
- totalStops: out.reduce((s, d) => s + d.stops.length, 0),
+ // Service stops are not stops in this sense — a charger on the way is part of the
+ // drive, and counting it here would make the head disagree with the day cards.
+ totalStops: out.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0),
+ quietDays,
loading,
}
- }, [plan, legsByDay, loading])
+ }, [plan, legsByDay, loading, quietDays])
}
diff --git a/client/src/components/Roadtrip/useRoadtripVias.test.ts b/client/src/components/Roadtrip/useRoadtripVias.test.ts
new file mode 100644
index 000000000..e526a1667
--- /dev/null
+++ b/client/src/components/Roadtrip/useRoadtripVias.test.ts
@@ -0,0 +1,85 @@
+import { renderHook, waitFor, act } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const { roadtripApi } = vi.hoisted(() => ({
+ roadtripApi: {
+ listVias: vi.fn(),
+ addVia: vi.fn(),
+ moveVia: vi.fn(),
+ removeVia: vi.fn(),
+ },
+}))
+vi.mock('../../api/client', () => ({ roadtripApi }))
+
+import { useRoadtripVias } from './useRoadtripVias'
+
+const via = (over: Partial<{ id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }> = {}) => ({
+ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53, lng: 10, ...over,
+})
+
+beforeEach(() => {
+ roadtripApi.listVias.mockReset().mockResolvedValue({ vias: [] })
+ roadtripApi.addVia.mockReset().mockResolvedValue({ via: via() })
+ roadtripApi.moveVia.mockReset().mockResolvedValue({ via: via() })
+ roadtripApi.removeVia.mockReset().mockResolvedValue({ success: true })
+})
+
+describe('useRoadtripVias', () => {
+ it('FE-ROADTRIP-VIAS-001: asks once for the whole trip, not once per day', async () => {
+ roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
+
+ const { result } = renderHook(() => useRoadtripVias(7, true))
+
+ await waitFor(() => expect(Object.keys(result.current.byDay)).toHaveLength(2))
+ expect(roadtripApi.listVias).toHaveBeenCalledTimes(1)
+ expect(result.current.byDay[1].map(v => v.id)).toEqual([1])
+ })
+
+ it('FE-ROADTRIP-VIAS-002: with road trip mode off nothing is fetched', async () => {
+ const { result } = renderHook(() => useRoadtripVias(7, false))
+
+ await waitFor(() => expect(result.current.byDay).toEqual({}))
+ expect(roadtripApi.listVias).not.toHaveBeenCalled()
+ })
+
+ it('FE-ROADTRIP-VIAS-003: an addon that is switched off means no vias, not an error', async () => {
+ roadtripApi.listVias.mockRejectedValue(new Error('404'))
+
+ const { result } = renderHook(() => useRoadtripVias(7, true))
+
+ await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalled())
+ expect(result.current.byDay).toEqual({})
+ })
+
+ it('FE-ROADTRIP-VIAS-004: adding one re-reads the list rather than guessing its id', async () => {
+ const { result } = renderHook(() => useRoadtripVias(7, true))
+ await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalledTimes(1))
+
+ roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 5, day_id: 3 })] })
+ await act(async () => { await result.current.add(3, 1, 52, 11) })
+
+ expect(roadtripApi.addVia).toHaveBeenCalledWith(7, 3, { after_order_index: 1, lat: 52, lng: 11 })
+ // The server assigns id and sequence; patching the list by hand would be a second
+ // source of truth for the sake of one round trip.
+ await waitFor(() => expect(result.current.byDay[3]?.[0].id).toBe(5))
+ })
+
+ it('FE-ROADTRIP-VIAS-005: moving and removing go to the day they belong to', async () => {
+ const { result } = renderHook(() => useRoadtripVias(7, true))
+ await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalled())
+
+ await act(async () => { await result.current.move(2, 9, 51, 12) })
+ expect(roadtripApi.moveVia).toHaveBeenCalledWith(7, 2, 9, { lat: 51, lng: 12 })
+
+ await act(async () => { await result.current.remove(2, 9) })
+ expect(roadtripApi.removeVia).toHaveBeenCalledWith(7, 2, 9)
+ })
+
+ it('FE-ROADTRIP-VIAS-006: without a trip there is nothing to write to', async () => {
+ const { result } = renderHook(() => useRoadtripVias(null, true))
+
+ await act(async () => { await result.current.add(1, 0, 53, 10) })
+
+ expect(roadtripApi.addVia).not.toHaveBeenCalled()
+ })
+})
diff --git a/client/src/components/Roadtrip/useRoadtripVias.ts b/client/src/components/Roadtrip/useRoadtripVias.ts
new file mode 100644
index 000000000..3d90ad1a5
--- /dev/null
+++ b/client/src/components/Roadtrip/useRoadtripVias.ts
@@ -0,0 +1,83 @@
+import { useCallback, useEffect, useState } from 'react'
+import { roadtripApi } from '../../api/client'
+import { isEmptyReanchoring, type Reanchoring } from './roadtripModel'
+import type { RoadtripVia } from '@trek/shared'
+
+export interface RoadtripVias {
+ /** Every via of the trip, keyed by day. */
+ byDay: Record
+ add: (dayId: number, afterOrderIndex: number, lat: number, lng: number) => Promise
+ move: (dayId: number, id: number, lat: number, lng: number) => Promise
+ remove: (dayId: number, id: number) => Promise
+ /**
+ * Correct a day's anchors after its stops changed shape.
+ *
+ * Awaited by the caller before it lets the day re-route: the routing effect resolves
+ * `after_order_index` against whatever the stop list looks like at that moment, so a
+ * re-anchoring that lands afterwards is a second, visibly wrong route in between.
+ */
+ reanchor: (dayId: number, plan: Reanchoring) => Promise
+}
+
+const EMPTY: Record = {}
+
+/**
+ * The points this trip's drives are routed through.
+ *
+ * Loaded once for the whole trip rather than per day: a road trip routes every day at
+ * once, and one request for the lot beats one per day against a server that has to open
+ * the same table each time.
+ *
+ * Writes are optimistic in the sense that the list is refreshed from the answer, not
+ * patched by hand — a via has a server-assigned id and sequence, and guessing them would
+ * be a second source of truth for the sake of one round trip.
+ */
+export function useRoadtripVias(tripId: number | string | null, active: boolean): RoadtripVias {
+ const [byDay, setByDay] = useState>(EMPTY)
+
+ const group = useCallback((vias: RoadtripVia[]) => {
+ const next: Record = {}
+ for (const v of vias) (next[v.day_id] ??= []).push(v)
+ return next
+ }, [])
+
+ const reload = useCallback(async () => {
+ if (!tripId || !active) { setByDay(EMPTY); return }
+ try {
+ const { vias } = await roadtripApi.listVias(tripId)
+ setByDay(group(vias))
+ } catch {
+ // An instance with the addon off answers 404 here, which is not an error worth
+ // reporting — it just means there are no vias to draw.
+ setByDay(EMPTY)
+ }
+ }, [tripId, active, group])
+
+ useEffect(() => { void reload() }, [reload])
+
+ const add = useCallback(async (dayId: number, afterOrderIndex: number, lat: number, lng: number) => {
+ if (!tripId) return
+ await roadtripApi.addVia(tripId, dayId, { after_order_index: afterOrderIndex, lat, lng })
+ await reload()
+ }, [tripId, reload])
+
+ const move = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
+ if (!tripId) return
+ await roadtripApi.moveVia(tripId, dayId, id, { lat, lng })
+ await reload()
+ }, [tripId, reload])
+
+ const reanchor = useCallback(async (dayId: number, plan: Reanchoring) => {
+ if (!tripId || isEmptyReanchoring(plan)) return
+ await roadtripApi.reanchorVias(tripId, dayId, plan)
+ await reload()
+ }, [tripId, reload])
+
+ const remove = useCallback(async (dayId: number, id: number) => {
+ if (!tripId) return
+ await roadtripApi.removeVia(tripId, dayId, id)
+ await reload()
+ }, [tripId, reload])
+
+ return { byDay, add, move, remove, reanchor }
+}
diff --git a/client/src/components/Roadtrip/useRouteAlternatives.ts b/client/src/components/Roadtrip/useRouteAlternatives.ts
new file mode 100644
index 000000000..e1bee3b84
--- /dev/null
+++ b/client/src/components/Roadtrip/useRouteAlternatives.ts
@@ -0,0 +1,112 @@
+import { useCallback, useRef, useState } from 'react'
+import { calculateAlternatives, calculateRoute, type RouteAlternative } from '../Map/RouteCalculator'
+import type { RoadtripStop } from './useRoadtripRoutes'
+import type { RoadtripVia } from '@trek/shared'
+
+/** One offered way of driving a leg, with what it means for this trip. */
+export interface OfferedRoute extends RouteAlternative {
+ /** True for the road currently driven — the one the vias on this leg produce. */
+ current?: boolean
+ /** True for the router's own preference with no vias at all. */
+ direct?: boolean
+}
+
+/** Which leg is being reconsidered, and what was offered for it. */
+export interface LegAlternatives {
+ dayId: number
+ /** Index of the leg within the day: the drive from stop `index` to `index + 1`. */
+ index: number
+ routes: OfferedRoute[]
+ loading: boolean
+ error: boolean
+}
+
+export interface RouteAlternativesState {
+ open: LegAlternatives | null
+ /** Asks the router for the ways of driving this one leg. */
+ ask: (dayId: number, index: number, from: RoadtripStop, to: RoadtripStop, profile: string, vias: RoadtripVia[]) => void
+ close: () => void
+}
+
+/** Two lines are the same road if their ends and their length agree closely enough. */
+function sameRoute(a: RouteAlternative, b: RouteAlternative): boolean {
+ return Math.abs(a.distance - b.distance) < 50 && Math.abs(a.duration - b.duration) < 30
+}
+
+/**
+ * Other ways of driving one leg.
+ *
+ * One leg at a time, on demand, and never for the whole trip: OSRM answers with
+ * alternatives only between exactly two coordinates, so asking for a day would mean
+ * breaking up the single request `splitIntoRuns` bundles it into — the optimisation that
+ * makes a road trip appear at once rather than trickle in.
+ *
+ * When the leg already carries vias, the road actually being driven is NOT among what the
+ * router offers for the two bare endpoints — it was asked a different question. So it is
+ * fetched separately and put at the top, marked as the current one. Without that, opening
+ * the picker on a leg you have already reshaped shows you three roads, none of which is
+ * the one you are on, and no way back to it.
+ */
+export function useRouteAlternatives(): RouteAlternativesState {
+ const [open, setOpen] = useState(null)
+ const abortRef = useRef(null)
+
+ const close = useCallback(() => {
+ abortRef.current?.abort()
+ abortRef.current = null
+ setOpen(null)
+ }, [])
+
+ const ask = useCallback((
+ dayId: number,
+ index: number,
+ from: RoadtripStop,
+ to: RoadtripStop,
+ profile: string,
+ vias: RoadtripVia[],
+ ) => {
+ abortRef.current?.abort()
+ const controller = new AbortController()
+ abortRef.current = controller
+ setOpen({ dayId, index, routes: [], loading: true, error: false })
+
+ const osrmProfile: 'driving' | 'walking' | 'cycling' =
+ profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
+ const ends = [{ lat: from.lat, lng: from.lng }, { lat: to.lat, lng: to.lng }]
+
+ // The router's offers for the bare leg, plus — only when the leg is already bent —
+ // the road that bend produces, so both are on the table together.
+ const offers = calculateAlternatives(ends[0], ends[1], osrmProfile, { signal: controller.signal })
+ const current = vias.length
+ ? calculateRoute(
+ [ends[0], ...vias.map(v => ({ lat: v.lat, lng: v.lng })), ends[1]],
+ osrmProfile,
+ { signal: controller.signal },
+ ).then(r => ({
+ coordinates: r.coordinates,
+ distance: r.distance,
+ duration: r.duration,
+ divergence: null,
+ current: true,
+ } as OfferedRoute)).catch(() => null)
+ : Promise.resolve(null)
+
+ void Promise.all([offers, current])
+ .then(([alternatives, driven]) => {
+ if (controller.signal.aborted) return
+ const routes: OfferedRoute[] = alternatives.map((r, i) => ({ ...r, direct: i === 0 }))
+ // Only prepend the driven road when it really is a different one; with a via that
+ // barely moves the route, the two are the same road and one entry is honest.
+ if (driven && !routes.some(r => sameRoute(r, driven))) routes.unshift(driven)
+ setOpen({ dayId, index, routes, loading: false, error: false })
+ })
+ .catch(() => {
+ if (controller.signal.aborted) return
+ // A router that will not answer is not worth a dialog; the leg keeps the route it
+ // already has and the panel says so.
+ setOpen({ dayId, index, routes: [], loading: false, error: true })
+ })
+ }, [])
+
+ return { open, ask, close }
+}
diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx
index aaa7f74ee..1355f0014 100644
--- a/client/src/components/Settings/MapSettingsTab.tsx
+++ b/client/src/components/Settings/MapSettingsTab.tsx
@@ -169,6 +169,7 @@ export default function MapSettingsTab(): React.ReactElement {
const [saving, setSaving] = useState(false)
const [provider, setProvider] = useState(initialProvider)
const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '')
+ const [routingBaseUrl, setRoutingBaseUrl] = useState(settings.routing_base_url || '')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState(settings.mapbox_access_token || '')
const [cartoKey, setCartoKey] = useState(settings.carto_api_key || '')
@@ -182,6 +183,7 @@ export default function MapSettingsTab(): React.ReactElement {
const nextProvider = normalizeProvider(settings.map_provider)
setProvider(nextProvider)
setMapTileUrl(settings.map_tile_url || '')
+ setRoutingBaseUrl(settings.routing_base_url || '')
setMapboxToken(settings.mapbox_access_token || '')
setCartoKey(settings.carto_api_key || '')
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
@@ -217,6 +219,7 @@ export default function MapSettingsTab(): React.ReactElement {
await updateSettings({
map_provider: provider,
map_tile_url: mapTileUrl,
+ routing_base_url: routingBaseUrl.trim(),
mapbox_access_token: mapboxToken,
carto_api_key: cartoKey,
...stylePatch,
@@ -333,6 +336,23 @@ export default function MapSettingsTab(): React.ReactElement {
)}
+ {/* Routing is the same engine whichever renderer draws the map, so this sits outside
+ the provider blocks. Tokens rather than the slate-* around it: theme:lint holds
+ new markup to them, and a field added today should not need converting later. */}
+ {!managed && (
+
diff --git a/client/src/index.css b/client/src/index.css
index a15392236..a0ec5300e 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -42,6 +42,13 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
.journey-feed-scroll { scrollbar-width: none; -ms-overflow-style: none; }
.journey-feed-scroll::-webkit-scrollbar { display: none; }
+/* Road trip rail: hide the scrollbar of the day list. The totals card is pinned
+ above the list and therefore outside its scroll box, so a visible scrollbar
+ would take width from the day cards alone and leave the two stacks a few
+ pixels out of line — the one thing a column of nested cards cannot hide. */
+.roadtrip-rail-scroll { scrollbar-width: none; -ms-overflow-style: none; }
+.roadtrip-rail-scroll::-webkit-scrollbar { display: none; }
+
/* Journey video: let the player fill the box the lightbox gives it.
*
* Plyr sizes its wrapper from the video's intrinsic aspect ratio via a
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index ed5e0bc4d..2f80dc6cb 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -69,6 +69,9 @@ const ExpenseModal = lazyWithRetry(() =>
const CollabPanel = lazyWithRetry(() => import('../components/Collab/CollabPanel'))
const RoadtripSidebar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripSidebar'))
const RoadtripCorridorPanel = lazyWithRetry(() => import('../components/Roadtrip/RoadtripCorridorPanel'))
+const RoadtripStopPopup = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStopPopup'))
+const RoadtripStayModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStayModal'))
+const RoadtripAlternativesBar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripAlternativesBar'))
// Already rendered conditionally, so lazy bites immediately. Worth it beyond its
// own 63 kB: it is the only path to TransitSearchPanel, which drags in tz-lookup
// — about 200 kB of packed zone geometry that every trip used to load.
@@ -256,6 +259,13 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
+ stopDraft, setStopDraft, saveStopDraft, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
+ roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
+ routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
+ stayDraft, setStayDraft, setRoadtripStay,
+ highlightedAlternative, setHighlightedAlternative,
+ moveRoadtripStopToDay,
+ dropPoiOnRoute,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable,
@@ -272,7 +282,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
- handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
+ handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
@@ -407,12 +417,47 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r)
}}
- pois={roadtripActive ? roadtripCorridor.search.results : poi.pois}
- onPoiClick={openAddPlaceFromPoi}
+ pois={roadtripActive
+ /* `visible`, not `search.results`: the map is the picture of this very
+ list, and filtering the list while seventy pins stay on the map no
+ longer answers "which of these". */
+ ? roadtripCorridor.visible
+ : poi.pois}
+ onPoiClick={handlePoiClick}
+ // Only while road trip mode is on: outside it there is no drive to drop onto.
+ onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined}
+ // Clicking the drawn route puts a via there; only in road trip mode, where
+ // the route is the thing being worked on.
+ onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
+ roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
+ alternativeRoutes={alternativeOverlays}
+ focusPoints={alternativeFocusPoints}
+ clusterLoosely={roadtripActive}
+ activeAlternative={highlightedAlternative}
+ onChooseAlternative={chooseRouteAlternative}
+ onHighlightAlternative={setHighlightedAlternative}
+ onMoveVia={can('day_edit', trip) ? moveRoadtripVia : undefined}
+ onRemoveVia={can('day_edit', trip) ? removeRoadtripVia : undefined}
onViewportChange={poi.onViewportChange}
onMapReady={setGlMap}
/>
+ {/* Over the map rather than in a dialog: the answer to "which of these" is the
+ roads drawn behind it, so covering them to ask would hide the point. */}
+ {routeAlternatives.open && (
+
+
+
+
+
+ )}
+
{(poiPillEnabled || glMap) && (
handlePlaceClick(placeId, assignmentId)}
+ onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
+ onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
+ onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
+ openAlternatives={routeAlternatives.open}
+ onEditStay={can('place_edit', trip) ? setStayDraft : undefined}
/>
) : (
@@ -590,7 +640,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{roadtripActive ? (
-
+ {/* No Add button for someone who may not add: openAddPlaceFromPoi
+ returns silently without the permission, which reads as a broken
+ button rather than a missing one. */}
+
) : (
+ {/* The small way in for something found along the drive. Mounted only while a
+ draft exists, so the chunk stays unloaded for anyone not using road trip mode. */}
+ {/* How long a stop takes. Its own gate, not the corridor draft's: a stay is set on
+ any stop of the trip, not only on something just found along the route. */}
+ {stayDraft && (
+
+ setStayDraft(null)} onSave={setRoadtripStay} />
+
+ )}
+ {stopDraft && (
+
+ setStopDraft(null)}
+ onSave={saveStopDraft}
+ onMoreDetails={stopDraftToForm}
+ />
+
+ )}
{ setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} />
{
expect(result.current.tripMembers).toEqual([])
})
})
+
+describe('useTripPlanner — road trip stops', () => {
+ /** A day whose stops the rail would show, plus one the rail hides for want of coordinates. */
+ const seedDrive = () => {
+ const places = [
+ buildPlace({ id: 10, name: 'Hamburg', lat: 53.55, lng: 9.99 }),
+ buildPlace({ id: 20, name: 'Lueneburg', lat: 53.25, lng: 10.41 }),
+ buildPlace({ id: 30, name: 'Berlin', lat: 52.52, lng: 13.40 }),
+ // No coordinates, so it never appears in the rail — and must survive a reorder.
+ buildPlace({ id: 40, name: 'Idee ohne Ort', lat: null, lng: null }),
+ ]
+ return seedTrip({
+ days: [buildDay({ id: 7, day_number: 1 })],
+ places,
+ assignments: {
+ '7': [
+ buildAssignment({ id: 1, day_id: 7, place_id: 10, order_index: 0, place: places[0] }),
+ buildAssignment({ id: 2, day_id: 7, place_id: 20, order_index: 1, place: places[1] }),
+ buildAssignment({ id: 3, day_id: 7, place_id: 30, order_index: 2, place: places[2] }),
+ buildAssignment({ id: 4, day_id: 7, place_id: 40, order_index: 3, place: places[3] }),
+ ],
+ },
+ } as unknown as Partial)
+ }
+
+ it('FE-TP-HOOK-111: reordering the rail sends the day’s WHOLE order, hidden stops included', async () => {
+ seedDrive()
+ const { result } = await renderPlanner()
+
+ // The rail lists three stops; the fourth has no coordinates and is not on show.
+ await act(async () => { await result.current.reorderRoadtripStop(7, 3, 0) })
+
+ // Both the slice and the WebSocket handler rebuild the day purely from these ids, so
+ // leaving the invisible one out would delete it from every session's store.
+ expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 7, [3, 1, 2, 4])
+ })
+
+ it('FE-TP-HOOK-112: moving a stop onto itself asks the server for nothing', async () => {
+ seedDrive()
+ const { result } = await renderPlanner()
+
+ await act(async () => { await result.current.reorderRoadtripStop(7, 2, 1) })
+
+ expect(actions.reorderAssignments).not.toHaveBeenCalled()
+ })
+
+ it('FE-TP-HOOK-113: an index past the end of the chain lands on the last stop', async () => {
+ seedDrive()
+ const { result } = await renderPlanner()
+
+ await act(async () => { await result.current.reorderRoadtripStop(7, 1, 99) })
+
+ // Clamped to the last VISIBLE stop, which is Berlin — not past the hidden fourth row.
+ expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 7, [2, 3, 1, 4])
+ })
+
+ it('FE-TP-HOOK-114: a failed reorder says so rather than leaving a phantom order', async () => {
+ seedDrive()
+ actions.reorderAssignments.mockRejectedValueOnce(new Error('nope'))
+ const { result } = await renderPlanner()
+
+ await act(async () => { await result.current.reorderRoadtripStop(7, 3, 0) })
+
+ expect(toasts.some(t => t.type === 'error')).toBe(true)
+ })
+
+ it('FE-TP-HOOK-115: an unknown assignment is not reordered into existence', async () => {
+ seedDrive()
+ const { result } = await renderPlanner()
+
+ await act(async () => { await result.current.reorderRoadtripStop(7, 999, 0) })
+
+ expect(actions.reorderAssignments).not.toHaveBeenCalled()
+ })
+})
+
+describe('useTripPlanner — dropping a hit on the drive', () => {
+ it('FE-TP-HOOK-116: a drop far from the route is ignored rather than guessed at', async () => {
+ seedTrip()
+ const { result } = await renderPlanner()
+
+ // No corridor results and no day: nothing to match, so nothing may open.
+ act(() => { result.current.dropPoiOnRoute('node:9', 0, 0) })
+
+ expect(result.current.stopDraft).toBeNull()
+ })
+
+ it('FE-TP-HOOK-117: a drop for an unknown hit opens nothing', async () => {
+ seedTrip()
+ const { result } = await renderPlanner()
+
+ act(() => { result.current.dropPoiOnRoute('node:does-not-exist', 53.5, 9.9) })
+
+ expect(result.current.stopDraft).toBeNull()
+ })
+})
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index bf3107838..67a79330d 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -21,6 +21,19 @@ import { useTripWebSocket } from '../../hooks/useTripWebSocket'
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes'
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
+import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
+import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternatives'
+import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
+import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois'
+import { projectOntoRoute, type LatLng } from '../../components/Roadtrip/corridor'
+import {
+ insertIndexForAlong,
+ reanchorAfterInsert,
+ reanchorAfterRemove,
+ reanchorAfterReorder,
+} from '../../components/Roadtrip/roadtripModel'
+import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
+import type { RoadtripStopType } from '@trek/shared'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
@@ -227,6 +240,19 @@ export function useTripPlanner() {
// mobile day toolbar, a long-press on the mobile map); every other opener
// clears it, so a place added from the pool still lands in the pool (#1998).
const [placeFormDayId, setPlaceFormDayId] = useState(null)
+ /**
+ * Where in the day the place being added belongs, when the caller knows.
+ * Null means the old behaviour: the server appends it at the end.
+ */
+ const [placeFormPosition, setPlaceFormPosition] = useState(null)
+ /**
+ * The corridor hit waiting to become a stop, while the small popup is open.
+ *
+ * The full place form is the wrong question for a petrol station — category, price,
+ * photo, notes and files are all empty for one — so in road trip mode a hit opens this
+ * instead, and the form stays one click away behind "more details".
+ */
+ const [stopDraft, setStopDraft] = useState(null)
const [reservationModalDayId, setReservationModalDayId] = useState(null)
// The bottom-nav "+" opens the new-place form via ?create=place.
@@ -508,7 +534,14 @@ export function useTripPlanner() {
// Road trip mode reads the whole trip, not the selected day, so it owns its own legs.
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
const roadtripActive = !!enabledAddons.roadtrip && roadtripMode
- const roadtripRoutes = useRoadtripRoutes(tripId, roadtripActive ? days : EMPTY_DAYS, assignments, routeProfile)
+ const roadtripVias = useRoadtripVias(tripId, roadtripActive)
+ const roadtripRoutes = useRoadtripRoutes(
+ tripId,
+ roadtripActive ? days : EMPTY_DAYS,
+ assignments,
+ routeProfile,
+ roadtripVias.byDay,
+ )
// Lives here rather than in the panel because the map draws what it finds.
const roadtripCorridor = useRoadtripCorridor(roadtripRoutes)
@@ -587,7 +620,12 @@ export function useTripPlanner() {
// Open the Add-Place form pre-filled from an OSM "explore" POI marker — all the
// data already comes from the POI, so no reverse-geocode is needed.
- const openAddPlaceFromPoi = useCallback((poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string }, dayId?: number | null) => {
+ const openAddPlaceFromPoi = useCallback((
+ poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string },
+ dayId?: number | null,
+ /** Index within that day. Omitted, the place is appended, which is what every caller did before. */
+ position?: number | null,
+ ) => {
if (!can('place_edit', trip)) return
setPrefillCoords({
lat: poi.lat,
@@ -601,9 +639,449 @@ export function useTripPlanner() {
setEditingPlace(null)
setEditingAssignmentId(null)
setPlaceFormDayId(dayId ?? null)
+ setPlaceFormPosition(position ?? null)
setShowPlaceForm(true)
}, [trip])
+ /**
+ * Adding a POI straight off the map, with the day it belongs to.
+ *
+ * In road trip mode that is the day being searched: without it the place lands in the
+ * unplanned pool, and neither column shows that pool while road trip mode is on, so a
+ * just-added stop disappears without a trace. Outside road trip mode nothing changes —
+ * `undefined` keeps the old "let the user pick" behaviour.
+ *
+ * Memoised because both map renderers rebuild every POI marker whenever this callback's
+ * identity changes.
+ */
+ const roadtripDayId = roadtripCorridor.day?.dayId ?? null
+ const { insertIndexFor: roadtripInsertIndexFor } = roadtripCorridor
+ const roadtripDayNumber = roadtripCorridor.day?.dayNumber ?? 0
+ const handlePoiClick = useCallback((poi: Parameters[0]) => {
+ if (!can('place_edit', trip)) return
+ // A corridor hit knows how far along the drive it sits, so it can go straight into
+ // the chain in driving order instead of being dragged there afterwards.
+ const hit = roadtripActive && 'alongKm' in poi ? (poi as unknown as CorridorPoi) : null
+ if (hit && roadtripDayId != null) {
+ setStopDraft({ poi: hit, dayId: roadtripDayId, position: roadtripInsertIndexFor(hit), dayNumber: roadtripDayNumber })
+ return
+ }
+ openAddPlaceFromPoi(poi, roadtripActive ? roadtripDayId : undefined)
+ }, [openAddPlaceFromPoi, roadtripActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, can, trip])
+
+ /**
+ * The stops of a day as the road trip counts them, in the order it drives them.
+ *
+ * This is the index space `after_order_index` lives in: sorted by `order_index`, and
+ * filtered to the rows that have coordinates, because a place the map cannot put
+ * anywhere is not a point the router is given. Built from `assignments` rather than
+ * from `roadtripRoutes` so it also answers for a day with one stop or none — exactly
+ * the day a stop gets pushed onto when a leg turns out too long.
+ */
+ const roadtripStopsOf = useCallback((dayId: number) =>
+ (assignments[String(dayId)] ?? [])
+ .slice()
+ .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
+ .filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number'),
+ [assignments])
+
+ /**
+ * Which half of a split leg a via belongs to, measured on the road actually driven.
+ *
+ * Both the via and the new stop are projected onto the day's current routed line, so
+ * the comparison is "which one does the car reach first" rather than a straight-line
+ * guess. The line already includes the detour the via causes, so the via sits exactly
+ * on it and its position along the drive is exact.
+ *
+ * Falls back to keeping the via on the first half. A projection only fails when the
+ * point is nowhere near the drive, and in that case leaving the anchor where it was is
+ * the answer that changes least.
+ */
+ const viaLiesBefore = useCallback((dayId: number, at: { lat: number; lng: number }) => {
+ const line: LatLng[] = (roadtripRoutes.days.find(d => d.dayId === dayId)?.geometry ?? [])
+ .map(([lat, lng]) => ({ lat, lng }))
+ const insertAt = line.length ? projectOntoRoute(at, line)?.alongKm ?? null : null
+ return (via: { lat: number; lng: number }) => {
+ if (insertAt === null) return true
+ const viaAt = projectOntoRoute({ lat: via.lat, lng: via.lng }, line)?.alongKm
+ return viaAt === undefined || viaAt === null ? true : viaAt < insertAt
+ }
+ }, [roadtripRoutes.days])
+
+ /**
+ * Saves a corridor hit as a stop: the place itself, then its position in the day.
+ *
+ * `stop_type` is what makes it a fuel stop rather than a place that happens to sell
+ * fuel — the road-trip kinds are their own dimension, deliberately not one of the
+ * traveller's editable categories, so the palette and the meaning stay put.
+ */
+ const saveStopDraft = useCallback(async ({ stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number }) => {
+ if (!stopDraft) return
+ const { poi, dayId, position } = stopDraft
+ try {
+ const place = await tripActions.addPlace(tripId, {
+ name: poi.name,
+ lat: poi.lat,
+ lng: poi.lng,
+ address: poi.address || null,
+ website: poi.website || undefined,
+ phone: poi.phone || undefined,
+ osm_id: poi.osm_id,
+ duration_minutes: dwellMinutes,
+ stop_type: stopType,
+ })
+ if (place?.id) {
+ // Worked out BEFORE the stop lands, against the day as it stands and the road as
+ // it is currently driven — once the list has shifted there is no record of which
+ // leg each via was drawn for.
+ const plan = reanchorAfterInsert(
+ roadtripVias.byDay[dayId] ?? [],
+ position,
+ viaLiesBefore(dayId, { lat: poi.lat, lng: poi.lng }),
+ )
+ await tripActions.assignPlaceToDay(tripId, dayId, place.id, position)
+ // Awaited before the day re-routes: the routing effect reads the anchors against
+ // the new stop list, so a correction landing after it would draw the wrong road
+ // first and the right one a moment later.
+ await roadtripVias.reanchor(dayId, plan)
+ updateRouteForDay(dayId)
+ }
+ setStopDraft(null)
+ toast.success(t('trip.toast.placeAdded'))
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : t('common.unknownError'))
+ }
+ }, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore])
+
+ /**
+ * Moves a stop within its day, from the road trip rail.
+ *
+ * The rail reports only "this assignment, from here to there" and the full order is
+ * rebuilt here, from the day's COMPLETE assignment list rather than from what the rail
+ * shows. That matters: the rail hides stops without coordinates, and both
+ * `reorderAssignments` and the WebSocket handler rebuild the day's array purely from the
+ * ids they are given — anything left out would vanish from the store, for every session
+ * watching the trip.
+ *
+ * Within one day only. Moving between days stays in the day plan, where empty and
+ * one-stop days are visible and can be dropped onto; the rail leaves them out, so a day
+ * would disappear from under the cursor mid-gesture.
+ *
+ * No confirmation prompt for a stop with a pinned time, unlike the day plan: the rail
+ * recomputes the cascade immediately and marks a stop it can no longer reach in time.
+ * Showing the consequence is better than asking about it in advance.
+ */
+ const reorderRoadtripStop = useCallback(async (dayId: number, assignmentId: number, toIndex: number) => {
+ if (!can('day_edit', trip)) return
+ const all = assignments[String(dayId)] ?? []
+ const ordered = [...all].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
+ const fromIdx = ordered.findIndex(a => a.id === assignmentId)
+ if (fromIdx === -1) return
+
+ // `toIndex` counts stops as the rail lists them; map it onto the full list, which may
+ // hold rows the rail never showed.
+ const visible = ordered.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number')
+ const target = visible[Math.min(Math.max(toIndex, 0), visible.length - 1)]
+ if (!target || target.id === assignmentId) return
+ const toIdx = ordered.findIndex(a => a.id === target.id)
+ if (toIdx === -1 || toIdx === fromIdx) return
+
+ const next = [...ordered]
+ const [moved] = next.splice(fromIdx, 1)
+ next.splice(toIdx, 0, moved)
+
+ // In the rail's own index space, which is the one the anchors are counted in. A stop
+ // without coordinates never entered that space, so moving it changes nothing there.
+ const fromVis = visible.findIndex(a => a.id === assignmentId)
+ const plan = fromVis === -1
+ ? null
+ : reanchorAfterReorder(roadtripVias.byDay[dayId] ?? [], fromVis, toIndex, visible.length)
+
+ try {
+ await tripActions.reorderAssignments(tripId, dayId, next.map(a => a.id))
+ if (plan) await roadtripVias.reanchor(dayId, plan)
+ updateRouteForDay(dayId)
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : t('common.unknownError'))
+ }
+ }, [assignments, tripId, tripActions, updateRouteForDay, toast, t, can, trip, roadtripVias])
+
+ /**
+ * The stop whose length is being set, or null while the dialog is closed.
+ *
+ * Held here rather than in the rail because the write goes through `tripActions`, and
+ * the rail is a list — putting a dialog's state inside a row means it dies whenever the
+ * list re-renders around it.
+ */
+ const [stayDraft, setStayDraft] = useState<{ placeId: number; name: string; minutes: number | null; arrival: string | null } | null>(null)
+
+ /**
+ * How long the traveller stays at one stop.
+ *
+ * Writes `places.duration_minutes`, which the schedule has read since it was written
+ * and which nothing in TREK has ever been able to set — the road trip is the only place
+ * the value means anything, so it is the only place that edits it.
+ *
+ * Zero rather than null to clear: the update statement folds a null into "leave it
+ * alone" (`COALESCE(?, duration_minutes)`), so a null could give a stop a stay but
+ * never take one away. The rail reads zero and absent as the same thing.
+ */
+ const setRoadtripStay = useCallback(async (placeId: number, minutes: number) => {
+ if (!can('place_edit', trip)) return
+ try {
+ await tripActions.updatePlace(tripId, placeId, { duration_minutes: minutes })
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : t('common.unknownError'))
+ }
+ }, [tripId, tripActions, toast, t, can, trip])
+
+ /**
+ * Moves a stop onto another day, from the road trip rail.
+ *
+ * Split from `reorderRoadtripStop` because it is a different call with a different
+ * failure mode: `moveAssignment` writes to two days, and the rail has to be able to
+ * reach days it draws no drive for — a day with one stop or none is exactly what a
+ * stop gets moved onto when a leg turns out to be too long for one day.
+ *
+ * The target index counts the stops the rail shows on that day; an empty day takes
+ * position 0.
+ */
+ const moveRoadtripStopToDay = useCallback(async (
+ fromDayId: number,
+ assignmentId: number,
+ toDayId: number,
+ toIndex: number,
+ ) => {
+ if (!can('day_edit', trip)) return
+ if (fromDayId === toDayId) return
+ const target = (assignments[String(toDayId)] ?? [])
+ .slice()
+ .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
+ const visible = target.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number')
+ // Map the rail's own count onto the day's full list, which may hold rows it hides.
+ const anchor = visible[Math.min(Math.max(toIndex, 0), Math.max(visible.length - 1, 0))]
+ const at = anchor ? target.findIndex(a => a.id === anchor.id) : target.length
+
+ // Both days shift at once, and each needs its own correction: the stop leaves a gap
+ // on one side and opens one on the other. No geometry is measured for the arriving
+ // day — its roads are about to be different anyway, so there is nothing stable to
+ // measure a via against.
+ const fromStops = roadtripStopsOf(fromDayId)
+ const fromVis = fromStops.findIndex(a => a.id === assignmentId)
+ const fromPlan = fromVis === -1
+ ? null
+ : reanchorAfterRemove(roadtripVias.byDay[fromDayId] ?? [], fromVis, fromStops.length)
+ const toVis = Math.min(Math.max(toIndex, 0), visible.length)
+ const toPlan = fromVis === -1
+ ? null
+ : reanchorAfterInsert(roadtripVias.byDay[toDayId] ?? [], toVis, () => true)
+
+ try {
+ await tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, at < 0 ? target.length : at)
+ if (fromPlan) await roadtripVias.reanchor(fromDayId, fromPlan)
+ if (toPlan) await roadtripVias.reanchor(toDayId, toPlan)
+ updateRouteForDay(fromDayId)
+ updateRouteForDay(toDayId)
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : t('common.unknownError'))
+ }
+ }, [assignments, tripId, tripActions, updateRouteForDay, toast, t, can, trip, roadtripVias, roadtripStopsOf])
+
+ const routeAlternatives = useRouteAlternatives()
+ /**
+ * Which offered route the pointer is on, so the map can light that one up.
+ *
+ * Lives here rather than in the bar because the map draws it and the bar reports it —
+ * neither owns it, and passing it through the page would put state in a wiring
+ * container the Page pattern keeps stateless.
+ */
+ const [highlightedAlternative, setHighlightedAlternative] = useState(null)
+ // Closing the picker has to clear it, or the next one opens with a road already lit.
+ useEffect(() => {
+ if (!routeAlternatives.open) setHighlightedAlternative(null)
+ }, [routeAlternatives.open])
+
+ /**
+ * The offered routes as the map draws them: line, colour, and the label that sits on
+ * the road. Built here rather than in the page so the page stays a wiring container
+ * and both renderers get the identical shape.
+ */
+ const alternativeOverlays = useMemo(
+ () => buildAlternativeOverlays(routeAlternatives.open?.routes, {
+ fastest: t('roadtrip.alt.fastest'),
+ current: t('roadtrip.alt.current'),
+ noMotorway: t('roadtrip.alt.noMotorway'),
+ noToll: t('roadtrip.alt.noToll'),
+ }),
+ [routeAlternatives.open, t],
+ )
+
+ /**
+ * The stretch of map the offered routes cover, handed to whichever renderer is up.
+ *
+ * Opening the picker without moving the camera means weighing three roads you cannot
+ * see. Derived from the overlays rather than from the two endpoints so the frame holds
+ * the whole of every alternative, including one that swings far off the direct line.
+ * Empty while nothing is open, and the map is told to do nothing with an empty list —
+ * so closing the picker leaves the view where the user put it.
+ */
+ const alternativeFocusPoints = useMemo(
+ () => alternativeOverlays.flatMap(o => o.coordinates),
+ [alternativeOverlays],
+ )
+
+ /** Asks the router for other ways of driving one leg of one day. */
+ const askRouteAlternatives = useCallback((dayId: number, legIndex: number) => {
+ const day = roadtripRoutes.days.find(d => d.dayId === dayId)
+ const from = day?.stops[legIndex]
+ const to = day?.stops[legIndex + 1]
+ if (!from || !to || !day) return
+ if (routeAlternatives.open?.dayId === dayId && routeAlternatives.open.index === legIndex) {
+ routeAlternatives.close()
+ return
+ }
+ // The vias on THIS leg, so the road currently driven is offered alongside the
+ // router's own suggestions rather than being missing from its own picker.
+ const dayIndex = day.stops.indexOf(from)
+ const legVias = (roadtripVias.byDay[dayId] ?? [])
+ .filter(v => v.after_order_index === dayIndex)
+ .sort((a, b) => a.sequence - b.sequence)
+ routeAlternatives.ask(dayId, legIndex, from, to, routeProfile, legVias)
+ }, [roadtripRoutes.days, routeAlternatives, routeProfile, roadtripVias.byDay])
+
+ /**
+ * Taking one of the offered routes.
+ *
+ * Saved as a via at the point where that route differs most from the default, not as a
+ * stored polyline: a polyline goes stale with the next OSM update and with every stop
+ * that moves, while a via keeps forcing the router back onto this road for as long as
+ * the road exists.
+ */
+ const chooseRouteAlternative = useCallback(async (index: number) => {
+ const open = routeAlternatives.open
+ const alt = open?.routes[index]
+ if (!open || !alt) return
+ // Choosing the road already being driven changes nothing.
+ if (alt.current) { routeAlternatives.close(); return }
+ // The router's own preference means no detour at all, so the vias on this leg go.
+ if (alt.direct || !alt.divergence) {
+ const day = roadtripRoutes.days.find(d => d.dayId === open.dayId)
+ const stop = day?.stops[open.index]
+ const dayIndex = stop && day ? day.stops.indexOf(stop) : -1
+ const existing = (roadtripVias.byDay[open.dayId] ?? []).filter(v => v.after_order_index === dayIndex)
+ for (const via of existing) await roadtripVias.remove(open.dayId, via.id).catch(() => {})
+ routeAlternatives.close()
+ return
+ }
+ const day = roadtripRoutes.days.find(d => d.dayId === open.dayId)
+ const stop = day?.stops[open.index]
+ if (!day || !stop) return
+ try {
+ await roadtripVias.add(open.dayId, day.stops.indexOf(stop), alt.divergence.lat, alt.divergence.lng)
+ routeAlternatives.close()
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : t('common.unknownError'))
+ }
+ }, [routeAlternatives, roadtripRoutes.days, roadtripVias, toast, t])
+
+ /**
+ * A click on the drawn route puts a via there, and the drive is redrawn through it.
+ *
+ * Which pair of stops it belongs between comes from projecting the click onto the
+ * day's routed geometry — the same measurement the corridor search uses, so "after the
+ * third stop" means the same thing everywhere. The day is the one whose line was hit,
+ * found by trying each day's geometry and keeping the closest.
+ */
+ const addRoadtripVia = useCallback(async (lat: number, lng: number) => {
+ if (!can('day_edit', trip)) return
+ let best: { dayId: number; afterIndex: number; offRouteKm: number } | null = null
+ for (const day of roadtripRoutes.days) {
+ if (day.geometry.length < 2) continue
+ const spine = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln }))
+ const hit = projectOntoRoute({ lat, lng }, spine)
+ if (!hit) continue
+ if (best && hit.offRouteKm >= best.offRouteKm) continue
+ // Which stop the via follows: the last one the car passes before reaching it.
+ const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0)
+ const afterIndex = Math.max(0, insertIndexForAlong(stopsAlong, hit.alongKm) - 1)
+ best = { dayId: day.dayId, afterIndex, offRouteKm: hit.offRouteKm }
+ }
+ // A click that landed on some other line is not a via anywhere.
+ if (!best || best.offRouteKm > 2) return
+ try {
+ await roadtripVias.add(best.dayId, best.afterIndex, lat, lng)
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : t('common.unknownError'))
+ }
+ }, [roadtripRoutes.days, roadtripVias, can, trip, toast, t])
+
+ /** Dragging a via redraws the route through its new position. */
+ const moveRoadtripVia = useCallback(async (dayId: number, id: number, lat: number, lng: number) => {
+ if (!can('day_edit', trip)) return
+ try {
+ await roadtripVias.move(dayId, id, lat, lng)
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : t('common.unknownError'))
+ }
+ }, [roadtripVias, can, trip, toast, t])
+
+ /** Removing a via lets the drive take the direct road again. */
+ const removeRoadtripVia = useCallback(async (dayId: number, id: number) => {
+ if (!can('day_edit', trip)) return
+ try {
+ await roadtripVias.remove(dayId, id)
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : t('common.unknownError'))
+ }
+ }, [roadtripVias, can, trip, toast, t])
+
+ /**
+ * A corridor hit dropped on the map, placed where it was dropped rather than where the
+ * corridor projected it.
+ *
+ * The two differ whenever a drive passes near the same spot twice — a loop, an
+ * out-and-back — and the automatic projection can only pick one of them. Dropping says
+ * which, and the drop coordinate is projected onto the same routed line the hits were
+ * measured along, so the answer is in the same units as everything else.
+ *
+ * A drop nowhere near the drive is ignored rather than guessed at: adding a stop
+ * fifty kilometres off the route because the pointer slipped is worse than nothing
+ * happening.
+ */
+ const dropPoiOnRoute = useCallback((osmId: string, lat: number, lng: number) => {
+ if (!can('place_edit', trip)) return
+ const hit = roadtripCorridor.visible.find(p => p.osm_id === osmId)
+ const day = roadtripCorridor.day
+ if (!hit || !day) return
+ const at = projectOntoRoute({ lat, lng }, roadtripCorridor.search.spine)
+ if (!at || at.offRouteKm > roadtripCorridor.widthKm) return
+ setStopDraft({
+ poi: hit,
+ dayId: day.dayId,
+ position: insertIndexForAlong(roadtripCorridor.stopsAlongKm, at.alongKm),
+ dayNumber: day.dayNumber,
+ })
+ }, [roadtripCorridor, can, trip])
+
+ /** Hands the draft over to the full form, keeping the day and the position it worked out. */
+ const stopDraftToForm = useCallback(() => {
+ if (!stopDraft) return
+ const { poi, dayId, position } = stopDraft
+ setStopDraft(null)
+ openAddPlaceFromPoi(poi, dayId, position)
+ }, [stopDraft, openAddPlaceFromPoi])
+
+ /**
+ * A place on this trip that came from the same OSM object.
+ *
+ * The full place form warns about duplicates; without the same check here the popup
+ * would be the quickest way to add one petrol station twice.
+ */
+ const stopDraftDuplicate = useMemo(() => {
+ if (!stopDraft?.poi.osm_id) return null
+ return places.find(p => p.osm_id === stopDraft.poi.osm_id)?.name ?? null
+ }, [stopDraft, places])
+
const handleSavePlace = useCallback(async (data) => {
const pendingFiles = data._pendingFiles
delete data._pendingFiles
@@ -641,7 +1119,9 @@ export function useTripPlanner() {
// screen entirely — which reads as "it wasn't saved" (#1998).
if (place?.id && placeFormDayId != null) {
try {
- await tripActions.assignPlaceToDay(tripId, placeFormDayId, place.id)
+ // With a position the stop lands where it will be driven past, not at the end
+ // of the day. The slice has taken one all along; nothing ever passed it.
+ await tripActions.assignPlaceToDay(tripId, placeFormDayId, place.id, placeFormPosition)
updateRouteForDay(placeFormDayId)
} catch (err: unknown) {
// The place itself exists; only the day link failed.
@@ -667,7 +1147,7 @@ export function useTripPlanner() {
// exist a moment ago (#1298), the same way the booking modals work.
return place?.id ? { id: place.id } : undefined
}
- }, [editingPlace, editingAssignmentId, placeFormDayId, tripId, toast, pushUndo, updateRouteForDay])
+ }, [editingPlace, editingAssignmentId, placeFormDayId, placeFormPosition, tripId, toast, pushUndo, updateRouteForDay])
// Open the place editor from any entry point (Places pool, inspector, map).
// Times live per day-assignment, so when no day is in context resolve the
@@ -1118,6 +1598,13 @@ export function useTripPlanner() {
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId,
+ stopDraft, setStopDraft, saveStopDraft, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
+ roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
+ routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
+ stayDraft, setStayDraft, setRoadtripStay,
+ highlightedAlternative, setHighlightedAlternative,
+ moveRoadtripStopToDay,
+ dropPoiOnRoute,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable,
@@ -1134,7 +1621,7 @@ export function useTripPlanner() {
isMobile, isTouch,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
- handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
+ handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handlePoiClick,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts
index 1dee91433..b3450c1d7 100644
--- a/client/src/store/settingsStore.ts
+++ b/client/src/store/settingsStore.ts
@@ -59,6 +59,8 @@ export function forgetServerLanguage(): void {
// can't drift apart again.
export const DEFAULT_SETTINGS: Settings = {
map_tile_url: '',
+ // Empty = the public routing hosts TREK ships with.
+ routing_base_url: '',
dark_mode: false,
// Empty = no personal display currency, so Costs falls back to the trip's own.
default_currency: '',
diff --git a/client/src/store/slices/assignmentsSlice.ts b/client/src/store/slices/assignmentsSlice.ts
index 8411a0724..45a2c786b 100644
--- a/client/src/store/slices/assignmentsSlice.ts
+++ b/client/src/store/slices/assignmentsSlice.ts
@@ -140,9 +140,13 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
const toItems = (state.assignments[String(toDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const insertAt = toOrderIndex !== null ? toOrderIndex : toItems.length
- const newToItems = [...toItems]
- newToItems.splice(insertAt, 0, { ...assignment, day_id: Number.parseInt(String(toDayId)) })
- newToItems.forEach((a, i) => { a.order_index = i })
+ const withMoved = [...toItems]
+ withMoved.splice(insertAt, 0, { ...assignment, day_id: Number.parseInt(String(toDayId)) })
+ // Renumber into copies. Assigning `order_index` in place would mutate the very objects
+ // still held in `prevAssignments`, so the rollback below would restore a list whose
+ // indices had already been overwritten — a failed move left the order wrong instead of
+ // putting it back.
+ const newToItems = withMoved.map((a, i) => ({ ...a, order_index: i }))
set(s => ({
assignments: {
diff --git a/client/src/types.ts b/client/src/types.ts
index 5e8bd14c1..c590994e3 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -108,6 +108,11 @@ export type DistanceUnit = 'metric' | 'imperial'
export interface Settings {
map_tile_url: string
+ /**
+ * Base URL of a self-hosted routing engine (#1797). Empty falls back to the public
+ * FOSSGIS hosts, which allow about one request a second.
+ */
+ routing_base_url?: string
dark_mode: boolean | string
/** Display currency for Costs. Empty/null = follow each trip's own currency. */
default_currency: string | null
diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts
index b1d158387..e80f76689 100644
--- a/server/src/bootstrap.ts
+++ b/server/src/bootstrap.ts
@@ -7,7 +7,8 @@ import type { INestApplication } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { AppModule } from './nest/app.module';
import { httpConfig } from './nest/app-config';
-import { applyGlobalMiddleware } from './middleware/globalMiddleware';
+import { applyGlobalMiddleware, routingCspOrigins } from './middleware/globalMiddleware';
+import { SettingsService } from './nest/settings/settings.service';
import { applyPlatformUploads, applyPlatformStatic } from './nest/platform/platform.routes';
import { apiDocsEnabled } from './nest/common/api-docs.kill-switch';
import { setupApiDocs } from './nest/platform/api-docs';
@@ -84,7 +85,14 @@ export async function buildApp(): Promise {
// the one bridge that lets the pre-init Express layer consume the validated
// config instead of reading process.env itself.
const http = app.get>(httpConfig.KEY);
- applyGlobalMiddleware(instance, { http });
+ // Same pre-init bridge: a self-hosted routing engine has to be named in connect-src, or
+ // the browser blocks every request to it without an error the app could report.
+ const settings = app.get(SettingsService, { strict: false });
+ const routingBase = settings?.getAdminUserDefaults()?.routing_base_url;
+ applyGlobalMiddleware(instance, {
+ http,
+ extraConnectSrc: routingCspOrigins([typeof routingBase === 'string' ? routingBase : null]),
+ });
// Same pre-init consumption bridge as httpConfig above: the StorageService
// instance is resolvable before init, and the handlers only *register* here —
// per-request resolution runs after app.init() completed the registry load.
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 2b326c26c..5eeca4fe2 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -4257,6 +4257,7 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE journey_entries ADD COLUMN stats_excluded INTEGER NOT NULL DEFAULT 0');
}
},
+ /**
/**
* Place shadow log: which search result a user actually picked.
*
@@ -4292,6 +4293,58 @@ function runMigrations(db: Database.Database): void {
// Retention deletes by age, the export pages by id.
db.exec('CREATE INDEX IF NOT EXISTS idx_place_shadow_created ON place_shadow_picks(created_at)');
},
+ /**
+ * What kind of stop a place is on a drive — fuel, charging, rest area, campsite.
+ *
+ * Deliberately NOT a `categories` row. Those are the traveller's own list, editable
+ * and instance-wide (`categories.service.ts` selects them without a user filter), so
+ * seeding four road-trip kinds there would push them into everyone's dropdown and hand
+ * their colour to whoever edits the list first. A refuelling stop is not a taste; it is
+ * a fact about the place, and the road-trip categories already own its icon and colour
+ * (`poiCategories.ts`).
+ *
+ * Free text rather than a CHECK constraint: the set grows with what the corridor
+ * search can look for, and SQLite cannot alter a constraint without rebuilding the
+ * table. NULL means an ordinary place, which is every row that exists today.
+ *
+ * Appended LAST: the array is index-addressed against schema_version.
+ */
+ () => {
+ const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>;
+ if (!cols.some(c => c.name === 'stop_type')) {
+ db.exec('ALTER TABLE places ADD COLUMN stop_type TEXT');
+ }
+ },
+ /**
+ * Points a day's drive is made to pass through, without being stops (#1797).
+ *
+ * The difference is the whole point: a stop is somewhere you go, and it takes a
+ * number in the chain, a place row, an arrival time and a line in the itinerary. A
+ * via is none of that — it only bends the route, which is what "take the coast road
+ * instead" means. Storing one as a place was the alternative, and it would have put a
+ * numbered stop in the middle of the day for a spot nobody stops at.
+ *
+ * Anchored to `after_order_index` rather than to an assignment id: a stop added
+ * mid-day is written with a temporary negative id and swapped for the real one moments
+ * later, so a foreign key to it would dangle. The index is what the routing request is
+ * built from anyway.
+ *
+ * Appended LAST: the array is index-addressed against schema_version.
+ */
+ () => {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS roadtrip_vias (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
+ after_order_index INTEGER NOT NULL,
+ sequence INTEGER NOT NULL DEFAULT 0,
+ lat REAL NOT NULL,
+ lng REAL NOT NULL,
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_vias_day ON roadtrip_vias(day_id, after_order_index, sequence)');
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts
index 8658015a9..06665600f 100644
--- a/server/src/middleware/globalMiddleware.ts
+++ b/server/src/middleware/globalMiddleware.ts
@@ -72,15 +72,47 @@ export function redact(value: unknown): unknown {
* bootstrap.ts. The flag that used to switch a second parser on had exactly one
* caller, which passed false.
*/
+/**
+ * Turns a configured routing base URL into the origin CSP needs.
+ *
+ * Only the origin: a CSP source with a path only matches that path prefix, and a router
+ * is asked at several of them (`/route/v1/...`, `/table/v1/...`). Anything that is not an
+ * absolute http(s) URL is dropped rather than passed through — a bad value in a settings
+ * row must not be able to widen the policy.
+ */
+export function routingCspOrigins(baseUrls: (string | null | undefined)[]): string[] {
+ const origins = new Set();
+ for (const raw of baseUrls) {
+ if (!raw) continue;
+ try {
+ const url = new URL(raw.trim());
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') continue;
+ origins.add(url.origin);
+ } catch {
+ // Not a URL; the client falls back to the public hosts, so the policy stays as it was.
+ }
+ }
+ return [...origins];
+}
+
export function applyGlobalMiddleware(
app: express.Application,
- opts: { http?: AppEnv['http'] } = {},
+ opts: {
+ http?: AppEnv['http'];
+ /**
+ * Extra origins the browser may talk to, from the instance's own settings — today the
+ * self-hosted routing engine (#1797). Read once at apply time like everything else
+ * here, so changing it takes a restart; the alternative is a database read on every
+ * request for a value that changes about once in a deployment's life.
+ */
+ extraConnectSrc?: string[];
+ } = {},
): void {
// The whole pipeline is configured at APPLY time (the per-request closures
// capture these values), so a snapshot is the correct semantic. bootstrap
// threads in the DI-loaded httpConfig; direct callers fall back to an
// apply-time readEnv() — same values, same freeze point.
- const { http = readEnv().http } = opts;
+ const { http = readEnv().http, extraConnectSrc = [] } = opts;
const { nodeEnv, isProduction } = readEnv().app;
// Trust first proxy (nginx/Docker) for correct req.ip
@@ -207,7 +239,11 @@ export function applyGlobalMiddleware(
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com",
- "https://tiles.openfreemap.org"
+ "https://tiles.openfreemap.org",
+ // A self-hosted routing engine, when the instance has one configured. Without
+ // this the browser blocks it silently: no error the app can catch, just legs
+ // that never route (#1797).
+ ...extraConnectSrc,
],
workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"],
diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts
index 3ba6170b7..8faacb667 100644
--- a/server/src/nest/app.module.ts
+++ b/server/src/nest/app.module.ts
@@ -11,6 +11,7 @@ import { GlobalAuthGuard } from './auth/global-auth.guard';
import { MfaPolicyGuard } from './auth/mfa-policy.guard';
import { ManagedGuard } from './common/managed.guard';
import { WeatherModule } from './weather/weather.module';
+import { RoadtripModule } from './roadtrip/roadtrip.module';
import { PublicApiModule } from './public-api/public-api.module';
import { HelpModule } from './help/help.module';
import { AirportsModule } from './airports/airports.module';
@@ -77,7 +78,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
- imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
+ imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
diff --git a/server/src/nest/common/rowShape.ts b/server/src/nest/common/rowShape.ts
index 4e69b5379..e48d76733 100644
--- a/server/src/nest/common/rowShape.ts
+++ b/server/src/nest/common/rowShape.ts
@@ -43,6 +43,9 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[]
osm_id: a.osm_id,
website: a.website,
phone: a.phone,
+ // The rail marks a fuel stop as one; without it here every stop would need
+ // its own place request to find out what kind it is.
+ stop_type: a.stop_type ?? null,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts
index 2fcbcc1c1..5b4feb38b 100644
--- a/server/src/nest/places/places.mcp.ts
+++ b/server/src/nest/places/places.mcp.ts
@@ -10,6 +10,8 @@ import {
placeImageUrlSchema,
placeImportListRequestSchema,
placeWebsiteSchema,
+ roadtripStopTypeSchema,
+ type RoadtripStopType,
} from '@trek/shared';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
@@ -74,22 +76,24 @@ export class PlacesMcp {
image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'),
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
+ stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area or campsite. Leave unset for an ordinary place.'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'places', mode: 'write' },
})
async createPlace(
- { tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency }: {
+ { tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency, stop_type }: {
tripId: number; name: string; description?: string; lat?: number; lng?: number; address?: string;
category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string;
notes?: string; website?: string; phone?: string; image_url?: string; price?: number; currency?: string;
+ stop_type?: RoadtripStopType;
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
- const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency });
+ const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency, stop_type });
this.guards.safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
}
diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts
index ba66db01d..86a611dea 100644
--- a/server/src/nest/places/places.service.ts
+++ b/server/src/nest/places/places.service.ts
@@ -67,6 +67,8 @@ export interface PlaceCreateInput {
place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
+ /** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
+ stop_type?: string | null;
transport_mode?: string; route_geometry?: string; route_color?: string; tags?: number[];
}
@@ -77,6 +79,8 @@ export interface PlaceUpdateInput {
place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
+ /** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
+ stop_type?: string | null;
transport_mode?: string; route_color?: string | null; tags?: number[];
}
@@ -224,24 +228,29 @@ export class PlacesService {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
- transport_mode, route_geometry, route_color, tags = [],
+ transport_mode, route_geometry, route_color, stop_type, tags = [],
} = body;
const result = this.dbs.run(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode,
- route_geometry, route_color)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ route_geometry, route_color, stop_type)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
// lat/lng/price/duration_minutes use an explicit undefined check, not `||`:
// 0 is a legitimate value for all four (Null Island, a free entry, a
// drive-by stop) and the falsy coercion silently threw it away.
+ //
+ // `duration_minutes` stays NULL when nobody says otherwise, rather than being
+ // invented as an hour. It is read in exactly one place — the road trip rail's
+ // "Stay" — and a default meant every place ever created claimed a one-hour stop
+ // the traveller had never entered, with no way to tell the two apart.
tripId, name, description || null, lat ?? null, lng ?? null, address || null,
category_id || null, price ?? null, currency || null,
- place_time || null, end_time || null, duration_minutes ?? 60, notes || null, image_url || null,
+ place_time || null, end_time || null, duration_minutes ?? null, notes || null, image_url || null,
google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking',
- route_geometry || null, route_color || null,
+ route_geometry || null, route_color || null, stop_type || null,
);
const placeId = result.lastInsertRowid;
@@ -306,7 +315,7 @@ export class PlacesService {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
- transport_mode, route_color, tags,
+ transport_mode, route_color, stop_type, tags,
} = body;
this.dbs.run(`
@@ -331,6 +340,7 @@ export class PlacesService {
phone = ?,
transport_mode = COALESCE(?, transport_mode),
route_color = ?,
+ stop_type = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
@@ -358,6 +368,8 @@ export class PlacesService {
// Deliberately not COALESCE: an explicit null is how the picker resets a
// track back to its category colour (#776).
route_color !== undefined ? route_color : existingPlace.route_color,
+ // Same shape: an explicit null is how a fuel stop becomes an ordinary place again.
+ stop_type !== undefined ? stop_type : existingPlace.stop_type,
placeId,
);
diff --git a/server/src/nest/roadtrip/roadtrip.controller.ts b/server/src/nest/roadtrip/roadtrip.controller.ts
new file mode 100644
index 000000000..a706ff10c
--- /dev/null
+++ b/server/src/nest/roadtrip/roadtrip.controller.ts
@@ -0,0 +1,109 @@
+import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
+import type { RoadtripVia } from '@trek/shared';
+import { RoadtripService } from './roadtrip.service';
+import { RoadtripViaCreateDto, RoadtripViaReanchorDto, RoadtripViaUpdateDto } from './roadtrip.dto';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
+import { RequireAddon } from '../addons/require-addon.decorator';
+import { ADDON_IDS } from '../../addons';
+
+/**
+ * /api/trips/:tripId/roadtrip — the points a drive is routed through (#1797).
+ *
+ * Gated on the road trip addon: an instance with it switched off gets 404s here, the same
+ * as every other addon surface, and nothing about the plain day plan changes.
+ *
+ * No WebSocket broadcast, unlike the assignment routes. A via is a detail of how one
+ * person's route is drawn rather than a change to the itinerary everybody reads, and
+ * adding an event family for it would mean extending the typed trip-event catalogue for
+ * something a collaborator sees on their next load anyway. Worth revisiting if vias ever
+ * become shared editing rather than route shaping.
+ */
+@Controller('api/trips/:tripId/roadtrip')
+@UseGuards(JwtAuthGuard, TripAccessGuard)
+@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
+export class RoadtripController {
+ constructor(private readonly roadtrip: RoadtripService) {}
+
+ /** Every via of the trip, so the client can route all days without a request per day. */
+ @Get('vias')
+ listAll(@Param('tripId') tripId: string): { vias: RoadtripVia[] } {
+ return { vias: this.roadtrip.listForTrip(tripId) };
+ }
+
+ @Get('days/:dayId/vias')
+ list(@Param('tripId') tripId: string, @Param('dayId') dayId: string): { vias: RoadtripVia[] } {
+ this.requireDay(dayId, tripId);
+ return { vias: this.roadtrip.listForDay(dayId) };
+ }
+
+ @RequirePermission('day_edit')
+ @Post('days/:dayId/vias')
+ create(
+ @Param('tripId') tripId: string,
+ @Param('dayId') dayId: string,
+ @Body() body: RoadtripViaCreateDto,
+ ): { via: RoadtripVia } {
+ this.requireDay(dayId, tripId);
+ return { via: this.roadtrip.create(dayId, body) };
+ }
+
+ /**
+ * Re-pin this day's vias after its stops changed shape.
+ *
+ * Declared before `vias/:id` so the static path is matched first, and taken as one
+ * batch rather than a PUT per via: the anchors are only correct as a set, and a
+ * half-applied shift leaves two vias on one leg and a third past the end of the day.
+ */
+ @RequirePermission('day_edit')
+ @Put('days/:dayId/vias')
+ @HttpCode(200)
+ reanchor(
+ @Param('tripId') tripId: string,
+ @Param('dayId') dayId: string,
+ @Body() body: RoadtripViaReanchorDto,
+ ): { vias: RoadtripVia[] } {
+ this.requireDay(dayId, tripId);
+ return { vias: this.roadtrip.reanchor(dayId, body) };
+ }
+
+ @RequirePermission('day_edit')
+ @Put('days/:dayId/vias/:id')
+ @HttpCode(200)
+ update(
+ @Param('tripId') tripId: string,
+ @Param('dayId') dayId: string,
+ @Param('id') id: string,
+ @Body() body: RoadtripViaUpdateDto,
+ ): { via: RoadtripVia } {
+ this.requireDay(dayId, tripId);
+ const via = this.roadtrip.move(id, dayId, body.lat, body.lng);
+ if (!via) throw new HttpException({ error: 'Via not found' }, 404);
+ return { via };
+ }
+
+ @RequirePermission('day_edit')
+ @Delete('days/:dayId/vias/:id')
+ remove(
+ @Param('tripId') tripId: string,
+ @Param('dayId') dayId: string,
+ @Param('id') id: string,
+ ): { success: true } {
+ this.requireDay(dayId, tripId);
+ if (!this.roadtrip.remove(id, dayId)) {
+ throw new HttpException({ error: 'Via not found' }, 404);
+ }
+ return { success: true };
+ }
+
+ /**
+ * The guard proves the caller may reach the trip; this proves the day is part of it.
+ * Without it a valid day id from someone else's trip would be editable through a trip
+ * the caller does have access to.
+ */
+ private requireDay(dayId: string, tripId: string): void {
+ if (!this.roadtrip.dayExists(dayId, tripId)) {
+ throw new HttpException({ error: 'Day not found' }, 404);
+ }
+ }
+}
diff --git a/server/src/nest/roadtrip/roadtrip.dto.ts b/server/src/nest/roadtrip/roadtrip.dto.ts
new file mode 100644
index 000000000..1efb71f91
--- /dev/null
+++ b/server/src/nest/roadtrip/roadtrip.dto.ts
@@ -0,0 +1,11 @@
+import { createZodDto } from 'nestjs-zod';
+import { roadtripViaCreateRequestSchema, roadtripViaReanchorRequestSchema, roadtripViaUpdateRequestSchema } from '@trek/shared';
+
+/**
+ * createZodDto wrappers over the @trek/shared road-trip contracts. The global
+ * ZodValidationPipe validates any @Body() typed with one of these by metatype, so the
+ * schemas in shared/ stay the single source of truth for the wire contract.
+ */
+export class RoadtripViaCreateDto extends createZodDto(roadtripViaCreateRequestSchema) {}
+export class RoadtripViaUpdateDto extends createZodDto(roadtripViaUpdateRequestSchema) {}
+export class RoadtripViaReanchorDto extends createZodDto(roadtripViaReanchorRequestSchema) {}
diff --git a/server/src/nest/roadtrip/roadtrip.mcp.ts b/server/src/nest/roadtrip/roadtrip.mcp.ts
new file mode 100644
index 000000000..be385b6fd
--- /dev/null
+++ b/server/src/nest/roadtrip/roadtrip.mcp.ts
@@ -0,0 +1,115 @@
+import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_NON_IDEMPOTENT, ok, type McpContext } from '../../nest-mcp';
+import { z } from 'zod';
+import { RoadtripService } from './roadtrip.service';
+import { DatabaseService } from '../database/database.service';
+import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
+import { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared';
+import { AuthService } from '../auth/auth.service';
+
+/**
+ * Road-trip via points over MCP, the same surface the REST routes expose (#1797).
+ *
+ * Parity is the point: the permission checked here is `day_edit`, the same action string
+ * the controller demands, so an assistant can never do through a tool what a person
+ * cannot do through the UI. Reads go through the same day-belongs-to-trip check, so a
+ * valid day id from another trip is not reachable by way of one the caller can see.
+ */
+@McpController()
+export class RoadtripMcp {
+ constructor(
+ private readonly roadtrip: RoadtripService,
+ private readonly db: DatabaseService,
+ private readonly guards: McpToolGuardsService,
+ private readonly auth: AuthService,
+ ) {}
+
+ @Tool({
+ name: 'list_route_vias',
+ description: 'List the points a day\'s drive is routed through without stopping at them. These bend the route (a scenic road, a pass, avoiding a motorway) and are not stops on the itinerary.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive().optional().describe('Omit to list the vias of every day of the trip'),
+ },
+ annotations: TOOL_ANNOTATIONS_READONLY,
+ access: { group: 'trips', mode: 'read' },
+ })
+ async listVias({ tripId, dayId }: { tripId: number; dayId?: number }, ctx: McpContext) {
+ if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
+ if (dayId != null) {
+ if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
+ return ok({ vias: this.roadtrip.listForDay(dayId) });
+ }
+ return ok({ vias: this.roadtrip.listForTrip(tripId) });
+ }
+
+ @Tool({
+ name: 'add_route_via',
+ description: 'Make a day\'s drive pass through a point without stopping there — use it to send the route over a particular road or away from one. For somewhere the traveller actually stops, add a place and assign it to the day instead.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive(),
+ after_order_index: z.number().int().min(0).describe('Which stop of the day the via follows, counting from 0'),
+ lat: z.number().min(-90).max(90),
+ lng: z.number().min(-180).max(180),
+ },
+ annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
+ access: { group: 'trips', mode: 'write' },
+ })
+ async addVia(
+ { tripId, dayId, after_order_index, lat, lng }: { tripId: number; dayId: number; after_order_index: number; lat: number; lng: number },
+ ctx: McpContext,
+ ) {
+ if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
+ if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
+ if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
+ if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
+ return ok({ via: this.roadtrip.create(dayId, { after_order_index, lat, lng }) });
+ }
+
+ @Tool({
+ name: 'reanchor_route_vias',
+ description: 'Re-pin a day\'s via points after its stops changed. A via records which stop it follows by position, so adding, removing or reordering a stop leaves every later via pointing at the wrong leg and the drive silently reverts to the road it was steered away from. Send the corrected positions for the whole day at once; ids left out keep the position they have.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive(),
+ vias: z.array(z.object({
+ id: z.number().int().positive(),
+ after_order_index: z.number().int().min(0).describe('Which stop of the day the via now follows, counting from 0'),
+ })).max(500),
+ remove: z.array(z.number().int().positive()).max(500).optional()
+ .describe('Vias whose leg no longer exists at all — deleting the last stop of a day leaves the leg into it with nothing to sit on'),
+ },
+ annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
+ access: { group: 'trips', mode: 'write' },
+ })
+ async reanchorVias(
+ { tripId, dayId, vias, remove }: { tripId: number; dayId: number; vias: { id: number; after_order_index: number }[]; remove?: number[] },
+ ctx: McpContext,
+ ) {
+ if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
+ if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
+ if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
+ if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
+ return ok({ vias: this.roadtrip.reanchor(dayId, { vias, remove }) });
+ }
+
+ @Tool({
+ name: 'remove_route_via',
+ description: 'Remove a via point, letting the drive take the direct route again.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive(),
+ viaId: z.number().int().positive(),
+ },
+ annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
+ access: { group: 'trips', mode: 'write' },
+ })
+ async removeVia({ tripId, dayId, viaId }: { tripId: number; dayId: number; viaId: number }, ctx: McpContext) {
+ if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
+ if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
+ if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
+ if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
+ if (!this.roadtrip.remove(viaId, dayId)) return noAccess();
+ return ok({ success: true });
+ }
+}
diff --git a/server/src/nest/roadtrip/roadtrip.module.ts b/server/src/nest/roadtrip/roadtrip.module.ts
new file mode 100644
index 000000000..8a1a42fd5
--- /dev/null
+++ b/server/src/nest/roadtrip/roadtrip.module.ts
@@ -0,0 +1,17 @@
+import { Module } from '@nestjs/common';
+import { RoadtripController } from './roadtrip.controller';
+import { RoadtripService } from './roadtrip.service';
+import { RoadtripMcp } from './roadtrip.mcp';
+import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
+import { PermissionsModule } from '../permissions/permissions.module';
+import { AuthModule } from '../auth/auth.module';
+
+/** 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.
+ imports: [McpSharedModule, PermissionsModule, AuthModule],
+ controllers: [RoadtripController],
+ providers: [RoadtripService, RoadtripMcp],
+ exports: [RoadtripService],
+})
+export class RoadtripModule {}
diff --git a/server/src/nest/roadtrip/roadtrip.service.ts b/server/src/nest/roadtrip/roadtrip.service.ts
new file mode 100644
index 000000000..00ce16d8e
--- /dev/null
+++ b/server/src/nest/roadtrip/roadtrip.service.ts
@@ -0,0 +1,123 @@
+import { Injectable } from '@nestjs/common';
+import type { RoadtripVia } from '@trek/shared';
+import { DatabaseService } from '../database/database.service';
+
+/**
+ * Via points: the places a day's drive is made to pass through without stopping.
+ *
+ * Kept apart from `day_assignments` on purpose. A stop is somewhere you go — it takes a
+ * number in the chain, an arrival time and a line in the itinerary. A via only bends the
+ * route. Modelling one as a place would put a numbered stop in the middle of the day for
+ * a spot nobody stops at, and it would show up in the PDF, the map pins and the schedule.
+ */
+@Injectable()
+export class RoadtripService {
+ constructor(private readonly db: DatabaseService) {}
+
+ /** The day exists and belongs to this trip. 404 material, checked before every write. */
+ dayExists(dayId: string | number, tripId: string | number): boolean {
+ return !!this.db.get<{ id: number }>(
+ 'SELECT id FROM days WHERE id = ? AND trip_id = ?',
+ dayId,
+ tripId,
+ );
+ }
+
+ listForDay(dayId: string | number): RoadtripVia[] {
+ return this.db.all(
+ `SELECT id, day_id, after_order_index, sequence, lat, lng, created_at
+ FROM roadtrip_vias
+ WHERE day_id = ?
+ ORDER BY after_order_index, sequence, id`,
+ dayId,
+ );
+ }
+
+ /** Every via of a trip, so the client can route all days without one request per day. */
+ listForTrip(tripId: string | number): RoadtripVia[] {
+ return this.db.all(
+ `SELECT v.id, v.day_id, v.after_order_index, v.sequence, v.lat, v.lng, v.created_at
+ FROM roadtrip_vias v
+ JOIN days d ON d.id = v.day_id
+ WHERE d.trip_id = ?
+ ORDER BY v.day_id, v.after_order_index, v.sequence, v.id`,
+ tripId,
+ );
+ }
+
+ create(dayId: string | number, input: { after_order_index: number; lat: number; lng: number; sequence?: number }): RoadtripVia {
+ // Appended after whatever already follows that stop, unless the caller says where.
+ const sequence = input.sequence ?? (
+ this.db.get<{ next: number }>(
+ 'SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?',
+ dayId,
+ input.after_order_index,
+ )?.next ?? 0
+ );
+ const result = this.db.run(
+ 'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, ?, ?)',
+ dayId,
+ input.after_order_index,
+ sequence,
+ input.lat,
+ input.lng,
+ );
+ return this.byId(Number(result.lastInsertRowid))!;
+ }
+
+ /** Moving a via is the whole edit; where it sits in the chain does not change. */
+ move(id: string | number, dayId: string | number, lat: number, lng: number): RoadtripVia | null {
+ const existing = this.db.get<{ id: number }>(
+ 'SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?',
+ id,
+ dayId,
+ );
+ if (!existing) return null;
+ this.db.run('UPDATE roadtrip_vias SET lat = ?, lng = ? WHERE id = ?', lat, lng, id);
+ return this.byId(Number(id));
+ }
+
+ /**
+ * Re-pin a day's vias in one go, after its stops changed shape.
+ *
+ * One transaction, because the numbers only mean anything as a set: applying half of a
+ * shift leaves two vias claiming the same leg and a third pinned past the end of the
+ * day. `AND day_id = ?` on every statement is what stops an id from another day — or
+ * another trip — being renumbered through a day the caller does happen to reach.
+ *
+ * Silent about ids it does not find. The caller computed this list from a snapshot of
+ * the day, and a via somebody else deleted in the meantime is not a failure of the
+ * re-anchoring; failing the batch over it would leave the rest of the day mis-pinned.
+ */
+ reanchor(
+ dayId: string | number,
+ input: { vias: { id: number; after_order_index: number }[]; remove?: number[] },
+ ): RoadtripVia[] {
+ return this.db.transaction(() => {
+ for (const id of input.remove ?? []) {
+ this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
+ }
+ for (const via of input.vias) {
+ this.db.run(
+ 'UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?',
+ via.after_order_index,
+ via.id,
+ dayId,
+ );
+ }
+ return this.listForDay(dayId);
+ });
+ }
+
+ remove(id: string | number, dayId: string | number): boolean {
+ const result = this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
+ return result.changes > 0;
+ }
+
+ private byId(id: number): RoadtripVia | null {
+ return this.db.get(
+ 'SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE id = ?',
+ id,
+ ) ?? null;
+ }
+}
diff --git a/server/src/nest/settings/settings.service.ts b/server/src/nest/settings/settings.service.ts
index 20ecd2cab..fc9cf331d 100644
--- a/server/src/nest/settings/settings.service.ts
+++ b/server/src/nest/settings/settings.service.ts
@@ -30,6 +30,18 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
'default_currency',
'blur_booking_codes',
'map_tile_url',
+ /**
+ * Base URL of the routing engine, when the instance runs its own (#1797).
+ *
+ * Empty means the public OSRM hosts TREK ships with, which allow roughly one request a
+ * second — enough for a day plan, not for a road trip that routes every leg of every
+ * day, and they answer nothing beyond plain routing. Defaultable rather than per-user
+ * because a routing engine is a property of the deployment, not a taste.
+ *
+ * Changing it needs a server restart: the browser's connect-src allowlist is assembled
+ * once at boot, and a router the policy does not name fails silently.
+ */
+ 'routing_base_url',
// CARTO stamps an "API KEY REQUIRED" watermark into keyless tiles (#2054), and
// the key is per-instance rather than per-person: defaultable so one admin
// value clears the watermark for everybody at once.
diff --git a/server/src/types.ts b/server/src/types.ts
index aed7164d6..18e457a7b 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -78,6 +78,8 @@ export interface Place {
website?: string | null;
phone?: string | null;
transport_mode?: string;
+ /** What kind of stop this is on a drive (#1797); null for an ordinary place. */
+ stop_type?: string | null;
created_at?: string;
updated_at?: string;
}
@@ -335,6 +337,7 @@ export interface AssignmentRow extends DayAssignment {
osm_id: string | null;
website: string | null;
phone: string | null;
+ stop_type: string | null;
category_name: string | null;
category_color: string | null;
category_icon: string | null;
diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts
index 25e043bcd..8b768d0bf 100644
--- a/server/tests/unit/middleware/globalMiddleware.csp.test.ts
+++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
-import { applyGlobalMiddleware } from '../../../src/middleware/globalMiddleware';
+import { applyGlobalMiddleware, routingCspOrigins } from '../../../src/middleware/globalMiddleware';
async function directiveSources(name: string): Promise {
const app = express();
@@ -122,3 +122,45 @@ describe('forced-HTTPS redirect', () => {
expect(probe.status).toBe(200);
});
});
+
+describe('routingCspOrigins', () => {
+ it('CSP-ROUTING-001: keeps only the origin, because a path source matches that path alone', () => {
+ // A router is asked at several paths (/route/v1/…, /table/v1/…); pinning one would
+ // block the rest without an error the app could report.
+ expect(routingCspOrigins(['https://osrm.example.org/route/v1/driving']))
+ .toEqual(['https://osrm.example.org']);
+ });
+
+ it('CSP-ROUTING-002: a port belongs to the origin and is kept', () => {
+ expect(routingCspOrigins(['http://192.168.178.72:5000'])).toEqual(['http://192.168.178.72:5000']);
+ });
+
+ it('CSP-ROUTING-003: anything that is not an http(s) URL widens nothing', () => {
+ // A bad settings row must not be able to loosen the policy.
+ expect(routingCspOrigins(['', null, undefined, 'not a url', 'javascript:alert(1)', 'ftp://x/y']))
+ .toEqual([]);
+ });
+
+ it('CSP-ROUTING-004: the same host twice is one source', () => {
+ expect(routingCspOrigins([
+ 'https://osrm.example.org/route/v1/driving',
+ 'https://osrm.example.org/table/v1/driving',
+ ])).toEqual(['https://osrm.example.org']);
+ });
+});
+
+describe('connect-src with a self-hosted router', () => {
+ it('CSP-ROUTING-005: a configured origin is named, or the browser blocks it silently', async () => {
+ const app = express();
+ applyGlobalMiddleware(app, { extraConnectSrc: ['https://osrm.example.org'] });
+ app.get('/probe', (_req, res) => res.json({ ok: true }));
+
+ const res = await request(app).get('/probe');
+ const csp = String(res.headers['content-security-policy'] || '');
+ const connect = csp.split(';').map(d => d.trim()).find(d => d.startsWith('connect-src'))!;
+
+ expect(connect).toContain('https://osrm.example.org');
+ // The public hosts stay, so an instance can be switched back without another deploy.
+ expect(connect).toContain('https://routing.openstreetmap.de/');
+ });
+});
diff --git a/server/tests/unit/nest/roadtrip.service.test.ts b/server/tests/unit/nest/roadtrip.service.test.ts
new file mode 100644
index 000000000..85ca4f82c
--- /dev/null
+++ b/server/tests/unit/nest/roadtrip.service.test.ts
@@ -0,0 +1,145 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import Database from 'better-sqlite3';
+import { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service';
+
+/**
+ * Via points, against a real SQLite. The table is tiny and the interesting parts are the
+ * ordering and the day scoping, both of which are SQL — mocking the DB would test the mock.
+ */
+function makeService() {
+ const raw = new Database(':memory:');
+ raw.exec(`
+ CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER NOT NULL);
+ CREATE TABLE roadtrip_vias (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
+ after_order_index INTEGER NOT NULL,
+ sequence INTEGER NOT NULL DEFAULT 0,
+ lat REAL NOT NULL,
+ lng REAL NOT NULL,
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
+ );
+ INSERT INTO days (id, trip_id) VALUES (1, 7), (2, 7), (3, 99);
+ `);
+ const db = {
+ get: (sql: string, ...params: unknown[]) => raw.prepare(sql).get(...params as never[]) as T | undefined,
+ all: (sql: string, ...params: unknown[]) => raw.prepare(sql).all(...params as never[]) as T[],
+ run: (sql: string, ...params: unknown[]) => raw.prepare(sql).run(...params as never[]),
+ transaction: (fn: (conn: unknown) => T) => raw.transaction(() => fn(raw))(),
+ };
+ return new RoadtripService(db as never);
+}
+
+describe('RoadtripService', () => {
+ let service: RoadtripService;
+ beforeEach(() => { service = makeService(); });
+
+ it('ROADTRIP-SVC-001: a day of another trip is not this trip’s day', () => {
+ // The trip guard proves access to the trip; this is what stops a valid day id from
+ // someone else’s trip being edited through one the caller can reach.
+ expect(service.dayExists(1, 7)).toBe(true);
+ expect(service.dayExists(3, 7)).toBe(false);
+ });
+
+ it('ROADTRIP-SVC-002: vias appended after the same stop keep the order they were added in', () => {
+ const first = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
+ const second = service.create(1, { after_order_index: 0, lat: 53.5, lng: 10.5 });
+
+ expect(first.sequence).toBe(0);
+ expect(second.sequence).toBe(1);
+ expect(service.listForDay(1).map(v => v.id)).toEqual([first.id, second.id]);
+ });
+
+ it('ROADTRIP-SVC-003: the list is ordered by stop first, then by sequence', () => {
+ const late = service.create(1, { after_order_index: 2, lat: 51, lng: 12 });
+ const early = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
+
+ expect(service.listForDay(1).map(v => v.id)).toEqual([early.id, late.id]);
+ });
+
+ it('ROADTRIP-SVC-004: the trip listing spans its days and stops at the trip boundary', () => {
+ service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
+ service.create(2, { after_order_index: 0, lat: 52, lng: 11 });
+ service.create(3, { after_order_index: 0, lat: 50, lng: 14 });
+
+ // Day 3 belongs to trip 99 and must not appear in trip 7.
+ expect(service.listForTrip(7).map(v => v.day_id)).toEqual([1, 2]);
+ });
+
+ it('ROADTRIP-SVC-005: moving a via changes where it is, not where it sits in the chain', () => {
+ const via = service.create(1, { after_order_index: 1, lat: 53, lng: 10 });
+
+ const moved = service.move(via.id, 1, 52.5, 11.5);
+
+ expect(moved).toMatchObject({ lat: 52.5, lng: 11.5, after_order_index: 1, sequence: via.sequence });
+ });
+
+ it('ROADTRIP-SVC-006: a via cannot be moved or removed through the wrong day', () => {
+ const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
+
+ expect(service.move(via.id, 2, 0, 0)).toBeNull();
+ expect(service.remove(via.id, 2)).toBe(false);
+ // Still where it was.
+ expect(service.listForDay(1)).toHaveLength(1);
+ });
+
+ it('ROADTRIP-SVC-008: re-anchoring moves the vias it names and leaves the rest alone', () => {
+ const a = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
+ const b = service.create(1, { after_order_index: 1, lat: 54, lng: 11 });
+ const c = service.create(1, { after_order_index: 2, lat: 55, lng: 12 });
+
+ const after = service.reanchor(1, { vias: [{ id: b.id, after_order_index: 2 }, { id: c.id, after_order_index: 3 }] });
+
+ const byId = new Map(after.map(v => [v.id, v.after_order_index]));
+ expect(byId.get(a.id)).toBe(0);
+ expect(byId.get(b.id)).toBe(2);
+ expect(byId.get(c.id)).toBe(3);
+ // Where a via sits is untouched — only which leg it belongs to changed.
+ expect(after.find(v => v.id === b.id)?.lat).toBe(54);
+ });
+
+ it('ROADTRIP-SVC-009: re-anchoring deletes the vias whose leg stopped existing', () => {
+ const gone = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
+ const kept = service.create(1, { after_order_index: 2, lat: 54, lng: 11 });
+
+ const after = service.reanchor(1, {
+ vias: [{ id: kept.id, after_order_index: 1 }],
+ remove: [gone.id],
+ });
+
+ expect(after.map(v => v.id)).toEqual([kept.id]);
+ expect(after[0].after_order_index).toBe(1);
+ });
+
+ it('ROADTRIP-SVC-010: a via of another day cannot be renumbered through this one', () => {
+ // The batch is trusted to name ids, so `AND day_id = ?` is the only thing standing
+ // between a day the caller can reach and every via of the trip.
+ const other = service.create(2, { after_order_index: 0, lat: 53, lng: 10 });
+
+ service.reanchor(1, { vias: [{ id: other.id, after_order_index: 9 }], remove: [other.id] });
+
+ const untouched = service.listForDay(2);
+ expect(untouched.map(v => v.id)).toEqual([other.id]);
+ expect(untouched[0].after_order_index).toBe(0);
+ });
+
+ it('ROADTRIP-SVC-011: an id that is gone does not fail the rest of the batch', () => {
+ // The caller computed the batch from a snapshot; a via somebody else deleted in the
+ // meantime must not leave the remaining ones mis-pinned.
+ const kept = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
+
+ const after = service.reanchor(1, {
+ vias: [{ id: 9999, after_order_index: 4 }, { id: kept.id, after_order_index: 1 }],
+ });
+
+ expect(after.map(v => v.after_order_index)).toEqual([1]);
+ });
+
+ it('ROADTRIP-SVC-007: removing one reports whether there was anything to remove', () => {
+ const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
+
+ expect(service.remove(via.id, 1)).toBe(true);
+ expect(service.remove(via.id, 1)).toBe(false);
+ expect(service.listForDay(1)).toEqual([]);
+ });
+});
diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts
index f0c1dd76e..fa632ff2f 100644
--- a/shared/src/i18n/ar/roadtrip.ts
+++ b/shared/src/i18n/ar/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'طعام',
'roadtrip.poi.sights': 'معالم',
'roadtrip.poi.within': 'ضمن',
+ 'roadtrip.poi.filter': 'تصفية حسب الاسم',
+ 'roadtrip.poi.noMatch': 'لا يوجد على الطريق ما يطابق «{name}».',
+ 'roadtrip.poi.foundFiltered': '{count} من {total} على الطريق',
+ 'roadtrip.poi.truncated': 'في {count} من المقاطع كان هناك أكثر مما تتسع له استجابة واحدة — قلّص الممر لرؤية البقية.',
'roadtrip.poi.search': 'ابحث على طول المسار',
'roadtrip.poi.searching': 'جارٍ البحث {done} من {total}',
'roadtrip.poi.capped': 'المسار طويل — تم البحث في الجزء الأول فقط.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'بعد {distance}',
'roadtrip.summary.distance': 'المسافة',
'roadtrip.summary.driving': 'مدة القيادة',
+ 'roadtrip.alt.ask': 'مسارات أخرى',
+ 'roadtrip.alt.title': 'طرق قيادة هذه المرحلة',
+ 'roadtrip.alt.loading': 'يجري سؤال خادم المسارات…',
+ 'roadtrip.alt.onlyOne': 'لهذه المرحلة طريق معقول واحد فقط.',
+ 'roadtrip.alt.failed': 'خادم المسارات لا يستجيب حاليًا.',
+ 'roadtrip.alt.current': 'الحالي',
+ 'roadtrip.alt.fastest': 'الأسرع',
+ 'roadtrip.alt.slower': 'أبطأ بـ {time}',
+ 'roadtrip.alt.quicker': 'أسرع بـ {time}',
'roadtrip.day': 'اليوم {number}',
+ 'roadtrip.quietDay.empty': 'لا توجد محطات بعد — أفلِت واحدة هنا',
+ 'roadtrip.quietDay.one': 'حتى الآن {name} فقط — أفلِت محطة هنا',
+ 'roadtrip.leg.driveText': '{distance} خلال {time}',
+ 'roadtrip.day.stopCount': '{count} محطات',
+ 'roadtrip.stop.stayShort': 'مدة',
'roadtrip.leg.pending': 'لا يوجد مسار',
+ 'roadtrip.stop.addTitle': 'إضافة كمحطة توقف',
+ 'roadtrip.stop.kind': 'نوع المحطة',
+ 'roadtrip.stop.landsOn': 'اليوم {day}، المحطة {position}',
+ 'roadtrip.stop.duplicate': '«{name}» موجود بالفعل في هذه الرحلة.',
+ 'roadtrip.stop.moreDetails': 'مزيد من التفاصيل',
'roadtrip.stop.stay': 'مدة التوقف هنا',
'roadtrip.stop.pinned': 'الوقت الذي حددته',
'roadtrip.stop.computed': 'محسوب من زمن القيادة',
+ 'roadtrip.via.hint': 'اسحب لإعادة تشكيل المسار، وانقر بالزر الأيمن للإزالة',
'roadtrip.warn.late': 'الوصول بعد {minutes} دقيقة من الوقت المحدد',
+ 'roadtrip.warn.overnight': 'اليوم التالي',
'roadtrip.summary.stops': 'محطات التوقف',
+ 'roadtrip.stay.add': 'تحديد مدة التوقف',
+ 'roadtrip.stay.none': 'بدون توقف',
+ 'roadtrip.stay.custom': 'دقائق',
+ 'roadtrip.stay.arrive': 'الوصول',
+ 'roadtrip.stay.leave': 'المغادرة',
+ 'roadtrip.alt.noMotorway': 'بدون طريق سريع',
+ 'roadtrip.alt.noToll': 'بدون رسوم',
+ 'roadtrip.summary.partial': 'لا يزال يجري حساب بقية الطريق',
};
export default roadtrip;
diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts
index efc891991..37356667c 100644
--- a/shared/src/i18n/ar/settings.ts
+++ b/shared/src/i18n/ar/settings.ts
@@ -23,6 +23,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'قالب الخريطة',
'settings.mapTemplatePlaceholder.select': 'اختر قالبًا...',
'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا',
+ 'settings.routingBase': 'محرك مسارات خاص',
+ 'settings.routingBaseHint': 'خادم OSRM خاص بك. اتركه فارغًا لاستخدام الخوادم العامة التي تسمح بطلب واحد تقريبًا في الثانية — يكفي ليوم واحد، وضيّق لرحلة برية. يسري بعد إعادة تشغيل الخادم.',
'settings.mapHint': 'قالب URL لبلاطات الخريطة',
'settings.mapProvider': 'مزود الخريطة',
'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.',
diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts
index 09d158b28..6fe5cbca1 100644
--- a/shared/src/i18n/br/roadtrip.ts
+++ b/shared/src/i18n/br/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Comida',
'roadtrip.poi.sights': 'Pontos turísticos',
'roadtrip.poi.within': 'Num raio de',
+ 'roadtrip.poi.filter': 'Filtrar por nome',
+ 'roadtrip.poi.noMatch': 'Nada no trajeto corresponde a “{name}”.',
+ '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.search': 'Buscar ao longo da rota',
'roadtrip.poi.searching': 'Buscando {done} de {total}',
'roadtrip.poi.capped': 'A rota é longa — apenas o primeiro trecho foi pesquisado.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'após {distance}',
'roadtrip.summary.distance': 'Distância',
'roadtrip.summary.driving': 'Tempo de direção',
+ 'roadtrip.alt.ask': 'Outras rotas',
+ 'roadtrip.alt.title': 'Rotas para este trecho',
+ 'roadtrip.alt.loading': 'Consultando o roteador…',
+ 'roadtrip.alt.onlyOne': 'Só há uma forma sensata de fazer este trecho.',
+ 'roadtrip.alt.failed': 'O roteador não está respondendo agora.',
+ 'roadtrip.alt.current': 'Atual',
+ 'roadtrip.alt.fastest': 'Mais rápida',
+ 'roadtrip.alt.slower': '{time} mais lenta',
+ 'roadtrip.alt.quicker': '{time} mais rápida',
'roadtrip.day': 'Dia {number}',
+ 'roadtrip.quietDay.empty': 'Ainda sem paradas — solte uma aqui',
+ 'roadtrip.quietDay.one': 'Até agora só {name} — solte uma parada aqui',
+ 'roadtrip.leg.driveText': '{distance} em {time}',
+ 'roadtrip.day.stopCount': '{count} paradas',
+ 'roadtrip.stop.stayShort': 'Parada',
'roadtrip.leg.pending': 'Sem rota',
+ 'roadtrip.stop.addTitle': 'Adicionar como parada',
+ 'roadtrip.stop.kind': 'Tipo de parada',
+ 'roadtrip.stop.landsOn': 'Dia {day}, como parada {position}',
+ 'roadtrip.stop.duplicate': '“{name}” já está nesta viagem.',
+ 'roadtrip.stop.moreDetails': 'Mais detalhes',
'roadtrip.stop.stay': 'Tempo nesta parada',
'roadtrip.stop.pinned': 'Horário definido por você',
'roadtrip.stop.computed': 'Calculado a partir do trajeto',
+ 'roadtrip.via.hint': 'Arraste para remodelar a rota, clique com o botão direito para remover',
'roadtrip.warn.late': 'Chega {minutes} min depois do horário definido',
+ 'roadtrip.warn.overnight': 'Dia seguinte',
'roadtrip.summary.stops': 'Paradas',
+ 'roadtrip.stay.add': 'Definir a permanencia',
+ 'roadtrip.stay.none': 'Sem permanencia',
+ 'roadtrip.stay.custom': 'Minutos',
+ 'roadtrip.stay.arrive': 'Chegada',
+ 'roadtrip.stay.leave': 'Partida',
+ 'roadtrip.alt.noMotorway': 'Sem rodovia',
+ 'roadtrip.alt.noToll': 'Sem pedagio',
+ 'roadtrip.summary.partial': 'O restante do trajeto ainda está sendo calculado',
};
export default roadtrip;
diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts
index 0c0c83420..b9e6edb61 100644
--- a/shared/src/i18n/br/settings.ts
+++ b/shared/src/i18n/br/settings.ts
@@ -25,6 +25,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Modelo de mapa',
'settings.mapTemplatePlaceholder.select': 'Selecione o modelo...',
'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)',
+ 'settings.routingBase': 'Servidor de rotas próprio',
+ 'settings.routingBaseHint': 'Uma instância própria do OSRM. Vazio usa os servidores públicos, que permitem cerca de uma requisição por segundo — suficiente para um dia, apertado para uma viagem de carro. Passa a valer após reiniciar o servidor.',
'settings.mapHint': 'URL do modelo de blocos do mapa',
'settings.mapProvider': 'Provedor de mapa',
'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.',
diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts
index 7000d2dbc..672b3b73a 100644
--- a/shared/src/i18n/ca/roadtrip.ts
+++ b/shared/src/i18n/ca/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Menjar',
'roadtrip.poi.sights': 'Llocs d’interès',
'roadtrip.poi.within': 'En un radi de',
+ 'roadtrip.poi.filter': 'Filtra per nom',
+ 'roadtrip.poi.noMatch': 'Res del trajecte no coincideix amb «{name}».',
+ '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.search': 'Cerca al llarg de la ruta',
'roadtrip.poi.searching': 'Cercant {done} de {total}',
'roadtrip.poi.capped': 'La ruta és llarga: només s’ha cercat el primer tram.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'després de {distance}',
'roadtrip.summary.distance': 'Distància',
'roadtrip.summary.driving': 'Temps de conducció',
+ 'roadtrip.alt.ask': 'Altres rutes',
+ 'roadtrip.alt.title': 'Rutes per a aquest tram',
+ 'roadtrip.alt.loading': 'S’està consultant l’encaminador…',
+ 'roadtrip.alt.onlyOne': 'Només hi ha una manera raonable de fer aquest tram.',
+ 'roadtrip.alt.failed': 'L’encaminador no respon ara mateix.',
+ 'roadtrip.alt.current': 'Actual',
+ 'roadtrip.alt.fastest': 'La més ràpida',
+ 'roadtrip.alt.slower': '{time} més lenta',
+ 'roadtrip.alt.quicker': '{time} més ràpida',
'roadtrip.day': 'Dia {number}',
+ 'roadtrip.quietDay.empty': 'Encara sense parades: deixa’n una aquí',
+ 'roadtrip.quietDay.one': 'De moment només {name}: deixa una parada aquí',
+ 'roadtrip.leg.driveText': '{distance} en {time}',
+ 'roadtrip.day.stopCount': '{count} parades',
+ 'roadtrip.stop.stayShort': 'Parada',
'roadtrip.leg.pending': 'Sense ruta',
+ 'roadtrip.stop.addTitle': 'Afegeix com a parada',
+ 'roadtrip.stop.kind': 'Tipus de parada',
+ 'roadtrip.stop.landsOn': 'Dia {day}, com a parada {position}',
+ 'roadtrip.stop.duplicate': '«{name}» ja és en aquest viatge.',
+ 'roadtrip.stop.moreDetails': 'Més detalls',
'roadtrip.stop.stay': 'Temps en aquesta parada',
'roadtrip.stop.pinned': 'Hora que has fixat',
'roadtrip.stop.computed': 'Calculat a partir del trajecte',
+ 'roadtrip.via.hint': 'Arrossega per remodelar la ruta, clic dret per eliminar',
'roadtrip.warn.late': 'Arriba {minutes} min després de l’hora fixada',
+ 'roadtrip.warn.overnight': 'Dia següent',
'roadtrip.summary.stops': 'Parades',
+ 'roadtrip.stay.add': 'Defineix una estada',
+ 'roadtrip.stay.none': 'Sense estada',
+ 'roadtrip.stay.custom': 'Minuts',
+ 'roadtrip.stay.arrive': 'Arribada',
+ 'roadtrip.stay.leave': 'Sortida',
+ 'roadtrip.alt.noMotorway': 'Sense autopista',
+ 'roadtrip.alt.noToll': 'Sense peatges',
+ 'roadtrip.summary.partial': 'Encara s’està calculant la resta del trajecte',
};
export default roadtrip;
diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts
index 72893e2b1..d63161207 100644
--- a/shared/src/i18n/ca/settings.ts
+++ b/shared/src/i18n/ca/settings.ts
@@ -14,6 +14,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Plantilla del mapa',
'settings.mapTemplatePlaceholder.select': 'Selecciona una plantilla...',
'settings.mapDefaultHint': 'Deixa-ho buit per a OpenStreetMap (per defecte)',
+ 'settings.routingBase': 'Motor de rutes propi',
+ 'settings.routingBaseHint': 'Una instància pròpia d’OSRM. Buit fa servir els servidors públics, que permeten aproximadament una petició per segon: prou per a un dia, just per a un viatge llarg. Té efecte després de reiniciar el servidor.',
'settings.mapHint': "Plantilla d'URL per als mosaics del mapa",
'settings.mapProvider': 'Proveïdor de mapa',
'settings.mapProviderHint': 'Afecta els mapes de Trip Planner i Journey. Atles sempre utilitza Leaflet.',
diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts
index 0986ca622..683954adf 100644
--- a/shared/src/i18n/cs/roadtrip.ts
+++ b/shared/src/i18n/cs/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Jídlo',
'roadtrip.poi.sights': 'Zajímavosti',
'roadtrip.poi.within': 'Do',
+ 'roadtrip.poi.filter': 'Filtrovat podle názvu',
+ 'roadtrip.poi.noMatch': 'Na trase nic neodpovídá „{name}“.',
+ '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.search': 'Hledat podél trasy',
'roadtrip.poi.searching': 'Hledání {done} z {total}',
'roadtrip.poi.capped': 'Trasa je dlouhá — prohledán byl jen první úsek.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'po {distance}',
'roadtrip.summary.distance': 'Vzdálenost',
'roadtrip.summary.driving': 'Doba jízdy',
+ 'roadtrip.alt.ask': 'Jiné trasy',
+ 'roadtrip.alt.title': 'Trasy tohoto úseku',
+ 'roadtrip.alt.loading': 'Dotaz na směrovací server…',
+ 'roadtrip.alt.onlyOne': 'Pro tento úsek existuje jen jedna rozumná trasa.',
+ 'roadtrip.alt.failed': 'Směrovací server teď neodpovídá.',
+ 'roadtrip.alt.current': 'Aktuální',
+ 'roadtrip.alt.fastest': 'Nejrychlejší',
+ 'roadtrip.alt.slower': 'o {time} déle',
+ 'roadtrip.alt.quicker': 'o {time} rychleji',
'roadtrip.day': 'Den {number}',
+ 'roadtrip.quietDay.empty': 'Zatím žádné zastávky — přetáhněte sem',
+ 'roadtrip.quietDay.one': 'Zatím jen {name} — přetáhněte sem zastávku',
+ 'roadtrip.leg.driveText': '{distance} za {time}',
+ 'roadtrip.day.stopCount': 'zastávek: {count}',
+ 'roadtrip.stop.stayShort': 'Pobyt',
'roadtrip.leg.pending': 'Bez trasy',
+ 'roadtrip.stop.addTitle': 'Přidat jako zastávku',
+ 'roadtrip.stop.kind': 'Druh zastávky',
+ 'roadtrip.stop.landsOn': '{day}. den, jako {position}. zastávka',
+ 'roadtrip.stop.duplicate': '„{name}“ už v této cestě je.',
+ 'roadtrip.stop.moreDetails': 'Více podrobností',
'roadtrip.stop.stay': 'Čas na této zastávce',
'roadtrip.stop.pinned': 'Čas, který jste nastavili',
'roadtrip.stop.computed': 'Vypočteno z doby jízdy',
+ 'roadtrip.via.hint': 'Tažením změníte trasu, pravým tlačítkem odeberete',
'roadtrip.warn.late': 'Příjezd {minutes} min po nastaveném čase',
+ 'roadtrip.warn.overnight': 'Následující den',
'roadtrip.summary.stops': 'Zastávky',
+ 'roadtrip.stay.add': 'Nastavit dobu zastavky',
+ 'roadtrip.stay.none': 'Bez zastavky',
+ 'roadtrip.stay.custom': 'Minuty',
+ 'roadtrip.stay.arrive': 'Prijezd',
+ 'roadtrip.stay.leave': 'Odjezd',
+ 'roadtrip.alt.noMotorway': 'Bez dalnice',
+ 'roadtrip.alt.noToll': 'Bez mytneho',
+ 'roadtrip.summary.partial': 'Zbytek trasy se ještě počítá',
};
export default roadtrip;
diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts
index f758da52b..9565653d0 100644
--- a/shared/src/i18n/cs/settings.ts
+++ b/shared/src/i18n/cs/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Šablona mapy',
'settings.mapTemplatePlaceholder.select': 'Vyberte šablonu...',
'settings.mapDefaultHint': 'Ponechte prázdné pro OpenStreetMap (výchozí)',
+ 'settings.routingBase': 'Vlastní směrovací server',
+ 'settings.routingBaseHint': 'Vlastní instance OSRM. Prázdné použije veřejné servery s limitem asi jednoho požadavku za sekundu — na den to stačí, na road trip je to málo. Projeví se po restartu serveru.',
'settings.mapHint': 'URL šablony pro mapové dlaždice',
'settings.mapProvider': 'Poskytovatel mapy',
'settings.mapProviderHint': 'Ovlivňuje mapy v Trip Planneru a Journey. Atlas vždy používá Leaflet.',
diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts
index bd2575d3e..478369da7 100644
--- a/shared/src/i18n/de/roadtrip.ts
+++ b/shared/src/i18n/de/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Essen',
'roadtrip.poi.sights': 'Sehenswertes',
'roadtrip.poi.within': 'Im Umkreis',
+ 'roadtrip.poi.filter': 'Nach Name filtern',
+ 'roadtrip.poi.noMatch': 'Unterwegs passt nichts zu „{name}“.',
+ '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.search': 'Entlang der Route suchen',
'roadtrip.poi.searching': 'Suche {done} von {total}',
'roadtrip.poi.capped': 'Die Route ist lang — nur der erste Abschnitt wurde durchsucht.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'nach {distance}',
'roadtrip.summary.distance': 'Strecke',
'roadtrip.summary.driving': 'Fahrzeit',
+ 'roadtrip.alt.ask': 'Andere Wege',
+ 'roadtrip.alt.title': 'Wege für diese Etappe',
+ 'roadtrip.alt.loading': 'Der Router wird gefragt…',
+ 'roadtrip.alt.onlyOne': 'Für diese Etappe gibt es nur einen sinnvollen Weg.',
+ 'roadtrip.alt.failed': 'Der Router antwortet gerade nicht.',
+ 'roadtrip.alt.current': 'Aktuell',
+ 'roadtrip.alt.fastest': 'Schnellste',
+ 'roadtrip.alt.slower': '{time} länger',
+ 'roadtrip.alt.quicker': '{time} kürzer',
'roadtrip.day': 'Tag {number}',
+ 'roadtrip.quietDay.empty': 'Noch keine Stopps — hier ablegen',
+ 'roadtrip.quietDay.one': 'Bisher nur {name} — hier einen Stopp ablegen',
+ 'roadtrip.leg.driveText': '{distance} in {time}',
+ 'roadtrip.day.stopCount': '{count} Stopps',
+ 'roadtrip.stop.stayShort': 'Dauer',
'roadtrip.leg.pending': 'Keine Route',
+ 'roadtrip.stop.addTitle': 'Als Stopp hinzufügen',
+ 'roadtrip.stop.kind': 'Art des Stopps',
+ 'roadtrip.stop.landsOn': 'Tag {day}, als Stopp {position}',
+ 'roadtrip.stop.duplicate': '„{name}“ ist auf dieser Reise schon vorhanden.',
+ 'roadtrip.stop.moreDetails': 'Mehr Details',
'roadtrip.stop.stay': 'Aufenthalt an diesem Stopp',
'roadtrip.stop.pinned': 'Von dir gesetzte Zeit',
'roadtrip.stop.computed': 'Aus der Fahrzeit gerechnet',
+ 'roadtrip.via.hint': 'Ziehen formt die Route um, Rechtsklick entfernt',
'roadtrip.warn.late': 'Ankunft {minutes} min nach der gesetzten Zeit',
+ 'roadtrip.warn.overnight': 'Folgetag',
'roadtrip.summary.stops': 'Stopps',
+ 'roadtrip.stay.add': 'Aufenthalt festlegen',
+ 'roadtrip.stay.none': 'Kein Aufenthalt',
+ 'roadtrip.stay.custom': 'Minuten',
+ 'roadtrip.stay.arrive': 'Ankunft',
+ 'roadtrip.stay.leave': 'Abfahrt',
+ 'roadtrip.alt.noMotorway': 'Ohne Autobahn',
+ 'roadtrip.alt.noToll': 'Ohne Maut',
+ 'roadtrip.summary.partial': 'Der Rest der Strecke wird noch berechnet',
};
export default roadtrip;
diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts
index 87bdf34e8..3a3d7c508 100644
--- a/shared/src/i18n/de/settings.ts
+++ b/shared/src/i18n/de/settings.ts
@@ -25,6 +25,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Karten-Vorlage',
'settings.mapTemplatePlaceholder.select': 'Vorlage auswählen...',
'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)',
+ 'settings.routingBase': 'Eigene Routing-Instanz',
+ 'settings.routingBaseHint': 'Eine eigene OSRM-Instanz. Leer nutzt die öffentlichen Server, die etwa eine Anfrage pro Sekunde erlauben — für einen Tag genug, für einen Roadtrip knapp. Wirkt erst nach einem Serverneustart.',
'settings.mapHint': 'URL-Template für die Kartenkacheln',
'settings.mapProvider': 'Kartenanbieter',
'settings.mapProviderHint': 'Gilt für Trip Planner und Journey. Atlas nutzt immer Leaflet.',
diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts
index 646015e19..65f37a383 100644
--- a/shared/src/i18n/en/roadtrip.ts
+++ b/shared/src/i18n/en/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Food',
'roadtrip.poi.sights': 'Sights',
'roadtrip.poi.within': 'Within',
+ 'roadtrip.poi.filter': 'Filter by name',
+ 'roadtrip.poi.noMatch': 'Nothing on the way matches “{name}”.',
+ 'roadtrip.poi.foundFiltered': '{count} of {total} on the way',
+ 'roadtrip.poi.truncated': '{count} stretches had more than fits in one answer — narrow the corridor to see the rest.',
'roadtrip.poi.search': 'Search along the route',
'roadtrip.poi.searching': 'Searching {done} of {total}',
'roadtrip.poi.capped': 'The route is long — only the first stretch was searched.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'after {distance}',
'roadtrip.summary.distance': 'Distance',
'roadtrip.summary.driving': 'Driving time',
+ 'roadtrip.alt.ask': 'Other ways',
+ 'roadtrip.alt.title': 'Ways to drive this leg',
+ 'roadtrip.alt.loading': 'Asking the router…',
+ 'roadtrip.alt.onlyOne': 'Only one sensible way to drive this one.',
+ 'roadtrip.alt.failed': 'The router is not answering right now.',
+ 'roadtrip.alt.current': 'Current',
+ 'roadtrip.alt.fastest': 'Fastest',
+ 'roadtrip.alt.slower': '{time} slower',
+ 'roadtrip.alt.quicker': '{time} quicker',
'roadtrip.day': 'Day {number}',
+ 'roadtrip.quietDay.empty': 'No stops yet — drop one here',
+ 'roadtrip.quietDay.one': 'Only {name} so far — drop a stop here',
+ 'roadtrip.leg.driveText': '{distance} in {time}',
+ 'roadtrip.day.stopCount': '{count} stops',
+ 'roadtrip.stop.stayShort': 'Stay',
'roadtrip.leg.pending': 'No route',
+ 'roadtrip.stop.addTitle': 'Add as a stop',
+ 'roadtrip.stop.kind': 'Kind of stop',
+ 'roadtrip.stop.landsOn': 'Day {day}, as stop {position}',
+ 'roadtrip.stop.duplicate': '“{name}” is already on this trip.',
+ 'roadtrip.stop.moreDetails': 'More details',
'roadtrip.stop.stay': 'Time at this stop',
'roadtrip.stop.pinned': 'Time you set',
'roadtrip.stop.computed': 'Calculated from the drive',
+ 'roadtrip.via.hint': 'Drag to reshape the route, right-click to remove',
'roadtrip.warn.late': 'Arrives {minutes} min after the time you set',
+ 'roadtrip.warn.overnight': 'Next day',
'roadtrip.summary.stops': 'Stops',
+ 'roadtrip.stay.add': 'Add a stay',
+ 'roadtrip.stay.none': 'No stay',
+ 'roadtrip.stay.custom': 'Minutes',
+ 'roadtrip.stay.arrive': 'Arrive',
+ 'roadtrip.stay.leave': 'Leave',
+ 'roadtrip.alt.noMotorway': 'No motorway',
+ 'roadtrip.alt.noToll': 'No tolls',
+ 'roadtrip.summary.partial': 'Still working out the rest of the drive',
};
export default roadtrip;
diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts
index e4c25d6ec..c3e0fd706 100644
--- a/shared/src/i18n/en/settings.ts
+++ b/shared/src/i18n/en/settings.ts
@@ -33,6 +33,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Map Template',
'settings.mapTemplatePlaceholder.select': 'Select template...',
'settings.mapDefaultHint': 'Leave empty for OpenStreetMap (default)',
+ 'settings.routingBase': 'Own routing engine',
+ 'settings.routingBaseHint': 'An OSRM instance of your own. Empty uses the public servers, which allow about one request a second — enough for a day, tight for a road trip. Takes effect after a server restart.',
'settings.mapHint': 'URL template for map tiles',
'settings.mapProvider': 'Map Provider',
'settings.mapProviderHint': 'Affects Trip Planner and Journey maps. Atlas always uses Leaflet.',
diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts
index cd90bd78d..8cc4a3218 100644
--- a/shared/src/i18n/es/roadtrip.ts
+++ b/shared/src/i18n/es/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Comida',
'roadtrip.poi.sights': 'Lugares de interés',
'roadtrip.poi.within': 'En un radio de',
+ 'roadtrip.poi.filter': 'Filtrar por nombre',
+ 'roadtrip.poi.noMatch': 'Nada en la ruta coincide con «{name}».',
+ '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.search': 'Buscar a lo largo de la ruta',
'roadtrip.poi.searching': 'Buscando {done} de {total}',
'roadtrip.poi.capped': 'La ruta es larga: solo se buscó en el primer tramo.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'tras {distance}',
'roadtrip.summary.distance': 'Distancia',
'roadtrip.summary.driving': 'Tiempo de conducción',
+ 'roadtrip.alt.ask': 'Otras rutas',
+ 'roadtrip.alt.title': 'Rutas para este tramo',
+ 'roadtrip.alt.loading': 'Consultando al enrutador…',
+ 'roadtrip.alt.onlyOne': 'Solo hay una forma razonable de hacer este tramo.',
+ 'roadtrip.alt.failed': 'El enrutador no responde ahora mismo.',
+ 'roadtrip.alt.current': 'Actual',
+ 'roadtrip.alt.fastest': 'La más rápida',
+ 'roadtrip.alt.slower': '{time} más',
+ 'roadtrip.alt.quicker': '{time} menos',
'roadtrip.day': 'Día {number}',
+ 'roadtrip.quietDay.empty': 'Sin paradas todavía: suelta una aquí',
+ 'roadtrip.quietDay.one': 'Por ahora solo {name}: suelta una parada aquí',
+ 'roadtrip.leg.driveText': '{distance} en {time}',
+ 'roadtrip.day.stopCount': '{count} paradas',
+ 'roadtrip.stop.stayShort': 'Parada',
'roadtrip.leg.pending': 'Sin ruta',
+ 'roadtrip.stop.addTitle': 'Añadir como parada',
+ 'roadtrip.stop.kind': 'Tipo de parada',
+ 'roadtrip.stop.landsOn': 'Día {day}, como parada {position}',
+ 'roadtrip.stop.duplicate': '«{name}» ya está en este viaje.',
+ 'roadtrip.stop.moreDetails': 'Más detalles',
'roadtrip.stop.stay': 'Tiempo en la parada',
'roadtrip.stop.pinned': 'Hora fijada por ti',
'roadtrip.stop.computed': 'Calculado a partir del trayecto',
+ 'roadtrip.via.hint': 'Arrastra para remodelar la ruta, clic derecho para quitar',
'roadtrip.warn.late': 'Llega {minutes} min después de la hora fijada',
+ 'roadtrip.warn.overnight': 'Día siguiente',
'roadtrip.summary.stops': 'Paradas',
+ 'roadtrip.stay.add': 'Anadir una parada',
+ 'roadtrip.stay.none': 'Sin parada',
+ 'roadtrip.stay.custom': 'Minutos',
+ 'roadtrip.stay.arrive': 'Llegada',
+ 'roadtrip.stay.leave': 'Salida',
+ 'roadtrip.alt.noMotorway': 'Sin autopista',
+ 'roadtrip.alt.noToll': 'Sin peajes',
+ 'roadtrip.summary.partial': 'Aún se está calculando el resto del trayecto',
};
export default roadtrip;
diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts
index 82b5c9691..5808e370d 100644
--- a/shared/src/i18n/es/settings.ts
+++ b/shared/src/i18n/es/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Plantilla del mapa',
'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...',
'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)',
+ 'settings.routingBase': 'Motor de rutas propio',
+ 'settings.routingBaseHint': 'Una instancia propia de OSRM. Vacío usa los servidores públicos, que permiten cerca de una petición por segundo: suficiente para un día, justo para una ruta larga. Surte efecto tras reiniciar el servidor.',
'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa',
'settings.mapProvider': 'Proveedor de mapa',
'settings.mapProviderHint': 'Afecta a los mapas de Trip Planner y Journey. Atlas siempre usa Leaflet.',
diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts
index 3dd521181..6d59eb3d2 100644
--- a/shared/src/i18n/fr/roadtrip.ts
+++ b/shared/src/i18n/fr/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Restauration',
'roadtrip.poi.sights': 'À voir',
'roadtrip.poi.within': 'Dans un rayon de',
+ 'roadtrip.poi.filter': 'Filtrer par nom',
+ 'roadtrip.poi.noMatch': 'Rien sur le trajet ne correspond à « {name} ».',
+ '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.search': 'Chercher le long du trajet',
'roadtrip.poi.searching': 'Recherche {done} sur {total}',
'roadtrip.poi.capped': 'L’itinéraire est long : seule la première portion a été parcourue.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'après {distance}',
'roadtrip.summary.distance': 'Distance',
'roadtrip.summary.driving': 'Temps de conduite',
+ 'roadtrip.alt.ask': 'Autres itinéraires',
+ 'roadtrip.alt.title': 'Itinéraires pour cette étape',
+ 'roadtrip.alt.loading': 'Interrogation du routeur…',
+ 'roadtrip.alt.onlyOne': 'Il n’y a qu’un itinéraire raisonnable pour cette étape.',
+ 'roadtrip.alt.failed': 'Le routeur ne répond pas pour le moment.',
+ 'roadtrip.alt.current': 'Actuel',
+ 'roadtrip.alt.fastest': 'La plus rapide',
+ 'roadtrip.alt.slower': '{time} de plus',
+ 'roadtrip.alt.quicker': '{time} de moins',
'roadtrip.day': 'Jour {number}',
+ 'roadtrip.quietDay.empty': 'Aucun arrêt — déposez-en un ici',
+ 'roadtrip.quietDay.one': 'Seulement {name} pour l’instant — déposez un arrêt ici',
+ 'roadtrip.leg.driveText': '{distance} en {time}',
+ 'roadtrip.day.stopCount': '{count} arrêts',
+ 'roadtrip.stop.stayShort': 'Durée',
'roadtrip.leg.pending': 'Pas d’itinéraire',
+ 'roadtrip.stop.addTitle': 'Ajouter comme arrêt',
+ 'roadtrip.stop.kind': 'Type d’arrêt',
+ 'roadtrip.stop.landsOn': 'Jour {day}, en tant qu’arrêt {position}',
+ 'roadtrip.stop.duplicate': '« {name} » figure déjà dans ce voyage.',
+ 'roadtrip.stop.moreDetails': 'Plus de détails',
'roadtrip.stop.stay': 'Temps sur place',
'roadtrip.stop.pinned': 'Heure que vous avez fixée',
'roadtrip.stop.computed': 'Calculé d’après le trajet',
+ 'roadtrip.via.hint': 'Faites glisser pour remodeler l’itinéraire, clic droit pour supprimer',
'roadtrip.warn.late': 'Arrivée {minutes} min après l’heure fixée',
+ 'roadtrip.warn.overnight': 'Jour suivant',
'roadtrip.summary.stops': 'Étapes',
+ 'roadtrip.stay.add': 'Definir une duree',
+ 'roadtrip.stay.none': 'Aucune duree',
+ 'roadtrip.stay.custom': 'Minutes',
+ 'roadtrip.stay.arrive': 'Arrivee',
+ 'roadtrip.stay.leave': 'Depart',
+ 'roadtrip.alt.noMotorway': 'Sans autoroute',
+ 'roadtrip.alt.noToll': 'Sans peage',
+ 'roadtrip.summary.partial': 'Le reste du trajet est encore en cours de calcul',
};
export default roadtrip;
diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts
index 386907192..1388e6208 100644
--- a/shared/src/i18n/fr/settings.ts
+++ b/shared/src/i18n/fr/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Modèle de carte',
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle…',
'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)',
+ 'settings.routingBase': 'Moteur de calcul d’itinéraire propre',
+ 'settings.routingBaseHint': 'Votre propre instance OSRM. Vide utilise les serveurs publics, limités à environ une requête par seconde : suffisant pour une journée, juste pour un road trip. Prend effet après un redémarrage du serveur.',
'settings.mapHint': "Modèle d'URL pour les tuiles de carte",
'settings.mapProvider': 'Fournisseur de carte',
'settings.mapProviderHint': 'Affecte les cartes Trip Planner et Journey. Atlas utilise toujours Leaflet.',
diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts
index 52209313d..954a7ee1e 100644
--- a/shared/src/i18n/gr/roadtrip.ts
+++ b/shared/src/i18n/gr/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Φαγητό',
'roadtrip.poi.sights': 'Αξιοθέατα',
'roadtrip.poi.within': 'Εντός',
+ 'roadtrip.poi.filter': 'Φιλτράρισμα κατά όνομα',
+ 'roadtrip.poi.noMatch': 'Τίποτα στη διαδρομή δεν ταιριάζει με «{name}».',
+ 'roadtrip.poi.foundFiltered': '{count} από {total} στη διαδρομή',
+ 'roadtrip.poi.truncated': 'Σε {count} τμήματα υπήρχαν περισσότερα από όσα χωρούν σε μία απάντηση — στενέψτε τον διάδρομο για να δείτε τα υπόλοιπα.',
'roadtrip.poi.search': 'Αναζήτηση κατά μήκος της διαδρομής',
'roadtrip.poi.searching': 'Αναζήτηση {done} από {total}',
'roadtrip.poi.capped': 'Η διαδρομή είναι μεγάλη — αναζητήθηκε μόνο το πρώτο τμήμα.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'μετά από {distance}',
'roadtrip.summary.distance': 'Απόσταση',
'roadtrip.summary.driving': 'Χρόνος οδήγησης',
+ 'roadtrip.alt.ask': 'Άλλες διαδρομές',
+ 'roadtrip.alt.title': 'Διαδρομές για αυτό το σκέλος',
+ 'roadtrip.alt.loading': 'Ερώτημα στον δρομολογητή…',
+ 'roadtrip.alt.onlyOne': 'Υπάρχει μόνο μία λογική διαδρομή για αυτό το σκέλος.',
+ 'roadtrip.alt.failed': 'Ο δρομολογητής δεν απαντά αυτή τη στιγμή.',
+ 'roadtrip.alt.current': 'Τρέχουσα',
+ 'roadtrip.alt.fastest': 'Η ταχύτερη',
+ 'roadtrip.alt.slower': '{time} πιο αργή',
+ 'roadtrip.alt.quicker': '{time} πιο γρήγορη',
'roadtrip.day': 'Ημέρα {number}',
+ 'roadtrip.quietDay.empty': 'Καμία στάση ακόμη — αφήστε μία εδώ',
+ 'roadtrip.quietDay.one': 'Μέχρι στιγμής μόνο {name} — αφήστε μια στάση εδώ',
+ 'roadtrip.leg.driveText': '{distance} σε {time}',
+ 'roadtrip.day.stopCount': '{count} στάσεις',
+ 'roadtrip.stop.stayShort': 'Στάση',
'roadtrip.leg.pending': 'Χωρίς διαδρομή',
+ 'roadtrip.stop.addTitle': 'Προσθήκη ως στάση',
+ 'roadtrip.stop.kind': 'Είδος στάσης',
+ 'roadtrip.stop.landsOn': 'Ημέρα {day}, ως στάση {position}',
+ 'roadtrip.stop.duplicate': '«{name}» υπάρχει ήδη σε αυτό το ταξίδι.',
+ 'roadtrip.stop.moreDetails': 'Περισσότερες λεπτομέρειες',
'roadtrip.stop.stay': 'Χρόνος σε αυτή τη στάση',
'roadtrip.stop.pinned': 'Ώρα που ορίσατε',
'roadtrip.stop.computed': 'Υπολογισμένη από τη διαδρομή',
+ 'roadtrip.via.hint': 'Σύρετε για να αλλάξετε τη διαδρομή, δεξί κλικ για αφαίρεση',
'roadtrip.warn.late': 'Άφιξη {minutes} λεπτά μετά την ορισμένη ώρα',
+ 'roadtrip.warn.overnight': 'Επόμενη μέρα',
'roadtrip.summary.stops': 'Στάσεις',
+ 'roadtrip.stay.add': 'Ορισμος διαρκειας σταθμευσης',
+ 'roadtrip.stay.none': 'Χωρις σταση',
+ 'roadtrip.stay.custom': 'Λεπτα',
+ 'roadtrip.stay.arrive': 'Αφιξη',
+ 'roadtrip.stay.leave': 'Αναχωρηση',
+ 'roadtrip.alt.noMotorway': 'Χωρις αυτοκινητοδρομο',
+ 'roadtrip.alt.noToll': 'Χωρις διοδια',
+ 'roadtrip.summary.partial': 'Υπολογίζεται ακόμη η υπόλοιπη διαδρομή',
};
export default roadtrip;
diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts
index 85943e203..c2a74989d 100644
--- a/shared/src/i18n/gr/settings.ts
+++ b/shared/src/i18n/gr/settings.ts
@@ -25,6 +25,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Πρότυπο Χάρτη',
'settings.mapTemplatePlaceholder.select': 'Επιλέξτε πρότυπο...',
'settings.mapDefaultHint': 'Αφήστε κενό για OpenStreetMap (προεπιλογή)',
+ 'settings.routingBase': 'Δική σας μηχανή δρομολόγησης',
+ 'settings.routingBaseHint': 'Δική σας εγκατάσταση OSRM. Κενό σημαίνει τους δημόσιους διακομιστές, που επιτρέπουν περίπου ένα αίτημα ανά δευτερόλεπτο: αρκεί για μία ημέρα, λίγο για ένα οδικό ταξίδι. Ισχύει μετά από επανεκκίνηση του διακομιστή.',
'settings.mapHint': 'Πρότυπο URL για πλακίδια χάρτη',
'settings.mapProvider': 'Πάροχος Χάρτη',
'settings.mapProviderHint':
diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts
index 34c476daf..649ed0f23 100644
--- a/shared/src/i18n/hu/roadtrip.ts
+++ b/shared/src/i18n/hu/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Étkezés',
'roadtrip.poi.sights': 'Látnivalók',
'roadtrip.poi.within': 'Ezen belül',
+ 'roadtrip.poi.filter': 'Szűrés név szerint',
+ 'roadtrip.poi.noMatch': 'Az útvonalon semmi sem egyezik ezzel: „{name}”.',
+ '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.search': 'Keresés az útvonal mentén',
'roadtrip.poi.searching': 'Keresés {done}/{total}',
'roadtrip.poi.capped': 'Az útvonal hosszú – csak az első szakaszon kerestünk.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': '{distance} után',
'roadtrip.summary.distance': 'Távolság',
'roadtrip.summary.driving': 'Vezetési idő',
+ 'roadtrip.alt.ask': 'Más útvonalak',
+ 'roadtrip.alt.title': 'Útvonalak ehhez a szakaszhoz',
+ 'roadtrip.alt.loading': 'Az útvonaltervező lekérdezése…',
+ 'roadtrip.alt.onlyOne': 'Ehhez a szakaszhoz csak egy értelmes útvonal van.',
+ 'roadtrip.alt.failed': 'Az útvonaltervező most nem válaszol.',
+ 'roadtrip.alt.current': 'Jelenlegi',
+ 'roadtrip.alt.fastest': 'Leggyorsabb',
+ 'roadtrip.alt.slower': '{time}-cel lassabb',
+ 'roadtrip.alt.quicker': '{time}-cel gyorsabb',
'roadtrip.day': '{number}. nap',
+ 'roadtrip.quietDay.empty': 'Még nincs megálló — húzzon ide egyet',
+ 'roadtrip.quietDay.one': 'Egyelőre csak {name} — húzzon ide egy megállót',
+ 'roadtrip.leg.driveText': '{distance} {time} alatt',
+ 'roadtrip.day.stopCount': '{count} megálló',
+ 'roadtrip.stop.stayShort': 'Idő',
'roadtrip.leg.pending': 'Nincs útvonal',
+ 'roadtrip.stop.addTitle': 'Hozzáadás megállóként',
+ 'roadtrip.stop.kind': 'Megálló típusa',
+ 'roadtrip.stop.landsOn': '{day}. nap, {position}. megállóként',
+ 'roadtrip.stop.duplicate': '„{name}” már szerepel ebben az utazásban.',
+ 'roadtrip.stop.moreDetails': 'További részletek',
'roadtrip.stop.stay': 'Idő ezen a megállón',
'roadtrip.stop.pinned': 'Az általad megadott időpont',
'roadtrip.stop.computed': 'A menetidőből számolva',
+ 'roadtrip.via.hint': 'Húzza az útvonal átalakításához, jobb gombbal eltávolítható',
'roadtrip.warn.late': 'Érkezés {minutes} perccel a megadott idő után',
+ 'roadtrip.warn.overnight': 'Következő nap',
'roadtrip.summary.stops': 'Megállók',
+ 'roadtrip.stay.add': 'Tartozkodas beallitasa',
+ 'roadtrip.stay.none': 'Nincs tartozkodas',
+ 'roadtrip.stay.custom': 'Perc',
+ 'roadtrip.stay.arrive': 'Erkezes',
+ 'roadtrip.stay.leave': 'Indulas',
+ 'roadtrip.alt.noMotorway': 'Autopalya nelkul',
+ 'roadtrip.alt.noToll': 'Dijmentes',
+ 'roadtrip.summary.partial': 'A hátralévő útszakasz számítása még folyamatban',
};
export default roadtrip;
diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts
index ab32eecfb..0bd3bde23 100644
--- a/shared/src/i18n/hu/settings.ts
+++ b/shared/src/i18n/hu/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Térkép sablon',
'settings.mapTemplatePlaceholder.select': 'Sablon kiválasztása...',
'settings.mapDefaultHint': 'Hagyd üresen az OpenStreetMap használatához (alapértelmezett)',
+ 'settings.routingBase': 'Saját útvonaltervező',
+ 'settings.routingBaseHint': 'Saját OSRM-példány. Üresen hagyva a nyilvános kiszolgálókat használja, amelyek másodpercenként körülbelül egy kérést engednek — egy napra elég, egy autós útra kevés. A kiszolgáló újraindítása után lép életbe.',
'settings.mapHint': 'URL sablon a térképcsempékhez',
'settings.mapProvider': 'Térkép szolgáltató',
'settings.mapProviderHint': 'A Trip Planner és Journey térképekre érvényes. Az Atlas mindig Leafletet használ.',
diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts
index f45fdd5f6..4aafa2a80 100644
--- a/shared/src/i18n/id/roadtrip.ts
+++ b/shared/src/i18n/id/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Makanan',
'roadtrip.poi.sights': 'Objek wisata',
'roadtrip.poi.within': 'Dalam',
+ 'roadtrip.poi.filter': 'Saring menurut nama',
+ 'roadtrip.poi.noMatch': 'Tidak ada di rute yang cocok dengan “{name}”.',
+ '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.search': 'Cari di sepanjang rute',
'roadtrip.poi.searching': 'Mencari {done} dari {total}',
'roadtrip.poi.capped': 'Rute panjang — hanya bagian pertama yang dicari.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'setelah {distance}',
'roadtrip.summary.distance': 'Jarak',
'roadtrip.summary.driving': 'Waktu berkendara',
+ 'roadtrip.alt.ask': 'Rute lain',
+ 'roadtrip.alt.title': 'Rute untuk ruas ini',
+ 'roadtrip.alt.loading': 'Menanyakan server rute…',
+ 'roadtrip.alt.onlyOne': 'Hanya ada satu rute yang masuk akal untuk ruas ini.',
+ 'roadtrip.alt.failed': 'Server rute sedang tidak merespons.',
+ 'roadtrip.alt.current': 'Saat ini',
+ 'roadtrip.alt.fastest': 'Tercepat',
+ 'roadtrip.alt.slower': '{time} lebih lama',
+ 'roadtrip.alt.quicker': '{time} lebih cepat',
'roadtrip.day': 'Hari {number}',
+ 'roadtrip.quietDay.empty': 'Belum ada perhentian — jatuhkan di sini',
+ 'roadtrip.quietDay.one': 'Sejauh ini hanya {name} — jatuhkan perhentian di sini',
+ 'roadtrip.leg.driveText': '{distance} dalam {time}',
+ 'roadtrip.day.stopCount': '{count} perhentian',
+ 'roadtrip.stop.stayShort': 'Singgah',
'roadtrip.leg.pending': 'Tidak ada rute',
+ 'roadtrip.stop.addTitle': 'Tambahkan sebagai perhentian',
+ 'roadtrip.stop.kind': 'Jenis perhentian',
+ 'roadtrip.stop.landsOn': 'Hari {day}, sebagai perhentian {position}',
+ 'roadtrip.stop.duplicate': '“{name}” sudah ada di perjalanan ini.',
+ 'roadtrip.stop.moreDetails': 'Detail lainnya',
'roadtrip.stop.stay': 'Waktu di perhentian ini',
'roadtrip.stop.pinned': 'Waktu yang Anda tetapkan',
'roadtrip.stop.computed': 'Dihitung dari waktu berkendara',
+ 'roadtrip.via.hint': 'Seret untuk mengubah rute, klik kanan untuk menghapus',
'roadtrip.warn.late': 'Tiba {minutes} menit setelah waktu yang ditetapkan',
+ 'roadtrip.warn.overnight': 'Hari berikutnya',
'roadtrip.summary.stops': 'Perhentian',
+ 'roadtrip.stay.add': 'Atur lama singgah',
+ 'roadtrip.stay.none': 'Tanpa singgah',
+ 'roadtrip.stay.custom': 'Menit',
+ 'roadtrip.stay.arrive': 'Tiba',
+ 'roadtrip.stay.leave': 'Berangkat',
+ 'roadtrip.alt.noMotorway': 'Tanpa jalan tol cepat',
+ 'roadtrip.alt.noToll': 'Tanpa tol',
+ 'roadtrip.summary.partial': 'Sisa perjalanan masih dihitung',
};
export default roadtrip;
diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts
index 9ad753bfa..8c8dffd3c 100644
--- a/shared/src/i18n/id/settings.ts
+++ b/shared/src/i18n/id/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Template Peta',
'settings.mapTemplatePlaceholder.select': 'Pilih template...',
'settings.mapDefaultHint': 'Kosongkan untuk OpenStreetMap (default)',
+ 'settings.routingBase': 'Mesin rute sendiri',
+ 'settings.routingBaseHint': 'Instans OSRM milik sendiri. Kosong berarti server publik, yang mengizinkan sekitar satu permintaan per detik — cukup untuk sehari, mepet untuk perjalanan darat. Berlaku setelah server dimulai ulang.',
'settings.mapHint': 'Template URL untuk tile peta',
'settings.mapProvider': 'Penyedia peta',
'settings.mapProviderHint': 'Berlaku untuk peta Trip Planner dan Journey. Atlas selalu menggunakan Leaflet.',
diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts
index 3f7c9973d..e3d868e79 100644
--- a/shared/src/i18n/it/roadtrip.ts
+++ b/shared/src/i18n/it/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Ristoro',
'roadtrip.poi.sights': 'Da vedere',
'roadtrip.poi.within': 'Entro',
+ 'roadtrip.poi.filter': 'Filtra per nome',
+ 'roadtrip.poi.noMatch': 'Lungo il percorso non c’è nulla che corrisponda a “{name}”.',
+ '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.search': 'Cerca lungo il percorso',
'roadtrip.poi.searching': 'Ricerca {done} di {total}',
'roadtrip.poi.capped': 'Il percorso è lungo: è stato cercato solo il primo tratto.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'dopo {distance}',
'roadtrip.summary.distance': 'Distanza',
'roadtrip.summary.driving': 'Tempo di guida',
+ 'roadtrip.alt.ask': 'Altri percorsi',
+ 'roadtrip.alt.title': 'Percorsi per questa tratta',
+ 'roadtrip.alt.loading': 'Interrogazione del router…',
+ 'roadtrip.alt.onlyOne': 'Per questa tratta c’è un solo percorso sensato.',
+ 'roadtrip.alt.failed': 'Il router non risponde al momento.',
+ 'roadtrip.alt.current': 'Attuale',
+ 'roadtrip.alt.fastest': 'La più veloce',
+ 'roadtrip.alt.slower': '{time} in più',
+ 'roadtrip.alt.quicker': '{time} in meno',
'roadtrip.day': 'Giorno {number}',
+ 'roadtrip.quietDay.empty': 'Ancora nessuna sosta: trascinane una qui',
+ 'roadtrip.quietDay.one': 'Finora solo {name}: trascina qui una sosta',
+ 'roadtrip.leg.driveText': '{distance} in {time}',
+ 'roadtrip.day.stopCount': '{count} soste',
+ 'roadtrip.stop.stayShort': 'Sosta',
'roadtrip.leg.pending': 'Nessun percorso',
+ 'roadtrip.stop.addTitle': 'Aggiungi come sosta',
+ 'roadtrip.stop.kind': 'Tipo di sosta',
+ 'roadtrip.stop.landsOn': 'Giorno {day}, come sosta {position}',
+ 'roadtrip.stop.duplicate': '“{name}” è già presente in questo viaggio.',
+ 'roadtrip.stop.moreDetails': 'Altri dettagli',
'roadtrip.stop.stay': 'Tempo alla sosta',
'roadtrip.stop.pinned': 'Orario impostato da te',
'roadtrip.stop.computed': 'Calcolato dal tragitto',
+ 'roadtrip.via.hint': 'Trascina per rimodellare il percorso, clic destro per rimuovere',
'roadtrip.warn.late': 'Arrivo {minutes} min dopo l’orario impostato',
+ 'roadtrip.warn.overnight': 'Giorno successivo',
'roadtrip.summary.stops': 'Soste',
+ 'roadtrip.stay.add': 'Imposta una sosta',
+ 'roadtrip.stay.none': 'Nessuna sosta',
+ 'roadtrip.stay.custom': 'Minuti',
+ 'roadtrip.stay.arrive': 'Arrivo',
+ 'roadtrip.stay.leave': 'Partenza',
+ 'roadtrip.alt.noMotorway': 'Senza autostrada',
+ 'roadtrip.alt.noToll': 'Senza pedaggi',
+ 'roadtrip.summary.partial': 'Il resto del percorso è ancora in calcolo',
};
export default roadtrip;
diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts
index 5f098134e..dea6807da 100644
--- a/shared/src/i18n/it/settings.ts
+++ b/shared/src/i18n/it/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Modello Mappa',
'settings.mapTemplatePlaceholder.select': 'Seleziona modello...',
'settings.mapDefaultHint': 'Lascia vuoto per OpenStreetMap (predefinito)',
+ 'settings.routingBase': 'Motore di percorso proprio',
+ 'settings.routingBaseHint': 'Una tua istanza OSRM. Vuoto usa i server pubblici, che consentono circa una richiesta al secondo: sufficiente per un giorno, poco per un viaggio in auto. Ha effetto dopo il riavvio del server.',
'settings.mapHint': 'Modello URL per i tile della mappa',
'settings.mapProvider': 'Provider mappa',
'settings.mapProviderHint': 'Influisce sulle mappe Trip Planner e Journey. Atlas usa sempre Leaflet.',
diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts
index fb706644d..34041766a 100644
--- a/shared/src/i18n/ja/roadtrip.ts
+++ b/shared/src/i18n/ja/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': '食事',
'roadtrip.poi.sights': '見どころ',
'roadtrip.poi.within': '範囲',
+ 'roadtrip.poi.filter': '名前で絞り込む',
+ 'roadtrip.poi.noMatch': 'ルート沿いに「{name}」に一致するものはありません。',
+ 'roadtrip.poi.foundFiltered': 'ルート沿いの {total} 件中 {count} 件',
+ 'roadtrip.poi.truncated': '{count} 区間で一度に返せる件数を超えました。回廊を狭めると残りを確認できます。',
'roadtrip.poi.search': 'ルート沿いを検索',
'roadtrip.poi.searching': '検索中 {done}/{total}',
'roadtrip.poi.capped': 'ルートが長いため、最初の区間のみ検索しました。',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': '{distance} 地点',
'roadtrip.summary.distance': '距離',
'roadtrip.summary.driving': '運転時間',
+ 'roadtrip.alt.ask': '別のルート',
+ 'roadtrip.alt.title': 'この区間の走り方',
+ 'roadtrip.alt.loading': 'ルーティングサーバーに問い合わせています…',
+ 'roadtrip.alt.onlyOne': 'この区間に妥当なルートは 1 つだけです。',
+ 'roadtrip.alt.failed': 'ルーティングサーバーが応答していません。',
+ 'roadtrip.alt.current': '現在',
+ 'roadtrip.alt.fastest': '最速',
+ 'roadtrip.alt.slower': '{time} 遅い',
+ 'roadtrip.alt.quicker': '{time} 速い',
'roadtrip.day': '{number} 日目',
+ 'roadtrip.quietDay.empty': 'まだ立ち寄り先がありません。ここにドロップ',
+ 'roadtrip.quietDay.one': '今のところ {name} だけです。立ち寄り先をここにドロップ',
+ 'roadtrip.leg.driveText': '{distance}/{time}',
+ 'roadtrip.day.stopCount': '{count} 箇所',
+ 'roadtrip.stop.stayShort': '滞在',
'roadtrip.leg.pending': 'ルートなし',
+ 'roadtrip.stop.addTitle': '立ち寄り先として追加',
+ 'roadtrip.stop.kind': '立ち寄りの種類',
+ 'roadtrip.stop.landsOn': '{day} 日目、{position} 番目の立ち寄り先',
+ 'roadtrip.stop.duplicate': '「{name}」はこの旅行にすでにあります。',
+ 'roadtrip.stop.moreDetails': '詳細を追加',
'roadtrip.stop.stay': 'この立ち寄り先での滞在時間',
'roadtrip.stop.pinned': '設定した時刻',
'roadtrip.stop.computed': '運転時間からの計算値',
+ 'roadtrip.via.hint': 'ドラッグでルートを変え、右クリックで削除',
'roadtrip.warn.late': '設定した時刻より {minutes} 分遅く到着します',
+ 'roadtrip.warn.overnight': '翌日',
'roadtrip.summary.stops': '立ち寄り先',
+ 'roadtrip.stay.add': '滞在時間を設定',
+ 'roadtrip.stay.none': '滞在なし',
+ 'roadtrip.stay.custom': '分',
+ 'roadtrip.stay.arrive': '到着',
+ 'roadtrip.stay.leave': '出発',
+ 'roadtrip.alt.noMotorway': '高速道路なし',
+ 'roadtrip.alt.noToll': '有料道路なし',
+ 'roadtrip.summary.partial': '残りの行程を計算しています',
};
export default roadtrip;
diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts
index d15d00871..a83647e21 100644
--- a/shared/src/i18n/ja/settings.ts
+++ b/shared/src/i18n/ja/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': '地図テンプレート',
'settings.mapTemplatePlaceholder.select': 'テンプレートを選択…',
'settings.mapDefaultHint': '空欄の場合は OpenStreetMap(既定)を使用',
+ 'settings.routingBase': '自前のルーティングサーバー',
+ 'settings.routingBaseHint': '自前の OSRM インスタンス。空欄なら公開サーバーを使いますが、毎秒 1 リクエスト程度の制限があり、1 日分には足りてもロードトリップには足りません。サーバー再起動後に反映されます。',
'settings.mapHint': '地図タイルのURLテンプレート',
'settings.mapProvider': '地図プロバイダー',
'settings.mapProviderHint': '旅程プランナーと日記地図に影響します。Atlas は常に Leaflet を使用します。',
diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts
index 371742bd5..522a8155e 100644
--- a/shared/src/i18n/ko/roadtrip.ts
+++ b/shared/src/i18n/ko/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': '식사',
'roadtrip.poi.sights': '볼거리',
'roadtrip.poi.within': '반경',
+ 'roadtrip.poi.filter': '이름으로 필터',
+ 'roadtrip.poi.noMatch': '경로상에 “{name}”와(과) 일치하는 곳이 없습니다.',
+ 'roadtrip.poi.foundFiltered': '경로상 {total}곳 중 {count}곳',
+ 'roadtrip.poi.truncated': '{count}개 구간에서 한 번에 반환할 수 있는 수를 넘었습니다. 회랑을 좁히면 나머지를 볼 수 있습니다.',
'roadtrip.poi.search': '경로 주변 검색',
'roadtrip.poi.searching': '검색 중 {done}/{total}',
'roadtrip.poi.capped': '경로가 길어 앞부분만 검색했습니다.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': '{distance} 지점',
'roadtrip.summary.distance': '거리',
'roadtrip.summary.driving': '운전 시간',
+ 'roadtrip.alt.ask': '다른 경로',
+ 'roadtrip.alt.title': '이 구간을 가는 방법',
+ 'roadtrip.alt.loading': '경로 서버에 묻는 중…',
+ 'roadtrip.alt.onlyOne': '이 구간에는 합리적인 경로가 하나뿐입니다.',
+ 'roadtrip.alt.failed': '경로 서버가 응답하지 않습니다.',
+ 'roadtrip.alt.current': '현재',
+ 'roadtrip.alt.fastest': '가장 빠름',
+ 'roadtrip.alt.slower': '{time} 더 걸림',
+ 'roadtrip.alt.quicker': '{time} 더 빠름',
'roadtrip.day': '{number}일차',
+ 'roadtrip.quietDay.empty': '아직 경유지가 없습니다. 여기에 놓으세요',
+ 'roadtrip.quietDay.one': '지금은 {name}뿐입니다. 경유지를 여기에 놓으세요',
+ 'roadtrip.leg.driveText': '{distance}, {time}',
+ 'roadtrip.day.stopCount': '{count}곳',
+ 'roadtrip.stop.stayShort': '체류',
'roadtrip.leg.pending': '경로 없음',
+ 'roadtrip.stop.addTitle': '경유지로 추가',
+ 'roadtrip.stop.kind': '경유지 종류',
+ 'roadtrip.stop.landsOn': '{day}일차, {position}번째 경유지',
+ 'roadtrip.stop.duplicate': '“{name}”은(는) 이미 이 여행에 있습니다.',
+ 'roadtrip.stop.moreDetails': '자세히 입력',
'roadtrip.stop.stay': '이 경유지 체류 시간',
'roadtrip.stop.pinned': '직접 지정한 시간',
'roadtrip.stop.computed': '주행 시간으로 계산됨',
+ 'roadtrip.via.hint': '끌어서 경로를 바꾸고, 오른쪽 클릭으로 삭제',
'roadtrip.warn.late': '지정한 시간보다 {minutes}분 늦게 도착',
+ 'roadtrip.warn.overnight': '다음 날',
'roadtrip.summary.stops': '경유지',
+ 'roadtrip.stay.add': '체류 시간 설정',
+ 'roadtrip.stay.none': '체류 없음',
+ 'roadtrip.stay.custom': '분',
+ 'roadtrip.stay.arrive': '도착',
+ 'roadtrip.stay.leave': '출발',
+ 'roadtrip.alt.noMotorway': '고속도로 제외',
+ 'roadtrip.alt.noToll': '통행료 없음',
+ 'roadtrip.summary.partial': '남은 구간을 계산하고 있습니다',
};
export default roadtrip;
diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts
index 27d268349..3243fa84e 100644
--- a/shared/src/i18n/ko/settings.ts
+++ b/shared/src/i18n/ko/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': '지도 템플릿',
'settings.mapTemplatePlaceholder.select': '템플릿 선택...',
'settings.mapDefaultHint': '비워두면 OpenStreetMap (기본값) 사용',
+ 'settings.routingBase': '자체 경로 서버',
+ 'settings.routingBaseHint': '자체 OSRM 인스턴스입니다. 비워 두면 초당 약 한 번의 요청만 허용하는 공개 서버를 사용합니다. 하루치는 충분하지만 로드트립에는 빠듯합니다. 서버를 다시 시작해야 적용됩니다.',
'settings.mapHint': '지도 타일 URL 템플릿',
'settings.mapProvider': '지도 공급자',
'settings.mapProviderHint': '여행 플래너 및 Journey 지도에 영향을 줍니다. Atlas는 항상 Leaflet을 사용합니다.',
diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts
index 25d47a5bf..034887065 100644
--- a/shared/src/i18n/nl/roadtrip.ts
+++ b/shared/src/i18n/nl/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Eten',
'roadtrip.poi.sights': 'Bezienswaardig',
'roadtrip.poi.within': 'Binnen',
+ 'roadtrip.poi.filter': 'Filteren op naam',
+ 'roadtrip.poi.noMatch': 'Niets onderweg komt overeen met “{name}”.',
+ '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.search': 'Langs de route zoeken',
'roadtrip.poi.searching': 'Zoeken {done} van {total}',
'roadtrip.poi.capped': 'De route is lang — alleen het eerste deel is doorzocht.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'na {distance}',
'roadtrip.summary.distance': 'Afstand',
'roadtrip.summary.driving': 'Rijtijd',
+ 'roadtrip.alt.ask': 'Andere routes',
+ 'roadtrip.alt.title': 'Routes voor dit traject',
+ 'roadtrip.alt.loading': 'Router wordt geraadpleegd…',
+ 'roadtrip.alt.onlyOne': 'Er is maar één zinnige route voor dit traject.',
+ 'roadtrip.alt.failed': 'De routeserver antwoordt op dit moment niet.',
+ 'roadtrip.alt.current': 'Huidig',
+ 'roadtrip.alt.fastest': 'Snelste',
+ 'roadtrip.alt.slower': '{time} langzamer',
+ 'roadtrip.alt.quicker': '{time} sneller',
'roadtrip.day': 'Dag {number}',
+ 'roadtrip.quietDay.empty': 'Nog geen stops — laat er hier een vallen',
+ 'roadtrip.quietDay.one': 'Voorlopig alleen {name} — laat hier een stop vallen',
+ 'roadtrip.leg.driveText': '{distance} in {time}',
+ 'roadtrip.day.stopCount': '{count} stops',
+ 'roadtrip.stop.stayShort': 'Duur',
'roadtrip.leg.pending': 'Geen route',
+ 'roadtrip.stop.addTitle': 'Als stop toevoegen',
+ 'roadtrip.stop.kind': 'Soort stop',
+ 'roadtrip.stop.landsOn': 'Dag {day}, als stop {position}',
+ 'roadtrip.stop.duplicate': '“{name}” staat al in deze reis.',
+ 'roadtrip.stop.moreDetails': 'Meer details',
'roadtrip.stop.stay': 'Tijd bij deze stop',
'roadtrip.stop.pinned': 'Door jou gezette tijd',
'roadtrip.stop.computed': 'Berekend uit de rit',
+ 'roadtrip.via.hint': 'Sleep om de route te hervormen, rechtsklik om te verwijderen',
'roadtrip.warn.late': 'Aankomst {minutes} min na de gezette tijd',
+ 'roadtrip.warn.overnight': 'Volgende dag',
'roadtrip.summary.stops': 'Stops',
+ 'roadtrip.stay.add': 'Verblijf instellen',
+ 'roadtrip.stay.none': 'Geen verblijf',
+ 'roadtrip.stay.custom': 'Minuten',
+ 'roadtrip.stay.arrive': 'Aankomst',
+ 'roadtrip.stay.leave': 'Vertrek',
+ 'roadtrip.alt.noMotorway': 'Zonder snelweg',
+ 'roadtrip.alt.noToll': 'Zonder tol',
+ 'roadtrip.summary.partial': 'De rest van de rit wordt nog berekend',
};
export default roadtrip;
diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts
index 1787cb473..3d3603853 100644
--- a/shared/src/i18n/nl/settings.ts
+++ b/shared/src/i18n/nl/settings.ts
@@ -25,6 +25,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Kaartsjabloon',
'settings.mapTemplatePlaceholder.select': 'Selecteer sjabloon...',
'settings.mapDefaultHint': 'Laat leeg voor OpenStreetMap (standaard)',
+ 'settings.routingBase': 'Eigen routeserver',
+ 'settings.routingBaseHint': 'Een eigen OSRM-server. Leeg gebruikt de publieke servers, die ongeveer één verzoek per seconde toestaan — genoeg voor een dag, krap voor een roadtrip. Werkt na een herstart van de server.',
'settings.mapHint': 'URL-sjabloon voor kaarttegels',
'settings.mapProvider': 'Kaartprovider',
'settings.mapProviderHint': 'Geldt voor Trip Planner en Journey kaarten. Atlas gebruikt altijd Leaflet.',
diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts
index 44de0a0c4..f64099a35 100644
--- a/shared/src/i18n/pl/roadtrip.ts
+++ b/shared/src/i18n/pl/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Jedzenie',
'roadtrip.poi.sights': 'Atrakcje',
'roadtrip.poi.within': 'W promieniu',
+ 'roadtrip.poi.filter': 'Filtruj wedug nazwy',
+ 'roadtrip.poi.noMatch': 'Nic po drodze nie pasuje do „{name}”.',
+ '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.search': 'Szukaj wzdłuż trasy',
'roadtrip.poi.searching': 'Szukanie {done} z {total}',
'roadtrip.poi.capped': 'Trasa jest długa — przeszukano tylko pierwszy odcinek.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'po {distance}',
'roadtrip.summary.distance': 'Dystans',
'roadtrip.summary.driving': 'Czas jazdy',
+ 'roadtrip.alt.ask': 'Inne trasy',
+ 'roadtrip.alt.title': 'Trasy tego odcinka',
+ 'roadtrip.alt.loading': 'Pytanie serwera tras…',
+ 'roadtrip.alt.onlyOne': 'Ten odcinek ma tylko jedną sensowną trasę.',
+ 'roadtrip.alt.failed': 'Serwer tras teraz nie odpowiada.',
+ 'roadtrip.alt.current': 'Obecna',
+ 'roadtrip.alt.fastest': 'Najszybsza',
+ 'roadtrip.alt.slower': '{time} dłużej',
+ 'roadtrip.alt.quicker': '{time} krócej',
'roadtrip.day': 'Dzień {number}',
+ 'roadtrip.quietDay.empty': 'Brak przystanków — upuść tutaj',
+ 'roadtrip.quietDay.one': 'Na razie tylko {name} — upuść tutaj przystanek',
+ 'roadtrip.leg.driveText': '{distance} w {time}',
+ 'roadtrip.day.stopCount': '{count} przystanków',
+ 'roadtrip.stop.stayShort': 'Postój',
'roadtrip.leg.pending': 'Brak trasy',
+ 'roadtrip.stop.addTitle': 'Dodaj jako przystanek',
+ 'roadtrip.stop.kind': 'Rodzaj przystanku',
+ 'roadtrip.stop.landsOn': 'Dzień {day}, jako przystanek {position}',
+ 'roadtrip.stop.duplicate': '„{name}” już jest w tej podróży.',
+ 'roadtrip.stop.moreDetails': 'Więcej szczegółów',
'roadtrip.stop.stay': 'Czas na tym przystanku',
'roadtrip.stop.pinned': 'Godzina ustawiona przez Ciebie',
'roadtrip.stop.computed': 'Obliczone na podstawie jazdy',
+ 'roadtrip.via.hint': 'Przeciągnij, aby zmienić trasę, kliknij prawym, aby usunąć',
'roadtrip.warn.late': 'Przyjazd {minutes} min po ustawionej godzinie',
+ 'roadtrip.warn.overnight': 'Następny dzień',
'roadtrip.summary.stops': 'Przystanki',
+ 'roadtrip.stay.add': 'Ustaw czas postoju',
+ 'roadtrip.stay.none': 'Bez postoju',
+ 'roadtrip.stay.custom': 'Minuty',
+ 'roadtrip.stay.arrive': 'Przyjazd',
+ 'roadtrip.stay.leave': 'Odjazd',
+ 'roadtrip.alt.noMotorway': 'Bez autostrady',
+ 'roadtrip.alt.noToll': 'Bez oplat',
+ 'roadtrip.summary.partial': 'Reszta trasy jest jeszcze obliczana',
};
export default roadtrip;
diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts
index 4cb6d06fc..70ef0d057 100644
--- a/shared/src/i18n/pl/settings.ts
+++ b/shared/src/i18n/pl/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Szablon mapy',
'settings.mapTemplatePlaceholder.select': 'Wybierz szablon...',
'settings.mapDefaultHint': 'Pozostaw puste dla OpenStreetMap (domyślnie)',
+ 'settings.routingBase': 'Własny silnik tras',
+ 'settings.routingBaseHint': 'Własna instancja OSRM. Puste używa serwerów publicznych, które pozwalają na około jedno zapytanie na sekundę — wystarczy na dzień, mało na road trip. Działa po restarcie serwera.',
'settings.mapHint': 'Szablon URL dla kafelków mapy',
'settings.mapProvider': 'Dostawca mapy',
'settings.mapProviderHint': 'Dotyczy map Trip Planner i Journey. Atlas zawsze używa Leaflet.',
diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts
index 4ec78dbdb..ca8739436 100644
--- a/shared/src/i18n/ru/roadtrip.ts
+++ b/shared/src/i18n/ru/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Еда',
'roadtrip.poi.sights': 'Достопримечательности',
'roadtrip.poi.within': 'В радиусе',
+ 'roadtrip.poi.filter': 'Фильтр по названию',
+ 'roadtrip.poi.noMatch': 'По пути ничего не соответствует «{name}».',
+ 'roadtrip.poi.foundFiltered': '{count} из {total} по пути',
+ 'roadtrip.poi.truncated': 'На {count} участках нашлось больше, чем помещается в один ответ — сузьте коридор, чтобы увидеть остальное.',
'roadtrip.poi.search': 'Искать вдоль маршрута',
'roadtrip.poi.searching': 'Поиск {done} из {total}',
'roadtrip.poi.capped': 'Маршрут длинный — просмотрен только первый участок.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'через {distance}',
'roadtrip.summary.distance': 'Расстояние',
'roadtrip.summary.driving': 'Время в пути',
+ 'roadtrip.alt.ask': 'Другие маршруты',
+ 'roadtrip.alt.title': 'Маршруты этого участка',
+ 'roadtrip.alt.loading': 'Запрос к маршрутизатору…',
+ 'roadtrip.alt.onlyOne': 'У этого участка только один разумный маршрут.',
+ 'roadtrip.alt.failed': 'Маршрутизатор сейчас не отвечает.',
+ 'roadtrip.alt.current': 'Текущий',
+ 'roadtrip.alt.fastest': 'Самый быстрый',
+ 'roadtrip.alt.slower': 'на {time} дольше',
+ 'roadtrip.alt.quicker': 'на {time} быстрее',
'roadtrip.day': 'День {number}',
+ 'roadtrip.quietDay.empty': 'Остановок пока нет — перетащите сюда',
+ 'roadtrip.quietDay.one': 'Пока только {name} — перетащите сюда остановку',
+ 'roadtrip.leg.driveText': '{distance} за {time}',
+ 'roadtrip.day.stopCount': 'остановок: {count}',
+ 'roadtrip.stop.stayShort': 'Стоянка',
'roadtrip.leg.pending': 'Нет маршрута',
+ 'roadtrip.stop.addTitle': 'Добавить как остановку',
+ 'roadtrip.stop.kind': 'Тип остановки',
+ 'roadtrip.stop.landsOn': 'День {day}, остановка {position}',
+ 'roadtrip.stop.duplicate': '«{name}» уже есть в этой поездке.',
+ 'roadtrip.stop.moreDetails': 'Подробнее',
'roadtrip.stop.stay': 'Время на остановке',
'roadtrip.stop.pinned': 'Заданное вами время',
'roadtrip.stop.computed': 'Рассчитано по времени в пути',
+ 'roadtrip.via.hint': 'Перетащите, чтобы изменить маршрут, правый клик — удалить',
'roadtrip.warn.late': 'Прибытие на {minutes} мин позже заданного времени',
+ 'roadtrip.warn.overnight': 'Следующий день',
'roadtrip.summary.stops': 'Остановки',
+ 'roadtrip.stay.add': 'Задать время остановки',
+ 'roadtrip.stay.none': 'Без остановки',
+ 'roadtrip.stay.custom': 'Минуты',
+ 'roadtrip.stay.arrive': 'Прибытие',
+ 'roadtrip.stay.leave': 'Отправление',
+ 'roadtrip.alt.noMotorway': 'Без автомагистрали',
+ 'roadtrip.alt.noToll': 'Без платных дорог',
+ 'roadtrip.summary.partial': 'Остальная часть маршрута ещё рассчитывается',
};
export default roadtrip;
diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts
index 43c6982c0..d0df5d67c 100644
--- a/shared/src/i18n/ru/settings.ts
+++ b/shared/src/i18n/ru/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Шаблон карты',
'settings.mapTemplatePlaceholder.select': 'Выберите шаблон...',
'settings.mapDefaultHint': 'Оставьте пустым для OpenStreetMap (по умолчанию)',
+ 'settings.routingBase': 'Собственный сервер маршрутов',
+ 'settings.routingBaseHint': 'Собственный экземпляр OSRM. Пусто — используются публичные серверы с ограничением около одного запроса в секунду: на день хватает, для автопутешествия мало. Вступает в силу после перезапуска сервера.',
'settings.mapHint': 'URL-шаблон для тайлов карты',
'settings.mapProvider': 'Провайдер карты',
'settings.mapProviderHint': 'Применяется к Trip Planner и Journey. Atlas всегда использует Leaflet.',
diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts
index a17020134..7c4ab7bd0 100644
--- a/shared/src/i18n/sv/roadtrip.ts
+++ b/shared/src/i18n/sv/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Mat',
'roadtrip.poi.sights': 'Sevärt',
'roadtrip.poi.within': 'Inom',
+ 'roadtrip.poi.filter': 'Filtrera på namn',
+ 'roadtrip.poi.noMatch': 'Inget längs vägen matchar ”{name}”.',
+ '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.search': 'Sök längs rutten',
'roadtrip.poi.searching': 'Söker {done} av {total}',
'roadtrip.poi.capped': 'Rutten är lång – bara första sträckan genomsöktes.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'efter {distance}',
'roadtrip.summary.distance': 'Sträcka',
'roadtrip.summary.driving': 'Körtid',
+ 'roadtrip.alt.ask': 'Andra vägar',
+ 'roadtrip.alt.title': 'Vägar för den här sträckan',
+ 'roadtrip.alt.loading': 'Frågar ruttservern…',
+ 'roadtrip.alt.onlyOne': 'Det finns bara en rimlig väg för den här sträckan.',
+ 'roadtrip.alt.failed': 'Ruttservern svarar inte just nu.',
+ 'roadtrip.alt.current': 'Nuvarande',
+ 'roadtrip.alt.fastest': 'Snabbast',
+ 'roadtrip.alt.slower': '{time} långsammare',
+ 'roadtrip.alt.quicker': '{time} snabbare',
'roadtrip.day': 'Dag {number}',
+ 'roadtrip.quietDay.empty': 'Inga stopp än – släpp ett här',
+ 'roadtrip.quietDay.one': 'Hittills bara {name} – släpp ett stopp här',
+ 'roadtrip.leg.driveText': '{distance} på {time}',
+ 'roadtrip.day.stopCount': '{count} stopp',
+ 'roadtrip.stop.stayShort': 'Stopp',
'roadtrip.leg.pending': 'Ingen rutt',
+ 'roadtrip.stop.addTitle': 'Lägg till som stopp',
+ 'roadtrip.stop.kind': 'Typ av stopp',
+ 'roadtrip.stop.landsOn': 'Dag {day}, som stopp {position}',
+ 'roadtrip.stop.duplicate': '”{name}” finns redan i den här resan.',
+ 'roadtrip.stop.moreDetails': 'Fler detaljer',
'roadtrip.stop.stay': 'Tid på stoppet',
'roadtrip.stop.pinned': 'Tid du har satt',
'roadtrip.stop.computed': 'Beräknad utifrån körningen',
+ 'roadtrip.via.hint': 'Dra för att forma om rutten, högerklicka för att ta bort',
'roadtrip.warn.late': 'Ankomst {minutes} min efter den satta tiden',
+ 'roadtrip.warn.overnight': 'Nästa dag',
'roadtrip.summary.stops': 'Stopp',
+ 'roadtrip.stay.add': 'Ange vistelse',
+ 'roadtrip.stay.none': 'Ingen vistelse',
+ 'roadtrip.stay.custom': 'Minuter',
+ 'roadtrip.stay.arrive': 'Ankomst',
+ 'roadtrip.stay.leave': 'Avfard',
+ 'roadtrip.alt.noMotorway': 'Utan motorvag',
+ 'roadtrip.alt.noToll': 'Utan vagavgifter',
+ 'roadtrip.summary.partial': 'Resten av resan beräknas fortfarande',
};
export default roadtrip;
diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts
index 4a9032ed2..c95264e02 100644
--- a/shared/src/i18n/sv/settings.ts
+++ b/shared/src/i18n/sv/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Kartmall',
'settings.mapTemplatePlaceholder.select': 'Välj mall...',
'settings.mapDefaultHint': 'Lämna fältet tomt för OpenStreetMap (standard)',
+ 'settings.routingBase': 'Egen ruttmotor',
+ 'settings.routingBaseHint': 'En egen OSRM-instans. Tomt använder de publika servrarna, som tillåter ungefär en förfrågan per sekund – nog för en dag, knappt för en bilresa. Träder i kraft efter omstart av servern.',
'settings.mapHint': 'URL-mall för kartrutor',
'settings.mapProvider': 'Kartleverantör',
'settings.mapProviderHint': 'Påverkar resplaneraren och resedagbokens kartor. Atlas använder alltid Leaflet.',
diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts
index aed933968..34e889c4b 100644
--- a/shared/src/i18n/tr/roadtrip.ts
+++ b/shared/src/i18n/tr/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Yemek',
'roadtrip.poi.sights': 'Görülecek yerler',
'roadtrip.poi.within': 'Mesafe',
+ 'roadtrip.poi.filter': 'Ada göre süz',
+ 'roadtrip.poi.noMatch': 'Yol boyunca “{name}” ile eşleşen bir şey 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.search': 'Rota boyunca ara',
'roadtrip.poi.searching': 'Aranıyor {done}/{total}',
'roadtrip.poi.capped': 'Rota uzun — yalnızca ilk bölüm arandı.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': '{distance} sonra',
'roadtrip.summary.distance': 'Mesafe',
'roadtrip.summary.driving': 'Sürüş süresi',
+ 'roadtrip.alt.ask': 'Başka yollar',
+ 'roadtrip.alt.title': 'Bu etap için yollar',
+ 'roadtrip.alt.loading': 'Rota sunucusuna soruluyor…',
+ 'roadtrip.alt.onlyOne': 'Bu etap için mantıklı tek bir yol var.',
+ 'roadtrip.alt.failed': 'Rota sunucusu şu anda yanıt vermiyor.',
+ 'roadtrip.alt.current': 'Mevcut',
+ 'roadtrip.alt.fastest': 'En hızlı',
+ 'roadtrip.alt.slower': '{time} daha uzun',
+ 'roadtrip.alt.quicker': '{time} daha kısa',
'roadtrip.day': '{number}. gün',
+ 'roadtrip.quietDay.empty': 'Henüz mola yok — buraya bırakın',
+ 'roadtrip.quietDay.one': 'Şimdilik yalnızca {name} — buraya bir mola bırakın',
+ 'roadtrip.leg.driveText': '{time} içinde {distance}',
+ 'roadtrip.day.stopCount': '{count} mola',
+ 'roadtrip.stop.stayShort': 'Süre',
'roadtrip.leg.pending': 'Rota yok',
+ 'roadtrip.stop.addTitle': 'Mola olarak ekle',
+ 'roadtrip.stop.kind': 'Mola türü',
+ 'roadtrip.stop.landsOn': '{day}. gün, {position}. mola',
+ 'roadtrip.stop.duplicate': '“{name}” bu seyahatte zaten var.',
+ 'roadtrip.stop.moreDetails': 'Daha fazla ayrıntı',
'roadtrip.stop.stay': 'Bu duraktaki süre',
'roadtrip.stop.pinned': 'Belirlediğiniz saat',
'roadtrip.stop.computed': 'Sürüşe göre hesaplandı',
+ 'roadtrip.via.hint': 'Rotayı yeniden şekillendirmek için sürükleyin, kaldırmak için sağ tıklayın',
'roadtrip.warn.late': 'Belirlenen saatten {minutes} dk sonra varış',
+ 'roadtrip.warn.overnight': 'Ertesi gün',
'roadtrip.summary.stops': 'Duraklar',
+ 'roadtrip.stay.add': 'Mola suresi ekle',
+ 'roadtrip.stay.none': 'Mola yok',
+ 'roadtrip.stay.custom': 'Dakika',
+ 'roadtrip.stay.arrive': 'Varis',
+ 'roadtrip.stay.leave': 'Kalkis',
+ 'roadtrip.alt.noMotorway': 'Otoyolsuz',
+ 'roadtrip.alt.noToll': 'Ucretsiz',
+ 'roadtrip.summary.partial': 'Yolun geri kalanı hâlâ hesaplanıyor',
};
export default roadtrip;
diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts
index e970d24d1..d7a79a241 100644
--- a/shared/src/i18n/tr/settings.ts
+++ b/shared/src/i18n/tr/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Harita Şablonu',
'settings.mapTemplatePlaceholder.select': 'Şablon seçin...',
'settings.mapDefaultHint': 'OpenStreetMap için boş bırakın (varsayılan)',
+ 'settings.routingBase': 'Kendi rota motoru',
+ 'settings.routingBaseHint': 'Kendi OSRM sunucunuz. Boş bırakılırsa saniyede yaklaşık bir istek veren genel sunucular kullanılır: bir gün için yeter, uzun yolculuk için az. Sunucu yeniden başlatıldığında etkin olur.',
'settings.mapHint': 'Harita kutucukları için URL şablonu',
'settings.mapProvider': 'Harita Sağlayıcısı',
'settings.mapProviderHint': 'Seyahat planlayıcı ve Journey haritalarını etkiler. Atlas her zaman Leaflet kullanır.',
diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts
index 55f37fb24..60d4919ca 100644
--- a/shared/src/i18n/uk/roadtrip.ts
+++ b/shared/src/i18n/uk/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Їжа',
'roadtrip.poi.sights': 'Пам’ятки',
'roadtrip.poi.within': 'У радіусі',
+ 'roadtrip.poi.filter': 'Фільтр за назвою',
+ 'roadtrip.poi.noMatch': 'Дорогою ніщо не відповідає «{name}».',
+ 'roadtrip.poi.foundFiltered': '{count} з {total} дорогою',
+ 'roadtrip.poi.truncated': 'На {count} відрізках знайшлося більше, ніж уміщується в одну відповідь — звузьте коридор, щоб побачити решту.',
'roadtrip.poi.search': 'Шукати вздовж маршруту',
'roadtrip.poi.searching': 'Пошук {done} з {total}',
'roadtrip.poi.capped': 'Маршрут довгий — переглянуто лише перший відрізок.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'через {distance}',
'roadtrip.summary.distance': 'Відстань',
'roadtrip.summary.driving': 'Час у дорозі',
+ 'roadtrip.alt.ask': 'Інші маршрути',
+ 'roadtrip.alt.title': 'Маршрути цього відрізка',
+ 'roadtrip.alt.loading': 'Запит до маршрутизатора…',
+ 'roadtrip.alt.onlyOne': 'Для цього відрізка є лише один розумний маршрут.',
+ 'roadtrip.alt.failed': 'Маршрутизатор зараз не відповідає.',
+ 'roadtrip.alt.current': 'Поточний',
+ 'roadtrip.alt.fastest': 'Найшвидший',
+ 'roadtrip.alt.slower': 'на {time} довше',
+ 'roadtrip.alt.quicker': 'на {time} швидше',
'roadtrip.day': 'День {number}',
+ 'roadtrip.quietDay.empty': 'Зупинок ще немає — перетягніть сюди',
+ 'roadtrip.quietDay.one': 'Поки лише {name} — перетягніть сюди зупинку',
+ 'roadtrip.leg.driveText': '{distance} за {time}',
+ 'roadtrip.day.stopCount': 'зупинок: {count}',
+ 'roadtrip.stop.stayShort': 'Стоянка',
'roadtrip.leg.pending': 'Немає маршруту',
+ 'roadtrip.stop.addTitle': 'Додати як зупинку',
+ 'roadtrip.stop.kind': 'Тип зупинки',
+ 'roadtrip.stop.landsOn': 'День {day}, зупинка {position}',
+ 'roadtrip.stop.duplicate': '«{name}» уже є в цій подорожі.',
+ 'roadtrip.stop.moreDetails': 'Докладніше',
'roadtrip.stop.stay': 'Час на зупинці',
'roadtrip.stop.pinned': 'Час, який ви задали',
'roadtrip.stop.computed': 'Розраховано за часом у дорозі',
+ 'roadtrip.via.hint': 'Перетягніть, щоб змінити маршрут, права кнопка — видалити',
'roadtrip.warn.late': 'Прибуття на {minutes} хв пізніше заданого часу',
+ 'roadtrip.warn.overnight': 'Наступний день',
'roadtrip.summary.stops': 'Зупинки',
+ 'roadtrip.stay.add': 'Задати час зупинки',
+ 'roadtrip.stay.none': 'Без зупинки',
+ 'roadtrip.stay.custom': 'Хвилини',
+ 'roadtrip.stay.arrive': 'Прибуття',
+ 'roadtrip.stay.leave': 'Відʼїзд',
+ 'roadtrip.alt.noMotorway': 'Без автомагістралі',
+ 'roadtrip.alt.noToll': 'Без платних доріг',
+ 'roadtrip.summary.partial': 'Решта маршруту ще обчислюється',
};
export default roadtrip;
diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts
index e19657e58..3872d1689 100644
--- a/shared/src/i18n/uk/settings.ts
+++ b/shared/src/i18n/uk/settings.ts
@@ -25,6 +25,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Шаблон карти',
'settings.mapTemplatePlaceholder.select': 'Виберіть шаблон...',
'settings.mapDefaultHint': 'Залиште порожнім для OpenStreetMap (за замовчуванням)',
+ 'settings.routingBase': 'Власний сервер маршрутів',
+ 'settings.routingBaseHint': 'Власний примірник OSRM. Порожньо — використовуються публічні сервери з обмеженням близько одного запиту на секунду: на день вистачає, для автоподорожі мало. Діє після перезапуску сервера.',
'settings.mapHint': 'URL-шаблон для тайлів карти',
'settings.mapProvider': 'Провайдер карти',
'settings.mapProviderHint': 'Застосовується до Trip Planner та Journey. Atlas завжди використовує Leaflet.',
diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts
index b72dd3217..e1fcdc11d 100644
--- a/shared/src/i18n/vi/roadtrip.ts
+++ b/shared/src/i18n/vi/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': 'Đồ ăn',
'roadtrip.poi.sights': 'Điểm tham quan',
'roadtrip.poi.within': 'Trong vòng',
+ 'roadtrip.poi.filter': 'Lọc theo tên',
+ 'roadtrip.poi.noMatch': 'Không có gì trên đường khớp với “{name}”.',
+ '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.search': 'Tìm dọc lộ trình',
'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.',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': 'sau {distance}',
'roadtrip.summary.distance': 'Quãng đường',
'roadtrip.summary.driving': 'Thời gian lái xe',
+ 'roadtrip.alt.ask': 'Lộ trình khác',
+ 'roadtrip.alt.title': 'Các lộ trình cho chặng này',
+ 'roadtrip.alt.loading': 'Đang hỏi máy chủ định tuyến…',
+ 'roadtrip.alt.onlyOne': 'Chặng này chỉ có một lộ trình hợp lý.',
+ 'roadtrip.alt.failed': 'Máy chủ định tuyến hiện không phản hồi.',
+ 'roadtrip.alt.current': 'Hiện tại',
+ 'roadtrip.alt.fastest': 'Nhanh nhất',
+ 'roadtrip.alt.slower': 'chậm hơn {time}',
+ 'roadtrip.alt.quicker': 'nhanh hơn {time}',
'roadtrip.day': 'Ngày {number}',
+ 'roadtrip.quietDay.empty': 'Chưa có điểm dừng — thả vào đây',
+ 'roadtrip.quietDay.one': 'Hiện chỉ có {name} — thả một điểm dừng vào đây',
+ 'roadtrip.leg.driveText': '{distance} trong {time}',
+ 'roadtrip.day.stopCount': '{count} điểm dừng',
+ 'roadtrip.stop.stayShort': 'Dừng',
'roadtrip.leg.pending': 'Không có lộ trình',
+ 'roadtrip.stop.addTitle': 'Thêm làm điểm dừng',
+ 'roadtrip.stop.kind': 'Loại điểm dừng',
+ 'roadtrip.stop.landsOn': 'Ngày {day}, là điểm dừng {position}',
+ 'roadtrip.stop.duplicate': '“{name}” đã có trong chuyến đi này.',
+ 'roadtrip.stop.moreDetails': 'Thêm chi tiết',
'roadtrip.stop.stay': 'Thời gian tại điểm dừng',
'roadtrip.stop.pinned': 'Giờ bạn đặt',
'roadtrip.stop.computed': 'Tính từ thời gian lái xe',
+ 'roadtrip.via.hint': 'Kéo để định hình lại lộ trình, chuột phải để xóa',
'roadtrip.warn.late': 'Đến muộn {minutes} phút so với giờ đã đặt',
+ 'roadtrip.warn.overnight': 'Ngày hôm sau',
'roadtrip.summary.stops': 'Điểm dừng',
+ 'roadtrip.stay.add': 'Dat thoi gian dung',
+ 'roadtrip.stay.none': 'Khong dung lai',
+ 'roadtrip.stay.custom': 'Phut',
+ 'roadtrip.stay.arrive': 'Den noi',
+ 'roadtrip.stay.leave': 'Roi di',
+ 'roadtrip.alt.noMotorway': 'Khong di cao toc',
+ 'roadtrip.alt.noToll': 'Khong thu phi',
+ 'roadtrip.summary.partial': 'Vẫn đang tính phần còn lại của chặng đường',
};
export default roadtrip;
diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts
index 64701fb64..79b4afbc0 100644
--- a/shared/src/i18n/vi/settings.ts
+++ b/shared/src/i18n/vi/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': 'Mẫu bản đồ',
'settings.mapTemplatePlaceholder.select': 'Chọn mẫu...',
'settings.mapDefaultHint': 'Để trống cho OpenStreetMap (mặc định)',
+ 'settings.routingBase': 'Máy chủ định tuyến riêng',
+ 'settings.routingBaseHint': 'Máy chủ OSRM của riêng bạn. Để trống sẽ dùng máy chủ công cộng, cho phép khoảng một yêu cầu mỗi giây — đủ cho một ngày, chật cho chuyến đi dài. Có hiệu lực sau khi khởi động lại máy chủ.',
'settings.mapHint': 'Mẫu URL cho ô bản đồ',
'settings.mapProvider': 'Nhà cung cấp bản đồ',
'settings.mapProviderHint':
diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts
index efba5d689..327107e8f 100644
--- a/shared/src/i18n/zh-TW/roadtrip.ts
+++ b/shared/src/i18n/zh-TW/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': '餐飲',
'roadtrip.poi.sights': '景點',
'roadtrip.poi.within': '範圍內',
+ 'roadtrip.poi.filter': '依名稱篩選',
+ 'roadtrip.poi.noMatch': '沿途沒有與「{name}」相符的地點。',
+ 'roadtrip.poi.foundFiltered': '沿途 {total} 個中的 {count} 個',
+ 'roadtrip.poi.truncated': '有 {count} 段的結果超出單次回應上限,縮小走廊寬度可查看其餘部分。',
'roadtrip.poi.search': '沿路線搜尋',
'roadtrip.poi.searching': '搜尋中 {done}/{total}',
'roadtrip.poi.capped': '路線較長,僅搜尋了前段。',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': '{distance} 處',
'roadtrip.summary.distance': '距離',
'roadtrip.summary.driving': '駕駛時間',
+ 'roadtrip.alt.ask': '其他路線',
+ 'roadtrip.alt.title': '這一段的走法',
+ 'roadtrip.alt.loading': '正在詢問路徑服務…',
+ 'roadtrip.alt.onlyOne': '這一段只有一條合理的走法。',
+ 'roadtrip.alt.failed': '路徑服務目前沒有回應。',
+ 'roadtrip.alt.current': '目前',
+ 'roadtrip.alt.fastest': '最快',
+ 'roadtrip.alt.slower': '慢 {time}',
+ 'roadtrip.alt.quicker': '快 {time}',
'roadtrip.day': '第 {number} 天',
+ 'roadtrip.quietDay.empty': '尚無停靠點 — 拖到這裡',
+ 'roadtrip.quietDay.one': '目前只有 {name} — 把停靠點拖到這裡',
+ 'roadtrip.leg.driveText': '{distance},{time}',
+ 'roadtrip.day.stopCount': '{count} 個停靠點',
+ 'roadtrip.stop.stayShort': '停留',
'roadtrip.leg.pending': '無路線',
+ 'roadtrip.stop.addTitle': '新增為停靠點',
+ 'roadtrip.stop.kind': '停靠類型',
+ 'roadtrip.stop.landsOn': '第 {day} 天,第 {position} 個停靠點',
+ 'roadtrip.stop.duplicate': '「{name}」已在此行程中。',
+ 'roadtrip.stop.moreDetails': '更多詳情',
'roadtrip.stop.stay': '在此停留時間',
'roadtrip.stop.pinned': '你設定的時間',
'roadtrip.stop.computed': '依行駛時間推算',
+ 'roadtrip.via.hint': '拖曳可改變路線,右鍵刪除',
'roadtrip.warn.late': '比設定時間晚到 {minutes} 分鐘',
+ 'roadtrip.warn.overnight': '次日',
'roadtrip.summary.stops': '停靠點',
+ 'roadtrip.stay.add': '設定停留時間',
+ 'roadtrip.stay.none': '不停留',
+ 'roadtrip.stay.custom': '分鐘',
+ 'roadtrip.stay.arrive': '抵達',
+ 'roadtrip.stay.leave': '出發',
+ 'roadtrip.alt.noMotorway': '不走高速',
+ 'roadtrip.alt.noToll': '免收費',
+ 'roadtrip.summary.partial': '正在計算剩餘路程',
};
export default roadtrip;
diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts
index d56de9a6c..84a18f0fb 100644
--- a/shared/src/i18n/zh-TW/settings.ts
+++ b/shared/src/i18n/zh-TW/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': '地圖模板',
'settings.mapTemplatePlaceholder.select': '選擇模板...',
'settings.mapDefaultHint': '留空則使用 OpenStreetMap(預設)',
+ 'settings.routingBase': '自建路徑規劃服務',
+ 'settings.routingBaseHint': '你自己的 OSRM 實例。留空則使用公共伺服器,約每秒一個請求 — 單日夠用,長途自駕則吃緊。需重新啟動伺服器後生效。',
'settings.mapHint': '地圖瓦片 URL 模板',
'settings.mapProvider': '地圖提供商',
'settings.mapProviderHint': '影響行程規劃和旅程地圖。Atlas 始終使用 Leaflet。',
diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts
index f0e988b5b..96a835fdd 100644
--- a/shared/src/i18n/zh/roadtrip.ts
+++ b/shared/src/i18n/zh/roadtrip.ts
@@ -22,6 +22,10 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.food': '餐饮',
'roadtrip.poi.sights': '景点',
'roadtrip.poi.within': '范围内',
+ 'roadtrip.poi.filter': '按名称筛选',
+ 'roadtrip.poi.noMatch': '沿途没有与“{name}”匹配的地点。',
+ 'roadtrip.poi.foundFiltered': '沿途 {total} 个中的 {count} 个',
+ 'roadtrip.poi.truncated': '有 {count} 段的结果超出单次响应上限,缩小走廊宽度可查看其余部分。',
'roadtrip.poi.search': '沿路线搜索',
'roadtrip.poi.searching': '搜索中 {done}/{total}',
'roadtrip.poi.capped': '路线较长,仅搜索了前段。',
@@ -34,12 +38,41 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.alongRoute': '{distance} 处',
'roadtrip.summary.distance': '距离',
'roadtrip.summary.driving': '驾驶时间',
+ 'roadtrip.alt.ask': '其他路线',
+ 'roadtrip.alt.title': '这一段的走法',
+ 'roadtrip.alt.loading': '正在询问路径服务…',
+ 'roadtrip.alt.onlyOne': '这一段只有一条合理的走法。',
+ 'roadtrip.alt.failed': '路径服务当前没有响应。',
+ 'roadtrip.alt.current': '当前',
+ 'roadtrip.alt.fastest': '最快',
+ 'roadtrip.alt.slower': '慢 {time}',
+ 'roadtrip.alt.quicker': '快 {time}',
'roadtrip.day': '第 {number} 天',
+ 'roadtrip.quietDay.empty': '暂无停靠点 — 拖到这里',
+ 'roadtrip.quietDay.one': '目前只有 {name} — 把停靠点拖到这里',
+ 'roadtrip.leg.driveText': '{distance},{time}',
+ 'roadtrip.day.stopCount': '{count} 个停靠点',
+ 'roadtrip.stop.stayShort': '停留',
'roadtrip.leg.pending': '无路线',
+ 'roadtrip.stop.addTitle': '添加为停靠点',
+ 'roadtrip.stop.kind': '停靠类型',
+ 'roadtrip.stop.landsOn': '第 {day} 天,第 {position} 个停靠点',
+ 'roadtrip.stop.duplicate': '“{name}”已在此行程中。',
+ 'roadtrip.stop.moreDetails': '更多详情',
'roadtrip.stop.stay': '在此停留时间',
'roadtrip.stop.pinned': '你设定的时间',
'roadtrip.stop.computed': '根据行驶时间推算',
+ 'roadtrip.via.hint': '拖动可改变路线,右键删除',
'roadtrip.warn.late': '比设定时间晚到 {minutes} 分钟',
+ 'roadtrip.warn.overnight': '次日',
'roadtrip.summary.stops': '停靠点',
+ 'roadtrip.stay.add': '设置停留时间',
+ 'roadtrip.stay.none': '不停留',
+ 'roadtrip.stay.custom': '分钟',
+ 'roadtrip.stay.arrive': '到达',
+ 'roadtrip.stay.leave': '出发',
+ 'roadtrip.alt.noMotorway': '不走高速',
+ 'roadtrip.alt.noToll': '免收费',
+ 'roadtrip.summary.partial': '正在计算剩余路程',
};
export default roadtrip;
diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts
index ded1a161d..5c22b2542 100644
--- a/shared/src/i18n/zh/settings.ts
+++ b/shared/src/i18n/zh/settings.ts
@@ -24,6 +24,8 @@ const settings: TranslationStrings = {
'settings.mapTemplate': '地图模板',
'settings.mapTemplatePlaceholder.select': '选择模板...',
'settings.mapDefaultHint': '留空则使用 OpenStreetMap(默认)',
+ 'settings.routingBase': '自建路径规划服务',
+ 'settings.routingBaseHint': '你自己的 OSRM 实例。留空则使用公共服务器,约每秒一个请求 — 单日够用,长途自驾则吃紧。需重启服务器后生效。',
'settings.mapHint': '地图瓦片 URL 模板',
'settings.mapProvider': '地图提供商',
'settings.mapProviderHint': '影响行程规划器和旅程地图。足迹始终使用 Leaflet。',
diff --git a/shared/src/index.ts b/shared/src/index.ts
index 8a45e9d1c..f7c580386 100644
--- a/shared/src/index.ts
+++ b/shared/src/index.ts
@@ -37,6 +37,7 @@ export * from './day/note-colors';
export * from './assignment/assignment.schema';
export * from './place/place.schema';
export * from './place/place-match';
+export * from './roadtrip/roadtrip.schema';
export * from './place/track-colors';
export * from './collection/collection.schema';
export * from './trip/trip.schema';
diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts
index d0d63da28..df7f94d1c 100644
--- a/shared/src/place/place.schema.ts
+++ b/shared/src/place/place.schema.ts
@@ -78,6 +78,15 @@ export const placeRatingVoteSchema = z.object({
});
export type PlaceRatingVote = z.infer;
+/**
+ * The kinds of stop a drive has, matching the categories the corridor search can look for
+ * (`client/src/components/Map/poiCategories.ts` and the OSM tag map in
+ * `server/src/nest/maps/maps.helpers.ts`). Kept as a closed list here so a typo cannot
+ * reach the column, and extended alongside those two when the search learns a new kind.
+ */
+export const roadtripStopTypeSchema = z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']);
+export type RoadtripStopType = z.infer;
+
export const placeSchema = z.object({
id: z.number(),
trip_id: z.number(),
@@ -103,6 +112,15 @@ export const placeSchema = z.object({
route_geometry: z.string().nullable().optional(),
// Manual track colour (#776). null = inherit the category colour like before.
route_color: hexColorSchema.nullable().optional(),
+ /**
+ * What kind of stop this place is on a drive (#1797). null is an ordinary place, which
+ * is every place that predates the road trip addon.
+ *
+ * Separate from `category_id` on purpose: categories are the traveller's own editable,
+ * instance-wide list, while this is a fact about the place that the corridor search
+ * already knows and that owns its own icon and colour.
+ */
+ stop_type: roadtripStopTypeSchema.nullable().optional(),
website: z.string().nullable().optional(),
phone: z.string().nullable().optional(),
transport_mode: z.string().nullable().optional(),
@@ -147,6 +165,9 @@ export const assignmentPlaceSchema = z.object({
osm_id: z.string().nullable().optional(),
website: z.string().nullable().optional(),
phone: z.string().nullable().optional(),
+ // Carried through so the road-trip rail can mark a fuel stop as one without a
+ // second request per stop.
+ stop_type: roadtripStopTypeSchema.nullable().optional(),
category: placeCategorySchema.optional(),
tags: z.array(tagSchema.partial()).optional(),
});
diff --git a/shared/src/roadtrip/roadtrip.schema.ts b/shared/src/roadtrip/roadtrip.schema.ts
new file mode 100644
index 000000000..26bb4b300
--- /dev/null
+++ b/shared/src/roadtrip/roadtrip.schema.ts
@@ -0,0 +1,78 @@
+import { z } from 'zod';
+
+/**
+ * Road trip contracts (#1797).
+ *
+ * A **via** is a point the day's drive is made to pass through without being a stop.
+ * The distinction is the whole feature: a stop is somewhere you go, and it earns a number
+ * in the chain, an arrival time and a line in the itinerary; a via only bends the route,
+ * which is what "take the coast road instead" means. Storing one as a place would put a
+ * numbered stop in the middle of the day for a spot nobody stops at.
+ */
+
+/** Latitude, in the range the routing engines accept. */
+const latSchema = z.number().min(-90).max(90);
+/** Longitude, same. */
+const lngSchema = z.number().min(-180).max(180);
+
+export const roadtripViaSchema = z.object({
+ id: z.number(),
+ day_id: z.number(),
+ /**
+ * Which stop this via follows, counted the way the routing request is built.
+ *
+ * Deliberately an index and not an assignment id: a stop added mid-day is written
+ * optimistically with a temporary negative id and swapped for the real one moments
+ * later, so a reference to it would dangle exactly when the user is dragging.
+ */
+ after_order_index: z.number().int().min(0),
+ /** Order among the vias that follow the same stop. */
+ sequence: z.number().int().min(0),
+ lat: latSchema,
+ lng: lngSchema,
+ created_at: z.string().optional(),
+});
+export type RoadtripVia = z.infer;
+
+export const roadtripViaCreateRequestSchema = z.object({
+ after_order_index: z.number().int().min(0),
+ lat: latSchema,
+ lng: lngSchema,
+ /** Where among the vias after that stop; appended when absent. */
+ sequence: z.number().int().min(0).optional(),
+});
+export type RoadtripViaCreateRequest = z.infer;
+
+/** Moving a via is the whole edit — dragging it somewhere else on the map. */
+export const roadtripViaUpdateRequestSchema = z.object({
+ lat: latSchema,
+ lng: lngSchema,
+});
+export type RoadtripViaUpdateRequest = z.infer;
+
+/**
+ * Re-pinning a day's vias after the shape of its stops changed.
+ *
+ * `after_order_index` is a position, so inserting, removing or reordering a stop moves
+ * the ground under every via that follows it: the anchors keep their old numbers and the
+ * route silently reverts to the road the user had steered it away from. Whoever changes
+ * the stops sends the corrected anchoring here, and it is applied in one transaction —
+ * a half-applied re-anchoring is worse than none, because it mixes two numbering schemes
+ * in the same day.
+ *
+ * `remove` carries the vias whose leg stopped existing at all: delete the last stop and
+ * the leg into it is gone, and a via pinned to it has nowhere left to sit.
+ */
+export const roadtripViaReanchorRequestSchema = z.object({
+ vias: z.array(z.object({
+ id: z.number().int().positive(),
+ after_order_index: z.number().int().min(0),
+ })).max(500),
+ remove: z.array(z.number().int().positive()).max(500).optional(),
+});
+export type RoadtripViaReanchorRequest = z.infer;
+
+export const roadtripViaListResponseSchema = z.object({
+ vias: z.array(roadtripViaSchema),
+});
+export type RoadtripViaListResponse = z.infer;