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;