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;
}