diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx
index 53404c79e..ca8abed43 100644
--- a/client/src/components/Map/MapView.test.tsx
+++ b/client/src/components/Map/MapView.test.tsx
@@ -599,6 +599,32 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
+ it('FE-COMP-MAPVIEW-082: a booking switched on for another day keeps drawing unless the map is held to the day', () => {
+ // The desktop passes no scope, so a booking switched on stays drawn on every day.
+ // Only a caller that asks for it drops the booking from a day it does not run on.
+ const days = [{ id: 10, trip_id: 1, day_number: 1 }, { id: 11, trip_id: 1, day_number: 2 }]
+ const reservation = buildReservation({
+ id: 44,
+ type: 'flight',
+ day_id: 10,
+ end_day_id: 10,
+ endpoints: [
+ { role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
+ { role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
+ ],
+ })
+ const reservations = [reservation]
+ const ids = [44]
+ const { rerender } = render()
+ expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
+
+ rerender()
+ expect(screen.queryByTestId('polyline')).not.toBeInTheDocument()
+
+ rerender()
+ expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
+ })
+
it('FE-COMP-MAPVIEW-073: a category icon that throws leaves the marker circle intact', () => {
// Icon components come from a lookup table a plugin can extend; one that
// throws must not take the whole map down, only its own glyph.
diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx
index 9acfb8be6..88c8bc46f 100644
--- a/client/src/components/Map/MapView.tsx
+++ b/client/src/components/Map/MapView.tsx
@@ -699,6 +699,9 @@ export const MapView = memo(function MapView({
showTransitRoutes = true,
days = [] as Day[],
selectedDayId = null,
+ // Whether a booking switched on by hand also has to run on the selected day to be
+ // drawn. Only the phone's plan map asks for it; see RouteVisibilityOptions.
+ scopeConnectionsToDay = false,
onReservationClick,
pois = [] as Poi[],
onPoiClick,
@@ -762,8 +765,8 @@ export const MapView = memo(function MapView({
)), [pois, onPoiClick, onPoiDropOnRoute])
const visibleReservations = useMemo(() => (
- visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
- ), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
+ visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
+ ), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations)
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx
index 78f863aba..ceb7a585f 100644
--- a/client/src/components/Map/MapViewGL.test.tsx
+++ b/client/src/components/Map/MapViewGL.test.tsx
@@ -1379,6 +1379,33 @@ describe('MapViewGL', () => {
expect(onReservationClick).toHaveBeenCalledWith(77)
})
+ it('FE-COMP-MAPVIEWGL-087: a booking switched on for another day reaches the overlay unless the map is held to the day', async () => {
+ loadOnAttach()
+ const days = [{ id: 10, trip_id: 1, day_number: 1 }, { id: 11, trip_id: 1, day_number: 2 }]
+ const flight = {
+ id: 44, trip_id: 1, type: 'flight', title: 'A → B', status: 'confirmed', day_id: 10, end_day_id: 10,
+ endpoints: [
+ { role: 'from', sequence: 0, name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
+ { role: 'to', sequence: 1, name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
+ ],
+ }
+ const reservations = [flight]
+ const ids = [44]
+ const drawn = () => (reservationOverlay.update.mock.lastCall?.[0] as { id: number }[]).map(r => r.id)
+
+ const { rerender } = render(
+ ,
+ )
+ await act(async () => {})
+ expect(drawn()).toEqual([44])
+
+ rerender(
+ ,
+ )
+ await act(async () => {})
+ expect(drawn()).toEqual([])
+ })
+
it('FE-COMP-MAPVIEWGL-042: explore POIs get their own pin, hover popup and click callback', async () => {
loadOnAttach()
const basePoi: Poi = {
diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx
index 36102b7d1..8ff5ccecb 100644
--- a/client/src/components/Map/MapViewGL.tsx
+++ b/client/src/components/Map/MapViewGL.tsx
@@ -164,6 +164,11 @@ interface Props {
showTransitRoutes?: boolean
days?: Day[]
selectedDayId?: number | null
+ /**
+ * Whether a booking switched on by hand also has to run on the selected day to be
+ * drawn. Only the phone's plan map asks for it; see RouteVisibilityOptions.
+ */
+ scopeConnectionsToDay?: boolean
showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void
pois?: Poi[]
@@ -695,6 +700,7 @@ export function MapViewGL({
showTransitRoutes = true,
days = NO_DAYS,
selectedDayId = null,
+ scopeConnectionsToDay = false,
showReservationStats = false,
onReservationClick,
pois = NO_POIS,
@@ -2186,8 +2192,8 @@ export function MapViewGL({
// DayPlanSidebar — nothing is rendered until the user enables a
// booking's route, matching the Leaflet MapView's behaviour.
const visibleReservations = useMemo(() => (
- visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
- ), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
+ visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
+ ), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations)
diff --git a/client/src/mobile/screens/settings/MSettingsGeneral.tsx b/client/src/mobile/screens/settings/MSettingsGeneral.tsx
index e3475679c..1d4fd9880 100644
--- a/client/src/mobile/screens/settings/MSettingsGeneral.tsx
+++ b/client/src/mobile/screens/settings/MSettingsGeneral.tsx
@@ -53,6 +53,13 @@ export default function MSettingsGeneral() {
on: settings.map_booking_labels === true,
value: (on) => on,
},
+ {
+ key: 'map_always_show_routes',
+ label: t('settings.alwaysShowRoutes'),
+ sub: t('settings.alwaysShowRoutesHint'),
+ on: settings.map_always_show_routes === true,
+ value: (on) => on,
+ },
{
key: 'map_poi_pill_enabled',
label: t('settings.mapPoiPill'),
diff --git a/client/src/mobile/screens/trip/map/MMapArea.tsx b/client/src/mobile/screens/trip/map/MMapArea.tsx
index 77cee4104..2f892ebc1 100644
--- a/client/src/mobile/screens/trip/map/MMapArea.tsx
+++ b/client/src/mobile/screens/trip/map/MMapArea.tsx
@@ -386,6 +386,10 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
// ride's leg carries no geometry, so without this the road ended at the
// departure terminal and picked up again at the arrival with nothing between.
visibleConnectionIds={onStage ? planner.roadtripConnections : planner.visibleConnections}
+ // The plan map shows one day, so a booking switched on for another day stays off
+ // it until that day is picked, or the whole trip is. The switch itself is still
+ // trip-wide. The stage keeps its seamed rides whatever day they were booked on.
+ scopeConnectionsToDay={!onStage}
// Transport overlay tap → the mobile transport detail sheet (desktop
// routes this through mapTransportDetail into the day sidebar instead).
onReservationClick={(rid: number) => shell.openSheet('transport', { reservationId: rid })}
diff --git a/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx
index 4f0526a46..c14791d6c 100644
--- a/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx
@@ -8,6 +8,7 @@ import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constan
import { splitReservationDateTime } from '../../../../utils/formatters'
import { getFlightLegs, getTrainLegs } from '../../../../utils/flightLegs'
import { openFile } from '../../../../utils/fileDownload'
+import { runsOnDay } from '../../../../utils/reservationRoutes'
import type { Reservation } from '../../../../types'
import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi'
@@ -107,7 +108,11 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
const confirmed = res.status === 'confirmed'
const codeBlurred = blurCodes && !codeRevealed
- const onMap = planner.visibleConnections.includes(res.id)
+ // The plan map only draws a switched-on booking on the days it runs on, so from any
+ // other day the button reads off, as the map does, and a tap takes the map to the
+ // booking instead of switching off a route that is not drawn here anyway.
+ const switchedOn = planner.visibleConnections.includes(res.id)
+ const onMap = switchedOn && runsOnDay(res, planner.selectedDayId, planner.days)
// First tap draws the overlay and jumps to the map; while drawn, the same
// button hides it again (per-booking overlay toggle, desktop parity).
@@ -116,10 +121,19 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
planner.toggleConnection(res.id)
return
}
- planner.toggleConnection(res.id)
+ if (!switchedOn) planner.toggleConnection(res.id)
shell.closeSheet()
if (shell.trTab !== 'plan') shell.setTrTab('plan')
if (shell.view !== 'map') shell.toggleView()
+ // The map draws the day on the chips, and the Transports tab lists the whole trip, so
+ // the booking may run on a different day. Moved last, because entering the map picks
+ // the day the shell was already holding. A day inside the booking's span, the
+ // all-days view and a booking bound to no day are left as they are.
+ const bookingDayId = res.day_id ?? res.end_day_id
+ if (planner.selectedDayId != null && bookingDayId != null && !runsOnDay(res, planner.selectedDayId, planner.days)) {
+ planner.handleSelectDay(bookingDayId, false)
+ planner.setExpandedDayIds(new Set([bookingDayId]))
+ }
}
const editTransport = () => {
diff --git a/client/src/utils/reservationRoutes.test.ts b/client/src/utils/reservationRoutes.test.ts
index 38405e767..dbb91e3b3 100644
--- a/client/src/utils/reservationRoutes.test.ts
+++ b/client/src/utils/reservationRoutes.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { hopIsVisible, isRoutableReservation, labelFloorPx, lineFloorPx, visibleRouteReservations } from './reservationRoutes'
+import { hopIsVisible, isRoutableReservation, labelFloorPx, lineFloorPx, runsOnDay, visibleRouteReservations } from './reservationRoutes'
import type { Day, Reservation, ReservationEndpoint } from '../types'
function endpoint(role: 'from' | 'to', lat: number, lng: number): ReservationEndpoint {
@@ -117,6 +117,92 @@ describe('visibleRouteReservations with a selected day', () => {
})
})
+// The phone's plan map shows one day, so it asks for the per-item toggle to follow the
+// selected day the way the transit toggle does. The span rule is the transit one.
+describe('visibleRouteReservations with the per-item toggle scoped to the day', () => {
+ const twoStop = [endpoint('from', 1, 2), endpoint('to', 3, 4)]
+ const days = [
+ { id: 10, trip_id: 1, day_number: 1 },
+ { id: 11, trip_id: 1, day_number: 2 },
+ { id: 20, trip_id: 1, day_number: 3 },
+ ] as Day[]
+
+ function booking(overrides: Partial): Reservation {
+ return reservation({ id: 5, type: 'flight', endpoints: twoStop, ...overrides })
+ }
+
+ const drawn = (r: Reservation, selectedDayId: number | null | undefined, onDays: Day[] = days) =>
+ visibleRouteReservations([r], {
+ visibleConnectionIds: [r.id], showTransitRoutes: false, selectedDayId, days: onDays, scopeConnectionsToDay: true,
+ })
+
+ it('leaves out a booking switched on for another day', () => {
+ expect(drawn(booking({ day_id: 20, end_day_id: 20 }), 10)).toEqual([])
+ })
+
+ it('draws a booking on the day it runs on', () => {
+ const r = booking({ day_id: 10, end_day_id: 10 })
+ expect(drawn(r, 10)).toEqual([r])
+ })
+
+ it('keeps an overnight booking on both of its days and off the rest', () => {
+ const r = booking({ day_id: 10, end_day_id: 11 })
+ expect(drawn(r, 10)).toEqual([r])
+ expect(drawn(r, 11)).toEqual([r])
+ expect(drawn(r, 20)).toEqual([])
+ })
+
+ it('keeps a multi-day booking on the days in between', () => {
+ const r = booking({ day_id: 10, end_day_id: 20 })
+ expect(drawn(r, 11)).toEqual([r])
+ })
+
+ it('goes by day order, not by day id', () => {
+ const reordered = [
+ { id: 20, trip_id: 1, day_number: 1 },
+ { id: 11, trip_id: 1, day_number: 2 },
+ { id: 10, trip_id: 1, day_number: 3 },
+ ] as Day[]
+ const r = booking({ day_id: 20, end_day_id: 11 })
+ expect(drawn(r, 11, reordered)).toEqual([r])
+ expect(drawn(r, 10, reordered)).toEqual([])
+ })
+
+ it('keeps a booking that is bound to no day', () => {
+ const r = booking({ day_id: null, end_day_id: null })
+ expect(drawn(r, 10)).toEqual([r])
+ })
+
+ it('keeps every switched-on booking while no day is selected', () => {
+ const r = booking({ day_id: 20, end_day_id: 20 })
+ expect(drawn(r, undefined)).toEqual([r])
+ expect(drawn(r, null)).toEqual([r])
+ })
+
+ it('still draws nothing that was not switched on', () => {
+ const r = booking({ day_id: 10, end_day_id: 10 })
+ expect(visibleRouteReservations([r], {
+ visibleConnectionIds: [], showTransitRoutes: false, selectedDayId: 10, days, scopeConnectionsToDay: true,
+ })).toEqual([])
+ })
+})
+
+describe('runsOnDay', () => {
+ const days = [
+ { id: 10, trip_id: 1, day_number: 1 },
+ { id: 11, trip_id: 1, day_number: 2 },
+ ] as Day[]
+
+ it('reads a booking with only an arrival day as running on that day', () => {
+ expect(runsOnDay({ day_id: null, end_day_id: 11 }, 11, days)).toBe(true)
+ expect(runsOnDay({ day_id: null, end_day_id: 11 }, 10, days)).toBe(false)
+ })
+
+ it('does not rule a booking out for a selected day it cannot find', () => {
+ expect(runsOnDay({ day_id: 10, end_day_id: 10 }, 99, days)).toBe(true)
+ })
+})
+
describe('hopIsVisible (#2275)', () => {
// A flat projection: one unit of lat or lng is one pixel.
const px = (p: readonly [number, number]) => ({ x: p[1], y: p[0] })
diff --git a/client/src/utils/reservationRoutes.ts b/client/src/utils/reservationRoutes.ts
index 94645d0ae..5356bac1b 100644
--- a/client/src/utils/reservationRoutes.ts
+++ b/client/src/utils/reservationRoutes.ts
@@ -22,10 +22,17 @@ export interface RouteVisibilityOptions {
selectedDayId?: number | null
/** The trip's days, needed to order them — day ids are not monotonic once days have been reordered. */
days?: Day[]
+ /**
+ * Hold the per-item toggle to the selected day as well. Off unless asked for: the desktop
+ * keeps a booking it was told to draw on every day, since switching it on is a deliberate
+ * act (#2019). The phone's plan map shows one day at a time, and a flight from another
+ * day drawn across it reads as part of that day.
+ */
+ scopeConnectionsToDay?: boolean
}
/**
- * Does this transit journey actually run on the selected day? `showTransitRoutes` is one
+ * Does this journey actually run on the selected day? `showTransitRoutes` is one
* day's toggle but the reservation list is trip-wide, so without this every automated
* transport in the trip drew its geometry as soon as any day's route was switched on (#2019).
*
@@ -34,7 +41,11 @@ export interface RouteVisibilityOptions {
* A journey bound to no day keeps drawing; hiding something that legitimately belongs
* to today is the worse failure.
*/
-function transitRunsOnDay(r: Reservation, selectedDayId: number | null | undefined, days: Day[]): boolean {
+export function runsOnDay(
+ r: Pick,
+ selectedDayId: number | null | undefined,
+ days: Day[],
+): boolean {
if (selectedDayId == null) return true
const startDayId = r.day_id ?? r.end_day_id
if (startDayId == null) return true
@@ -45,11 +56,11 @@ function transitRunsOnDay(r: Reservation, selectedDayId: number | null | undefin
/** Which reservations should draw a route on the map, combining the two independent toggles above. */
export function visibleRouteReservations(reservations: Reservation[], options: RouteVisibilityOptions): Reservation[] {
- const { visibleConnectionIds, showTransitRoutes, selectedDayId, days } = options
+ const { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay } = options
const set = new Set(visibleConnectionIds || [])
return reservations.filter(r =>
- (r.type === 'transit' && showTransitRoutes && transitRunsOnDay(r, selectedDayId, days || [])) ||
- set.has(r.id)
+ (r.type === 'transit' && showTransitRoutes && runsOnDay(r, selectedDayId, days || [])) ||
+ (set.has(r.id) && (!scopeConnectionsToDay || runsOnDay(r, selectedDayId, days || [])))
)
}
diff --git a/client/tests/unit/mobile/settings/MSettingsGeneral.test.tsx b/client/tests/unit/mobile/settings/MSettingsGeneral.test.tsx
index d1a7a99ed..e7aa9421d 100644
--- a/client/tests/unit/mobile/settings/MSettingsGeneral.test.tsx
+++ b/client/tests/unit/mobile/settings/MSettingsGeneral.test.tsx
@@ -190,4 +190,31 @@ describe('MSettingsGeneral', () => {
expect(updateSetting).toHaveBeenCalledWith('start_trip_tab', 'finanzplan');
});
+
+ // #2456: the account-wide default behind every trip's booking routes. Desktop has it
+ // under Settings -> Display; a phone had no way to reach it, so a booking route on the
+ // phone map only ever came from switching each booking on by hand.
+ it('FE-MOB-SET-015: the travel card carries the always-show-booking-routes default, off until set', async () => {
+ const user = userEvent.setup();
+ const updateSetting = vi.fn().mockResolvedValue(undefined);
+ seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), updateSetting });
+ render();
+
+ const toggle = screen.getByRole('switch', { name: 'Always show booking routes' });
+ expect(toggle).toHaveAttribute('aria-checked', 'false');
+ await user.click(toggle);
+ expect(updateSetting).toHaveBeenCalledWith('map_always_show_routes', true);
+ });
+
+ it('FE-MOB-SET-016: the always-show-booking-routes toggle reflects a stored true and switches it off', async () => {
+ const user = userEvent.setup();
+ const updateSetting = vi.fn().mockResolvedValue(undefined);
+ seedStore(useSettingsStore, { settings: buildSettings({ language: 'en', map_always_show_routes: true }), updateSetting });
+ render();
+
+ const toggle = screen.getByRole('switch', { name: 'Always show booking routes' });
+ expect(toggle).toHaveAttribute('aria-checked', 'true');
+ await user.click(toggle);
+ expect(updateSetting).toHaveBeenCalledWith('map_always_show_routes', false);
+ });
});
diff --git a/client/tests/unit/mobile/trip/MMapArea.test.tsx b/client/tests/unit/mobile/trip/MMapArea.test.tsx
index 6a1c99080..e13c4e2f5 100644
--- a/client/tests/unit/mobile/trip/MMapArea.test.tsx
+++ b/client/tests/unit/mobile/trip/MMapArea.test.tsx
@@ -9,12 +9,13 @@ import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../../../sr
import { useSettingsStore } from '../../../../src/store/settingsStore'
import { useTripStore } from '../../../../src/store/tripStore'
import { seedStore } from '../../../helpers/store'
-import type { Place } from '../../../../src/types'
+import type { Day, Place, Reservation } from '../../../../src/types'
+import { visibleRouteReservations, type RouteVisibilityOptions } from '../../../../src/utils/reservationRoutes'
import type { AlternativeOverlay } from '../../../../src/components/Roadtrip/alternativeOverlays'
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
-// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-037
+// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-042
//
// The stage's pins come out of the trip store rather than the planner's map list, so the
// stage fixtures seed the store and leave `mapPlaces` to stand for what the plan tab shows.
@@ -791,10 +792,99 @@ describe('MMapArea', () => {
const { rerender } = render()
expect(mocks.props.visibleConnectionIds).toEqual([70])
+ // A seamed ride joins the road whatever day it was booked on, so the stage does not
+ // hold bookings to the selected day the way the plan map does.
+ expect(mocks.props.scopeConnectionsToDay).toBe(false)
// The plan tab keeps the traveller's own toggles, as the desktop does off road trip mode.
rerender()
expect(mocks.props.visibleConnectionIds).toBe(planner.visibleConnections)
expect(mocks.props.visibleConnectionIds).toEqual([])
+ expect(mocks.props.scopeConnectionsToDay).toBe(true)
+ })
+})
+
+/**
+ * #2456: the phone map is one day's picture. A day chip frames the day, draws its route
+ * and drops the other days' pins (MTripShell.selectDayOnMap), and the automated
+ * transports ride the day since #2019. A booking switched on through its own toggle
+ * ("On map") stayed on every day's map, though, because the per-booking branch of
+ * visibleRouteReservations is trip-wide.
+ *
+ * The renderer is mocked here, so what it WOULD draw is worked out the way both engines
+ * work it out: MapView and MapViewGL hand their own props to visibleRouteReservations.
+ * The whole prop bag goes in, so a day-scoping option added to the renderers under the
+ * util's own name is picked up as well as a filtered id list is.
+ */
+describe('MMapArea booking routes on the day-scoped plan map (#2456)', () => {
+ const DAYS = [
+ { id: 10, trip_id: 1, day_number: 1, date: '2026-05-01' },
+ { id: 11, trip_id: 1, day_number: 2, date: '2026-05-02' },
+ { id: 12, trip_id: 1, day_number: 3, date: '2026-05-03' },
+ ] as Day[]
+
+ function flight(over: Partial = {}): Reservation {
+ return {
+ id: 7, trip_id: 1, type: 'flight', title: 'FRA → LIS', status: 'confirmed',
+ day_id: 10, end_day_id: 10,
+ reservation_time: '2026-05-01T09:00', reservation_end_time: '2026-05-01T11:00',
+ endpoints: [
+ { role: 'from', sequence: 0, name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.57, timezone: null, local_time: null, local_date: null },
+ { role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS', lat: 38.77, lng: -9.13, timezone: null, local_time: null, local_date: null },
+ ],
+ ...over,
+ } as unknown as Reservation
+ }
+
+ /** The plan tab's map in front, a booking switched on, and the given day on the chips. */
+ function renderPlanMap(selectedDayId: number | null, booking: Reservation = flight()) {
+ const planner = buildPlanner({
+ days: DAYS,
+ selectedDayId,
+ reservations: [booking],
+ visibleConnections: [booking.id],
+ transitRoutesShown: false,
+ } as unknown as Partial)
+ render()
+ }
+
+ /** The booking ids either renderer would draw a route for, from the props it was handed. */
+ const drawnBookingIds = () => visibleRouteReservations(
+ (mocks.props.reservations as Reservation[]) ?? [],
+ mocks.props as unknown as RouteVisibilityOptions,
+ ).map(r => r.id)
+
+ it('FE-MOB-MAPAREA-038: a booking switched on for day 1 is not drawn on the map of day 3', () => {
+ renderPlanMap(12)
+
+ expect(drawnBookingIds()).toEqual([])
+ })
+
+ it('FE-MOB-MAPAREA-039: the booking stays drawn on the day it runs on', () => {
+ renderPlanMap(10)
+
+ expect(drawnBookingIds()).toEqual([7])
+ })
+
+ it('FE-MOB-MAPAREA-040: an overnight booking is drawn on both of its days and on no other', () => {
+ const overnight = flight({ day_id: 10, end_day_id: 11 })
+
+ renderPlanMap(11, overnight)
+ expect(drawnBookingIds()).toEqual([7])
+
+ renderPlanMap(12, overnight)
+ expect(drawnBookingIds()).toEqual([])
+ })
+
+ it('FE-MOB-MAPAREA-041: the all days view keeps every switched-on booking', () => {
+ renderPlanMap(null)
+
+ expect(drawnBookingIds()).toEqual([7])
+ })
+
+ it('FE-MOB-MAPAREA-042: a booking bound to no day keeps drawing on every day', () => {
+ renderPlanMap(12, flight({ day_id: null, end_day_id: null }))
+
+ expect(drawnBookingIds()).toEqual([7])
})
})
diff --git a/client/tests/unit/mobile/trip/MTransportSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportSheet.test.tsx
index a5488a3a8..46fd99fc3 100644
--- a/client/tests/unit/mobile/trip/MTransportSheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MTransportSheet.test.tsx
@@ -7,7 +7,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
-// FE-MOB-TRSH-001 to FE-MOB-TRSH-024
+// FE-MOB-TRSH-001 to FE-MOB-TRSH-030
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
@@ -301,4 +301,110 @@ describe('MTransportSheet', () => {
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
})
+
+ /**
+ * #2456: the phone map is one day's picture, so "On map" has to land on a day the
+ * booking runs on. The Transports tab lists the whole trip, and the day the chips hold
+ * can be any other day: switched on from there, the route went to a map that frames,
+ * and (once booking routes follow the day) draws, a different day.
+ *
+ * The booking's day goes in after the view switch: entering the map re-selects the day
+ * the shell still holds (MTripShell.toggleView), so it has to come last to win.
+ */
+ describe('On map lands on a day the booking runs on (#2456)', () => {
+ const DAYS = [
+ { id: 3, trip_id: 1, day_number: 1, date: '2026-05-01' },
+ { id: 4, trip_id: 1, day_number: 2, date: '2026-05-02' },
+ { id: 5, trip_id: 1, day_number: 3, date: '2026-05-03' },
+ ]
+
+ it("FE-MOB-TRSH-025: from another day, switches the map to the booking's own day", () => {
+ const planner = makePlanner({ days: DAYS, selectedDayId: 5 })
+ const shell = makeShell({ trTab: 'transports', view: 'plan' })
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'On map' }))
+
+ expect(planner.toggleConnection).toHaveBeenCalledWith(7)
+ expect(planner.handleSelectDay).toHaveBeenCalledWith(3, false)
+ expect(planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([3]))
+ const viewSwitch = vi.mocked(shell.toggleView).mock.invocationCallOrder[0]
+ const daySwitches = vi.mocked(planner.handleSelectDay).mock.invocationCallOrder
+ expect(daySwitches[daySwitches.length - 1]).toBeGreaterThan(viewSwitch)
+ })
+
+ it("FE-MOB-TRSH-026: with the map already in front, still moves it to the booking's day", () => {
+ const planner = makePlanner({ days: DAYS, selectedDayId: 5 })
+ const shell = makeShell({ trTab: 'transports', view: 'map' })
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'On map' }))
+
+ expect(shell.toggleView).not.toHaveBeenCalled()
+ expect(planner.handleSelectDay).toHaveBeenCalledWith(3, false)
+ expect(planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([3]))
+ })
+
+ it("FE-MOB-TRSH-027: keeps the selected day while it is one of the booking's days", () => {
+ // Overnight: departs on day 3, lands on day 4, which the chips hold.
+ const overnight = { ...FLIGHT, end_day_id: 4 } as unknown as Reservation
+ const planner = makePlanner({ days: DAYS, selectedDayId: 4, reservations: [overnight] })
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'On map' }))
+
+ expect(planner.toggleConnection).toHaveBeenCalledWith(7)
+ expect(planner.handleSelectDay).not.toHaveBeenCalled()
+ expect(planner.setExpandedDayIds).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-TRSH-028: leaves the all days view and a booking bound to no day alone', () => {
+ const unbound = { ...FLIGHT, day_id: null, end_day_id: null } as unknown as Reservation
+ const allDays = makePlanner({ days: DAYS, selectedDayId: null })
+ const { unmount } = render()
+ fireEvent.click(screen.getByRole('button', { name: 'On map' }))
+ expect(allDays.handleSelectDay).not.toHaveBeenCalled()
+ unmount()
+
+ const noDay = makePlanner({ days: DAYS, selectedDayId: 5, reservations: [unbound] })
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'On map' }))
+ expect(noDay.toggleConnection).toHaveBeenCalledWith(7)
+ expect(noDay.handleSelectDay).not.toHaveBeenCalled()
+ })
+
+ // A booking already switched on is still left off another day's map, which is also
+ // where "Always show booking routes" leaves every booking. The button follows the map
+ // there, and a tap takes it to the booking rather than switching the route off unseen.
+ it('FE-MOB-TRSH-029: a booking switched on but not drawn on this day reads off and a tap moves to its day', () => {
+ const planner = makePlanner({ days: DAYS, selectedDayId: 5, visibleConnections: [7] })
+ const shell = makeShell({ trTab: 'transports', view: 'plan' })
+ render()
+
+ const btn = screen.getByRole('button', { name: 'On map' })
+ expect(btn).toHaveAttribute('aria-pressed', 'false')
+ fireEvent.click(btn)
+
+ expect(planner.toggleConnection).not.toHaveBeenCalled()
+ expect(shell.closeSheet).toHaveBeenCalledTimes(1)
+ expect(shell.setTrTab).toHaveBeenCalledWith('plan')
+ expect(planner.handleSelectDay).toHaveBeenCalledWith(3, false)
+ expect(planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([3]))
+ })
+
+ it("FE-MOB-TRSH-030: on one of the booking's days a switched-on route reads on and a tap hides it in place", () => {
+ const overnight = { ...FLIGHT, end_day_id: 4 } as unknown as Reservation
+ const planner = makePlanner({ days: DAYS, selectedDayId: 4, reservations: [overnight], visibleConnections: [7] })
+ const shell = makeShell({ trTab: 'plan', view: 'map' })
+ render()
+
+ const btn = screen.getByRole('button', { name: 'On map' })
+ expect(btn).toHaveAttribute('aria-pressed', 'true')
+ fireEvent.click(btn)
+
+ expect(planner.toggleConnection).toHaveBeenCalledWith(7)
+ expect(shell.closeSheet).not.toHaveBeenCalled()
+ expect(planner.handleSelectDay).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/wiki/Map-Features.md b/wiki/Map-Features.md
index a78ea983b..d4e6f29c5 100644
--- a/wiki/Map-Features.md
+++ b/wiki/Map-Features.md
@@ -93,6 +93,8 @@ Flights, trains, cars, and cruises can be drawn as overlays between their endpoi
- **Show all / hide all** — a route icon button in the day-plan toolbar (next to Undo/Reorder) flips the whole trip between showing every routable booking and showing none. It is a clean slate rather than a layer on top: whatever you had set with the per-booking **Route** icons is discarded, so pressing it twice leaves you with all routes on or all off, not back where you started. (Automated public-transit journeys have a second gate of their own — see the transit bullet.)
- **Always show booking routes** (Settings → General → Travel & map) — an account-wide default that shows every booking's route automatically on any trip you haven't touched before. It sets the *default* only — a trip where you've already used the per-booking toggle or the bulk button keeps its own choice even if you change this setting afterwards.
+On a phone the plan map shows one day at a time, so a booking you have switched on is drawn there only on the days it runs on, the arrival day of an overnight journey included, and in the all-days view. The **On map** button in a booking's detail sheet reads as on only where the route is drawn; from any other day it takes the map to the booking's own day instead of hiding the route. The desktop map and the road trip stage keep drawing a switched-on booking on every day.
+
> **Tip:** Whether endpoint text labels appear on the endpoint markers is your own choice — the **Booking route labels** setting in Settings → General → Travel & map (`map_booking_labels`). It is off by default; with it off, the endpoint markers show only the transport icon.
## Plugin map markers