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.
This commit is contained in:
Maurice
2026-09-13 13:23:32 +02:00
parent 6845db4cb8
commit d4677bdeab
5 changed files with 119 additions and 5 deletions
@@ -3,7 +3,7 @@ import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import React, { useState } from 'react' import React, { useState } from 'react'
import { import {
CarFront, Footprints, Bike, Zap, AlertTriangle, 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, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import MDancingTrek from '../../mobile/components/MDancingTrek' 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 if (stop.checkoutAt === undefined || !entry?.departure) return null
return <Tooltip label={t('roadtrip.spill.departs', { time: formatClockTime(entry.departure, is12h) })}> return <Tooltip label={t('roadtrip.spill.departs', { time: formatClockTime(entry.departure, is12h) })}>
<span dir="ltr" tabIndex={0} className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-secondary px-1 py-0.5 align-middle whitespace-nowrap font-medium text-[length:calc(10px*var(--fs-scale-caption,1))] leading-none tabular-nums text-content-muted"> <span dir="ltr" tabIndex={0} className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-secondary px-1 py-0.5 align-middle whitespace-nowrap font-medium text-[length:calc(10px*var(--fs-scale-caption,1))] leading-none tabular-nums text-content-muted">
<Clock size={10} aria-hidden />{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. */}
<LogOut size={10} aria-hidden />{formatClockTime(entry.departure, is12h)}
</span> </span>
</Tooltip> </Tooltip>
} }
+5 -1
View File
@@ -2,7 +2,7 @@ import { spurFor } from './accessSpur';
import { pointAtMeters } from './corridor'; import { pointAtMeters } from './corridor';
import type { RoadtripDayBoundary } from './day-boundary.schema'; import type { RoadtripDayBoundary } from './day-boundary.schema';
import { planDayWindow, type DayWindow } from './dayWindow'; import { planDayWindow, type DayWindow } from './dayWindow';
import { spillChains } from './nightSpill'; import { earliestFreeMinute, spillChains } from './nightSpill';
import type { import type {
PlanDay, PlanDay,
QuietDay, QuietDay,
@@ -77,6 +77,9 @@ export function assembleRoadtrip({
) )
: null; : null;
const automaticSchedule = !!timed && timed.issue === 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 const displayChains = automaticSchedule
? timed.chains ? timed.chains
: timed?.issue : timed?.issue
@@ -93,6 +96,7 @@ export function assembleRoadtrip({
departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440, 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), 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); : splitScheduledDays(chains, [...plan, ...quietDays], storedLegFor);
+64
View File
@@ -276,4 +276,68 @@ describe('spillChains', () => {
expect(chains[1]!.spills).toHaveLength(1); expect(chains[1]!.spills).toHaveLength(1);
expect(chains[1]!.spills[0]!).toMatchObject({ at: 0, count: 2, fromDayNumber: 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();
});
});
}); });
+31
View File
@@ -54,6 +54,35 @@ interface Placed {
from: RoadtripStop | undefined; 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<number, number> {
const held = new Map<number, number>();
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[] { export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegLookup): SpillChain[] {
const all: PlanDay[] = [ const all: PlanDay[] = [
...plan, ...plan,
@@ -66,6 +95,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
})), })),
].sort((a, b) => a.dayNumber - b.dayNumber); ].sort((a, b) => a.dayNumber - b.dayNumber);
const numbers = new Set(all.map((d) => d.dayNumber)); const numbers = new Set(all.map((d) => d.dayNumber));
const heldUntil = earliestFreeMinute(all);
const landing = new Map<number, Placed[]>(); const landing = new Map<number, Placed[]>();
for (const d of all) landing.set(d.dayNumber, []); 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, departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440,
})), })),
legs.map((l) => l?.duration), legs.map((l) => l?.duration),
{ notBefore: heldUntil.get(d.dayNumber) ?? null },
); );
let day = 0; let day = 0;
+14 -2
View File
@@ -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 warnings: ScheduleWarning[] = [];
const arrivals: (number | null)[] = new Array(stops.length).fill(null); const arrivals: (number | null)[] = new Array(stops.length).fill(null);
const anchored: boolean[] = new Array(stops.length).fill(false); const anchored: boolean[] = new Array(stops.length).fill(false);
let cursor: number | null = null; let cursor: number | null = opts.notBefore ?? null;
let dayOffset = 0; let dayOffset = 0;
for (let i = 0; i < stops.length; i++) { for (let i = 0; i < stops.length; i++) {