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 =