feat(dayplan): offer public transport on any connector leg (#1281 follow-up)

The per-segment travel-mode menu now carries a "Public transit" entry on every
editable connector: stop-to-stop legs and the boundary hotel/booking bookends
alike. Choosing it opens the existing transit search pre-filled with the leg's
origin, destination and the stop's departure time, instead of drawing an OSRM
road route.

A connector only renders when both endpoints are located, so the option is
inherently limited to legs that can actually be searched. A leg's coordinates
are resolved back to named, timed points from the trip's places, accommodations
and booking endpoints. TransitSearchPanel gains an initialTime prop so the
departure time can be seeded.
This commit is contained in:
Ida Rogalska
2026-08-12 13:13:30 +02:00
committed by Maurice
parent 1aa1aea57e
commit ecea39d912
7 changed files with 161 additions and 18 deletions
@@ -183,11 +183,17 @@ beforeEach(() => {
// otherwise leak into the ones after them.
vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({
coordinates: [], distance: 0, duration: 0,
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
mid: [0, 0] as [number, number], from: [0, 0] as [number, number], to: [0, 0] as [number, number],
distance: 2000, duration: 600, distanceText: '2 km', durationText: '10 min',
drivingText: '10 min', walkingText: '25 min',
})),
// Each leg carries its endpoint coordinates (mirrors the real RouteCalculator), so
// connector-driven features that read seg.from/seg.to see faithful [lat, lng] pairs.
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, (_, i) => {
const a = waypoints[i], b = waypoints[i + 1]
return {
mid: [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2] as [number, number],
from: [a.lat, a.lng] as [number, number], to: [b.lat, b.lng] as [number, number],
distance: 2000, duration: 600, distanceText: '2 km', durationText: '10 min',
drivingText: '10 min', walkingText: '25 min',
}
}),
}))
vi.mocked(generateGoogleMapsUrl).mockReturnValue('https://maps.google.com/...')
sessionStorage.clear()
@@ -3427,6 +3433,72 @@ describe('DayPlanSidebar', () => {
expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenLastCalledWith(1, 11, null)
})
it('FE-PLANNER-DAYPLAN-171b: a stop-to-stop connector offers public transport pre-filled from the leg', async () => {
const user = userEvent.setup()
const onPlanTransitLeg = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignments = {
'10': [
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', place_time: '10:30', lat: 48.86, lng: 2.34 }) }),
buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true, onPlanTransitLeg })} />)
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
// Origin/destination + this stop's departure time, resolved back from the leg coords.
expect(onPlanTransitLeg).toHaveBeenCalledWith({
dayId: 10,
from: { name: 'Louvre', lat: 48.86, lng: 2.34 },
to: { name: 'Orsay', lat: 48.87, lng: 2.33 },
time: '10:30',
})
})
it('FE-PLANNER-DAYPLAN-171c: the public-transport entry is absent without the onPlanTransitLeg handler', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignments = {
'10': [
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }),
buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
await user.click(await screen.findByTitle('Change travel mode'))
expect(contextMenu().queryByRole('button', { name: 'Public transit' })).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-171d: a hotel bookend connector offers public transport from the hotel', async () => {
const user = userEvent.setup()
const onPlanTransitLeg = vi.fn()
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const accommodations: Accommodation[] = [{
id: 1, trip_id: 1, start_day_id: 10, end_day_id: 12,
place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia',
}]
const assignments = {
'11': [
buildAssignment({ id: 11, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.34 }) }),
buildAssignment({ id: 12, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11, routeShown: true, onPlanTransitLeg })} />)
// The morning bookend (hotel -> first stop) is the first connector in the list.
const connectors = await screen.findAllByTitle('Change travel mode')
await user.click(connectors[0])
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({
dayId: 11,
from: { name: 'Hotel Lutetia', lat: 48.85, lng: 2.35 },
to: { name: 'Louvre', lat: 48.86, lng: 2.34 },
}))
})
it('FE-PLANNER-DAYPLAN-172: a failing per-segment save is reported and the days are refetched', async () => {
const user = userEvent.setup()
const { assignmentsApi } = await import('../../api/client')
@@ -5,6 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } 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, TramFront, Zap } from 'lucide-react'
import { type PickedPlace } from './TransitSearchPanel'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
@@ -99,6 +100,12 @@ interface DayPlanSidebarProps {
onAddTransport?: (dayId: number) => void
/** Opens the public-transit route search for a day (#1065). */
onPlanTransit?: (dayId: number) => void
/**
* Opens the public-transit search pre-filled for a single leg (#1281 follow-up):
* the leg's origin and destination endpoints plus the origin's departure time,
* so "public transport" becomes an option on any connector, not just the day header.
*/
onPlanTransitLeg?: (leg: { dayId: number; from: PickedPlace; to: PickedPlace; time: string | null }) => void
/** Opens the journey view for a saved transit entry (#1065). */
onOpenTransit?: (reservation: Reservation) => void
onEditTransport?: (reservation: Reservation) => void
@@ -148,6 +155,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onRouteRefresh,
onAddTransport,
onPlanTransit,
onPlanTransitLeg,
onOpenTransit,
onEditTransport,
onEditReservation,
@@ -977,6 +985,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onRouteRefresh,
onAddTransport,
onPlanTransit,
onPlanTransitLeg,
onOpenTransit,
onEditTransport,
expandedTransitIds,
@@ -1151,6 +1160,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onRouteRefresh,
onAddTransport,
onPlanTransit,
onPlanTransitLeg,
onOpenTransit,
onEditTransport,
expandedTransitIds,
@@ -1284,11 +1294,53 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
})
}
// Open the mode menu at the clicked connector: every route profile plus a
// "use day default" entry that clears the per-leg override.
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number) => {
// A connector's endpoints carry only coordinates (RouteSegment.from/to, both
// [lat, lng]); resolve them back to the named, timed points the transit search
// wants by indexing every located place, hotel and booking endpoint on the trip.
// A connector only renders when both ends are located, so a coordinate that misses
// the index is rare — fall back to a nameless pick (the search still runs off the
// coordinates alone).
const transitPointIndex = useMemo(() => {
const m = new Map<string, { name: string; time: string | null }>()
const put = (lat?: number | null, lng?: number | null, name?: string | null, time?: string | null) => {
if (lat == null || lng == null) return
const k = `${lat},${lng}`
if (!m.has(k)) m.set(k, { name: name || '', time: time ?? null })
}
for (const list of Object.values(assignments)) {
for (const a of list) put(a.place?.lat, a.place?.lng, a.place?.name, a.place?.place_time)
}
for (const acc of accommodations) put(acc.place_lat, acc.place_lng, acc.place_name, null)
for (const r of reservations) {
for (const ep of (r.endpoints || [])) put(ep.lat, ep.lng, ep.name, ep.local_time)
}
return m
}, [assignments, accommodations, reservations])
// Build the "public transport" prefill for a leg from its RouteSegment: MOTIS is
// driven off the coordinates, the names + departure time come from the index above.
const buildTransitLeg = (seg?: RouteSegment): { from: PickedPlace; to: PickedPlace; time: string | null } | null => {
if (!seg?.from || !seg?.to) return null
const pick = (c: [number, number]): PickedPlace => ({ name: transitPointIndex.get(`${c[0]},${c[1]}`)?.name || '', lat: c[0], lng: c[1] })
const rawTime = transitPointIndex.get(`${seg.from[0]},${seg.from[1]}`)?.time
return { from: pick(seg.from), to: pick(seg.to), time: rawTime && /^\d{2}:\d{2}/.test(rawTime) ? rawTime.slice(0, 5) : null }
}
// The extra connector-menu entry (#1281 follow-up): search public transit for this
// leg instead of drawing a road route. Only when a handler is wired (day has dates).
const transitLegMenuItem = (dayId: number, seg?: RouteSegment) => {
if (!onPlanTransitLeg) return []
const leg = buildTransitLeg(seg)
if (!leg) return []
return [{ label: t('transit.title'), icon: TramFront, onClick: () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time }) }]
}
// Open the mode menu at the clicked connector: every route profile, the optional
// "public transport" entry, plus a "use day default" entry that clears the override.
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
ctxMenu.open(e, [
...routeProfileOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => setLegMode(assignmentId, dayId, o.key) })),
...transitLegMenuItem(dayId, seg ?? routeLegs[dayId]?.[assignmentId]),
{ divider: true },
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setLegMode(assignmentId, dayId, null) },
])
@@ -1310,9 +1362,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
})
}
const openIncomingLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number) => {
const openIncomingLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
ctxMenu.open(e, [
...routeProfileOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => setIncomingLegMode(assignmentId, dayId, o.key) })),
...transitLegMenuItem(dayId, seg),
{ divider: true },
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setIncomingLegMode(assignmentId, dayId, null) },
])
@@ -1642,7 +1695,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const targetId = hotelLegs[day.id]?.top?.targetId
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.top!.seg} name={hotelLegs[day.id]!.top!.name} profile={routeProfile} placement="top" />
return canEditDays && targetId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id)} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} style={{ cursor: 'pointer' }}>
{connector}
</div>
) : connector
@@ -2012,7 +2065,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
{routeLegs[day.id]?.[assignment.id] && (canEditDays ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id)} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} style={{ cursor: 'pointer' }}>
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
</div>
) : (
@@ -2251,7 +2304,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const nextPlaceId = merged.slice(idx + 1).find(i => i.type === 'place' && i.data.place?.lat && i.data.place?.lng)?.data.id
const connector = <RouteConnector seg={routeLegs[day.id]![res.id]} profile={routeProfile} />
return canEditDays && nextPlaceId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id)} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} style={{ cursor: 'pointer' }}>
{connector}
</div>
) : connector
@@ -2385,7 +2438,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const targetId = hotelLegs[day.id]?.bottom?.targetId
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.bottom!.seg} name={hotelLegs[day.id]!.bottom!.name} profile={routeProfile} placement="bottom" />
return canEditDays && targetId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id)} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} style={{ cursor: 'pointer' }}>
{connector}
</div>
) : connector
@@ -177,6 +177,20 @@ describe('TransitSearchPanel', () => {
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: true, time: '2025-06-01T08:00:00.000Z' })
})
it('FE-PLANNER-TRANSIT-008b: initialTime seeds the departure time', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps({
initialFrom: { name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 },
initialTo: { name: 'London Victoria', lat: 51.5074, lng: -0.1278 },
initialTime: '11:00',
})} />)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
// 11:00 in Berlin (UTC+2) anchors depart-by to the origin zone → 09:00Z.
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ time: '2025-06-01T09:00:00.000Z' })
})
it('FE-PLANNER-TRANSIT-009: arrive-by lists the latest-arriving itinerary first', async () => {
const user = userEvent.setup()
// MOTIS returns arrive-by results ascending, deadline-adjacent last (#1479).
@@ -318,9 +318,11 @@ interface TransitSearchPanelProps {
/** Pre-seed from/to — used by "change route" on an existing journey. */
initialFrom?: PickedPlace | null
initialTo?: PickedPlace | null
/** Pre-seed the departure time ('HH:mm') — used when planning a specific leg. */
initialTime?: string | null
}
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null }: TransitSearchPanelProps) {
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
const { t } = useTranslation()
const toast = useToast()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
@@ -328,7 +330,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
const [to, setTo] = useState<PickedPlace | null>(initialTo)
const [time, setTime] = useState('09:00')
const [time, setTime] = useState(initialTime || '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')
@@ -160,8 +160,8 @@ interface TransportModalProps {
initialAutomated?: boolean
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
tripHasDates?: boolean
/** Pre-seed the transit search — used by "change route" on an existing journey. */
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null } | null
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null; time?: string | null } | null
/** Trip members + guests, for the traveler picker (#1517). */
tripMembers?: TripMember[]
}
@@ -701,6 +701,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
onAdd={(payload) => onSave(payload as Record<string, any> & { title: string })}
initialFrom={transitPrefill?.from ?? null}
initialTo={transitPrefill?.to ?? null}
initialTime={transitPrefill?.time ?? null}
/>
)
})()}
+1
View File
@@ -459,6 +459,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) } : undefined}
onOpenTransit={(r) => setTransitJourney(r)}
onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined}
onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true) } : 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) }}
@@ -241,7 +241,7 @@ export function useTripPlanner() {
// Public transit (#1065): open the TransportModal in its Automated mode, seed
// the search (change-route), and show the journey view for a saved entry.
const [transportModalAutomated, setTransportModalAutomated] = useState<boolean>(false)
const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null } | null>(null)
const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null; time?: string | null } | null>(null)
const [transitJourney, setTransitJourney] = useState<Reservation | null>(null)
// The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs