+ setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))}
+ />
+
+
+
+ ))}
+
+
+ )}
+
{/* Departure row */}
diff --git a/client/src/components/Roadtrip/RoadtripModeSwitch.tsx b/client/src/components/Roadtrip/RoadtripModeSwitch.tsx
new file mode 100644
index 000000000..19f50622b
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripModeSwitch.tsx
@@ -0,0 +1,47 @@
+import React from 'react'
+import { CalendarDays, Route } from 'lucide-react'
+import { useTranslation } from '../../i18n/TranslationContext'
+
+interface RoadtripModeSwitchProps {
+ active: boolean
+ onChange: (roadtrip: boolean) => void
+}
+
+/**
+ * Switches the plan view's left rail between the day plan and the road trip reading of
+ * the same trip. Only rendered while the road trip addon is on, so a trip that isn't
+ * driven anywhere never grows a control it has no use for.
+ */
+export default function RoadtripModeSwitch({ active, onChange }: RoadtripModeSwitchProps): React.ReactElement {
+ const { t } = useTranslation()
+ const options: [boolean, string, typeof Route][] = [
+ [false, t('roadtrip.mode.days'), CalendarDays],
+ [true, t('roadtrip.mode.roadtrip'), Route],
+ ]
+ return (
+
+ )
+}
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
new file mode 100644
index 000000000..4dde26e58
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -0,0 +1,183 @@
+import React from 'react'
+import { MapPin, Clock, Car, Footprints, Bike, Milestone, Flag, AlertTriangle } from 'lucide-react'
+import { useTranslation } from '../../i18n/TranslationContext'
+import { useSettingsStore } from '../../store/settingsStore'
+import { formatDistance } from '../../utils/units'
+import { formatDate } from '../../utils/formatters'
+import { formatDurationShort, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
+import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
+import type { RouteSegment } from '../../types'
+
+interface RoadtripSidebarProps {
+ /** Legs and totals for the whole trip, computed once in the planner hook. */
+ routes: RoadtripRoutes
+ selectedAssignmentId?: number | null
+ onSelectStop?: (placeId: number, assignmentId: number) => void
+}
+
+const MODE_ICON: Record = {
+ driving: Car,
+ walking: Footprints,
+ cycling: Bike,
+}
+
+function Totals({ distance, duration, stops }: { distance: number; duration: number; stops: number }): React.ReactElement {
+ const { t } = useTranslation()
+ const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
+ const cells: [typeof Milestone, string, string][] = [
+ [Milestone, t('roadtrip.summary.distance'), formatDistance(distance / 1000, distanceUnit)],
+ [Clock, t('roadtrip.summary.driving'), formatDurationShort(duration)],
+ [Flag, t('roadtrip.summary.stops'), String(stops)],
+ ]
+ return (
+
+ {cells.map(([Icon, label, value]) => (
+
+
+
+ {/* Wraps rather than truncates: "Driving time" and its translations do not
+ fit one line in a third of the rail, and a clipped label reads as a bug. */}
+ {label}
+
+
+ )
+}
+
+/**
+ * The road trip rail: the whole trip as one chain of stops with the driving distance
+ * and time between them.
+ *
+ * Takes the day plan's place in the left column while road trip mode is on, because a
+ * road trip is read across days — which day a stop sits on matters less than how far
+ * apart the stops are (#1797, #435). Legs come from the same routing cache the map
+ * uses, so switching modes costs no extra requests.
+ */
+export default function RoadtripSidebar({
+ routes, selectedAssignmentId, onSelectStop,
+}: RoadtripSidebarProps): React.ReactElement {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {routes.days.length === 0 ? (
+
+
+
{t('roadtrip.empty.title')}
+
{t('roadtrip.empty.body')}
+
+ ) : (
+ routes.days.map(day => (
+
+ ))
+ )}
+
+ )
+}
diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts
new file mode 100644
index 000000000..51a8ebaeb
--- /dev/null
+++ b/client/src/components/Roadtrip/roadtripModel.test.ts
@@ -0,0 +1,184 @@
+import { describe, it, expect } from 'vitest'
+import {
+ computeSchedule,
+ formatClock,
+ formatDurationShort,
+ parseClock,
+ sumLegSeconds,
+} from './roadtripModel'
+
+describe('formatDurationShort', () => {
+ it('prints minutes below an hour', () => {
+ expect(formatDurationShort(45 * 60)).toBe('45 min')
+ expect(formatDurationShort(0)).toBe('0 min')
+ })
+
+ it('drops the zero minutes on a whole hour', () => {
+ expect(formatDurationShort(3600)).toBe('1 h')
+ expect(formatDurationShort(2 * 3600)).toBe('2 h')
+ })
+
+ it('prints hours and minutes together', () => {
+ expect(formatDurationShort(2 * 3600 + 10 * 60)).toBe('2 h 10 min')
+ })
+
+ it('carries instead of printing sixty minutes', () => {
+ // 1 h 59 min 40 s rounds the minutes to 60, which must become 2 h.
+ expect(formatDurationShort(3600 + 59 * 60 + 40)).toBe('2 h')
+ })
+
+ it('treats nonsense as nothing rather than throwing', () => {
+ expect(formatDurationShort(Number.NaN)).toBe('0 min')
+ expect(formatDurationShort(-90)).toBe('0 min')
+ })
+})
+
+describe('parseClock', () => {
+ it('reads wall-clock times', () => {
+ expect(parseClock('09:45')).toBe(9 * 60 + 45)
+ expect(parseClock('9:05')).toBe(9 * 60 + 5)
+ expect(parseClock('00:00')).toBe(0)
+ })
+
+ it('ignores anything trailing, as stored times sometimes carry seconds', () => {
+ expect(parseClock('14:30:00')).toBe(14 * 60 + 30)
+ })
+
+ it('rejects what is not a time', () => {
+ expect(parseClock(null)).toBeNull()
+ expect(parseClock('')).toBeNull()
+ expect(parseClock('later')).toBeNull()
+ expect(parseClock('25:00')).toBeNull()
+ expect(parseClock('12:75')).toBeNull()
+ })
+})
+
+describe('formatClock', () => {
+ it('pads both halves', () => {
+ expect(formatClock(9 * 60 + 5)).toBe('09:05')
+ expect(formatClock(0)).toBe('00:00')
+ })
+
+ it('wraps past midnight instead of printing a 25th hour', () => {
+ expect(formatClock(25 * 60)).toBe('01:00')
+ expect(formatClock(-30)).toBe('23:30')
+ })
+})
+
+describe('computeSchedule', () => {
+ const hours = (h: number): number => h * 3600
+
+ it('walks arrival and departure forward from the first pinned time', () => {
+ // The example from discussion #1797: leave at 8:00, drive 1 h 45, stay 2 h, drive 1 h 10.
+ const { entries } = computeSchedule(
+ [
+ { anchor: '08:00', dwellMinutes: 0 },
+ { anchor: null, dwellMinutes: 120 },
+ { anchor: null, dwellMinutes: 45 },
+ ],
+ [hours(1.75), hours(1) + 10 * 60],
+ )
+ expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '08:00', anchored: true })
+ expect(entries[1]).toMatchObject({ arrival: '09:45', departure: '11:45', anchored: false })
+ expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false })
+ })
+
+ it('leaves stops before the first pinned time blank rather than inventing one', () => {
+ const { entries } = computeSchedule(
+ [
+ { anchor: null, dwellMinutes: 60 },
+ { anchor: '10:00', dwellMinutes: 30 },
+ ],
+ [hours(1)],
+ )
+ expect(entries[0]).toMatchObject({ arrival: null, departure: null })
+ expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
+ })
+
+ it('restarts the cascade at a pinned stop instead of pushing it', () => {
+ // The drive would arrive at 09:00, but the museum ticket says 11:00: everything
+ // after it counts from 11:00, not from the drive.
+ const { entries } = computeSchedule(
+ [
+ { anchor: '08:00', dwellMinutes: 0 },
+ { anchor: '11:00', dwellMinutes: 60 },
+ { anchor: null, dwellMinutes: 0 },
+ ],
+ [hours(1), hours(1)],
+ )
+ expect(entries[1].arrival).toBe('11:00')
+ expect(entries[2].arrival).toBe('13:00')
+ })
+
+ it('reports a stop the drive cannot reach in time', () => {
+ const { warnings } = computeSchedule(
+ [
+ { anchor: '08:00', dwellMinutes: 0 },
+ { anchor: '09:00', dwellMinutes: 0 },
+ ],
+ [hours(3)],
+ )
+ expect(warnings).toEqual([{ index: 1, code: 'late', minutes: 120 }])
+ })
+
+ it('does not cry about a minute of rounding', () => {
+ const { warnings } = computeSchedule(
+ [
+ { anchor: '08:00', dwellMinutes: 0 },
+ { anchor: '09:00', dwellMinutes: 0 },
+ ],
+ [hours(1) + 30],
+ )
+ expect(warnings).toEqual([])
+ })
+
+ it('breaks the chain at a leg that never routed', () => {
+ const { entries } = computeSchedule(
+ [
+ { anchor: '08:00', dwellMinutes: 30 },
+ { anchor: null, dwellMinutes: 30 },
+ { anchor: null, dwellMinutes: 0 },
+ ],
+ [undefined, hours(1)],
+ )
+ expect(entries[0].arrival).toBe('08:00')
+ expect(entries[1].arrival).toBeNull()
+ expect(entries[2].arrival).toBeNull()
+ })
+
+ it('flags the day rolling past midnight and keeps counting', () => {
+ const { entries, warnings } = computeSchedule(
+ [
+ { anchor: '22:00', dwellMinutes: 0 },
+ { anchor: null, dwellMinutes: 0 },
+ ],
+ [hours(4)],
+ )
+ expect(entries[1].arrival).toBe('02:00')
+ expect(entries[1].dayOffset).toBe(1)
+ expect(warnings).toEqual([{ index: 1, code: 'overnight' }])
+ })
+
+ it('handles an empty chain', () => {
+ expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] })
+ })
+
+ it('treats a missing dwell as no time spent', () => {
+ const { entries } = computeSchedule(
+ [
+ { anchor: '08:00', dwellMinutes: null },
+ { anchor: null, dwellMinutes: null },
+ ],
+ [hours(1)],
+ )
+ expect(entries[0].departure).toBe('08:00')
+ expect(entries[1].arrival).toBe('09:00')
+ })
+})
+
+describe('sumLegSeconds', () => {
+ it('adds only the legs that routed', () => {
+ expect(sumLegSeconds([600, undefined, 1200])).toBe(1800)
+ expect(sumLegSeconds([])).toBe(0)
+ })
+})
diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts
new file mode 100644
index 000000000..fe1a5762e
--- /dev/null
+++ b/client/src/components/Roadtrip/roadtripModel.ts
@@ -0,0 +1,132 @@
+/**
+ * Pure road-trip arithmetic — no React, no network, no store. Everything here is a
+ * function of the stops, the routed legs and the day's rules, so it can be unit tested
+ * on its own and reused by the map, the rail and (later) the MCP tool.
+ */
+
+/** Seconds → "2 h 10 min" / "45 min", matching the wording of the map's route connectors. */
+export function formatDurationShort(seconds: number): string {
+ const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
+ const h = Math.floor(safe / 3600)
+ const m = Math.round((safe % 3600) / 60)
+ // 59.6 min must not print as "60 min"; carry it.
+ if (m === 60) return `${h + 1} h`
+ if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h`
+ return `${m} min`
+}
+
+/** "09:45" → 585. Anything that is not a wall-clock time returns null. */
+export function parseClock(value: string | null | undefined): number | null {
+ if (!value) return null
+ const m = /^(\d{1,2}):(\d{2})/.exec(value.trim())
+ if (!m) return null
+ const h = Number(m[1])
+ const min = Number(m[2])
+ if (h > 23 || min > 59) return null
+ return h * 60 + min
+}
+
+/** 585 → "09:45". Minutes past midnight beyond a day wrap around and report the carry. */
+export function formatClock(minutes: number): string {
+ const day = 24 * 60
+ const wrapped = ((minutes % day) + day) % day
+ const h = Math.floor(wrapped / 60)
+ const m = Math.round(wrapped % 60)
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
+}
+
+export interface ScheduleStop {
+ /** Wall-clock time the user pinned on this stop, if any. */
+ anchor: string | null
+ /** Planned time spent here, in minutes. */
+ dwellMinutes: number | null
+}
+
+export interface ScheduleWarning {
+ /** Index of the stop the finding belongs to. */
+ index: number
+ code: 'late' | 'overnight'
+ /** Minutes the computed arrival misses the anchor by (code 'late'). */
+ minutes?: number
+}
+
+export interface ScheduleEntry {
+ /** Computed arrival, or the anchor where the user pinned one. */
+ arrival: string | null
+ departure: string | null
+ /** True when the time was pinned by the user rather than derived. */
+ anchored: boolean
+ /** Days past the first stop, so a chain running past midnight stays readable. */
+ dayOffset: number
+}
+
+export interface Schedule {
+ entries: ScheduleEntry[]
+ warnings: ScheduleWarning[]
+}
+
+/**
+ * Walks the chain forward: arrival = previous departure + driving time, departure =
+ * arrival + time spent at the stop. A stop the user pinned a time on does not move —
+ * it restarts the cascade from its own time, which is what makes a ferry, a museum
+ * ticket or a hotel check-in usable as a fixed point (Furkot solves it the same way).
+ *
+ * `legSeconds[i]` is the drive from stop i to stop i+1; a leg that never routed is
+ * `undefined` and breaks the chain rather than inventing a duration.
+ */
+export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
+ const entries: ScheduleEntry[] = []
+ const warnings: ScheduleWarning[] = []
+ // Minutes since the first stop's midnight, so a chain crossing midnight keeps counting.
+ let cursor: number | null = null
+ let dayOffset = 0
+
+ for (let i = 0; i < stops.length; i++) {
+ const stop = stops[i]
+ const anchor = parseClock(stop.anchor)
+ let arrival: number | null = cursor
+
+ if (anchor !== null) {
+ if (arrival !== null) {
+ const anchorToday = anchor + dayOffset * 24 * 60
+ // The pinned time is in the past relative to the drive: the plan does not fit.
+ if (arrival > anchorToday + 1) {
+ warnings.push({ index: i, code: 'late', minutes: Math.round(arrival - anchorToday) })
+ }
+ arrival = anchorToday
+ } else {
+ arrival = anchor
+ }
+ }
+
+ if (arrival === null) {
+ entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 })
+ continue
+ }
+
+ const offset = Math.floor(arrival / (24 * 60))
+ if (offset > dayOffset) {
+ dayOffset = offset
+ warnings.push({ index: i, code: 'overnight' })
+ }
+
+ const dwell = stop.dwellMinutes ?? 0
+ const departure = arrival + dwell
+ entries.push({
+ arrival: formatClock(arrival),
+ departure: formatClock(departure),
+ anchored: anchor !== null,
+ dayOffset: offset,
+ })
+
+ const leg = legSeconds[i]
+ cursor = leg === undefined ? null : departure + Math.round(leg / 60)
+ }
+
+ return { entries, warnings }
+}
+
+/** Total driving seconds of the legs that actually routed. */
+export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
+ return legSeconds.reduce((sum, s) => sum + (s ?? 0), 0)
+}
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts
new file mode 100644
index 000000000..47928e207
--- /dev/null
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts
@@ -0,0 +1,250 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { calculateRouteWithLegs } from '../Map/RouteCalculator'
+import { resolveLegMode } from '../Planner/legMode'
+import { computeSchedule, type Schedule } from './roadtripModel'
+import { useSettingsStore } from '../../store/settingsStore'
+import type { Assignment, AssignmentsMap, Day, RouteSegment } from '../../types'
+
+/** One stop on a day's drive — an assignment whose place actually has coordinates. */
+export interface RoadtripStop {
+ assignmentId: number
+ placeId: number
+ name: string
+ lat: number
+ lng: number
+ /** Planned time on the assignment, or the place's own time as the fallback the API already resolves. */
+ time: string | null
+ /** How long the visit is planned to take. `places.duration_minutes` has carried this for years. */
+ dwellMinutes: number | null
+ /** Mode of the leg LEAVING this stop; null inherits the day default. */
+ legMode: string | null
+ incomingLegMode: string | null
+}
+
+export interface RoadtripDay {
+ dayId: number
+ dayNumber: number
+ date: string | null
+ title: string | null
+ stops: RoadtripStop[]
+ /** Leg i connects stops[i] → stops[i+1]. Shorter than `stops` by one once every leg resolved. */
+ legs: (RouteSegment | undefined)[]
+ /** Arrival and departure per stop, walked forward from the day's first pinned time. */
+ schedule: Schedule
+ distance: number
+ duration: number
+}
+
+export interface RoadtripRoutes {
+ days: RoadtripDay[]
+ /** 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. */
+ segments: RouteSegment[]
+ totalDistance: number
+ totalDuration: number
+ totalStops: number
+ /** True while legs are still coming in — the numbers below are a partial sum until then. */
+ loading: boolean
+}
+
+/**
+ * Gap between two routing requests. The public OSRM hosts TREK ships with state one
+ * request per second; a road trip asks for every leg of every day, so without spacing
+ * the first handful answer and the rest come back 429.
+ */
+const REQUEST_SPACING_MS = 1100
+/** How often a leg that failed (usually a rate limit) is tried again, and how long after. */
+const RETRY_DELAYS_MS = [1500, 4000]
+
+const sleep = (ms: number, signal: AbortSignal): Promise =>
+ new Promise(resolve => {
+ if (signal.aborted) { resolve(); return }
+ const timer = setTimeout(done, ms)
+ function done(): void {
+ clearTimeout(timer)
+ signal.removeEventListener('abort', done)
+ resolve()
+ }
+ signal.addEventListener('abort', done, { once: true })
+ })
+
+/** A leg that routed: the numbers for the rail plus the geometry the map draws. */
+interface RoutedLeg {
+ seg: RouteSegment
+ line: [number, number][]
+}
+
+const asStop = (a: Assignment): RoadtripStop | null => {
+ const p = a.place
+ if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
+ return {
+ assignmentId: a.id,
+ placeId: a.place_id,
+ name: p.name,
+ lat: p.lat,
+ lng: p.lng,
+ time: a.assignment_time ?? p.place_time ?? null,
+ dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
+ legMode: a.leg_transport_mode ?? null,
+ incomingLegMode: a.incoming_leg_transport_mode ?? null,
+ }
+}
+
+/**
+ * Distance and driving time for every leg of every day of the trip.
+ *
+ * The day plan sidebar routes one day at a time because that is all it shows; a road
+ * trip is the whole chain, which is exactly what #435 asks for. Legs are fetched
+ * pairwise so RouteCalculator's cache is shared with the sidebar — a day already drawn
+ * on the map costs nothing here — and the worker pool stays small because the routing
+ * host is usually someone else's OSRM.
+ */
+export function useRoadtripRoutes(
+ tripId: number | string | null,
+ days: Day[],
+ assignments: AssignmentsMap,
+ /** Mode for legs that neither the stop nor the day pins down. */
+ fallbackProfile: string = 'driving',
+): 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 [loading, setLoading] = useState(false)
+ const abortRef = useRef(null)
+
+ const plan = useMemo(() => {
+ return [...days]
+ .sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
+ .map(d => {
+ const 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)
+ return {
+ dayId: d.id,
+ dayNumber: d.day_number ?? 0,
+ date: d.date ?? null,
+ title: d.title ?? null,
+ stops,
+ legs: [],
+ schedule: { entries: [], warnings: [] },
+ distance: 0,
+ duration: 0,
+ }
+ })
+ .filter(d => d.stops.length > 1)
+ }, [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 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],
+ )
+
+ useEffect(() => {
+ abortRef.current?.abort()
+ if (!plan.length) {
+ setLegsByDay({})
+ setLoading(false)
+ return
+ }
+ const controller = new AbortController()
+ abortRef.current = controller
+ setLoading(true)
+
+ const dayDefault = (dayId: number): string =>
+ days.find(d => d.id === dayId)?.default_transport_mode || routeProfile
+
+ const collected: Record> = {}
+ const tasks: (() => Promise)[] = []
+
+ for (const day of plan) {
+ const dayLegs: Record = {}
+ collected[day.dayId] = dayLegs
+ const dfMode = dayDefault(day.dayId)
+ for (let i = 0; i < day.stops.length - 1; i++) {
+ const from = day.stops[i]
+ const to = day.stops[i + 1]
+ const mode = resolveLegMode(
+ { isPlace: true, leg_transport_mode: from.legMode },
+ { isPlace: true, incoming_leg_transport_mode: to.incomingLegMode },
+ dfMode,
+ )
+ tasks.push(async () => {
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
+ if (controller.signal.aborted) return
+ try {
+ const r = await calculateRouteWithLegs(
+ [{ lat: from.lat, lng: from.lng }, { lat: to.lat, lng: to.lng }],
+ { signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId },
+ )
+ if (r.legs[0]) dayLegs[from.assignmentId] = { seg: { ...r.legs[0], mode }, line: r.coordinates }
+ return
+ } catch {
+ // Almost always a rate limit on the shared routing host. Back off and try
+ // again; a leg 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)
+ }
+ }
+ })
+ }
+ }
+
+ // One at a time, spaced out. The day sidebar can afford a small pool because it
+ // routes a single day; a road trip is every leg of every day at once, and the
+ // public routing hosts answer that with 429 after the first handful. Results are
+ // published as they land so the rail fills in instead of sitting empty.
+ void (async () => {
+ for (const task of tasks) {
+ if (controller.signal.aborted) return
+ await task()
+ if (controller.signal.aborted) return
+ setLegsByDay({ ...collected })
+ await sleep(REQUEST_SPACING_MS, controller.signal)
+ }
+ if (!controller.signal.aborted) setLoading(false)
+ })()
+
+ return () => controller.abort()
+ // planKey stands in for `plan`: same geometry, same legs.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [planKey, routeProfile, distanceUnit, tripId])
+
+ return useMemo(() => {
+ const lines: [number, number][][] = []
+ 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])
+ for (const leg of routed) {
+ if (!leg) continue
+ if (leg.line.length > 1) lines.push(leg.line)
+ segments.push(leg.seg)
+ }
+ const legs = routed.map(l => l?.seg)
+ const distance = legs.reduce((sum, l) => sum + (l?.distance ?? 0), 0)
+ const duration = legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0)
+ const schedule = computeSchedule(
+ day.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
+ legs.map(l => l?.duration),
+ )
+ return { ...day, legs, schedule, distance, duration }
+ })
+ return {
+ days: out,
+ lines,
+ 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),
+ loading,
+ }
+ }, [plan, legsByDay, loading])
+}
diff --git a/client/src/hooks/useTransportRoutes.ts b/client/src/hooks/useTransportRoutes.ts
index d31f718dc..74a79422d 100644
--- a/client/src/hooks/useTransportRoutes.ts
+++ b/client/src/hooks/useTransportRoutes.ts
@@ -21,9 +21,9 @@ const ROAD_PROFILE: Record = {
bicycle: 'cycling',
}
-// Beyond this straight-line distance a car/taxi/bike (or even coach) booking is
-// almost always a data quirk or an inter-continental hop the road router can't
-// resolve — keep the straight line and don't hammer the public OSRM demo.
+// Beyond this straight-line distance a single leg of a car/taxi/bike (or even coach)
+// booking is almost always a data quirk or an inter-continental hop the road router
+// can't resolve — keep the straight line and don't hammer the public OSRM demo.
// Shared with the day-route builder, which draws the same conclusion about a
// booking endpoint that landed next to a local stop (#2133).
const MAX_ROUTE_KM = MAX_DRIVE_KM
@@ -60,9 +60,14 @@ export function useTransportRoutes(reservations: Reservation[]): Map MAX_ROUTE_KM) continue
+ // Per leg, not over the whole booking: a road trip with stops is long by design
+ // and its total would trip a limit meant to catch a single impossible hop. It is
+ // one leg spanning an ocean that the road router cannot resolve, not the sum (#1797).
+ let tooFar = false
+ for (let i = 0; i < wps.length - 1; i++) {
+ if (haversineKm(wps[i], wps[i + 1]) > MAX_ROUTE_KM) { tooFar = true; break }
+ }
+ if (tooFar) continue
const key = `${profile}:${wps.map(w => `${w.lat},${w.lng}`).join('|')}`
if (attempted.get(r.id) === key) continue
attempted.set(r.id, key)
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 29213dd1d..42b6eeed5 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -8,6 +8,7 @@ import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
+import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
import TripLoadingSplash from '../components/shared/TripLoadingSplash'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
import PlaceInspector from '../components/Planner/PlaceInspector'
@@ -66,6 +67,7 @@ const ExpenseModal = lazyWithRetry(() =>
import('../components/Budget/CostsPanel').then(m => ({ default: m.ExpenseModal }))
)
const CollabPanel = lazyWithRetry(() => import('../components/Collab/CollabPanel'))
+const RoadtripSidebar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripSidebar'))
// 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.
@@ -243,6 +245,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
+ roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
leftWidth, rightWidth,
@@ -373,14 +376,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
tripId={tripId}
places={mapPlaces}
dayPlaces={dayPlaces}
- route={route}
- routeVias={routeVias}
+ route={roadtripActive ? roadtripRoutes.lines : route}
+ routeVias={roadtripActive ? [] : routeVias}
showTransitRoutes={transitRoutesShown}
// The route toggle belongs to one day, so the map needs that day to
// know which automated transports may ride it (#2019).
days={days}
selectedDayId={selectedDayId}
- routeSegments={routeSegments}
+ routeSegments={roadtripActive ? roadtripRoutes.segments : routeSegments}
selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick}
@@ -468,6 +471,18 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
transition: 'width 0.25s ease',
opacity: leftHidden ? 0 : 1,
}}>
+ {enabledAddons.roadtrip && (
+ { if (v !== roadtripMode) toggleRoadtripMode() }} />
+ )}
+ {enabledAddons.roadtrip && roadtripMode ? (
+
+ handlePlaceClick(placeId, assignmentId)}
+ />
+
+ ) : (
{ if (selectedDayId) updateRouteForDay(selectedDayId) }}
onAddBookingToAssignment={can('day_edit', trip) ? (dayId, assignmentId) => { tripActions.setSelectedDay(dayId); setBookingForAssignmentId(assignmentId); setEditingReservation(null); setShowReservationModal(true) } : undefined}
/>
+ )}
{!leftHidden && !narrowPanels && (
)}
+
{activeTab.startsWith('plugin:') && (
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index 4ed67dcad..45cdad0ad 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -19,6 +19,7 @@ import { useAuthStore } from '../../store/authStore'
import { useResizablePanels } from '../../hooks/useResizablePanels'
import { useTripWebSocket } from '../../hooks/useTripWebSocket'
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
+import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
@@ -37,6 +38,9 @@ import {
} from '../../utils/connectionsVisibility'
import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlaces'
+/** Stable empty list so the road trip hook stays inert while its mode is off. */
+const EMPTY_DAYS: Day[] = []
+
/**
* Trip planner page logic — the big one. Owns the trip store wiring, addon
* gating, accommodations/members loading, the tab + resizable-panel + selection
@@ -83,11 +87,23 @@ export function useTripPlanner() {
toast.info(t('undo.done', { action: label ?? '' }))
}, [undo, lastActionLabel, toast])
- const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true, collab: false })
+ const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false })
// The values above are an optimistic guess until the addon feed answers. The
// tab guard below waits for this before evicting anything, so a tab we were
// asked to open ('collab' in particular, guessed off) survives the gap.
const [addonsLoaded, setAddonsLoaded] = useState(false)
+ // Road trip mode swaps the plan view's left rail (and later its map layer) for the
+ // drive-first reading of the same trip. Per trip and per session, like the tab choice:
+ // someone planning a road trip stays in it across reloads without it leaking into
+ // their next, non-driving trip.
+ const [roadtripMode, setRoadtripMode] = useState(() => sessionStorage.getItem(`trip-roadtrip-${tripId}`) === '1')
+ const toggleRoadtripMode = useCallback(() => {
+ setRoadtripMode(prev => {
+ const next = !prev
+ sessionStorage.setItem(`trip-roadtrip-${tripId}`, next ? '1' : '0')
+ return next
+ })
+ }, [tripId])
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
const [tripAccommodations, setTripAccommodations] = useState([])
const [allowedFileTypes, setAllowedFileTypes] = useState(null)
@@ -114,7 +130,7 @@ export function useTripPlanner() {
addonsApi.enabled().then(data => {
const map: Record = {}
data.addons.forEach(a => { map[a.id] = true })
- setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
+ setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip })
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
}).catch(() => {}).finally(() => setAddonsLoaded(true))
authApi.getAppConfig().then(config => {
@@ -488,6 +504,11 @@ export function useTripPlanner() {
const { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
+ // 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 handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
tripActions.setSelectedDay(dayId)
if (!skipFit) setFitKey(k => k + 1)
@@ -1083,6 +1104,7 @@ export function useTripPlanner() {
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
+ roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
diff --git a/client/tests/integration/hooks/useTransportRoutes.test.ts b/client/tests/integration/hooks/useTransportRoutes.test.ts
index 357f2a8da..95291eec6 100644
--- a/client/tests/integration/hooks/useTransportRoutes.test.ts
+++ b/client/tests/integration/hooks/useTransportRoutes.test.ts
@@ -62,6 +62,33 @@ describe('useTransportRoutes (#1425 real road routes)', () => {
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
})
+ it('routes a road trip whose stops add up past the cap, because no single leg does (#1797)', async () => {
+ // Paris → Munich → Vienna → Budapest: ~1800 km end to end, every leg well under
+ // the cap. Before the cap moved to the leg, the sum alone dropped the whole drive.
+ const res = [{
+ id: 20,
+ type: 'car',
+ status: 'confirmed',
+ endpoints: [
+ { role: 'from', sequence: 0, name: 'Paris', code: null, lat: 48.8566, lng: 2.3522, timezone: null, local_time: null, local_date: null },
+ { role: 'stop', sequence: 1, name: 'Munich', code: null, lat: 48.1351, lng: 11.582, timezone: null, local_time: null, local_date: null },
+ { role: 'stop', sequence: 2, name: 'Vienna', code: null, lat: 48.2082, lng: 16.3738, timezone: null, local_time: null, local_date: null },
+ { role: 'to', sequence: 3, name: 'Budapest', code: null, lat: 47.4979, lng: 19.0402, timezone: null, local_time: null, local_date: null },
+ ],
+ } as unknown as Reservation]
+ const { result } = renderHook(() => useTransportRoutes(res))
+ await waitFor(() => expect(result.current.get(20)).toBeTruthy())
+ expect(calculateRouteWithLegs).toHaveBeenCalledWith(
+ [
+ { lat: 48.8566, lng: 2.3522 },
+ { lat: 48.1351, lng: 11.582 },
+ { lat: 48.2082, lng: 16.3738 },
+ { lat: 47.4979, lng: 19.0402 },
+ ],
+ expect.objectContaining({ profile: 'driving' }),
+ )
+ })
+
it('falls back silently (no entry) when routing throws', async () => {
calculateRouteWithLegs.mockRejectedValueOnce(new Error('OSRM down'))
const res = [booking(7, 'taxi', PARIS, VERSAILLES)]
diff --git a/server/src/addons.ts b/server/src/addons.ts
index b5aa22da3..890d656a1 100644
--- a/server/src/addons.ts
+++ b/server/src/addons.ts
@@ -10,6 +10,7 @@ export const ADDON_IDS = {
AIRTRAIL: 'airtrail',
LLM_PARSING: 'llm_parsing',
COLLECTIONS: 'collections',
+ ROADTRIP: 'roadtrip',
} as const;
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts
index eca32dfc6..8ad4ff046 100644
--- a/server/src/db/seeds.ts
+++ b/server/src/db/seeds.ts
@@ -120,6 +120,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from a shared Naver Maps list', type: 'integration', icon: 'Link2', enabled: 1, sort_order: 13 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
+ { id: 'roadtrip', name: 'Road trip', description: 'Drives with stops along the route, driving times, and arrival times that update themselves', type: 'trip', icon: 'Route', enabled: 0, sort_order: 7 },
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
{ id: 'llm_parsing', name: 'AI Parsing', description: 'LLM fallback for booking imports kitinerary cannot read', type: 'integration', icon: 'Sparkles', enabled: 0, sort_order: 15 },
diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts
index ef69a45ef..41834d580 100644
--- a/shared/src/i18n/ar/admin.ts
+++ b/shared/src/i18n/ar/admin.ts
@@ -591,6 +591,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'خريطة العالم مع الدول التي تمت زيارتها وإحصائيات السفر',
'admin.addons.catalog.collab.name': 'التعاون',
'admin.addons.catalog.collab.description': 'ملاحظات واستطلاعات ومحادثة واقتراحات للتخطيط المشترك',
+ 'admin.addons.catalog.roadtrip.name': 'رحلة برية',
+ 'admin.addons.catalog.roadtrip.description': 'خطّط رحلات القيادة مع محطات توقف، وتُحدَّث أوقات القيادة والوصول تلقائيًا',
'admin.addons.catalog.memories.name': 'صور (Immich)',
'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich',
'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي',
diff --git a/shared/src/i18n/ar/index.ts b/shared/src/i18n/ar/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/ar/index.ts
+++ b/shared/src/i18n/ar/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts
new file mode 100644
index 000000000..f0380b91e
--- /dev/null
+++ b/shared/src/i18n/ar/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'رحلة برية',
+ 'roadtrip.mode.days': 'الأيام',
+ 'roadtrip.mode.label': 'عرض الخطة',
+ 'roadtrip.title': 'رحلة برية',
+ 'roadtrip.subtitle': 'محطات التوقف وأوقات القيادة وأوقات الوصول لهذه الرحلة',
+ 'roadtrip.empty.title': 'لا يوجد مسار بعد',
+ 'roadtrip.empty.body': 'أضف أماكن إلى يوم أو حجز سيارة مع محطات توقف، وسيظهر المسار هنا.',
+ 'roadtrip.stops.label': 'محطات على الطريق',
+ 'roadtrip.stops.remove': 'إزالة المحطة',
+ 'roadtrip.summary.distance': 'المسافة',
+ 'roadtrip.summary.driving': 'مدة القيادة',
+ 'roadtrip.day': 'اليوم {number}',
+ 'roadtrip.leg.pending': 'لا يوجد مسار',
+ 'roadtrip.stop.stay': 'مدة التوقف هنا',
+ 'roadtrip.stop.pinned': 'الوقت الذي حددته',
+ 'roadtrip.stop.computed': 'محسوب من زمن القيادة',
+ 'roadtrip.warn.late': 'الوصول بعد {minutes} دقيقة من الوقت المحدد',
+ 'roadtrip.summary.stops': 'محطات التوقف',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts
index 7111a4506..c32952bee 100644
--- a/shared/src/i18n/br/admin.ts
+++ b/shared/src/i18n/br/admin.ts
@@ -584,6 +584,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Mapa mundial com países visitados e estatísticas',
'admin.addons.catalog.collab.name': 'Colab',
'admin.addons.catalog.collab.description': 'Notas, enquetes, chat e sugestões para planejar em grupo',
+ 'admin.addons.catalog.roadtrip.name': 'Viagem de carro',
+ 'admin.addons.catalog.roadtrip.description': 'Planeje trajetos com paradas; tempos de direção e horários de chegada se recalculam sozinhos',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Model Context Protocol para integração com assistentes de IA',
'admin.addons.subtitleBefore': 'Ative ou desative recursos para personalizar sua ',
diff --git a/shared/src/i18n/br/index.ts b/shared/src/i18n/br/index.ts
index f7465a624..1bb07ab5e 100644
--- a/shared/src/i18n/br/index.ts
+++ b/shared/src/i18n/br/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts
new file mode 100644
index 000000000..763feb53b
--- /dev/null
+++ b/shared/src/i18n/br/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Viagem de carro',
+ 'roadtrip.mode.days': 'Dias',
+ 'roadtrip.mode.label': 'Visão do plano',
+ 'roadtrip.title': 'Viagem de carro',
+ 'roadtrip.subtitle': 'Paradas, tempos de direção e horários de chegada desta viagem',
+ 'roadtrip.empty.title': 'Ainda sem rota',
+ 'roadtrip.empty.body': 'Adicione locais a um dia ou uma reserva de carro com paradas e a rota aparecerá aqui.',
+ 'roadtrip.stops.label': 'Paradas pelo caminho',
+ 'roadtrip.stops.remove': 'Remover parada',
+ 'roadtrip.summary.distance': 'Distância',
+ 'roadtrip.summary.driving': 'Tempo de direção',
+ 'roadtrip.day': 'Dia {number}',
+ 'roadtrip.leg.pending': 'Sem rota',
+ 'roadtrip.stop.stay': 'Tempo nesta parada',
+ 'roadtrip.stop.pinned': 'Horário definido por você',
+ 'roadtrip.stop.computed': 'Calculado a partir do trajeto',
+ 'roadtrip.warn.late': 'Chega {minutes} min depois do horário definido',
+ 'roadtrip.summary.stops': 'Paradas',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts
index 9cf899380..aa66635ce 100644
--- a/shared/src/i18n/ca/admin.ts
+++ b/shared/src/i18n/ca/admin.ts
@@ -344,6 +344,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Mapa del món amb els països visitats i estadístiques de viatge',
'admin.addons.catalog.collab.name': 'Col·laboració',
'admin.addons.catalog.collab.description': 'Notes, enquestes, xat i suggeriments per planificar en grup',
+ 'admin.addons.catalog.roadtrip.name': 'Viatge per carretera',
+ 'admin.addons.catalog.roadtrip.description': 'Planifica trajectes amb parades: els temps de conducció i les hores d’arribada es recalculen sols',
'admin.addons.catalog.collections.name': 'Col·leccions',
'admin.addons.catalog.collections.description': 'Recull llocs de qualsevol viatge en llistes amb nom i reutilitza’ls',
'admin.oidcOnlyMode': 'Desactivar autenticació per contrasenya',
diff --git a/shared/src/i18n/ca/index.ts b/shared/src/i18n/ca/index.ts
index 5b95ae022..3bbf7b802 100644
--- a/shared/src/i18n/ca/index.ts
+++ b/shared/src/i18n/ca/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts
new file mode 100644
index 000000000..825b5e4b2
--- /dev/null
+++ b/shared/src/i18n/ca/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Viatge per carretera',
+ 'roadtrip.mode.days': 'Dies',
+ 'roadtrip.mode.label': 'Vista del pla',
+ 'roadtrip.title': 'Viatge per carretera',
+ 'roadtrip.subtitle': 'Parades, temps de conducció i hores d’arribada d’aquest viatge',
+ 'roadtrip.empty.title': 'Encara no hi ha ruta',
+ 'roadtrip.empty.body': 'Afegeix llocs a un dia o una reserva de cotxe amb parades i la ruta apareixerà aquí.',
+ 'roadtrip.stops.label': 'Parades pel camí',
+ 'roadtrip.stops.remove': 'Elimina la parada',
+ 'roadtrip.summary.distance': 'Distància',
+ 'roadtrip.summary.driving': 'Temps de conducció',
+ 'roadtrip.day': 'Dia {number}',
+ 'roadtrip.leg.pending': 'Sense ruta',
+ 'roadtrip.stop.stay': 'Temps en aquesta parada',
+ 'roadtrip.stop.pinned': 'Hora que has fixat',
+ 'roadtrip.stop.computed': 'Calculat a partir del trajecte',
+ 'roadtrip.warn.late': 'Arriba {minutes} min després de l’hora fixada',
+ 'roadtrip.summary.stops': 'Parades',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts
index a47b3683c..c40d53d33 100644
--- a/shared/src/i18n/cs/admin.ts
+++ b/shared/src/i18n/cs/admin.ts
@@ -586,6 +586,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.airtrail.description': 'Synchronizujte lety ze své instance AirTrail',
'admin.addons.catalog.llm_parsing.name': 'Analýza pomocí AI',
'admin.addons.catalog.llm_parsing.description': 'Přečte rezervace, které vestavěný analyzátor nezvládne, pomocí zvoleného modelu AI',
+ 'admin.addons.catalog.roadtrip.name': 'Cesta autem',
+ 'admin.addons.catalog.roadtrip.description': 'Plánujte jízdy se zastávkami – doby jízdy a časy příjezdu se přepočítají samy',
'admin.addons.enabled': 'Povoleno',
'admin.addons.disabled': 'Zakázáno',
'admin.addons.type.trip': 'Cesta',
diff --git a/shared/src/i18n/cs/index.ts b/shared/src/i18n/cs/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/cs/index.ts
+++ b/shared/src/i18n/cs/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts
new file mode 100644
index 000000000..fec79cd1f
--- /dev/null
+++ b/shared/src/i18n/cs/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Cesta autem',
+ 'roadtrip.mode.days': 'Dny',
+ 'roadtrip.mode.label': 'Zobrazení plánu',
+ 'roadtrip.title': 'Cesta autem',
+ 'roadtrip.subtitle': 'Zastávky, doby jízdy a časy příjezdu této cesty',
+ 'roadtrip.empty.title': 'Zatím žádná trasa',
+ 'roadtrip.empty.body': 'Přidejte místa ke dni nebo rezervaci auta se zastávkami a trasa se zobrazí zde.',
+ 'roadtrip.stops.label': 'Zastávky po cestě',
+ 'roadtrip.stops.remove': 'Odebrat zastávku',
+ 'roadtrip.summary.distance': 'Vzdálenost',
+ 'roadtrip.summary.driving': 'Doba jízdy',
+ 'roadtrip.day': 'Den {number}',
+ 'roadtrip.leg.pending': 'Bez trasy',
+ '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.warn.late': 'Příjezd {minutes} min po nastaveném čase',
+ 'roadtrip.summary.stops': 'Zastávky',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts
index 3f9dca51c..5fc18ef06 100644
--- a/shared/src/i18n/de/admin.ts
+++ b/shared/src/i18n/de/admin.ts
@@ -588,6 +588,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.description': 'Notizen, Umfragen, Chat und Vorschläge für die gemeinsame Planung',
+ 'admin.addons.catalog.roadtrip.name': 'Roadtrip',
+ 'admin.addons.catalog.roadtrip.description': 'Fahrten mit Zwischenstopps planen — Fahrzeiten und Ankunftszeiten rechnen sich selbst neu',
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen',
'admin.addons.catalog.mcp.name': 'MCP',
diff --git a/shared/src/i18n/de/index.ts b/shared/src/i18n/de/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/de/index.ts
+++ b/shared/src/i18n/de/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts
new file mode 100644
index 000000000..093de610b
--- /dev/null
+++ b/shared/src/i18n/de/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Roadtrip',
+ 'roadtrip.mode.days': 'Tage',
+ 'roadtrip.mode.label': 'Plan-Ansicht',
+ 'roadtrip.title': 'Roadtrip',
+ 'roadtrip.subtitle': 'Stopps, Fahrzeiten und Ankunftszeiten dieser Reise',
+ 'roadtrip.empty.title': 'Noch keine Route',
+ 'roadtrip.empty.body': 'Ordne einem Tag Orte zu oder lege eine Autofahrt mit Stopps an — die Route erscheint dann hier.',
+ 'roadtrip.stops.label': 'Stopps unterwegs',
+ 'roadtrip.stops.remove': 'Stopp entfernen',
+ 'roadtrip.summary.distance': 'Strecke',
+ 'roadtrip.summary.driving': 'Fahrzeit',
+ 'roadtrip.day': 'Tag {number}',
+ 'roadtrip.leg.pending': 'Keine Route',
+ 'roadtrip.stop.stay': 'Aufenthalt an diesem Stopp',
+ 'roadtrip.stop.pinned': 'Von dir gesetzte Zeit',
+ 'roadtrip.stop.computed': 'Aus der Fahrzeit gerechnet',
+ 'roadtrip.warn.late': 'Ankunft {minutes} min nach der gesetzten Zeit',
+ 'roadtrip.summary.stops': 'Stopps',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts
index fbde8bee8..42b8592d7 100644
--- a/shared/src/i18n/en/admin.ts
+++ b/shared/src/i18n/en/admin.ts
@@ -625,6 +625,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.description': 'Notes, polls, chat and suggestions for planning together',
+ 'admin.addons.catalog.roadtrip.name': 'Road trip',
+ 'admin.addons.catalog.roadtrip.description': 'Plan drives with stops along the route, driving times, and arrival times that update themselves',
'admin.addons.catalog.memories.name': 'Photos (Immich)',
'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance',
'admin.addons.catalog.mcp.name': 'MCP',
diff --git a/shared/src/i18n/en/index.ts b/shared/src/i18n/en/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/en/index.ts
+++ b/shared/src/i18n/en/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts
new file mode 100644
index 000000000..309a89c84
--- /dev/null
+++ b/shared/src/i18n/en/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Road trip',
+ 'roadtrip.mode.days': 'Days',
+ 'roadtrip.mode.label': 'Plan view',
+ 'roadtrip.title': 'Road trip',
+ 'roadtrip.subtitle': 'Stops, driving times and arrival times for this trip',
+ 'roadtrip.empty.title': 'No route yet',
+ 'roadtrip.empty.body': 'Add places to a day, or a car booking with stops, and the route appears here.',
+ 'roadtrip.stops.label': 'Stops along the way',
+ 'roadtrip.stops.remove': 'Remove stop',
+ 'roadtrip.summary.distance': 'Distance',
+ 'roadtrip.summary.driving': 'Driving time',
+ 'roadtrip.day': 'Day {number}',
+ 'roadtrip.leg.pending': 'No route',
+ 'roadtrip.stop.stay': 'Time at this stop',
+ 'roadtrip.stop.pinned': 'Time you set',
+ 'roadtrip.stop.computed': 'Calculated from the drive',
+ 'roadtrip.warn.late': 'Arrives {minutes} min after the time you set',
+ 'roadtrip.summary.stops': 'Stops',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts
index 3da66d734..92032e06c 100644
--- a/shared/src/i18n/es/admin.ts
+++ b/shared/src/i18n/es/admin.ts
@@ -702,6 +702,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Mapa del mundo con los países visitados y estadísticas de viaje',
'admin.addons.catalog.collab.name': 'Colaboración',
'admin.addons.catalog.collab.description': 'Notas, encuestas, chat y sugerencias para planificar en grupo',
+ 'admin.addons.catalog.roadtrip.name': 'Viaje por carretera',
+ 'admin.addons.catalog.roadtrip.description': 'Planifica trayectos con paradas: los tiempos de conducción y las horas de llegada se recalculan solos',
'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña',
'admin.oidcOnlyModeHint':
'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.',
diff --git a/shared/src/i18n/es/index.ts b/shared/src/i18n/es/index.ts
index ee2ee75ac..db8e8d078 100644
--- a/shared/src/i18n/es/index.ts
+++ b/shared/src/i18n/es/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts
new file mode 100644
index 000000000..4d057549d
--- /dev/null
+++ b/shared/src/i18n/es/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Viaje por carretera',
+ 'roadtrip.mode.days': 'Días',
+ 'roadtrip.mode.label': 'Vista del plan',
+ 'roadtrip.title': 'Viaje por carretera',
+ 'roadtrip.subtitle': 'Paradas, tiempos de conducción y horas de llegada de este viaje',
+ 'roadtrip.empty.title': 'Aún no hay ruta',
+ 'roadtrip.empty.body': 'Añade lugares a un día o una reserva de coche con paradas y la ruta aparecerá aquí.',
+ 'roadtrip.stops.label': 'Paradas por el camino',
+ 'roadtrip.stops.remove': 'Eliminar parada',
+ 'roadtrip.summary.distance': 'Distancia',
+ 'roadtrip.summary.driving': 'Tiempo de conducción',
+ 'roadtrip.day': 'Día {number}',
+ 'roadtrip.leg.pending': 'Sin ruta',
+ 'roadtrip.stop.stay': 'Tiempo en la parada',
+ 'roadtrip.stop.pinned': 'Hora fijada por ti',
+ 'roadtrip.stop.computed': 'Calculado a partir del trayecto',
+ 'roadtrip.warn.late': 'Llega {minutes} min después de la hora fijada',
+ 'roadtrip.summary.stops': 'Paradas',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts
index 8c65706d4..7d82971a6 100644
--- a/shared/src/i18n/fr/admin.ts
+++ b/shared/src/i18n/fr/admin.ts
@@ -598,6 +598,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Carte du monde avec pays visités et statistiques de voyage',
'admin.addons.catalog.collab.name': 'Collaboration',
'admin.addons.catalog.collab.description': 'Notes, sondages, chat et suggestions pour planifier à plusieurs',
+ 'admin.addons.catalog.roadtrip.name': 'Road trip',
+ 'admin.addons.catalog.roadtrip.description': 'Planifiez des trajets avec étapes : temps de conduite et heures d’arrivée se recalculent seuls',
'admin.addons.subtitleBefore': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience ',
'admin.addons.subtitleAfter': '.',
'admin.addons.catalog.naver_list_import.name': 'Naver List Import',
diff --git a/shared/src/i18n/fr/index.ts b/shared/src/i18n/fr/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/fr/index.ts
+++ b/shared/src/i18n/fr/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts
new file mode 100644
index 000000000..5f7913c4a
--- /dev/null
+++ b/shared/src/i18n/fr/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Road trip',
+ 'roadtrip.mode.days': 'Jours',
+ 'roadtrip.mode.label': 'Vue du plan',
+ 'roadtrip.title': 'Road trip',
+ 'roadtrip.subtitle': 'Étapes, temps de conduite et heures d’arrivée de ce voyage',
+ 'roadtrip.empty.title': 'Pas encore d’itinéraire',
+ 'roadtrip.empty.body': 'Ajoutez des lieux à une journée ou une location de voiture avec des étapes : l’itinéraire apparaîtra ici.',
+ 'roadtrip.stops.label': 'Étapes en chemin',
+ 'roadtrip.stops.remove': 'Supprimer l’étape',
+ 'roadtrip.summary.distance': 'Distance',
+ 'roadtrip.summary.driving': 'Temps de conduite',
+ 'roadtrip.day': 'Jour {number}',
+ 'roadtrip.leg.pending': 'Pas d’itinéraire',
+ '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.warn.late': 'Arrivée {minutes} min après l’heure fixée',
+ 'roadtrip.summary.stops': 'Étapes',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts
index 2e7cb4639..b011e0e2e 100644
--- a/shared/src/i18n/gr/admin.ts
+++ b/shared/src/i18n/gr/admin.ts
@@ -643,6 +643,8 @@ const admin: TranslationStrings = {
'Παγκόσμιος χάρτης με χώρες που έχετε επισκεφθεί και ταξιδιωτικά στατιστικά',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.description': 'Σημειώσεις, ψηφοφορίες, συνομιλία και προτάσεις για κοινό σχεδιασμό',
+ 'admin.addons.catalog.roadtrip.name': 'Οδικό ταξίδι',
+ 'admin.addons.catalog.roadtrip.description': 'Σχεδιάστε διαδρομές με στάσεις — οι χρόνοι οδήγησης και άφιξης υπολογίζονται ξανά αυτόματα',
'admin.addons.catalog.memories.name': 'Φωτογραφίες (Immich)',
'admin.addons.catalog.memories.description':
'Μοιραστείτε φωτογραφίες ταξιδιού μέσω της δικής σας εγκατάστασης Immich',
diff --git a/shared/src/i18n/gr/index.ts b/shared/src/i18n/gr/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/gr/index.ts
+++ b/shared/src/i18n/gr/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts
new file mode 100644
index 000000000..f2e25b6dd
--- /dev/null
+++ b/shared/src/i18n/gr/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Οδικό ταξίδι',
+ 'roadtrip.mode.days': 'Ημέρες',
+ 'roadtrip.mode.label': 'Προβολή σχεδίου',
+ 'roadtrip.title': 'Οδικό ταξίδι',
+ 'roadtrip.subtitle': 'Στάσεις, χρόνοι οδήγησης και ώρες άφιξης αυτού του ταξιδιού',
+ 'roadtrip.empty.title': 'Δεν υπάρχει διαδρομή ακόμη',
+ 'roadtrip.empty.body': 'Προσθέστε τοποθεσίες σε μια ημέρα ή μια κράτηση αυτοκινήτου με στάσεις και η διαδρομή θα εμφανιστεί εδώ.',
+ 'roadtrip.stops.label': 'Στάσεις στη διαδρομή',
+ 'roadtrip.stops.remove': 'Αφαίρεση στάσης',
+ 'roadtrip.summary.distance': 'Απόσταση',
+ 'roadtrip.summary.driving': 'Χρόνος οδήγησης',
+ 'roadtrip.day': 'Ημέρα {number}',
+ 'roadtrip.leg.pending': 'Χωρίς διαδρομή',
+ 'roadtrip.stop.stay': 'Χρόνος σε αυτή τη στάση',
+ 'roadtrip.stop.pinned': 'Ώρα που ορίσατε',
+ 'roadtrip.stop.computed': 'Υπολογισμένη από τη διαδρομή',
+ 'roadtrip.warn.late': 'Άφιξη {minutes} λεπτά μετά την ορισμένη ώρα',
+ 'roadtrip.summary.stops': 'Στάσεις',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts
index 07ebcc6ac..ce672b25b 100644
--- a/shared/src/i18n/hu/admin.ts
+++ b/shared/src/i18n/hu/admin.ts
@@ -593,6 +593,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Világtérkép meglátogatott országokkal és utazási statisztikákkal',
'admin.addons.catalog.collab.name': 'Együttműködés',
'admin.addons.catalog.collab.description': 'Jegyzetek, szavazások, csevegés és javaslatok a közös tervezéshez',
+ 'admin.addons.catalog.roadtrip.name': 'Autós út',
+ 'admin.addons.catalog.roadtrip.description': 'Tervezz megállókkal tarkított utakat – a vezetési és érkezési idők maguktól újraszámolódnak',
'admin.addons.catalog.memories.name': 'Fotók (Immich)',
'admin.addons.catalog.memories.description': 'Utazási fotók megosztása az Immich példányon keresztül',
'admin.addons.catalog.mcp.name': 'MCP',
diff --git a/shared/src/i18n/hu/index.ts b/shared/src/i18n/hu/index.ts
index f7465a624..1bb07ab5e 100644
--- a/shared/src/i18n/hu/index.ts
+++ b/shared/src/i18n/hu/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts
new file mode 100644
index 000000000..372f6a884
--- /dev/null
+++ b/shared/src/i18n/hu/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Autós út',
+ 'roadtrip.mode.days': 'Napok',
+ 'roadtrip.mode.label': 'Terv nézet',
+ 'roadtrip.title': 'Autós út',
+ 'roadtrip.subtitle': 'Az utazás megállói, vezetési idői és érkezési időpontjai',
+ 'roadtrip.empty.title': 'Még nincs útvonal',
+ 'roadtrip.empty.body': 'Adj helyszíneket egy naphoz, vagy hozz létre megállókkal rendelkező autós foglalást – az útvonal itt jelenik meg.',
+ 'roadtrip.stops.label': 'Megállók útközben',
+ 'roadtrip.stops.remove': 'Megálló eltávolítása',
+ 'roadtrip.summary.distance': 'Távolság',
+ 'roadtrip.summary.driving': 'Vezetési idő',
+ 'roadtrip.day': '{number}. nap',
+ 'roadtrip.leg.pending': 'Nincs útvonal',
+ '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.warn.late': 'Érkezés {minutes} perccel a megadott idő után',
+ 'roadtrip.summary.stops': 'Megállók',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts
index 9fa736f9d..1665df127 100644
--- a/shared/src/i18n/id/admin.ts
+++ b/shared/src/i18n/id/admin.ts
@@ -635,6 +635,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Peta dunia dengan negara yang pernah dikunjungi dan statistik perjalanan',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.description': 'Catatan, jajak pendapat, obrolan, dan saran untuk merencanakan bersama',
+ 'admin.addons.catalog.roadtrip.name': 'Perjalanan darat',
+ 'admin.addons.catalog.roadtrip.description': 'Rencanakan perjalanan dengan perhentian; waktu berkendara dan tiba dihitung ulang otomatis',
'admin.addons.catalog.memories.name': 'Foto (Immich)',
'admin.addons.catalog.memories.description': 'Bagikan foto perjalanan melalui instans Immich kamu',
'admin.addons.catalog.mcp.name': 'MCP',
diff --git a/shared/src/i18n/id/index.ts b/shared/src/i18n/id/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/id/index.ts
+++ b/shared/src/i18n/id/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts
new file mode 100644
index 000000000..74b80bda9
--- /dev/null
+++ b/shared/src/i18n/id/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Perjalanan darat',
+ 'roadtrip.mode.days': 'Hari',
+ 'roadtrip.mode.label': 'Tampilan rencana',
+ 'roadtrip.title': 'Perjalanan darat',
+ 'roadtrip.subtitle': 'Perhentian, waktu berkendara, dan waktu tiba untuk perjalanan ini',
+ 'roadtrip.empty.title': 'Belum ada rute',
+ 'roadtrip.empty.body': 'Tambahkan tempat ke sebuah hari, atau pemesanan mobil dengan perhentian, dan rute akan muncul di sini.',
+ 'roadtrip.stops.label': 'Perhentian di sepanjang jalan',
+ 'roadtrip.stops.remove': 'Hapus perhentian',
+ 'roadtrip.summary.distance': 'Jarak',
+ 'roadtrip.summary.driving': 'Waktu berkendara',
+ 'roadtrip.day': 'Hari {number}',
+ 'roadtrip.leg.pending': 'Tidak ada rute',
+ 'roadtrip.stop.stay': 'Waktu di perhentian ini',
+ 'roadtrip.stop.pinned': 'Waktu yang Anda tetapkan',
+ 'roadtrip.stop.computed': 'Dihitung dari waktu berkendara',
+ 'roadtrip.warn.late': 'Tiba {minutes} menit setelah waktu yang ditetapkan',
+ 'roadtrip.summary.stops': 'Perhentian',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts
index 60488a250..6c274f7ff 100644
--- a/shared/src/i18n/it/admin.ts
+++ b/shared/src/i18n/it/admin.ts
@@ -589,6 +589,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Mappa del mondo con paesi visitati e statistiche di viaggio',
'admin.addons.catalog.collab.name': 'Collaborazione',
'admin.addons.catalog.collab.description': 'Note, sondaggi, chat e suggerimenti per pianificare insieme',
+ 'admin.addons.catalog.roadtrip.name': 'Viaggio su strada',
+ 'admin.addons.catalog.roadtrip.description': 'Pianifica viaggi con soste: tempi di guida e orari di arrivo si ricalcolano da soli',
'admin.addons.catalog.memories.name': 'Foto (Immich)',
'admin.addons.catalog.memories.description': 'Condividi le foto del viaggio tramite la tua istanza Immich',
'admin.addons.catalog.mcp.name': 'MCP',
diff --git a/shared/src/i18n/it/index.ts b/shared/src/i18n/it/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/it/index.ts
+++ b/shared/src/i18n/it/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts
new file mode 100644
index 000000000..efb652dfc
--- /dev/null
+++ b/shared/src/i18n/it/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Viaggio su strada',
+ 'roadtrip.mode.days': 'Giorni',
+ 'roadtrip.mode.label': 'Vista del piano',
+ 'roadtrip.title': 'Viaggio su strada',
+ 'roadtrip.subtitle': 'Soste, tempi di guida e orari di arrivo di questo viaggio',
+ 'roadtrip.empty.title': 'Nessun percorso',
+ 'roadtrip.empty.body': 'Aggiungi luoghi a un giorno o una prenotazione auto con soste: il percorso comparirà qui.',
+ 'roadtrip.stops.label': 'Soste lungo il tragitto',
+ 'roadtrip.stops.remove': 'Rimuovi sosta',
+ 'roadtrip.summary.distance': 'Distanza',
+ 'roadtrip.summary.driving': 'Tempo di guida',
+ 'roadtrip.day': 'Giorno {number}',
+ 'roadtrip.leg.pending': 'Nessun percorso',
+ 'roadtrip.stop.stay': 'Tempo alla sosta',
+ 'roadtrip.stop.pinned': 'Orario impostato da te',
+ 'roadtrip.stop.computed': 'Calcolato dal tragitto',
+ 'roadtrip.warn.late': 'Arrivo {minutes} min dopo l’orario impostato',
+ 'roadtrip.summary.stops': 'Soste',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts
index ed5e3b502..47fdbf966 100644
--- a/shared/src/i18n/ja/admin.ts
+++ b/shared/src/i18n/ja/admin.ts
@@ -599,6 +599,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': '訪問国と旅行統計の世界地図',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.description': 'メモ、投票、チャット、提案で一緒に計画',
+ 'admin.addons.catalog.roadtrip.name': 'ロードトリップ',
+ 'admin.addons.catalog.roadtrip.description': '立ち寄り先付きのドライブを計画。運転時間と到着時刻は自動で再計算されます',
'admin.addons.catalog.memories.name': '写真(Immich)',
'admin.addons.catalog.memories.description': 'Immichで旅行写真を共有',
'admin.addons.catalog.mcp.name': 'MCP',
diff --git a/shared/src/i18n/ja/index.ts b/shared/src/i18n/ja/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/ja/index.ts
+++ b/shared/src/i18n/ja/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts
new file mode 100644
index 000000000..7f84c65c4
--- /dev/null
+++ b/shared/src/i18n/ja/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'ロードトリップ',
+ 'roadtrip.mode.days': '日ごと',
+ 'roadtrip.mode.label': 'プラン表示',
+ 'roadtrip.title': 'ロードトリップ',
+ 'roadtrip.subtitle': 'この旅行の立ち寄り先、運転時間、到着時刻',
+ 'roadtrip.empty.title': 'ルートはまだありません',
+ 'roadtrip.empty.body': '日に場所を追加するか、立ち寄り先付きの車の予約を作成すると、ここにルートが表示されます。',
+ 'roadtrip.stops.label': '途中の立ち寄り先',
+ 'roadtrip.stops.remove': '立ち寄り先を削除',
+ 'roadtrip.summary.distance': '距離',
+ 'roadtrip.summary.driving': '運転時間',
+ 'roadtrip.day': '{number} 日目',
+ 'roadtrip.leg.pending': 'ルートなし',
+ 'roadtrip.stop.stay': 'この立ち寄り先での滞在時間',
+ 'roadtrip.stop.pinned': '設定した時刻',
+ 'roadtrip.stop.computed': '運転時間からの計算値',
+ 'roadtrip.warn.late': '設定した時刻より {minutes} 分遅く到着します',
+ 'roadtrip.summary.stops': '立ち寄り先',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts
index 1418399ac..89e295663 100644
--- a/shared/src/i18n/ko/admin.ts
+++ b/shared/src/i18n/ko/admin.ts
@@ -599,6 +599,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': '방문한 나라와 여행 통계가 있는 세계 지도',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.description': '메모, 투표, 채팅, 제안으로 함께 계획하기',
+ 'admin.addons.catalog.roadtrip.name': '로드트립',
+ 'admin.addons.catalog.roadtrip.description': '경유지가 있는 주행을 계획하세요. 운전 시간과 도착 시간이 자동으로 다시 계산됩니다',
'admin.addons.catalog.memories.name': '사진 (Immich)',
'admin.addons.catalog.memories.description': 'Immich 인스턴스를 통해 여행 사진 공유',
'admin.addons.catalog.mcp.name': 'MCP',
diff --git a/shared/src/i18n/ko/index.ts b/shared/src/i18n/ko/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/ko/index.ts
+++ b/shared/src/i18n/ko/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts
new file mode 100644
index 000000000..f85dcde2d
--- /dev/null
+++ b/shared/src/i18n/ko/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': '로드트립',
+ 'roadtrip.mode.days': '일자별',
+ 'roadtrip.mode.label': '계획 보기',
+ 'roadtrip.title': '로드트립',
+ 'roadtrip.subtitle': '이 여행의 경유지, 운전 시간 및 도착 시간',
+ 'roadtrip.empty.title': '아직 경로가 없습니다',
+ 'roadtrip.empty.body': '하루에 장소를 추가하거나 경유지가 있는 차량 예약을 만들면 여기에 경로가 표시됩니다.',
+ 'roadtrip.stops.label': '가는 길의 경유지',
+ 'roadtrip.stops.remove': '경유지 삭제',
+ 'roadtrip.summary.distance': '거리',
+ 'roadtrip.summary.driving': '운전 시간',
+ 'roadtrip.day': '{number}일차',
+ 'roadtrip.leg.pending': '경로 없음',
+ 'roadtrip.stop.stay': '이 경유지 체류 시간',
+ 'roadtrip.stop.pinned': '직접 지정한 시간',
+ 'roadtrip.stop.computed': '주행 시간으로 계산됨',
+ 'roadtrip.warn.late': '지정한 시간보다 {minutes}분 늦게 도착',
+ 'roadtrip.summary.stops': '경유지',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts
index f2f1a1f1c..d1853dc53 100644
--- a/shared/src/i18n/nl/admin.ts
+++ b/shared/src/i18n/nl/admin.ts
@@ -590,6 +590,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Wereldkaart met bezochte landen en reisstatistieken',
'admin.addons.catalog.collab.name': 'Samenwerking',
'admin.addons.catalog.collab.description': 'Notities, peilingen, chat en suggesties om samen te plannen',
+ 'admin.addons.catalog.roadtrip.name': 'Roadtrip',
+ 'admin.addons.catalog.roadtrip.description': 'Plan ritten met tussenstops; rijtijden en aankomsttijden berekenen zichzelf opnieuw',
'admin.addons.subtitleBefore': 'Schakel functies in of uit om je ',
'admin.addons.subtitleAfter': '-ervaring aan te passen.',
'admin.addons.catalog.naver_list_import.name': 'Naver List Import',
diff --git a/shared/src/i18n/nl/index.ts b/shared/src/i18n/nl/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/nl/index.ts
+++ b/shared/src/i18n/nl/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts
new file mode 100644
index 000000000..1cb81b3f5
--- /dev/null
+++ b/shared/src/i18n/nl/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Roadtrip',
+ 'roadtrip.mode.days': 'Dagen',
+ 'roadtrip.mode.label': 'Planweergave',
+ 'roadtrip.title': 'Roadtrip',
+ 'roadtrip.subtitle': 'Stops, rijtijden en aankomsttijden van deze reis',
+ 'roadtrip.empty.title': 'Nog geen route',
+ 'roadtrip.empty.body': 'Voeg plaatsen toe aan een dag of een autoboeking met stops, dan verschijnt de route hier.',
+ 'roadtrip.stops.label': 'Stops onderweg',
+ 'roadtrip.stops.remove': 'Stop verwijderen',
+ 'roadtrip.summary.distance': 'Afstand',
+ 'roadtrip.summary.driving': 'Rijtijd',
+ 'roadtrip.day': 'Dag {number}',
+ 'roadtrip.leg.pending': 'Geen route',
+ 'roadtrip.stop.stay': 'Tijd bij deze stop',
+ 'roadtrip.stop.pinned': 'Door jou gezette tijd',
+ 'roadtrip.stop.computed': 'Berekend uit de rit',
+ 'roadtrip.warn.late': 'Aankomst {minutes} min na de gezette tijd',
+ 'roadtrip.summary.stops': 'Stops',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts
index bc4b9f93b..05eb18cd4 100644
--- a/shared/src/i18n/pl/admin.ts
+++ b/shared/src/i18n/pl/admin.ts
@@ -585,6 +585,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Mapa świata z odwiedzonymi krajami i statystykami podróży',
'admin.addons.catalog.collab.name': 'Współpraca',
'admin.addons.catalog.collab.description': 'Notatki, ankiety, czat i podpowiedzi do wspólnego planowania',
+ 'admin.addons.catalog.roadtrip.name': 'Podróż samochodowa',
+ 'admin.addons.catalog.roadtrip.description': 'Planuj przejazdy z przystankami — czasy jazdy i godziny przyjazdu przeliczają się same',
'admin.addons.catalog.memories.name': 'Zdjęcia (Immich)',
'admin.addons.catalog.memories.description': 'Udostępniaj zdjęcia z podróży za pośrednictwem swojej instancji Immich',
'admin.addons.catalog.mcp.name': 'MCP',
diff --git a/shared/src/i18n/pl/index.ts b/shared/src/i18n/pl/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/pl/index.ts
+++ b/shared/src/i18n/pl/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts
new file mode 100644
index 000000000..81cfe8d84
--- /dev/null
+++ b/shared/src/i18n/pl/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Podróż samochodowa',
+ 'roadtrip.mode.days': 'Dni',
+ 'roadtrip.mode.label': 'Widok planu',
+ 'roadtrip.title': 'Podróż samochodowa',
+ 'roadtrip.subtitle': 'Przystanki, czasy jazdy i godziny przyjazdu tej podróży',
+ 'roadtrip.empty.title': 'Brak trasy',
+ 'roadtrip.empty.body': 'Dodaj miejsca do dnia albo rezerwację samochodu z przystankami, a trasa pojawi się tutaj.',
+ 'roadtrip.stops.label': 'Przystanki po drodze',
+ 'roadtrip.stops.remove': 'Usuń przystanek',
+ 'roadtrip.summary.distance': 'Dystans',
+ 'roadtrip.summary.driving': 'Czas jazdy',
+ 'roadtrip.day': 'Dzień {number}',
+ 'roadtrip.leg.pending': 'Brak trasy',
+ 'roadtrip.stop.stay': 'Czas na tym przystanku',
+ 'roadtrip.stop.pinned': 'Godzina ustawiona przez Ciebie',
+ 'roadtrip.stop.computed': 'Obliczone na podstawie jazdy',
+ 'roadtrip.warn.late': 'Przyjazd {minutes} min po ustawionej godzinie',
+ 'roadtrip.summary.stops': 'Przystanki',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts
index dab6ebad1..ef468af8b 100644
--- a/shared/src/i18n/ru/admin.ts
+++ b/shared/src/i18n/ru/admin.ts
@@ -593,6 +593,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Карта мира с посещёнными странами и статистикой путешествий',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.description': 'Заметки, опросы, чат и подсказки для совместного планирования',
+ 'admin.addons.catalog.roadtrip.name': 'Автопутешествие',
+ 'admin.addons.catalog.roadtrip.description': 'Планируйте поездки с остановками: время в пути и прибытия пересчитывается автоматически',
'admin.addons.subtitleBefore': 'Включайте или отключайте функции для настройки ',
'admin.addons.subtitleAfter': ' под себя.',
'admin.addons.catalog.naver_list_import.name': 'Naver List Import',
diff --git a/shared/src/i18n/ru/index.ts b/shared/src/i18n/ru/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/ru/index.ts
+++ b/shared/src/i18n/ru/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts
new file mode 100644
index 000000000..5bd7b0b6c
--- /dev/null
+++ b/shared/src/i18n/ru/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Автопутешествие',
+ 'roadtrip.mode.days': 'Дни',
+ 'roadtrip.mode.label': 'Вид плана',
+ 'roadtrip.title': 'Автопутешествие',
+ 'roadtrip.subtitle': 'Остановки, время в пути и время прибытия для этой поездки',
+ 'roadtrip.empty.title': 'Маршрута пока нет',
+ 'roadtrip.empty.body': 'Добавьте места в день или бронирование авто с остановками — маршрут появится здесь.',
+ 'roadtrip.stops.label': 'Остановки в пути',
+ 'roadtrip.stops.remove': 'Удалить остановку',
+ 'roadtrip.summary.distance': 'Расстояние',
+ 'roadtrip.summary.driving': 'Время в пути',
+ 'roadtrip.day': 'День {number}',
+ 'roadtrip.leg.pending': 'Нет маршрута',
+ 'roadtrip.stop.stay': 'Время на остановке',
+ 'roadtrip.stop.pinned': 'Заданное вами время',
+ 'roadtrip.stop.computed': 'Рассчитано по времени в пути',
+ 'roadtrip.warn.late': 'Прибытие на {minutes} мин позже заданного времени',
+ 'roadtrip.summary.stops': 'Остановки',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts
index 4f951a9d7..aa74e72d9 100644
--- a/shared/src/i18n/sv/admin.ts
+++ b/shared/src/i18n/sv/admin.ts
@@ -646,6 +646,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Världskarta med besökta länder och resestatistik',
'admin.addons.catalog.collab.name': 'Samarbete',
'admin.addons.catalog.collab.description': 'Anteckningar, omröstningar, chatt och förslag för gemensam planering',
+ 'admin.addons.catalog.roadtrip.name': 'Bilresa',
+ 'admin.addons.catalog.roadtrip.description': 'Planera körningar med stopp – körtider och ankomsttider räknas om automatiskt',
'admin.addons.catalog.memories.name': 'Foton (Immich)',
'admin.addons.catalog.memories.description': 'Dela resefoton via din Immich-instans',
'admin.addons.catalog.mcp.name': 'MCP',
diff --git a/shared/src/i18n/sv/index.ts b/shared/src/i18n/sv/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/sv/index.ts
+++ b/shared/src/i18n/sv/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts
new file mode 100644
index 000000000..7c9ae9b85
--- /dev/null
+++ b/shared/src/i18n/sv/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Bilresa',
+ 'roadtrip.mode.days': 'Dagar',
+ 'roadtrip.mode.label': 'Planvy',
+ 'roadtrip.title': 'Bilresa',
+ 'roadtrip.subtitle': 'Stopp, körtider och ankomsttider för den här resan',
+ 'roadtrip.empty.title': 'Ingen rutt ännu',
+ 'roadtrip.empty.body': 'Lägg till platser på en dag eller en bilbokning med stopp, så visas rutten här.',
+ 'roadtrip.stops.label': 'Stopp längs vägen',
+ 'roadtrip.stops.remove': 'Ta bort stopp',
+ 'roadtrip.summary.distance': 'Sträcka',
+ 'roadtrip.summary.driving': 'Körtid',
+ 'roadtrip.day': 'Dag {number}',
+ 'roadtrip.leg.pending': 'Ingen rutt',
+ 'roadtrip.stop.stay': 'Tid på stoppet',
+ 'roadtrip.stop.pinned': 'Tid du har satt',
+ 'roadtrip.stop.computed': 'Beräknad utifrån körningen',
+ 'roadtrip.warn.late': 'Ankomst {minutes} min efter den satta tiden',
+ 'roadtrip.summary.stops': 'Stopp',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts
index 147c5dcee..65483893a 100644
--- a/shared/src/i18n/tr/admin.ts
+++ b/shared/src/i18n/tr/admin.ts
@@ -631,6 +631,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Ziyaret edilen ülkeleri ve seyahat istatistiklerini içeren dünya haritası',
'admin.addons.catalog.collab.name': 'İş birliği',
'admin.addons.catalog.collab.description': 'Birlikte planlamak için notlar, anketler, sohbet ve öneriler',
+ 'admin.addons.catalog.roadtrip.name': 'Yol gezisi',
+ 'admin.addons.catalog.roadtrip.description': 'Duraklı sürüşler planlayın; sürüş ve varış saatleri kendiliğinden yeniden hesaplanır',
'admin.addons.catalog.memories.name': 'Fotoğraflar (Immich)',
'admin.addons.catalog.memories.description': 'Immich örneğiniz aracılığıyla gezi fotoğraflarını paylaşın',
'admin.addons.catalog.mcp.name': 'MCP',
diff --git a/shared/src/i18n/tr/index.ts b/shared/src/i18n/tr/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/tr/index.ts
+++ b/shared/src/i18n/tr/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts
new file mode 100644
index 000000000..cc8cf3d32
--- /dev/null
+++ b/shared/src/i18n/tr/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Yol gezisi',
+ 'roadtrip.mode.days': 'Günler',
+ 'roadtrip.mode.label': 'Plan görünümü',
+ 'roadtrip.title': 'Yol gezisi',
+ 'roadtrip.subtitle': 'Bu geziye ait duraklar, sürüş süreleri ve varış saatleri',
+ 'roadtrip.empty.title': 'Henüz rota yok',
+ 'roadtrip.empty.body': 'Bir güne yer ekleyin ya da duraklı bir araç rezervasyonu oluşturun; rota burada görünür.',
+ 'roadtrip.stops.label': 'Yol üzerindeki duraklar',
+ 'roadtrip.stops.remove': 'Durağı kaldır',
+ 'roadtrip.summary.distance': 'Mesafe',
+ 'roadtrip.summary.driving': 'Sürüş süresi',
+ 'roadtrip.day': '{number}. gün',
+ 'roadtrip.leg.pending': 'Rota yok',
+ 'roadtrip.stop.stay': 'Bu duraktaki süre',
+ 'roadtrip.stop.pinned': 'Belirlediğiniz saat',
+ 'roadtrip.stop.computed': 'Sürüşe göre hesaplandı',
+ 'roadtrip.warn.late': 'Belirlenen saatten {minutes} dk sonra varış',
+ 'roadtrip.summary.stops': 'Duraklar',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts
index f51cbc6ba..0a09b8db6 100644
--- a/shared/src/i18n/uk/admin.ts
+++ b/shared/src/i18n/uk/admin.ts
@@ -590,6 +590,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Карта світу з відвіданими країнами та статистикою подорожей',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.description': 'Нотатки, опитування, чат і підказки для спільного планування',
+ 'admin.addons.catalog.roadtrip.name': 'Автоподорож',
+ 'admin.addons.catalog.roadtrip.description': 'Плануйте поїздки із зупинками — час у дорозі та прибуття перераховуються автоматично',
'admin.addons.subtitleBefore': 'Увімкніть або вимкніть функції для налаштування ',
'admin.addons.subtitleAfter': ' під себе.',
'admin.addons.catalog.naver_list_import.name': 'Naver List Import',
diff --git a/shared/src/i18n/uk/index.ts b/shared/src/i18n/uk/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/uk/index.ts
+++ b/shared/src/i18n/uk/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts
new file mode 100644
index 000000000..87099c734
--- /dev/null
+++ b/shared/src/i18n/uk/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Автоподорож',
+ 'roadtrip.mode.days': 'Дні',
+ 'roadtrip.mode.label': 'Вигляд плану',
+ 'roadtrip.title': 'Автоподорож',
+ 'roadtrip.subtitle': 'Зупинки, час у дорозі та час прибуття для цієї подорожі',
+ 'roadtrip.empty.title': 'Маршруту ще немає',
+ 'roadtrip.empty.body': 'Додайте місця до дня або бронювання авто із зупинками — маршрут з’явиться тут.',
+ 'roadtrip.stops.label': 'Зупинки в дорозі',
+ 'roadtrip.stops.remove': 'Видалити зупинку',
+ 'roadtrip.summary.distance': 'Відстань',
+ 'roadtrip.summary.driving': 'Час у дорозі',
+ 'roadtrip.day': 'День {number}',
+ 'roadtrip.leg.pending': 'Немає маршруту',
+ 'roadtrip.stop.stay': 'Час на зупинці',
+ 'roadtrip.stop.pinned': 'Час, який ви задали',
+ 'roadtrip.stop.computed': 'Розраховано за часом у дорозі',
+ 'roadtrip.warn.late': 'Прибуття на {minutes} хв пізніше заданого часу',
+ 'roadtrip.summary.stops': 'Зупинки',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts
index 05dc628d5..f2db71ce5 100644
--- a/shared/src/i18n/vi/admin.ts
+++ b/shared/src/i18n/vi/admin.ts
@@ -588,6 +588,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': 'Bản đồ thế giới với các quốc gia đã ghé thăm và số liệu thống kê du lịch',
'admin.addons.catalog.collab.name': 'Cộng tác',
'admin.addons.catalog.collab.description': 'Ghi chú, bình chọn, trò chuyện và gợi ý để cùng lên kế hoạch',
+ 'admin.addons.catalog.roadtrip.name': 'Chuyến đi đường bộ',
+ 'admin.addons.catalog.roadtrip.description': 'Lên kế hoạch lái xe có điểm dừng; thời gian lái và giờ đến tự tính lại',
'admin.addons.catalog.memories.name': 'Hình ảnh (Immich)',
'admin.addons.catalog.memories.description': 'Chia sẻ ảnh chuyến đi qua phiên bản Immich của bạn',
'admin.addons.catalog.mcp.name': 'MCP',
diff --git a/shared/src/i18n/vi/index.ts b/shared/src/i18n/vi/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/vi/index.ts
+++ b/shared/src/i18n/vi/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts
new file mode 100644
index 000000000..de8ff072a
--- /dev/null
+++ b/shared/src/i18n/vi/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': 'Chuyến đi đường bộ',
+ 'roadtrip.mode.days': 'Ngày',
+ 'roadtrip.mode.label': 'Chế độ xem kế hoạch',
+ 'roadtrip.title': 'Chuyến đi đường bộ',
+ 'roadtrip.subtitle': 'Điểm dừng, thời gian lái xe và giờ đến của chuyến đi này',
+ 'roadtrip.empty.title': 'Chưa có lộ trình',
+ 'roadtrip.empty.body': 'Thêm địa điểm vào một ngày hoặc đặt xe có điểm dừng, lộ trình sẽ hiện ở đây.',
+ 'roadtrip.stops.label': 'Điểm dừng dọc đường',
+ 'roadtrip.stops.remove': 'Xóa điểm dừng',
+ 'roadtrip.summary.distance': 'Quãng đường',
+ 'roadtrip.summary.driving': 'Thời gian lái xe',
+ 'roadtrip.day': 'Ngày {number}',
+ 'roadtrip.leg.pending': 'Không có lộ trình',
+ '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.warn.late': 'Đến muộn {minutes} phút so với giờ đã đặt',
+ 'roadtrip.summary.stops': 'Điểm dừng',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts
index 88501e474..dff431030 100644
--- a/shared/src/i18n/zh-TW/admin.ts
+++ b/shared/src/i18n/zh-TW/admin.ts
@@ -585,6 +585,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': '標記已訪問國家和旅行統計的世界地圖',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.description': '備註、投票、聊天與建議,方便一起規劃',
+ 'admin.addons.catalog.roadtrip.name': '公路旅行',
+ 'admin.addons.catalog.roadtrip.description': '規劃含停靠點的駕駛行程,駕駛時間與抵達時間會自動重算',
'admin.addons.subtitleBefore': '啟用或停用功能以自定義你的 ',
'admin.addons.subtitleAfter': ' 體驗。',
'admin.addons.catalog.naver_list_import.name': 'Naver List Import',
diff --git a/shared/src/i18n/zh-TW/index.ts b/shared/src/i18n/zh-TW/index.ts
index d6370b596..d32ec8c27 100644
--- a/shared/src/i18n/zh-TW/index.ts
+++ b/shared/src/i18n/zh-TW/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts
new file mode 100644
index 000000000..bc073dc08
--- /dev/null
+++ b/shared/src/i18n/zh-TW/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': '公路旅行',
+ 'roadtrip.mode.days': '依日',
+ 'roadtrip.mode.label': '計畫檢視',
+ 'roadtrip.title': '公路旅行',
+ 'roadtrip.subtitle': '本次行程的停靠點、駕駛時間與抵達時間',
+ 'roadtrip.empty.title': '尚無路線',
+ 'roadtrip.empty.body': '為某一天新增地點,或建立含停靠點的租車預訂,路線就會顯示在這裡。',
+ 'roadtrip.stops.label': '沿途停靠點',
+ 'roadtrip.stops.remove': '移除停靠點',
+ 'roadtrip.summary.distance': '距離',
+ 'roadtrip.summary.driving': '駕駛時間',
+ 'roadtrip.day': '第 {number} 天',
+ 'roadtrip.leg.pending': '無路線',
+ 'roadtrip.stop.stay': '在此停留時間',
+ 'roadtrip.stop.pinned': '你設定的時間',
+ 'roadtrip.stop.computed': '依行駛時間推算',
+ 'roadtrip.warn.late': '比設定時間晚到 {minutes} 分鐘',
+ 'roadtrip.summary.stops': '停靠點',
+};
+export default roadtrip;
diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts
index 403d3da93..5f4c151ee 100644
--- a/shared/src/i18n/zh/admin.ts
+++ b/shared/src/i18n/zh/admin.ts
@@ -545,6 +545,8 @@ const admin: TranslationStrings = {
'admin.addons.catalog.atlas.description': '标记已访问国家和旅行统计的世界地图',
'admin.addons.catalog.collab.name': '协作',
'admin.addons.catalog.collab.description': '备忘、投票、聊天和建议,方便一起规划',
+ 'admin.addons.catalog.roadtrip.name': '公路旅行',
+ 'admin.addons.catalog.roadtrip.description': '规划带停靠点的驾驶行程,驾驶时间和到达时间会自动重算',
'admin.addons.subtitleBefore': '启用或禁用功能以自定义你的 ',
'admin.addons.subtitleAfter': ' 体验。',
'admin.addons.catalog.naver_list_import.name': 'Naver List Import',
diff --git a/shared/src/i18n/zh/index.ts b/shared/src/i18n/zh/index.ts
index 11ef2a989..7580137eb 100644
--- a/shared/src/i18n/zh/index.ts
+++ b/shared/src/i18n/zh/index.ts
@@ -37,6 +37,7 @@ import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
+import roadtrip from './roadtrip';
import reservations from './reservations';
import settings from './settings';
import share from './share';
@@ -56,6 +57,7 @@ const locale = {
...trips,
...nav,
...dashboard,
+ ...roadtrip,
...settings,
...admin,
...dayplan,
diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts
new file mode 100644
index 000000000..22c7ca0f2
--- /dev/null
+++ b/shared/src/i18n/zh/roadtrip.ts
@@ -0,0 +1,23 @@
+import type { TranslationStrings } from '../types';
+
+const roadtrip: TranslationStrings = {
+ 'roadtrip.mode.roadtrip': '公路旅行',
+ 'roadtrip.mode.days': '按天',
+ 'roadtrip.mode.label': '计划视图',
+ 'roadtrip.title': '公路旅行',
+ 'roadtrip.subtitle': '本次行程的停靠点、驾驶时间和到达时间',
+ 'roadtrip.empty.title': '还没有路线',
+ 'roadtrip.empty.body': '为某一天添加地点,或创建带停靠点的租车预订,路线就会显示在这里。',
+ 'roadtrip.stops.label': '沿途停靠点',
+ 'roadtrip.stops.remove': '移除停靠点',
+ 'roadtrip.summary.distance': '距离',
+ 'roadtrip.summary.driving': '驾驶时间',
+ 'roadtrip.day': '第 {number} 天',
+ 'roadtrip.leg.pending': '无路线',
+ 'roadtrip.stop.stay': '在此停留时间',
+ 'roadtrip.stop.pinned': '你设定的时间',
+ 'roadtrip.stop.computed': '根据行驶时间推算',
+ 'roadtrip.warn.late': '比设定时间晚到 {minutes} 分钟',
+ 'roadtrip.summary.stops': '停靠点',
+};
+export default roadtrip;