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++) {