diff --git a/client/src/components/Planner/TransitSearchModal.test.tsx b/client/src/components/Planner/TransitSearchModal.test.tsx
new file mode 100644
index 000000000..b2bbd9b78
--- /dev/null
+++ b/client/src/components/Planner/TransitSearchModal.test.tsx
@@ -0,0 +1,151 @@
+// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-006
+import { render, screen, waitFor } from '../../../tests/helpers/render'
+import userEvent from '@testing-library/user-event'
+import { resetAllStores, seedStore } from '../../../tests/helpers/store'
+import { useAuthStore } from '../../store/authStore'
+import { useSettingsStore } from '../../store/settingsStore'
+import { buildUser, buildDay, buildPlace } from '../../../tests/helpers/factories'
+import TransitSearchModal from './TransitSearchModal'
+
+const { transitApiMock } = vi.hoisted(() => ({
+ transitApiMock: { geocode: vi.fn(), plan: vi.fn() },
+}))
+
+vi.mock('../../api/client', async (importOriginal) => {
+ const actual = await importOriginal() as object
+ return { ...actual, transitApi: transitApiMock }
+})
+
+vi.mock('../shared/Toast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn() }) }))
+
+// Berlin, summer time (UTC+2): 06:30Z departs 08:30 local, 07:00Z arrives 09:00.
+const ITINERARY = {
+ startTime: '2025-06-01T06:30:00Z',
+ endTime: '2025-06-01T07:00:00Z',
+ duration: 1800,
+ transfers: 1,
+ walkSeconds: 240,
+ legs: [
+ { mode: 'WALK', from: { name: 'Start', lat: 52.52, lng: 13.4, time: '2025-06-01T06:30:00Z', scheduledTime: null, track: null }, to: { name: 'Alexanderplatz', lat: 52.521, lng: 13.41, time: '2025-06-01T06:34:00Z', scheduledTime: null, track: null }, duration: 240, distance: 300, headsign: null, line: null, lineColor: null, lineTextColor: null, agency: null, intermediateStops: 0 },
+ { mode: 'SUBWAY', from: { name: 'Alexanderplatz', lat: 52.521, lng: 13.41, time: '2025-06-01T06:36:00Z', scheduledTime: null, track: '2' }, to: { name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, time: '2025-06-01T07:00:00Z', scheduledTime: null, track: null }, duration: 1440, distance: null, headsign: 'Ruhleben', line: 'U2', lineColor: '#FF3300', lineTextColor: '#FFFFFF', agency: 'BVG', intermediateStops: 6 },
+ ],
+}
+
+const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' })
+
+function makeProps(overrides = {}) {
+ return {
+ isOpen: true,
+ onClose: vi.fn(),
+ day,
+ days: [day],
+ places: [buildPlace({ id: 1, name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 })],
+ accommodations: [],
+ onAdd: vi.fn().mockResolvedValue({}),
+ ...overrides,
+ }
+}
+
+async function pickFromAndTo(user: ReturnType
) {
+ // Quick picks (the day's places) appear on focus with an empty query.
+ const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…')
+ await user.click(fromInput)
+ await user.click(await screen.findByText('Fernsehturm'))
+
+ transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, type: 'STOP', area: 'Berlin' }] })
+ await user.click(toInput)
+ await user.type(toInput, 'Zoo')
+ await user.click(await screen.findByText(/Zoologischer Garten/))
+}
+
+beforeEach(() => {
+ resetAllStores()
+ vi.clearAllMocks()
+ seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
+ seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
+})
+
+describe('TransitSearchModal', () => {
+ it('FE-PLANNER-TRANSIT-001: renders from/to pickers, modes and preferences', () => {
+ render()
+ expect(screen.getByText('Public transit')).toBeInTheDocument()
+ expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2)
+ expect(screen.getByText('Subway')).toBeInTheDocument()
+ expect(screen.getByText('Fewer transfers')).toBeInTheDocument()
+ })
+
+ it('FE-PLANNER-TRANSIT-002: searching lists itineraries with times, transfers and line badges', async () => {
+ const user = userEvent.setup()
+ transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
+ render()
+ await pickFromAndTo(user)
+ await user.click(screen.getByRole('button', { name: /^Search$/ }))
+ // Local Berlin times, U2 badge, 1 transfer.
+ expect(await screen.findByText(/08:30 – 09:00/)).toBeInTheDocument()
+ expect(screen.getByText('U2')).toBeInTheDocument()
+ expect(screen.getByText('1 transfers')).toBeInTheDocument()
+ })
+
+ it('FE-PLANNER-TRANSIT-003: adding a route builds a transport payload with local times + endpoints', async () => {
+ const user = userEvent.setup()
+ const onAdd = vi.fn().mockResolvedValue({})
+ transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
+ render()
+ await pickFromAndTo(user)
+ await user.click(screen.getByRole('button', { name: /^Search$/ }))
+ await user.click(await screen.findByText(/08:30 – 09:00/))
+ await user.click(await screen.findByRole('button', { name: 'Add to day' }))
+
+ await waitFor(() => expect(onAdd).toHaveBeenCalled())
+ const payload = onAdd.mock.calls[0][0]
+ expect(payload.type).toBe('train') // dominant leg = SUBWAY → train
+ expect(payload.title).toBe('Fernsehturm → Zoologischer Garten')
+ expect(payload.day_id).toBe(10)
+ expect(payload.reservation_time).toBe('2025-06-01T08:30')
+ expect(payload.reservation_end_time).toBe('2025-06-01T09:00')
+ expect(payload.status).toBe('confirmed')
+ // from + to endpoints (single transit leg → no transfer stops)
+ expect(payload.endpoints).toHaveLength(2)
+ expect(payload.endpoints[0]).toMatchObject({ role: 'from', name: 'Fernsehturm', timezone: 'Europe/Berlin' })
+ expect(payload.endpoints[1]).toMatchObject({ role: 'to', name: 'Zoologischer Garten' })
+ // compact itinerary stored for the detail modal
+ expect(payload.metadata.transit.provider).toBe('transitous')
+ expect(payload.metadata.transit.legs).toHaveLength(2)
+ expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
+ })
+
+ it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
+ const user = userEvent.setup()
+ transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
+ render()
+ await pickFromAndTo(user)
+ await user.click(screen.getByRole('button', { name: /^Search$/ }))
+ expect(await screen.findByText(/No connections found/)).toBeInTheDocument()
+ })
+
+ it('FE-PLANNER-TRANSIT-005: preference "fewer transfers" re-ranks the list', async () => {
+ const user = userEvent.setup()
+ const direct = { ...ITINERARY, startTime: '2025-06-01T06:40:00Z', endTime: '2025-06-01T07:20:00Z', duration: 2400, transfers: 0 }
+ transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, direct] })
+ render()
+ await pickFromAndTo(user)
+ await user.click(screen.getByRole('button', { name: /^Search$/ }))
+ await screen.findByText(/08:30 – 09:00/)
+ await user.click(screen.getByText('Fewer transfers'))
+ const cards = screen.getAllByText(/–/).filter(el => el.textContent?.match(/\d{2}:\d{2} – \d{2}:\d{2}/))
+ // The direct (0-transfer) itinerary now ranks first.
+ expect(cards[0].textContent).toContain('08:40')
+ })
+
+ it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => {
+ const user = userEvent.setup()
+ render()
+ const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…')
+ await user.click(fromInput)
+ await user.click(await screen.findByText('Fernsehturm'))
+ await user.click(screen.getByLabelText('Swap'))
+ const inputs = screen.getAllByPlaceholderText('Search stop or station…')
+ expect((inputs[0] as HTMLInputElement).value).toBe('')
+ expect((inputs[1] as HTMLInputElement).value).toBe('Fernsehturm')
+ })
+})
diff --git a/client/src/components/Planner/TransitSearchModal.tsx b/client/src/components/Planner/TransitSearchModal.tsx
new file mode 100644
index 000000000..163933f94
--- /dev/null
+++ b/client/src/components/Planner/TransitSearchModal.tsx
@@ -0,0 +1,584 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react'
+import tzlookup from 'tz-lookup'
+import { ArrowLeftRight, ArrowRight, Bus, CableCar, ChevronDown, ChevronUp, Clock, Footprints, MapPin, Sailboat, Search, Train, TramFront, TrainFront } from 'lucide-react'
+import Modal from '../shared/Modal'
+import CustomTimePicker from '../shared/CustomTimePicker'
+import { transitApi } from '../../api/client'
+import { useSettingsStore } from '../../store/settingsStore'
+import { useToast } from '../shared/Toast'
+import { useTranslation } from '../../i18n'
+import type { Day, Place, Accommodation } from '../../types'
+
+/**
+ * Public transit route search (#1065), backed by Transitous (MOTIS) through the
+ * server proxy — no paid providers. Google-Maps-like flow in TREK's clean style:
+ * pick from/to (stop search + the day's own places as quick picks), filter by
+ * mode and preference, compare the returned itineraries, then add the chosen
+ * one to the day. The result is saved as a regular transport reservation
+ * (endpoints + metadata.transit), so timeline slotting, editing, deleting and
+ * drag/drop all reuse the existing machinery.
+ */
+
+// ── transit data shapes (mirrors the server's compact mapping) ──────────────
+
+interface TransitLegStop { name: string; lat: number; lng: number; time: string | null; scheduledTime: string | null; track: string | null }
+interface TransitLeg {
+ mode: string; from: TransitLegStop; to: TransitLegStop; duration: number; distance: number | null
+ headsign: string | null; line: string | null; lineColor: string | null; lineTextColor: string | null
+ agency: string | null; intermediateStops: number
+}
+export interface TransitItinerary {
+ startTime: string; endTime: string; duration: number; transfers: number; walkSeconds: number; legs: TransitLeg[]
+}
+
+interface TransitPlaceResult { name: string; lat: number; lng: number; type: string; area: string | null }
+
+interface PickedPlace { name: string; lat: number; lng: number }
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [
+ { key: 'rail', labelKey: 'transit.mode.rail', Icon: Train, modes: 'HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,SUBURBAN' },
+ { key: 'subway', labelKey: 'transit.mode.subway', Icon: TrainFront, modes: 'SUBWAY' },
+ { key: 'tram', labelKey: 'transit.mode.tram', Icon: TramFront, modes: 'TRAM' },
+ { key: 'bus', labelKey: 'transit.mode.bus', Icon: Bus, modes: 'BUS,COACH' },
+ { key: 'ferry', labelKey: 'transit.mode.ferry', Icon: Sailboat, modes: 'FERRY' },
+ { key: 'cable', labelKey: 'transit.mode.cable', Icon: CableCar, modes: 'FUNICULAR,AERIAL_LIFT' },
+]
+
+function legIcon(mode: string) {
+ if (mode === 'WALK') return Footprints
+ if (mode === 'BUS' || mode === 'COACH') return Bus
+ if (mode === 'TRAM') return TramFront
+ if (mode === 'SUBWAY') return TrainFront
+ if (mode === 'FERRY') return Sailboat
+ if (mode === 'FUNICULAR' || mode === 'AERIAL_LIFT') return CableCar
+ return Train
+}
+
+/** TREK reservation type for the itinerary's dominant (longest) transit leg. */
+function dominantType(legs: TransitLeg[]): 'train' | 'bus' | 'ferry' | 'transport_other' {
+ let best: TransitLeg | null = null
+ for (const l of legs) {
+ if (l.mode === 'WALK') continue
+ if (!best || l.duration > best.duration) best = l
+ }
+ if (!best) return 'transport_other'
+ if (best.mode === 'BUS' || best.mode === 'COACH') return 'bus'
+ if (best.mode === 'FERRY') return 'ferry'
+ if (best.mode === 'FUNICULAR' || best.mode === 'AERIAL_LIFT') return 'transport_other'
+ return 'train'
+}
+
+function tzAt(lat: number, lng: number): string {
+ try { return tzlookup(lat, lng) } catch { return 'UTC' }
+}
+
+/** 'YYYY-MM-DD' + 'HH:mm' in an IANA zone → UTC ISO string. */
+function localToUtcIso(dateStr: string, timeStr: string, tz: string): string {
+ const naive = Date.parse(`${dateStr}T${timeStr}:00Z`)
+ const inTz = new Date(new Date(naive).toLocaleString('en-US', { timeZone: tz })).getTime()
+ const inUtc = new Date(new Date(naive).toLocaleString('en-US', { timeZone: 'UTC' })).getTime()
+ return new Date(naive - (inTz - inUtc)).toISOString()
+}
+
+function fmtTimeInTz(iso: string | null, tz: string, is12h: boolean): string {
+ if (!iso) return ''
+ try { return new Intl.DateTimeFormat(is12h ? 'en-US' : 'en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h }).format(new Date(iso)) } catch { return '' }
+}
+
+function timeHHmmInTz(iso: string, tz: string): string {
+ return new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(iso))
+}
+
+function dateYMDInTz(iso: string, tz: string): string {
+ return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(iso))
+}
+
+function fmtDuration(seconds: number, t: (k: string, p?: Record) => string): string {
+ const mins = Math.round(seconds / 60)
+ if (mins < 60) return t('transit.min', { count: mins })
+ const h = Math.floor(mins / 60)
+ const m = mins % 60
+ return m > 0 ? `${h} h ${m} min` : `${h} h`
+}
+
+// ── from/to stop picker ──────────────────────────────────────────────────────
+
+function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
+ label: string
+ value: PickedPlace | null
+ onPick: (p: PickedPlace | null) => void
+ quickPicks: PickedPlace[]
+ near: string | null
+ placeholder: string
+}) {
+ const { language } = useTranslation()
+ const [text, setText] = useState('')
+ const [results, setResults] = useState([])
+ const [open, setOpen] = useState(false)
+ const debounceRef = useRef | null>(null)
+ const rootRef = useRef(null)
+
+ useEffect(() => {
+ const close = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false) }
+ document.addEventListener('mousedown', close)
+ return () => document.removeEventListener('mousedown', close)
+ }, [])
+
+ useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, [])
+
+ const search = (q: string) => {
+ setText(q)
+ onPick(null)
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ if (q.trim().length < 2) { setResults([]); return }
+ debounceRef.current = setTimeout(() => {
+ transitApi.geocode(q, { lang: language, near: near || undefined })
+ .then((d: { results: TransitPlaceResult[] }) => setResults(d.results || []))
+ .catch(() => setResults([]))
+ }, 300)
+ }
+
+ const display = value ? value.name : text
+
+ return (
+
+
+
+
+ search(e.target.value)}
+ onFocus={() => setOpen(true)}
+ placeholder={placeholder}
+ className="text-content"
+ style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', width: '100%', fontFamily: 'inherit' }}
+ />
+
+ {open && (results.length > 0 || (!value && text.trim().length < 2 && quickPicks.length > 0)) && (
+
+ {results.length > 0
+ ? results.map((r, i) => (
+
+ ))
+ : quickPicks.map((p, i) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ── itinerary card ───────────────────────────────────────────────────────────
+
+function LineBadge({ leg }: { leg: TransitLeg }) {
+ const bg = leg.lineColor || 'var(--bg-tertiary)'
+ const fg = leg.lineColor ? (leg.lineTextColor || '#fff') : 'var(--text-primary)'
+ const Icon = legIcon(leg.mode)
+ return (
+
+
+ {leg.line || leg.mode}
+
+ )
+}
+
+function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, t }: {
+ it: TransitItinerary
+ tzFrom: string
+ tzTo: string
+ is12h: boolean
+ expanded: boolean
+ onToggle: () => void
+ onAdd: () => void
+ adding: boolean
+ t: (k: string, p?: Record) => string
+}) {
+ const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
+ const walkMins = Math.round(it.walkSeconds / 60)
+ return (
+
+
+
+ {expanded && (
+
+
+ {it.legs.map((leg, i) => {
+ const color = leg.mode === 'WALK' ? 'var(--border-primary)' : (leg.lineColor || 'var(--text-muted)')
+ return (
+
+
+ {leg.mode === 'WALK' ? '' : fmtTimeInTz(leg.from.time, tzAt(leg.from.lat, leg.from.lng), is12h)}
+
+
+
+
+
+
+
+ {leg.mode === 'WALK' ? t('transit.walkTo', { name: leg.to.name }) : leg.from.name}
+ {leg.from.track && leg.mode !== 'WALK' && · {t('transit.platform', { track: leg.from.track })}}
+
+
+ {leg.mode === 'WALK' ? (
+
{fmtDuration(leg.duration, t)}{leg.distance ? ` · ${leg.distance >= 1000 ? `${(leg.distance / 1000).toFixed(1)} km` : `${leg.distance} m`}` : ''}
+ ) : (
+ <>
+
+ {leg.headsign &&
{leg.headsign}}
+
{fmtDuration(leg.duration, t)}{leg.intermediateStops > 0 ? ` · ${t('transit.stops', { count: leg.intermediateStops })}` : ''}
+ >
+ )}
+
+
+
+ )
+ })}
+ {/* arrival row */}
+
+
+ {fmtTimeInTz(it.endTime, tzTo, is12h)}
+
+
+
+
+
+ {it.legs[it.legs.length - 1]?.to.name}
+
+
+
+ {transitLegs[0]?.agency && (
+
+ {[...new Set(transitLegs.map(l => l.agency).filter(Boolean))].join(' · ')}
+
+ )}
+
+
+ )}
+
+ )
+}
+
+// ── the modal ────────────────────────────────────────────────────────────────
+
+interface TransitSearchModalProps {
+ isOpen: boolean
+ onClose: () => void
+ day: Day
+ days: Day[]
+ places: Place[]
+ accommodations?: Accommodation[]
+ /** Persist the built reservation payload; resolves when saved. */
+ onAdd: (payload: Record) => Promise
+}
+
+export default function TransitSearchModal({ isOpen, onClose, day, days, places, accommodations = [], onAdd }: TransitSearchModalProps) {
+ const { t } = useTranslation()
+ const toast = useToast()
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+
+ const [from, setFrom] = useState(null)
+ const [to, setTo] = useState(null)
+ const [time, setTime] = useState('09:00')
+ const [arriveBy, setArriveBy] = useState(false)
+ const [activeModes, setActiveModes] = useState>(() => new Set(MODE_GROUPS.map(m => m.key)))
+ const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
+ const [itineraries, setItineraries] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [expandedIdx, setExpandedIdx] = useState(null)
+ const [addingIdx, setAddingIdx] = useState(null)
+
+ // Quick picks: the day's located places, plus the trip's located accommodations.
+ const quickPicks = useMemo(() => {
+ const picks: PickedPlace[] = []
+ for (const p of places) {
+ if (p.lat != null && p.lng != null) picks.push({ name: p.name, lat: p.lat, lng: p.lng })
+ }
+ for (const a of accommodations) {
+ const lat = (a as { place_lat?: number | null }).place_lat
+ const lng = (a as { place_lng?: number | null }).place_lng
+ const name = (a as { place_name?: string | null }).place_name
+ if (lat != null && lng != null && name) picks.push({ name, lat, lng })
+ }
+ const seen = new Set()
+ return picks.filter(p => { const k = `${p.name}:${p.lat}`; if (seen.has(k)) return false; seen.add(k); return true }).slice(0, 8)
+ }, [places, accommodations])
+
+ const near = quickPicks.length > 0 ? `${quickPicks[0].lat},${quickPicks[0].lng}` : null
+
+ const toggleMode = (key: string) => {
+ setActiveModes(prev => {
+ const next = new Set(prev)
+ if (next.has(key)) { if (next.size > 1) next.delete(key) } else { next.add(key) }
+ return next
+ })
+ }
+
+ const search = async () => {
+ if (!from || !to || !day.date) return
+ setLoading(true)
+ setItineraries(null)
+ setExpandedIdx(null)
+ try {
+ const tzFrom = tzAt(from.lat, from.lng)
+ const timeIso = localToUtcIso(day.date, time, tzFrom)
+ const allModes = activeModes.size === MODE_GROUPS.length
+ const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',')
+ const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes })
+ setItineraries(d.itineraries || [])
+ } catch {
+ toast.error(t('transit.searchError'))
+ setItineraries([])
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Preference is a client-side ranking over one result set — no extra API calls.
+ const ranked = useMemo(() => {
+ if (!itineraries) return null
+ const list = itineraries.slice()
+ if (pref === 'transfers') list.sort((a, b) => a.transfers - b.transfers || a.duration - b.duration)
+ if (pref === 'walking') list.sort((a, b) => a.walkSeconds - b.walkSeconds || a.duration - b.duration)
+ return list
+ }, [itineraries, pref])
+
+ const addItinerary = async (it: TransitItinerary, idx: number) => {
+ if (!from || !to || !day.date) return
+ setAddingIdx(idx)
+ try {
+ const tzFrom = tzAt(from.lat, from.lng)
+ const tzTo = tzAt(to.lat, to.lng)
+ const depDate = dateYMDInTz(it.startTime, tzFrom)
+ const depTime = timeHHmmInTz(it.startTime, tzFrom)
+ const arrDate = dateYMDInTz(it.endTime, tzTo)
+ const arrTime = timeHHmmInTz(it.endTime, tzTo)
+
+ // An after-midnight arrival lands on the next trip day when it exists.
+ const endDay = arrDate !== depDate ? days.find(d2 => d2.date === arrDate) : null
+
+ // Endpoints: origin, each transfer stop, destination — the same shape
+ // flights persist, so the map + connectors work unchanged.
+ const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
+ const endpoints: Record[] = []
+ endpoints.push({ role: 'from', sequence: 0, name: from.name, code: null, lat: from.lat, lng: from.lng, timezone: tzFrom, local_date: depDate, local_time: depTime })
+ transitLegs.slice(0, -1).forEach((leg, i) => {
+ const s = leg.to
+ endpoints.push({ role: 'stop', sequence: i + 1, name: s.name, code: null, lat: s.lat, lng: s.lng, timezone: tzAt(s.lat, s.lng), local_date: s.time ? dateYMDInTz(s.time, tzAt(s.lat, s.lng)) : null, local_time: s.time ? timeHHmmInTz(s.time, tzAt(s.lat, s.lng)) : null })
+ })
+ endpoints.push({ role: 'to', sequence: endpoints.length, name: to.name, code: null, lat: to.lat, lng: to.lng, timezone: tzTo, local_date: arrDate, local_time: arrTime })
+
+ const payload = {
+ title: `${from.name} → ${to.name}`,
+ type: dominantType(it.legs),
+ status: 'confirmed',
+ day_id: day.id,
+ end_day_id: endDay ? endDay.id : day.id,
+ reservation_time: `${depDate}T${depTime}`,
+ reservation_end_time: `${arrDate}T${arrTime}`,
+ location: null,
+ confirmation_number: null,
+ notes: null,
+ metadata: {
+ transit: {
+ provider: 'transitous',
+ duration: it.duration,
+ transfers: it.transfers,
+ walk_seconds: it.walkSeconds,
+ legs: it.legs.map(l => ({
+ mode: l.mode,
+ line: l.line,
+ line_color: l.lineColor,
+ line_text_color: l.lineTextColor,
+ headsign: l.headsign,
+ agency: l.agency,
+ duration: l.duration,
+ stops: l.intermediateStops,
+ from: { name: l.from.name, time: l.from.time ? timeHHmmInTz(l.from.time, tzAt(l.from.lat, l.from.lng)) : null, track: l.from.track },
+ to: { name: l.to.name, time: l.to.time ? timeHHmmInTz(l.to.time, tzAt(l.to.lat, l.to.lng)) : null, track: l.to.track },
+ })),
+ },
+ },
+ endpoints,
+ needs_review: false,
+ }
+
+ await onAdd(payload)
+ onClose()
+ } catch {
+ toast.error(t('common.unknownError'))
+ } finally {
+ setAddingIdx(null)
+ }
+ }
+
+ const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC'
+ const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom
+
+ const segBtn = (active: boolean): React.CSSProperties => ({
+ padding: '6px 11px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500,
+ border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
+ background: active ? 'var(--bg-card)' : 'transparent', color: active ? 'var(--text-primary)' : 'var(--text-muted)',
+ boxShadow: active ? '0 1px 4px rgba(0,0,0,0.08)' : 'none',
+ })
+
+ return (
+
+
+ {/* from / to */}
+
+
+
+
+
+
+ {/* time + depart/arrive */}
+
+
+
+
+
+
+
+
+ {day.date && (
+
+
+ {new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
+
+ )}
+
+
+ {/* mode chips */}
+
+ {MODE_GROUPS.map(m => {
+ const active = activeModes.has(m.key)
+ return (
+
+ )
+ })}
+
+
+ {/* preference + search */}
+
+
+
+
+
+
+
+
+
+ {/* results */}
+ {loading && (
+
+ )}
+ {!loading && ranked && ranked.length === 0 && (
+
+ {t('transit.noResults')}
+
+ )}
+ {!loading && ranked && ranked.length > 0 && (
+
+ {ranked.map((it, idx) => (
+
setExpandedIdx(expandedIdx === idx ? null : idx)}
+ onAdd={() => addItinerary(it, idx)}
+ adding={addingIdx === idx}
+ t={t}
+ />
+ ))}
+
+
+ )}
+
+
+ )
+}
diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx
index bdeb0c09f..6abe36301 100644
--- a/client/src/components/Planner/TransportModal.test.tsx
+++ b/client/src/components/Planner/TransportModal.test.tsx
@@ -324,4 +324,46 @@ describe('TransportModal', () => {
expect(fd.get('reservation_id')).toBe('99');
expect(fd.get('file')).toBeTruthy();
});
+
+ // ── Transit itinerary preservation (#1065) ─────────────────────────────────
+
+ it('FE-PLANNER-TRANSMODAL-020: re-saving a transit reservation keeps metadata.transit + stop endpoints', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ const res = buildReservation({ title: 'Fernsehturm → Zoo', type: 'bus' }) as any;
+ res.metadata = { transit: { provider: 'transitous', transfers: 1, legs: [{ mode: 'BUS', line: '100' }] } };
+ res.endpoints = [
+ { role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.5208, lng: 13.4094, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '08:30' },
+ { role: 'stop', sequence: 1, name: 'Alexanderplatz', code: null, lat: 52.521, lng: 13.41, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '08:40' },
+ { role: 'to', sequence: 2, name: 'Zoologischer Garten', code: null, lat: 52.507, lng: 13.332, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '09:00' },
+ ];
+ render();
+ // Save without touching the route — the itinerary must survive.
+ await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
+ await waitFor(() => expect(onSave).toHaveBeenCalled());
+ const payload = onSave.mock.calls[0][0];
+ expect(payload.metadata?.transit?.provider).toBe('transitous');
+ expect(payload.metadata?.transit?.legs).toHaveLength(1);
+ expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'to']);
+ expect(payload.endpoints[1]).toMatchObject({ name: 'Alexanderplatz', lat: 52.521 });
+ });
+
+ it('FE-PLANNER-TRANSMODAL-021: changing the destination drops the stale transit itinerary', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ const res = buildReservation({ title: 'Fernsehturm → Zoo', type: 'bus' }) as any;
+ res.metadata = { transit: { provider: 'transitous', legs: [{ mode: 'BUS' }] } };
+ res.endpoints = [
+ { role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.5208, lng: 13.4094, timezone: 'Europe/Berlin', local_date: null, local_time: null },
+ { role: 'stop', sequence: 1, name: 'Alexanderplatz', code: null, lat: 52.521, lng: 13.41, timezone: 'Europe/Berlin', local_date: null, local_time: null },
+ { role: 'to', sequence: 2, name: 'Zoologischer Garten', code: null, lat: 52.507, lng: 13.332, timezone: 'Europe/Berlin', local_date: null, local_time: null },
+ ];
+ render();
+ // Pick a different destination (mocked LocationSelect emits lat/lng 0,0).
+ const locationInputs = screen.getAllByTestId('location-select');
+ fireEvent.change(locationInputs[1], { target: { value: 'Somewhere Else' } });
+ await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
+ await waitFor(() => expect(onSave).toHaveBeenCalled());
+ const payload = onSave.mock.calls[0][0];
+ expect(payload.metadata?.transit).toBeUndefined();
+ expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
+ });
});
diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx
index 3a26ec61f..9472e3337 100644
--- a/client/src/components/Planner/TransportModal.tsx
+++ b/client/src/components/Planner/TransportModal.tsx
@@ -300,6 +300,22 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
+
+ // A transit itinerary (#1065) lives in metadata.transit + 'stop' endpoints,
+ // neither of which this form shows or edits — so re-saving must not wipe
+ // them. They're kept only while from/to are unchanged: picking a different
+ // origin or destination invalidates the stored connection.
+ const prevMeta = reservation ? parseReservationMetadata(reservation) : {}
+ const prevEndpointsAll = reservation?.endpoints || []
+ const prevFrom = prevEndpointsAll.find(ep => ep.role === 'from')
+ const prevTo = prevEndpointsAll.find(ep => ep.role === 'to')
+ const near = (a?: number | null, b?: number | null) => a != null && b != null && Math.abs(a - b) < 1e-6
+ const keepTransit = !!(prevMeta.transit && form.type !== 'flight' &&
+ prevFrom && prevTo && fromPick.location && toPick.location &&
+ near(prevFrom.lat, fromPick.location.lat) && near(prevFrom.lng, fromPick.location.lng) &&
+ near(prevTo.lat, toPick.location.lat) && near(prevTo.lng, toPick.location.lng))
+ if (keepTransit) metadata.transit = prevMeta.transit
+
const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType[] = []
@@ -314,7 +330,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
})
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
- if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
+ // Keep the itinerary's transfer stops while the route is unchanged (#1065).
+ const stops = keepTransit
+ ? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
+ : []
+ stops.forEach((s, i) => endpoints.push({
+ role: 'stop', sequence: i + 1, name: s.name, code: s.code ?? null,
+ lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
+ local_date: s.local_date ?? null, local_time: s.local_time ?? null,
+ }))
+ if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
}
// Flights derive their span from the first/last waypoint; other transports
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index f57de317b..2bf50d9fa 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -17,6 +17,7 @@ import SlidingTabs from '../components/shared/SlidingTabs'
import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal'
import { TransportModal } from '../components/Planner/TransportModal'
+import TransitSearchModal from '../components/Planner/TransitSearchModal'
import BookingImportModal from '../components/Planner/BookingImportModal'
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
// MemoriesPanel moved to Journey addon
@@ -194,6 +195,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
+ transitModalDayId, setTransitModalDayId, handleAddTransitRoute,
reservationPrefill, transportPrefill, importReviewActive, advanceImportReview,
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
@@ -397,6 +399,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onExternalTransportDetailHandled={() => setMapTransportDetail(null)}
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true) } : undefined}
+ onPlanTransit={can('day_edit', trip) ? (dayId) => setTransitModalDayId(dayId) : undefined}
onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined}
onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
@@ -526,6 +529,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
collapsed={dayDetailCollapsed}
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
mobile={isMobile}
+ onUpdateDayTitle={handleUpdateDayTitle}
/>
)
})()}
@@ -618,7 +622,7 @@ export default function TripPlannerPage(): React.ReactElement | null {