From 45f1ff77990e7fc4ddbaf2ef0498b40a431f082e Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 2 Jul 2026 17:29:12 +0200 Subject: [PATCH] feat(planner): public transit routing via Transitous (#1065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each day header gets a transit button (replacing the rename pencil, which moved next to the day name in the day detail panel). It opens a route search backed by Transitous/MOTIS — free, open data, no paid provider: from/to stop search with the day's own places as quick picks, depart/arrive time, mode filters (train, subway, tram, bus, ferry, cable car) and ranking by best route, fewer transfers or less walking. Results show local times, duration, transfers, walking time and line badges in their official colors, with a stop-by-stop breakdown per connection. Adding a connection saves it as a regular transport reservation — typed by its dominant leg, timed from the itinerary's wall-clock departure/arrival converted to station-local time (tz-lookup), with the origin, transfer stops and destination as endpoints and the compact legs in metadata.transit. It slots into the day timeline by time and inherits editing, deletion and drag-reordering from the existing transport machinery; the transport detail view renders the full itinerary. Re-saving a transit transport through the edit modal preserves the stored itinerary while the route is unchanged. The server proxies the Transitous API (JWT-guarded, rate-limited, identifying User-Agent, short response cache, strict mode whitelist); TRANSIT_API_URL lets self-hosters use their own MOTIS instance. New i18n keys in every locale, wiki page updated. Discussion #1065 --- client/src/api/client.ts | 8 + .../Planner/DayDetailPanel.test.tsx | 30 + .../src/components/Planner/DayDetailPanel.tsx | 53 +- .../Planner/DayPlanSidebar.test.tsx | 58 +- .../src/components/Planner/DayPlanSidebar.tsx | 17 +- .../DayPlanSidebarTransportDetailModal.tsx | 50 +- .../Planner/TransitSearchModal.test.tsx | 151 +++++ .../components/Planner/TransitSearchModal.tsx | 584 ++++++++++++++++++ .../Planner/TransportModal.test.tsx | 42 ++ .../src/components/Planner/TransportModal.tsx | 27 +- client/src/pages/TripPlannerPage.tsx | 22 +- .../src/pages/tripPlanner/useTripPlanner.ts | 11 + client/src/types/tz-lookup.d.ts | 4 + server/src/nest/app.module.ts | 3 +- server/src/nest/transit/transit.controller.ts | 67 ++ server/src/nest/transit/transit.module.ts | 9 + server/src/services/transitService.ts | 254 ++++++++ server/tests/e2e/transit.e2e.test.ts | 96 +++ .../unit/services/transitService.test.ts | 125 ++++ shared/src/i18n/ar/trip.ts | 30 + shared/src/i18n/br/trip.ts | 30 + shared/src/i18n/cs/trip.ts | 30 + shared/src/i18n/de/trip.ts | 30 + shared/src/i18n/en/trip.ts | 30 + shared/src/i18n/es/trip.ts | 30 + shared/src/i18n/fr/trip.ts | 30 + shared/src/i18n/gr/trip.ts | 30 + shared/src/i18n/hu/trip.ts | 30 + shared/src/i18n/id/trip.ts | 30 + shared/src/i18n/it/trip.ts | 30 + shared/src/i18n/ja/trip.ts | 30 + shared/src/i18n/ko/trip.ts | 30 + shared/src/i18n/nl/trip.ts | 30 + shared/src/i18n/pl/trip.ts | 30 + shared/src/i18n/ru/trip.ts | 30 + shared/src/i18n/sv/trip.ts | 30 + shared/src/i18n/tr/trip.ts | 30 + shared/src/i18n/uk/trip.ts | 30 + shared/src/i18n/vi/trip.ts | 30 + shared/src/i18n/zh-TW/trip.ts | 30 + shared/src/i18n/zh/trip.ts | 30 + wiki/Transport-Flights-Trains-Cars.md | 11 + 42 files changed, 2227 insertions(+), 55 deletions(-) create mode 100644 client/src/components/Planner/TransitSearchModal.test.tsx create mode 100644 client/src/components/Planner/TransitSearchModal.tsx create mode 100644 client/src/types/tz-lookup.d.ts create mode 100644 server/src/nest/transit/transit.controller.ts create mode 100644 server/src/nest/transit/transit.module.ts create mode 100644 server/src/services/transitService.ts create mode 100644 server/tests/e2e/transit.e2e.test.ts create mode 100644 server/tests/unit/services/transitService.test.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 93616512a..20ee2b9cf 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -813,6 +813,14 @@ export const shareApi = { getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data), } +// Public transit routing (#1065) — Transitous/MOTIS proxied through the server. +export const transitApi = { + geocode: (q: string, opts?: { lang?: string; near?: string }) => + apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data), + plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) => + apiClient.get('/transit/plan', { params }).then(r => r.data), +} + // Trip invite links (#1143) — join a trip as an existing, logged-in user. export const tripInviteApi = { getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/invite-link`).then(r => r.data), diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index cf9642a1f..ef5aa39ec 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -71,6 +71,36 @@ describe('DayDetailPanel', () => { expect(screen.getByText('Day in Paris')).toBeInTheDocument(); }); + // ── Inline rename (#1065 — moved here from the sidebar pencil) ────────────── + + it('FE-PLANNER-DAYDETAIL-064: pencil next to the title renames the day (Enter commits)', async () => { + const user = userEvent.setup(); + const onUpdateDayTitle = vi.fn(); + render(); + await user.click(screen.getByLabelText('Edit')); + const input = await screen.findByDisplayValue('Day in Paris'); + await user.clear(input); + await user.type(input, 'New Title'); + await user.keyboard('{Enter}'); + expect(onUpdateDayTitle).toHaveBeenCalledWith(1, 'New Title'); + }); + + it('FE-PLANNER-DAYDETAIL-065: Escape cancels the rename without saving', async () => { + const user = userEvent.setup(); + const onUpdateDayTitle = vi.fn(); + render(); + await user.click(screen.getByLabelText('Edit')); + await screen.findByDisplayValue('Day in Paris'); + await user.keyboard('{Escape}'); + expect(onUpdateDayTitle).not.toHaveBeenCalled(); + expect(screen.getByText('Day in Paris')).toBeInTheDocument(); + }); + + it('FE-PLANNER-DAYDETAIL-066: no rename pencil without the onUpdateDayTitle prop', () => { + render(); + expect(screen.queryByLabelText('Edit')).not.toBeInTheDocument(); + }); + it('FE-PLANNER-DAYDETAIL-004: shows day number when title is null', () => { const untitled = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: null }); render(); diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index bc95235e7..cd5b4ead7 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -60,9 +60,11 @@ interface DayDetailPanelProps { collapsed?: boolean onToggleCollapse?: () => void mobile?: boolean + /** Rename the day from here — the sidebar pencil moved to the transit search (#1065). */ + onUpdateDayTitle?: (dayId: number, title: string) => void } -export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) { +export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false, onUpdateDayTitle }: DayDetailPanelProps) { const { t, language, locale } = useTranslation() const can = useCanDo() const tripObj = useTripStore((s) => s.trip) @@ -78,6 +80,22 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const unit = isFahrenheit ? '°F' : '°C' const collapsed = collapsedProp const toggleCollapse = () => onToggleCollapse?.() + + // Inline day rename (#1065) — took over from the sidebar's pencil, which the + // transit search button replaced. + const [editingTitle, setEditingTitle] = useState(false) + const [titleDraft, setTitleDraft] = useState('') + const titleInputRef = useRef(null) + useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle]) + const startRename = (e: React.MouseEvent) => { + e.stopPropagation() + setTitleDraft(day?.title || '') + setEditingTitle(true) + } + const commitRename = () => { + setEditingTitle(false) + if (day && onUpdateDayTitle) onUpdateDayTitle(day.id, titleDraft.trim()) + } const { weather, loading, accommodation, setAccommodation, dayAccommodations, setDayAccommodations, accommodations, setAccommodations, showHotelPicker, setShowHotelPicker, @@ -133,10 +151,35 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
-
- {day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })} - {collapsed && formattedDate && {formattedDate}} -
+ {editingTitle ? ( + setTitleDraft(e.target.value)} + onClick={e => e.stopPropagation()} + onBlur={commitRename} + onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTitle(false) }} + placeholder={t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })} + className="text-content" + style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', padding: 0, fontFamily: 'inherit', fontSize: 15, fontWeight: 700, borderBottom: '1.5px solid var(--text-primary)' }} + /> + ) : collapsed ? ( +
+ {day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })} + {formattedDate && {formattedDate}} +
+ ) : ( +
+ + {day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })} + + {canEditDays && onUpdateDayTitle && ( + + )} +
+ )} {!collapsed && formattedDate &&
{formattedDate}
}
+ {/* Public transit search (#1065) — replaced the rename pencil, + which moved next to the day name in the day detail view. */} + {onPlanTransit ? ( + + ) :
} {onAddTransport ? (
)} + {/* Public-transit itinerary (#1065) — legs from the transit search */} + {meta.transit?.legs && Array.isArray(meta.transit.legs) && meta.transit.legs.length > 0 && ( +
+
+ {t('transit.itinerary')} + {typeof meta.transit.transfers === 'number' && meta.transit.transfers > 0 && ` · ${t('transit.transfers', { count: meta.transit.transfers })}`} +
+
+ {meta.transit.legs.map((leg: { mode?: string; line?: string | null; line_color?: string | null; line_text_color?: string | null; headsign?: string | null; duration?: number; stops?: number; from?: { name?: string; time?: string | null }; to?: { name?: string; time?: string | null } }, i: number) => { + const isWalk = leg.mode === 'WALK' + const mins = leg.duration ? Math.round(leg.duration / 60) : null + return ( +
+ {isWalk ? ( + + + + ) : ( + + {leg.line || leg.mode} + + )} +
+
+ {isWalk + ? {t('transit.walkTo', { name: leg.to?.name || '' })} + : <> + {leg.from?.name} + + {leg.to?.name} + } +
+
+ {[ + leg.from?.time && !isWalk ? `${leg.from.time}${leg.to?.time ? ` – ${leg.to.time}` : ''}` : null, + mins ? t('transit.min', { count: mins }) : null, + !isWalk && leg.stops ? t('transit.stops', { count: leg.stops }) : null, + !isWalk && leg.headsign ? `→ ${leg.headsign}` : null, + ].filter(Boolean).join(' · ')} +
+
+
+ ) + })} +
+
+ )} + {/* Notizen */} {res.notes && (
diff --git a/client/src/components/Planner/TransitSearchModal.test.tsx b/client/src/components/Planner/TransitSearchModal.test.tsx new file mode 100644 index 000000000..b2bbd9b78 --- /dev/null +++ b/client/src/components/Planner/TransitSearchModal.test.tsx @@ -0,0 +1,151 @@ +// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-006 +import { render, screen, waitFor } from '../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { useAuthStore } from '../../store/authStore' +import { useSettingsStore } from '../../store/settingsStore' +import { buildUser, buildDay, buildPlace } from '../../../tests/helpers/factories' +import TransitSearchModal from './TransitSearchModal' + +const { transitApiMock } = vi.hoisted(() => ({ + transitApiMock: { geocode: vi.fn(), plan: vi.fn() }, +})) + +vi.mock('../../api/client', async (importOriginal) => { + const actual = await importOriginal() as object + return { ...actual, transitApi: transitApiMock } +}) + +vi.mock('../shared/Toast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn() }) })) + +// Berlin, summer time (UTC+2): 06:30Z departs 08:30 local, 07:00Z arrives 09:00. +const ITINERARY = { + startTime: '2025-06-01T06:30:00Z', + endTime: '2025-06-01T07:00:00Z', + duration: 1800, + transfers: 1, + walkSeconds: 240, + legs: [ + { mode: 'WALK', from: { name: 'Start', lat: 52.52, lng: 13.4, time: '2025-06-01T06:30:00Z', scheduledTime: null, track: null }, to: { name: 'Alexanderplatz', lat: 52.521, lng: 13.41, time: '2025-06-01T06:34:00Z', scheduledTime: null, track: null }, duration: 240, distance: 300, headsign: null, line: null, lineColor: null, lineTextColor: null, agency: null, intermediateStops: 0 }, + { mode: 'SUBWAY', from: { name: 'Alexanderplatz', lat: 52.521, lng: 13.41, time: '2025-06-01T06:36:00Z', scheduledTime: null, track: '2' }, to: { name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, time: '2025-06-01T07:00:00Z', scheduledTime: null, track: null }, duration: 1440, distance: null, headsign: 'Ruhleben', line: 'U2', lineColor: '#FF3300', lineTextColor: '#FFFFFF', agency: 'BVG', intermediateStops: 6 }, + ], +} + +const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' }) + +function makeProps(overrides = {}) { + return { + isOpen: true, + onClose: vi.fn(), + day, + days: [day], + places: [buildPlace({ id: 1, name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 })], + accommodations: [], + onAdd: vi.fn().mockResolvedValue({}), + ...overrides, + } +} + +async function pickFromAndTo(user: ReturnType) { + // Quick picks (the day's places) appear on focus with an empty query. + const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…') + await user.click(fromInput) + await user.click(await screen.findByText('Fernsehturm')) + + transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, type: 'STOP', area: 'Berlin' }] }) + await user.click(toInput) + await user.type(toInput, 'Zoo') + await user.click(await screen.findByText(/Zoologischer Garten/)) +} + +beforeEach(() => { + resetAllStores() + vi.clearAllMocks() + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }) + seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any) +}) + +describe('TransitSearchModal', () => { + it('FE-PLANNER-TRANSIT-001: renders from/to pickers, modes and preferences', () => { + render() + expect(screen.getByText('Public transit')).toBeInTheDocument() + expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2) + expect(screen.getByText('Subway')).toBeInTheDocument() + expect(screen.getByText('Fewer transfers')).toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSIT-002: searching lists itineraries with times, transfers and line badges', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + // Local Berlin times, U2 badge, 1 transfer. + expect(await screen.findByText(/08:30 – 09:00/)).toBeInTheDocument() + expect(screen.getByText('U2')).toBeInTheDocument() + expect(screen.getByText('1 transfers')).toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSIT-003: adding a route builds a transport payload with local times + endpoints', async () => { + const user = userEvent.setup() + const onAdd = vi.fn().mockResolvedValue({}) + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await user.click(await screen.findByText(/08:30 – 09:00/)) + await user.click(await screen.findByRole('button', { name: 'Add to day' })) + + await waitFor(() => expect(onAdd).toHaveBeenCalled()) + const payload = onAdd.mock.calls[0][0] + expect(payload.type).toBe('train') // dominant leg = SUBWAY → train + expect(payload.title).toBe('Fernsehturm → Zoologischer Garten') + expect(payload.day_id).toBe(10) + expect(payload.reservation_time).toBe('2025-06-01T08:30') + expect(payload.reservation_end_time).toBe('2025-06-01T09:00') + expect(payload.status).toBe('confirmed') + // from + to endpoints (single transit leg → no transfer stops) + expect(payload.endpoints).toHaveLength(2) + expect(payload.endpoints[0]).toMatchObject({ role: 'from', name: 'Fernsehturm', timezone: 'Europe/Berlin' }) + expect(payload.endpoints[1]).toMatchObject({ role: 'to', name: 'Zoologischer Garten' }) + // compact itinerary stored for the detail modal + expect(payload.metadata.transit.provider).toBe('transitous') + expect(payload.metadata.transit.legs).toHaveLength(2) + expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' }) + }) + + it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockRejectedValueOnce(new Error('boom')) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + expect(await screen.findByText(/No connections found/)).toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSIT-005: preference "fewer transfers" re-ranks the list', async () => { + const user = userEvent.setup() + const direct = { ...ITINERARY, startTime: '2025-06-01T06:40:00Z', endTime: '2025-06-01T07:20:00Z', duration: 2400, transfers: 0 } + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, direct] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await screen.findByText(/08:30 – 09:00/) + await user.click(screen.getByText('Fewer transfers')) + const cards = screen.getAllByText(/–/).filter(el => el.textContent?.match(/\d{2}:\d{2} – \d{2}:\d{2}/)) + // The direct (0-transfer) itinerary now ranks first. + expect(cards[0].textContent).toContain('08:40') + }) + + it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => { + const user = userEvent.setup() + render() + const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…') + await user.click(fromInput) + await user.click(await screen.findByText('Fernsehturm')) + await user.click(screen.getByLabelText('Swap')) + const inputs = screen.getAllByPlaceholderText('Search stop or station…') + expect((inputs[0] as HTMLInputElement).value).toBe('') + expect((inputs[1] as HTMLInputElement).value).toBe('Fernsehturm') + }) +}) diff --git a/client/src/components/Planner/TransitSearchModal.tsx b/client/src/components/Planner/TransitSearchModal.tsx new file mode 100644 index 000000000..163933f94 --- /dev/null +++ b/client/src/components/Planner/TransitSearchModal.tsx @@ -0,0 +1,584 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import tzlookup from 'tz-lookup' +import { ArrowLeftRight, ArrowRight, Bus, CableCar, ChevronDown, ChevronUp, Clock, Footprints, MapPin, Sailboat, Search, Train, TramFront, TrainFront } from 'lucide-react' +import Modal from '../shared/Modal' +import CustomTimePicker from '../shared/CustomTimePicker' +import { transitApi } from '../../api/client' +import { useSettingsStore } from '../../store/settingsStore' +import { useToast } from '../shared/Toast' +import { useTranslation } from '../../i18n' +import type { Day, Place, Accommodation } from '../../types' + +/** + * Public transit route search (#1065), backed by Transitous (MOTIS) through the + * server proxy — no paid providers. Google-Maps-like flow in TREK's clean style: + * pick from/to (stop search + the day's own places as quick picks), filter by + * mode and preference, compare the returned itineraries, then add the chosen + * one to the day. The result is saved as a regular transport reservation + * (endpoints + metadata.transit), so timeline slotting, editing, deleting and + * drag/drop all reuse the existing machinery. + */ + +// ── transit data shapes (mirrors the server's compact mapping) ────────────── + +interface TransitLegStop { name: string; lat: number; lng: number; time: string | null; scheduledTime: string | null; track: string | null } +interface TransitLeg { + mode: string; from: TransitLegStop; to: TransitLegStop; duration: number; distance: number | null + headsign: string | null; line: string | null; lineColor: string | null; lineTextColor: string | null + agency: string | null; intermediateStops: number +} +export interface TransitItinerary { + startTime: string; endTime: string; duration: number; transfers: number; walkSeconds: number; legs: TransitLeg[] +} + +interface TransitPlaceResult { name: string; lat: number; lng: number; type: string; area: string | null } + +interface PickedPlace { name: string; lat: number; lng: number } + +// ── helpers ────────────────────────────────────────────────────────────────── + +const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [ + { key: 'rail', labelKey: 'transit.mode.rail', Icon: Train, modes: 'HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,SUBURBAN' }, + { key: 'subway', labelKey: 'transit.mode.subway', Icon: TrainFront, modes: 'SUBWAY' }, + { key: 'tram', labelKey: 'transit.mode.tram', Icon: TramFront, modes: 'TRAM' }, + { key: 'bus', labelKey: 'transit.mode.bus', Icon: Bus, modes: 'BUS,COACH' }, + { key: 'ferry', labelKey: 'transit.mode.ferry', Icon: Sailboat, modes: 'FERRY' }, + { key: 'cable', labelKey: 'transit.mode.cable', Icon: CableCar, modes: 'FUNICULAR,AERIAL_LIFT' }, +] + +function legIcon(mode: string) { + if (mode === 'WALK') return Footprints + if (mode === 'BUS' || mode === 'COACH') return Bus + if (mode === 'TRAM') return TramFront + if (mode === 'SUBWAY') return TrainFront + if (mode === 'FERRY') return Sailboat + if (mode === 'FUNICULAR' || mode === 'AERIAL_LIFT') return CableCar + return Train +} + +/** TREK reservation type for the itinerary's dominant (longest) transit leg. */ +function dominantType(legs: TransitLeg[]): 'train' | 'bus' | 'ferry' | 'transport_other' { + let best: TransitLeg | null = null + for (const l of legs) { + if (l.mode === 'WALK') continue + if (!best || l.duration > best.duration) best = l + } + if (!best) return 'transport_other' + if (best.mode === 'BUS' || best.mode === 'COACH') return 'bus' + if (best.mode === 'FERRY') return 'ferry' + if (best.mode === 'FUNICULAR' || best.mode === 'AERIAL_LIFT') return 'transport_other' + return 'train' +} + +function tzAt(lat: number, lng: number): string { + try { return tzlookup(lat, lng) } catch { return 'UTC' } +} + +/** 'YYYY-MM-DD' + 'HH:mm' in an IANA zone → UTC ISO string. */ +function localToUtcIso(dateStr: string, timeStr: string, tz: string): string { + const naive = Date.parse(`${dateStr}T${timeStr}:00Z`) + const inTz = new Date(new Date(naive).toLocaleString('en-US', { timeZone: tz })).getTime() + const inUtc = new Date(new Date(naive).toLocaleString('en-US', { timeZone: 'UTC' })).getTime() + return new Date(naive - (inTz - inUtc)).toISOString() +} + +function fmtTimeInTz(iso: string | null, tz: string, is12h: boolean): string { + if (!iso) return '' + try { return new Intl.DateTimeFormat(is12h ? 'en-US' : 'en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h }).format(new Date(iso)) } catch { return '' } +} + +function timeHHmmInTz(iso: string, tz: string): string { + return new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(iso)) +} + +function dateYMDInTz(iso: string, tz: string): string { + return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(iso)) +} + +function fmtDuration(seconds: number, t: (k: string, p?: Record) => string): string { + const mins = Math.round(seconds / 60) + if (mins < 60) return t('transit.min', { count: mins }) + const h = Math.floor(mins / 60) + const m = mins % 60 + return m > 0 ? `${h} h ${m} min` : `${h} h` +} + +// ── from/to stop picker ────────────────────────────────────────────────────── + +function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: { + label: string + value: PickedPlace | null + onPick: (p: PickedPlace | null) => void + quickPicks: PickedPlace[] + near: string | null + placeholder: string +}) { + const { language } = useTranslation() + const [text, setText] = useState('') + const [results, setResults] = useState([]) + const [open, setOpen] = useState(false) + const debounceRef = useRef | null>(null) + const rootRef = useRef(null) + + useEffect(() => { + const close = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false) } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, []) + + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, []) + + const search = (q: string) => { + setText(q) + onPick(null) + if (debounceRef.current) clearTimeout(debounceRef.current) + if (q.trim().length < 2) { setResults([]); return } + debounceRef.current = setTimeout(() => { + transitApi.geocode(q, { lang: language, near: near || undefined }) + .then((d: { results: TransitPlaceResult[] }) => setResults(d.results || [])) + .catch(() => setResults([])) + }, 300) + } + + const display = value ? value.name : text + + return ( +
+ +
+ + search(e.target.value)} + onFocus={() => setOpen(true)} + placeholder={placeholder} + className="text-content" + style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', width: '100%', fontFamily: 'inherit' }} + /> +
+ {open && (results.length > 0 || (!value && text.trim().length < 2 && quickPicks.length > 0)) && ( +
+ {results.length > 0 + ? results.map((r, i) => ( + + )) + : quickPicks.map((p, i) => ( + + ))} +
+ )} +
+ ) +} + +// ── itinerary card ─────────────────────────────────────────────────────────── + +function LineBadge({ leg }: { leg: TransitLeg }) { + const bg = leg.lineColor || 'var(--bg-tertiary)' + const fg = leg.lineColor ? (leg.lineTextColor || '#fff') : 'var(--text-primary)' + const Icon = legIcon(leg.mode) + return ( + + + {leg.line || leg.mode} + + ) +} + +function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, t }: { + it: TransitItinerary + tzFrom: string + tzTo: string + is12h: boolean + expanded: boolean + onToggle: () => void + onAdd: () => void + adding: boolean + t: (k: string, p?: Record) => string +}) { + const transitLegs = it.legs.filter(l => l.mode !== 'WALK') + const walkMins = Math.round(it.walkSeconds / 60) + return ( +
+ + + {expanded && ( +
+
+ {it.legs.map((leg, i) => { + const color = leg.mode === 'WALK' ? 'var(--border-primary)' : (leg.lineColor || 'var(--text-muted)') + return ( +
+
+ {leg.mode === 'WALK' ? '' : fmtTimeInTz(leg.from.time, tzAt(leg.from.lat, leg.from.lng), is12h)} +
+
+ + +
+
+
+ {leg.mode === 'WALK' ? t('transit.walkTo', { name: leg.to.name }) : leg.from.name} + {leg.from.track && leg.mode !== 'WALK' && · {t('transit.platform', { track: leg.from.track })}} +
+
+ {leg.mode === 'WALK' ? ( + {fmtDuration(leg.duration, t)}{leg.distance ? ` · ${leg.distance >= 1000 ? `${(leg.distance / 1000).toFixed(1)} km` : `${leg.distance} m`}` : ''} + ) : ( + <> + + {leg.headsign && {leg.headsign}} + {fmtDuration(leg.duration, t)}{leg.intermediateStops > 0 ? ` · ${t('transit.stops', { count: leg.intermediateStops })}` : ''} + + )} +
+
+
+ ) + })} + {/* arrival row */} +
+
+ {fmtTimeInTz(it.endTime, tzTo, is12h)} +
+
+ +
+
+ {it.legs[it.legs.length - 1]?.to.name} +
+
+
+ {transitLegs[0]?.agency && ( +
+ {[...new Set(transitLegs.map(l => l.agency).filter(Boolean))].join(' · ')} +
+ )} + +
+ )} +
+ ) +} + +// ── the modal ──────────────────────────────────────────────────────────────── + +interface TransitSearchModalProps { + isOpen: boolean + onClose: () => void + day: Day + days: Day[] + places: Place[] + accommodations?: Accommodation[] + /** Persist the built reservation payload; resolves when saved. */ + onAdd: (payload: Record) => Promise +} + +export default function TransitSearchModal({ isOpen, onClose, day, days, places, accommodations = [], onAdd }: TransitSearchModalProps) { + const { t } = useTranslation() + const toast = useToast() + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + + const [from, setFrom] = useState(null) + const [to, setTo] = useState(null) + const [time, setTime] = useState('09:00') + const [arriveBy, setArriveBy] = useState(false) + const [activeModes, setActiveModes] = useState>(() => new Set(MODE_GROUPS.map(m => m.key))) + const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best') + const [itineraries, setItineraries] = useState(null) + const [loading, setLoading] = useState(false) + const [expandedIdx, setExpandedIdx] = useState(null) + const [addingIdx, setAddingIdx] = useState(null) + + // Quick picks: the day's located places, plus the trip's located accommodations. + const quickPicks = useMemo(() => { + const picks: PickedPlace[] = [] + for (const p of places) { + if (p.lat != null && p.lng != null) picks.push({ name: p.name, lat: p.lat, lng: p.lng }) + } + for (const a of accommodations) { + const lat = (a as { place_lat?: number | null }).place_lat + const lng = (a as { place_lng?: number | null }).place_lng + const name = (a as { place_name?: string | null }).place_name + if (lat != null && lng != null && name) picks.push({ name, lat, lng }) + } + const seen = new Set() + return picks.filter(p => { const k = `${p.name}:${p.lat}`; if (seen.has(k)) return false; seen.add(k); return true }).slice(0, 8) + }, [places, accommodations]) + + const near = quickPicks.length > 0 ? `${quickPicks[0].lat},${quickPicks[0].lng}` : null + + const toggleMode = (key: string) => { + setActiveModes(prev => { + const next = new Set(prev) + if (next.has(key)) { if (next.size > 1) next.delete(key) } else { next.add(key) } + return next + }) + } + + const search = async () => { + if (!from || !to || !day.date) return + setLoading(true) + setItineraries(null) + setExpandedIdx(null) + try { + const tzFrom = tzAt(from.lat, from.lng) + const timeIso = localToUtcIso(day.date, time, tzFrom) + const allModes = activeModes.size === MODE_GROUPS.length + const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',') + const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes }) + setItineraries(d.itineraries || []) + } catch { + toast.error(t('transit.searchError')) + setItineraries([]) + } finally { + setLoading(false) + } + } + + // Preference is a client-side ranking over one result set — no extra API calls. + const ranked = useMemo(() => { + if (!itineraries) return null + const list = itineraries.slice() + if (pref === 'transfers') list.sort((a, b) => a.transfers - b.transfers || a.duration - b.duration) + if (pref === 'walking') list.sort((a, b) => a.walkSeconds - b.walkSeconds || a.duration - b.duration) + return list + }, [itineraries, pref]) + + const addItinerary = async (it: TransitItinerary, idx: number) => { + if (!from || !to || !day.date) return + setAddingIdx(idx) + try { + const tzFrom = tzAt(from.lat, from.lng) + const tzTo = tzAt(to.lat, to.lng) + const depDate = dateYMDInTz(it.startTime, tzFrom) + const depTime = timeHHmmInTz(it.startTime, tzFrom) + const arrDate = dateYMDInTz(it.endTime, tzTo) + const arrTime = timeHHmmInTz(it.endTime, tzTo) + + // An after-midnight arrival lands on the next trip day when it exists. + const endDay = arrDate !== depDate ? days.find(d2 => d2.date === arrDate) : null + + // Endpoints: origin, each transfer stop, destination — the same shape + // flights persist, so the map + connectors work unchanged. + const transitLegs = it.legs.filter(l => l.mode !== 'WALK') + const endpoints: Record[] = [] + endpoints.push({ role: 'from', sequence: 0, name: from.name, code: null, lat: from.lat, lng: from.lng, timezone: tzFrom, local_date: depDate, local_time: depTime }) + transitLegs.slice(0, -1).forEach((leg, i) => { + const s = leg.to + endpoints.push({ role: 'stop', sequence: i + 1, name: s.name, code: null, lat: s.lat, lng: s.lng, timezone: tzAt(s.lat, s.lng), local_date: s.time ? dateYMDInTz(s.time, tzAt(s.lat, s.lng)) : null, local_time: s.time ? timeHHmmInTz(s.time, tzAt(s.lat, s.lng)) : null }) + }) + endpoints.push({ role: 'to', sequence: endpoints.length, name: to.name, code: null, lat: to.lat, lng: to.lng, timezone: tzTo, local_date: arrDate, local_time: arrTime }) + + const payload = { + title: `${from.name} → ${to.name}`, + type: dominantType(it.legs), + status: 'confirmed', + day_id: day.id, + end_day_id: endDay ? endDay.id : day.id, + reservation_time: `${depDate}T${depTime}`, + reservation_end_time: `${arrDate}T${arrTime}`, + location: null, + confirmation_number: null, + notes: null, + metadata: { + transit: { + provider: 'transitous', + duration: it.duration, + transfers: it.transfers, + walk_seconds: it.walkSeconds, + legs: it.legs.map(l => ({ + mode: l.mode, + line: l.line, + line_color: l.lineColor, + line_text_color: l.lineTextColor, + headsign: l.headsign, + agency: l.agency, + duration: l.duration, + stops: l.intermediateStops, + from: { name: l.from.name, time: l.from.time ? timeHHmmInTz(l.from.time, tzAt(l.from.lat, l.from.lng)) : null, track: l.from.track }, + to: { name: l.to.name, time: l.to.time ? timeHHmmInTz(l.to.time, tzAt(l.to.lat, l.to.lng)) : null, track: l.to.track }, + })), + }, + }, + endpoints, + needs_review: false, + } + + await onAdd(payload) + onClose() + } catch { + toast.error(t('common.unknownError')) + } finally { + setAddingIdx(null) + } + } + + const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC' + const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom + + const segBtn = (active: boolean): React.CSSProperties => ({ + padding: '6px 11px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500, + border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap', + background: active ? 'var(--bg-card)' : 'transparent', color: active ? 'var(--text-primary)' : 'var(--text-muted)', + boxShadow: active ? '0 1px 4px rgba(0,0,0,0.08)' : 'none', + }) + + return ( + +
+ {/* from / to */} +
+ + + +
+ + {/* time + depart/arrive */} +
+
+ + +
+
+ +
+ {day.date && ( + + + {new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })} + + )} +
+ + {/* mode chips */} +
+ {MODE_GROUPS.map(m => { + const active = activeModes.has(m.key) + return ( + + ) + })} +
+ + {/* preference + search */} +
+
+ + + +
+ +
+ + {/* results */} + {loading && ( +
+
+
+ )} + {!loading && ranked && ranked.length === 0 && ( +
+ {t('transit.noResults')} +
+ )} + {!loading && ranked && ranked.length > 0 && ( +
+ {ranked.map((it, idx) => ( + setExpandedIdx(expandedIdx === idx ? null : idx)} + onAdd={() => addItinerary(it, idx)} + adding={addingIdx === idx} + t={t} + /> + ))} +
+ {t('transit.attribution')}{' '} + Transitous +
+
+ )} +
+ + ) +} diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index bdeb0c09f..6abe36301 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -324,4 +324,46 @@ describe('TransportModal', () => { expect(fd.get('reservation_id')).toBe('99'); expect(fd.get('file')).toBeTruthy(); }); + + // ── Transit itinerary preservation (#1065) ───────────────────────────────── + + it('FE-PLANNER-TRANSMODAL-020: re-saving a transit reservation keeps metadata.transit + stop endpoints', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const res = buildReservation({ title: 'Fernsehturm → Zoo', type: 'bus' }) as any; + res.metadata = { transit: { provider: 'transitous', transfers: 1, legs: [{ mode: 'BUS', line: '100' }] } }; + res.endpoints = [ + { role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.5208, lng: 13.4094, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '08:30' }, + { role: 'stop', sequence: 1, name: 'Alexanderplatz', code: null, lat: 52.521, lng: 13.41, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '08:40' }, + { role: 'to', sequence: 2, name: 'Zoologischer Garten', code: null, lat: 52.507, lng: 13.332, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '09:00' }, + ]; + render(); + // Save without touching the route — the itinerary must survive. + await userEvent.click(screen.getByRole('button', { name: /^Update$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.metadata?.transit?.provider).toBe('transitous'); + expect(payload.metadata?.transit?.legs).toHaveLength(1); + expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'to']); + expect(payload.endpoints[1]).toMatchObject({ name: 'Alexanderplatz', lat: 52.521 }); + }); + + it('FE-PLANNER-TRANSMODAL-021: changing the destination drops the stale transit itinerary', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const res = buildReservation({ title: 'Fernsehturm → Zoo', type: 'bus' }) as any; + res.metadata = { transit: { provider: 'transitous', legs: [{ mode: 'BUS' }] } }; + res.endpoints = [ + { role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.5208, lng: 13.4094, timezone: 'Europe/Berlin', local_date: null, local_time: null }, + { role: 'stop', sequence: 1, name: 'Alexanderplatz', code: null, lat: 52.521, lng: 13.41, timezone: 'Europe/Berlin', local_date: null, local_time: null }, + { role: 'to', sequence: 2, name: 'Zoologischer Garten', code: null, lat: 52.507, lng: 13.332, timezone: 'Europe/Berlin', local_date: null, local_time: null }, + ]; + render(); + // Pick a different destination (mocked LocationSelect emits lat/lng 0,0). + const locationInputs = screen.getAllByTestId('location-select'); + fireEvent.change(locationInputs[1], { target: { value: 'Somewhere Else' } }); + await userEvent.click(screen.getByRole('button', { name: /^Update$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.metadata?.transit).toBeUndefined(); + expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']); + }); }); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 3a26ec61f..9472e3337 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -300,6 +300,22 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel if (form.meta_platform) metadata.platform = form.meta_platform if (form.meta_seat) metadata.seat = form.meta_seat } + + // A transit itinerary (#1065) lives in metadata.transit + 'stop' endpoints, + // neither of which this form shows or edits — so re-saving must not wipe + // them. They're kept only while from/to are unchanged: picking a different + // origin or destination invalidates the stored connection. + const prevMeta = reservation ? parseReservationMetadata(reservation) : {} + const prevEndpointsAll = reservation?.endpoints || [] + const prevFrom = prevEndpointsAll.find(ep => ep.role === 'from') + const prevTo = prevEndpointsAll.find(ep => ep.role === 'to') + const near = (a?: number | null, b?: number | null) => a != null && b != null && Math.abs(a - b) < 1e-6 + const keepTransit = !!(prevMeta.transit && form.type !== 'flight' && + prevFrom && prevTo && fromPick.location && toPick.location && + near(prevFrom.lat, fromPick.location.lat) && near(prevFrom.lng, fromPick.location.lng) && + near(prevTo.lat, toPick.location.lat) && near(prevTo.lng, toPick.location.lng)) + if (keepTransit) metadata.transit = prevMeta.transit + const startDate = startDay?.date ?? null const endDate = (endDay ?? startDay)?.date ?? null const endpoints: ReturnType[] = [] @@ -314,7 +330,16 @@ 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)) - if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null)) + // Keep the itinerary's transfer stops while the route is unchanged (#1065). + const stops = keepTransit + ? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0)) + : [] + stops.forEach((s, i) => endpoints.push({ + role: 'stop', sequence: i + 1, name: s.name, code: s.code ?? null, + 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)) } // Flights derive their span from the first/last waypoint; other transports diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index f57de317b..2bf50d9fa 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -17,6 +17,7 @@ import SlidingTabs from '../components/shared/SlidingTabs' import TripMembersModal from '../components/Trips/TripMembersModal' import { ReservationModal } from '../components/Planner/ReservationModal' import { TransportModal } from '../components/Planner/TransportModal' +import TransitSearchModal from '../components/Planner/TransitSearchModal' import BookingImportModal from '../components/Planner/BookingImportModal' import AirTrailImportModal from '../components/Planner/AirTrailImportModal' // MemoriesPanel moved to Journey addon @@ -194,6 +195,7 @@ export default function TripPlannerPage(): React.ReactElement | null { bookingForAssignmentId, setBookingForAssignmentId, showTransportModal, setShowTransportModal, editingTransport, setEditingTransport, transportModalDayId, setTransportModalDayId, + transitModalDayId, setTransitModalDayId, handleAddTransitRoute, reservationPrefill, transportPrefill, importReviewActive, advanceImportReview, routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey, mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef, @@ -397,6 +399,7 @@ export default function TripPlannerPage(): React.ReactElement | null { onExternalTransportDetailHandled={() => setMapTransportDetail(null)} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true) } : undefined} + onPlanTransit={can('day_edit', trip) ? (dayId) => setTransitModalDayId(dayId) : undefined} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} @@ -526,6 +529,7 @@ export default function TripPlannerPage(): React.ReactElement | null { collapsed={dayDetailCollapsed} onToggleCollapse={() => setDayDetailCollapsed(c => !c)} mobile={isMobile} + onUpdateDayTitle={handleUpdateDayTitle} /> ) })()} @@ -618,7 +622,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransitModalDayId(dayId); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded /> : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> }
@@ -716,6 +720,22 @@ export default function TripPlannerPage(): React.ReactElement | null { setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} /> {showTransportModal && { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />} + {/* Public transit route search (#1065) */} + {transitModalDayId != null && (() => { + const transitDay = days.find(d => d.id === transitModalDayId) + if (!transitDay) return null + return ( + setTransitModalDayId(null)} + day={transitDay} + days={days} + places={places} + accommodations={tripAccommodations} + onAdd={handleAddTransitRoute} + /> + ) + })()} {bookingExpense && ( (false) const [editingTransport, setEditingTransport] = useState(null) const [transportModalDayId, setTransportModalDayId] = useState(null) + // Public transit route search (#1065) — non-null while the modal is open for that day. + const [transitModalDayId, setTransitModalDayId] = useState(null) // The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs // it opens the booking / transport modal via ?create=reservation|transport @@ -689,6 +691,14 @@ export function useTripPlanner() { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } } + // A chosen transit itinerary is persisted as a regular transport reservation, + // so it slots into the timeline by time and inherits edit/delete/drag (#1065). + const handleAddTransitRoute = async (payload: Record & { title: string }) => { + const r = await tripActions.addReservation(tripId, payload) + toast.success(t('trip.toast.reservationAdded')) + return r + } + const handleDeleteReservation = async (id) => { try { await tripActions.deleteReservation(tripId, id) @@ -859,6 +869,7 @@ export function useTripPlanner() { bookingForAssignmentId, setBookingForAssignmentId, showTransportModal, setShowTransportModal, editingTransport, setEditingTransport, transportModalDayId, setTransportModalDayId, + transitModalDayId, setTransitModalDayId, handleAddTransitRoute, reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview, routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey, mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef, diff --git a/client/src/types/tz-lookup.d.ts b/client/src/types/tz-lookup.d.ts new file mode 100644 index 000000000..7d2fba705 --- /dev/null +++ b/client/src/types/tz-lookup.d.ts @@ -0,0 +1,4 @@ +declare module 'tz-lookup' { + /** Returns the IANA time zone for a coordinate, throwing on invalid input. */ + export default function tzlookup(lat: number, lng: number): string; +} diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index eb92c1c18..d3f6a9d41 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -31,6 +31,7 @@ import { JourneyModule } from './journey/journey.module'; import { CollectionsModule } from './collections/collections.module'; import { ShareModule } from './share/share.module'; import { TripInviteModule } from './trip-invite/trip-invite.module'; +import { TransitModule } from './transit/transit.module'; import { FeedsModule } from './feeds/feeds.module'; import { SettingsModule } from './settings/settings.module'; import { BackupModule } from './backup/backup.module'; @@ -50,7 +51,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * migrated. */ @Module({ - imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule], + imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule], controllers: [HealthController], providers: [ HealthService, diff --git a/server/src/nest/transit/transit.controller.ts b/server/src/nest/transit/transit.controller.ts new file mode 100644 index 000000000..06a4d2818 --- /dev/null +++ b/server/src/nest/transit/transit.controller.ts @@ -0,0 +1,67 @@ +import { Controller, Get, HttpException, Query, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RateLimitService } from '../auth/rate-limit.service'; +import * as transit from '../../services/transitService'; + +const RL_WINDOW = 15 * 60 * 1000; + +/** + * /api/transit — public transit routing (#1065) proxied through Transitous + * (or a self-hosted MOTIS via TRANSIT_API_URL). JWT-guarded and rate-limited: + * the Transitous usage policy asks integrators to keep expensive routing + * traffic reasonable, so planning gets a tighter bucket than geocoding. + */ +@Controller('api/transit') +@UseGuards(JwtAuthGuard) +export class TransitController { + constructor(private readonly rl: RateLimitService) {} + + private limit(bucket: string, req: Request, max: number): void { + if (!this.rl.check(bucket, req.ip || 'unknown', max, RL_WINDOW, Date.now())) { + throw new HttpException({ error: 'Too many requests. Please try again later.' }, 429); + } + } + + private rethrow(err: unknown): never { + const status = (err as { status?: number }).status || 502; + const message = err instanceof Error ? err.message : 'Transit provider error'; + throw new HttpException({ error: message }, status); + } + + @Get('geocode') + async geocode( + @Query('q') q: string | undefined, + @Query('lang') lang: string | undefined, + @Query('near') near: string | undefined, + @Req() req: Request, + ) { + this.limit('transit_geocode', req, 300); + try { + return await transit.geocode(q || '', lang, near); + } catch (err) { this.rethrow(err); } + } + + @Get('plan') + async plan( + @Query('from') from: string | undefined, + @Query('to') to: string | undefined, + @Query('time') time: string | undefined, + @Query('arriveBy') arriveBy: string | undefined, + @Query('modes') modes: string | undefined, + @Query('maxTransfers') maxTransfers: string | undefined, + @Req() req: Request, + ) { + this.limit('transit_plan', req, 60); + try { + return await transit.plan({ + from: from || '', + to: to || '', + time, + arriveBy: arriveBy === 'true' || arriveBy === '1', + modes, + maxTransfers: maxTransfers !== undefined && maxTransfers !== '' ? Number(maxTransfers) : undefined, + }); + } catch (err) { this.rethrow(err); } + } +} diff --git a/server/src/nest/transit/transit.module.ts b/server/src/nest/transit/transit.module.ts new file mode 100644 index 000000000..0b2f96d1f --- /dev/null +++ b/server/src/nest/transit/transit.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TransitController } from './transit.controller'; +import { RateLimitService } from '../auth/rate-limit.service'; + +@Module({ + controllers: [TransitController], + providers: [RateLimitService], +}) +export class TransitModule {} diff --git a/server/src/services/transitService.ts b/server/src/services/transitService.ts new file mode 100644 index 000000000..8bf13fa2b --- /dev/null +++ b/server/src/services/transitService.ts @@ -0,0 +1,254 @@ +import { getAppUrl } from './notifications'; +import { buildUserAgent } from './mapsService'; + +/** + * Public transit routing (#1065) backed by Transitous (api.transitous.org), the + * community-run MOTIS instance over public GTFS feeds — free, no API key, fits + * TREK's no-paid-providers rule. Self-hosters can point TRANSIT_API_URL at their + * own MOTIS instance instead. + * + * This service is a thin, validating proxy: the browser never talks to + * Transitous directly (their usage policy wants an identifying User-Agent with + * contact info, which we send once from the server), and responses are mapped + * to a compact shape so the client isn't coupled to the MOTIS schema. + */ + +const TRANSIT_API_BASE = (process.env.TRANSIT_API_URL || 'https://api.transitous.org').replace(/\/+$/, ''); +const UA = buildUserAgent(getAppUrl()); + +// Modes the client may request — a strict whitelist so the proxy can't be used +// to smuggle arbitrary query values upstream. TRANSIT covers everything; the +// others let the user filter (RAIL already includes subway/suburban etc.). +const ALLOWED_MODES = new Set([ + 'TRANSIT', 'BUS', 'COACH', 'TRAM', 'SUBWAY', 'RAIL', 'FERRY', 'FUNICULAR', 'AERIAL_LIFT', + // Fine-grained rail modes so "train without subway" is expressible (RAIL + // itself includes SUBWAY per the MOTIS mode taxonomy). + 'HIGHSPEED_RAIL', 'LONG_DISTANCE', 'NIGHT_RAIL', 'REGIONAL_RAIL', 'SUBURBAN', +]); + +// Short-lived response cache: planning is the expensive call per the Transitous +// usage policy, and a user toggling filters re-requests identical plans. +const CACHE_TTL = 60 * 1000; +const CACHE_MAX = 200; +const cache = new Map(); + +function cacheGet(key: string): unknown | null { + const hit = cache.get(key); + if (!hit) return null; + if (Date.now() - hit.at > CACHE_TTL) { cache.delete(key); return null; } + return hit.data; +} + +function cacheSet(key: string, data: unknown): void { + if (cache.size >= CACHE_MAX) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, { at: Date.now(), data }); +} + +const COORD_RE = /^-?\d{1,3}(\.\d+)?,-?\d{1,3}(\.\d+)?$/; + +function isCoord(v: string): boolean { + if (!COORD_RE.test(v)) return false; + const [lat, lng] = v.split(',').map(Number); + return Math.abs(lat) <= 90 && Math.abs(lng) <= 180; +} + +async function upstream(path: string, params: URLSearchParams): Promise { + const url = `${TRANSIT_API_BASE}${path}?${params}`; + const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } }); + if (!res.ok) { + const err = new Error(`Transit provider error (HTTP ${res.status})`) as Error & { status: number }; + err.status = res.status === 429 ? 429 : 502; + throw err; + } + return res.json(); +} + +// ── Geocode ────────────────────────────────────────────────────────────────── + +export interface TransitPlace { + name: string; + lat: number; + lng: number; + type: string; + area: string | null; +} + +/** Station/place search for the from/to pickers. `near` biases results. */ +export async function geocode(query: string, language?: string, near?: string): Promise<{ results: TransitPlace[] }> { + const text = (query || '').trim(); + if (text.length < 2) return { results: [] }; + if (text.length > 200) { const e = new Error('Query too long') as Error & { status: number }; e.status = 400; throw e; } + + const params = new URLSearchParams({ text }); + if (language) params.set('language', language.slice(0, 5)); + if (near && isCoord(near)) params.set('place', near); + + const key = `geo:${params.toString()}`; + const cached = cacheGet(key); + if (cached) return cached as { results: TransitPlace[] }; + + const raw = (await upstream('/api/v1/geocode', params)) as Array<{ + name?: string; lat?: number; lon?: number; type?: string; + areas?: Array<{ name?: string; matched?: boolean; default?: boolean }>; + }>; + + const results: TransitPlace[] = (Array.isArray(raw) ? raw : []).slice(0, 8).flatMap((m) => { + if (typeof m.lat !== 'number' || typeof m.lon !== 'number' || !m.name) return []; + const area = m.areas?.find((a) => a.default)?.name || m.areas?.[0]?.name || null; + return [{ name: m.name, lat: m.lat, lng: m.lon, type: m.type || 'PLACE', area }]; + }); + + const data = { results }; + cacheSet(key, data); + return data; +} + +// ── Plan ───────────────────────────────────────────────────────────────────── + +export interface TransitLegStop { + name: string; + lat: number; + lng: number; + time: string | null; + scheduledTime: string | null; + track: string | null; +} + +export interface TransitLeg { + mode: string; + from: TransitLegStop; + to: TransitLegStop; + duration: number; + distance: number | null; + headsign: string | null; + line: string | null; + lineColor: string | null; + lineTextColor: string | null; + agency: string | null; + intermediateStops: number; +} + +export interface TransitItinerary { + startTime: string; + endTime: string; + duration: number; + transfers: number; + walkSeconds: number; + legs: TransitLeg[]; +} + +export interface PlanQuery { + from: string; + to: string; + time?: string; + arriveBy?: boolean; + modes?: string; + maxTransfers?: number; +} + +// GTFS colors come as bare hex ("FF0000"), with hash, or empty — normalise to +// a #-prefixed value or null so the client can use them in CSS directly. +function safeColor(v: unknown): string | null { + if (typeof v !== 'string') return null; + const hex = v.trim().replace(/^#/, ''); + return /^[0-9a-fA-F]{6}$/.test(hex) || /^[0-9a-fA-F]{3}$/.test(hex) ? `#${hex}` : null; +} + +interface MotisPlaceRaw { + name?: string; lat?: number; lon?: number; + departure?: string; arrival?: string; + scheduledDeparture?: string; scheduledArrival?: string; + track?: string; scheduledTrack?: string; +} + +function mapStop(p: MotisPlaceRaw | undefined, kind: 'departure' | 'arrival'): TransitLegStop { + return { + name: p?.name || '', + lat: typeof p?.lat === 'number' ? p.lat : 0, + lng: typeof p?.lon === 'number' ? p.lon : 0, + time: (kind === 'departure' ? p?.departure : p?.arrival) || null, + scheduledTime: (kind === 'departure' ? p?.scheduledDeparture : p?.scheduledArrival) || null, + track: p?.track || p?.scheduledTrack || null, + }; +} + +/** Route search between two coordinates. Returns compact itineraries for the picker. */ +export async function plan(q: PlanQuery): Promise<{ itineraries: TransitItinerary[] }> { + const bad = (msg: string) => { const e = new Error(msg) as Error & { status: number }; e.status = 400; throw e; }; + if (!q.from || !isCoord(q.from)) bad('from must be "lat,lng"'); + if (!q.to || !isCoord(q.to)) bad('to must be "lat,lng"'); + + const params = new URLSearchParams({ fromPlace: q.from, toPlace: q.to, numItineraries: '8' }); + + if (q.time) { + const parsed = new Date(q.time); + if (isNaN(parsed.getTime())) bad('time must be an ISO date-time'); + params.set('time', parsed.toISOString()); + } + if (q.arriveBy) params.set('arriveBy', 'true'); + + if (q.modes) { + const modes = q.modes.split(',').map((m) => m.trim().toUpperCase()).filter(Boolean); + if (modes.some((m) => !ALLOWED_MODES.has(m))) bad('unsupported transit mode'); + if (modes.length > 0) params.set('transitModes', modes.join(',')); + } + if (q.maxTransfers !== undefined && q.maxTransfers !== null) { + const n = Number(q.maxTransfers); + if (!Number.isInteger(n) || n < 0 || n > 10) bad('maxTransfers must be 0-10'); + params.set('maxTransfers', String(n)); + } + // We only want scheduled transit journeys in the results — a pure-walk + // "direct" connection is what the existing OSRM footpath routing is for. + params.set('directModes', 'WALK'); + + const key = `plan:${params.toString()}`; + const cached = cacheGet(key); + if (cached) return cached as { itineraries: TransitItinerary[] }; + + const raw = (await upstream('/api/v6/plan', params)) as { + itineraries?: Array<{ + duration?: number; startTime?: string; endTime?: string; transfers?: number; + legs?: Array<{ + mode?: string; duration?: number; distance?: number; headsign?: string; + routeShortName?: string; displayName?: string; routeColor?: string; routeTextColor?: string; + agencyName?: string; from?: MotisPlaceRaw; to?: MotisPlaceRaw; + intermediateStops?: unknown[]; + }>; + }>; + }; + + const itineraries: TransitItinerary[] = (raw.itineraries || []).flatMap((it) => { + if (!it.startTime || !it.endTime || !Array.isArray(it.legs)) return []; + const legs: TransitLeg[] = it.legs.map((leg) => ({ + mode: (leg.mode || 'WALK').toUpperCase(), + from: mapStop(leg.from, 'departure'), + to: mapStop(leg.to, 'arrival'), + duration: typeof leg.duration === 'number' ? leg.duration : 0, + distance: typeof leg.distance === 'number' ? Math.round(leg.distance) : null, + headsign: leg.headsign || null, + line: leg.routeShortName || leg.displayName || null, + lineColor: safeColor(leg.routeColor), + lineTextColor: safeColor(leg.routeTextColor), + agency: leg.agencyName || null, + intermediateStops: Array.isArray(leg.intermediateStops) ? leg.intermediateStops.length : 0, + })); + const walkSeconds = legs.filter((l) => l.mode === 'WALK').reduce((a, l) => a + l.duration, 0); + return [{ + startTime: it.startTime, + endTime: it.endTime, + // Wall-clock duration (start→end) so waits/transfers count — summing leg + // run-times would understate the journey and mis-slot it in the timeline. + duration: Math.max(0, Math.round((new Date(it.endTime).getTime() - new Date(it.startTime).getTime()) / 1000)), + transfers: typeof it.transfers === 'number' ? it.transfers : Math.max(0, legs.filter((l) => l.mode !== 'WALK').length - 1), + walkSeconds, + legs, + }]; + }); + + const data = { itineraries }; + cacheSet(key, data); + return data; +} diff --git a/server/tests/e2e/transit.e2e.test.ts b/server/tests/e2e/transit.e2e.test.ts new file mode 100644 index 000000000..a75c9a2ef --- /dev/null +++ b/server/tests/e2e/transit.e2e.test.ts @@ -0,0 +1,96 @@ +/** + * Transit proxy e2e — /api/transit/geocode + /api/transit/plan through the real + * JwtAuthGuard against a temp SQLite db. The transit service is mocked; this + * focuses on auth (401), param pass-through and error propagation (#1065). + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { seedUser, sessionCookie } from './harness'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); + return { db: tmp }; +}); +vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); + +const { transitSvc } = vi.hoisted(() => ({ transitSvc: { geocode: vi.fn(), plan: vi.fn() } })); +vi.mock('../../src/services/transitService', () => transitSvc); + +import { TransitModule } from '../../src/nest/transit/transit.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; + +describe('Transit proxy e2e (real auth guard + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [TransitModule] }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + seedUser(db as never, { id: 1 }); + app = await build(); + server = app.getHttpServer(); + }); + + beforeEach(() => { + transitSvc.geocode.mockReset(); + transitSvc.plan.mockReset(); + }); + + afterAll(async () => { await app.close(); }); + + it('401 without a session cookie', async () => { + expect((await request(server).get('/api/transit/geocode?q=alexanderplatz')).status).toBe(401); + expect((await request(server).get('/api/transit/plan?from=1,2&to=3,4')).status).toBe(401); + }); + + it('geocode passes q/lang/near through and returns the service result', async () => { + transitSvc.geocode.mockResolvedValueOnce({ results: [{ name: 'Alexanderplatz' }] }); + const res = await request(server).get('/api/transit/geocode?q=alex&lang=de&near=52.5,13.4').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body.results[0].name).toBe('Alexanderplatz'); + expect(transitSvc.geocode).toHaveBeenCalledWith('alex', 'de', '52.5,13.4'); + }); + + it('plan passes all params through (arriveBy + maxTransfers coerced)', async () => { + transitSvc.plan.mockResolvedValueOnce({ itineraries: [] }); + const res = await request(server) + .get('/api/transit/plan?from=52.5,13.4&to=52.6,13.5&time=2026-07-13T09:00:00Z&arriveBy=true&modes=BUS&maxTransfers=2') + .set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(transitSvc.plan).toHaveBeenCalledWith({ + from: '52.5,13.4', to: '52.6,13.5', time: '2026-07-13T09:00:00Z', arriveBy: true, modes: 'BUS', maxTransfers: 2, + }); + }); + + it('service validation errors propagate with their status', async () => { + const err = new Error('from must be "lat,lng"') as Error & { status: number }; + err.status = 400; + transitSvc.plan.mockRejectedValueOnce(err); + const res = await request(server).get('/api/transit/plan?from=bad&to=1,2').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(400); + expect(res.body.error).toContain('lat,lng'); + }); + + it('upstream failures surface as 502', async () => { + const err = new Error('Transit provider error (HTTP 500)') as Error & { status: number }; + err.status = 502; + transitSvc.plan.mockRejectedValueOnce(err); + const res = await request(server).get('/api/transit/plan?from=1,2&to=3,4').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(502); + }); +}); diff --git a/server/tests/unit/services/transitService.test.ts b/server/tests/unit/services/transitService.test.ts new file mode 100644 index 000000000..0700b320e --- /dev/null +++ b/server/tests/unit/services/transitService.test.ts @@ -0,0 +1,125 @@ +/** + * Unit tests for transitService — TRANSIT-SVC-001..010. + * The Transitous/MOTIS proxy (#1065): input validation, mode whitelist, + * response mapping (colors, walk time, wall-clock duration) and caching. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../../../src/services/notifications', () => ({ getAppUrl: () => 'https://trek.example.com' })); +vi.mock('../../../src/services/mapsService', () => ({ buildUserAgent: () => 'TREK-Test-UA' })); + +import { geocode, plan } from '../../../src/services/transitService'; + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); +}); +afterEach(() => vi.unstubAllGlobals()); + +function okJson(data: unknown) { + return { ok: true, json: async () => data }; +} + +describe('geocode', () => { + it('TRANSIT-SVC-001: returns [] without calling upstream for short queries', async () => { + const r = await geocode('a'); + expect(r.results).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('TRANSIT-SVC-002: maps matches to compact places and sends the UA', async () => { + fetchMock.mockResolvedValueOnce(okJson([ + { name: 'Alexanderplatz', lat: 52.52, lon: 13.41, type: 'STOP', areas: [{ name: 'Berlin', default: true }] }, + { name: 'no-coords' }, + ])); + const r = await geocode('alexanderplatz-u1'); + expect(r.results).toEqual([{ name: 'Alexanderplatz', lat: 52.52, lng: 13.41, type: 'STOP', area: 'Berlin' }]); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain('/api/v1/geocode?'); + expect(init.headers['User-Agent']).toBe('TREK-Test-UA'); + }); + + it('TRANSIT-SVC-003: ignores an invalid near bias instead of forwarding it', async () => { + fetchMock.mockResolvedValueOnce(okJson([])); + await geocode('hauptbahnhof-x1', undefined, 'not,coords'); + expect(String(fetchMock.mock.calls[0][0])).not.toContain('place='); + }); +}); + +describe('plan validation', () => { + it('TRANSIT-SVC-004: rejects malformed coordinates with 400', async () => { + await expect(plan({ from: 'x', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 }); + await expect(plan({ from: '95,13.4', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('TRANSIT-SVC-005: rejects modes outside the whitelist', async () => { + await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', modes: 'BUS,CAR' })).rejects.toMatchObject({ status: 400 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('TRANSIT-SVC-006: rejects out-of-range maxTransfers and bad time', async () => { + await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', maxTransfers: 99 })).rejects.toMatchObject({ status: 400 }); + await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', time: 'not-a-date' })).rejects.toMatchObject({ status: 400 }); + }); +}); + +describe('plan mapping', () => { + const motisResponse = { + itineraries: [ + { + duration: 999, // deliberately wrong — mapping must use wall-clock instead + startTime: '2026-07-13T08:00:00Z', + endTime: '2026-07-13T08:30:00Z', + transfers: 1, + legs: [ + { mode: 'WALK', duration: 300, distance: 250.7, from: { name: 'A', lat: 1, lon: 2, departure: '2026-07-13T08:00:00Z' }, to: { name: 'Stop 1', lat: 1.1, lon: 2.1, arrival: '2026-07-13T08:05:00Z' } }, + { mode: 'BUS', duration: 1200, routeShortName: '100', routeColor: 'FF0000', routeTextColor: '#ffffff', headsign: 'Zoo', agencyName: 'BVG', intermediateStops: [{}, {}], from: { name: 'Stop 1', lat: 1.1, lon: 2.1, departure: '2026-07-13T08:07:00Z', track: '2' }, to: { name: 'Stop 2', lat: 1.2, lon: 2.2, arrival: '2026-07-13T08:27:00Z' } }, + ], + }, + ], + }; + + it('TRANSIT-SVC-007: maps legs compactly, normalises GTFS colors and counts walk seconds', async () => { + fetchMock.mockResolvedValueOnce(okJson(motisResponse)); + const r = await plan({ from: '52.5000,13.4000', to: '52.5100,13.4100' }); + expect(r.itineraries).toHaveLength(1); + const it = r.itineraries[0]; + // Wall-clock 08:00→08:30, not the reported 999s. + expect(it.duration).toBe(1800); + expect(it.walkSeconds).toBe(300); + expect(it.transfers).toBe(1); + const bus = it.legs[1]; + expect(bus.line).toBe('100'); + expect(bus.lineColor).toBe('#FF0000'); + expect(bus.lineTextColor).toBe('#ffffff'); + expect(bus.intermediateStops).toBe(2); + expect(bus.from.track).toBe('2'); + expect(it.legs[0].distance).toBe(251); + }); + + it('TRANSIT-SVC-008: forwards only whitelisted params and pins directModes=WALK', async () => { + fetchMock.mockResolvedValueOnce(okJson({ itineraries: [] })); + await plan({ from: '48.1000,11.5000', to: '48.2000,11.6000', modes: 'BUS,TRAM', maxTransfers: 2, arriveBy: true, time: '2026-07-13T09:00:00Z' }); + const url = String(fetchMock.mock.calls[0][0]); + expect(url).toContain('/api/v6/plan?'); + expect(url).toContain('transitModes=BUS%2CTRAM'); + expect(url).toContain('maxTransfers=2'); + expect(url).toContain('arriveBy=true'); + expect(url).toContain('directModes=WALK'); + }); + + it('TRANSIT-SVC-009: identical plans hit the cache (single upstream call)', async () => { + fetchMock.mockResolvedValue(okJson({ itineraries: [] })); + await plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' }); + await plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('TRANSIT-SVC-010: upstream failure surfaces as a 502-style error', async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 500 }); + await expect(plan({ from: '41.0000,2.0000', to: '41.1000,2.1000' })).rejects.toMatchObject({ status: 502 }); + }); +}); diff --git a/shared/src/i18n/ar/trip.ts b/shared/src/i18n/ar/trip.ts index 62cfd7b6f..b400d893e 100644 --- a/shared/src/i18n/ar/trip.ts +++ b/shared/src/i18n/ar/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'الدعوة غير متاحة', 'trip.invite.invalid': 'رابط الدعوة هذا غير صالح أو انتهت صلاحيته.', 'trip.invite.backToDashboard': 'العودة إلى لوحة التحكم', + 'transit.title': 'النقل العام', + 'transit.from': 'من', + 'transit.to': 'إلى', + 'transit.searchStop': 'ابحث عن موقف أو محطة…', + 'transit.swap': 'تبديل', + 'transit.depart': 'المغادرة', + 'transit.arrive': 'الوصول', + 'transit.mode.rail': 'قطار', + 'transit.mode.subway': 'مترو الأنفاق', + 'transit.mode.tram': 'ترام', + 'transit.mode.bus': 'حافلة', + 'transit.mode.ferry': 'عبّارة', + 'transit.mode.cable': 'تلفريك', + 'transit.pref.best': 'أفضل مسار', + 'transit.pref.transfers': 'تحويلات أقل', + 'transit.pref.walking': 'مشي أقل', + 'transit.search': 'بحث', + 'transit.searching': 'جارٍ البحث…', + 'transit.searchError': 'فشل البحث عن المسار. يُرجى المحاولة مرة أخرى.', + 'transit.noResults': 'لم يُعثر على أي رحلات. جرّب وقتًا أو عوامل تصفية مختلفة.', + 'transit.direct': 'مباشر', + 'transit.transfers': '{count} تحويلات', + 'transit.min': '{count} دقيقة', + 'transit.stops': '{count} محطات', + 'transit.walkTo': 'امشِ إلى {name}', + 'transit.platform': 'الرصيف {track}', + 'transit.adding': 'جارٍ الإضافة…', + 'transit.addToDay': 'إضافة إلى اليوم', + 'transit.itinerary': 'خط سير الرحلة', + 'transit.attribution': 'بيانات المسارات عبر', }; export default trip; diff --git a/shared/src/i18n/br/trip.ts b/shared/src/i18n/br/trip.ts index 9c60e0f08..6d7126ffa 100644 --- a/shared/src/i18n/br/trip.ts +++ b/shared/src/i18n/br/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Convite indisponível', 'trip.invite.invalid': 'Este link de convite é inválido ou expirou.', 'trip.invite.backToDashboard': 'Voltar ao painel', + 'transit.title': 'Transporte público', + 'transit.from': 'De', + 'transit.to': 'Para', + 'transit.searchStop': 'Buscar parada ou estação…', + 'transit.swap': 'Inverter', + 'transit.depart': 'Partida', + 'transit.arrive': 'Chegada', + 'transit.mode.rail': 'Trem', + 'transit.mode.subway': 'Metrô', + 'transit.mode.tram': 'Bonde', + 'transit.mode.bus': 'Ônibus', + 'transit.mode.ferry': 'Barca', + 'transit.mode.cable': 'Teleférico', + 'transit.pref.best': 'Melhor rota', + 'transit.pref.transfers': 'Menos baldeações', + 'transit.pref.walking': 'Menos caminhada', + 'transit.search': 'Buscar', + 'transit.searching': 'Buscando…', + 'transit.searchError': 'Falha na busca de rota. Tente novamente.', + 'transit.noResults': 'Nenhuma conexão encontrada. Tente outro horário ou filtros.', + 'transit.direct': 'Direto', + 'transit.transfers': '{count} baldeações', + 'transit.min': '{count} min', + 'transit.stops': '{count} paradas', + 'transit.walkTo': 'Caminhar até {name}', + 'transit.platform': 'Plataforma {track}', + 'transit.adding': 'Adicionando…', + 'transit.addToDay': 'Adicionar ao dia', + 'transit.itinerary': 'Itinerário', + 'transit.attribution': 'Dados de rota via', }; export default trip; diff --git a/shared/src/i18n/cs/trip.ts b/shared/src/i18n/cs/trip.ts index 9daa3d5b0..9171b3939 100644 --- a/shared/src/i18n/cs/trip.ts +++ b/shared/src/i18n/cs/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Pozvánka není dostupná', 'trip.invite.invalid': 'Tento odkaz pro pozvání je neplatný nebo jeho platnost vypršela.', 'trip.invite.backToDashboard': 'Zpět na přehled', + 'transit.title': 'Veřejná doprava', + 'transit.from': 'Odkud', + 'transit.to': 'Kam', + 'transit.searchStop': 'Hledat zastávku nebo stanici…', + 'transit.swap': 'Prohodit', + 'transit.depart': 'Odjezd', + 'transit.arrive': 'Příjezd', + 'transit.mode.rail': 'Vlak', + 'transit.mode.subway': 'Metro', + 'transit.mode.tram': 'Tramvaj', + 'transit.mode.bus': 'Autobus', + 'transit.mode.ferry': 'Trajekt', + 'transit.mode.cable': 'Lanovka', + 'transit.pref.best': 'Nejlepší spojení', + 'transit.pref.transfers': 'Méně přestupů', + 'transit.pref.walking': 'Méně chůze', + 'transit.search': 'Hledat', + 'transit.searching': 'Hledání…', + 'transit.searchError': 'Vyhledání spojení selhalo. Zkuste to prosím znovu.', + 'transit.noResults': 'Nebyla nalezena žádná spojení. Zkuste jiný čas nebo filtry.', + 'transit.direct': 'Přímé', + 'transit.transfers': '{count} přestupů', + 'transit.min': '{count} min', + 'transit.stops': '{count} zastávek', + 'transit.walkTo': 'Pěšky k {name}', + 'transit.platform': 'Nástupiště {track}', + 'transit.adding': 'Přidávání…', + 'transit.addToDay': 'Přidat ke dni', + 'transit.itinerary': 'Itinerář', + 'transit.attribution': 'Data o spojení přes', }; export default trip; diff --git a/shared/src/i18n/de/trip.ts b/shared/src/i18n/de/trip.ts index 43f6cd139..f0b0f48c1 100644 --- a/shared/src/i18n/de/trip.ts +++ b/shared/src/i18n/de/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Einladung nicht verfügbar', 'trip.invite.invalid': 'Dieser Einladungslink ist ungültig oder abgelaufen.', 'trip.invite.backToDashboard': 'Zurück zum Dashboard', + 'transit.title': 'Öffentliche Verkehrsmittel', + 'transit.from': 'Von', + 'transit.to': 'Nach', + 'transit.searchStop': 'Haltestelle oder Bahnhof suchen …', + 'transit.swap': 'Tauschen', + 'transit.depart': 'Abfahrt', + 'transit.arrive': 'Ankunft', + 'transit.mode.rail': 'Zug', + 'transit.mode.subway': 'U-Bahn', + 'transit.mode.tram': 'Tram', + 'transit.mode.bus': 'Bus', + 'transit.mode.ferry': 'Fähre', + 'transit.mode.cable': 'Seilbahn', + 'transit.pref.best': 'Beste Route', + 'transit.pref.transfers': 'Wenige Umstiege', + 'transit.pref.walking': 'Wenig Fußweg', + 'transit.search': 'Suchen', + 'transit.searching': 'Suche …', + 'transit.searchError': 'Routensuche fehlgeschlagen. Bitte erneut versuchen.', + 'transit.noResults': 'Keine Verbindungen gefunden. Andere Zeit oder Filter probieren.', + 'transit.direct': 'Direkt', + 'transit.transfers': '{count} Umstiege', + 'transit.min': '{count} Min.', + 'transit.stops': '{count} Halte', + 'transit.walkTo': 'Fußweg zu {name}', + 'transit.platform': 'Gleis {track}', + 'transit.adding': 'Wird hinzugefügt …', + 'transit.addToDay': 'Zum Tag hinzufügen', + 'transit.itinerary': 'Verbindung', + 'transit.attribution': 'Routing-Daten via', }; export default trip; diff --git a/shared/src/i18n/en/trip.ts b/shared/src/i18n/en/trip.ts index 9cde19496..247dbb649 100644 --- a/shared/src/i18n/en/trip.ts +++ b/shared/src/i18n/en/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Invite unavailable', 'trip.invite.invalid': 'This invite link is invalid or has expired.', 'trip.invite.backToDashboard': 'Back to dashboard', + 'transit.title': 'Public transit', + 'transit.from': 'From', + 'transit.to': 'To', + 'transit.searchStop': 'Search stop or station…', + 'transit.swap': 'Swap', + 'transit.depart': 'Depart', + 'transit.arrive': 'Arrive', + 'transit.mode.rail': 'Train', + 'transit.mode.subway': 'Subway', + 'transit.mode.tram': 'Tram', + 'transit.mode.bus': 'Bus', + 'transit.mode.ferry': 'Ferry', + 'transit.mode.cable': 'Cable car', + 'transit.pref.best': 'Best route', + 'transit.pref.transfers': 'Fewer transfers', + 'transit.pref.walking': 'Less walking', + 'transit.search': 'Search', + 'transit.searching': 'Searching…', + 'transit.searchError': 'Route search failed. Please try again.', + 'transit.noResults': 'No connections found. Try a different time or filters.', + 'transit.direct': 'Direct', + 'transit.transfers': '{count} transfers', + 'transit.min': '{count} min', + 'transit.stops': '{count} stops', + 'transit.walkTo': 'Walk to {name}', + 'transit.platform': 'Platform {track}', + 'transit.adding': 'Adding…', + 'transit.addToDay': 'Add to day', + 'transit.itinerary': 'Itinerary', + 'transit.attribution': 'Routing data via', }; export default trip; diff --git a/shared/src/i18n/es/trip.ts b/shared/src/i18n/es/trip.ts index 3004f6ac1..edf412436 100644 --- a/shared/src/i18n/es/trip.ts +++ b/shared/src/i18n/es/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Invitación no disponible', 'trip.invite.invalid': 'Este enlace de invitación no es válido o ha caducado.', 'trip.invite.backToDashboard': 'Volver al panel', + 'transit.title': 'Transporte público', + 'transit.from': 'Origen', + 'transit.to': 'Destino', + 'transit.searchStop': 'Buscar parada o estación…', + 'transit.swap': 'Intercambiar', + 'transit.depart': 'Salida', + 'transit.arrive': 'Llegada', + 'transit.mode.rail': 'Tren', + 'transit.mode.subway': 'Metro', + 'transit.mode.tram': 'Tranvía', + 'transit.mode.bus': 'Autobús', + 'transit.mode.ferry': 'Ferry', + 'transit.mode.cable': 'Teleférico', + 'transit.pref.best': 'Mejor ruta', + 'transit.pref.transfers': 'Menos transbordos', + 'transit.pref.walking': 'Menos caminata', + 'transit.search': 'Buscar', + 'transit.searching': 'Buscando…', + 'transit.searchError': 'No se pudo buscar la ruta. Inténtalo de nuevo.', + 'transit.noResults': 'No se encontraron conexiones. Prueba con otra hora u otros filtros.', + 'transit.direct': 'Directo', + 'transit.transfers': '{count} transbordos', + 'transit.min': '{count} min', + 'transit.stops': '{count} paradas', + 'transit.walkTo': 'Camina hasta {name}', + 'transit.platform': 'Andén {track}', + 'transit.adding': 'Añadiendo…', + 'transit.addToDay': 'Añadir al día', + 'transit.itinerary': 'Itinerario', + 'transit.attribution': 'Datos de rutas vía', }; export default trip; diff --git a/shared/src/i18n/fr/trip.ts b/shared/src/i18n/fr/trip.ts index 9972d35a0..4b6c48f92 100644 --- a/shared/src/i18n/fr/trip.ts +++ b/shared/src/i18n/fr/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Invitation indisponible', 'trip.invite.invalid': 'Ce lien d\'invitation est invalide ou a expiré.', 'trip.invite.backToDashboard': 'Retour au tableau de bord', + 'transit.title': 'Transports en commun', + 'transit.from': 'Départ', + 'transit.to': 'Arrivée', + 'transit.searchStop': 'Rechercher un arrêt ou une gare…', + 'transit.swap': 'Inverser', + 'transit.depart': 'Départ', + 'transit.arrive': 'Arrivée', + 'transit.mode.rail': 'Train', + 'transit.mode.subway': 'Métro', + 'transit.mode.tram': 'Tramway', + 'transit.mode.bus': 'Bus', + 'transit.mode.ferry': 'Ferry', + 'transit.mode.cable': 'Téléphérique', + 'transit.pref.best': 'Meilleur itinéraire', + 'transit.pref.transfers': 'Moins de correspondances', + 'transit.pref.walking': 'Moins de marche', + 'transit.search': 'Rechercher', + 'transit.searching': 'Recherche…', + 'transit.searchError': 'Échec de la recherche d\'itinéraire. Veuillez réessayer.', + 'transit.noResults': 'Aucune connexion trouvée. Essayez un autre horaire ou d\'autres filtres.', + 'transit.direct': 'Direct', + 'transit.transfers': '{count} correspondances', + 'transit.min': '{count} min', + 'transit.stops': '{count} arrêts', + 'transit.walkTo': 'Marcher jusqu\'à {name}', + 'transit.platform': 'Voie {track}', + 'transit.adding': 'Ajout…', + 'transit.addToDay': 'Ajouter au jour', + 'transit.itinerary': 'Itinéraire', + 'transit.attribution': 'Données d\'itinéraire via', }; export default trip; diff --git a/shared/src/i18n/gr/trip.ts b/shared/src/i18n/gr/trip.ts index 6bc828c61..17b5b40e1 100644 --- a/shared/src/i18n/gr/trip.ts +++ b/shared/src/i18n/gr/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Η πρόσκληση δεν είναι διαθέσιμη', 'trip.invite.invalid': 'Αυτός ο σύνδεσμος πρόσκλησης δεν είναι έγκυρος ή έχει λήξει.', 'trip.invite.backToDashboard': 'Επιστροφή στα ταξίδια μου', + 'transit.title': 'Μέσα μαζικής μεταφοράς', + 'transit.from': 'Από', + 'transit.to': 'Προς', + 'transit.searchStop': 'Αναζήτηση στάσης ή σταθμού…', + 'transit.swap': 'Εναλλαγή', + 'transit.depart': 'Αναχώρηση', + 'transit.arrive': 'Άφιξη', + 'transit.mode.rail': 'Τρένο', + 'transit.mode.subway': 'Μετρό', + 'transit.mode.tram': 'Τραμ', + 'transit.mode.bus': 'Λεωφορείο', + 'transit.mode.ferry': 'Πλοίο', + 'transit.mode.cable': 'Τελεφερίκ', + 'transit.pref.best': 'Καλύτερη διαδρομή', + 'transit.pref.transfers': 'Λιγότερες μετεπιβιβάσεις', + 'transit.pref.walking': 'Λιγότερο περπάτημα', + 'transit.search': 'Αναζήτηση', + 'transit.searching': 'Αναζήτηση…', + 'transit.searchError': 'Η αναζήτηση διαδρομής απέτυχε. Δοκιμάστε ξανά.', + 'transit.noResults': 'Δεν βρέθηκαν συνδέσεις. Δοκιμάστε άλλη ώρα ή φίλτρα.', + 'transit.direct': 'Απευθείας', + 'transit.transfers': '{count} μετεπιβιβάσεις', + 'transit.min': '{count} λεπτά', + 'transit.stops': '{count} στάσεις', + 'transit.walkTo': 'Περπάτημα προς {name}', + 'transit.platform': 'Αποβάθρα {track}', + 'transit.adding': 'Προσθήκη…', + 'transit.addToDay': 'Προσθήκη στην ημέρα', + 'transit.itinerary': 'Δρομολόγιο', + 'transit.attribution': 'Δεδομένα διαδρομών μέσω', }; export default trip; diff --git a/shared/src/i18n/hu/trip.ts b/shared/src/i18n/hu/trip.ts index 32127f4de..311317635 100644 --- a/shared/src/i18n/hu/trip.ts +++ b/shared/src/i18n/hu/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'A meghívó nem érhető el', 'trip.invite.invalid': 'Ez a meghívó link érvénytelen vagy lejárt.', 'trip.invite.backToDashboard': 'Vissza az irányítópultra', + 'transit.title': 'Tömegközlekedés', + 'transit.from': 'Innen', + 'transit.to': 'Ide', + 'transit.searchStop': 'Megálló vagy állomás keresése…', + 'transit.swap': 'Csere', + 'transit.depart': 'Indulás', + 'transit.arrive': 'Érkezés', + 'transit.mode.rail': 'Vonat', + 'transit.mode.subway': 'Metró', + 'transit.mode.tram': 'Villamos', + 'transit.mode.bus': 'Busz', + 'transit.mode.ferry': 'Komp', + 'transit.mode.cable': 'Sikló', + 'transit.pref.best': 'Legjobb útvonal', + 'transit.pref.transfers': 'Kevesebb átszállás', + 'transit.pref.walking': 'Kevesebb gyaloglás', + 'transit.search': 'Keresés', + 'transit.searching': 'Keresés…', + 'transit.searchError': 'Az útvonalkeresés nem sikerült. Kérjük, próbáld újra.', + 'transit.noResults': 'Nem található kapcsolat. Próbálj másik időpontot vagy szűrőket.', + 'transit.direct': 'Közvetlen', + 'transit.transfers': '{count} átszállás', + 'transit.min': '{count} perc', + 'transit.stops': '{count} megálló', + 'transit.walkTo': 'Gyaloglás ide: {name}', + 'transit.platform': '{track}. vágány', + 'transit.adding': 'Hozzáadás…', + 'transit.addToDay': 'Hozzáadás a naphoz', + 'transit.itinerary': 'Útiterv', + 'transit.attribution': 'Útvonaladatok innen:', }; export default trip; diff --git a/shared/src/i18n/id/trip.ts b/shared/src/i18n/id/trip.ts index 90bc33e60..76828eda4 100644 --- a/shared/src/i18n/id/trip.ts +++ b/shared/src/i18n/id/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Undangan tidak tersedia', 'trip.invite.invalid': 'Tautan undangan ini tidak valid atau sudah kedaluwarsa.', 'trip.invite.backToDashboard': 'Kembali ke dasbor', + 'transit.title': 'Transportasi umum', + 'transit.from': 'Dari', + 'transit.to': 'Ke', + 'transit.searchStop': 'Cari halte atau stasiun…', + 'transit.swap': 'Tukar', + 'transit.depart': 'Berangkat', + 'transit.arrive': 'Tiba', + 'transit.mode.rail': 'Kereta', + 'transit.mode.subway': 'MRT', + 'transit.mode.tram': 'Trem', + 'transit.mode.bus': 'Bus', + 'transit.mode.ferry': 'Feri', + 'transit.mode.cable': 'Kereta gantung', + 'transit.pref.best': 'Rute terbaik', + 'transit.pref.transfers': 'Lebih sedikit transit', + 'transit.pref.walking': 'Lebih sedikit jalan kaki', + 'transit.search': 'Cari', + 'transit.searching': 'Mencari…', + 'transit.searchError': 'Pencarian rute gagal. Silakan coba lagi.', + 'transit.noResults': 'Tidak ada koneksi ditemukan. Coba waktu atau filter lain.', + 'transit.direct': 'Langsung', + 'transit.transfers': '{count} transit', + 'transit.min': '{count} mnt', + 'transit.stops': '{count} halte', + 'transit.walkTo': 'Jalan kaki ke {name}', + 'transit.platform': 'Peron {track}', + 'transit.adding': 'Menambahkan…', + 'transit.addToDay': 'Tambahkan ke hari', + 'transit.itinerary': 'Itinerari', + 'transit.attribution': 'Data rute melalui', }; export default trip; diff --git a/shared/src/i18n/it/trip.ts b/shared/src/i18n/it/trip.ts index 12c81e4d2..ef0e4a949 100644 --- a/shared/src/i18n/it/trip.ts +++ b/shared/src/i18n/it/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Invito non disponibile', 'trip.invite.invalid': 'Questo link di invito non è valido o è scaduto.', 'trip.invite.backToDashboard': 'Torna alla dashboard', + 'transit.title': 'Trasporto pubblico', + 'transit.from': 'Da', + 'transit.to': 'A', + 'transit.searchStop': 'Cerca fermata o stazione…', + 'transit.swap': 'Inverti', + 'transit.depart': 'Partenza', + 'transit.arrive': 'Arrivo', + 'transit.mode.rail': 'Treno', + 'transit.mode.subway': 'Metropolitana', + 'transit.mode.tram': 'Tram', + 'transit.mode.bus': 'Autobus', + 'transit.mode.ferry': 'Traghetto', + 'transit.mode.cable': 'Funivia', + 'transit.pref.best': 'Percorso migliore', + 'transit.pref.transfers': 'Meno cambi', + 'transit.pref.walking': 'Meno spostamenti a piedi', + 'transit.search': 'Cerca', + 'transit.searching': 'Ricerca in corso…', + 'transit.searchError': 'Ricerca del percorso non riuscita. Riprova.', + 'transit.noResults': 'Nessun collegamento trovato. Prova un altro orario o filtri.', + 'transit.direct': 'Diretto', + 'transit.transfers': '{count} cambi', + 'transit.min': '{count} min', + 'transit.stops': '{count} fermate', + 'transit.walkTo': 'A piedi fino a {name}', + 'transit.platform': 'Binario {track}', + 'transit.adding': 'Aggiunta in corso…', + 'transit.addToDay': 'Aggiungi al giorno', + 'transit.itinerary': 'Itinerario', + 'transit.attribution': 'Dati di routing tramite', }; export default trip; diff --git a/shared/src/i18n/ja/trip.ts b/shared/src/i18n/ja/trip.ts index 862cdfcbc..2c0c5be08 100644 --- a/shared/src/i18n/ja/trip.ts +++ b/shared/src/i18n/ja/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': '招待は利用できません', 'trip.invite.invalid': 'この招待リンクは無効か、有効期限が切れています。', 'trip.invite.backToDashboard': 'ダッシュボードに戻る', + 'transit.title': '公共交通機関', + 'transit.from': '出発地', + 'transit.to': '目的地', + 'transit.searchStop': '停留所・駅を検索…', + 'transit.swap': '入れ替え', + 'transit.depart': '出発', + 'transit.arrive': '到着', + 'transit.mode.rail': '電車', + 'transit.mode.subway': '地下鉄', + 'transit.mode.tram': '路面電車', + 'transit.mode.bus': 'バス', + 'transit.mode.ferry': 'フェリー', + 'transit.mode.cable': 'ケーブルカー', + 'transit.pref.best': 'おすすめルート', + 'transit.pref.transfers': '乗り換え少', + 'transit.pref.walking': '徒歩少', + 'transit.search': '検索', + 'transit.searching': '検索中…', + 'transit.searchError': 'ルート検索に失敗しました。もう一度お試しください。', + 'transit.noResults': '接続が見つかりませんでした。時間やフィルターを変更してお試しください。', + 'transit.direct': '直通', + 'transit.transfers': '乗り換え{count}回', + 'transit.min': '{count}分', + 'transit.stops': '{count}駅', + 'transit.walkTo': '{name}まで徒歩', + 'transit.platform': '{track}番線', + 'transit.adding': '追加中…', + 'transit.addToDay': '日程に追加', + 'transit.itinerary': '行程', + 'transit.attribution': 'ルートデータ提供:', }; export default trip; diff --git a/shared/src/i18n/ko/trip.ts b/shared/src/i18n/ko/trip.ts index 9d3a807f1..45a9659b3 100644 --- a/shared/src/i18n/ko/trip.ts +++ b/shared/src/i18n/ko/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': '초대를 사용할 수 없음', 'trip.invite.invalid': '이 초대 링크는 유효하지 않거나 만료되었습니다.', 'trip.invite.backToDashboard': '대시보드로 돌아가기', + 'transit.title': '대중교통', + 'transit.from': '출발', + 'transit.to': '도착', + 'transit.searchStop': '정류장 또는 역 검색…', + 'transit.swap': '출발지·도착지 교체', + 'transit.depart': '출발 시각', + 'transit.arrive': '도착 시각', + 'transit.mode.rail': '기차', + 'transit.mode.subway': '지하철', + 'transit.mode.tram': '트램', + 'transit.mode.bus': '버스', + 'transit.mode.ferry': '여객선', + 'transit.mode.cable': '케이블카', + 'transit.pref.best': '최적 경로', + 'transit.pref.transfers': '환승 최소', + 'transit.pref.walking': '도보 최소', + 'transit.search': '검색', + 'transit.searching': '검색 중…', + 'transit.searchError': '경로 검색에 실패했습니다. 다시 시도해 주세요.', + 'transit.noResults': '연결편을 찾을 수 없습니다. 시간이나 필터를 변경해 보세요.', + 'transit.direct': '직행', + 'transit.transfers': '환승 {count}회', + 'transit.min': '{count}분', + 'transit.stops': '{count}개 정류장', + 'transit.walkTo': '{name}까지 도보', + 'transit.platform': '{track}번 승강장', + 'transit.adding': '추가 중…', + 'transit.addToDay': '일정에 추가', + 'transit.itinerary': '여정', + 'transit.attribution': '경로 데이터 제공:', }; export default trip; diff --git a/shared/src/i18n/nl/trip.ts b/shared/src/i18n/nl/trip.ts index 41a6d94bd..82d3df77c 100644 --- a/shared/src/i18n/nl/trip.ts +++ b/shared/src/i18n/nl/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Uitnodiging niet beschikbaar', 'trip.invite.invalid': 'Deze uitnodigingslink is ongeldig of verlopen.', 'trip.invite.backToDashboard': 'Terug naar dashboard', + 'transit.title': 'Openbaar vervoer', + 'transit.from': 'Van', + 'transit.to': 'Naar', + 'transit.searchStop': 'Zoek halte of station…', + 'transit.swap': 'Wisselen', + 'transit.depart': 'Vertrek', + 'transit.arrive': 'Aankomst', + 'transit.mode.rail': 'Trein', + 'transit.mode.subway': 'Metro', + 'transit.mode.tram': 'Tram', + 'transit.mode.bus': 'Bus', + 'transit.mode.ferry': 'Veerboot', + 'transit.mode.cable': 'Kabelbaan', + 'transit.pref.best': 'Beste route', + 'transit.pref.transfers': 'Minder overstappen', + 'transit.pref.walking': 'Minder lopen', + 'transit.search': 'Zoeken', + 'transit.searching': 'Zoeken…', + 'transit.searchError': 'Routezoeken mislukt. Probeer het opnieuw.', + 'transit.noResults': 'Geen verbindingen gevonden. Probeer een andere tijd of andere filters.', + 'transit.direct': 'Direct', + 'transit.transfers': '{count} keer overstappen', + 'transit.min': '{count} min', + 'transit.stops': '{count} haltes', + 'transit.walkTo': 'Loop naar {name}', + 'transit.platform': 'Perron {track}', + 'transit.adding': 'Toevoegen…', + 'transit.addToDay': 'Toevoegen aan dag', + 'transit.itinerary': 'Reisplan', + 'transit.attribution': 'Routegegevens via', }; export default trip; diff --git a/shared/src/i18n/pl/trip.ts b/shared/src/i18n/pl/trip.ts index 8882702a3..4305bde15 100644 --- a/shared/src/i18n/pl/trip.ts +++ b/shared/src/i18n/pl/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Zaproszenie niedostępne', 'trip.invite.invalid': 'Ten link zaproszenia jest nieprawidłowy lub wygasł.', 'trip.invite.backToDashboard': 'Powrót do panelu', + 'transit.title': 'Transport publiczny', + 'transit.from': 'Skąd', + 'transit.to': 'Dokąd', + 'transit.searchStop': 'Szukaj przystanku lub stacji…', + 'transit.swap': 'Zamień', + 'transit.depart': 'Odjazd', + 'transit.arrive': 'Przyjazd', + 'transit.mode.rail': 'Pociąg', + 'transit.mode.subway': 'Metro', + 'transit.mode.tram': 'Tramwaj', + 'transit.mode.bus': 'Autobus', + 'transit.mode.ferry': 'Prom', + 'transit.mode.cable': 'Kolej linowa', + 'transit.pref.best': 'Najlepsza trasa', + 'transit.pref.transfers': 'Mniej przesiadek', + 'transit.pref.walking': 'Mniej pieszo', + 'transit.search': 'Szukaj', + 'transit.searching': 'Wyszukiwanie…', + 'transit.searchError': 'Wyszukiwanie trasy nie powiodło się. Spróbuj ponownie.', + 'transit.noResults': 'Nie znaleziono połączeń. Zmień godzinę lub filtry.', + 'transit.direct': 'Bezpośrednio', + 'transit.transfers': 'Przesiadki: {count}', + 'transit.min': '{count} min', + 'transit.stops': 'Przystanki: {count}', + 'transit.walkTo': 'Idź pieszo do {name}', + 'transit.platform': 'Peron {track}', + 'transit.adding': 'Dodawanie…', + 'transit.addToDay': 'Dodaj do dnia', + 'transit.itinerary': 'Plan podróży', + 'transit.attribution': 'Dane tras z', }; export default trip; diff --git a/shared/src/i18n/ru/trip.ts b/shared/src/i18n/ru/trip.ts index c5911a6f9..e50575c6e 100644 --- a/shared/src/i18n/ru/trip.ts +++ b/shared/src/i18n/ru/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Приглашение недоступно', 'trip.invite.invalid': 'Эта ссылка-приглашение недействительна или срок её действия истёк.', 'trip.invite.backToDashboard': 'Вернуться на панель управления', + 'transit.title': 'Общественный транспорт', + 'transit.from': 'Откуда', + 'transit.to': 'Куда', + 'transit.searchStop': 'Поиск остановки или станции…', + 'transit.swap': 'Поменять местами', + 'transit.depart': 'Отправление', + 'transit.arrive': 'Прибытие', + 'transit.mode.rail': 'Поезд', + 'transit.mode.subway': 'Метро', + 'transit.mode.tram': 'Трамвай', + 'transit.mode.bus': 'Автобус', + 'transit.mode.ferry': 'Паром', + 'transit.mode.cable': 'Канатная дорога', + 'transit.pref.best': 'Лучший маршрут', + 'transit.pref.transfers': 'Меньше пересадок', + 'transit.pref.walking': 'Меньше пешком', + 'transit.search': 'Поиск', + 'transit.searching': 'Поиск…', + 'transit.searchError': 'Не удалось найти маршрут. Попробуйте снова.', + 'transit.noResults': 'Маршруты не найдены. Измените время или фильтры.', + 'transit.direct': 'Без пересадок', + 'transit.transfers': 'Пересадок: {count}', + 'transit.min': '{count} мин', + 'transit.stops': 'Остановок: {count}', + 'transit.walkTo': 'Пешком до {name}', + 'transit.platform': 'Платформа {track}', + 'transit.adding': 'Добавление…', + 'transit.addToDay': 'Добавить в день', + 'transit.itinerary': 'Маршрут', + 'transit.attribution': 'Данные о маршрутах через', }; export default trip; diff --git a/shared/src/i18n/sv/trip.ts b/shared/src/i18n/sv/trip.ts index 55c142eeb..94ba2e4b4 100644 --- a/shared/src/i18n/sv/trip.ts +++ b/shared/src/i18n/sv/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Inbjudan inte tillgänglig', 'trip.invite.invalid': 'Den här inbjudningslänken är ogiltig eller har gått ut.', 'trip.invite.backToDashboard': 'Tillbaka till översikten', + 'transit.title': 'Kollektivtrafik', + 'transit.from': 'Från', + 'transit.to': 'Till', + 'transit.searchStop': 'Sök hållplats eller station…', + 'transit.swap': 'Byt plats', + 'transit.depart': 'Avgång', + 'transit.arrive': 'Ankomst', + 'transit.mode.rail': 'Tåg', + 'transit.mode.subway': 'Tunnelbana', + 'transit.mode.tram': 'Spårvagn', + 'transit.mode.bus': 'Buss', + 'transit.mode.ferry': 'Färja', + 'transit.mode.cable': 'Linbana', + 'transit.pref.best': 'Bästa resväg', + 'transit.pref.transfers': 'Färre byten', + 'transit.pref.walking': 'Mindre gång', + 'transit.search': 'Sök', + 'transit.searching': 'Söker…', + 'transit.searchError': 'Reseökningen misslyckades. Försök igen.', + 'transit.noResults': 'Inga förbindelser hittades. Prova en annan tid eller andra filter.', + 'transit.direct': 'Direkt', + 'transit.transfers': '{count} byten', + 'transit.min': '{count} min', + 'transit.stops': '{count} hållplatser', + 'transit.walkTo': 'Gå till {name}', + 'transit.platform': 'Spår {track}', + 'transit.adding': 'Lägger till…', + 'transit.addToDay': 'Lägg till dag', + 'transit.itinerary': 'Reseplan', + 'transit.attribution': 'Ruttdata via', }; export default trip; diff --git a/shared/src/i18n/tr/trip.ts b/shared/src/i18n/tr/trip.ts index 736de165f..de6ed7b74 100644 --- a/shared/src/i18n/tr/trip.ts +++ b/shared/src/i18n/tr/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Davet kullanılamıyor', 'trip.invite.invalid': 'Bu davet bağlantısı geçersiz veya süresi dolmuş.', 'trip.invite.backToDashboard': 'Panoya dön', + 'transit.title': 'Toplu taşıma', + 'transit.from': 'Nereden', + 'transit.to': 'Nereye', + 'transit.searchStop': 'Durak veya istasyon ara…', + 'transit.swap': 'Değiştir', + 'transit.depart': 'Kalkış', + 'transit.arrive': 'Varış', + 'transit.mode.rail': 'Tren', + 'transit.mode.subway': 'Metro', + 'transit.mode.tram': 'Tramvay', + 'transit.mode.bus': 'Otobüs', + 'transit.mode.ferry': 'Vapur', + 'transit.mode.cable': 'Teleferik', + 'transit.pref.best': 'En iyi rota', + 'transit.pref.transfers': 'Daha az aktarma', + 'transit.pref.walking': 'Daha az yürüme', + 'transit.search': 'Ara', + 'transit.searching': 'Aranıyor…', + 'transit.searchError': 'Rota araması başarısız oldu. Lütfen tekrar deneyin.', + 'transit.noResults': 'Bağlantı bulunamadı. Farklı bir saat veya filtre deneyin.', + 'transit.direct': 'Aktarmasız', + 'transit.transfers': '{count} aktarma', + 'transit.min': '{count} dk', + 'transit.stops': '{count} durak', + 'transit.walkTo': '{name} durağına yürü', + 'transit.platform': 'Peron {track}', + 'transit.adding': 'Ekleniyor…', + 'transit.addToDay': 'Güne ekle', + 'transit.itinerary': 'Güzergah', + 'transit.attribution': 'Rota verileri:', }; export default trip; diff --git a/shared/src/i18n/uk/trip.ts b/shared/src/i18n/uk/trip.ts index ffa9ebafb..b79e4ce21 100644 --- a/shared/src/i18n/uk/trip.ts +++ b/shared/src/i18n/uk/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Запрошення недоступне', 'trip.invite.invalid': 'Це посилання-запрошення недійсне або термін його дії минув.', 'trip.invite.backToDashboard': 'Назад до панелі', + 'transit.title': 'Громадський транспорт', + 'transit.from': 'Звідки', + 'transit.to': 'Куди', + 'transit.searchStop': 'Пошук зупинки або станції…', + 'transit.swap': 'Поміняти місцями', + 'transit.depart': 'Відправлення', + 'transit.arrive': 'Прибуття', + 'transit.mode.rail': 'Поїзд', + 'transit.mode.subway': 'Метро', + 'transit.mode.tram': 'Трамвай', + 'transit.mode.bus': 'Автобус', + 'transit.mode.ferry': 'Пором', + 'transit.mode.cable': 'Канатна дорога', + 'transit.pref.best': 'Найкращий маршрут', + 'transit.pref.transfers': 'Менше пересадок', + 'transit.pref.walking': 'Менше пішки', + 'transit.search': 'Пошук', + 'transit.searching': 'Пошук…', + 'transit.searchError': 'Не вдалося знайти маршрут. Спробуйте ще раз.', + 'transit.noResults': 'З\'єднань не знайдено. Спробуйте інший час або фільтри.', + 'transit.direct': 'Прямий', + 'transit.transfers': 'Пересадок: {count}', + 'transit.min': '{count} хв', + 'transit.stops': 'Зупинок: {count}', + 'transit.walkTo': 'Пішки до {name}', + 'transit.platform': 'Платформа {track}', + 'transit.adding': 'Додавання…', + 'transit.addToDay': 'Додати до дня', + 'transit.itinerary': 'Маршрут', + 'transit.attribution': 'Дані маршрутизації через', }; export default trip; diff --git a/shared/src/i18n/vi/trip.ts b/shared/src/i18n/vi/trip.ts index f9c589d33..1002579b5 100644 --- a/shared/src/i18n/vi/trip.ts +++ b/shared/src/i18n/vi/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': 'Lời mời không khả dụng', 'trip.invite.invalid': 'Liên kết mời này không hợp lệ hoặc đã hết hạn.', 'trip.invite.backToDashboard': 'Quay lại bảng điều khiển', + 'transit.title': 'Giao thông công cộng', + 'transit.from': 'Điểm đi', + 'transit.to': 'Điểm đến', + 'transit.searchStop': 'Tìm điểm dừng hoặc ga…', + 'transit.swap': 'Đổi chiều', + 'transit.depart': 'Khởi hành', + 'transit.arrive': 'Đến nơi', + 'transit.mode.rail': 'Tàu hỏa', + 'transit.mode.subway': 'Tàu điện ngầm', + 'transit.mode.tram': 'Tàu điện', + 'transit.mode.bus': 'Xe buýt', + 'transit.mode.ferry': 'Phà', + 'transit.mode.cable': 'Cáp treo', + 'transit.pref.best': 'Tuyến tốt nhất', + 'transit.pref.transfers': 'Ít chuyển tuyến hơn', + 'transit.pref.walking': 'Ít đi bộ hơn', + 'transit.search': 'Tìm kiếm', + 'transit.searching': 'Đang tìm…', + 'transit.searchError': 'Tìm tuyến thất bại. Vui lòng thử lại.', + 'transit.noResults': 'Không tìm thấy tuyến. Hãy thử thời gian hoặc bộ lọc khác.', + 'transit.direct': 'Trực tiếp', + 'transit.transfers': '{count} lần chuyển tuyến', + 'transit.min': '{count} phút', + 'transit.stops': '{count} điểm dừng', + 'transit.walkTo': 'Đi bộ đến {name}', + 'transit.platform': 'Sân ga {track}', + 'transit.adding': 'Đang thêm…', + 'transit.addToDay': 'Thêm vào ngày', + 'transit.itinerary': 'Lộ trình', + 'transit.attribution': 'Dữ liệu tìm tuyến từ', }; export default trip; diff --git a/shared/src/i18n/zh-TW/trip.ts b/shared/src/i18n/zh-TW/trip.ts index ff5794734..56c4d6449 100644 --- a/shared/src/i18n/zh-TW/trip.ts +++ b/shared/src/i18n/zh-TW/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': '邀請無法使用', 'trip.invite.invalid': '此邀請連結無效或已過期。', 'trip.invite.backToDashboard': '返回儀表板', + 'transit.title': '大眾運輸', + 'transit.from': '起點', + 'transit.to': '終點', + 'transit.searchStop': '搜尋站點或車站…', + 'transit.swap': '對調', + 'transit.depart': '出發', + 'transit.arrive': '抵達', + 'transit.mode.rail': '火車', + 'transit.mode.subway': '捷運', + 'transit.mode.tram': '路面電車', + 'transit.mode.bus': '公車', + 'transit.mode.ferry': '渡輪', + 'transit.mode.cable': '纜車', + 'transit.pref.best': '最佳路線', + 'transit.pref.transfers': '較少轉乘', + 'transit.pref.walking': '較少步行', + 'transit.search': '搜尋', + 'transit.searching': '搜尋中…', + 'transit.searchError': '路線搜尋失敗,請重試。', + 'transit.noResults': '找不到路線。請嘗試其他時間或篩選條件。', + 'transit.direct': '直達', + 'transit.transfers': '轉乘 {count} 次', + 'transit.min': '{count} 分鐘', + 'transit.stops': '{count} 站', + 'transit.walkTo': '步行至 {name}', + 'transit.platform': '{track} 月台', + 'transit.adding': '新增中…', + 'transit.addToDay': '加入當日行程', + 'transit.itinerary': '行程', + 'transit.attribution': '路線資料來自', }; export default trip; diff --git a/shared/src/i18n/zh/trip.ts b/shared/src/i18n/zh/trip.ts index ee0c46b9e..310408dba 100644 --- a/shared/src/i18n/zh/trip.ts +++ b/shared/src/i18n/zh/trip.ts @@ -39,5 +39,35 @@ const trip: TranslationStrings = { 'trip.invite.invalidTitle': '邀请不可用', 'trip.invite.invalid': '此邀请链接无效或已过期。', 'trip.invite.backToDashboard': '返回仪表盘', + 'transit.title': '公共交通', + 'transit.from': '出发地', + 'transit.to': '目的地', + 'transit.searchStop': '搜索站点或车站…', + 'transit.swap': '交换', + 'transit.depart': '出发', + 'transit.arrive': '到达', + 'transit.mode.rail': '火车', + 'transit.mode.subway': '地铁', + 'transit.mode.tram': '有轨电车', + 'transit.mode.bus': '公交车', + 'transit.mode.ferry': '渡轮', + 'transit.mode.cable': '缆车', + 'transit.pref.best': '最佳路线', + 'transit.pref.transfers': '减少换乘', + 'transit.pref.walking': '减少步行', + 'transit.search': '搜索', + 'transit.searching': '搜索中…', + 'transit.searchError': '路线搜索失败,请重试。', + 'transit.noResults': '未找到线路,请尝试其他时间或筛选条件。', + 'transit.direct': '直达', + 'transit.transfers': '换乘 {count} 次', + 'transit.min': '{count} 分钟', + 'transit.stops': '{count} 站', + 'transit.walkTo': '步行至 {name}', + 'transit.platform': '{track} 站台', + 'transit.adding': '添加中…', + 'transit.addToDay': '添加到当天', + 'transit.itinerary': '行程', + 'transit.attribution': '路线数据来自', }; export default trip; diff --git a/wiki/Transport-Flights-Trains-Cars.md b/wiki/Transport-Flights-Trains-Cars.md index 0e7b020fb..0e9f3ac3f 100644 --- a/wiki/Transport-Flights-Trains-Cars.md +++ b/wiki/Transport-Flights-Trains-Cars.md @@ -6,6 +6,17 @@ Log flights, trains, car rentals, and cruises with departure and arrival endpoin Open the **Transports** tab in the trip planner and click **Add**, or open the planner from a day view and use the transport shortcut. Transport records appear in the [Reservations](Reservations-and-Bookings) panel alongside other bookings. +## Public transit search + +Each day header in the plan sidebar has a **transit button** (tram icon) that opens a public-transit route search, powered by [Transitous](https://transitous.org/) — free, open data, no API key or paid provider. (The rename pencil this button replaced moved next to the day name in the day detail panel.) + +- Pick **from** and **to** (stop/station search; the day's own places and hotels appear as quick picks), a **depart/arrive** time, and filter by mode: train, subway, tram, bus, ferry, cable car. +- Rank the results by **best route**, **fewer transfers**, or **less walking**. +- Each result shows local departure/arrival times, duration, transfers, walking time and the line badges in their official colors; expand it for the stop-by-stop breakdown. +- **Add to day** saves the chosen connection as a regular transport (typed by its dominant leg — train, bus, ferry …). It slots into the day timeline at its departure time and can be edited, deleted and drag-reordered like any other transport. The full itinerary stays visible in the transport's detail view; editing the transport keeps it as long as the origin and destination are unchanged. + +Self-hosters can point the `TRANSIT_API_URL` environment variable at their own MOTIS instance. + ## Transport types Four types are available: **Flight**, **Train**, **Car**, **Cruise**.