fix(freight): guard booking-desk hours against overnight ranges

Review follow-ups on the window-close-hour feature:

- Reject windowCloseHour < windowOpenHour when saving global rules. The
  reopen engine (nextCycleOpensAt) assumes the daily desk runs within one
  EAT day; an overnight range would misroute a ready-time inside the span
  to the next morning. openHour === closeHour stays valid (24-hour desk).
- Derive the board projection's runaway cap from the real first-open →
  departure span over the minimum per-cycle advance, so a legitimate
  long-lead config is never silently truncated (was a flat 200).
- Document the open <= close precondition on nextCycleOpensAt and clarify
  the ready-before-open comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marshal
2026-07-05 09:07:21 +00:00
parent 93b8b69464
commit dfdf7a025d
3 changed files with 31 additions and 8 deletions

View File

@@ -164,6 +164,10 @@ export function isRoundTheClock(hours: OfficeHours): boolean {
*
* Returns `null` when the next open would fall on/after `departure` — the train
* leaves before another cycle could run, so the window is done.
*
* Precondition: `windowCloseHour >= windowOpenHour` — the desk runs within a
* single EAT day and never wraps past midnight (enforced when global rules are
* saved). openHour === closeHour is the 24-hour desk, handled first.
*/
export function nextCycleOpensAt(
earliestNextOpen: Date,
@@ -179,7 +183,7 @@ export function nextCycleOpensAt(
const openMinutes = hours.windowOpenHour * 60;
const closeMinutes = hours.windowCloseHour * 60;
if (readyMinutes < openMinutes) {
// Desk not open yet today (ready before opening) → open this morning.
// Ready before the desk opens on its own EAT calendar day → open this morning.
opensAt = eatDayToUtc(eatDay(earliestNextOpen), hours.windowOpenHour);
} else if (readyMinutes < closeMinutes) {
// Inside office hours → open as soon as ready.
@@ -397,9 +401,16 @@ export function listConfigBookingWindows(
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// Reopen daily until the train departs; cap the projection so a tiny duration
// can't run away (spanning up to importWindowLeadDays of office days).
for (let cycle = 0; cycle < 200; cycle += 1) {
// The loop terminates naturally: every cycle advances opensAt by at least
// (duration + reopen) > 0, and nextCycleOpensAt returns null once opensAt would
// reach departure. maxCycles is a derived runaway backstop sized to the real
// span (first open → departure) over the smallest possible advance, so a
// legitimate config is never silently truncated — only a pathological
// zero-length one would hit it.
const spanMs = departure.getTime() - opensAt.getTime();
const minAdvanceMs = Math.max(durationMs + reopenMs, 60_000);
const maxCycles = Math.ceil(spanMs / minAdvanceMs) + 2;
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;