From 3902e7799138b9b4fd74320e917cf4d8190728ae Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 21:35:16 +0200 Subject: [PATCH] feat(roadtrip): add the road trip addon with a drive-first plan view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trip planner could show one day's route at a time and nothing across days, so a road trip had to be pieced together in Google Maps (#1797, #435). The addon adds a mode to the plan view rather than a tab of its own: the map stays the workspace and gains the route over the whole trip, while the left rail becomes one chain of stops with the driving distance and time between them and arrival times that follow from them. A time pinned on a stop is kept and restarts the chain, so a ferry or a museum ticket stays where the user put it. Car bookings can finally carry the stops the schema has always allowed: the endpoints table has held role/sequence for every transport type since #384, but only flights and trains had an editor for them. The MCP tools already accepted them, so this closes the gap the other way round. Legs are fetched one at a time and spaced out — the public routing hosts answer a whole trip's worth of requests with 429 — and the distance cap now looks at the single leg instead of the sum, which is what dropped long drives entirely. --- .../Planner/TransportModal.test.tsx | 46 ++++ .../src/components/Planner/TransportModal.tsx | 83 +++++- .../Roadtrip/RoadtripModeSwitch.tsx | 47 ++++ .../components/Roadtrip/RoadtripSidebar.tsx | 183 +++++++++++++ .../components/Roadtrip/roadtripModel.test.ts | 184 +++++++++++++ .../src/components/Roadtrip/roadtripModel.ts | 132 +++++++++ .../components/Roadtrip/useRoadtripRoutes.ts | 250 ++++++++++++++++++ client/src/hooks/useTransportRoutes.ts | 17 +- client/src/pages/TripPlannerPage.tsx | 23 +- .../src/pages/tripPlanner/useTripPlanner.ts | 26 +- .../hooks/useTransportRoutes.test.ts | 27 ++ server/src/addons.ts | 1 + server/src/db/seeds.ts | 1 + shared/src/i18n/ar/admin.ts | 2 + shared/src/i18n/ar/index.ts | 2 + shared/src/i18n/ar/roadtrip.ts | 23 ++ shared/src/i18n/br/admin.ts | 2 + shared/src/i18n/br/index.ts | 2 + shared/src/i18n/br/roadtrip.ts | 23 ++ shared/src/i18n/ca/admin.ts | 2 + shared/src/i18n/ca/index.ts | 2 + shared/src/i18n/ca/roadtrip.ts | 23 ++ shared/src/i18n/cs/admin.ts | 2 + shared/src/i18n/cs/index.ts | 2 + shared/src/i18n/cs/roadtrip.ts | 23 ++ shared/src/i18n/de/admin.ts | 2 + shared/src/i18n/de/index.ts | 2 + shared/src/i18n/de/roadtrip.ts | 23 ++ shared/src/i18n/en/admin.ts | 2 + shared/src/i18n/en/index.ts | 2 + shared/src/i18n/en/roadtrip.ts | 23 ++ shared/src/i18n/es/admin.ts | 2 + shared/src/i18n/es/index.ts | 2 + shared/src/i18n/es/roadtrip.ts | 23 ++ shared/src/i18n/fr/admin.ts | 2 + shared/src/i18n/fr/index.ts | 2 + shared/src/i18n/fr/roadtrip.ts | 23 ++ shared/src/i18n/gr/admin.ts | 2 + shared/src/i18n/gr/index.ts | 2 + shared/src/i18n/gr/roadtrip.ts | 23 ++ shared/src/i18n/hu/admin.ts | 2 + shared/src/i18n/hu/index.ts | 2 + shared/src/i18n/hu/roadtrip.ts | 23 ++ shared/src/i18n/id/admin.ts | 2 + shared/src/i18n/id/index.ts | 2 + shared/src/i18n/id/roadtrip.ts | 23 ++ shared/src/i18n/it/admin.ts | 2 + shared/src/i18n/it/index.ts | 2 + shared/src/i18n/it/roadtrip.ts | 23 ++ shared/src/i18n/ja/admin.ts | 2 + shared/src/i18n/ja/index.ts | 2 + shared/src/i18n/ja/roadtrip.ts | 23 ++ shared/src/i18n/ko/admin.ts | 2 + shared/src/i18n/ko/index.ts | 2 + shared/src/i18n/ko/roadtrip.ts | 23 ++ shared/src/i18n/nl/admin.ts | 2 + shared/src/i18n/nl/index.ts | 2 + shared/src/i18n/nl/roadtrip.ts | 23 ++ shared/src/i18n/pl/admin.ts | 2 + shared/src/i18n/pl/index.ts | 2 + shared/src/i18n/pl/roadtrip.ts | 23 ++ shared/src/i18n/ru/admin.ts | 2 + shared/src/i18n/ru/index.ts | 2 + shared/src/i18n/ru/roadtrip.ts | 23 ++ shared/src/i18n/sv/admin.ts | 2 + shared/src/i18n/sv/index.ts | 2 + shared/src/i18n/sv/roadtrip.ts | 23 ++ shared/src/i18n/tr/admin.ts | 2 + shared/src/i18n/tr/index.ts | 2 + shared/src/i18n/tr/roadtrip.ts | 23 ++ shared/src/i18n/uk/admin.ts | 2 + shared/src/i18n/uk/index.ts | 2 + shared/src/i18n/uk/roadtrip.ts | 23 ++ shared/src/i18n/vi/admin.ts | 2 + shared/src/i18n/vi/index.ts | 2 + shared/src/i18n/vi/roadtrip.ts | 23 ++ shared/src/i18n/zh-TW/admin.ts | 2 + shared/src/i18n/zh-TW/index.ts | 2 + shared/src/i18n/zh-TW/roadtrip.ts | 23 ++ shared/src/i18n/zh/admin.ts | 2 + shared/src/i18n/zh/index.ts | 2 + shared/src/i18n/zh/roadtrip.ts | 23 ++ 82 files changed, 1627 insertions(+), 14 deletions(-) create mode 100644 client/src/components/Roadtrip/RoadtripModeSwitch.tsx create mode 100644 client/src/components/Roadtrip/RoadtripSidebar.tsx create mode 100644 client/src/components/Roadtrip/roadtripModel.test.ts create mode 100644 client/src/components/Roadtrip/roadtripModel.ts create mode 100644 client/src/components/Roadtrip/useRoadtripRoutes.ts create mode 100644 shared/src/i18n/ar/roadtrip.ts create mode 100644 shared/src/i18n/br/roadtrip.ts create mode 100644 shared/src/i18n/ca/roadtrip.ts create mode 100644 shared/src/i18n/cs/roadtrip.ts create mode 100644 shared/src/i18n/de/roadtrip.ts create mode 100644 shared/src/i18n/en/roadtrip.ts create mode 100644 shared/src/i18n/es/roadtrip.ts create mode 100644 shared/src/i18n/fr/roadtrip.ts create mode 100644 shared/src/i18n/gr/roadtrip.ts create mode 100644 shared/src/i18n/hu/roadtrip.ts create mode 100644 shared/src/i18n/id/roadtrip.ts create mode 100644 shared/src/i18n/it/roadtrip.ts create mode 100644 shared/src/i18n/ja/roadtrip.ts create mode 100644 shared/src/i18n/ko/roadtrip.ts create mode 100644 shared/src/i18n/nl/roadtrip.ts create mode 100644 shared/src/i18n/pl/roadtrip.ts create mode 100644 shared/src/i18n/ru/roadtrip.ts create mode 100644 shared/src/i18n/sv/roadtrip.ts create mode 100644 shared/src/i18n/tr/roadtrip.ts create mode 100644 shared/src/i18n/uk/roadtrip.ts create mode 100644 shared/src/i18n/vi/roadtrip.ts create mode 100644 shared/src/i18n/zh-TW/roadtrip.ts create mode 100644 shared/src/i18n/zh/roadtrip.ts diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index 421022873..b38cfc636 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -1192,6 +1192,52 @@ describe('TransportModal', () => { expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']); }); + it('FE-PLANNER-TRANSMODAL-058: a car booking keeps its stops in order between pick-up and return (#1797)', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^Car$/i })); + await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen Berlin → Prag'); + // Pick-up and return are the rental frame; the stops sit between them. + expect(screen.getAllByTestId('location-select')).toHaveLength(2); + await userEvent.click(screen.getByRole('button', { name: /Add stop/i })); + await userEvent.click(screen.getByRole('button', { name: /Add stop/i })); + // Pick-up and return come first in the form, the stops follow — so the saved + // route is from · stop · stop · to even though the fields read in another order. + const fields = screen.getAllByTestId('location-select'); + expect(fields).toHaveLength(4); + fireEvent.change(fields[0], { target: { value: 'Berlin' } }); + fireEvent.change(fields[1], { target: { value: 'Prag' } }); + fireEvent.change(fields[2], { target: { value: 'Dresden' } }); + fireEvent.change(fields[3], { target: { value: 'Bastei' } }); + + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'stop', 'to']); + expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]); + expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Dresden', 'Bastei', 'Prag']); + }); + + it('FE-PLANNER-TRANSMODAL-059: a stop removed from a car booking is gone from the saved route', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^Car$/i })); + await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen'); + await userEvent.click(screen.getByRole('button', { name: /Add stop/i })); + expect(screen.getAllByTestId('location-select')).toHaveLength(3); + await userEvent.click(screen.getByRole('button', { name: /Remove stop/i })); + expect(screen.getAllByTestId('location-select')).toHaveLength(2); + + const fields = screen.getAllByTestId('location-select'); + fireEvent.change(fields[0], { target: { value: 'Berlin' } }); + fireEvent.change(fields[1], { target: { value: 'Prag' } }); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']); + }); + it('FE-PLANNER-TRANSMODAL-057: the open-file link on an attached document does not throw', async () => { const res = buildReservation({ id: 23, type: 'flight', title: 'LH 400' }); const attached = buildTripFile({ id: 72, original_name: 'boarding.pdf' }); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 0263a08a9..031e80156 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -116,6 +116,19 @@ interface StationWaypointForm { seat: string confirmation_number: string } +// ── Car stops along the drive ────────────────────────────────────────────── +// +// A rental is one booking with one pick-up and one return, so — unlike a flight or a +// train — the stops in between are not legs of their own: they are places the drive +// passes through, each with an optional time the driver plans to be there. They persist +// as the same `role: 'stop'` endpoints every other transport type already uses. +interface CarStopForm { + location: LocationPoint | null + time: string +} + +const emptyCarStop = (): CarStopForm => ({ location: null, time: '' }) + function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm { return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' } } @@ -200,6 +213,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel const [waypoints, setWaypoints] = useState([emptyWaypoint(), emptyWaypoint()]) // Train route as an ordered list of stations (origin .. stops .. destination). const [trainWaypoints, setTrainWaypoints] = useState([emptyStationWaypoint(), emptyStationWaypoint()]) + // A car keeps its pick-up and return as the frame of the rental and gains the stops + // in between (#1797): one booking, one continuous drive, several places along it. + const [carStops, setCarStops] = useState([]) const [uploadingFile, setUploadingFile] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) const [showFilePicker, setShowFilePicker] = useState(false) @@ -346,6 +362,17 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel } else { setFromPick({ location: locationFromEndpoint(from) || undefined }) setToPick({ location: locationFromEndpoint(to) || undefined }) + // Stops persist for every type; only a car offers an editor for them, so only a + // car reads them back into one. The others keep passing theirs through untouched. + setCarStops( + src.type === 'car' + ? (src.endpoints ?? []) + .filter(e => e.role === 'stop') + .slice() + .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)) + .map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' })) + : [], + ) } } else { setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' }) @@ -354,6 +381,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel setToPick({}) setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')]) setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')]) + setCarStops([]) } }, [isOpen, reservation, prefill, selectedDayId, budgetItems]) @@ -504,8 +532,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel }) } else { if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null)) - // Keep the itinerary's transfer stops while the route is unchanged (#1065). - const stops = keepTransit + // A car writes the stops the driver planned; every other type keeps passing the + // itinerary's transfer stops through while the route is unchanged (#1065). + const carEndpoints = form.type === 'car' + ? carStops + .filter(s => s.location) + .map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null)) + : [] + const stops = keepTransit && form.type !== 'car' ? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0)) : [] stops.forEach((s, i) => endpoints.push({ @@ -513,7 +547,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel lat: s.lat, lng: s.lng, timezone: s.timezone ?? null, local_date: s.local_date ?? null, local_time: s.local_time ?? null, })) - if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null)) + carEndpoints.forEach(e => endpoints.push(e)) + const stopCount = stops.length + carEndpoints.length + if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null)) } // Flights and trains derive their span from the first/last waypoint; other @@ -955,6 +991,47 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel + {/* Stops along the drive — cars only (#1797). The rental frame above stays the + pick-up and return; these are the places in between, in order. */} + {form.type === 'car' && ( +
+ + {carStops.map((stop, i) => ( +
+
+ setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))} + /> +
+
+ 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 ( +
+ {options.map(([value, label, Icon]) => { + const selected = active === value + 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} +
+
{value}
+
+ ))} +
+ ) +} + +function Leg({ leg }: { leg: RouteSegment | undefined }): React.ReactElement { + const { t } = useTranslation() + const Icon = MODE_ICON[leg?.mode ?? 'driving'] ?? Car + return ( +
+ + + + {leg ? `${leg.distanceText} · ${leg.durationText}` : t('roadtrip.leg.pending')} + +
+ ) +} + +function Stop({ stop, index, entry, warning, selected, onSelect }: { + stop: RoadtripStop + index: number + entry: ScheduleEntry | undefined + warning: ScheduleWarning | undefined + selected: boolean + onSelect?: () => void +}): React.ReactElement { + const { t } = useTranslation() + const late = warning?.code === 'late' + return ( + + ) +} + +function DaySection({ day, selectedAssignmentId, onSelectStop }: { + day: RoadtripDay + selectedAssignmentId?: number | null + onSelectStop?: (placeId: number, assignmentId: number) => void +}): React.ReactElement { + const { t, language } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + return ( +
+
+

{t('roadtrip.day', { number: day.dayNumber })}

+ {day.date ? {formatDate(day.date, language)} : null} + + {formatDistance(day.distance / 1000, distanceUnit)} · {formatDurationShort(day.duration)} + +
+
+ {day.stops.map((stop, i) => ( + + w.index === i)} + selected={selectedAssignmentId === stop.assignmentId} + onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} + /> + {i < day.stops.length - 1 ? : null} + + ))} +
+
+ ) +} + +/** + * 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;