diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx index 4c06c9185..2cc8e309c 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx @@ -1435,3 +1435,114 @@ describe('RoadtripSidebar with the drive in from the day before (#2461)', () => }) }) }) + +describe('RoadtripSidebar with a booked night at the edge of the day', () => { + const bookend = (phase: 'morning' | 'evening', over: Partial> = {}, at: Partial = {}) => + stop({ + assignmentId: phase === 'morning' ? -6_000_000_002 : -6_000_000_003, + name: 'Hotel Alpenblick', + placeId: 900, + lat: 47.2, + lng: 11.4, + stopType: 'hotel', + dwellMinutes: 0, + bookend: { phase, accommodationId: 5, reservationId: 41, checkingOut: false, checkingIn: false, checkOut: null, ...over }, + ...at, + }) + /** Out of the hotel on its check-out morning, two places, and into the night's hotel. */ + const loop = (over: Partial = {}) => { + const stops = [ + bookend('morning', { checkingOut: true, checkOut: '10:00' }, { ownerIndex: 0 }), + stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 }), + stop({ assignmentId: 2, name: 'Falls', ownerIndex: 1 }), + bookend('evening', {}, { ownerIndex: 2 }), + ] + return day({ + stops, + legs: [leg(), leg(), leg()], + schedule: { + entries: ['08:40', '09:40', '10:40', '11:40'].map(arrival => ({ arrival, departure: arrival, anchored: false, dayOffset: 0 })), + warnings: [], + }, + ...over, + }) + } + + it('FE-ROADTRIP-SIDEBAR-064: names the hotel it sets out from and the one it ends at, and counts neither', () => { + wrap() + expect(screen.getByText('Check-out · Hotel Alpenblick')).toBeInTheDocument() + expect(screen.getByText('until 10:00')).toBeInTheDocument() + expect(screen.getByText('Back to Hotel Alpenblick')).toBeInTheDocument() + expect(screen.getByText('2 stops')).toBeInTheDocument() + // The places are one and two; the hotel wears no number. + expect(screen.getByText('Lookout').closest('button')).toHaveTextContent('1') + expect(screen.getByText('11:40')).toBeInTheDocument() + }) + + it('FE-ROADTRIP-SIDEBAR-065: the hotel rows are neither dragged nor dropped on, and carry no stay, kind or fill control', () => { + const { container } = wrap( + , + ) + expect(container.querySelectorAll('li[draggable="true"]')).toHaveLength(2) + const hotelRow = screen.getByText('Back to Hotel Alpenblick').closest('li')! + expect(hotelRow).not.toHaveAttribute('draggable') + // The row is one button, with nothing inside it to press. + expect(within(hotelRow).getAllByRole('button')).toHaveLength(1) + expect(within(hotelRow).queryByText('Stay')).toBeNull() + }) + + it('FE-ROADTRIP-SIDEBAR-066: offers other ways on the drive between the places only', () => { + const onAskAlternatives = vi.fn() + wrap() + const offered = screen.getAllByLabelText('Other ways') + expect(offered).toHaveLength(1) + fireEvent.click(offered[0]) + expect(onAskAlternatives).toHaveBeenCalledWith(1, { kind: 'leg', index: 1 }) + }) + + it('FE-ROADTRIP-SIDEBAR-067: opens the booking behind the night for an editor, the hotel for anybody else', () => { + const onOpenBooking = vi.fn() + const onSelectStop = vi.fn() + const editor = wrap() + fireEvent.click(screen.getByText('Back to Hotel Alpenblick')) + expect(onOpenBooking).toHaveBeenCalledWith(41) + expect(onSelectStop).not.toHaveBeenCalled() + editor.unmount() + + wrap() + fireEvent.click(screen.getByText('Check-out · Hotel Alpenblick')) + // The place alone, without an assignment: the hotel is no stop of the day. + expect(onSelectStop).toHaveBeenCalledWith(900) + expect(onOpenBooking).toHaveBeenCalledTimes(1) + }) + + it('FE-ROADTRIP-SIDEBAR-068: a place is not moved past the hotel at either end of its day', () => { + const onReorderStop = vi.fn() + wrap() + fireEvent.keyDown(screen.getByText('Lookout').closest('button')!, { key: 'ArrowUp', altKey: true }) + fireEvent.keyDown(screen.getByText('Falls').closest('button')!, { key: 'ArrowDown', altKey: true }) + expect(onReorderStop).not.toHaveBeenCalled() + fireEvent.keyDown(screen.getByText('Lookout').closest('button')!, { key: 'ArrowDown', altKey: true }) + expect(onReorderStop).toHaveBeenCalledWith(1, 1, 1) + }) + + it('FE-ROADTRIP-SIDEBAR-069: the morning marker at the hotel is drawn as the hotel row, not as a line of its own', () => { + const resume = stop({ + assignmentId: -2_000_000_003, + name: 'Continue journey', + lat: 47.2, + lng: 11.4, + automaticNight: { phase: 'start', fromDayNumber: 1 }, + }) + const stops = [resume, bookend('morning', {}, { ownerIndex: 0 }), stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 })] + wrap() + expect(screen.getByText('From Hotel Alpenblick')).toBeInTheDocument() + expect(screen.queryByText('Continue journey')).toBeNull() + }) + + it('FE-ROADTRIP-SIDEBAR-070: a drive to the hotel over the limit is flagged on the hotel row', () => { + wrap() + const hotelRow = screen.getByText('Back to Hotel Alpenblick').closest('li')! + expect(within(hotelRow).getByText('+30 min')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 5eec95484..55f623461 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -17,7 +17,8 @@ import { formatDistance } from '../../utils/units' import { formatDate, formatClockTime } from '../../utils/formatters' import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel' import { STOP_KIND_BY_KEY } from './stopKinds' -import { arrivingReroutable, destinationCount, isHop, legReroutable } from './roadtripRowModel' +import { arrivingReroutable, bookendReading, destinationCount, isHop, legReroutable, movableWithin, resumeFoldsIntoBookend, type BookendReading } from './roadtripRowModel' +import { BOOKEND_ICON, bookendBooking, bookendMeta, bookendTitle } from './nightBookend' import { spurWorthLabelling } from './accessSpur' import StopKindPicker from './StopKindPicker' import StopFillPicker from './StopFillPicker' @@ -44,7 +45,11 @@ interface RoadtripSidebarProps { /** Legs and totals for the whole trip, computed once in the planner hook. */ routes: RoadtripRoutes selectedAssignmentId?: number | null - onSelectStop?: (placeId: number, assignmentId: number) => void + /** + * Selects a stop, which opens its place in the inspector. Without an assignment it is + * the place alone: a booked night at a day's edge is the stay's place, no stop. + */ + onSelectStop?: (placeId: number, assignmentId?: number) => void /** * Moves a stop within its day. Absent means the chain is read-only, which is also how * a viewer sees it — no handles, no drop targets. @@ -807,13 +812,33 @@ function TerminalStop({ stop, entry, late, continues, starts, onOpen }: { onOpen?: () => void }): React.ReactElement { const { t } = useTranslation() - const Icon = carrierIcon(stop.carrier!.type) + return ( + + + + + + ) +} + +/** + * A row of the rail that is one button on a plain disc: a terminal, a ride, a booked night. + * The line runs in from above unless the chain starts here, and on below while it goes on. + */ +function DiscRow({ Icon, starts, continues, onOpen, label, children }: { + Icon: LucideIcon + starts?: boolean + continues: boolean + onOpen?: () => void + label?: string + children: React.ReactNode +}): React.ReactElement { return ( ) } +/** + * A booked night at the edge of the day: the hotel the day sets out from, or the one it + * ends at (`seatNightBookends`). + * + * Laid out like a terminal, with no number, stay, kind picker or drag handle: it is the + * stay's place and no stop of the day, so nothing about it is changed here. Under its line + * the latest hour the room is handed back, on the morning it is, and whatever the drive + * into it runs over; on the right the time the chain has the traveller there. The whole + * row opens the booking behind the night, or the hotel's place when there is none to open. + */ +function BookendStop({ reading, entry, late, driveFindings, continues, starts, onOpen }: { + reading: BookendReading + entry: ScheduleEntry | undefined + late: ScheduleWarning[] + driveFindings: ScheduleWarning[] + continues: boolean + starts?: boolean + onOpen?: () => void +}): React.ReactElement { + const { t } = useTranslation() + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const meta = bookendMeta(reading, t, is12h) + const badges = late.length + driveFindings.length > 0 + return ( + + + + + {bookendTitle(reading, t)} + + {meta || badges ? ( + + {meta ? {meta} : null} + {driveFindings.map(w => )} + {late.map(w => )} + + ) : null} + + {entry?.arrival ? : null} + + + ) +} + /** * One end of a ride: the terminal's name and code, the timetable's clock under it, and * on the right the time the chain has the traveller there. Inside the ride's block for @@ -884,21 +951,7 @@ function RideBlock({ departure, arrival, entries, late, seg, continues, starts, const carrier = departure.carrier! const Icon = carrierIcon(carrier.type) return ( - + ) } @@ -1516,7 +1569,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca collapsed?: boolean onToggle?: () => void selectedAssignmentId?: number | null - onSelectStop?: (placeId: number, assignmentId: number) => void + onSelectStop?: RoadtripSidebarProps['onSelectStop'] onReorderStop?: (dayId: number, assignmentId: number, toIndex: number) => void onMoveStopToDay?: RoadtripSidebarProps['onMoveStopToDay'] /** What is being dragged right now, shared across days so a stop can leave its own. */ @@ -1594,6 +1647,8 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca )) : null const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement | null => { + // The morning marker at the hotel the day sets out from is drawn as that hotel's row. + if (resumeFoldsIntoBookend(day, i)) return null if (stop.automaticNight) return (
  • @@ -1608,6 +1663,33 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca // overnight crossing swallow the "you arrive late" flag without a trace, and a stop // can be late for the time it is reached and the time it is left at both at once. const lateness = latenessAt(i) + // A booked night at the day's edge: not draggable, not numbered, no stay and no chips, + // and the drive into or out of it offers no other ways (`legReroutable`). + if (stop.bookend) { + const reading = bookendReading(day, i)! + const booking = bookendBooking(reading, !!canEditBookings) + let open: (() => void) | undefined + if (booking !== null && onOpenBooking) open = () => onOpenBooking(booking) + else if (onSelectStop) open = () => onSelectStop(stop.placeId) + return ( +
  • + + {i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? : null} + {refuelBandsFor(i)} + {i < last ? (day.legVias[i] ?? []).map((via, vi) => ( + + )) : null} +
  • + ) + } // A terminal or a hire car's desk: not draggable, not numbered, not a place. A ride // that lands on the day it left is one block from its departure to its arrival, and // the arrival index draws nothing of its own. A lone terminal (a ride landing @@ -1721,7 +1803,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca starts={i === 0} onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} onMove={onReorderStop ? delta => onReorderStop(ownDay, stop.assignmentId, ownIndex + delta) : undefined} - canMove={{ up: i > 0, down: i < last }} + canMove={movableWithin(day, i)} onEditStay={onEditStay ? () => onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined} onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined} onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} diff --git a/client/src/components/Roadtrip/alternativePins.test.ts b/client/src/components/Roadtrip/alternativePins.test.ts index 8145b587c..23c241205 100644 --- a/client/src/components/Roadtrip/alternativePins.test.ts +++ b/client/src/components/Roadtrip/alternativePins.test.ts @@ -4,7 +4,7 @@ import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './u import type { OfferedRoute } from './useRouteAlternatives' /** - * FE-ALTPIN-001..012: proving a choice with the rail's own router before it is saved. + * FE-ALTPIN-001..013: proving a choice with the rail's own router before it is saved. * * A choice used to be one via at the point where the offer strayed furthest, written * without asking whether the router then drove the offer. On a ferry it did not (OSRM @@ -148,6 +148,19 @@ describe('railLegAt', () => { expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toBeNull() expect(railLegAt(days, { dayId: 3, afterIndex: 0 })).toBeNull() }) + + it('FE-ALTPIN-013: neither is the hotel a day sets out from, so a choice on the first leg is still that leg', () => { + // The morning's hotel borrows the first stop's index. Answering for it, the leg read + // back was hotel to first stop, and every choice on leg 0 came back as changed. + const bookend = (id: number, ownerIndex: number, phase: 'morning' | 'evening') => stop(id, 1, ownerIndex, { + bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null }, + }) + const days = [day(1, [bookend(-6_000_000_002, 0, 'morning'), stop(10, 1, 0), stop(11, 1, 1), bookend(-6_000_000_003, 2, 'evening')])] + + expect(railLegAt(days, { dayId: 1, afterIndex: 0 })).toMatchObject({ from: { assignmentId: 10 }, to: { assignmentId: 11 } }) + // Behind the last stop the rail drives to tonight's hotel, and that is what it reports. + expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toMatchObject({ from: { assignmentId: 11 }, to: { assignmentId: -6_000_000_003 } }) + }) }) describe('railDriveOn', () => { diff --git a/client/src/components/Roadtrip/alternativePins.ts b/client/src/components/Roadtrip/alternativePins.ts index 55b9dc4d9..f801261b5 100644 --- a/client/src/components/Roadtrip/alternativePins.ts +++ b/client/src/components/Roadtrip/alternativePins.ts @@ -1,5 +1,5 @@ import { furthestFrom, sameRoad, type RoadLine } from '../Map/RouteCalculator' -import type { RouteSegment } from '@trek/shared/roadtrip' +import { isStoredStop, type RouteSegment } from '@trek/shared/roadtrip' import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes' import type { OfferedRoute, RailDrive, ViaAnchor } from './useRouteAlternatives' @@ -111,12 +111,12 @@ export function refusalHint( * inside one card or across to the next. The caller hands in the days with a single stop * too: they draw no card, but the drive into the day after one leaves from its stop. The * markers an automatic night puts on the chain stand on or between stops without being - * one, and a terminal borrows the index of a stored stop to be seated by, so neither can - * answer for the anchor. + * one, and a terminal or a booked night at a day's edge borrows the index of a stored stop + * to be seated by, so none of them can answer for the anchor. */ export function railLegAt(days: readonly Pick[], anchor: ViaAnchor): { from: RoadtripStop; to: RoadtripStop } | null { const chain = days.flatMap(day => day.stops).filter(stop => !stop.automaticNight) - const at = chain.findIndex(stop => !stop.carrier && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex) + const at = chain.findIndex(stop => isStoredStop(stop) && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex) const to = at >= 0 ? chain[at + 1] : undefined return to ? { from: chain[at], to } : null } diff --git a/client/src/components/Roadtrip/nightBookend.test.ts b/client/src/components/Roadtrip/nightBookend.test.ts new file mode 100644 index 000000000..7017cd8e3 --- /dev/null +++ b/client/src/components/Roadtrip/nightBookend.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { BedDouble } from 'lucide-react' +import { BOOKEND_ICON, bookendBooking, bookendMeta, bookendTitle } from './nightBookend' +import type { BookendReading } from './roadtripRowModel' + +// FE-BOOKEND-001 to FE-BOOKEND-005 + +const reading = (over: Partial = {}): BookendReading => ({ + phase: 'morning', + variant: 'from', + name: 'Hotel Alpenblick', + until: null, + reservationId: 41, + accommodationId: 5, + placeId: 900, + ...over, +}) + +/** A translator that shows the key and what went into it, so a test reads which sentence was asked for. */ +const t = (key: string, params?: Record) => + params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key + +describe('a booked night as the rail and the phone say it', () => { + it('FE-BOOKEND-001: each of the four readings is its own sentence, with the stay named in it', () => { + expect(bookendTitle(reading({ variant: 'checkOut' }), t)).toBe('roadtrip.bookend.checkOut(name=Hotel Alpenblick)') + expect(bookendTitle(reading({ variant: 'from' }), t)).toBe('roadtrip.bookend.from(name=Hotel Alpenblick)') + expect(bookendTitle(reading({ phase: 'evening', variant: 'back' }), t)).toBe('roadtrip.bookend.back(name=Hotel Alpenblick)') + expect(bookendTitle(reading({ phase: 'evening', variant: 'checkIn' }), t)).toBe('roadtrip.bookend.checkIn(name=Hotel Alpenblick)') + }) + + it('FE-BOOKEND-002: the check-out hour is a label in the reader\'s clock', () => { + expect(bookendMeta(reading({ variant: 'checkOut', until: '10:00' }), t, false)).toBe('roadtrip.stay.until(time=10:00)') + expect(bookendMeta(reading({ variant: 'checkOut', until: '14:30' }), t, true)).toBe('roadtrip.stay.until(time=2:30 PM)') + }) + + it('FE-BOOKEND-003: nothing under the line when the morning hands no room back', () => { + expect(bookendMeta(reading(), t, false)).toBeNull() + expect(bookendMeta(reading({ phase: 'evening', variant: 'back' }), t, false)).toBeNull() + }) + + it('FE-BOOKEND-004: a tap opens the booking behind the night for somebody who may edit bookings', () => { + expect(bookendBooking(reading(), true)).toBe(41) + // The editor is the only view a hotel booking has, so anybody else gets the place. + expect(bookendBooking(reading(), false)).toBeNull() + // A stay entered without a booking has none to open. + expect(bookendBooking(reading({ reservationId: null }), true)).toBeNull() + }) + + it('FE-BOOKEND-005: the hotel wears the bed the stay chips wear', () => { + expect(BOOKEND_ICON).toBe(BedDouble) + }) +}) diff --git a/client/src/components/Roadtrip/nightBookend.ts b/client/src/components/Roadtrip/nightBookend.ts new file mode 100644 index 000000000..b08e2a4a8 --- /dev/null +++ b/client/src/components/Roadtrip/nightBookend.ts @@ -0,0 +1,41 @@ +import { BedDouble, type LucideIcon } from 'lucide-react' +import { formatClockTime } from '../../utils/formatters' +import type { TranslationFn } from '../../types' +import type { BookendReading } from './roadtripRowModel' + +/** + * How a booked night at the edge of a day reads on the rail and in the phone chain, and + * what a tap on it opens. + * + * One module for both shells, the way `carrierRide` is for a ride: the two would otherwise + * carry the same sentence and the same decision each, and the duplication budget does not + * stretch to that. What the row IS comes from the row model (`bookendReading`); this only + * says it. + */ + +/** The bed the day plan's stay chips wear, so the hotel looks like itself here too. */ +export const BOOKEND_ICON: LucideIcon = BedDouble + +/** "Check-out · the stay", "From …", "Back to …" or "Check-in · …". */ +export function bookendTitle(reading: BookendReading, t: TranslationFn): string { + return t(`roadtrip.bookend.${reading.variant}`, { name: reading.name }) +} + +/** + * The line under it: on a check-out morning the latest the room is handed back, in the + * reader's own clock format. A label, never the time the drive leaves at (#2357). + */ +export function bookendMeta(reading: BookendReading, t: TranslationFn, is12h: boolean): string | null { + return reading.until ? t('roadtrip.stay.until', { time: formatClockTime(reading.until, is12h) }) : null +} + +/** + * The booking a tap on the row opens, or null when it opens the stay's place instead. + * + * The booking behind the night when there is one and this reader may open it: a hotel + * booking has only its editor, the split `bookingOpens` makes for a chip. Otherwise the + * place, which anybody may look at. + */ +export function bookendBooking(reading: BookendReading, canEditBookings: boolean): number | null { + return canEditBookings ? reading.reservationId : null +} diff --git a/client/src/components/Roadtrip/roadtripRowModel.test.ts b/client/src/components/Roadtrip/roadtripRowModel.test.ts index e60046742..e880f5c95 100644 --- a/client/src/components/Roadtrip/roadtripRowModel.test.ts +++ b/client/src/components/Roadtrip/roadtripRowModel.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest' import { arrivingReroutable, + bookendReading, destinationCount, firstStopOfPlace, legReroutable, + movableWithin, pickWarning, + resumeFoldsIntoBookend, roadtripRows, stageClocks, stageEnd, @@ -14,9 +17,9 @@ import { type StopRow, } from './roadtripRowModel' import type { ScheduleEntry, ScheduleWarning } from './roadtripModel' -import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip' +import type { BookendPhase, NightBookend, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip' -// FE-RTROW-001 to FE-RTROW-058 +// FE-RTROW-001 to FE-RTROW-066 function stop(name: string, over: Partial = {}): RoadtripStop { return { @@ -700,3 +703,129 @@ describe('the drive in from the day before (#2461)', () => { expect(arrivingReroutable(joined({ stops: [night('start'), stop('Potsdam')] }))).toBe(false) }) }) + +describe('a booked night at the edge of the day', () => { + const reading = (phase: BookendPhase, over: Partial = {}): NightBookend => ({ + phase, + accommodationId: 5, + reservationId: 41, + checkingOut: false, + checkingIn: false, + checkOut: null, + ...over, + }) + const hotel = (phase: BookendPhase, over: Partial = {}, at: Partial = {}) => + stop('Hotel Alpenblick', { + assignmentId: phase === 'morning' ? -6_000_000_014 : -6_000_000_015, + placeId: 900, + lat: 45, + lng: 7, + stopType: 'hotel', + dwellMinutes: 0, + bookend: reading(phase, over), + ...at, + }) + /** From the hotel, two places, back to the hotel, each with a clock. */ + const loop = (over: Partial = {}) => { + const stops = [ + hotel('morning'), + stop('Lookout', { placeId: 1 }), + stop('Falls', { placeId: 2, ownerIndex: 1 }), + hotel('evening', {}, { ownerIndex: 2 }), + ] + return day(stops, { + schedule: { entries: [entry('08:40'), entry('09:30'), entry('11:00'), entry('12:10')], warnings: [] }, + ...over, + }) + } + + it('FE-RTROW-059: reads as a check-out, a morning, an evening back or a check-in', () => { + const out = day([hotel('morning', { checkingOut: true, checkOut: '10:00' }), stop('Zoo')]) + expect(bookendReading(out, 0)).toEqual({ + phase: 'morning', + variant: 'checkOut', + name: 'Hotel Alpenblick', + until: '10:00', + reservationId: 41, + accommodationId: 5, + placeId: 900, + }) + expect(bookendReading(loop(), 0)).toMatchObject({ variant: 'from', until: null }) + expect(bookendReading(loop(), 3)).toMatchObject({ variant: 'back', phase: 'evening' }) + // The check-in day: back when the stay's own stop heads the card, a check-in without it. + const stayed = day([stop('Hotel Alpenblick', { lat: 45, lng: 7, night: true }), stop('Lookout'), hotel('evening', { checkingIn: true })]) + expect(bookendReading(stayed, 2)?.variant).toBe('back') + const transfer = day([hotel('morning', { checkingOut: true }), hotel('evening', { checkingIn: true }, { name: 'Wallinga', lat: 46 })]) + expect(bookendReading(transfer, 1)).toMatchObject({ variant: 'checkIn', name: 'Wallinga' }) + // Nothing to read on an ordinary stop, or past the end. + expect(bookendReading(loop(), 1)).toBeNull() + expect(bookendReading(loop(), 9)).toBeNull() + }) + + it('FE-RTROW-060: a row of its own, unnumbered, that counts as no destination', () => { + const rows = roadtripRows(loop()) + expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop', 'leg', 'stop']) + const stops = stopRows(rows) + expect(stops.map(r => r.number)).toEqual([null, 1, 2, null]) + expect(stops.map(r => r.bookend?.variant ?? null)).toEqual(['from', null, null, 'back']) + expect(stops[0]!.service).toBe(true) + expect(destinationCount(loop())).toBe(2) + }) + + it('FE-RTROW-061: offers no other ways from or to the hotel, on a leg or on the drive in', () => { + expect([0, 1, 2].map(i => legReroutable(loop(), i))).toEqual([false, true, false]) + const joined = (from: RoadtripStop, first: RoadtripStop) => day([first, stop('Falls')], { + arrivingLeg: { ...seg(3), distance: 120_000, duration: 5_400 }, + arrivingFrom: from, + arrivingLine: [[45, 7], [48, 11]], + }) + expect(arrivingReroutable(joined(stop('Town', { ownerDayId: 6 }), stop('Lookout')))).toBe(true) + expect(arrivingReroutable(joined(hotel('evening'), stop('Lookout')))).toBe(false) + expect(arrivingReroutable(joined(stop('Town', { ownerDayId: 6 }), hotel('morning')))).toBe(false) + }) + + it('FE-RTROW-062: the morning marker at the hotel the day sets out from is folded into its row', () => { + const resume = night('start') + const at = { ...resume, lat: 45, lng: 7 } + const folded = day([at, hotel('morning'), stop('Lookout')], { + schedule: { entries: [entry('08:00'), entry('08:00'), entry('09:00')], warnings: [] }, + }) + expect(resumeFoldsIntoBookend(folded, 0)).toBe(true) + expect(roadtripRows(folded).map(r => r.kind)).toEqual(['stop', 'leg', 'stop']) + expect(stageClocks(roadtripRows(folded)).start).toBe('08:00') + // A marker somewhere else keeps its own row: the drive from there to the hotel is real. + const apart = day([resume, hotel('morning'), stop('Lookout')]) + expect(resumeFoldsIntoBookend(apart, 0)).toBe(false) + expect(roadtripRows(apart)[0]).toMatchObject({ kind: 'auto', phase: 'resume' }) + expect(resumeFoldsIntoBookend(folded, 1)).toBe(false) + }) + + it('FE-RTROW-063: the stage starts when the hotel is left and arrives when it is reached again', () => { + const rows = roadtripRows(loop()) + expect(stageClocks(rows)).toEqual({ start: '08:40', arrive: '12:10' }) + expect(stageEnd(rows)?.bookend?.variant).toBe('back') + }) + + it('FE-RTROW-064: a pin at the hotel opens the stay’s own stop, never a bookend', () => { + const own = stop('Hotel Alpenblick', { placeId: 900, assignmentId: 77, ownerDayId: 3 }) + expect(firstStopOfPlace([loop(), day([own])], 900)).toBe(own) + expect(firstStopOfPlace([loop()], 900)).toBeNull() + }) + + it('FE-RTROW-065: a stop moves past anything but the hotel at the edge of its day', () => { + expect(movableWithin(loop(), 1)).toEqual({ up: false, down: true }) + expect(movableWithin(loop(), 2)).toEqual({ up: true, down: false }) + const plain = day([stop('A'), stop('B'), stop('C')]) + expect([0, 1, 2].map(i => movableWithin(plain, i))).toEqual([ + { up: false, down: true }, + { up: true, down: true }, + { up: true, down: false }, + ]) + }) + + it('FE-RTROW-066: up next is a place, not the hotel the day comes back to', () => { + // 11:30 is past both places and before the hotel: the last place is still next. + expect(upNextStop(loop(), 11 * 60 + 30, true)?.row.stop.name).toBe('Falls') + expect(upNextStop(loop(), 8 * 60 + 45, true)?.row.stop.name).toBe('Lookout') + }) +}) diff --git a/client/src/components/Roadtrip/roadtripRowModel.ts b/client/src/components/Roadtrip/roadtripRowModel.ts index 0a585d1ba..5bd0bd82f 100644 --- a/client/src/components/Roadtrip/roadtripRowModel.ts +++ b/client/src/components/Roadtrip/roadtripRowModel.ts @@ -1,5 +1,5 @@ import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel' -import { isCarrierMode, type CarrierTerminal, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip' +import { isCarrierMode, isStoredStop, type BookendPhase, type CarrierTerminal, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip' import { readStay } from './stayReading' /** @@ -54,6 +54,73 @@ export interface StopRow { /** How long the stop is stood at, which for one left at a set time is what that time leaves. */ dwellMinutes: number | null offRoadMeters: number | null + /** What the row says about the booked night it stands for, when it is one; see `bookendReading`. */ + bookend: BookendReading | null +} + +/** + * A booked night at the edge of a day, as a row reads it (`seatNightBookends`). + * + * Four ways of saying it. The morning of the day the stay is handed back is a check-out, + * with the latest hour it may be; any other morning sets out from the stay. The evening + * of the day the stay begins is a check-in, unless the stay's own stop is already on the + * card before it, in which case the day comes back to it like any other evening. + */ +export interface BookendReading { + phase: BookendPhase + variant: 'checkOut' | 'from' | 'back' | 'checkIn' + name: string + /** The latest the room is handed back, on the check-out morning. A label, never a departure. */ + until: string | null + /** The booking a tap opens, or null for a stay entered without one. */ + reservationId: number | null + accommodationId: number + placeId: number +} + +export function bookendReading(day: Pick, index: number): BookendReading | null { + const stop = day.stops[index] + const bookend = stop?.bookend + if (!stop || !bookend) return null + const morning = bookend.phase === 'morning' + const stayStopBefore = day.stops + .slice(0, index) + .some(s => isStoredStop(s) && s.lat === stop.lat && s.lng === stop.lng) + const evening = bookend.checkingIn && !stayStopBefore ? 'checkIn' : 'back' + return { + phase: bookend.phase, + variant: morning ? (bookend.checkingOut ? 'checkOut' : 'from') : evening, + name: stop.name, + until: morning && bookend.checkingOut ? bookend.checkOut : null, + reservationId: bookend.reservationId, + accommodationId: bookend.accommodationId, + placeId: stop.placeId, + } +} + +/** + * Whether the automatic morning marker at `index` is the same point as the hotel the day + * sets out from right after it. The hotel's row carries that morning, clock and all, and a + * "continue journey" line above it would say the same thing twice. + */ +export function resumeFoldsIntoBookend(day: Pick, index: number): boolean { + const stop = day.stops[index] + const next = day.stops[index + 1] + return stop?.automaticNight?.phase === 'start' + && next?.bookend?.phase === 'morning' + && stop.lat === next.lat + && stop.lng === next.lng +} + +/** + * Where the stop at `index` may be moved by one: not past a booked night at the day's + * edge, which is no stored stop and holds its place. Past anything else, as before. + */ +export function movableWithin(day: Pick, index: number): { up: boolean; down: boolean } { + return { + up: index > 0 && !day.stops[index - 1]?.bookend, + down: index < day.stops.length - 1 && !day.stops[index + 1]?.bookend, + } } /** @@ -86,17 +153,12 @@ export function stageOf(days: readonly RoadtripDay[], dayId: number | null): Roa return days.find(d => d.dayId === dayId) ?? null } -function stopRow( - stop: RoadtripStop, - index: number, - number: number | null, - schedule: RoadtripDay['schedule'], - driveWarnings: readonly ScheduleWarning[], -): StopRow { +function stopRow(day: RoadtripDay, index: number, number: number | null): StopRow { // Only ever called for a real stop: an automatic night is the shell's own marker // for "the day ended here" and `roadtripRows` turns it into an 'auto' row instead. - const entry = schedule.entries[index] - const mine = driveWarnings.filter(w => w.index === index) + const stop = day.stops[index] + const entry = day.schedule.entries[index] + const mine = day.driveWarnings.filter(w => w.index === index) return { kind: 'stop', stop, @@ -109,6 +171,7 @@ function stopRow( warning: pickWarning(mine), dwellMinutes: readStay(stop, entry).minutes, offRoadMeters: stop.offRoadMeters ?? null, + bookend: bookendReading(day, index), } } @@ -196,6 +259,8 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] { if (stop.carrier?.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return const automatic = !!stop.automaticNight + // The morning marker at the hotel the day sets out from is that hotel's row. + if (resumeFoldsIntoBookend(day, i)) return if (automatic) { rows.push({ kind: 'auto', @@ -208,8 +273,8 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] { index: i, carrier: stop.carrier, seg: day.legs[i], - departure: stopRow(stop, i, null, day.schedule, day.driveWarnings), - arrival: stopRow(next!, i + 1, null, day.schedule, day.driveWarnings), + departure: stopRow(day, i, null), + arrival: stopRow(day, i + 1, null), }) // The road out of the arrival belongs to this row too; the pass below only // looks at the index it is on. @@ -220,7 +285,7 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] { // place the trip is for: it carries no number, the way a service stop carries none. const unnumbered = isServiceStopType(stop.stopType) || !!stop.carrier if (!unnumbered) number += 1 - rows.push(stopRow(stop, i, unnumbered ? null : number, day.schedule, day.driveWarnings)) + rows.push(stopRow(day, i, unnumbered ? null : number)) } pushLeg(i) @@ -268,6 +333,11 @@ export function legReroutable(day: RoadtripDay, index: number): boolean { // index of the stop after it. The road INTO a departure terminal leaves a stored // stop and can be offered other ways like any other. && !day.stops[index].carrier + // The drive from the hotel a day sets out from, or to the one it ends at, takes no + // via: the morning's has no index to file one at, and the evening's index is the + // road into tomorrow's, which a choice here would bend instead. + && !day.stops[index].bookend + && !day.stops[index + 1].bookend } /** @@ -291,6 +361,8 @@ export function arrivingReroutable(day: RoadtripDay): boolean { && !from.carrier && !from.automaticNight && !to.automaticNight + && !from.bookend + && !to.bookend } /** Stops that carry a number, for a count that agrees with the numbering above. */ @@ -380,12 +452,13 @@ export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null { * traveller reading the drive from its start reaches first. * * An automatic night can sit on a place's position, but nobody chose to stop there, so it - * never answers. Null when none of the days stops at the place, which is a pin the road + * never answers. Neither does a booked night at a day's edge: it is the stay's place, and + * no stop of the day, so the inspector answers for the hotel instead. Null when none of the days stops at the place, which is a pin the road * trip cannot explain and the caller hands to the plan's own place inspector instead. */ export function firstStopOfPlace(days: readonly RoadtripDay[], placeId: number): RoadtripStop | null { for (const day of days) { - const stop = day.stops.find(s => !s.automaticNight && !s.carrier && s.placeId === placeId) + const stop = day.stops.find(s => isStoredStop(s) && s.placeId === placeId) if (stop) return stop } return null diff --git a/client/src/components/Roadtrip/stageMap.test.ts b/client/src/components/Roadtrip/stageMap.test.ts index 529ac81b8..1daf870f3 100644 --- a/client/src/components/Roadtrip/stageMap.test.ts +++ b/client/src/components/Roadtrip/stageMap.test.ts @@ -3,7 +3,7 @@ import { dayColor } from './dayColors' import { stageMapData, stagePlaceIds, stagePoints } from './stageMap' import type { AccessSpur, RoadtripDay, RoadtripRoutes, RoadtripStop } from '@trek/shared/roadtrip' -// FE-RTSTAGE-001 to FE-RTSTAGE-015 +// FE-RTSTAGE-001 to FE-RTSTAGE-021 function stop(name: string, over: Partial = {}): RoadtripStop { return { @@ -248,3 +248,22 @@ describe('stagePoints', () => { ]) }) }) + +describe('a stage that starts or ends at a booked night', () => { + it('FE-RTSTAGE-021: the hotel is on the stage, pin, fit and walk from the road, when only its bookend stands there', () => { + const hotel = stop('Hotel Alpenblick', { + assignmentId: -6_000_000_014, + placeId: 900, + lat: 47.2, + lng: 11.4, + stopType: 'hotel', + bookend: { phase: 'morning', accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null }, + }) + const stage = day([hotel, stop('Kassel', { placeId: 11, lat: 51.31, lng: 9.49 })]) + const walk = spur(keyFor(hotel)) + const data = stageMapData(routes({ lines: [[[47.2, 11.4], [51.31, 9.49]]], lineDays: [2], lineJoins: [false], accessLines: [walk] }), stage, false) + expect([...data.placeIds]).toEqual([900, 11]) + expect(data.focusPoints[0]).toEqual([47.2, 11.4]) + expect(data.accessLines).toEqual([walk]) + }) +}) diff --git a/client/src/components/Roadtrip/stopBookings.test.ts b/client/src/components/Roadtrip/stopBookings.test.ts index 06397f787..a273eb067 100644 --- a/client/src/components/Roadtrip/stopBookings.test.ts +++ b/client/src/components/Roadtrip/stopBookings.test.ts @@ -80,3 +80,24 @@ describe('dayBookings (#2428)', () => { expect(bookingClock({ reservation_time: null })).toBeNull() }) }) + +describe('dayBookings and a booked night at the edge of the day', () => { + it('FE-STOPBOOKINGS-006: a table at the hotel hangs under the hotel stop, never under the bookend in front of it', () => { + const bookend = (phase: 'morning' | 'evening', assignmentId: number) => ({ + assignmentId, + placeId: 900, + bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null }, + }) + const withHotel = { + dayId: 1, + stops: [bookend('morning', -6_000_000_002), { assignmentId: 11, placeId: 110 }, { assignmentId: 13, placeId: 900 }], + } + const dinner = booking({ id: 9, title: 'Dinner at the hotel', place_id: 900 }) + expect([...dayBookings(withHotel, [dinner]).atStop.keys()]).toEqual([2]) + // With the hotel only there as the evening's bookend, the table is the day's, for no stop. + const backOnly = { dayId: 1, stops: [{ assignmentId: 11, placeId: 110 }, bookend('evening', -6_000_000_003)] } + const { atStop, loose } = dayBookings(backOnly, [dinner]) + expect(atStop.size).toBe(0) + expect(loose.map(r => r.title)).toEqual(['Dinner at the hotel']) + }) +}) diff --git a/client/src/components/Roadtrip/stopBookings.ts b/client/src/components/Roadtrip/stopBookings.ts index 2d27312c0..f0c7218ee 100644 --- a/client/src/components/Roadtrip/stopBookings.ts +++ b/client/src/components/Roadtrip/stopBookings.ts @@ -1,5 +1,5 @@ import { Ticket, type LucideIcon } from 'lucide-react' -import { isCarrierType, isRentalType } from '@trek/shared/roadtrip' +import { isCarrierType, isRentalType, isStoredStop } from '@trek/shared/roadtrip' import { RES_ICONS } from '../Planner/DayPlanSidebar.constants' import { hidesOnMiddleDay } from '../../utils/dayMerge' import { splitReservationDateTime } from '../../utils/formatters' @@ -60,7 +60,7 @@ function spansDay(r: Reservation, dayId: number, dayOrder?: (dayId: number) => n } export function dayBookings( - day: { dayId: number; stops: readonly Pick[] }, + day: { dayId: number; stops: readonly Pick[] }, 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, @@ -71,9 +71,11 @@ export function dayBookings( for (const r of reservations) { if (drawnElsewhere(r.type)) continue const stops = day.stops - // Only a real stop can hold a booking: a terminal has a negative place and an - // automatic night has no assignment anybody pinned to. - const real = (i: number): boolean => !stops[i]!.carrier && !stops[i]!.automaticNight + // Only a real stop can hold a booking: a terminal has a negative place, an automatic + // night has no assignment anybody pinned to, and a booked night at the day's edge is + // the stay's place on a row that carries no chips. A table at the hotel belongs to the + // 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 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) diff --git a/client/src/components/Roadtrip/useFollowTrack.test.ts b/client/src/components/Roadtrip/useFollowTrack.test.ts index e207e6e30..9906bd73f 100644 --- a/client/src/components/Roadtrip/useFollowTrack.test.ts +++ b/client/src/components/Roadtrip/useFollowTrack.test.ts @@ -30,8 +30,8 @@ function day(over: Partial = {}): RoadtripDay { return { dayId: 7, dayNumber: 1, date: null, title: null, stops: [ - { assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, - { assignmentId: 2, ownerDayId: 1, ownerIndex: 1, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, + { assignmentId: 1, ownerDayId: 7, ownerIndex: 0, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, + { assignmentId: 2, ownerDayId: 7, ownerIndex: 1, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, ], legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] }, geometry: [[START.lat, START.lng], [END.lat, END.lng]], @@ -83,7 +83,7 @@ beforeEach(() => { describe('useFollowTrack', () => { it('clears the original leg when an automatic pause splits its displayed days', async () => { - const [a, b] = day().stops + const [a, b] = day().stops.map(s => ({ ...s, ownerDayId: 1 })) const end = { ...a, assignmentId: -10, automaticNight: { phase: 'end' as const, fromDayNumber: 1 } } const start = { ...end, assignmentId: -11, automaticNight: { phase: 'start' as const, fromDayNumber: 1 } } const split = routes([ @@ -239,6 +239,45 @@ describe('useFollowTrack', () => { expect(result.current.error).toBeNull() }) + it('FE-FOLLOWHOOK-013: the hotel a day sets out from and comes back to is no stop the track is fitted between', async () => { + const [a, b] = day().stops + const bookend = (phase: 'morning' | 'evening', ownerIndex: number) => ({ + ...a, assignmentId: phase === 'morning' ? -6_000_000_014 : -6_000_000_015, ownerIndex, placeId: 900, lat: 51.9, lng: 13.5, stopType: 'hotel', + bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null }, + }) + answersStraight() + const vias = viasStub() + const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day({ stops: [bookend('morning', 0), a, b, bookend('evening', 2)] })]), vias)) + act(() => { result.current.open(7) }) + await act(async () => { await result.current.apply(3) }) + + // One leg, A to B: a via fitted to the drive to or from the hotel would be filed behind + // a stop it does not leave from. + const [, written, legs] = (vias.addMany as ReturnType).mock.calls[0] + expect(legs).toEqual([0]) + expect(written.every((v: { after_order_index: number }) => v.after_order_index === 0)).toBe(true) + await act(async () => { await result.current.clear() }) + expect(vias.addMany).toHaveBeenLastCalledWith(7, [], [0], null) + }) + + it('FE-FOLLOWHOOK-014: nor is a terminal, and a day a night drive split over two cards is fitted whole', async () => { + const [a, b] = day().stops + const terminal = (role: 'departure' | 'arrival') => ({ + ...a, assignmentId: role === 'departure' ? -3_000_000_140 : -3_000_000_141, ownerIndex: 1, placeId: -70, + carrier: { reservationId: 70, type: 'flight', role, title: 'LH 2020', code: null, at: null }, + }) + const next = { ...b, assignmentId: 3, ownerDayId: 8, ownerIndex: 0, placeId: 30, name: 'C' } + const cards = routes([ + day({ stops: [a, terminal('departure'), terminal('arrival')] }), + day({ dayId: 8, dayNumber: 2, stops: [b, next] }), + ]) + const vias = viasStub() + const { result } = renderHook(() => useFollowTrack(1, [TRACK], cards, vias)) + act(() => { result.current.open(7) }) + await act(async () => { await result.current.clear() }) + expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], null) + }) + it('FE-FOLLOWHOOK-009: a day with nothing to drive between is not applied to', async () => { answersStraight() const vias = viasStub() diff --git a/client/src/components/Roadtrip/useFollowTrack.ts b/client/src/components/Roadtrip/useFollowTrack.ts index 2a0cb8284..22fc036d8 100644 --- a/client/src/components/Roadtrip/useFollowTrack.ts +++ b/client/src/components/Roadtrip/useFollowTrack.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { calculateRouteWithLegs } from '../Map/RouteCalculator' import { lineLengthKm, parseTrack, planTrack, trackGapKm, type FollowTrackPlan } from './followTrack' import type { LatLng } from './corridor' +import { isStoredStop } from '@trek/shared/roadtrip' import type { RoadtripRoutes } from './useRoadtripRoutes' import type { RoadtripVias } from './useRoadtripVias' @@ -99,13 +100,20 @@ export function useFollowTrack( const [outcome, setOutcome] = useState(null) const abort = useRef(null) - const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId]) - const stops = useMemo(() => { - const original = day?.automaticSchedule - ? routes.days.flatMap(d => d.stops).filter(s => !s.automaticNight && s.ownerDayId === dayId).sort((a, b) => a.ownerIndex - b.ownerIndex) - : day?.stops ?? [] - return original.map(s => ({ lat: s.lat, lng: s.lng })) - }, [day, routes.days, dayId]) + /** + * The day's stored stops, in their stored order, wherever the cards draw them. + * + * A via is filed behind a stored stop, by its index (`legs` in the plan is that index), + * so the track is fitted between those and nothing else: not the markers a day window + * adds, not a terminal and not the hotel a day starts or ends at, each of which would + * shift every via behind it one leg on. Read across every card because a night drive + * draws some of them on the next one. + */ + const stops = useMemo(() => routes.days + .flatMap(d => d.stops) + .filter(s => isStoredStop(s) && s.ownerDayId === dayId) + .sort((a, b) => a.ownerIndex - b.ownerIndex) + .map(s => ({ lat: s.lat, lng: s.lng })), [routes.days, dayId]) /** * Parsed only while the dialog is open. diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 45fabf069..6d7d79e78 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -1,5 +1,5 @@ import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' -import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, isCarrierMode, mergeRouteSegments, seatCarrierStops, standsAsDay, terminalAssignmentId, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip' +import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, isCarrierMode, isStationaryJoin, mergeRouteSegments, seatCarrierStops, standsAsDay, terminalAssignmentId, viasOnLeg, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { avoidedClasses, calculateRouteWithLegs, routeEngineFor, RoutingRefusedError, type RouteEngine } from '../Map/RouteCalculator' import { resolveLegMode } from '../Planner/legMode' @@ -56,7 +56,7 @@ const stopKey = (s: RoadtripStop): string => /** * Everything a seam's answer depends on besides its two stops, as one comparable string: * the mode it is driven in, the classes the trip avoids, and the via points that shape - * the drive leaving `from`. + * the drive from `from` to `to` (`viasOnLeg`). * * Read in two places that must agree: when deciding whether a seam still matches the * answer already in hand, and when recording what an answer was fetched for. One function @@ -65,14 +65,14 @@ const stopKey = (s: RoadtripStop): string => * seam round and found every seam already answered, so the drive between two days kept * its old road and its old minutes until the page was loaded again. */ -const seamShape = (from: RoadtripStop, viasByDay: Record, mode: string, avoidKey: string, unit: string): string => +const seamShape = (from: RoadtripStop, to: RoadtripStop, viasByDay: Record, mode: string, avoidKey: string, unit: string): string => [ mode, avoidKey, // The seam's printed texts are in this unit, so a switch asks for it again, the way a // day's own run is asked for again. unit, - viasLeaving(from, viasByDay[from.ownerDayId] ?? []) + viasOnLeg(from, to, viasByDay[from.ownerDayId] ?? []) .map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`) .join('|'), ].join('#') @@ -459,8 +459,8 @@ export function useRoadtripRoutes( // `nightSpill.ts`), the lookup has to be the stop's own day and its own // index — this used to be the position within the chain, so on any day that // received a night drive every via matched nothing and quietly stopped - // shaping the road. - viasLeaving(stop, viasByDay[stop.ownerDayId ?? day.dayId] ?? []) + // shaping the road. None bend the drive from or to a booked night's hotel. + viasOnLeg(stop, run[i + 1], viasByDay[stop.ownerDayId ?? day.dayId] ?? []) .forEach(v => waypoints.push({ lat: v.lat, lng: v.lng })) }) @@ -610,6 +610,8 @@ export function useRoadtripRoutes( const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => { // The two ends of one ride: the leg between them is the booking's, never a road. if (rideLegs[legKey(from, to)]) return + // A night spent at one hotel: nothing is driven, so nothing is asked. + if (isStationaryJoin(from, to)) return // Routed as part of a day's own run — that request is rebuilt whenever its vias // change, so there is nothing to catch up here. if (legsByDay[from.ownerDayId]?.[legKey(from, to)]) return @@ -618,7 +620,7 @@ export function useRoadtripRoutes( // nothing at all: the first answer was cached under the pair, and dragging the // point changed the request nobody was going to send again. const mode = legModeOf(from, to, dayId) - const shape = seamShape(from, viasByDay, mode, avoidKey, distanceUnit) + const shape = seamShape(from, to, viasByDay, mode, avoidKey, distanceUnit) const have = seamLegs[legKey(from, to)] const current = have?.shape === shape // What the drawn join could not avoid is flagged on the card it arrives on, the way @@ -710,7 +712,7 @@ export function useRoadtripRoutes( // theirs. Without them a seam is the one stretch of the trip a via cannot // shape: it is asked for on its own, so the points the traveller dropped on it // never reached the router and dragging one did visibly nothing. - const shaping = viasLeaving(seam.from, viasByDay[seam.from.ownerDayId] ?? []) + const shaping = viasOnLeg(seam.from, seam.to, viasByDay[seam.from.ownerDayId] ?? []) const r = await calculateRouteWithLegs( [ { lat: seam.from.lat, lng: seam.from.lng }, diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx index c5900643a..e891624e4 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx @@ -4,18 +4,19 @@ import MDancingTrek from '../../../components/MDancingTrek' import MIconBtn from '../../../components/MIconBtn' import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel' import { bookingOpens, carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide' +import { BOOKEND_ICON, bookendMeta, bookendTitle } from '../../../../components/Roadtrip/nightBookend' import { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings' import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds' import { formatDistance } from '../../../../utils/units' import { formatClockTime } from '../../../../utils/formatters' -import type { RoadtripRow, StopRow } from '../../../../components/Roadtrip/roadtripRowModel' +import type { BookendReading, RoadtripRow, StopRow } from '../../../../components/Roadtrip/roadtripRowModel' import type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch' import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion' import type { DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip' import type { Reservation, TranslationFn } from '../../../../types' /** - * The four row types of the mobile drive chain, plus the two bands that interrupt it. + * The row types of the mobile drive chain, plus the two bands that interrupt it. * * What the desktop rail does in five stacked 8px badges per stop, this does in at most * two 10.5px marks: on touch there is no hover, so a badge nobody can explain is worse @@ -127,10 +128,25 @@ function warningMark(warning: ScheduleWarning, chrome: RowChrome): ReactNode { } /** - * A stop of the stage. The whole row is the tap target, at least 46px tall, which is - * why it is a div with role rather than a button: the desktop needs controls nested - * inside its row, and a button inside a button is not markup. + * A row of the stage that is its own tap target, at least 46px tall, which is why it is a + * div with role rather than a button: the desktop needs controls nested inside its row, + * and a button inside a button is not markup. */ +function TapRow({ onOpen, children }: { onOpen: () => void; children: ReactNode }) { + return ( +
    { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen() } }} + className="grid cursor-pointer items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr auto' }} + > + {children} +
    + ) +} + +/** A stop of the stage; see `TapRow`. */ export function RtStopRow({ row, chrome, onOpen, onPickKind }: { row: StopRow chrome: RowChrome @@ -157,13 +173,7 @@ export function RtStopRow({ row, chrome, onOpen, onPickKind }: { } return ( -
    { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen() } }} - className="grid cursor-pointer items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr auto' }} - > + @@ -178,7 +188,47 @@ export function RtStopRow({ row, chrome, onOpen, onPickKind }: { {formatClockTime(row.time, chrome.is12h)} )} -
    + + ) +} + +/** + * A booked night at the edge of the stage: the hotel the day sets out from, or the one it + * ends at. Laid out like a stop, on the bed a stay wears, with no number and no kind to + * pick: it is the stay's place, not a stop of the day. Under it the latest hour the room + * is handed back, on the morning it is, and the one finding a row has room for. The whole + * row opens the booking behind the night, or the stay. + */ +export function RtBookendRow({ row, bookend, chrome, onOpen }: { + row: StopRow + bookend: BookendReading + chrome: RowChrome + onOpen: () => void +}) { + const { t } = chrome + const meta = bookendMeta(bookend, t, chrome.is12h) + return ( + + + + + + + {bookendTitle(bookend, t)} + {(meta || row.warning) && ( + + {meta && {meta}} + {row.warning && warningMark(row.warning, chrome)} + + )} + + {row.time && ( + + {formatClockTime(row.time, chrome.is12h)} + + )} + ) } diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx index b0a7105cb..a5f8a07f0 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx @@ -6,7 +6,7 @@ import { useMRtCorridor } from './useMRtCorridor' import { useMRtAlternatives } from './useMRtAlternatives' import MRtCorridorBar from './MRtCorridorBar' import MRtAlternativesBar from './MRtAlternativesBar' -import { RtAutoRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows' +import { RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows' import MBadge from '../../../components/MBadge' import MDancingTrek from '../../../components/MDancingTrek' import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel' @@ -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 { bookendBooking } from '../../../../components/Roadtrip/nightBookend' import { getDayOrder } from '../../../../utils/dayOrder' import { undatedRides } from '@trek/shared/roadtrip' import type { Reservation, TranslationFn } from '../../../../types' @@ -84,6 +85,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) { const openStop = (row: StopRow) => { // A terminal is the booking's, and opens it: there is no stop sheet for an airport. if (row.stop.carrier) shell.openSheet('transport', { reservationId: row.stop.carrier.reservationId }) + else if (row.bookend) openBookend(row.stop.ownerDayId, row.bookend) else shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId }) } @@ -116,6 +118,16 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) { planner.setEditingReservation(res) planner.setShowReservationModal(true) } + // A booked night at the edge of the stage is no stop, and has no stop sheet: it opens the + // booking behind the night, else the stay for somebody who may edit days, else the + // hotel's place, the way a stay chip in the day timeline does (#2210). + const openBookend = (dayId: number, reading: NonNullable) => { + const booking = bookendBooking(reading, canEditBookings) + const res = booking === null ? undefined : planner.reservations.find(r => r.id === booking) + if (res) openBooking(res) + else if (planner.can('day_edit', planner.trip)) shell.openSheet('accommodation', { dayId, accId: reading.accommodationId }) + else planner.handlePlaceClick(reading.placeId) + } // The search bar sits in the same band on both halves, at the same offset, so the // list/map switch never moves it: it is one stage seen two ways, and a control that @@ -261,6 +273,9 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
    {rt.rows.map((row, i) => { + if (row.kind === 'stop' && row.bookend) { + return openStop(row)} /> + } if (row.kind === 'stop') { const chips = bookings.atStop.get(row.stop.assignmentId) return ( diff --git a/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx index fd4e8c759..98f06c4a9 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx @@ -7,6 +7,7 @@ import { useTranslation } from '../../../../i18n' import { useSettingsStore } from '../../../../store/settingsStore' import { formatClock, formatDurationShort, parseClock } from '../../../../components/Roadtrip/roadtripModel' import { stageOf } from '../../../../components/Roadtrip/roadtripRowModel' +import { isStoredStop } from '@trek/shared/roadtrip' import { locateStop, missedLeaveOf } from '../../../../components/Roadtrip/stayReading' import { useLeaveMode, type LeaveMode } from '../../../../components/Roadtrip/useLeaveMode' import { formatClockTime } from '../../../../utils/formatters' @@ -110,7 +111,9 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) { const arrival = useMemo(() => { if (located) return located.entry?.arrival ?? null if (!stage || stopPlaceId == null) return null - const index = stage.stops.findIndex(s => s.placeId === stopPlaceId) + // A stored stop at the place: the hotel a day sets out from shares the place of the + // hotel's own stop and comes first on the card, with a clock of its own. + const index = stage.stops.findIndex(s => isStoredStop(s) && s.placeId === stopPlaceId) if (index === -1) return null return stage.schedule.entries[index]?.arrival ?? null }, [located, stage, stopPlaceId]) diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index 0556c25e9..56b12a4f3 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -1,5 +1,5 @@ import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo' -// FE-TP-ROAD-001 to FE-TP-ROAD-134 +// FE-TP-ROAD-001 to FE-TP-ROAD-147 import React from 'react' import { renderHook, act, waitFor } from '@testing-library/react' import { TranslationProvider } from '../../i18n/TranslationContext' @@ -3091,3 +3091,174 @@ describe('useTripPlanner road trip: the drive between two days (#2461)', () => { expect(rt.vias.reanchor).toHaveBeenLastCalledWith(5, { vias: [], remove: [9] }) }) }) + +describe('useTripPlanner road trip: a booked night at the edge of a day', () => { + const NO_VIA = 'The drive from or to your stay keeps its road.' + const bookend = (phase: 'morning' | 'evening', over: Record) => ({ + assignmentId: phase === 'morning' ? -6_000_000_010 : -6_000_000_011, + placeId: 900, + name: 'Hotel', + stopType: 'hotel', + bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: phase === 'morning', checkingIn: phase === 'evening', checkOut: null }, + ...over, + }) + /** + * A transfer day west to east: out of one hotel, two places, into the next. Four points + * on one straight road, so every leg is a stretch of the line of its own. + */ + const transferDay = () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.routes.days = [{ + dayId: 5, + dayNumber: 1, + stops: [ + bookend('morning', { ownerIndex: 0, lat: 53, lng: 9 }), + { assignmentId: 11, ownerIndex: 0, lat: 53, lng: 10, name: 'Lookout' }, + { assignmentId: 12, ownerIndex: 1, lat: 53, lng: 11, name: 'Falls' }, + bookend('evening', { ownerIndex: 2, lat: 53, lng: 12 }), + ], + geometry: [[53, 9], [53, 10], [53, 11], [53, 12]], + }] + } + + it('FE-TP-ROAD-140: a click on the drive out of the morning hotel puts no via there, and says why', async () => { + transferDay() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.addRoadtripVia(53, 9.5) }) + + expect(rt.vias.add).not.toHaveBeenCalled() + expect(toasts).toContainEqual({ message: NO_VIA, type: 'info' }) + }) + + it('FE-TP-ROAD-141: nor on the drive into tonight’s hotel, while the drive between the places still takes one', async () => { + transferDay() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.addRoadtripVia(53, 11.5) }) + expect(rt.vias.add).not.toHaveBeenCalled() + expect(toasts.filter(t => t.message === NO_VIA)).toHaveLength(1) + + await act(async () => { await result.current.addRoadtripVia(53, 10.5) }) + expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53, 10.5) + }) + + it('FE-TP-ROAD-142: a via dragged onto the drive from or to a hotel keeps the leg it was on', async () => { + transferDay() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.moveRoadtripVia(5, 3, 53, 9.5) }) + await act(async () => { await result.current.moveRoadtripVia(5, 3, 53, 11.5) }) + + expect(rt.vias.move).toHaveBeenNthCalledWith(1, 5, 3, 53, 9.5, undefined) + expect(rt.vias.move).toHaveBeenNthCalledWith(2, 5, 3, 53, 11.5, undefined) + }) + + it('FE-TP-ROAD-143: a place added on the drive out of the hotel comes first, one on the drive into the next comes last', async () => { + transferDay() + const { result } = await renderRoadtrip() + + // Card positions: after the morning hotel, and in front of the evening one. + expect(result.current.manualStopTargetFor(53, 9.5)).toMatchObject({ dayId: 5, position: 1 }) + expect(result.current.manualStopTargetFor(53, 11.5)).toMatchObject({ dayId: 5, position: 3 }) + }) + + it('FE-TP-ROAD-144: a place added between the first two places lands between them, not in front of the first', async () => { + // The morning hotel shares the first place's index. Found by that index, it stood in + // for the place, and the new one went in one stop early. + transferDay() + const { result } = await renderRoadtrip() + + expect(result.current.manualStopTargetFor(53, 10.5)).toMatchObject({ dayId: 5, position: 2 }) + }) + + it('FE-TP-ROAD-145: behind a terminal, and on the road after the next place, a place lands where it fell', async () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + const carrier = (role: 'departure' | 'arrival') => + ({ reservationId: 70, type: 'flight', role, title: 'LH 2020', code: null, at: null }) + rt.routes.days = [{ + dayId: 5, + dayNumber: 1, + stops: [ + { assignmentId: 11, ownerIndex: 0, lat: 53, lng: 9, name: 'Hamburg' }, + { assignmentId: -3000000140, placeId: -70, ownerIndex: 1, lat: 53, lng: 10, carrier: carrier('departure') }, + { assignmentId: -3000000141, placeId: -70, ownerIndex: 1, lat: 53, lng: 11, carrier: carrier('arrival') }, + { assignmentId: 12, ownerIndex: 1, lat: 53, lng: 12, name: 'Munich' }, + { assignmentId: 13, ownerIndex: 2, lat: 53, lng: 13, name: 'Garmisch' }, + ], + geometry: [[53, 9], [53, 10], [53, 11], [53, 12], [53, 13]], + }] + const { result } = await renderRoadtrip() + + // Right behind the arrival, not between the two ends of the flight. + expect(result.current.manualStopTargetFor(53, 11.5)).toMatchObject({ dayId: 5, position: 3 }) + // Behind Munich, which shares its index with the terminals seated in front of it. + expect(result.current.manualStopTargetFor(53, 12.5)).toMatchObject({ dayId: 5, position: 4 }) + }) + + it('FE-TP-ROAD-146: the inspector speaks for the stay’s own stop, whatever bookends stand at its place', async () => { + seedTrip({ places: [buildPlace({ id: 900 })], days: [buildDay({ id: 5 }), buildDay({ id: 6 })] }) + rt.routes.days = [ + { + dayId: 5, + stops: [ + { assignmentId: 21, placeId: 900, lat: 53, lng: 9, night: true }, + { assignmentId: 22, placeId: 901, lat: 53, lng: 10 }, + bookend('evening', { ownerIndex: 2, lat: 53, lng: 9 }), + ], + }, + { + dayId: 6, + stops: [bookend('morning', { assignmentId: -6_000_000_012, ownerIndex: 0, lat: 53, lng: 9 }), { assignmentId: 23, placeId: 902, lat: 53, lng: 11 }], + }, + ] + useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' } })) + const { result } = await renderRoadtrip() + + act(() => { result.current.handlePlaceClick(900) }) + + // One stop answers for the place, so its day end can be set there. + expect(result.current.roadtripEndDay?.active).toBe(false) + await act(async () => result.current.roadtripEndDay?.onToggle()) + expect(actions.setAssignmentEndDay).toHaveBeenCalledWith(42, 5, 21, true) + }) + + it('FE-TP-ROAD-147: a way chosen for the first leg after the morning hotel is still that leg', async () => { + const LINE: [number, number][] = [[53, 10], [53, 11]] + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.routes.days = [{ + dayId: 5, + dayNumber: 1, + stops: [ + bookend('morning', { ownerIndex: 0, lat: 53, lng: 9 }), + { assignmentId: 11, ownerIndex: 0, lat: 53, lng: 10, name: 'Lookout' }, + { assignmentId: 12, ownerIndex: 1, lat: 53, lng: 11, name: 'Falls' }, + ], + legs: [{ distance: 70_000, duration: 3_600 }, { distance: 70_000, duration: 3_600 }], + legLines: [[[53, 9], [53, 10]], LINE], + geometry: [[53, 9], [53, 10], [53, 11]], + }] + rt.vias.byDay = { 5: [via(1, 5, 0, 0, 53.1, 10.5)] } + rt.alt.open = { + dayId: 5, drive: { kind: 'leg', index: 1 }, loading: false, error: false, proving: null, + anchor: { dayId: 5, afterIndex: 0 }, + ends: { from: 11, to: 12 }, + engine: 'osrm', + route: rt.legRoute, + routes: [ + { coordinates: [[53, 10], [53.1, 10.5], [53, 11]], distance: 80_000, duration: 4_000, divergence: null, current: true }, + { coordinates: LINE, distance: 70_000, duration: 3_600, divergence: null, direct: true }, + ], + } + rt.legRoute.mockResolvedValue({ coordinates: LINE, distance: 70_000, duration: 3_600, fellBack: false }) + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.chooseRouteAlternative(1) }) + + expect(toasts.filter(t => t.message.includes('changed'))).toEqual([]) + expect(rt.vias.addMany).toHaveBeenCalledWith(5, [], [0]) + }) +}) diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index dc50ea6ce..80d0316fd 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -31,7 +31,7 @@ import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDay import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries' import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag' import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow' -import { carrierReservationIds, viasLeaving, type CarrierTerminal } from '@trek/shared/roadtrip' +import { carrierReservationIds, isStoredStop, viasLeaving, type CarrierTerminal } from '@trek/shared/roadtrip' import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview' import { useDawarichTrail } from '../../components/Map/useDawarichTrail' import { collapsedDayDates } from '../../components/Map/dawarichTrail' @@ -1746,7 +1746,20 @@ export function useTripPlanner() { // (#2428). Nothing can be filed against a terminal: it stands in for no assignment, // so a via anchored to it would be stored at a position that belongs to the stop // after it and bend that stop's road instead. The callers decide what to refuse. - let best: { dayId: number; afterIndex: number; offRouteKm: number; terminal: CarrierTerminal['role'] | null } | null = null + // + // `bookendLeg` says the drive leaves or reaches a booked night's hotel at the day's + // edge, which files nothing either: the morning's hotel has no index of its own, and + // the evening's drive is reached from the index that shapes the road into tomorrow. + // `card` is where the point fell on the card it was measured on, the day and the index + // of the stop before it there, -1 for the drive in before the card's first stop. + let best: { + dayId: number + afterIndex: number + offRouteKm: number + terminal: CarrierTerminal['role'] | null + bookendLeg: boolean + card: { dayId: number; index: number } + } | null = null for (const day of roadtripRoutes.days) { // NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored // on, but that day's stops are no longer all on the card of the same name: after a @@ -1782,7 +1795,14 @@ export function useTripPlanner() { if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) { const owner = arrivedFrom.ownerDayId ?? day.dayId if (onlyDayId !== undefined && owner !== onlyDayId) continue - best = { dayId: owner, afterIndex: arrivedFrom.ownerIndex ?? 0, offRouteKm: hit.offRouteKm, terminal: arrivedFrom.carrier?.role ?? null } + best = { + dayId: owner, + afterIndex: arrivedFrom.ownerIndex ?? 0, + offRouteKm: hit.offRouteKm, + terminal: arrivedFrom.carrier?.role ?? null, + bookendLeg: !!arrivedFrom.bookend || !!day.stops[0]?.bookend, + card: { dayId: day.dayId, index: -1 }, + } continue } const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1 @@ -1799,7 +1819,14 @@ export function useTripPlanner() { const owner = anchor.ownerDayId ?? day.dayId // A drag stays on its own day; a fresh click may land wherever it landed. if (onlyDayId !== undefined && owner !== onlyDayId) continue - best = { dayId: owner, afterIndex: anchor.ownerIndex ?? at, offRouteKm: hit.offRouteKm, terminal: anchor.carrier?.role ?? null } + best = { + dayId: owner, + afterIndex: anchor.ownerIndex ?? at, + offRouteKm: hit.offRouteKm, + terminal: anchor.carrier?.role ?? null, + bookendLeg: !!anchor.bookend || !!day.stops[at + 1]?.bookend, + card: { dayId: day.dayId, index: at }, + } } return best }, [roadtripRoutes.days]) @@ -1823,8 +1850,17 @@ export function useTripPlanner() { // Nothing is stopped at on a flight. Behind an arrival terminal or a hire car's desk // is a road, and a stop there is the first stop after landing or after the pick-up. if (!anchor || anchor.terminal === 'departure') return null + // Behind a terminal, and on the drive from the hotel a day sets out from or to the one + // it ends at, the place goes where it fell on the card. None of them is a stored stop + // to be found by its index, which each shares with one: after the morning's hotel is + // before the day's first stop, before the evening's is after its last. + if (anchor.bookendLeg || anchor.terminal) { + return { dayId: anchor.card.dayId, position: anchor.card.index + 1, offRouteKm: anchor.offRouteKm } + } for (const day of roadtripRoutes.days) { - const at = day.stops.findIndex(stop => stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex) + // The stored stop the anchor names, not a terminal or a hotel seated in front of it + // with the same index, which put the place one stop early. + const at = day.stops.findIndex(stop => isStoredStop(stop) && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex) if (at >= 0) return { dayId: day.dayId, position: at + 1, offRouteKm: anchor.offRouteKm } } // No card draws that stop, which happens while the rail is between rebuilds. Its own @@ -1911,6 +1947,9 @@ export function useTripPlanner() { // ride, or on the road out of a terminal: a via is filed by the position of a stored // stop, and a terminal is not one. if (!best || best.offRouteKm > 2 || best.terminal) return + // Nor on the drive from or to a booked night's hotel, which says so rather than + // ignoring the click (`legReroutable`). + if (best.bookendLeg) { toast.info(t('roadtrip.bookend.noVia')); return } try { await roadtripVias.add(best.dayId, best.afterIndex, lat, lng) } catch (err: unknown) { @@ -1930,7 +1969,7 @@ export function useTripPlanner() { // just says which leg gets bent, and the router answers the rest. const anchor = anchorFor(lat, lng, dayId) try { - await roadtripVias.move(dayId, id, lat, lng, anchor && !anchor.terminal ? anchor.afterIndex : undefined) + await roadtripVias.move(dayId, id, lat, lng, anchor && !anchor.terminal && !anchor.bookendLeg ? anchor.afterIndex : undefined) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } @@ -2653,8 +2692,11 @@ export function useTripPlanner() { } const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null + // The stops the inspector speaks for. A booked night at a day's edge stands on the + // hotel's place without being a stop of the day, so it is left out: counted, the hotel's + // own stop lost its stay and its day end to a second match. const selectedRoadtripStops = roadtripRoutes.days.flatMap(day => day.stops).filter(stop => - !stop.automaticNight && (selectedAssignmentId ? stop.assignmentId === selectedAssignmentId : stop.placeId === selectedPlaceId), + !stop.automaticNight && !stop.bookend && (selectedAssignmentId ? stop.assignmentId === selectedAssignmentId : stop.placeId === selectedPlaceId), ) const endDayStop = selectedRoadtripStops.length === 1 ? selectedRoadtripStops[0] : undefined const roadtripEndDay = roadtripActive && dailyTimesActive && can('day_edit', trip) && endDayStop && endDayStop.assignmentId > 0 diff --git a/client/tests/unit/mobile/trip/MMapArea.test.tsx b/client/tests/unit/mobile/trip/MMapArea.test.tsx index 85d5442c8..ea3502a7a 100644 --- a/client/tests/unit/mobile/trip/MMapArea.test.tsx +++ b/client/tests/unit/mobile/trip/MMapArea.test.tsx @@ -16,7 +16,7 @@ import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRou import { openLeg } from '../../../helpers/legAlternatives' import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives' -// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-042 +// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-043 // // 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. @@ -889,3 +889,35 @@ describe('MMapArea booking routes on the day-scoped plan map (#2456)', () => { expect(drawnBookingIds()).toEqual([7]) }) }) + +describe('MMapArea and a booked night at the edge of the stage', () => { + it('FE-MOB-MAPAREA-043: a pin at the hotel opens its stored stop, never the bookend in front of it, else the inspector', () => { + const base = drivePlanner(3) + const [before, loop] = base.roadtripRoutes.days + const hotel = (placeId: number) => ({ + ...loop.stops[0], + assignmentId: -6_000_000_006, + placeId, + ownerIndex: 0, + stopType: 'hotel', + bookend: { phase: 'morning', accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null }, + }) + const withBookend = (placeId: number) => ({ + ...base, + roadtripRoutes: { ...base.roadtripRoutes, days: [before, { ...loop, stops: [hotel(placeId), ...loop.stops] }] }, + }) as TripPlanner + const shell = stageShell() + const { unmount } = render() + tapPin(11) + expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 3, assignmentId: 31 }) + unmount() + + // A hotel the drive has as its bookend only: the road trip has no stop to open. + const only = withBookend(99) + const other = stageShell() + render() + tapPin(99) + expect(other.openSheet).not.toHaveBeenCalled() + expect(only.handleMarkerClick).toHaveBeenCalledWith(99) + }) +}) diff --git a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx index ab075df45..2c2fcedbd 100644 --- a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx +++ b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx @@ -1,15 +1,15 @@ import { describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen } from '../../../helpers/render' import { - RtAutoRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome, + RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome, } from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripRows' -import type { RoadtripRow, StopRow } from '../../../../src/components/Roadtrip/roadtripRowModel' +import type { BookendReading, RoadtripRow, StopRow } from '../../../../src/components/Roadtrip/roadtripRowModel' import type { CarrierTerminal, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip' import type { Reservation, TranslationFn } from '../../../../src/types' import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch' import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion' -// FE-MOB-RTROW-001 to FE-MOB-RTROW-056 +// FE-MOB-RTROW-001 to FE-MOB-RTROW-058 // Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy. const t: TranslationFn = (key, params) => @@ -801,3 +801,45 @@ describe('RtSpillRow', () => { expect(screen.queryByText('roadtrip.spill.departs:21:30')).toBeNull() }) }) + +describe('RtBookendRow', () => { + const reading = (over: Partial = {}): BookendReading => ({ + phase: 'morning', + variant: 'checkOut', + name: 'Hotel Alpenblick', + until: '10:00', + reservationId: 41, + accommodationId: 5, + placeId: 900, + ...over, + }) + const row = (over: Partial = {}) => + stopRow({ number: null, service: true, time: '08:40', stop: { ...stopRow().stop, name: 'Hotel Alpenblick', stopType: 'hotel' }, ...over }) + + it('FE-MOB-RTROW-057: says which night it is on the bed a stay wears, with no number, and opens on a tap', () => { + const onOpen = vi.fn() + const { container } = render() + + expect(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick')).toBeInTheDocument() + expect(screen.getByText('roadtrip.stay.until:10:00')).toBeInTheDocument() + expect(screen.getByText('08:40')).toBeInTheDocument() + expect(container.querySelector('.lucide-bed-double')).not.toBeNull() + expect(screen.queryByText('2')).toBeNull() + fireEvent.click(screen.getByRole('button')) + fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' }) + expect(onOpen).toHaveBeenCalledTimes(2) + }) + + it('FE-MOB-RTROW-058: an evening says nothing under its name but what the drive into it ran over', () => { + const warning: ScheduleWarning = { index: 3, code: 'leg', overMinutes: 30 } + const quiet = render() + expect(screen.getByText('roadtrip.bookend.back:Hotel Alpenblick')).toBeInTheDocument() + expect(screen.queryByText(/roadtrip\.stay\.until/)).toBeNull() + expect(screen.queryByText('08:40')).toBeNull() + quiet.unmount() + + render() + expect(screen.getByText('30 min')).toBeInTheDocument() + expect(screen.getByText('7:10 PM')).toBeInTheDocument() + }) +}) diff --git a/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx b/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx index eb49fa21f..069bbce9b 100644 --- a/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx +++ b/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx @@ -10,7 +10,7 @@ import type { CarrierTerminal, RoadtripDay, RoadtripRoutes, RoadtripStop, RouteS import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives' import { openLeg } from '../../../helpers/legAlternatives' -// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-061 +// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-064 // // The stage bar pictures the place its day ends at. It reads that place out of the trip // store rather than the planner, the unfiltered list, so the picture tests seed the store. @@ -937,6 +937,77 @@ describe('MRoadtripTab', () => { fireEvent.click(within(notice).getByRole('button', { name: 'roadtrip.ride.open' })) expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 70 }) }) + + describe('a booked night at the edge of the stage', () => { + const bookend = (phase: 'morning' | 'evening', ownerIndex: number): RoadtripStop => ({ + ...real(phase === 'morning' ? -6_000_000_004 : -6_000_000_005, 900, 'Hotel Alpenblick'), + ownerIndex, + lat: 47.2, + lng: 11.4, + stopType: 'hotel', + bookend: { phase, accommodationId: 5, reservationId: 41, checkingOut: phase === 'morning', checkingIn: false, checkOut: phase === 'morning' ? '10:00' : null }, + }) + /** Out of the hotel on its check-out morning, two places, and into the night's hotel. */ + const hotelStage = (first: RoadtripStop[] = []) => stage({ + stops: [...first, bookend('morning', 0), real(503, 103, 'Kyoto Station'), { ...real(505, 105, 'Munich'), ownerIndex: 1 }, bookend('evening', 2)], + legs: [...first.map((): RouteSegment => ({ ...seg('', ''), distance: 0, from: [47.2, 11.4], to: [47.2, 11.4] })), seg('12 km', '20 min'), seg('30 km', '35 min'), seg('40 km', '45 min')], + schedule: { + entries: [...first.map(() => entry('08:00')), entry('08:40'), entry('09:00', '09:30'), entry('10:05'), entry('10:50')], + warnings: [], + }, + legVias: [[], [], [], []], driveWarnings: [], spills: [], dryPoints: [], + }) + const hotelBooking = booking({ id: 41, title: 'Hotel Alpenblick', type: 'hotel', day_id: 1 }) + + it('FE-MOB-RTTAB-062: the stage leaves from the hotel and comes back to one, with no number and no kind on either', () => { + renderTab(planner({ roadtripRoutes: routes({ days: [hotelStage()] }) })) + + expect(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick')).toBeInTheDocument() + expect(screen.getByText('roadtrip.stay.until:10:00')).toBeInTheDocument() + expect(screen.getByText('roadtrip.bookend.back:Hotel Alpenblick')).toBeInTheDocument() + // Only the two places offer to become a stop on the way, and the count says two. + expect(screen.getAllByRole('button', { name: 'roadtrip.stop.makeService' })).toHaveLength(2) + expect(screen.getByText('roadtrip.day.stopCount:2')).toBeInTheDocument() + // The stage starts when the hotel is left and ends when the next one is reached. + const start = screen.getByText('mobileTrip.rtStart').parentElement as HTMLElement + expect(within(start).getByText('08:40')).toBeInTheDocument() + const arrive = screen.getByText('roadtrip.stay.arrive').parentElement as HTMLElement + expect(within(arrive).getByText('10:50')).toBeInTheDocument() + }) + + it('FE-MOB-RTTAB-063: a tap opens the booking behind the night, or the stay for somebody who may not edit bookings', () => { + const editor = planner({ roadtripRoutes: routes({ days: [hotelStage()] }), reservations: [hotelBooking] }) + const first = renderTab(editor) + fireEvent.click(screen.getByText('roadtrip.bookend.back:Hotel Alpenblick')) + expect(editor.setEditingReservation).toHaveBeenCalledWith(hotelBooking) + expect(editor.setShowReservationModal).toHaveBeenCalledWith(true) + expect(first.shell.openSheet).not.toHaveBeenCalled() + first.unmount() + + const dayEditor = planner({ + roadtripRoutes: routes({ days: [hotelStage()] }), + reservations: [hotelBooking], + can: (action: string) => action !== 'reservation_edit', + }) + const { shell } = renderTab(dayEditor) + fireEvent.click(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick')) + expect(shell.openSheet).toHaveBeenCalledWith('accommodation', { dayId: 2, accId: 5 }) + expect(dayEditor.setEditingReservation).not.toHaveBeenCalled() + }) + + it('FE-MOB-RTTAB-064: a reader gets the hotel as a place, and the morning marker there is the hotel row', () => { + const reader = planner({ + roadtripRoutes: routes({ days: [hotelStage([{ ...bookend('morning', 0), assignmentId: -2_000_000_003, bookend: undefined, automaticNight: { phase: 'start', fromDayNumber: 1 } } as RoadtripStop])] }), + can: vi.fn(() => false), + }) + const { shell } = renderTab(reader) + + expect(screen.queryByText('roadtrip.window.resume')).toBeNull() + fireEvent.click(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick')) + expect(reader.handlePlaceClick).toHaveBeenCalledWith(900) + expect(shell.openSheet).not.toHaveBeenCalled() + }) + }) }) describe('day swipe', () => { diff --git a/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx b/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx index 3902c3a2b..e07fdfa96 100644 --- a/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx +++ b/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx @@ -10,7 +10,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { resetAllStores, seedStore } from '../../../helpers/store' import { fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-RTSTAY-001 to FE-MOB-RTSTAY-025 +// FE-MOB-RTSTAY-001 to FE-MOB-RTSTAY-026 // // The sheet renders inside the real TranslationProvider, so the copy is asserted // in English. @@ -322,3 +322,29 @@ describe('MRtStaySheet', () => { }) }) }) + +describe('MRtStaySheet and a booked night at the edge of the stage', () => { + beforeEach(() => { + resetAllStores() + }) + + it('FE-MOB-RTSTAY-026: the arrival is the stop of the place, not the hotel the day sets out from there', () => { + // The morning's hotel stands on Bremen's place at the head of the card, with a clock of + // its own. Found by the place, it put 07:10 in the preview for a stop reached at 22:30. + const hotel = { + ...STAGE.stops[1], + assignmentId: -6_000_000_022, + ownerIndex: 0, + stopType: 'hotel', + bookend: { phase: 'morning', accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null }, + } + const withHotel = { + ...STAGE, + stops: [hotel, ...STAGE.stops], + schedule: { entries: [{ arrival: '07:10', departure: '07:10', anchored: false, dayOffset: 0 }, ...STAGE.schedule.entries], warnings: [] }, + } as unknown as RoadtripDay + renderSheet({ roadtripRoutes: { days: [withHotel] } }, { sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 30, name: 'Bremen Marktplatz' } } }) + expect(screen.getByText('22:30')).toBeInTheDocument() + expect(screen.queryByText('07:10')).not.toBeInTheDocument() + }) +}) diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts index 898099d4a..d93adb4a6 100644 --- a/server/src/nest/roadtrip/roadtrip-plan.service.ts +++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts @@ -11,6 +11,7 @@ import { carrierLegsFor, carrierSeam, foldRouteRun, + isStationaryJoin, splitIntoRuns, spillChains, dayWindow, @@ -20,7 +21,7 @@ import { formatDurationShort, seatCarrierStops, undatedRides, - viasLeaving, + viasOnLeg, type CarrierBooking, type CarrierSeam, type DistanceUnit, @@ -269,8 +270,9 @@ export class RoadtripPlanService { stops.forEach((stop, index) => { stopAt.push(points.length); points.push({ lat: stop.lat, lng: stop.lng }); + // None bend the drive from or to a booked night's hotel (`viasOnLeg`). if (index < stops.length - 1) - points.push(...viasLeaving(stop, context.vias).map((v) => ({ lat: v.lat, lng: v.lng }))); + points.push(...viasOnLeg(stop, stops[index + 1], context.vias).map((v) => ({ lat: v.lat, lng: v.lng }))); }); try { if (points.length > 100) throw new Error('Too many waypoints'); @@ -336,7 +338,8 @@ export class RoadtripPlanService { const pairs = chain.stops.slice(0, -1).map((from, index) => ({ from, to: chain.stops[index + 1] })); if (connectDays && previous && chain.stops.length) pairs.push({ from: previous, to: chain.stops[0] }); for (const { from, to } of pairs) { - if (asked.has(stopKey(from) + '>' + stopKey(to))) continue; + // A night spent at one hotel is no drive, and no router is asked about it. + if (isStationaryJoin(from, to) || asked.has(stopKey(from) + '>' + stopKey(to))) continue; await fetchRun( [from, to], chain.dayId, diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 40cb12892..2a643e5a3 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -283,5 +283,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': 'فتح الحجز', 'roadtrip.ride.undated': 'الحجز {title} لا يقع في أي يوم من أيام هذه الرحلة، لذلك لا يستخدمه المسار.', 'roadtrip.bookings.loose': 'محجوز أيضًا في هذا اليوم', + 'roadtrip.bookend.checkOut': 'تسجيل المغادرة · {name}', + 'roadtrip.bookend.from': 'من {name}', + 'roadtrip.bookend.back': 'العودة إلى {name}', + 'roadtrip.bookend.checkIn': 'تسجيل الوصول · {name}', + 'roadtrip.bookend.noVia': 'يبقى الطريق من مكان إقامتك وإليه كما هو.', }; export default roadtrip; diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index b35e79fe9..9558f0114 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -289,5 +289,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.undated': 'A reserva {title} não está em nenhum dia desta viagem, por isso o percurso não a considera.', 'roadtrip.bookings.loose': 'Também reservado neste dia', + 'roadtrip.bookend.checkOut': 'Check-out · {name}', + 'roadtrip.bookend.from': 'Saindo de {name}', + 'roadtrip.bookend.back': 'Volta para {name}', + 'roadtrip.bookend.checkIn': 'Check-in · {name}', + 'roadtrip.bookend.noVia': 'O trajeto de ida e volta da sua hospedagem mantém o próprio caminho.', }; export default roadtrip; diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 53ebf8678..f5a3c0f73 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -292,5 +292,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.undated': 'La reserva {title} no és a cap dia d’aquest viatge, així que el trajecte no la té en compte.', 'roadtrip.bookings.loose': 'També reservat aquest dia', + 'roadtrip.bookend.checkOut': 'Sortida · {name}', + 'roadtrip.bookend.from': 'Des de {name}', + 'roadtrip.bookend.back': 'Tornada a {name}', + 'roadtrip.bookend.checkIn': 'Entrada · {name}', + 'roadtrip.bookend.noVia': 'El trajecte des del teu allotjament o fins a ell manté la seva ruta.', }; export default roadtrip; diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 375dfc370..1421f06a3 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -286,5 +286,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': 'Otevřít rezervaci', 'roadtrip.ride.undated': 'Rezervace {title} nepatří k žádnému dni této cesty, proto s ní jízda nepočítá.', 'roadtrip.bookings.loose': 'Také rezervováno na tento den', + 'roadtrip.bookend.checkOut': 'Check-out · {name}', + 'roadtrip.bookend.from': 'Z ubytování {name}', + 'roadtrip.bookend.back': 'Zpět do ubytování {name}', + 'roadtrip.bookend.checkIn': 'Check-in · {name}', + 'roadtrip.bookend.noVia': 'Trasa z vašeho ubytování a do něj zůstává beze změny.', }; export default roadtrip; diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 0552839be..c86414591 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -286,5 +286,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.undated': 'Die Buchung {title} liegt auf keinem Tag dieser Reise, deshalb plant die Fahrt nicht mit ihr.', 'roadtrip.bookings.loose': 'Außerdem an diesem Tag gebucht', + 'roadtrip.bookend.checkOut': 'Check-out · {name}', + 'roadtrip.bookend.from': 'Ab {name}', + 'roadtrip.bookend.back': 'Zurück zu {name}', + 'roadtrip.bookend.checkIn': 'Check-in · {name}', + 'roadtrip.bookend.noVia': 'Die Fahrt von und zu deiner Unterkunft behält ihre Strecke.', }; export default roadtrip; diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index f04c5dd5e..2ebef9cae 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -283,5 +283,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': 'Open booking', 'roadtrip.ride.undated': '{title} is on none of this trip’s days, so the drive does not use it.', 'roadtrip.bookings.loose': 'Also booked this day', + 'roadtrip.bookend.checkOut': 'Check-out · {name}', + 'roadtrip.bookend.from': 'From {name}', + 'roadtrip.bookend.back': 'Back to {name}', + 'roadtrip.bookend.checkIn': 'Check-in · {name}', + 'roadtrip.bookend.noVia': 'The drive from or to your stay keeps its road.', }; export default roadtrip; diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index fb3c98db2..0a24cba69 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -290,5 +290,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.undated': 'La reserva {title} no está en ningún día de este viaje, así que el trayecto no la tiene en cuenta.', 'roadtrip.bookings.loose': 'También reservado este día', + 'roadtrip.bookend.checkOut': 'Salida · {name}', + 'roadtrip.bookend.from': 'Desde {name}', + 'roadtrip.bookend.back': 'Vuelta a {name}', + 'roadtrip.bookend.checkIn': 'Entrada · {name}', + 'roadtrip.bookend.noVia': 'El trayecto desde tu alojamiento o hasta él mantiene su ruta.', }; export default roadtrip; diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index aacf5dbc6..f9d097afc 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -292,5 +292,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.undated': 'La réservation {title} n’est sur aucun jour de ce voyage, le trajet n’en tient donc pas compte.', 'roadtrip.bookings.loose': 'Également réservé ce jour', + 'roadtrip.bookend.checkOut': 'Départ · {name}', + 'roadtrip.bookend.from': 'Depuis {name}', + 'roadtrip.bookend.back': 'Retour à {name}', + 'roadtrip.bookend.checkIn': 'Arrivée · {name}', + 'roadtrip.bookend.noVia': 'Le trajet depuis ou vers votre hébergement garde son itinéraire.', }; export default roadtrip; diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index a59fa1e04..b2dfcc295 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -294,5 +294,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.undated': 'Η κράτηση {title} δεν βρίσκεται σε καμία μέρα αυτού του ταξιδιού, οπότε η διαδρομή δεν τη λαμβάνει υπόψη.', 'roadtrip.bookings.loose': 'Επίσης κρατημένο αυτή τη μέρα', + 'roadtrip.bookend.checkOut': 'Check-out · {name}', + 'roadtrip.bookend.from': 'Από {name}', + 'roadtrip.bookend.back': 'Επιστροφή: {name}', + 'roadtrip.bookend.checkIn': 'Check-in · {name}', + 'roadtrip.bookend.noVia': 'Η διαδρομή προς και από το κατάλυμα διατηρεί τον δρόμο της.', }; export default roadtrip; diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index b82b34a76..c8fe40a83 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -288,5 +288,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': 'Foglalás megnyitása', 'roadtrip.ride.undated': '{title}: a foglalás az utazás egyik napjára sem esik, ezért az útvonal nem számol vele.', 'roadtrip.bookings.loose': 'Erre a napra még lefoglalva', + 'roadtrip.bookend.checkOut': 'Kijelentkezés · {name}', + 'roadtrip.bookend.from': 'Indulás: {name}', + 'roadtrip.bookend.back': 'Vissza: {name}', + 'roadtrip.bookend.checkIn': 'Bejelentkezés · {name}', + 'roadtrip.bookend.noVia': 'A szálláshoz vezető és onnan induló út útvonala nem változik.', }; export default roadtrip; diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index fe0e8d9b8..50abae47b 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -291,5 +291,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.undated': 'Pemesanan {title} tidak berada di hari mana pun dalam perjalanan ini, jadi perjalanan tidak memperhitungkannya.', 'roadtrip.bookings.loose': 'Juga dipesan hari ini', + 'roadtrip.bookend.checkOut': 'Check-out · {name}', + 'roadtrip.bookend.from': 'Dari {name}', + 'roadtrip.bookend.back': 'Kembali ke {name}', + 'roadtrip.bookend.checkIn': 'Check-in · {name}', + 'roadtrip.bookend.noVia': 'Perjalanan dari dan ke penginapan Anda tetap pada jalurnya.', }; export default roadtrip; diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 0fd28c588..b5ac61f0f 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -291,5 +291,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.undated': 'La prenotazione {title} non cade in nessun giorno di questo viaggio, quindi il percorso non la considera.', 'roadtrip.bookings.loose': 'Prenotato anche questo giorno', + 'roadtrip.bookend.checkOut': 'Check-out · {name}', + 'roadtrip.bookend.from': 'Da {name}', + 'roadtrip.bookend.back': 'Ritorno a {name}', + 'roadtrip.bookend.checkIn': 'Check-in · {name}', + 'roadtrip.bookend.noVia': 'Il tragitto da e verso il tuo alloggio mantiene il suo percorso.', }; export default roadtrip; diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index 40b168838..a2bb0ad07 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -285,5 +285,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': '予約を開く', 'roadtrip.ride.undated': '{title} はこの旅行のどの日にも入っていないため、走行では使われません。', 'roadtrip.bookings.loose': 'この日のその他の予約', + 'roadtrip.bookend.checkOut': 'チェックアウト · {name}', + 'roadtrip.bookend.from': '{name}から', + 'roadtrip.bookend.back': '{name}へ戻る', + 'roadtrip.bookend.checkIn': 'チェックイン · {name}', + 'roadtrip.bookend.noVia': '宿泊先との間の区間は、今のルートのままになります。', }; export default roadtrip; diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 7ec3abb24..4f064f3c1 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -282,5 +282,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': '예약 열기', 'roadtrip.ride.undated': '{title}은(는) 이 여행의 어느 날에도 속하지 않아 주행에 반영되지 않습니다.', 'roadtrip.bookings.loose': '이날의 다른 예약', + 'roadtrip.bookend.checkOut': '체크아웃 · {name}', + 'roadtrip.bookend.from': '{name}에서 출발', + 'roadtrip.bookend.back': '{name}(으)로 돌아가기', + 'roadtrip.bookend.checkIn': '체크인 · {name}', + 'roadtrip.bookend.noVia': '숙소를 오가는 구간은 지금 경로를 그대로 유지합니다.', }; export default roadtrip; diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index ba0aa5eb9..cd9e1197d 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -290,5 +290,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.undated': 'De boeking {title} valt op geen enkele dag van deze reis, dus de rit houdt er geen rekening mee.', 'roadtrip.bookings.loose': 'Ook geboekt op deze dag', + 'roadtrip.bookend.checkOut': 'Uitchecken · {name}', + 'roadtrip.bookend.from': 'Vanaf {name}', + 'roadtrip.bookend.back': 'Terug naar {name}', + 'roadtrip.bookend.checkIn': 'Inchecken · {name}', + 'roadtrip.bookend.noVia': 'De rit van en naar je verblijf houdt zijn route.', }; export default roadtrip; diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index a6c7eff05..6c6c5c6ff 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -287,5 +287,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': 'Otwórz rezerwację', 'roadtrip.ride.undated': 'Rezerwacja {title} nie przypada na żaden dzień tej podróży, więc trasa jej nie uwzględnia.', 'roadtrip.bookings.loose': 'Zarezerwowane też tego dnia', + 'roadtrip.bookend.checkOut': 'Wymeldowanie · {name}', + 'roadtrip.bookend.from': 'Wyjazd: {name}', + 'roadtrip.bookend.back': 'Powrót: {name}', + 'roadtrip.bookend.checkIn': 'Zameldowanie · {name}', + 'roadtrip.bookend.noVia': 'Trasa do noclegu i z niego pozostaje bez zmian.', }; export default roadtrip; diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index 80c002880..22bf5c26a 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -291,5 +291,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.undated': 'Бронирование {title} не относится ни к одному дню этой поездки, поэтому маршрут его не учитывает.', 'roadtrip.bookings.loose': 'Также забронировано в этот день', + 'roadtrip.bookend.checkOut': 'Выезд · {name}', + 'roadtrip.bookend.from': 'Отправление: {name}', + 'roadtrip.bookend.back': 'Возвращение: {name}', + 'roadtrip.bookend.checkIn': 'Заезд · {name}', + 'roadtrip.bookend.noVia': 'Маршрут от места проживания и обратно к нему остаётся прежним.', }; export default roadtrip; diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 3cc5ca03b..19bbde618 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -286,5 +286,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': 'Öppna bokning', 'roadtrip.ride.undated': 'Bokningen {title} ligger inte på någon av resans dagar, så körningen tar inte med den.', 'roadtrip.bookings.loose': 'Även bokat denna dag', + 'roadtrip.bookend.checkOut': 'Utcheckning · {name}', + 'roadtrip.bookend.from': 'Från {name}', + 'roadtrip.bookend.back': 'Tillbaka till {name}', + 'roadtrip.bookend.checkIn': 'Incheckning · {name}', + 'roadtrip.bookend.noVia': 'Resan till och från ditt boende behåller sin väg.', }; export default roadtrip; diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 9b9505f1e..3558b909b 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -289,5 +289,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.undated': '{title} rezervasyonu bu yolculuğun hiçbir gününde değil, bu yüzden sürüş onu hesaba katmıyor.', 'roadtrip.bookings.loose': 'Bu gün için ayrıca rezerve edildi', + 'roadtrip.bookend.checkOut': 'Çıkış · {name}', + 'roadtrip.bookend.from': 'Başlangıç: {name}', + 'roadtrip.bookend.back': 'Dönüş: {name}', + 'roadtrip.bookend.checkIn': 'Giriş · {name}', + 'roadtrip.bookend.noVia': 'Konaklama yerinize gidiş ve dönüş yolu değişmez.', }; export default roadtrip; diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index d519b7844..0019e4f2b 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -288,5 +288,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': 'Відкрити бронювання', 'roadtrip.ride.undated': 'Бронювання {title} не припадає на жоден день цієї подорожі, тому маршрут його не враховує.', 'roadtrip.bookings.loose': 'Також заброньовано цього дня', + 'roadtrip.bookend.checkOut': 'Виїзд · {name}', + 'roadtrip.bookend.from': 'Відправлення: {name}', + 'roadtrip.bookend.back': 'Повернення: {name}', + 'roadtrip.bookend.checkIn': 'Заїзд · {name}', + 'roadtrip.bookend.noVia': 'Маршрут від місця проживання й назад до нього лишається незмінним.', }; export default roadtrip; diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 3c4ef5e60..05a3b7305 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -287,5 +287,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': 'Mở đặt chỗ', 'roadtrip.ride.undated': 'Đặt chỗ {title} không thuộc ngày nào của chuyến đi này, nên hành trình không tính đến nó.', 'roadtrip.bookings.loose': 'Cũng đã đặt trong ngày này', + 'roadtrip.bookend.checkOut': 'Trả phòng · {name}', + 'roadtrip.bookend.from': 'Từ {name}', + 'roadtrip.bookend.back': 'Về lại {name}', + 'roadtrip.bookend.checkIn': 'Nhận phòng · {name}', + 'roadtrip.bookend.noVia': 'Chặng đi và về nơi lưu trú giữ nguyên tuyến đường.', }; export default roadtrip; diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 61141d738..bb53dc685 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -272,5 +272,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': '開啟預訂', 'roadtrip.ride.undated': '{title} 不在本次旅行的任何一天,因此行程不會用到它。', 'roadtrip.bookings.loose': '當天的其他預訂', + 'roadtrip.bookend.checkOut': '退房 · {name}', + 'roadtrip.bookend.from': '從 {name} 出發', + 'roadtrip.bookend.back': '返回 {name}', + 'roadtrip.bookend.checkIn': '入住 · {name}', + 'roadtrip.bookend.noVia': '往返住宿地點的路段維持原本的路線。', }; export default roadtrip; diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index ff561545a..fe29c1570 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -273,5 +273,10 @@ const roadtrip: TranslationStrings = { 'roadtrip.ride.open': '打开预订', 'roadtrip.ride.undated': '{title} 不在本次旅行的任何一天,因此行程不会用到它。', 'roadtrip.bookings.loose': '当天的其他预订', + 'roadtrip.bookend.checkOut': '退房 · {name}', + 'roadtrip.bookend.from': '从 {name} 出发', + 'roadtrip.bookend.back': '返回 {name}', + 'roadtrip.bookend.checkIn': '入住 · {name}', + 'roadtrip.bookend.noVia': '往返住处的路段保持原有路线。', }; export default roadtrip; diff --git a/shared/src/roadtrip/assemble.spec.ts b/shared/src/roadtrip/assemble.spec.ts index e609b91fc..fc35bae7c 100644 --- a/shared/src/roadtrip/assemble.spec.ts +++ b/shared/src/roadtrip/assemble.spec.ts @@ -1,6 +1,8 @@ /** - * ROADTRIP-ASSEMBLE-001..016: what the assembler does with the model's warnings, and - * what it tells a surface about the drive between connected days. + * ROADTRIP-ASSEMBLE-001..022: what the assembler does with the model's warnings, and + * what it tells a surface about the drive between connected days. From 017 on: a booked + * night seated at both ends of its days (`seatNightBookends`) is one spot overnight, + * driven to and from like any stop and never between. * * The model itself is pinned by roadtripModel.spec.ts. This file pins the * wrapper, which is where the arguments are chosen: `deriveDriveWarnings` takes @@ -12,7 +14,9 @@ */ import { assembleRoadtrip } from './assemble'; import type { DayWindow } from './dayWindow'; -import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types'; +import { seatNightBookends } from './nightBookends'; +import type { PlanDay, RoadtripStop, RoutedLeg, SnappedWaypoint } from './planning-types'; +import { standsAsDay } from './roadtripModel'; import { describe, it, expect } from 'vitest'; @@ -379,3 +383,149 @@ describe('assembleRoadtrip booked nights', () => { expect(schedule.warnings).toEqual([]); }); }); + +describe('assembleRoadtrip nights at a booked hotel', () => { + // Three nights in one hotel from day 1, places on days 1 and 2, the check-out on day 3 + // with nothing of its own. + const days = [1, 2, 3].map((id) => ({ id, day_number: id })); + const hotelStay = { + id: 5, + place_id: 900, + start_day_id: 1, + end_day_id: 3, + place_lat: 45, + place_lng: 7, + place_name: 'Hotel', + check_out: '10:00', + reservation_id: null, + }; + const checkIn = stop({ ownerIndex: 0, lat: 45, lng: 7, night: true, stopType: 'hotel', checkInTime: '15:00' }); + const stored: PlanDay[] = [ + { + dayId: 1, + dayNumber: 1, + date: '2026-06-01', + title: null, + stops: [checkIn, stop({ ownerIndex: 1 }), stop({ ownerIndex: 2 })], + }, + { + dayId: 2, + dayNumber: 2, + date: '2026-06-02', + title: null, + stops: [ + stop({ ownerDayId: 2, ownerIndex: 0, assignmentId: 300, placeId: 400, lat: 60 }), + stop({ ownerDayId: 2, ownerIndex: 1, assignmentId: 301, placeId: 401, lat: 61 }), + ], + }, + { dayId: 3, dayNumber: 3, date: '2026-06-03', title: null, stops: [] }, + ]; + const seated = seatNightBookends(stored, days, [hotelStay]); + const evening = seated[0]!.stops[3]!; + const morning = seated[1]!.stops[0]!; + const hotelKey = stopKey(checkIn); + + function assembleNights( + options: { connectDays?: boolean; window?: DayWindow | null; snaps?: Record } = {}, + ) { + const allLegs: Record = {}; + for (const day of seated) { + day.stops.slice(0, -1).forEach((from, i) => { + allLegs[legKey(from, day.stops[i + 1]!)] = leg(100); + }); + } + // A drive fetched for the hotel and itself some other time: the night is none. + allLegs[legKey(evening, morning)] = leg(172); + return assembleRoadtrip({ + plan: seated.filter((d) => standsAsDay(d.stops)), + quietDays: seated.filter((d) => !standsAsDay(d.stops)), + window: options.window ?? null, + distanceUnit: 'metric', + allLegs, + snapByDay: options.snaps ? { 1: options.snaps } : {}, + missedByDay: {}, + loading: false, + limits: { rangeKm: null, legMinutes: null, dayMinutes: null }, + vehicleKind: null, + connectDays: options.connectDays ?? false, + boundaries: [], + labels: { start: 'start', end: 'end' }, + }); + } + const names = (stops: RoadtripStop[]) => stops.map((s) => s.bookend?.phase ?? s.automaticNight?.phase ?? s.name); + + it('ROADTRIP-ASSEMBLE-017: connected days draw no band, no line and no figure across a night at one hotel', () => { + const routes = assembleNights({ connectDays: true }); + const second = routes.days[1]!; + expect(second.arrivingLeg).toBeUndefined(); + expect(second.arrivingLine).toBeUndefined(); + expect(second.arrivingFrom).toBeUndefined(); + // Three legs a day, the drive to the hotel and from it included, and nothing between. + expect(routes.lines).toHaveLength(6); + expect(routes.lineJoins).toEqual([false, false, false, false, false, false]); + expect(routes.segments).toHaveLength(6); + }); + + it('ROADTRIP-ASSEMBLE-018: the drives to and from the hotel count, the hotel is no destination', () => { + const routes = assembleNights({ connectDays: true }); + expect(routes.days.map((d) => d.distance)).toEqual([300_000, 300_000]); + expect(routes.totalDistance).toBe(600_000); + // The four places; the stay's own stop and its bookends are the hotel, which is not counted. + expect(routes.totalStops).toBe(4); + // The check-out day with nothing of its own is not driven, and holds no hotel either. + expect(routes.days.map((d) => d.dayId)).toEqual([1, 2]); + expect(routes.quietDays.flatMap((d) => d.stops)).toEqual([]); + }); + + it('ROADTRIP-ASSEMBLE-019: a quiet day lists no hotel, even one left alone on it', () => { + // Where every stop after the morning's hotel went on past midnight, the hotel is all + // that is left on the card: nothing anybody could move to another day. + const alone: PlanDay = { ...seated[1]!, stops: [morning] }; + const routes = assembleRoadtrip({ + plan: [alone], + quietDays: [], + window: null, + distanceUnit: 'metric', + allLegs: {}, + snapByDay: {}, + missedByDay: {}, + loading: false, + limits: { rangeKm: null, legMinutes: null, dayMinutes: null }, + vehicleKind: null, + connectDays: false, + boundaries: [], + labels: { start: 'start', end: 'end' }, + }); + expect(routes.days).toEqual([]); + expect(routes.quietDays).toEqual([expect.objectContaining({ dayId: 2, stops: [] })]); + }); + + it('ROADTRIP-ASSEMBLE-020: a day window runs the night at the hotel without a gap and ends the day there', () => { + const routes = assembleNights({ window: { start: 8 * 60, end: 18 * 60 } }); + expect(routes.dayWindowIssue).toBeNull(); + const [first, second] = routes.days; + // The drive to the hotel is not cut at 18:00: the night is booked there. + expect(names(first!.stops)).toEqual(['Stop 0', 'Stop 1', 'Stop 2', 'evening', 'end']); + expect(first!.stops[4]!).toMatchObject({ lat: 45, lng: 7 }); + expect(first!.stops[4]!.bookend).toBeUndefined(); + expect(names(second!.stops)).toEqual(['start', 'morning', 'Stop 0', 'Stop 1', 'evening']); + expect(second!.schedule.entries.slice(0, 2).map((e) => e.arrival)).toEqual(['08:00', '08:00']); + }); + + it('ROADTRIP-ASSEMBLE-021: a boundary can only be dragged along a drive between two stored stops', () => { + const path = assembleNights({ window: { start: 8 * 60, end: 18 * 60 } }).boundaryPath ?? []; + expect(path.map((p) => [p.from.assignmentId, p.to.assignmentId])).toEqual([ + [100, 101], + [101, 102], + [300, 301], + ]); + }); + + it('ROADTRIP-ASSEMBLE-022: the walk from the road to the hotel is drawn once, however often the days stand there', () => { + const snap: SnappedWaypoint = { asked: [45, 7], at: [45.001, 7], meters: 110 }; + const routes = assembleNights({ connectDays: true, snaps: { [hotelKey]: snap } }); + expect(routes.accessLines.filter((a) => a.stopKey === hotelKey)).toHaveLength(1); + // Every stop there still says how far the road is. + expect(routes.days[1]!.stops[0]!.offRoadMeters).toBe(110); + }); +}); diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts index fcb464103..96f0c7f9f 100644 --- a/shared/src/roadtrip/assemble.ts +++ b/shared/src/roadtrip/assemble.ts @@ -3,6 +3,7 @@ import { carriesTheCar, isCarrierMode, isPickupStop } from './carriers'; import { pointAtMeters } from './corridor'; import type { RoadtripDayBoundary } from './day-boundary.schema'; import { planDayWindow, type DayWindow } from './dayWindow'; +import { isStationaryJoin, withStationaryJoins } from './nightBookends'; import { spillChains } from './nightSpill'; import type { PlanDay, @@ -63,11 +64,13 @@ export function assembleRoadtrip({ boundaries: RoadtripDayBoundary[]; labels: { start: string; end: string }; }): RoadtripRoutes { - const chains = spillChains(plan, quietDays, (a, b) => allLegs[legKey(a, b)]); + // A night spent at one hotel is a leg going nowhere, whatever was fetched for the pair: + // the hotel in the evening and the same hotel the next morning are one spot. + const storedLegFor = withStationaryJoins((from, to) => allLegs[legKey(from, to)]); + const chains = spillChains(plan, quietDays, storedLegFor); const allSnaps: Record = {}; for (const day of plan) Object.assign(allSnaps, snapByDay[day.dayId] ?? {}); - const storedLegFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)]; const timed = window ? planDayWindow( [...plan, ...quietDays], @@ -103,6 +106,8 @@ export function assembleRoadtrip({ const lineJoins: boolean[] = []; const segments: RouteSegment[] = []; const accessLines: RoadtripRoutes['accessLines'] = []; + const spurKeys = new Set(); + const bookendSpots = new Set(); const out: RoadtripDay[] = []; let carryKm: number | null = 0; @@ -122,7 +127,13 @@ export function assembleRoadtrip({ inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber }); } - const joined = inboundAt.has(0) ? undefined : previousStop && legFor(previousStop, chain.stops[0]!); + // No band for a night spent at one hotel: nothing is driven between the evening and + // the morning, and a band would read "0 km" between a hotel and itself. + const first = chain.stops[0]; + const joined = + inboundAt.has(0) || !previousStop || !first || isStationaryJoin(previousStop, first) + ? undefined + : legFor(previousStop, first); if (joined) { inboundAt.set(0, { seg: joined.seg, line: joined.line, drawnAs: previousDayNumber ?? chain.dayNumber }); arrivingLeg = joined.seg; @@ -188,9 +199,17 @@ export function assembleRoadtrip({ const schedule = chain.schedule; const legVias = routed.map((l) => l?.vias ?? []); const stops = chain.stops.map((s) => { - const snap = s.automaticNight ? undefined : allSnaps[stopKey(s)]; + const key = stopKey(s); + const snap = s.automaticNight ? undefined : allSnaps[key]; const line = spurFor(snap); - if (line) accessLines.push({ line, meters: snap!.meters, stopKey: stopKey(s) }); + // A hotel the days start and end at is one walk from the road, however many of its + // bookends and its own stop stand there: drawn once. + const twice = spurKeys.has(key) && (!!s.bookend || bookendSpots.has(key)); + if (line && !twice) { + accessLines.push({ line, meters: snap!.meters, stopKey: key }); + spurKeys.add(key); + } + if (s.bookend) bookendSpots.add(key); return { ...s, offRoadMeters: line ? snap!.meters : null }; }); @@ -341,9 +360,17 @@ export function assembleRoadtrip({ 0, ), + // A quiet day lists what could be moved onto it, and a bookend is nothing anybody moves: + // it is left behind alone only when every stop after it went on past midnight. quietDays: out .filter((d) => !standsAsDay(d.stops) && !d.spills?.length && !d.stops.some((s) => s.automaticNight)) - .map((d) => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })), + .map((d) => ({ + dayId: d.dayId, + dayNumber: d.dayNumber, + date: d.date, + title: d.title, + stops: d.stops.filter((s) => !s.bookend), + })), loading, }; } diff --git a/shared/src/roadtrip/carriers.spec.ts b/shared/src/roadtrip/carriers.spec.ts index 51ad5485a..93ca9524a 100644 --- a/shared/src/roadtrip/carriers.spec.ts +++ b/shared/src/roadtrip/carriers.spec.ts @@ -1,5 +1,5 @@ /** - * ROADTRIP-CARRIERS-001..029: a booking the traveller rides becomes a seam in the drive, + * ROADTRIP-CARRIERS-001..031: a booking the traveller rides becomes a seam in the drive, * and a hire car puts its desks on it. * * The road ends at the terminal the ride leaves from and starts again at the one it @@ -10,6 +10,8 @@ * on no day is named rather than dropped, and a ride within one day is seated where it * adds the least road, as far as the clock leaves a choice (#2461). From 028 on: a ride * that lands on a later day is not seated there by the slot seeded on the day it left. + * From 030 on: a booked night at a day's edge takes no via, and a terminal says which + * edge of the day it holds. */ import { assembleRoadtrip } from './assemble'; import { @@ -20,15 +22,18 @@ import { carrierReservationIds, carrierSeam, carriesTheCar, + closesTheDay, isCarrierMode, isPickupStop, isUndatedRide, + opensTheDay, rideSeatAfter, sameDayRide, seatCarrierStops, terminalAssignmentId, undatedRides, viasLeaving, + viasOnLeg, type CarrierBooking, type SeatItem, } from './carriers'; @@ -815,3 +820,51 @@ describe('a ride that lands on a later day (#2461)', () => { expect(car.arrival!.position).toBe(1.5); }); }); + +describe('a booked night at the edge of a day', () => { + const hotel = (phase: 'morning' | 'evening', ownerIndex: number) => + stop({ + ownerIndex, + bookend: { + phase, + accommodationId: 5, + reservationId: null, + checkingOut: false, + checkingIn: false, + checkOut: null, + }, + }); + const vias = [ + { day_id: 1, after_order_index: 0, sequence: 0, lat: 1, lng: 1 }, + { day_id: 1, after_order_index: 1, sequence: 0, lat: 2, lng: 2 }, + ]; + + it('ROADTRIP-CARRIERS-030: no via leaves the hotel, and none bends the drive to tonight’s', () => { + // The morning's hotel borrows the first stop's index; the points filed there are + // that stop's, for the drive leaving it. + expect(viasLeaving(hotel('morning', 0), vias)).toEqual([]); + expect(viasOnLeg(hotel('morning', 0), stop({ ownerIndex: 0 }), vias)).toEqual([]); + expect(viasOnLeg(stop({ ownerIndex: 0 }), stop({ ownerIndex: 1 }), vias)).toEqual([vias[0]]); + // The points behind the day's last stop shape the road into tomorrow, which the drive + // to tonight's hotel is not. + expect(viasOnLeg(stop({ ownerIndex: 1 }), hotel('evening', 2), vias)).toEqual([]); + expect(viasLeaving(stop({ ownerIndex: 1 }), vias)).toEqual([vias[1]]); + }); + + it('ROADTRIP-CARRIERS-031: a landing or a pick-up opens a day, a departure or a hand-back closes it', () => { + expect(['arrival', 'pickup', 'departure', 'return', undefined].map((role) => opensTheDay(role as never))).toEqual([ + true, + true, + false, + false, + false, + ]); + expect(['arrival', 'pickup', 'departure', 'return', undefined].map((role) => closesTheDay(role as never))).toEqual([ + false, + false, + true, + true, + false, + ]); + }); +}); diff --git a/shared/src/roadtrip/carriers.ts b/shared/src/roadtrip/carriers.ts index 57569bbcd..e52a7eda2 100644 --- a/shared/src/roadtrip/carriers.ts +++ b/shared/src/roadtrip/carriers.ts @@ -475,10 +475,15 @@ function terminalStop( } /** Whether a terminal opens a day it has no timed stop before it on: an arrival or a pick-up. */ -function opensTheDay(role: CarrierTerminal['role']): boolean { +export function opensTheDay(role: CarrierTerminal['role'] | undefined): boolean { return role === 'arrival' || role === 'pickup'; } +/** Whether a terminal closes the day it stands last on: a departure or a hire car handed back. */ +export function closesTheDay(role: CarrierTerminal['role'] | undefined): boolean { + return role === 'departure' || role === 'return'; +} + /** * The day's stops with the terminals of its rides, and the desks of its hire cars, seated * among them. @@ -653,20 +658,38 @@ export function carrierLegsFor( } /** - * The via points shaping the drive that leaves a stop. None leave a terminal: a via is - * filed by the position of a stored stop, and a terminal stands in for none, so the - * points filed at its index belong to the stop that really has that index. + * The via points shaping the drive that leaves a stop. None leave a terminal or a booked + * night at a day's edge: a via is filed by the position of a stored stop, and neither + * stands in for one, so the points filed at its index belong to the stop that really has + * that index. */ export function viasLeaving( - stop: Pick, + stop: Pick, vias: readonly V[], ): V[] { - if (stop.carrier) return []; + if (stop.carrier || stop.bookend) return []; return vias .filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex) .sort((a, b) => a.sequence - b.sequence); } +/** + * The via points shaping the drive from `from` to `to`. + * + * Those leaving `from`, unless the drive ends at a booked night's hotel. The points behind + * a day's last stored stop shape the road from it into the next day (`carriedSeam`), and + * with the night booked that road is no longer driven: the day ends at the hotel. Bent + * through them, the drive to the hotel would run out along tomorrow's road and back. They + * stay stored, and bend the road again once the day ends at its last stop. + */ +export function viasOnLeg( + from: Pick, + to: Pick, + vias: readonly V[], +): V[] { + return to.bookend ? [] : viasLeaving(from, vias); +} + /** * The bookings whose ride the days draw, for the map to show their arcs beside the roads. * A hire car is left out: its line on the map is the road the drive already draws. diff --git a/shared/src/roadtrip/dayWindow.spec.ts b/shared/src/roadtrip/dayWindow.spec.ts index 365b01823..23eebc50a 100644 --- a/shared/src/roadtrip/dayWindow.spec.ts +++ b/shared/src/roadtrip/dayWindow.spec.ts @@ -1,6 +1,7 @@ import { MAX_TRIP_DAYS } from '../trip/trip.schema'; import { dayWindow, planDayWindow, roadtripInsertion } from './dayWindow'; -import type { RoadtripStop, RoutedLeg } from './planning-types'; +import { withStationaryJoins } from './nightBookends'; +import type { BookendPhase, RoadtripStop, RoutedLeg } from './planning-types'; import { describe, expect, it } from 'vitest'; @@ -529,3 +530,136 @@ describe('a time set to leave a stop, with daily travel times', () => { expect(night!.leaveAt).toBeUndefined(); }); }); + +describe('night bookends in the day window', () => { + const HOTEL = { lat: 1, lng: 1 }; + const bookend = (id: number, dayId: number, phase: BookendPhase, ownerIndex: number, at = HOTEL) => + stop(id, { + assignmentId: -6_000_000_000 - id, + ownerDayId: dayId, + ownerIndex, + ...at, + stopType: 'hotel', + name: `${phase} hotel`, + bookend: { + phase, + accommodationId: 5, + reservationId: null, + checkingOut: false, + checkingIn: false, + checkOut: null, + }, + }); + const minutesBetween = (table: Record) => + withStationaryJoins((a, b) => leg(table[`${a.assignmentId}>${b.assignmentId}`] ?? 60, a, b)); + const names = (chain: { stops: RoadtripStop[] } | undefined) => + (chain?.stops ?? []).map((s) => s.automaticNight?.phase ?? s.name); + + // Day 1 drives to the hotel it sleeps in, day 2 sets out from it. + const p1 = stop(1, { time: '08:00' }); + const p2 = stop(2, { dwellMinutes: 60 }); + const evening = bookend(90, 1, 'evening', 2); + const morning = bookend(91, 2, 'morning', 0); + const p3 = stop(3, { ownerDayId: 2, ownerIndex: 0, lng: 7 }); + const p4 = stop(4, { ownerDayId: 2, ownerIndex: 1, lng: 8 }); + // Five hours to the second place, five more to the hotel: in at 19:00, past the window. + const long = minutesBetween({ '1>2': 300, [`2>${evening.assignmentId}`]: 300 }); + const twoDays = (first: RoadtripStop[], second: RoadtripStop[] = [morning, p3, p4]) => [ + day(1, first), + day(2, second), + ]; + + it('never cuts the drive to tonight’s hotel, and closes the day there', () => { + const plan = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels); + expect(plan.issue).toBeNull(); + expect(names(plan.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']); + expect(plan.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['08:00', '13:00', '19:00', '19:00']); + expect(names(plan.chains[1])).toEqual(['start', 'morning hotel', 'Stop 3', 'Stop 4']); + expect(plan.chains[1]!.schedule.entries.slice(0, 2).map((e) => e.arrival)).toEqual(['08:00', '08:00']); + }); + + it('never puts the drive to tonight’s hotel off until the morning either', () => { + const plan = planDayWindow(twoDays([p1, p2, evening]), { ...hours, endMode: 'stop' }, long, 'metric', labels); + expect(plan.issue).toBeNull(); + expect(names(plan.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']); + }); + + it('leaves the marker at the hotel without the hotel’s booking on it', () => { + const plan = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels); + const markers = plan.chains.flatMap((c) => c.stops.filter((s) => s.automaticNight)); + expect(markers).toHaveLength(2); + for (const marker of markers) { + expect(marker.bookend).toBeUndefined(); + expect(marker).toMatchObject(HOTEL); + } + }); + + it('ends a day ended at its last place behind the hotel, by hand or by the stop', () => { + const short = minutesBetween({}); + const byStop = planDayWindow(twoDays([p1, { ...p2, endDay: true }, evening]), hours, short, 'metric', labels); + expect(byStop.issue).toBeNull(); + expect(names(byStop.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']); + const byHand = planDayWindow(twoDays([p1, p2, evening]), hours, short, 'metric', labels, [ + { day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }, + ]); + expect(byHand.issue).toBeNull(); + expect(names(byHand.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']); + expect(byHand.chains[0]!.stops[3]!.automaticNight).toMatchObject({ manual: true }); + }); + + it('lets a boundary across the night go: the booked night ends the day there already', () => { + const plan = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels, [ + { day_number: 1, from_assignment_id: 2, to_assignment_id: 3, fraction: 0.5 }, + ]); + expect(plan.issue).toBeNull(); + expect(names(plan.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']); + // Across a stop it is still the conflict it always was. + const across = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels, [ + { day_number: 1, from_assignment_id: 1, to_assignment_id: 3, fraction: 0.5 }, + ]); + expect(across.issue).toBe('conflict'); + }); + + it('sets out from the hotel early for a place pinned before the window opens', () => { + const pinned = { ...p3, time: '07:00' }; + const plan = planDayWindow( + twoDays([p1, p2, evening], [morning, pinned, p4]), + hours, + minutesBetween({}), + 'metric', + labels, + ); + expect(plan.issue).toBeNull(); + expect(plan.chains[1]!.schedule.entries.slice(0, 3).map((e) => e.arrival)).toEqual(['06:00', '06:00', '07:00']); + }); + + it('does so on the first day of the drive too, where no marker comes before the hotel', () => { + const pinned = { ...p3, time: '07:00' }; + const plan = planDayWindow([day(2, [morning, pinned, p4])], hours, minutesBetween({}), 'metric', labels); + expect(plan.issue).toBeNull(); + expect(plan.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['06:00', '07:00', '08:00']); + }); + + it('keeps the drive between two hotels when the morning moves early', () => { + const other = bookend(92, 2, 'morning', 0, { lat: 1, lng: 3 }); + const pinned = { ...p3, time: '07:00' }; + const lookup = minutesBetween({ [`${evening.assignmentId}>${other.assignmentId}`]: 30 }); + const plan = planDayWindow(twoDays([p1, p2, evening], [other, pinned]), hours, lookup, 'metric', labels); + expect(plan.issue).toBeNull(); + expect(plan.chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['05:30', '06:00', '07:00']); + }); + + it('files a stop added behind tonight’s hotel or a desk behind the day’s last place', () => { + const card = { stops: [morning, p3, p4, bookend(93, 2, 'evening', 2)] }; + expect(roadtripInsertion(card, 0)).toEqual({ dayId: 2, position: 0 }); + expect(roadtripInsertion(card, 1)).toEqual({ dayId: 2, position: 0 }); + expect(roadtripInsertion(card, 3)).toEqual({ dayId: 2, position: 2 }); + expect(roadtripInsertion(card, 4)).toEqual({ dayId: 2, position: 2 }); + const handedBack = stop(9, { + ownerIndex: 2, + carrier: { reservationId: 4, type: 'car', role: 'return', title: 'Car', code: null, at: null }, + }); + expect(roadtripInsertion({ stops: [p1, p2, handedBack] }, 3)).toEqual({ dayId: 1, position: 2 }); + expect(roadtripInsertion({ stops: [p1, p2] }, 2)).toEqual({ dayId: 1, position: 2 }); + }); +}); diff --git a/shared/src/roadtrip/dayWindow.ts b/shared/src/roadtrip/dayWindow.ts index 6323d3ddf..da09ed47b 100644 --- a/shared/src/roadtrip/dayWindow.ts +++ b/shared/src/roadtrip/dayWindow.ts @@ -4,7 +4,7 @@ import type { RoadtripDayBoundary } from './day-boundary.schema'; import type { SpillChain } from './nightSpill'; import type { RoadtripDay, RoadtripStop, RoutedLeg } from './planning-types'; import type { DistanceUnit } from './planning-types'; -import { formatClock, hasChosenArrival, leaveAfter, parseClock } from './roadtripModel'; +import { formatClock, hasChosenArrival, isStoredStop, leaveAfter, parseClock } from './roadtripModel'; import { formatDurationShort } from './roadtripModel'; import { formatDistance } from './units'; @@ -70,7 +70,13 @@ function portion(leg: RoutedLeg, from: number, to: number, unit: DistanceUnit): }; } -function stationary(stop: RoadtripStop): RoutedLeg { +/** + * The leg of a drive that goes nowhere: from a stop to the same spot, nothing measured. + * + * Between the last stop of a day and the marker that closes it, and between a booked + * night's hotel in the evening and the same hotel the next morning (`withStationaryJoins`). + */ +export function stationary(stop: RoadtripStop): RoutedLeg { const at: [number, number] = [stop.lat, stop.lng]; return { line: [], @@ -108,13 +114,36 @@ export function planDayWindow( const failed = (issue: WindowPlan['issue']): WindowPlan => ({ chains: [], legFor: lookup, issue }); if (!stops.length) return { chains: [], legFor: lookup, issue: null }; const positions = new Map(stops.map(({ stop }, i) => [stop.assignmentId, i])); + // Where a day ended at stop `i` really ends: behind tonight's hotel when the booked night + // stands right after it on the same day. Ended at the stop, the hotel would be put on the + // morning after, a drive to bed made the next day. + const behindTonight = (i: number): number => { + const next = stops[i + 1]?.stop; + return next?.bookend?.phase === 'evening' && next.ownerDayId === stops[i]!.stop.ownerDayId ? i + 1 : i; + }; + // A stop set to end its day ends it there, or behind tonight's hotel right after it. + const endsDayHere = (i: number): boolean => { + if (stops[i]!.stop.endDay) return behindTonight(i) === i; + return i > 0 && !!stops[i - 1]!.stop.endDay && behindTonight(i - 1) === i; + }; const targets = new Map(); let lastTarget = -1; for (const boundary of [...boundaries].sort((a, b) => a.day_number - b.day_number)) { const from = positions.get(boundary.from_assignment_id); const to = boundary.to_assignment_id === null ? null : positions.get(boundary.to_assignment_id); + // A boundary between two stops a booked night now stands between is left alone: the + // night ends the day there already, and the two are no longer next to each other only + // because the hotel stands in between. + if ( + from !== undefined && + to !== null && + to !== undefined && + to > from + 1 && + stops.slice(from + 1, to).every(({ stop }) => stop.bookend) + ) + continue; if (from === undefined || (to !== null && to !== from + 1)) return failed('conflict'); - const target = from + (to === null ? 0 : boundary.fraction); + const target = to === null ? behindTonight(from) : from + boundary.fraction; if (target <= lastTarget) return failed('conflict'); targets.set(boundary.day_number, target); lastTarget = target; @@ -178,8 +207,10 @@ export function planDayWindow( const last = chain.stops[chain.stops.length - 1]!; const end: RoadtripStop = { ...at, - // A marker placed where a terminal stands is a marker, not the terminal. + // A marker placed where a terminal or a booked night's hotel stands is a marker, not + // the terminal or the hotel. carrier: undefined, + bookend: undefined, assignmentId: -2000000000 - number * 2, name: labels.end, time: null, @@ -271,17 +302,34 @@ export function planDayWindow( let early: number | null = null; if (pin !== null) early = pin - minutes; else if (leave !== null && leave - minutes >= 0) early = leave - minutes; - if (early !== null && early < clock && chainAt(number).stops.every((s) => s.automaticNight)) { + // The hotel the day wakes up in is part of the morning, not a stop in the way of it: + // the day sets out from there early just the same. Everything before this leg moves + // back by the same minutes, so the marker keeps the drive to the hotel it has, which + // is none when the night was spent there. + const morning = chainAt(number); + if ( + early !== null && + early < clock && + morning.stops.every((s) => s.automaticNight || s.bookend?.phase === 'morning') + ) { + const shift = early - clock; + const moved = (at: string | null): number => (parseClock(at) ?? clock) + shift; + if (early < 0 || moved(morning.schedule.entries[0]!.arrival) < 0) return failed('conflict'); + for (const entry of morning.schedule.entries) { + entry.arrival = formatClock(Math.round(moved(entry.arrival))); + entry.departure = formatClock(Math.round(moved(entry.departure))); + } clock = early; - if (clock < 0) return failed('conflict'); - const startEntry = chainAt(number).schedule.entries[0]!; - startEntry.arrival = formatClock(Math.round(clock)); - startEntry.departure = formatClock(Math.round(clock)); } if (pin !== null && clock + minutes > pin + 1) return failed('conflict'); let fraction = 0; - const split = pin === null && (leg.seg.mode === 'driving' || leg.seg.mode === undefined); + // The drive to tonight's hotel is never cut by the window, and never put off to the + // morning: the night is booked there, so the day ends there, later than the window + // says if it must. Cut, the day ended on the road and the hotel was reached the next + // morning, just before the day set out from it again. + const split = + pin === null && stop.bookend?.phase !== 'evening' && (leg.seg.mode === 'driving' || leg.seg.mode === undefined); if (split && !targets.has(number) && window.endMode === 'stop' && clock + minutes > window.end + 0.000001) { if (minutes > window.end - window.start) return failed('legTooLong'); previous = night(previous); @@ -360,7 +408,7 @@ export function planDayWindow( } } } - if (target === position || (!targets.has(number) && stop.endDay && i < stops.length - 1)) { + if (target === position || (!targets.has(number) && endsDayHere(i) && i < stops.length - 1)) { previous = night(previous, i === stops.length - 1); if (issue) return failed(issue); } @@ -398,9 +446,17 @@ export function planDayWindow( }; } +/** + * Where a stop inserted at `index` of a card lands on the day it is stored on. + * + * In front of the stop at that index, which is its own index there. Behind the card's last + * stop, one further on, unless that stop is a terminal or tonight's hotel: those stand + * behind the stored stops with the index the next stored stop would have, which is the + * position a stop added at the end of the day takes. + */ export function roadtripInsertion(day: Pick, index: number) { const stop = day.stops[Math.min(Math.max(0, index), day.stops.length - 1)]!; - return stop - ? { dayId: stop.ownerDayId, position: stop.ownerIndex + (stop.automaticNight || index >= day.stops.length ? 1 : 0) } - : null; + if (!stop) return null; + const behind = stop.automaticNight || (index >= day.stops.length && isStoredStop(stop)); + return { dayId: stop.ownerDayId, position: stop.ownerIndex + (behind ? 1 : 0) }; } diff --git a/shared/src/roadtrip/nightBookends.spec.ts b/shared/src/roadtrip/nightBookends.spec.ts new file mode 100644 index 000000000..ccc3f5ba8 --- /dev/null +++ b/shared/src/roadtrip/nightBookends.spec.ts @@ -0,0 +1,508 @@ +/** + * ROADTRIP-BOOKENDS-001..027: a booked night stands at both ends of the days around it. + * + * Pinned here: which hotel a day wakes up in and which it sleeps in, where the two are + * seated and where they are not (the hotel already there, a landing, a departure, a hire + * car's desk, a day with nothing of its own), what a bookend carries and what it never + * does (a clock), that the choice does not depend on the order the stays come in, and + * that a night spent at one hotel is a leg going nowhere. + */ +import { terminalAssignmentId } from './carriers'; +import { + bookendAssignmentId, + bookendStaysOf, + hotelBookendsOn, + isStationaryJoin, + seatNightBookends, + withStationaryJoins, + type BookendStay, +} from './nightBookends'; +import type { CarrierTerminal, PlanDay, RoadtripStop, RoutedLeg } from './planning-types'; +import { standsAsDay } from './roadtripModel'; + +import { describe, expect, it } from 'vitest'; + +const days = [1, 2, 3, 4, 5, 6].map((n) => ({ id: n * 10, day_number: n })); +const [D1, D2, D3, D4] = [10, 20, 30, 40]; + +const GETAWAY: [number, number] = [-33.5, 150.4]; +const WALLINGA: [number, number] = [-34.1, 150.9]; +const HOTEL: [number, number] = [45.1, 7.6]; + +const stay = ( + id: number, + start: number, + end: number, + at: [number, number] | null, + over: Partial = {}, +): BookendStay => ({ + id, + place_id: 500 + id, + start_day_id: start, + end_day_id: end, + place_lat: at ? at[0] : null, + place_lng: at ? at[1] : null, + place_name: `Stay ${id}`, + check_out: '10:00', + reservation_id: 900 + id, + ...over, +}); + +let nextId = 1; +const visit = (dayId: number, ownerIndex: number, over: Partial = {}): RoadtripStop => { + const id = nextId++; + return { + assignmentId: id, + ownerDayId: dayId, + ownerIndex, + placeId: 100 + id, + name: `Place ${id}`, + lat: 40 + id * 0.01, + lng: 5 + id * 0.01, + time: null, + dwellMinutes: 30, + legMode: null, + incomingLegMode: null, + stopType: null, + ...over, + }; +}; +/** The stay's own stop on its check-in day, where the booking put it (#2354). */ +const stayStop = (dayId: number, ownerIndex: number, at: [number, number], over: Partial = {}) => + visit(dayId, ownerIndex, { lat: at[0], lng: at[1], night: true, stopType: 'hotel', ...over }); +const terminal = (dayId: number, ownerIndex: number, role: CarrierTerminal['role']): RoadtripStop => + visit(dayId, ownerIndex, { + carrier: { + reservationId: 77, + type: role === 'pickup' || role === 'return' ? 'car' : 'flight', + role, + title: 'Ride', + code: null, + at: null, + }, + assignmentId: terminalAssignmentId(77, role), + placeId: -77, + }); + +const planDay = (dayId: number, stops: RoadtripStop[]): PlanDay => ({ + dayId, + dayNumber: dayId / 10, + date: null, + title: null, + stops, +}); +const shape = (day: PlanDay | undefined): string[] => + (day?.stops ?? []).map((s) => + s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.carrier ? s.carrier.role : s.name, + ); + +describe('a check-in with places, then a transfer day with nothing of its own', () => { + const getaway = stay(1, D1, D2, GETAWAY, { check_out: '10:00' }); + const wallinga = stay(2, D2, D3, WALLINGA); + const checkIn = stayStop(D1, 0, GETAWAY, { name: 'Getaway', checkInTime: '14:00' }); + const lookout = visit(D1, 1, { name: 'Lookout' }); + const falls = visit(D1, 2, { name: 'Falls' }); + const wallingaStop = stayStop(D2, 0, WALLINGA, { name: 'Wallinga', checkInTime: '15:00' }); + const plan = [planDay(D1, [checkIn, lookout, falls]), planDay(D2, [wallingaStop]), planDay(D3, [])]; + const seated = seatNightBookends(plan, days, [getaway, wallinga]); + + it('ROADTRIP-BOOKENDS-001: the check-in day ends back at the stay, and nothing wakes up there', () => { + expect(shape(seated[0])).toEqual(['Getaway', 'Lookout', 'Falls', 'evening:1']); + const back = seated[0]!.stops[3]!; + expect(back.bookend).toEqual({ + phase: 'evening', + accommodationId: 1, + reservationId: 901, + checkingOut: false, + checkingIn: true, + checkOut: null, + }); + expect(back).toMatchObject({ + placeId: 501, + name: 'Stay 1', + lat: GETAWAY[0], + lng: GETAWAY[1], + ownerDayId: D1, + ownerIndex: 3, + stopType: 'hotel', + assignmentId: bookendAssignmentId(D1, 'evening'), + }); + }); + + it('ROADTRIP-BOOKENDS-002: the transfer day checks out of one stay and drives to the stop of the next', () => { + expect(shape(seated[1])).toEqual(['morning:1', 'Wallinga']); + const out = seated[1]!.stops[0]!; + expect(out.bookend).toMatchObject({ phase: 'morning', checkingOut: true, checkingIn: false, checkOut: '10:00' }); + expect(out.ownerIndex).toBe(0); + // Tonight's stay is already the day's last stop, so no evening is seated behind it. + expect(seated[1]!.stops).toHaveLength(2); + }); + + it('ROADTRIP-BOOKENDS-003: a day that is only a check-out gets no drive, and keeps its object', () => { + expect(seated[2]!.stops).toEqual([]); + expect(seated[2]).toBe(plan[2]); + }); +}); + +describe('three nights in one hotel, places on the days between', () => { + const hotel = stay(5, D1, D4, HOTEL); + const h = stayStop(D1, 0, HOTEL, { name: 'H', checkInTime: '15:00' }); + const plan = [ + planDay(D1, [h, visit(D1, 1, { name: 'P1' }), visit(D1, 2, { name: 'P2' })]), + planDay(D2, [visit(D2, 0, { name: 'P3' }), visit(D2, 1, { name: 'P4' })]), + planDay(D3, [visit(D3, 0, { name: 'P5' }), visit(D3, 1, { name: 'P6' })]), + planDay(D4, []), + ]; + const seated = seatNightBookends(plan, days, [hotel]); + + it('ROADTRIP-BOOKENDS-004: every day between the nights starts and ends at the hotel', () => { + expect(shape(seated[0])).toEqual(['H', 'P1', 'P2', 'evening:5']); + expect(shape(seated[1])).toEqual(['morning:5', 'P3', 'P4', 'evening:5']); + expect(shape(seated[2])).toEqual(['morning:5', 'P5', 'P6', 'evening:5']); + expect(seated[1]!.stops[0]!.bookend).toMatchObject({ checkingOut: false, checkingIn: false, checkOut: null }); + // The evening keeps the index the next stored stop would have, the terminals' rule. + expect(seated[1]!.stops[3]!.ownerIndex).toBe(2); + }); + + it('ROADTRIP-BOOKENDS-005: the empty check-out day is not driven', () => { + expect(seated[3]).toBe(plan[3]); + }); + + it('ROADTRIP-BOOKENDS-006: a day in the hotel with nothing planned stays quiet, one with a single place becomes a loop', () => { + const quiet = seatNightBookends([planDay(D2, [])], days, [hotel]); + expect(quiet[0]!.stops).toEqual([]); + const one = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' })])], days, [hotel]); + expect(shape(one[0])).toEqual(['morning:5', 'P', 'evening:5']); + }); + + it('ROADTRIP-BOOKENDS-007: the hotel already first or last on the day is not seated again on that side', () => { + const first = seatNightBookends( + [planDay(D2, [stayStop(D2, 0, HOTEL, { name: 'H again' }), visit(D2, 1, { name: 'P' })])], + days, + [hotel], + ); + expect(shape(first[0])).toEqual(['H again', 'P', 'evening:5']); + const last = seatNightBookends( + [planDay(D2, [visit(D2, 0, { name: 'P' }), stayStop(D2, 1, HOTEL, { name: 'H again' })])], + days, + [hotel], + ); + expect(shape(last[0])).toEqual(['morning:5', 'P', 'H again']); + }); +}); + +describe('a transfer day', () => { + const a = stay(1, D1, D2, GETAWAY); + const b = stay(2, D2, D3, WALLINGA); + + it('ROADTRIP-BOOKENDS-008: with nothing stored, the day drives from one stay to the other', () => { + const [day] = seatNightBookends([planDay(D2, [])], days, [a, b]); + expect(shape(day)).toEqual(['morning:1', 'evening:2']); + expect(day!.stops[0]!.bookend).toMatchObject({ checkingOut: true }); + expect(day!.stops[1]!.bookend).toMatchObject({ checkingIn: true, checkOut: null }); + expect(day!.stops.map((s) => s.ownerIndex)).toEqual([0, 0]); + }); + + it('ROADTRIP-BOOKENDS-009: with places, a place with its own earlier hour stays where it is', () => { + const p0 = visit(D2, 0, { name: 'P0', time: '11:00' }); + const checkIn = stayStop(D2, 1, WALLINGA, { name: 'B', checkInTime: '15:00' }); + const p1 = visit(D2, 2, { name: 'P1' }); + const [day] = seatNightBookends([planDay(D2, [p0, checkIn, p1])], days, [a, b]); + expect(shape(day)).toEqual(['morning:1', 'P0', 'B', 'P1', 'evening:2']); + }); +}); + +describe('a day that starts or ends on a ride or at a hire car desk', () => { + const hotel = stay(5, D1, D4, HOTEL); + + // Landing, or at the desk the car is picked up at: the traveller was not at the hotel. + it.each([{ role: 'arrival' }, { role: 'pickup' }] as const)( + 'ROADTRIP-BOOKENDS-010: no morning when $role opens the day', + ({ role }) => { + const [day] = seatNightBookends([planDay(D2, [terminal(D2, 0, role), visit(D2, 0, { name: 'P' })])], days, [ + hotel, + ]); + expect(shape(day)).toEqual([role, 'P', 'evening:5']); + }, + ); + + // At the gate, or where the car goes back: the traveller is not driving to the hotel. + it.each([{ role: 'departure' }, { role: 'return' }] as const)( + 'ROADTRIP-BOOKENDS-011: no evening when $role closes the day', + ({ role }) => { + const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' }), terminal(D2, 1, role)])], days, [ + hotel, + ]); + expect(shape(day)).toEqual(['morning:5', 'P', role]); + }, + ); + + it('ROADTRIP-BOOKENDS-012: a flight in the morning after a night sets off from the hotel', () => { + const [day] = seatNightBookends( + [planDay(D2, [terminal(D2, 0, 'departure'), terminal(D2, 0, 'arrival'), visit(D2, 0, { name: 'P' })])], + days, + [hotel], + ); + expect(shape(day)).toEqual(['morning:5', 'departure', 'arrival', 'P', 'evening:5']); + // Counted by stored stops, not by terminals. + expect(day!.stops[4]!.ownerIndex).toBe(1); + }); +}); + +describe('what a stay without the facts gets', () => { + it('ROADTRIP-BOOKENDS-013: a stay without coordinates gets no bookend, and the other side stands on its own', () => { + const a = stay(1, D1, D2, null); + const b = stay(2, D2, D3, WALLINGA); + const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' })])], days, [a, b]); + expect(shape(day)).toEqual(['P', 'evening:2']); + }); + + it('ROADTRIP-BOOKENDS-014: with no stay on the map at all, the plan comes back as it was', () => { + const plan = [planDay(D2, [visit(D2, 0), visit(D2, 1)])]; + expect(seatNightBookends(plan, days, [stay(1, D1, D3, null), stay(2, D1, D3, null)])).toBe(plan); + expect(seatNightBookends(plan, days, [stay(3, D1, D3, HOTEL, { place_id: null })])).toBe(plan); + expect(seatNightBookends(plan, days, [])).toBe(plan); + }); + + it('ROADTRIP-BOOKENDS-015: a stay entered without a booking still stands there, with nothing to open', () => { + const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' })])], days, [ + stay(5, D1, D3, HOTEL, { reservation_id: null }), + ]); + expect(day!.stops[0]!.bookend?.reservationId).toBeNull(); + expect(day!.stops[2]!.bookend?.reservationId).toBeNull(); + }); + + it('ROADTRIP-BOOKENDS-016: a stay that begins and ends on one day is slept in by nobody', () => { + const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' }), visit(D2, 1, { name: 'Q' })])], days, [ + stay(5, D2, D2, HOTEL), + ]); + expect(shape(day)).toEqual(['P', 'Q']); + }); +}); + +describe('two stays on one day', () => { + it('ROADTRIP-BOOKENDS-017: of two checked into on one day, the evening goes to the one entered first', () => { + const later = stay(7, D2, D3, WALLINGA); + const earlier = stay(3, D2, D3, HOTEL); + const [day] = seatNightBookends([planDay(D2, [visit(D2, 0), visit(D2, 1)])], days, [later, earlier]); + expect(day!.stops[2]!.bookend?.accommodationId).toBe(3); + }); + + it('ROADTRIP-BOOKENDS-018: overlapping stays follow the day plan: woken up in the older, tonight in the one checked into', () => { + const long = stay(1, D1, D4, HOTEL); + const short = stay(2, D2, D3, WALLINGA); + const plan = [planDay(D2, [visit(D2, 0, { name: 'P' })]), planDay(D3, [visit(D3, 0, { name: 'Q' })])]; + const seated = seatNightBookends(plan, days, [long, short]); + expect(shape(seated[0])).toEqual(['morning:1', 'P', 'evening:2']); + expect(shape(seated[1])).toEqual(['morning:1', 'Q', 'evening:1']); + }); + + it('ROADTRIP-BOOKENDS-019: the order the stays come in changes nothing', () => { + const stays = [stay(1, D1, D4, HOTEL), stay(2, D2, D3, WALLINGA), stay(3, D2, D3, GETAWAY)]; + const plan = [planDay(D2, [visit(D2, 0)]), planDay(D3, [visit(D3, 0)])]; + expect(seatNightBookends(plan, days, [...stays].reverse())).toEqual(seatNightBookends(plan, days, stays)); + }); +}); + +describe('what a bookend is', () => { + const hotel = stay(5, D1, D4, HOTEL); + const plan = [2, 3].map((n) => planDay(n * 10, [visit(n * 10, 0), visit(n * 10, 1)])); + const seated = seatNightBookends(plan, days, [hotel]); + const bookends = seated.flatMap((d) => d.stops.filter((s) => s.bookend)); + + it('ROADTRIP-BOOKENDS-020: one id per day and phase, the same every run, and below every terminal', () => { + const ids = bookends.map((s) => s.assignmentId); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toEqual([ + bookendAssignmentId(D2, 'morning'), + bookendAssignmentId(D2, 'evening'), + bookendAssignmentId(D3, 'morning'), + bookendAssignmentId(D3, 'evening'), + ]); + expect(seatNightBookends(plan, days, [hotel]).flatMap((d) => d.stops.map((s) => s.assignmentId))).toEqual( + seated.flatMap((d) => d.stops.map((s) => s.assignmentId)), + ); + for (const id of ids) expect(id).toBeLessThan(terminalAssignmentId(1_000_000, 'arrival')); + }); + + it('ROADTRIP-BOOKENDS-021: never a clock, a stay, a mode or a night of its own, whatever the stay says', () => { + const [day] = seatNightBookends([planDay(D4, [visit(D4, 0), visit(D4, 1)])], days, [ + stay(5, D1, D4, HOTEL, { check_out: '11:00' }), + ]); + const all = [...bookends, day!.stops[0]!]; + expect(day!.stops[0]!.bookend?.checkOut).toBe('11:00'); + for (const s of all) { + expect(s).toMatchObject({ + time: null, + leaveAt: null, + checkInTime: null, + dwellMinutes: 0, + night: false, + endDay: false, + legMode: null, + incomingLegMode: null, + fillPercent: null, + }); + } + }); + + it('ROADTRIP-BOOKENDS-022: a day with a bookend has two stops or more, and stands as a day for it', () => { + const everyCase = [ + ...seated, + ...seatNightBookends([planDay(D2, [])], days, [stay(1, D1, D2, GETAWAY), stay(2, D2, D3, WALLINGA)]), + ...seatNightBookends([planDay(D2, [visit(D2, 0)])], days, [hotel]), + ...seatNightBookends([planDay(D4, [visit(D4, 0)])], days, [hotel]), + ]; + for (const day of everyCase.filter((d) => d.stops.some((s) => s.bookend))) { + expect(day.stops.length).toBeGreaterThanOrEqual(2); + expect(standsAsDay(day.stops)).toBe(true); + } + }); + + it('ROADTRIP-BOOKENDS-023: a day the trip does not list is passed over', () => { + const stray = planDay(990, [visit(990, 0), visit(990, 1)]); + const out = seatNightBookends([stray], days, [hotel]); + expect(out[0]).toBe(stray); + }); +}); + +describe('a night spent at one hotel', () => { + const at = (over: Partial) => visit(D2, 0, { lat: HOTEL[0], lng: HOTEL[1], ...over }); + const evening = at({ + bookend: { + phase: 'evening', + accommodationId: 5, + reservationId: null, + checkingOut: false, + checkingIn: false, + checkOut: null, + }, + }); + const morning = at({ + bookend: { + phase: 'morning', + accommodationId: 5, + reservationId: null, + checkingOut: false, + checkingIn: false, + checkOut: null, + }, + }); + const drive: RoutedLeg = { + seg: { + from: HOTEL, + to: HOTEL, + mid: HOTEL, + distance: 172_000, + duration: 7200, + mode: 'driving', + walkingText: '', + drivingText: '', + distanceText: '', + }, + line: [HOTEL, [45.2, 7.7], HOTEL], + vias: [], + }; + + it('ROADTRIP-BOOKENDS-024: is a join only where a bookend stands at one end and both are one spot', () => { + expect(isStationaryJoin(evening, morning)).toBe(true); + expect(isStationaryJoin(at({}), morning)).toBe(true); + expect(isStationaryJoin(evening, at({}))).toBe(true); + // Two stored stops on one spot are a stop visited twice, not a night. + expect(isStationaryJoin(at({}), at({}))).toBe(false); + expect(isStationaryJoin(evening, { ...morning, lat: WALLINGA[0], lng: WALLINGA[1] })).toBe(false); + }); + + it('ROADTRIP-BOOKENDS-025: goes nowhere whatever was fetched for the pair, and every other pair is looked up', () => { + const lookup = withStationaryJoins(() => drive); + const night = lookup(evening, morning)!; + expect(night.seg.distance).toBe(0); + expect(night.seg.duration).toBe(0); + expect(night.line).toEqual([]); + expect(lookup(at({}), at({}))).toBe(drive); + expect(withStationaryJoins(() => undefined)(at({}), visit(D2, 1))).toBeUndefined(); + }); +}); + +describe('the stays the rule reads, and the switch', () => { + it('ROADTRIP-BOOKENDS-026: each stay once, with its earliest booking; the switch is on only when set on', () => { + const accommodations = [ + { + id: 1, + place_id: 11, + start_day_id: D1, + end_day_id: D2, + place_lat: 1, + place_lng: 2, + place_name: 'A', + check_out: '10:00', + }, + { + id: 1, + place_id: 11, + start_day_id: D1, + end_day_id: D2, + place_lat: 1, + place_lng: 2, + place_name: 'A', + check_out: '10:00', + }, + { id: 2, place_id: 12, start_day_id: D2, end_day_id: D3 }, + ]; + const reservations = [ + { id: 40, accommodation_id: '1' }, + { id: 31, accommodation_id: 1 }, + { id: 12, accommodation_id: null }, + { id: 5, accommodation_id: '' }, + ]; + expect(bookendStaysOf(accommodations, reservations)).toEqual([ + { + id: 1, + place_id: 11, + start_day_id: D1, + end_day_id: D2, + place_lat: 1, + place_lng: 2, + place_name: 'A', + check_out: '10:00', + reservation_id: 31, + }, + { + id: 2, + place_id: 12, + start_day_id: D2, + end_day_id: D3, + place_lat: null, + place_lng: null, + place_name: null, + check_out: null, + reservation_id: null, + }, + ]); + expect(hotelBookendsOn({ roadtrip_hotel_bookends: true })).toBe(true); + expect(hotelBookendsOn({ roadtrip_hotel_bookends: false })).toBe(false); + expect(hotelBookendsOn({})).toBe(false); + expect(hotelBookendsOn({ roadtrip_hotel_bookends: null })).toBe(false); + }); +}); + +describe('with the switch off', () => { + it('ROADTRIP-BOOKENDS-027: the plan is the one stored, stop for stop, whatever stays the trip holds', () => { + const hotel = stay(5, D1, D4, HOTEL); + const plan = [ + planDay(D1, [stayStop(D1, 0, HOTEL, { name: 'H' }), visit(D1, 1, { name: 'P1' })]), + planDay(D2, [visit(D2, 0, { name: 'P2' })]), + planDay(D4, []), + ]; + const read = (on: boolean) => (on ? seatNightBookends(plan, days, [hotel]) : plan); + const off = read(hotelBookendsOn({})); + expect(off).toBe(plan); + expect(off.map(shape)).toEqual([['H', 'P1'], ['P2'], []]); + // Only the switch set on seats anything. + expect(read(hotelBookendsOn({ roadtrip_hotel_bookends: true })).map(shape)).toEqual([ + ['H', 'P1', 'evening:5'], + ['morning:5', 'P2', 'evening:5'], + [], + ]); + }); +}); diff --git a/shared/src/roadtrip/nightBookends.ts b/shared/src/roadtrip/nightBookends.ts new file mode 100644 index 000000000..04ab9c699 --- /dev/null +++ b/shared/src/roadtrip/nightBookends.ts @@ -0,0 +1,203 @@ +import { getDayBookendHotels, getDayOrder, hotelIsTheStop, type DayRef } from '../day/stay-bookends'; +import { closesTheDay, opensTheDay } from './carriers'; +import { stationary } from './dayWindow'; +import type { BookendPhase, PlanDay, RoadtripStop, RoutedLeg } from './planning-types'; + +/** + * A booked night at both ends of the days around it. + * + * A stay's hotel is a stop on the day it is checked in on, where the booking put it + * (#2354). The days after it did not know about it: a day after a booked night set off + * from its first place, a day before one ended at its last, and with the days connected + * the drive between them ran from one place to the other as if nobody slept anywhere. The + * day plan has always drawn those two legs (`getDayBookendHotels`); this seats the same + * two hotels on the road trip, as stops of their own at a day's edges, so the drive to + * them and from them is timed, measured and drawn like any other. + * + * One rule for the browser and the server, run right after the terminals are seated + * (`seatCarrierStops`) and before a day is asked whether it stands as one. Nothing is + * stored: a bookend is the stay's place with an id that belongs to no assignment, and + * every reader that files something by a stored stop's index passes it over + * (`isStoredStop`). + */ + +/** A stay as the rule reads it: its nights, its place, and the booking behind it. */ +export interface BookendStay { + id: number; + place_id?: number | null; + start_day_id: number; + end_day_id: number; + place_lat?: number | null; + place_lng?: number | null; + place_name?: string | null; + check_out?: string | null; + reservation_id?: number | null; +} + +/** + * Below the terminals (-3e9), the automatic nights (-2e9) and the days the window + * invents (-1e9), so a bookend's id never meets one of theirs. + */ +const BOOKEND_ID_BASE = -6_000_000_000; + +/** The id a day's bookend goes by: one per day and phase, and the same on every run. */ +export function bookendAssignmentId(dayId: number, phase: BookendPhase): number { + return BOOKEND_ID_BASE - dayId * 2 - (phase === 'evening' ? 1 : 0); +} + +/** Whether a trip's days start and end at the stay. Off unless it was switched on. */ +export function hotelBookendsOn(preferences: { roadtrip_hotel_bookends?: boolean | null }): boolean { + return preferences.roadtrip_hotel_bookends === true; +} + +/** + * Whether the drive between two stops is a night spent at one hotel: one end is a + * bookend and both stand on the same spot. Nothing is driven there, and nothing is asked. + */ +export function isStationaryJoin( + from: Pick, + to: Pick, +): boolean { + return !!(from.bookend || to.bookend) && from.lat === to.lat && from.lng === to.lng; +} + +/** + * A leg lookup that answers a night spent at one hotel with a leg going nowhere, ahead of + * whatever is stored for the pair. A leg fetched for the same two points some other time + * is a drive, and the night is not one. + */ +export function withStationaryJoins( + lookup: (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined, +): (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined { + return (from, to) => (isStationaryJoin(from, to) ? stationary(from) : lookup(from, to)); +} + +/** + * The stays as the rule reads them, from the trip's stays and its bookings: each stay + * once, with the earliest booking linked to it. A booking names its stay as text on some + * rows, so the link is compared as a number. + */ +export function bookendStaysOf>( + stays: readonly S[], + reservations: readonly { id: number; accommodation_id?: number | string | null }[], +): BookendStay[] { + const seen = new Set(); + const out: BookendStay[] = []; + for (const stay of stays) { + if (seen.has(stay.id)) continue; + seen.add(stay.id); + const linked = reservations.filter( + (r) => r.accommodation_id != null && r.accommodation_id !== '' && Number(r.accommodation_id) === stay.id, + ); + out.push({ + id: stay.id, + place_id: stay.place_id ?? null, + start_day_id: stay.start_day_id, + end_day_id: stay.end_day_id, + place_lat: stay.place_lat ?? null, + place_lng: stay.place_lng ?? null, + place_name: stay.place_name ?? null, + check_out: stay.check_out ?? null, + reservation_id: linked.length ? Math.min(...linked.map((r) => r.id)) : null, + }); + } + return out; +} + +function bookendStop(day: PlanDay, phase: BookendPhase, stay: BookendStay, ownerIndex: number): RoadtripStop { + const checkingOut = phase === 'morning' && stay.end_day_id === day.dayId; + return { + bookend: { + phase, + accommodationId: stay.id, + reservationId: stay.reservation_id ?? null, + checkingOut, + checkingIn: phase === 'evening' && stay.start_day_id === day.dayId, + checkOut: checkingOut ? (stay.check_out ?? null) : null, + }, + assignmentId: bookendAssignmentId(day.dayId, phase), + ownerDayId: day.dayId, + ownerIndex, + placeId: stay.place_id!, + name: stay.place_name ?? '', + lat: stay.place_lat!, + lng: stay.place_lng!, + // No clock of its own. The check-out is the latest the room is handed back, not when + // the drive leaves (#2357), and the evening's check-in is the stay's business: a stop + // nobody can move or edit must never be the one the day is late for. + time: null, + leaveAt: null, + checkInTime: null, + dwellMinutes: 0, + night: false, + endDay: false, + legMode: null, + incomingLegMode: null, + stopType: 'hotel', + fillPercent: null, + }; +} + +const sameSpot = (a: BookendStay | undefined, b: BookendStay | undefined): boolean => + !!a && !!b && a.place_lat === b.place_lat && a.place_lng === b.place_lng; + +/** + * The plan's days with the night before and the night after seated at their edges. + * + * Which stay a day wakes up in and which one it sleeps in is the day plan's own choice + * (`getDayBookendHotels`), read over the real nights only: a stay that begins and ends on + * one day is slept in by nobody. The stays are read in id order, so the browser, which may + * hold them in any order, and the server choose the same one where two overlap. + * + * The morning gets the stay slept in, unless the day already starts there, or starts on + * landing or at a hire car's desk, where the traveller was not at the hotel. The evening + * gets tonight's stay, unless the day already ends there, or ends at a departure or at the + * desk the hire car goes back to. A day without a stop of its own gets both or neither: a + * morning at one hotel and an evening at another is the drive between them, while a day + * between two nights in one hotel, or a day that is only its check-out, is not driven. + * + * So a day with a bookend always has two stops or more, and stands as a day for that + * reason and no other. Days that change nothing come back as they were, and so does the + * plan when no day changes. + */ +export function seatNightBookends(plan: PlanDay[], days: readonly DayRef[], stays: readonly BookendStay[]): PlanDay[] { + const dayOf = (id: number): DayRef | undefined => days.find((d) => d.id === id); + const realNight = (stay: BookendStay): boolean => { + const start = dayOf(stay.start_day_id); + const end = dayOf(stay.end_day_id); + return start && end ? getDayOrder(end, days) > getDayOrder(start, days) : stay.end_day_id > stay.start_day_id; + }; + const nights = stays.filter((stay) => stay.place_id != null && realNight(stay)).sort((a, b) => a.id - b.id); + if (!nights.length) return plan; + + let changed = false; + const out = plan.map((day) => { + const ref = dayOf(day.dayId); + if (!ref) return day; + const hotels = getDayBookendHotels(ref, days, nights); + const stops = day.stops; + const first = stops[0]; + const last = stops[stops.length - 1]; + let head = + !!hotels.morning && + !!hotels.morningIsSleptHere && + !(first && (hotelIsTheStop(hotels.morning, first) || opensTheDay(first.carrier?.role))); + let tail = + !!hotels.evening && + !!hotels.eveningIsOvernight && + !(last && (hotelIsTheStop(hotels.evening, last) || closesTheDay(last.carrier?.role))); + if (!stops.length) head = tail = head && tail && !sameSpot(hotels.morning, hotels.evening); + if (!head && !tail) return day; + changed = true; + const stored = stops.filter((stop) => !stop.carrier).length; + return { + ...day, + stops: [ + ...(head ? [bookendStop(day, 'morning', hotels.morning!, 0)] : []), + ...stops, + ...(tail ? [bookendStop(day, 'evening', hotels.evening!, stored)] : []), + ], + }; + }); + return changed ? out : plan; +} diff --git a/shared/src/roadtrip/nightSpill.spec.ts b/shared/src/roadtrip/nightSpill.spec.ts index df7c9b0f5..169250563 100644 --- a/shared/src/roadtrip/nightSpill.spec.ts +++ b/shared/src/roadtrip/nightSpill.spec.ts @@ -1,3 +1,4 @@ +import { withStationaryJoins } from './nightBookends'; import { spillChains } from './nightSpill'; import type { QuietDay, RoadtripDay, RoadtripStop } from './planning-types'; import type { RouteSegment } from './planning-types'; @@ -571,3 +572,46 @@ describe('spillChains', () => { }); }); }); + +describe('a booked night reached after midnight', () => { + const bookend = (phase: 'morning' | 'evening') => ({ + phase, + accommodationId: 5, + reservationId: null, + checkingOut: false, + checkingIn: false, + checkOut: null, + }); + + it('FE-NIGHTSPILL-028: the drive back to the hotel goes on the next card, and the morning waits for it', () => { + // 20:00 + 1 h and four hours back to the hotel: in at 01:00. The night at the hotel + // is one spot, so the morning sets out no earlier than that. + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '20:00', dwellMinutes: 60 }), + stop({ + assignmentId: -6_000_000_003, + name: 'Back to the hotel', + lat: 52, + lng: 13, + bookend: bookend('evening'), + }), + ]), + day(2, 2, [ + stop({ assignmentId: -6_000_000_004, name: 'From the hotel', lat: 52, lng: 13, bookend: bookend('morning') }), + stop({ assignmentId: 3, name: 'Dresden' }), + ]), + ]; + + const chains = spillChains( + plan, + [], + withStationaryJoins(() => ({ ...everyLeg(240)(), vias: [] })), + ); + + expect(chains[0]!.stops.map((s) => s.name)).toEqual(['Hamburg']); + expect(chains[1]!.stops.map((s) => s.name)).toEqual(['Back to the hotel', 'From the hotel', 'Dresden']); + expect(chains[1]!.spills).toEqual([expect.objectContaining({ at: 0, count: 1, fromDayNumber: 1 })]); + expect(chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['01:00', '01:00', '05:00']); + }); +}); diff --git a/shared/src/roadtrip/planning-types.ts b/shared/src/roadtrip/planning-types.ts index 6d9bf8062..49b8a58cb 100644 --- a/shared/src/roadtrip/planning-types.ts +++ b/shared/src/roadtrip/planning-types.ts @@ -28,10 +28,39 @@ export interface CarrierTerminal { at: string | null; } +/** Which end of a day a booked night stands at: the stay slept in, or tonight's. */ +export type BookendPhase = 'morning' | 'evening'; + +/** + * A booked night standing at one end of a day it is no stop of: in the morning the stay + * the traveller woke up in, in the evening the one they sleep in (`seatNightBookends`). + * + * The stay's own place, not an assignment: nothing about it can be moved, timed or given a + * via point, and what it opens is the booking behind the night, or the stay itself when + * nothing was booked for it. + */ +export interface NightBookend { + phase: BookendPhase; + accommodationId: number; + /** The booking behind the night, the one a tap opens. Null for a stay entered without one. */ + reservationId: number | null; + /** A morning on the day the stay is handed back. */ + checkingOut: boolean; + /** An evening on the day the stay begins. */ + checkingIn: boolean; + /** + * The latest the room has to be handed back, on the morning it is: a label for the row, + * never a time the drive leaves at (#2357). + */ + checkOut: string | null; +} + export interface RoadtripStop { automaticNight?: AutomaticNight; /** Set on the two ends of a carrier ride. Such a stop is no place and belongs to no assignment. */ carrier?: CarrierTerminal; + /** Set on a booked night standing at the start or the end of a day. Belongs to no assignment. */ + bookend?: NightBookend; assignmentId: number; ownerDayId: number; diff --git a/shared/src/roadtrip/planning.ts b/shared/src/roadtrip/planning.ts index 367b5f392..e68965d14 100644 --- a/shared/src/roadtrip/planning.ts +++ b/shared/src/roadtrip/planning.ts @@ -8,3 +8,4 @@ export * from './vehicleRange'; export * from './assemble'; export * from './routeRun'; export * from './carriers'; +export * from './nightBookends'; diff --git a/shared/src/roadtrip/roadtripModel.spec.ts b/shared/src/roadtrip/roadtripModel.spec.ts index ce068eeac..077eaf60f 100644 --- a/shared/src/roadtrip/roadtripModel.spec.ts +++ b/shared/src/roadtrip/roadtripModel.spec.ts @@ -19,6 +19,7 @@ import { reanchorAfterReorder, reanchorByStopOrder, seamViaIndex, + isStoredStop, } from './roadtripModel'; import { describe, it, expect } from 'vitest'; @@ -1243,3 +1244,27 @@ describe('seamViaIndex', () => { } }); }); + +describe('isStoredStop', () => { + it('FE-ROADTRIP-MODEL-111: a stored stop is an assignment, not a marker, a terminal or a booked night at the edge', () => { + expect(isStoredStop({})).toBe(true); + expect(isStoredStop({ automaticNight: { phase: 'end', fromDayNumber: 1 } })).toBe(false); + expect( + isStoredStop({ + carrier: { reservationId: 1, type: 'flight', role: 'arrival', title: 'Flight', code: null, at: null }, + }), + ).toBe(false); + expect( + isStoredStop({ + bookend: { + phase: 'morning', + accommodationId: 1, + reservationId: null, + checkingOut: false, + checkingIn: false, + checkOut: null, + }, + }), + ).toBe(false); + }); +}); diff --git a/shared/src/roadtrip/roadtripModel.ts b/shared/src/roadtrip/roadtripModel.ts index 06fc98c06..ad06f8fbd 100644 --- a/shared/src/roadtrip/roadtripModel.ts +++ b/shared/src/roadtrip/roadtripModel.ts @@ -377,6 +377,20 @@ export function computeSchedule( * at the bottom of the rail under "only X so far", which reads as the hotel missing * from the road trip altogether. */ +/** + * Whether a stop is one somebody stored on the day: an assignment, filed at its own + * index. Not an automatic night, a terminal or a booked night at a day's edge, which the + * plan seats between the stored stops and which borrow a stored stop's index to be seated + * by. A reader looking a stop up by its day and index has to pass those three over, or it + * finds the marker, the airport or the hotel in front of the stop it meant. + * + * Asked of the shape rather than of the id's sign: a stop added a moment ago carries a + * temporary negative id until the server answers, and is stored all the same. + */ +export function isStoredStop(stop: Pick): boolean { + return !stop.automaticNight && !stop.carrier && !stop.bookend; +} + export function standsAsDay(stops: readonly { night?: boolean }[]): boolean { return stops.length > 1 || stops.some((stop) => stop.night === true); }