Revert "fix(roadtrip): a night booked into the next morning holds that morning"

This reverts commit d4677bdea, and keeps the departure chip's icon.

The premise was wrong. A check-out is the LATEST moment the room has to be
handed back, not the earliest one you may leave: whoever wants to set off at
seven checks out before that or leaves the bags at the desk. Reading it as a
floor turned a perfectly good plan into a finding, and no amount of wording
around that would have made it true.

The chip keeps the door-out mark. That part says what it always said, only in a
symbol that is not a second clock face beside the arrival.
This commit is contained in:
Maurice
2026-09-13 14:01:18 +02:00
parent d4677bdeab
commit f4809a67d3
4 changed files with 3 additions and 114 deletions
+1 -5
View File
@@ -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 { earliestFreeMinute, spillChains } from './nightSpill';
import { spillChains } from './nightSpill';
import type {
PlanDay,
QuietDay,
@@ -77,9 +77,6 @@ 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
@@ -96,7 +93,6 @@ 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);
-64
View File
@@ -276,68 +276,4 @@ 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();
});
});
});
-31
View File
@@ -54,35 +54,6 @@ 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<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[] {
const all: PlanDay[] = [
...plan,
@@ -95,7 +66,6 @@ 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<number, Placed[]>();
for (const d of all) landing.set(d.dayNumber, []);
@@ -109,7 +79,6 @@ 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;
+2 -14
View File
@@ -154,25 +154,13 @@ function resolveArrival(
};
}
/**
* @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 {
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): 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 = opts.notBefore ?? null;
let cursor: number | null = null;
let dayOffset = 0;
for (let i = 0; i < stops.length; i++) {