From 7b506923722df46d6e19b5dccba272408e65888c Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 13 Sep 2026 15:28:48 +0200 Subject: [PATCH] fix(roadtrip): a check-in is a door opening, not an appointment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../accommodations-domain.module.ts | 24 +++++++++++ shared/src/roadtrip/assemble.ts | 3 +- shared/src/roadtrip/nightSpill.ts | 3 +- shared/src/roadtrip/roadtripModel.spec.ts | 40 +++++++++++++++++++ shared/src/roadtrip/roadtripModel.ts | 24 ++++++++++- 5 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 server/src/nest/accommodations/accommodations-domain.module.ts diff --git a/server/src/nest/accommodations/accommodations-domain.module.ts b/server/src/nest/accommodations/accommodations-domain.module.ts new file mode 100644 index 000000000..e7e98a7a7 --- /dev/null +++ b/server/src/nest/accommodations/accommodations-domain.module.ts @@ -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 {} diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts index 54f267967..a012cd973 100644 --- a/shared/src/roadtrip/assemble.ts +++ b/shared/src/roadtrip/assemble.ts @@ -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), diff --git a/shared/src/roadtrip/nightSpill.ts b/shared/src/roadtrip/nightSpill.ts index 4fb6d65cb..2307b9f75 100644 --- a/shared/src/roadtrip/nightSpill.ts +++ b/shared/src/roadtrip/nightSpill.ts @@ -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), diff --git a/shared/src/roadtrip/roadtripModel.spec.ts b/shared/src/roadtrip/roadtripModel.spec.ts index 2a2d1a1dd..f6bbd03b1 100644 --- a/shared/src/roadtrip/roadtripModel.spec.ts +++ b/shared/src/roadtrip/roadtripModel.spec.ts @@ -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 }); + }); +}); diff --git a/shared/src/roadtrip/roadtripModel.ts b/shared/src/roadtrip/roadtripModel.ts index a94cc6320..1d49e3416 100644 --- a/shared/src/roadtrip/roadtripModel.ts +++ b/shared/src/roadtrip/roadtripModel.ts @@ -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 =