From ecea39d9122c9bfa1dab15c19d9912330ada1316 Mon Sep 17 00:00:00 2001 From: Ida Rogalska Date: Wed, 12 Aug 2026 13:13:30 +0200 Subject: [PATCH] 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. --- .../Planner/DayPlanSidebar.test.tsx | 82 +++++++++++++++++-- .../src/components/Planner/DayPlanSidebar.tsx | 69 ++++++++++++++-- .../Planner/TransitSearchPanel.test.tsx | 14 ++++ .../components/Planner/TransitSearchPanel.tsx | 6 +- .../src/components/Planner/TransportModal.tsx | 5 +- client/src/pages/TripPlannerPage.tsx | 1 + .../src/pages/tripPlanner/useTripPlanner.ts | 2 +- 7 files changed, 161 insertions(+), 18 deletions(-) diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index ff5002f8a..b1c8e843d 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -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() + 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() + 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() + // 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') diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index f76ff9451..63d5c71c3 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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() + 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 = return canEditDays && targetId != null ? ( -
openIncomingLegModeMenu(e, targetId, day.id)} style={{ cursor: 'pointer' }}> +
openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} style={{ cursor: 'pointer' }}> {connector}
) : connector @@ -2012,7 +2065,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => )} {routeLegs[day.id]?.[assignment.id] && (canEditDays ? ( -
openLegModeMenu(e, assignment.id, day.id)} style={{ cursor: 'pointer' }}> +
openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} style={{ cursor: 'pointer' }}>
) : ( @@ -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 = return canEditDays && nextPlaceId != null ? ( -
openIncomingLegModeMenu(e, Number(nextPlaceId), day.id)} style={{ cursor: 'pointer' }}> +
openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} style={{ cursor: 'pointer' }}> {connector}
) : connector @@ -2385,7 +2438,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const targetId = hotelLegs[day.id]?.bottom?.targetId const connector = return canEditDays && targetId != null ? ( -
openLegModeMenu(e, targetId, day.id)} style={{ cursor: 'pointer' }}> +
openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} style={{ cursor: 'pointer' }}> {connector}
) : connector diff --git a/client/src/components/Planner/TransitSearchPanel.test.tsx b/client/src/components/Planner/TransitSearchPanel.test.tsx index 3f4bdde6c..6d0b66cb2 100644 --- a/client/src/components/Planner/TransitSearchPanel.test.tsx +++ b/client/src/components/Planner/TransitSearchPanel.test.tsx @@ -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() + 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). diff --git a/client/src/components/Planner/TransitSearchPanel.tsx b/client/src/components/Planner/TransitSearchPanel.tsx index 11faf09b8..dd8478b67 100644 --- a/client/src/components/Planner/TransitSearchPanel.tsx +++ b/client/src/components/Planner/TransitSearchPanel.tsx @@ -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(initialFrom) const [to, setTo] = useState(initialTo) - const [time, setTime] = useState('09:00') + const [time, setTime] = useState(initialTime || '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') diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 6ccc3b175..1d6656acf 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -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 & { title: string })} initialFrom={transitPrefill?.from ?? null} initialTo={transitPrefill?.to ?? null} + initialTime={transitPrefill?.time ?? null} /> ) })()} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 814213cce..5c95091d4 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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) }} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 4e920677d..ef59b9519 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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(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(null) // The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs