fix(roadtrip): a check-in is a door opening, not an appointment

It is the hour a room becomes available. Reaching it later is reaching it; only a
time somebody pinned to a stop can be missed. Read as an anchor it did the exact
opposite of what it means: arrive at 16:14 at a hotel that checks in from 11:00
and the rail reported five hours fourteen late, then read that 11:00 as earlier
than the 15:00 before it, took it for a new day and pushed the hotel onto
tomorrow. The day it came from was left with one stop and dropped out of the list
altogether, so the stop before it appeared to vanish.

The schedule now takes it as a floor: waited for when the drive gets there first,
and otherwise silent. Waiting still prints in ink, because the hour is one
somebody named; arriving late to it prints as computed, because nothing was
decided.

Same shape of mistake as the check-out, from the other side. One says the earliest
you may be somewhere, the other the latest you may still be — neither says when
you drive.
This commit is contained in:
Maurice
2026-09-13 15:28:48 +02:00
parent 62d4cdcc18
commit 7b50692372
5 changed files with 90 additions and 4 deletions
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { AccommodationsService } from './accommodations.service';
import { AssignmentsDomainModule } from '../assignments/assignments-domain.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { RealtimeModule } from '../realtime/realtime.module';
/**
* The accommodations SERVICE, split from the controller/MCP/RPC surfaces (the
* assignments-domain precedent). AccommodationsService itself never touches
* PlacesService -- only AccommodationsMcp does, for the one tool that writes a
* place and a stay together -- so the service can live in a module that stays
* off the AccommodationsModule -> PlacesModule edge.
*
* That is what lets PlacesModule import this: deleting a place has to take the
* nights booked at it with it, and the cascade behind a stay (its partner
* booking, that booking's expense, the day stop it wrote) belongs in one place
* rather than copied into the delete path.
*/
@Module({
imports: [PermissionsModule, RealtimeModule, AssignmentsDomainModule],
providers: [AccommodationsService],
exports: [AccommodationsService],
})
export class AccommodationsDomainModule {}
+2 -1
View File
@@ -88,7 +88,8 @@ export function assembleRoadtrip({
spills: [],
schedule: computeSchedule(
d.stops.map((s) => ({
anchor: s.time ?? s.checkInTime ?? null,
anchor: s.time ?? null,
earliest: s.checkInTime ?? null,
dwellMinutes: s.dwellMinutes,
})),
d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration),
+2 -1
View File
@@ -94,7 +94,8 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
const legs = routed.map((l) => l?.seg);
const schedule = computeSchedule(
d.stops.map((s) => ({
anchor: s.time ?? s.checkInTime ?? null,
anchor: s.time ?? null,
earliest: s.checkInTime ?? null,
dwellMinutes: s.dwellMinutes,
})),
legs.map((l) => l?.duration),
+40
View File
@@ -847,3 +847,43 @@ describe('hasChosenArrival', () => {
expect(hasChosenArrival({ time: '22:00', automaticNight: { phase: 'end' } })).toBe(false);
});
});
describe('a check-in is a door opening, not an appointment', () => {
// The hour a room becomes available. Reaching it later is reaching it; only a time
// somebody pinned to a stop can be missed. Read as an anchor it did the opposite:
// arriving at 16:14 was reported as 5 h 14 late against an 11:00 check-in, and the
// hotel was pushed onto the next day because 11:00 reads as earlier than the 15:00
// before it.
it('waits for it when the drive gets there first', () => {
const schedule = computeSchedule(
[{ anchor: '09:00', dwellMinutes: 0 }, { anchor: null, earliest: '15:00', dwellMinutes: 0 }],
[3600],
);
expect(schedule.entries[1]!.arrival).toBe('15:00');
// Waiting for a door is waiting for a time somebody named, so it is printed as one.
expect(schedule.entries[1]!.anchored).toBe(true);
expect(schedule.warnings).toEqual([]);
});
it('is simply arrived at when the drive gets there later', () => {
const schedule = computeSchedule(
[{ anchor: '15:00', dwellMinutes: 60 }, { anchor: null, earliest: '11:00', dwellMinutes: 60 }],
[14 * 60],
);
expect(schedule.entries[1]!.arrival).toBe('16:14');
// Nothing was decided here, so it reads as computed.
expect(schedule.entries[1]!.anchored).toBe(false);
// And nothing was missed.
expect(schedule.warnings).toEqual([]);
// Still the same day, which is what the day split reads.
expect(schedule.entries[1]!.dayOffset).toBe(0);
});
it('still reports a pinned time that cannot be made', () => {
const schedule = computeSchedule(
[{ anchor: '15:00', dwellMinutes: 60 }, { anchor: '11:00', dwellMinutes: 0 }],
[14 * 60],
);
expect(schedule.warnings).toContainEqual({ index: 1, code: 'late', minutes: 314 });
});
});
+22 -2
View File
@@ -74,8 +74,17 @@ export function formatClock(minutes: number): string {
export interface ScheduleStop {
departureAt?: number;
/** A time somebody fixed this stop to. The chain restarts from it, and arriving
* after it is being late. */
anchor: string | null;
/**
* The earliest this stop can be entered, when something says so — a check-in is
* the hour a room becomes available, not an appointment. Arriving before it means
* waiting for it; arriving after it means arriving, with nothing to report.
*/
earliest?: string | null;
dwellMinutes: number | null;
}
@@ -186,7 +195,16 @@ export function computeSchedule(
for (let i = 0; i < stops.length; i++) {
const stop = stops[i]!;
const anchor = parseClock(stop.anchor);
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset);
const resolved = resolveArrival(anchor, cursor, dayOffset);
// A door that opens at eleven is not an appointment at eleven. Reaching the stop
// later than that is simply reaching it; only a time somebody pinned can be missed.
const opens = parseClock(stop.earliest ?? null);
const waited = anchor === null && opens !== null && resolved.arrival !== null
? opens + Math.round((resolved.arrival - opens) / DAY_MINUTES) * DAY_MINUTES
: null;
const held = waited !== null && waited > resolved.arrival!;
const arrival = held ? waited : resolved.arrival;
const lateBy = resolved.lateBy;
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy });
if (arrival === null) {
@@ -199,7 +217,9 @@ export function computeSchedule(
if (offset > dayOffset) dayOffset = offset;
arrivals[i]! = arrival;
anchored[i]! = anchor !== null;
// Ink for a time somebody decided: a pinned one always, a check-in only where the
// drive actually had to wait for it.
anchored[i]! = anchor !== null || held;
const leg = legSeconds[i];
cursor =