From 910c62ea902e4571e15cc3d27ef0bc593f181854 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 18:10:06 +0200 Subject: [PATCH] fix(transit): show the stop you get off at instead of the walk that follows Every row of an expanded search result is anchored at its leg's start: the time, the stop name, the platform. A leg's arrival appears as the next row's heading, and the last one gets the closing row. The walking legs broke that and printed their DESTINATION, which is the stop the next row already names. MOTIS brackets every journey in walking legs, so the last leg of a real connection is the walk from the arrival station to wherever you were headed. Its origin is the stop you get off at, and because that row printed the destination instead, the station had no row anywhere in the card and vanished. The same branch blanked the time cell, so the train's arrival time went with it. At the other end the access walk swallowed the origin the user had picked. Rows now read from.name for every mode, and the walk says it is a walk in its badge row instead. The platform tags along, which gives the exit row the arrival platform it was already carrying. The time falls back to scheduledTime the way the save path does, since a schedule-only feed would otherwise leave the row that matters without a clock. Fixes #2106 --- .../Planner/TransitSearchPanel.test.tsx | 76 ++++++++++++++++++- .../components/Planner/TransitSearchPanel.tsx | 25 +++++- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/client/src/components/Planner/TransitSearchPanel.test.tsx b/client/src/components/Planner/TransitSearchPanel.test.tsx index 6d0b66cb2..eb2296a0c 100644 --- a/client/src/components/Planner/TransitSearchPanel.test.tsx +++ b/client/src/components/Planner/TransitSearchPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-027 — the transit search panel +// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-030 — the transit search panel // (embedded as the TransportModal's Automated mode). import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' @@ -36,6 +36,27 @@ const ITINERARY = { ], } +const stop = (name: string, lat: number, lng: number, time: string, track: string | null = null) => + ({ name, lat, lng, time, scheduledTime: null, track }) + +// The shape MOTIS actually returns for the reported connection: every journey is +// bracketed in walking legs, and a change between two stations adds one in the +// middle (#2106). +const WALK_BRACKETED = { + startTime: '2025-06-01T06:30:00Z', + endTime: '2025-06-01T09:10:00Z', + duration: 9600, + transfers: 1, + walkSeconds: 1080, + legs: [ + { mode: 'WALK', from: stop('Aachen, Bushof', 50.777, 6.09, '2025-06-01T06:30:00Z'), to: stop('Aachen Hbf', 50.768, 6.091, '2025-06-01T06:34:00Z'), duration: 240, distance: 300, headsign: null, line: null, lineColor: null, lineTextColor: null, agency: null, intermediateStops: 0 }, + { mode: 'HIGHSPEED_RAIL', from: stop('Aachen Hbf', 50.768, 6.091, '2025-06-01T06:40:00Z', '2'), to: stop('Koeln Hbf', 50.943, 6.959, '2025-06-01T07:30:00Z'), duration: 3000, distance: null, headsign: 'Koeln', line: 'ICE 10', lineColor: null, lineTextColor: null, agency: 'DB', intermediateStops: 1 }, + { mode: 'WALK', from: stop('Koeln Hbf', 50.943, 6.959, '2025-06-01T07:32:00Z'), to: stop('Koeln Messe/Deutz', 50.941, 6.974, '2025-06-01T07:40:00Z'), duration: 480, distance: 600, headsign: null, line: null, lineColor: null, lineTextColor: null, agency: null, intermediateStops: 0 }, + { mode: 'HIGHSPEED_RAIL', from: stop('Koeln Messe/Deutz', 50.941, 6.974, '2025-06-01T07:50:00Z', '11'), to: stop('Frankfurt(Main)Hbf', 50.107, 8.663, '2025-06-01T08:57:00Z'), duration: 4020, distance: null, headsign: 'Frankfurt', line: 'ICE 610', lineColor: null, lineTextColor: null, agency: 'DB', intermediateStops: 2 }, + { mode: 'WALK', from: stop('Frankfurt(Main)Hbf', 50.107, 8.663, '2025-06-01T09:00:00Z'), to: stop('Frankfurt Flughafen Fernbf', 50.053, 8.57, '2025-06-01T09:10:00Z'), duration: 600, distance: 800, headsign: null, line: null, lineColor: null, lineTextColor: null, agency: null, intermediateStops: 0 }, + ], +} + const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' }) function makeProps(overrides = {}) { @@ -517,8 +538,59 @@ describe('TransitSearchPanel', () => { await user.click(await screen.findByText(/08:30 – 09:00/)) expect(await screen.findByText('BVG')).toBeInTheDocument() expect(screen.getByText(/Platform 2/)).toBeInTheDocument() - expect(screen.getByText('Walk to Alexanderplatz')).toBeInTheDocument() + // Rows are anchored at the leg's start, so the access walk carries the picked + // origin and not the station the next row already names (#2106). + expect(screen.getByText('Start')).toBeInTheDocument() + expect(screen.queryByText('Walk to Alexanderplatz')).not.toBeInTheDocument() expect(screen.getByText('300 m')).toBeInTheDocument() expect(screen.getByText('6 stops')).toBeInTheDocument() }) + + // #2106 — the walking legs printed their DESTINATION as the row heading, which is + // the stop the next row already names. The stop you actually get off at is a + // walking leg's origin, so it had no row at all and disappeared from the card. + it('FE-PLANNER-TRANSIT-028: the stop you get off at is listed, with the arrival time', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [WALK_BRACKETED] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await user.click(await screen.findByText(/08:30 – 11:10/)) + + // The final train's arrival station, which is where the closing walk begins. + expect(await screen.findByText(/Frankfurt\(Main\)Hbf/)).toBeInTheDocument() + // Its arrival time, which the old WALK branch blanked. Not a time the card + // header already prints, or this would pass without the fix. + expect(screen.getByText('11:00')).toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSIT-029: a walk between two stations names the station it starts from', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [WALK_BRACKETED] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await user.click(await screen.findByText(/08:30 – 11:10/)) + + // The change happens on foot between two different stations: the one you leave + // is its own row now, at the time the first train gets in. + expect(await screen.findByText('Koeln Hbf')).toBeInTheDocument() + expect(screen.getByText('09:32')).toBeInTheDocument() + // And the walk is still marked as one, in the badge row rather than the heading. + expect(screen.getAllByText('Walking').length).toBe(3) + }) + + it('FE-PLANNER-TRANSIT-030: no row is headed with the destination of its own walk', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [WALK_BRACKETED] }) + render() + await pickFromAndTo(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await user.click(await screen.findByText(/08:30 – 11:10/)) + + await screen.findByText(/Frankfurt\(Main\)Hbf/) + expect(screen.queryByText(/^Walk to /)).not.toBeInTheDocument() + // The picked origin survives instead of being replaced by the first station. + expect(screen.getByText('Aachen, Bushof')).toBeInTheDocument() + }) }) diff --git a/client/src/components/Planner/TransitSearchPanel.tsx b/client/src/components/Planner/TransitSearchPanel.tsx index 981a68f1d..e08f2534a 100644 --- a/client/src/components/Planner/TransitSearchPanel.tsx +++ b/client/src/components/Planner/TransitSearchPanel.tsx @@ -234,12 +234,28 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add {expanded && (
+ {/* + Every row is anchored at the leg's START: its time, its stop name and its + platform. A leg's arrival shows up as the next row's heading, and the last + arrival gets the closing row below. + + The walking legs used to break that and print leg.to.name, which is the stop + the NEXT row already names. The stop where you actually get off is a walking + leg's from.name, so it had no row of its own and vanished from the card + entirely, along with the train's arrival time, which the same branch blanked + (#2106). MOTIS brackets every journey in walking legs, so this hit the exit + of any real connection. + + The time falls back to scheduledTime for the same reason the save path does + (see stopTime below): a feed without realtime data carries only the schedule, + and this row is now the one that shows the arrival. + */} {it.legs.map((leg, i) => { const color = leg.mode === 'WALK' ? 'var(--border-primary)' : (leg.lineColor || 'var(--text-muted)') return (
- {leg.mode === 'WALK' ? '' : fmtTimeInTz(leg.from.time, tzAt(leg.from.lat, leg.from.lng), is12h)} + {fmtTimeInTz(leg.from.time ?? leg.from.scheduledTime, tzAt(leg.from.lat, leg.from.lng), is12h)}
@@ -247,13 +263,14 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
- {leg.mode === 'WALK' ? t('transit.walkTo', { name: leg.to.name }) : leg.from.name} - {leg.from.track && leg.mode !== 'WALK' && · {t('transit.platform', { track: leg.from.track })}} + {leg.from.name} + {leg.from.track && · {t('transit.platform', { track: leg.from.track })}}
{leg.mode === 'WALK' ? ( = 1000 ? `${(leg.distance / 1000).toFixed(1)} km` : `${leg.distance} m`) : '' }, ]} /> ) : (