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
This commit is contained in:
Maurice
2026-08-28 18:10:06 +02:00
committed by Maurice
parent 6c7e257c17
commit 910c62ea90
2 changed files with 95 additions and 6 deletions
@@ -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(<TransitSearchPanel {...makeProps()} />)
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(<TransitSearchPanel {...makeProps()} />)
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(<TransitSearchPanel {...makeProps()} />)
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()
})
})
@@ -234,12 +234,28 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
{expanded && (
<div style={{ borderTop: '1px solid var(--border-faint)', padding: '10px 14px 12px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/*
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 (
<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)}
{fmtTimeInTz(leg.from.time ?? leg.from.scheduledTime, 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 }} />
@@ -247,13 +263,14 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
</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>}
{leg.from.name}
{leg.from.track && <span className="text-content-faint" style={{ fontWeight: 500 }}> · {t('transit.platform', { track: leg.from.track })}</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{leg.mode === 'WALK' ? (
<TransitMetaBadges size="sm" items={[
{ icon: Footprints, text: fmtDuration(leg.duration, t) },
{ icon: Footprints, text: t('transit.walkLabel') },
{ text: fmtDuration(leg.duration, t) },
{ text: leg.distance ? (leg.distance >= 1000 ? `${(leg.distance / 1000).toFixed(1)} km` : `${leg.distance} m`) : '' },
]} />
) : (