feat(planner): public transit routing via Transitous (#1065)

Each day header gets a transit button (replacing the rename pencil, which
moved next to the day name in the day detail panel). It opens a route search
backed by Transitous/MOTIS — free, open data, no paid provider: from/to stop
search with the day's own places as quick picks, depart/arrive time, mode
filters (train, subway, tram, bus, ferry, cable car) and ranking by best
route, fewer transfers or less walking. Results show local times, duration,
transfers, walking time and line badges in their official colors, with a
stop-by-stop breakdown per connection.

Adding a connection saves it as a regular transport reservation — typed by
its dominant leg, timed from the itinerary's wall-clock departure/arrival
converted to station-local time (tz-lookup), with the origin, transfer stops
and destination as endpoints and the compact legs in metadata.transit. It
slots into the day timeline by time and inherits editing, deletion and
drag-reordering from the existing transport machinery; the transport detail
view renders the full itinerary. Re-saving a transit transport through the
edit modal preserves the stored itinerary while the route is unchanged.

The server proxies the Transitous API (JWT-guarded, rate-limited, identifying
User-Agent, short response cache, strict mode whitelist); TRANSIT_API_URL
lets self-hosters use their own MOTIS instance. New i18n keys in every
locale, wiki page updated.

Discussion #1065
This commit is contained in:
Maurice
2026-07-02 17:29:12 +02:00
committed by Maurice
parent 268ed419b2
commit 45f1ff7799
42 changed files with 2227 additions and 55 deletions
+8
View File
@@ -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),
@@ -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(<DayDetailPanel {...defaultProps} onUpdateDayTitle={onUpdateDayTitle} />);
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(<DayDetailPanel {...defaultProps} onUpdateDayTitle={onUpdateDayTitle} />);
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(<DayDetailPanel {...defaultProps} />);
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(<DayDetailPanel {...defaultProps} day={untitled} days={[untitled]} />);
@@ -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<HTMLInputElement | null>(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
<Calendar size={collapsed ? 16 : 20} className="text-content" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: collapsed ? 13 : 15, fontWeight: 700, transition: 'font-size 0.15s ease' }}>
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
{collapsed && formattedDate && <span className="text-content-muted" style={{ fontWeight: 500, marginLeft: 8 }}>{formattedDate}</span>}
</div>
{editingTitle ? (
<input
ref={titleInputRef}
value={titleDraft}
onChange={e => 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 ? (
<div className="text-content" style={{ fontSize: 13, fontWeight: 700, transition: 'font-size 0.15s ease' }}>
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
{formattedDate && <span className="text-content-muted" style={{ fontWeight: 500, marginLeft: 8 }}>{formattedDate}</span>}
</div>
) : (
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 15, fontWeight: 700, transition: 'font-size 0.15s ease', minWidth: 0 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
</span>
{canEditDays && onUpdateDayTitle && (
<button onClick={startRename} aria-label={t('common.edit')} title={t('common.edit')} className="text-content-faint" style={{ border: 'none', background: 'none', padding: 3, cursor: 'pointer', display: 'flex', flexShrink: 0 }}>
<Pencil size={12} strokeWidth={1.8} />
</button>
)}
</div>
)}
{!collapsed && formattedDate && <div className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 1 }}>{formattedDate}</div>}
</div>
<button onClick={(e) => { e.stopPropagation(); toggleCollapse() }} title={collapsed ? t('common.expand') : t('common.collapse')}
@@ -297,41 +297,28 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText('Louvre Museum')).toBeInTheDocument()
})
// ── Day title editing ───────────────────────────────────────────────────
// ── Transit search button (#1065 — replaced the rename pencil; renaming
// moved next to the day name in the day detail panel) ─────────────────
it('FE-PLANNER-DAYPLAN-015: clicking edit button enters edit mode', async () => {
it('FE-PLANNER-DAYPLAN-015: transit button opens the route search for the day', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
await user.click(screen.getByLabelText('Edit'))
await waitFor(() => {
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
})
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const onPlanTransit = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onPlanTransit })} />)
await user.click(screen.getByLabelText('Public transit'))
expect(onPlanTransit).toHaveBeenCalledWith(10)
})
it('FE-PLANNER-DAYPLAN-016: pressing Enter commits title', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
const onUpdateDayTitle = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
// Enter edit mode
await user.click(screen.getByLabelText('Edit'))
const input = await screen.findByDisplayValue('Original Title')
await user.clear(input)
await user.type(input, 'New Title')
await user.keyboard('{Enter}')
expect(onUpdateDayTitle).toHaveBeenCalledWith(10, 'New Title')
it('FE-PLANNER-DAYPLAN-016: transit button is absent without the onPlanTransit prop', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
expect(screen.queryByLabelText('Public transit')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-017: pressing Escape cancels edit', async () => {
const user = userEvent.setup()
it('FE-PLANNER-DAYPLAN-017: the day header no longer has a rename pencil (#1065)', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
await user.click(screen.getByLabelText('Edit'))
const input = await screen.findByDisplayValue('Original Title')
await user.keyboard('{Escape}')
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
expect(screen.getByText('Original Title')).toBeInTheDocument()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onPlanTransit: vi.fn() })} />)
expect(screen.queryByLabelText('Edit')).not.toBeInTheDocument()
})
// ── Day info button ─────────────────────────────────────────────────────
@@ -642,20 +629,7 @@ describe('DayPlanSidebar', () => {
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
})
// ── Title blur commits ───────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-042: blurring title input commits the edit', async () => {
const user = userEvent.setup()
const onUpdateDayTitle = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
await user.click(screen.getByLabelText('Edit'))
const input = await screen.findByDisplayValue('Old Title')
await user.clear(input)
await user.type(input, 'New Title')
fireEvent.blur(input)
await waitFor(() => expect(onUpdateDayTitle).toHaveBeenCalledWith(10, 'New Title'))
})
// Day-title renaming moved to DayDetailPanel (#1065) — covered there.
// ── ICS export button ────────────────────────────────────────────────────
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront } from 'lucide-react'
import { assignmentsApi, reservationsApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
@@ -84,6 +84,8 @@ interface DayPlanSidebarProps {
onUndo?: () => void
onRouteRefresh?: () => void
onAddTransport?: (dayId: number) => void
/** Opens the public-transit route search for a day (#1065). */
onPlanTransit?: (dayId: number) => void
onEditTransport?: (reservation: Reservation) => void
onEditReservation?: (reservation: Reservation) => void
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
@@ -127,6 +129,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onUndo,
onRouteRefresh,
onAddTransport,
onPlanTransit,
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
@@ -1000,6 +1003,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onUndo,
onRouteRefresh,
onAddTransport,
onPlanTransit,
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
@@ -1161,6 +1165,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onUndo,
onRouteRefresh,
onAddTransport,
onPlanTransit,
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
@@ -1472,9 +1477,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const div = '1px solid var(--border-faint)'
return (
<div className="dp-day-actions" style={{ alignSelf: 'flex-start', flexShrink: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', border: div, borderRadius: 9, overflow: 'hidden' }}>
<button onClick={e => startEditTitle(day, e)} aria-label={t('common.edit')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
<Pencil size={14} strokeWidth={1.8} />
</button>
{/* Public transit search (#1065) — replaced the rename pencil,
which moved next to the day name in the day detail view. */}
{onPlanTransit ? (
<button onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} title={t('transit.title')} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
<TramFront size={14} strokeWidth={1.8} />
</button>
) : <div style={{ borderRight: div, borderBottom: div }} />}
{onAddTransport ? (
<button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
<Plus size={14} strokeWidth={1.8} />
@@ -1,5 +1,5 @@
import ReactDOM from 'react-dom'
import { Ticket, FileText, ExternalLink } from 'lucide-react'
import { Ticket, FileText, ExternalLink, Footprints, ArrowRight } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
@@ -117,6 +117,54 @@ export function DayPlanSidebarTransportDetailModal({
</div>
)}
{/* Public-transit itinerary (#1065) — legs from the transit search */}
{meta.transit?.legs && Array.isArray(meta.transit.legs) && meta.transit.legs.length > 0 && (
<div className="bg-surface-tertiary" style={{ padding: '10px 12px', borderRadius: 8 }}>
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 8 }}>
{t('transit.itinerary')}
{typeof meta.transit.transfers === 'number' && meta.transit.transfers > 0 && ` · ${t('transit.transfers', { count: meta.transit.transfers })}`}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{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 (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
{isWalk ? (
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0, paddingTop: 1 }}>
<Footprints size={12} />
</span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', background: leg.line_color || 'var(--bg-hover)', color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', borderRadius: 5, padding: '1px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
{leg.line || leg.mode}
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{isWalk
? <span className="text-content-muted">{t('transit.walkTo', { name: leg.to?.name || '' })}</span>
: <>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.from?.name}</span>
<ArrowRight size={10} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.to?.name}</span>
</>}
</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', marginTop: 1 }}>
{[
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(' · ')}
</div>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Notizen */}
{res.notes && (
<div className="bg-surface-tertiary" style={{ padding: '8px 10px', borderRadius: 8 }}>
@@ -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<typeof userEvent.setup>) {
// 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(<TransitSearchModal {...makeProps()} />)
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(<TransitSearchModal {...makeProps()} />)
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(<TransitSearchModal {...makeProps({ onAdd })} />)
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(<TransitSearchModal {...makeProps()} />)
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(<TransitSearchModal {...makeProps()} />)
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(<TransitSearchModal {...makeProps()} />)
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')
})
})
@@ -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 | number>) => 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<TransitPlaceResult[]>([])
const [open, setOpen] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rootRef = useRef<HTMLDivElement>(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 (
<div ref={rootRef} style={{ position: 'relative', flex: 1, minWidth: 0 }}>
<label className="block text-[11px] font-semibold text-content-faint mb-[5px] uppercase tracking-[0.03em]">{label}</label>
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 7, borderRadius: 10, padding: '0 10px', height: 38 }}>
<MapPin size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
<input
value={display}
onChange={e => 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' }}
/>
</div>
{open && (results.length > 0 || (!value && text.trim().length < 2 && quickPicks.length > 0)) && (
<div className="bg-surface-card border border-edge" style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 4, borderRadius: 10, boxShadow: '0 8px 32px rgba(0,0,0,0.14)', zIndex: 30, overflow: 'hidden', maxHeight: 240, overflowY: 'auto' }}>
{results.length > 0
? results.map((r, i) => (
<button key={i} onClick={() => { onPick({ name: r.name, lat: r.lat, lng: r.lng }); setText(''); setResults([]); setOpen(false) }}
className="text-content"
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left', padding: '8px 10px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<MapPin size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.name}
{r.area && <span className="text-content-faint"> · {r.area}</span>}
</span>
</button>
))
: quickPicks.map((p, i) => (
<button key={i} onClick={() => { onPick(p); setText(''); setOpen(false) }}
className="text-content"
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left', padding: '8px 10px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<MapPin size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
</button>
))}
</div>
)}
</div>
)
}
// ── 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 (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: bg, color: fg, borderRadius: 6, padding: '2px 7px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, whiteSpace: 'nowrap' }}>
<Icon size={11} strokeWidth={2.2} />
{leg.line || leg.mode}
</span>
)
}
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, string | number>) => string
}) {
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
const walkMins = Math.round(it.walkSeconds / 60)
return (
<div className="bg-surface-card border border-edge" style={{ borderRadius: 14, overflow: 'hidden' }}>
<button onClick={onToggle} style={{ display: 'block', width: '100%', textAlign: 'left', padding: '12px 14px', border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.01em' }}>
{fmtTimeInTz(it.startTime, tzFrom, is12h)} – {fmtTimeInTz(it.endTime, tzTo, is12h)}
</span>
<span className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{fmtDuration(it.duration, t)}</span>
<span className="text-content-faint" style={{ marginLeft: 'auto', fontSize: 'calc(12px * var(--fs-scale-body, 1))', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<span>{it.transfers === 0 ? t('transit.direct') : t('transit.transfers', { count: it.transfers })}</span>
{walkMins > 0 && <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><Footprints size={12} />{t('transit.min', { count: walkMins })}</span>}
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
</div>
{/* signature: Walk › U2 › Bus 100 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginTop: 8, flexWrap: 'wrap' }}>
{it.legs.map((leg, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="text-content-faint" style={{ fontSize: 10 }}>›</span>}
{leg.mode === 'WALK'
? <Footprints size={13} className="text-content-faint" />
: <LineBadge leg={leg} />}
</React.Fragment>
))}
</div>
</button>
{expanded && (
<div style={{ borderTop: '1px solid var(--border-faint)', padding: '10px 14px 12px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{it.legs.map((leg, i) => {
const color = leg.mode === 'WALK' ? 'var(--border-primary)' : (leg.lineColor || 'var(--text-muted)')
return (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '44px 18px 1fr', gap: 8, alignItems: 'stretch' }}>
<div className="text-content-muted" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, paddingTop: 2, textAlign: 'right' }}>
{leg.mode === 'WALK' ? '' : fmtTimeInTz(leg.from.time, tzAt(leg.from.lat, leg.from.lng), is12h)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: `2.5px solid ${color}`, background: 'var(--bg-card)', flexShrink: 0, marginTop: 4 }} />
<span style={{ flex: 1, width: 3, borderRadius: 2, background: color, opacity: leg.mode === 'WALK' ? 0.45 : 1, margin: '2px 0', ...(leg.mode === 'WALK' ? { backgroundImage: 'repeating-linear-gradient(to bottom, var(--border-primary) 0 4px, transparent 4px 8px)', background: 'none' } : {}) }} />
</div>
<div style={{ paddingBottom: 14, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{leg.mode === 'WALK' ? t('transit.walkTo', { name: leg.to.name }) : leg.from.name}
{leg.from.track && leg.mode !== 'WALK' && <span className="text-content-faint" style={{ fontWeight: 500 }}> · {t('transit.platform', { track: leg.from.track })}</span>}
</div>
<div className="text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, fontSize: 'calc(12px * var(--fs-scale-body, 1))', flexWrap: 'wrap' }}>
{leg.mode === 'WALK' ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Footprints size={12} />{fmtDuration(leg.duration, t)}{leg.distance ? ` · ${leg.distance >= 1000 ? `${(leg.distance / 1000).toFixed(1)} km` : `${leg.distance} m`}` : ''}</span>
) : (
<>
<LineBadge leg={leg} />
{leg.headsign && <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><ArrowRight size={11} />{leg.headsign}</span>}
<span className="text-content-faint">{fmtDuration(leg.duration, t)}{leg.intermediateStops > 0 ? ` · ${t('transit.stops', { count: leg.intermediateStops })}` : ''}</span>
</>
)}
</div>
</div>
</div>
)
})}
{/* arrival row */}
<div style={{ display: 'grid', gridTemplateColumns: '44px 18px 1fr', gap: 8, alignItems: 'center' }}>
<div className="text-content-muted" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textAlign: 'right' }}>
{fmtTimeInTz(it.endTime, tzTo, is12h)}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--text-primary)', flexShrink: 0 }} />
</div>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{it.legs[it.legs.length - 1]?.to.name}
</div>
</div>
</div>
{transitLegs[0]?.agency && (
<div className="text-content-faint" style={{ marginTop: 8, fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
{[...new Set(transitLegs.map(l => l.agency).filter(Boolean))].join(' · ')}
</div>
)}
<button
onClick={onAdd}
disabled={adding}
className="bg-accent text-accent-text"
style={{ marginTop: 12, width: '100%', border: 'none', borderRadius: 10, padding: '9px 0', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: adding ? 'default' : 'pointer', fontFamily: 'inherit', opacity: adding ? 0.6 : 1 }}
>
{adding ? t('transit.adding') : t('transit.addToDay')}
</button>
</div>
)}
</div>
)
}
// ── 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<string, unknown>) => Promise<unknown>
}
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<PickedPlace | null>(null)
const [to, setTo] = useState<PickedPlace | null>(null)
const [time, setTime] = useState('09:00')
const [arriveBy, setArriveBy] = useState(false)
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
const [itineraries, setItineraries] = useState<TransitItinerary[] | null>(null)
const [loading, setLoading] = useState(false)
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
const [addingIdx, setAddingIdx] = useState<number | null>(null)
// Quick picks: the day's located places, plus the trip's located accommodations.
const quickPicks = useMemo<PickedPlace[]>(() => {
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<string>()
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<string, unknown>[] = []
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 (
<Modal isOpen={isOpen} onClose={onClose} title={t('transit.title')} size="lg">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, fontFamily: 'var(--font-system)' }}>
{/* from / to */}
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
<button
onClick={() => { const f = from; setFrom(to); setTo(f) }}
aria-label={t('transit.swap')}
title={t('transit.swap')}
className="bg-surface-secondary text-content-muted"
style={{ border: 'none', borderRadius: 10, width: 38, height: 38, display: 'grid', placeItems: 'center', cursor: 'pointer', flexShrink: 0 }}
>
<ArrowLeftRight size={15} />
</button>
<StopPicker label={t('transit.to')} value={to} onPick={setTo} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
</div>
{/* time + depart/arrive */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
<button onClick={() => setArriveBy(false)} style={segBtn(!arriveBy)}>{t('transit.depart')}</button>
<button onClick={() => setArriveBy(true)} style={segBtn(arriveBy)}>{t('transit.arrive')}</button>
</div>
<div style={{ width: 110 }}>
<CustomTimePicker value={time} onChange={setTime} />
</div>
{day.date && (
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<Clock size={12} />
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
</span>
)}
</div>
{/* mode chips */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{MODE_GROUPS.map(m => {
const active = activeModes.has(m.key)
return (
<button key={m.key} onClick={() => toggleMode(m.key)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1.5px solid', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: active ? 'var(--text-primary)' : 'transparent',
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<m.Icon size={12} strokeWidth={2} />
{t(m.labelKey)}
</button>
)
})}
</div>
{/* preference + search */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
<button onClick={() => setPref('best')} style={segBtn(pref === 'best')}>{t('transit.pref.best')}</button>
<button onClick={() => setPref('transfers')} style={segBtn(pref === 'transfers')}>{t('transit.pref.transfers')}</button>
<button onClick={() => setPref('walking')} style={segBtn(pref === 'walking')}>{t('transit.pref.walking')}</button>
</div>
<button
onClick={search}
disabled={!from || !to || loading}
className="bg-accent text-accent-text"
style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1 }}
>
<Search size={14} strokeWidth={2.2} />
{loading ? t('transit.searching') : t('transit.search')}
</button>
</div>
{/* results */}
{loading && (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '28px 0' }}>
<div style={{ width: 20, height: 20, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
</div>
)}
{!loading && ranked && ranked.length === 0 && (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '24px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{t('transit.noResults')}
</div>
)}
{!loading && ranked && ranked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ranked.map((it, idx) => (
<ItineraryCard
key={`${it.startTime}-${it.endTime}-${idx}`}
it={it}
tzFrom={tzFrom}
tzTo={tzTo}
is12h={is12h}
expanded={expandedIdx === idx}
onToggle={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
onAdd={() => addItinerary(it, idx)}
adding={addingIdx === idx}
t={t}
/>
))}
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', textAlign: 'center', marginTop: 2 }}>
{t('transit.attribution')}{' '}
<a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>
</div>
</div>
)}
</div>
</Modal>
)
}
@@ -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(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
// 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(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
// 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']);
});
});
@@ -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<typeof endpointFromAirport>[] = []
@@ -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
+21 -1
View File
@@ -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 {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { 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 />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { 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 />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { 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 }} />
}
</div>
@@ -716,6 +720,22 @@ export default function TripPlannerPage(): React.ReactElement | null {
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { 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 && <TransportModal isOpen={showTransportModal} onClose={() => { 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 (
<TransitSearchModal
isOpen
onClose={() => setTransitModalDayId(null)}
day={transitDay}
days={days}
places={places}
accommodations={tripAccommodations}
onAdd={handleAddTransitRoute}
/>
)
})()}
{bookingExpense && (
<ExpenseModal
tripId={tripId}
@@ -162,6 +162,8 @@ export function useTripPlanner() {
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
// Public transit route search (#1065) — non-null while the modal is open for that day.
const [transitModalDayId, setTransitModalDayId] = useState<number | null>(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<string, unknown> & { 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,
+4
View File
@@ -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;
}
+2 -1
View File
@@ -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,
@@ -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); }
}
}
@@ -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 {}
+254
View File
@@ -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<string, { at: number; data: unknown }>();
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<unknown> {
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;
}
+96
View File
@@ -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<ReturnType<typeof build>>;
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);
});
});
@@ -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 });
});
});
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+11
View File
@@ -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**.