From 6845db4cb80e50a3d813473244dfda9906ccdc88 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 13 Sep 2026 13:10:58 +0200 Subject: [PATCH] fix(roadtrip): a check-in is a time somebody chose, so print it as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail prints an hour in ink when a person picked it and in grey when the chain worked it out. Every scheduler anchors on `time ?? checkInTime`, so a booked night starts its day on the check-in exactly the way a pinned stop does — but two of the three then asked only about `time` when labelling the result, and printed that chosen hour grey. The answer lived in three places and two of them disagreed with the anchor they had just used, so it is one function now. Nothing but the rail reads the flag. Only visible since a booked night became a stop of its own; before that no stop carried a check-in. --- shared/src/roadtrip/dayWindow.ts | 4 ++-- shared/src/roadtrip/nightSpill.ts | 4 ++-- shared/src/roadtrip/roadtripModel.spec.ts | 25 +++++++++++++++++++++++ shared/src/roadtrip/roadtripModel.ts | 16 +++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/shared/src/roadtrip/dayWindow.ts b/shared/src/roadtrip/dayWindow.ts index d79569a6a..8ba7edb53 100644 --- a/shared/src/roadtrip/dayWindow.ts +++ b/shared/src/roadtrip/dayWindow.ts @@ -3,7 +3,7 @@ import type { RoadtripDayBoundary } from './day-boundary.schema'; import type { SpillChain } from './nightSpill'; import type { RoadtripDay, RoadtripStop, RoutedLeg } from './planning-types'; import type { DistanceUnit } from './planning-types'; -import { formatClock, parseClock } from './roadtripModel'; +import { formatClock, hasChosenArrival, parseClock } from './roadtripModel'; import { formatDurationShort } from './roadtripModel'; import { formatDistance } from './units'; @@ -168,7 +168,7 @@ export function planDayWindow( chain.schedule.entries.push({ arrival: formatClock(Math.round(arrival)), departure: formatClock(Math.round(departure)), - anchored: !stop.automaticNight && stop.time !== null, + anchored: hasChosenArrival(stop), dayOffset: Math.floor(arrival / 1440), }); }; diff --git a/shared/src/roadtrip/nightSpill.ts b/shared/src/roadtrip/nightSpill.ts index 5e10315be..9c51af731 100644 --- a/shared/src/roadtrip/nightSpill.ts +++ b/shared/src/roadtrip/nightSpill.ts @@ -1,6 +1,6 @@ import type { QuietDay, RoadtripDay, RoadtripStop } from './planning-types'; import type { RouteSegment } from './planning-types'; -import { computeSchedule, parseClock, type Schedule } from './roadtripModel'; +import { computeSchedule, parseClock, type Schedule, hasChosenArrival } from './roadtripModel'; export interface SpillMark { automatic?: boolean; @@ -147,7 +147,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL entries: placed.map((p) => ({ arrival: p.arrival, departure: p.departure, - anchored: p.stop.time !== null && p.stop.time !== undefined, + anchored: hasChosenArrival(p.stop), dayOffset: p.dayOffset, })), warnings: placed.flatMap((p, i) => diff --git a/shared/src/roadtrip/roadtripModel.spec.ts b/shared/src/roadtrip/roadtripModel.spec.ts index f0a1db319..7b163cfda 100644 --- a/shared/src/roadtrip/roadtripModel.spec.ts +++ b/shared/src/roadtrip/roadtripModel.spec.ts @@ -1,6 +1,7 @@ import { computeSchedule, formatClock, + hasChosenArrival, formatDurationShort, insertIndexForAlong, deriveDriveWarnings, @@ -822,3 +823,27 @@ it('uses checkout as departure even without an arrival or daily start', () => { expect(schedule.entries[2]!.arrival).toBe('10:00'); expect(schedule.entries[2]!.dayOffset).toBe(1); }); + + +describe('hasChosenArrival', () => { + // The rail prints a chosen hour in ink and a computed one in grey. Every scheduler + // anchors on `time ?? checkInTime`, so a booked night's check-in is as chosen as a + // pinned stop; two of the three used to ask only about `time` and printed it grey. + it('counts a pinned time', () => { + expect(hasChosenArrival({ time: '09:00' })).toBe(true); + }); + + it('counts a check-in, which is the hour a booked night starts its day on', () => { + expect(hasChosenArrival({ time: null, checkInTime: '08:00' })).toBe(true); + }); + + it('leaves a stop the chain worked out alone', () => { + expect(hasChosenArrival({ time: null, checkInTime: null })).toBe(false); + expect(hasChosenArrival({})).toBe(false); + }); + + it('never counts a night the planner inserted itself', () => { + // Its clock comes from the driving limits, not from anybody's decision. + expect(hasChosenArrival({ time: '22:00', automaticNight: { phase: 'end' } })).toBe(false); + }); +}); diff --git a/shared/src/roadtrip/roadtripModel.ts b/shared/src/roadtrip/roadtripModel.ts index 4dc3fd878..efcb9177a 100644 --- a/shared/src/roadtrip/roadtripModel.ts +++ b/shared/src/roadtrip/roadtripModel.ts @@ -38,6 +38,22 @@ export function formatDurationShort(seconds: number): string { return `${m} min`; } +/** + * Whether this stop's arrival is a time somebody chose, rather than one the chain + * worked out from the stop before it. + * + * A check-in counts. Every schedule already anchors on `time ?? checkInTime`, so a + * booked night starts its day exactly like a pinned stop does; asking only about + * `time` here made the rail print that chosen hour in the grey it reserves for + * computed ones. One function because the answer was spelled out separately in each + * of the three schedulers, and two of them spelled it differently from the anchor + * they had just used. + */ +export function hasChosenArrival(stop: { time?: string | null; checkInTime?: string | null; automaticNight?: unknown }): boolean { + if (stop.automaticNight) return false; + return (stop.time ?? null) !== null || (stop.checkInTime ?? null) !== null; +} + export function parseClock(value: string | null | undefined): number | null { if (!value) return null; const m = /^(\d{1,2}):(\d{2})/.exec(value.trim());