@@ -374,10 +375,10 @@ function DayWeather({ weather, loading, isFahrenheit, placeName }: { weather: We
const OPENS_BOOKING = 'w-full cursor-pointer text-start transition-shadow hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--text-primary)]'
/** The bookings of the day, hotels aside (they have their own block). With `onOpen` each row opens its booking. */
-function DayReservations({ day, assignments, reservations, is12h, onOpen }: { day: Day; assignments: AssignmentsMap; reservations: Reservation[]; is12h: boolean; onOpen?: (r: Reservation) => void }) {
+function DayReservations({ day, days, assignments, reservations, is12h, onOpen }: { day: Day; days: Day[]; assignments: AssignmentsMap; reservations: Reservation[]; is12h: boolean; onOpen?: (r: Reservation) => void }) {
const { t } = useTranslation()
const dayAssignments = assignments[String(day.id)] || []
- const dayReservations = dayBookings(day.id, dayAssignments, reservations)
+ const dayReservations = dayBookings(day.id, dayAssignments, reservations, pinnedAwayIn(assignments, days))
if (dayReservations.length === 0) return null
return (
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index cd2c502cd..8d9a22e98 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -45,7 +45,7 @@ import { getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, sho
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay,
- getAssignmentReservations, timedSlot, storedRideSlot, seatTimedDrop,
+ getAssignmentReservations, timedSlot, storedRideSlot, seatTimedDrop, stopDayIds,
type MergedItem,
} from '../../utils/dayMerge'
import { withinDriveRange } from '@trek/shared/roadtrip'
@@ -434,8 +434,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
return { day_id: startId, end_day_id: targetDayId }
}
+ // A booking pinned to a stop on another day shows at that stop only (#2604).
+ const stopDays = useMemo(() => stopDayIds(assignments), [assignments])
const getTransportForDay = (dayId: number) =>
- _getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
+ _getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days, stopDays })
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
const getActiveRentalsForDay = (dayId: number) => {
diff --git a/client/src/components/Planner/dayDetailModel.test.ts b/client/src/components/Planner/dayDetailModel.test.ts
index acd07f3d0..8a04e078f 100644
--- a/client/src/components/Planner/dayDetailModel.test.ts
+++ b/client/src/components/Planner/dayDetailModel.test.ts
@@ -1,4 +1,4 @@
-// FE-PLANNER-DAYDETAILMODEL-001 to -005: the day detail derivations the desktop day
+// FE-PLANNER-DAYDETAILMODEL-001 to -006: the day detail derivations the desktop day
// panel and the phone day sheet share.
import { describe, expect, it } from 'vitest';
@@ -22,6 +22,13 @@ describe('dayBookings', () => {
expect(dayBookings(4, [{ id: 11 }], [moved]).map((r) => r.id)).toEqual([5]);
expect(dayBookings(6, [{ id: 11 }], [moved])).toEqual([]);
});
+
+ it('FE-PLANNER-DAYDETAILMODEL-006: a booking whose stop moved to another day is listed at the stop only (#2604)', () => {
+ const dinner = buildReservation({ id: 5, type: 'restaurant', assignment_id: 77, day_id: 4 });
+ const away = (r: { assignment_id?: number | null }) => r.assignment_id === 77;
+ expect(dayBookings(4, [{ id: 11 }], [dinner], away)).toEqual([]);
+ expect(dayBookings(6, [{ id: 77 }], [dinner], away).map((r) => r.id)).toEqual([5]);
+ });
});
describe('toDisplayTemp', () => {
diff --git a/client/src/components/Planner/dayDetailModel.ts b/client/src/components/Planner/dayDetailModel.ts
index f8536b3a3..da9134248 100644
--- a/client/src/components/Planner/dayDetailModel.ts
+++ b/client/src/components/Planner/dayDetailModel.ts
@@ -4,17 +4,19 @@ type Translate = (key: string, params?: Record) => stri
/**
* The bookings of a day, hotels aside (they have their own block): the ones hanging
- * on one of the day's stops and the ones booked on the day itself.
+ * on one of the day's stops and the ones booked on the day itself. `pinnedAway` leaves
+ * a booking whose stop sits on another day to that stop (#2604).
*/
export function dayBookings>(
dayId: number,
dayAssignments: { id: number }[],
- reservations: R[]
+ reservations: R[],
+ pinnedAway?: (r: Pick) => boolean
): R[] {
return reservations.filter((r) => {
if (r.type === 'hotel') return false;
if (r.assignment_id && dayAssignments.some((a) => a.id === r.assignment_id)) return true;
- return r.day_id === dayId;
+ return r.day_id === dayId && !pinnedAway?.(r);
});
}
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index c971b5bef..d16dd52cc 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -18,7 +18,7 @@ import { ARRIVING_DRIVE, openOn, type LegAlternatives, type RailDrive } from './
import { FS } from './typeScale'
import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop'
-import { dayBookings } from './stopBookings'
+import { dayBookings, stopDaysOf } from './stopBookings'
import type { Reservation } from '../../types'
import type { StayDraft } from './RoadtripStayModal'
import { missedLeaveOf, stayDraftOf } from './stayReading'
@@ -138,7 +138,7 @@ const RAIL_NARROW_PX = 330
* move, a stay edit or a refuel offer still names the day the server knows it by. See
* `nightSpill.ts`.
*/
-function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, dayOrder, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint, narrow }: {
+function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, dayOrder, stopDays, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint, narrow }: {
onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
/** The rail is pulled too narrow for every badge; the stop count gives way. */
narrow?: boolean
@@ -159,6 +159,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
reservations?: Reservation[]
/** A day's place in the trip by its id, so a booking spanning days is seen on the days between. */
dayOrder?: (dayId: number) => number | null
+ stopDays?: ReadonlyMap
onEditStay?: RoadtripSidebarProps['onEditStay']
onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
onSetStopFill?: RoadtripSidebarProps['onSetStopFill']
@@ -175,7 +176,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
// Which disc the picker hangs under, and for which stop. One at a time: two open
// popovers over the same rail is two answers to one question.
const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
- const bookings = useMemo(() => dayBookings(day, reservations ?? [], dayOrder), [day, reservations, dayOrder])
+ const bookings = useMemo(() => dayBookings(day, reservations ?? [], dayOrder, stopDays), [day, reservations, dayOrder, stopDays])
const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const { t } = useTranslation()
const last = day.stops.length - 1
@@ -622,6 +623,8 @@ export default function RoadtripSidebar({
const numbers = new Map([...routes.days, ...routes.quietDays].map(d => [d.dayId, d.dayNumber]))
return (dayId: number): number | null => numbers.get(dayId) ?? null
}, [routes.days, routes.quietDays])
+ // A booking pinned to a stop on another day is listed at that stop only (#2604).
+ const stopDays = useMemo(() => stopDaysOf(routes.days), [routes.days])
const undated = useMemo(() => undatedRides(reservations ?? []), [reservations])
// Nothing to total up, so nothing pretends to: no "0 km" standing above "No route yet".
@@ -677,6 +680,7 @@ export default function RoadtripSidebar({
canEditBookings={canEditBookings}
reservations={reservations}
dayOrder={dayOrder}
+ stopDays={stopDays}
onEditStay={onEditStay}
onSetStopKind={onSetStopKind}
onSetStopFill={onSetStopFill}
diff --git a/client/src/components/Roadtrip/stopBookings.test.ts b/client/src/components/Roadtrip/stopBookings.test.ts
index a273eb067..bdfc36484 100644
--- a/client/src/components/Roadtrip/stopBookings.test.ts
+++ b/client/src/components/Roadtrip/stopBookings.test.ts
@@ -17,6 +17,15 @@ const day = {
}
describe('dayBookings (#2428)', () => {
+ it('FE-STOPBOOKINGS-007: a booking pinned to a stop on another day is left to that stop, not listed loose here (#2604)', () => {
+ const order = (dayId: number) => dayId
+ const stopDays = new Map([[11, 1], [77, 3]])
+ const dinner = booking({ id: 9, title: 'Dinner', day_id: 1, assignment_id: 77 })
+ expect(dayBookings(day, [dinner], order, stopDays).loose).toEqual([])
+ // Without the trip's stops it stays where it was.
+ expect(dayBookings(day, [dinner], order).loose.map(r => r.title)).toEqual(['Dinner'])
+ })
+
it('FE-STOPBOOKINGS-001: a booking hangs under the stop it is pinned to, or under the stop at its place on its day, in clock order', () => {
const { atStop, loose } = dayBookings(day, [
booking({ id: 3, title: 'Late', reservation_time: '2026-10-05T20:00', assignment_id: 11 }),
diff --git a/client/src/components/Roadtrip/stopBookings.ts b/client/src/components/Roadtrip/stopBookings.ts
index f0c7218ee..e2708a923 100644
--- a/client/src/components/Roadtrip/stopBookings.ts
+++ b/client/src/components/Roadtrip/stopBookings.ts
@@ -1,7 +1,7 @@
import { Ticket, type LucideIcon } from 'lucide-react'
import { isCarrierType, isRentalType, isStoredStop } from '@trek/shared/roadtrip'
import { RES_ICONS } from '../Planner/DayPlanSidebar.constants'
-import { hidesOnMiddleDay } from '../../utils/dayMerge'
+import { hidesOnMiddleDay, pinnedAway } from '../../utils/dayMerge'
import { splitReservationDateTime } from '../../utils/formatters'
import type { Reservation } from '../../types'
import type { RoadtripStop } from '@trek/shared/roadtrip'
@@ -64,6 +64,8 @@ export function dayBookings(
reservations: readonly Reservation[],
/** A day's position in the trip by its id, so a booking spanning days is on the days between too. */
dayOrder?: (dayId: number) => number | null | undefined,
+ /** The day each stop of the trip sits on, so a booking pinned to one elsewhere stays there. */
+ stopDays?: ReadonlyMap,
): DayBookings {
if (!reservations.length) return EMPTY
const atStop = new Map()
@@ -77,6 +79,7 @@ export function dayBookings(
// hotel's own stop, or under the day.
const real = (i: number): boolean => isStoredStop(stops[i]!)
let index = r.assignment_id ? stops.findIndex((s, i) => real(i) && s.assignmentId === r.assignment_id) : -1
+ if (index < 0 && dayOrder && pinnedAway(r, stopDays, dayOrder)) continue
const onDay = spansDay(r, day.dayId, dayOrder)
if (index < 0 && onDay && r.place_id) index = stops.findIndex((s, i) => real(i) && s.placeId === r.place_id)
if (index >= 0) {
@@ -92,6 +95,15 @@ export function dayBookings(
return atStop.size || loose.length ? { atStop, loose } : EMPTY
}
+/** The day each stored stop of the drive sits on, by its assignment id, for {@link dayBookings}. */
+export function stopDaysOf(
+ days: readonly { dayId: number; stops: readonly Pick[] }[],
+): Map {
+ const out = new Map()
+ for (const d of days) for (const s of d.stops) if (isStoredStop(s)) out.set(s.assignmentId, d.dayId)
+ return out
+}
+
/** The booking panel's icon for the type, so a table reads as a table here too. */
export function bookingIcon(type: string): LucideIcon {
return (RES_ICONS as Record)[type] ?? Ticket
diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
index e1efd8203..9d404f119 100644
--- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
+++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
@@ -7,7 +7,7 @@ import { toDisplayTemp } from '../../../../components/Planner/dayDetailModel'
import { getDayBookendHotels } from '../../../../utils/dayOrder'
import { dayWeatherAnchor } from '../../../../utils/dayWeather'
import { metadataWithLegPositions, planMergedOrder, withTransportPositions } from '../../../../utils/mergedOrder'
-import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay, isCarrierTransport } from '../../../../utils/dayMerge'
+import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay, isCarrierTransport, stopDayIds } from '../../../../utils/dayMerge'
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, optimizeDayOrder, type DayCarrier } from '../../../../components/Planner/dayRoute'
import { buildTransitLeg, buildTransitNameIndex, type TransitLeg } from '../../../../components/Planner/transitLeg'
import {
@@ -45,6 +45,9 @@ export function useMPlanTimeline(planner: TripPlanner) {
return [...(assignments[String(day.id)] || [])].sort((a, b) => a.order_index - b.order_index)
}, [assignments, day])
+ // A booking pinned to a stop on another day shows at that stop only (#2604).
+ const stopDays = useMemo(() => stopDayIds(assignments), [assignments])
+
const merged = useMemo(() => {
if (!day) return []
const dayTransports = getTransportForDay({
@@ -52,6 +55,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
dayId: day.id,
dayAssignmentIds: dayAssignments.map(a => a.id),
days,
+ stopDays,
})
return getMergedItems({
dayAssignments,
@@ -59,7 +63,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
dayTransports,
dayId: day.id,
})
- }, [day, dayAssignments, dayNotes, reservations, days])
+ }, [day, dayAssignments, dayNotes, reservations, days, stopDays])
// A carrier with a located endpoint today — the exported deep links need it so
// their no-time hotel bookends match the drawn route (#2157).
diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx
index 0c3a33e3b..0c9f2477e 100644
--- a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx
+++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx
@@ -19,6 +19,7 @@ import type { MTripTabPanelProps } from '../MTripShell'
import { arrivingReroutable, legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import { ARRIVING_DRIVE, type RailDrive } from '../../../../components/Roadtrip/useRouteAlternatives'
import { dayBookings } from '../../../../components/Roadtrip/stopBookings'
+import { stopDayIds } from '../../../../utils/dayMerge'
import { bookendBooking } from '../../../../components/Roadtrip/nightBookend'
import { getDayOrder } from '../../../../utils/dayOrder'
import { undatedRides } from '@trek/shared/roadtrip'
@@ -99,12 +100,14 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
// by the stop's assignment because the rows carry no index. Opened the way the place
// sheet opens its linked bookings: a transport in its sheet, anything else in its
// editor.
+ // A booking pinned to a stop on another day is listed at that stop only (#2604).
+ const stopDays = useMemo(() => stopDayIds(planner.assignments), [planner.assignments])
const bookings = useMemo(() => {
- const byIndex = stage ? dayBookings(stage, planner.reservations, dayOrder) : null
+ const byIndex = stage ? dayBookings(stage, planner.reservations, dayOrder, stopDays) : null
const atStop = new Map()
if (stage && byIndex) for (const [i, list] of byIndex.atStop) atStop.set(stage.stops[i]!.assignmentId, list)
return { atStop, loose: byIndex?.loose ?? [] }
- }, [stage, planner.reservations, dayOrder])
+ }, [stage, planner.reservations, dayOrder, stopDays])
// The rides on no day, which the drive leaves out: listed above the stage, whichever it is.
const undated = useMemo(() => undatedRides(planner.reservations), [planner.reservations])
// A chip that may not be opened is not a button in the first place (`bookingOpens`),
diff --git a/client/src/mobile/screens/trip/sheets/MDaySheet.tsx b/client/src/mobile/screens/trip/sheets/MDaySheet.tsx
index a637e37bd..3a1c06acd 100644
--- a/client/src/mobile/screens/trip/sheets/MDaySheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MDaySheet.tsx
@@ -12,7 +12,7 @@ import { useDayForecast, useDayRename } from '../../../../components/Planner/use
import { dayBookings, stayDayLabel, toDisplayTemp } from '../../../../components/Planner/dayDetailModel'
import { RES_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants'
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
-import { getTransportForDay, hasCarrierEndpointOnDay } from '../../../../utils/dayMerge'
+import { getTransportForDay, hasCarrierEndpointOnDay, pinnedAwayIn, stopDayIds } from '../../../../utils/dayMerge'
import { splitReservationDateTime } from '../../../../utils/formatters'
import { dayCoMapsUrl, dayGoogleMapsUrl, optimizeDayOrder } from '../../../../components/Planner/dayRoute'
import GoogleMapsIcon from '../../../../components/shared/GoogleMapsIcon'
@@ -84,8 +84,8 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
const dayReservations = useMemo(() => {
if (!day) return []
- return dayBookings(day.id, dayAssignments, planner.reservations)
- }, [day, planner.reservations, dayAssignments])
+ return dayBookings(day.id, dayAssignments, planner.reservations, pinnedAwayIn(planner.assignments, planner.days))
+ }, [day, planner.reservations, dayAssignments, planner.assignments, planner.days])
const dayAccommodations = useMemo(() => {
if (!day) return []
@@ -160,8 +160,9 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
dayId: day.id,
dayAssignmentIds: dayAssignments.map(a => a.id),
days: planner.days,
+ stopDays: stopDayIds(planner.assignments),
}).some(r => hasCarrierEndpointOnDay(r, day.id))
- }, [day, planner.reservations, planner.days, dayAssignments])
+ }, [day, planner.reservations, planner.days, dayAssignments, planner.assignments])
// Google-Maps export of the day's stops, hotel-bookended like the drawn route (#1372/#1465).
const openInGoogleMaps = () => {
diff --git a/client/src/utils/dayMerge.test.ts b/client/src/utils/dayMerge.test.ts
index e832594a6..5f99670af 100644
--- a/client/src/utils/dayMerge.test.ts
+++ b/client/src/utils/dayMerge.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, getPlaceBookings, getMergedItems, nightRowsOf, rideSeatKey, seatTimedDrop, stayStartingOn, storedRideSlot, timedSlot, type MergedItem } from './dayMerge'
+import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, pinnedAway, stopDayIds, getPlaceBookings, getMergedItems, nightRowsOf, rideSeatKey, seatTimedDrop, stayStartingOn, storedRideSlot, timedSlot, type MergedItem } from './dayMerge'
import { planMergedOrder, withTransportPositions } from './mergedOrder'
describe('parseTimeToMinutes', () => {
@@ -200,6 +200,48 @@ describe('getTransportForDay', () => {
})
})
+describe('a booking pinned to a stop on another day (#2604)', () => {
+ const days = [
+ { id: 1, day_number: 1 },
+ { id: 2, day_number: 2 },
+ { id: 3, day_number: 3 },
+ { id: 4, day_number: 4 },
+ ]
+ const order = (id: number) => days.find(d => d.id === id)?.day_number ?? null
+ const stopDays = stopDayIds({ '1': [{ id: 41 }], '3': [{ id: 43 }, { id: 44 }], '4': undefined })
+
+ it('maps every stop to its day', () => {
+ expect([...stopDays.entries()]).toEqual([[41, 1], [43, 3], [44, 3]])
+ })
+
+ it("a stop outside the booking's own days wins, a stop on one of them or a gone stop does not", () => {
+ expect(pinnedAway({ assignment_id: 43, day_id: 1 }, stopDays, order)).toBe(true)
+ expect(pinnedAway({ assignment_id: 41, day_id: 1 }, stopDays, order)).toBe(false)
+ // A rental picked up at a stop still runs across the rest of its days.
+ expect(pinnedAway({ assignment_id: 43, day_id: 2, end_day_id: 4 }, stopDays, order)).toBe(false)
+ expect(pinnedAway({ assignment_id: 99, day_id: 1 }, stopDays, order)).toBe(false)
+ expect(pinnedAway({ assignment_id: null, day_id: 1 }, stopDays, order)).toBe(false)
+ expect(pinnedAway({ assignment_id: 43, day_id: null }, stopDays, order)).toBe(false)
+ expect(pinnedAway({ assignment_id: 43, day_id: 1 }, undefined, order)).toBe(false)
+ })
+
+ it("the timeline of the booking's own day leaves it to the stop, so it is listed once", () => {
+ const dinner = { id: 10, type: 'restaurant', day_id: 1, assignment_id: 43 }
+ const reservations = [dinner]
+ expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [41], days, stopDays })).toHaveLength(0)
+ expect(getTransportForDay({ reservations, dayId: 3, dayAssignmentIds: [43, 44], days, stopDays })).toHaveLength(0)
+ expect(getAssignmentReservations(reservations, 43)).toEqual([dinner])
+ // Without the stops of the trip the old rule stands.
+ expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [41], days })).toHaveLength(1)
+ })
+
+ it('a multi-day booking pinned to a stop on one of its days keeps its other days', () => {
+ const rental = { id: 11, type: 'event', day_id: 2, end_day_id: 4, assignment_id: 43 }
+ expect(getTransportForDay({ reservations: [rental], dayId: 2, dayAssignmentIds: [], days, stopDays })).toHaveLength(1)
+ expect(getTransportForDay({ reservations: [rental], dayId: 3, dayAssignmentIds: [43, 44], days, stopDays })).toHaveLength(0)
+ })
+})
+
describe('getPlaceBookings (#2363)', () => {
const reservations = [
{ id: 1, assignment_id: 42, reservation_time: '2025-06-01T10:00:00' },
diff --git a/client/src/utils/dayMerge.ts b/client/src/utils/dayMerge.ts
index 25e4dce3d..e94ef0014 100644
--- a/client/src/utils/dayMerge.ts
+++ b/client/src/utils/dayMerge.ts
@@ -204,14 +204,63 @@ export function expandFlightLegsForDay(
return out
}
-/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
+/** The day each stop of the trip sits on, by its assignment id, for {@link pinnedAway}. */
+export function stopDayIds(assignments: Record): Map {
+ const out = new Map()
+ for (const [dayId, list] of Object.entries(assignments)) {
+ for (const a of list || []) out.set(a.id, Number(dayId))
+ }
+ return out
+}
+
+/**
+ * Whether a booking pinned to a stop is shown at that stop and not on its own day.
+ * Such a booking has two days to go by, its own date and the stop's, and they part
+ * when the stop is moved to another day or the trip's dates shift while bookings keep
+ * theirs. The stop wins then, so the booking is listed once instead of on two days,
+ * and its date stays as it is (#2604). A stop on one of the booking's own days changes
+ * nothing: a rental picked up at a stop still runs across the rest of its days.
+ */
+export function pinnedAway(
+ r: { assignment_id?: number | null; day_id?: number | null; end_day_id?: number | null },
+ stopDays: ReadonlyMap | undefined,
+ dayOrder: (dayId: number) => number | null | undefined
+): boolean {
+ if (!r.assignment_id || !stopDays || r.day_id == null) return false
+ const stopDay = stopDays.get(r.assignment_id)
+ if (stopDay == null) return false
+ const at = dayOrder(stopDay)
+ const first = dayOrder(r.day_id)
+ const last = dayOrder(r.end_day_id ?? r.day_id)
+ if (at == null || first == null || last == null) return false
+ return at < Math.min(first, last) || at > Math.max(first, last)
+}
+
+/** {@link pinnedAway} over the whole trip: its stops from `assignments`, its days in plan order. */
+export function pinnedAwayIn(
+ assignments: Record,
+ days: Array<{ id: number; day_number?: number }>
+): (r: { assignment_id?: number | null; day_id?: number | null; end_day_id?: number | null }) => boolean {
+ const stopDays = stopDayIds(assignments)
+ const order = (id: number): number | null => {
+ const d = days.find(x => x.id === id)
+ return d ? (d.day_number ?? days.indexOf(d)) : null
+ }
+ return r => pinnedAway(r, stopDays, order)
+}
+
+/**
+ * Filter reservations that are active transports for the given day, excluding assignment-linked ones.
+ * With `stopDays` a booking pinned to a stop on another day is left to that stop as well.
+ */
export function getTransportForDay(opts: {
reservations: any[]
dayId: number
dayAssignmentIds: number[]
days: Array<{ id: number; day_number?: number; date?: string | null }>
+ stopDays?: ReadonlyMap
}): any[] {
- const { reservations, dayId, dayAssignmentIds, days } = opts
+ const { reservations, dayId, dayAssignmentIds, days, stopDays } = opts
const getDayOrder = (id: number): number => {
const d = days.find(x => x.id === id)
@@ -222,6 +271,7 @@ export function getTransportForDay(opts: {
return reservations.filter(r => {
if (r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
+ if (pinnedAway(r, stopDays, getDayOrder)) return false
const startDayId = r.day_id
const endDayId = r.end_day_id ?? startDayId
diff --git a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts
index 96b3b91d4..f71a29709 100644
--- a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts
+++ b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts
@@ -12,7 +12,7 @@ import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, renderHook, waitFor } from '../../../helpers/render'
-// FE-MOB-PLTL-001 to FE-MOB-PLTL-051
+// FE-MOB-PLTL-001 to FE-MOB-PLTL-052
// The connector calculation is its own hook with real OSRM calls — stubbed here so
// the timeline sees exactly the legs a test wants to match against.
@@ -699,4 +699,17 @@ describe('useMPlanTimeline', () => {
await act(async () => { result.current.setLegMode(11, 'driving') })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError'))
})
+
+ it('FE-MOB-PLTL-052: a booking pinned to a stop on another day shows at that stop only (#2604)', async () => {
+ const zoo = buildAssignment({ id: 31, day_id: 3, order_index: 0, place_id: 101, place: MUSEUM })
+ const tickets = buildReservation({ id: 80, type: 'event', title: 'Zoo tickets', day_id: 2, assignment_id: 31 })
+ const assignments = { '2': [A_MUSEUM], '3': [zoo] }
+ const ownDay = await renderTimeline(makePlanner({ assignments, reservations: [tickets] }))
+ expect(ownDay.result.current.rows.map(r => r.key)).toEqual(['pl-11'])
+ ownDay.unmount()
+
+ const stopDay = await renderTimeline(makePlanner({ assignments, reservations: [tickets], selectedDayId: 3 }))
+ const row = stopDay.result.current.rows.find(r => r.key === 'pl-31')
+ expect(row && row.kind === 'place' ? row.linkedReservations.map(r => r.id) : []).toEqual([80])
+ })
})