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