From d4677bdeab7eeff473a764ff431107e1fd9e7610 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 13 Sep 2026 13:23:32 +0200 Subject: [PATCH] fix(roadtrip): a night booked into the next morning holds that morning Every day was scheduled from nothing, so a booking that runs into the next day ended at midnight as far as the chain was concerned. Check in at 08:00 and out at 08:00 the next day, and the rail was happy to put you somewhere else at 07:00 that morning, with no sign that the room was still yours. The day a check-out falls in now starts at the check-out rather than at nothing. A floor, never a move: a stop with a clock of its own keeps it and collects the late finding any leg it cannot make in time would give it, so a plan that says 07:00 still says 07:00 and merely admits it does not add up. A stop with no clock starts at the check-out instead of having no time at all. Only that one day. A stay running Monday to Thursday leaves Tuesday and Wednesday alone, because you sleep there, you do not sit there. The departure chip takes the door-out mark instead of a clock face. Beside an arrival time, a second clock read as a second arrival. --- .../components/Roadtrip/RoadtripSidebar.tsx | 7 +- shared/src/roadtrip/assemble.ts | 6 +- shared/src/roadtrip/nightSpill.spec.ts | 64 +++++++++++++++++++ shared/src/roadtrip/nightSpill.ts | 31 +++++++++ shared/src/roadtrip/roadtripModel.ts | 16 ++++- 5 files changed, 119 insertions(+), 5 deletions(-) diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index aee4d1067..0e2bfeae2 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -3,7 +3,7 @@ import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import React, { useState } from 'react' import { CarFront, Footprints, Bike, Zap, AlertTriangle, - ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, + ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, LogOut, type LucideIcon, } from 'lucide-react' import MDancingTrek from '../../mobile/components/MDancingTrek' @@ -965,7 +965,10 @@ function CheckoutDeparture({ stop, entry }: { stop: RoadtripStop; entry?: Schedu if (stop.checkoutAt === undefined || !entry?.departure) return null return - {formatClockTime(entry.departure, is12h)} + {/* Leaving, not just a time of day: this chip is the one clock in the rail that + says when you give the room back, and beside an arrival it read as a second + arrival. The door-out mark is what a hotel desk calls check-out. */} + {formatClockTime(entry.departure, is12h)} } diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts index d5b8a3ebb..126c483b6 100644 --- a/shared/src/roadtrip/assemble.ts +++ b/shared/src/roadtrip/assemble.ts @@ -2,7 +2,7 @@ import { spurFor } from './accessSpur'; import { pointAtMeters } from './corridor'; import type { RoadtripDayBoundary } from './day-boundary.schema'; import { planDayWindow, type DayWindow } from './dayWindow'; -import { spillChains } from './nightSpill'; +import { earliestFreeMinute, spillChains } from './nightSpill'; import type { PlanDay, QuietDay, @@ -77,6 +77,9 @@ export function assembleRoadtrip({ ) : null; const automaticSchedule = !!timed && timed.issue === null; + // Same floor the spill chains use: a night booked into the next morning holds the + // traveller until check-out, so the day it lands in cannot start before that. + const heldUntil = earliestFreeMinute([...plan, ...quietDays]); const displayChains = automaticSchedule ? timed.chains : timed?.issue @@ -93,6 +96,7 @@ export function assembleRoadtrip({ departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440, })), d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration), + { notBefore: heldUntil.get(d.dayNumber) ?? null }, ), })) : splitScheduledDays(chains, [...plan, ...quietDays], storedLegFor); diff --git a/shared/src/roadtrip/nightSpill.spec.ts b/shared/src/roadtrip/nightSpill.spec.ts index 3093959c1..e49d6af5d 100644 --- a/shared/src/roadtrip/nightSpill.spec.ts +++ b/shared/src/roadtrip/nightSpill.spec.ts @@ -276,4 +276,68 @@ describe('spillChains', () => { expect(chains[1]!.spills).toHaveLength(1); expect(chains[1]!.spills[0]!).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 }); }); + + describe('a night booked into the next morning holds that morning', () => { + // Check in on day 1 at 08:00, check out on day 2 at 08:00. Until the room is + // handed back the traveller is at the hotel, so day 2 cannot quietly start at + // 07:00 somewhere else as if the night had ended at midnight. + const hotel = () => stop({ + assignmentId: 1, name: 'Mercure Hamburg', checkInTime: '08:00', + checkoutAt: 2 * 1440 + 480, + }); + + it('FE-NIGHTSPILL-013: a stop pinned before check-out keeps its clock and is flagged late', () => { + const plan = [ + day(1, 1, [hotel()]), + day(2, 2, [stop({ assignmentId: 2, name: 'Hotel Panorama', time: '07:00' })]), + ]; + + const chains = spillChains(plan, [], everyLeg(11)); + const dayTwo = chains.find((c) => c.dayNumber === 2)!; + + // The hour the traveller chose is never moved. + expect(dayTwo.schedule.entries[0]!.arrival).toBe('07:00'); + // But it cannot be reached: the room goes back an hour after it. + expect(dayTwo.schedule.warnings).toContainEqual({ index: 0, code: 'late', minutes: 60 }); + }); + + it('FE-NIGHTSPILL-014: a stop with no clock of its own starts at check-out, not at nothing', () => { + const plan = [ + day(1, 1, [hotel()]), + day(2, 2, [stop({ assignmentId: 2, name: 'Bergedorf' })]), + ]; + + const chains = spillChains(plan, [], everyLeg(11)); + + expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBe('08:00'); + }); + + it('FE-NIGHTSPILL-015: the days between a longer stay are left alone', () => { + // Monday to Thursday: you sleep there, you do not sit there. Only the day the + // check-out falls in is held. + const plan = [ + day(1, 1, [stop({ assignmentId: 1, name: 'Mercure Hamburg', checkInTime: '08:00', checkoutAt: 4 * 1440 + 480 })]), + day(2, 2, [stop({ assignmentId: 2, name: 'Speicherstadt' })]), + day(4, 4, [stop({ assignmentId: 3, name: 'Bergedorf' })]), + ]; + + const chains = spillChains(plan, [], everyLeg(11)); + + expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBeNull(); + expect(chains.find((c) => c.dayNumber === 4)!.schedule.entries[0]!.arrival).toBe('08:00'); + }); + + it('FE-NIGHTSPILL-016: a check-out on the same day it started binds nothing', () => { + // A day room, or a stay whose end day is the day it started: the stops after it + // already follow through the leg chain and need no floor of their own. + const plan = [ + day(1, 1, [stop({ assignmentId: 1, name: 'Day room', checkInTime: '08:00', checkoutAt: 1 * 1440 + 480 })]), + day(2, 2, [stop({ assignmentId: 2, name: 'Bergedorf' })]), + ]; + + const chains = spillChains(plan, [], everyLeg(11)); + + expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBeNull(); + }); + }); }); diff --git a/shared/src/roadtrip/nightSpill.ts b/shared/src/roadtrip/nightSpill.ts index 9c51af731..07185044d 100644 --- a/shared/src/roadtrip/nightSpill.ts +++ b/shared/src/roadtrip/nightSpill.ts @@ -54,6 +54,35 @@ interface Placed { from: RoadtripStop | undefined; } +/** + * The minute each day is free to start on, for the days a booking still holds. + * + * A night booked into the next morning does not end at midnight: the room is given + * back at check-out, and until then the traveller is at the hotel and not at the + * first stop of the new day. Without this each day was scheduled from nothing, so a + * stop pinned before check-out read as perfectly fine. + * + * Only the day the check-out falls in. A stay running Monday to Thursday leaves + * Tuesday and Wednesday alone, which is the point: you sleep there, you do not sit + * there. And the bound is a floor, never a move. A stop with a clock of its own + * keeps it and collects the late warning any leg it cannot make would give it. + */ +export function earliestFreeMinute(days: PlanDay[]): Map { + const held = new Map(); + for (const day of days) { + for (const stop of day.stops) { + if (stop.checkoutAt === undefined) continue; + const checkoutDay = Math.floor(stop.checkoutAt / 1440); + // A check-out on the stop's own day binds nothing: that is a day trip, and the + // stops after it already follow through the leg chain. + if (checkoutDay <= day.dayNumber) continue; + const minute = stop.checkoutAt - checkoutDay * 1440; + held.set(checkoutDay, Math.max(held.get(checkoutDay) ?? 0, minute)); + } + } + return held; +} + export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegLookup): SpillChain[] { const all: PlanDay[] = [ ...plan, @@ -66,6 +95,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL })), ].sort((a, b) => a.dayNumber - b.dayNumber); const numbers = new Set(all.map((d) => d.dayNumber)); + const heldUntil = earliestFreeMinute(all); const landing = new Map(); for (const d of all) landing.set(d.dayNumber, []); @@ -79,6 +109,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440, })), legs.map((l) => l?.duration), + { notBefore: heldUntil.get(d.dayNumber) ?? null }, ); let day = 0; diff --git a/shared/src/roadtrip/roadtripModel.ts b/shared/src/roadtrip/roadtripModel.ts index efcb9177a..184ac55bd 100644 --- a/shared/src/roadtrip/roadtripModel.ts +++ b/shared/src/roadtrip/roadtripModel.ts @@ -154,13 +154,25 @@ function resolveArrival( }; } -export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule { +/** + * @param opts.notBefore Minute of this day before which the first stop cannot be + * reached, because a booking still holds the traveller somewhere else: the night + * before ran into this morning and its check-out is when the room is given back. + * Behaves like the arrival of an imaginary stop just before the first, so a stop + * pinned earlier keeps its clock and picks up the same late warning it would from + * any other leg it cannot make in time. + */ +export function computeSchedule( + stops: ScheduleStop[], + legSeconds: (number | undefined)[], + opts: { notBefore?: number | null } = {}, +): Schedule { const warnings: ScheduleWarning[] = []; const arrivals: (number | null)[] = new Array(stops.length).fill(null); const anchored: boolean[] = new Array(stops.length).fill(false); - let cursor: number | null = null; + let cursor: number | null = opts.notBefore ?? null; let dayOffset = 0; for (let i = 0; i < stops.length; i++) {