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**.