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, TramFront, TrainFront, TrainFrontTunnel } from 'lucide-react' import CustomTimePicker from '../shared/CustomTimePicker' import { TransitMetaBadges } from './transitDisplay' 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 geometry?: string | null; geometryPrecision?: 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 } export 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 }[] = [ // lucide's `Train` is an alias of TramFront, so rail keeps TrainFront and the // subway takes the tunnel variant — otherwise the chips share a glyph. { key: 'rail', labelKey: 'transit.mode.rail', Icon: TrainFront, modes: 'HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,SUBURBAN' }, { key: 'subway', labelKey: 'transit.mode.subway', Icon: TrainFrontTunnel, 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' }, ] // Only called for non-WALK legs — walking renders its own Footprints inline. function legIcon(mode: string) { if (mode === 'BUS' || mode === 'COACH') return Bus if (mode === 'TRAM') return TramFront if (mode === 'SUBWAY') return TrainFrontTunnel if (mode === 'FERRY') return Sailboat if (mode === 'FUNICULAR' || mode === 'AERIAL_LIFT') return CableCar return TrainFront } 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' ? ( = 1000 ? `${(leg.distance / 1000).toFixed(1)} km` : `${leg.distance} m`) : '' }, ]} /> ) : ( <> 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 search panel ───────────────────────────────────────────────────────── // Modal-less on purpose: it renders as the "Automated transport" mode inside // the TransportModal (and could embed anywhere else). The host owns the modal // chrome and closes itself once onAdd resolves. interface TransitSearchPanelProps { day: Day days: Day[] places: Place[] accommodations?: Accommodation[] /** Persist the built reservation payload; resolves when saved. */ onAdd: (payload: Record) => Promise /** Pre-seed from/to — used by "change route" on an existing journey. */ initialFrom?: PickedPlace | null initialTo?: PickedPlace | null } export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null }: TransitSearchPanelProps) { const { t } = useTranslation() const toast = useToast() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 const [from, setFrom] = useState(initialFrom) const [to, setTo] = useState(initialTo) 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 tzTo = tzAt(to.lat, to.lng) // Depart-by anchors the entered time at the origin; arrive-by anchors it at // the destination (#1479), so each mode must convert with the matching zone. const timeIso = localToUtcIso(day.date, time, arriveBy ? tzTo : 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 }) // MOTIS names the request coordinates START/END — swap in the places the // user actually picked so walks read "Walk to Zoologischer Garten". const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n) const cleaned = (d.itineraries || []).map((it: TransitItinerary) => ({ ...it, legs: it.legs.map(l => ({ ...l, from: { ...l.from, name: cleanStop(l.from.name) }, to: { ...l.to, name: cleanStop(l.to.name) }, })), })) // MOTIS returns arrive-by results ascending with the deadline-adjacent // connection last (#1479) — flip so the itinerary arriving closest to the // requested time leads the list, mirroring depart-by. if (arriveBy) cleaned.sort((a, b) => Date.parse(b.endTime) - Date.parse(a.endTime)) setItineraries(cleaned) } 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 // Realtime time with scheduled fallback — the same `time ?? scheduledTime` // the server's buildTransitReservationParts applies, so a scheduled-only // feed still yields stop times instead of nulls. const stopTime = (s: TransitLegStop) => s.time ?? s.scheduledTime // 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 const t2 = stopTime(s) 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: t2 ? dateYMDInTz(t2, tzAt(s.lat, s.lng)) : null, local_time: t2 ? timeHHmmInTz(t2, 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}`, // Its own first-class type: transit journeys get their own icon, their // rich timeline row and the itinerary detail view instead of the // generic transport rendering. type: 'transit', 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: stopTime(l.from) ? timeHHmmInTz(stopTime(l.from)!, tzAt(l.from.lat, l.from.lng)) : null, track: l.from.track }, to: { name: l.to.name, time: stopTime(l.to) ? timeHHmmInTz(stopTime(l.to)!, tzAt(l.to.lat, l.to.lng)) : null, track: l.to.track }, geometry: l.geometry || null, geometry_precision: l.geometryPrecision ?? 6, })), }, }, endpoints, needs_review: false, } await onAdd(payload) } 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: isMobile ? '6px 5px' : '6px 11px', fontSize: isMobile ? 'calc(11px * var(--fs-scale-body, 1))' : '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 — stacked tight on mobile, swap button on desktop only */}
{!isMobile && ( )}
{/* search options — one calm card: when + how on top, modes + go below */}
{day.date && ( {new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })} )}
{MODE_GROUPS.map(m => { const active = activeModes.has(m.key) return ( ) })}
{/* results */} {loading && (
)} {!loading && ranked && ranked.length === 0 && (
{t('transit.noResults')}
)} {!loading && ranked && ranked.length > 0 && (
{ranked.map((it, idx) => ( setExpandedIdx(expandedIdx === idx ? null : idx)} onAdd={() => addItinerary(it, idx)} adding={addingIdx === idx} t={t} /> ))}
{t('transit.attribution')}{' '} Transitous
)}
) }