remove reopen delay minutes from global rules and update related types

- Removed the  field from  and related components.
- Updated  to reflect the removal of the reopen delay input field.
- Modified  to include new train number fields:  and .
- Added  interface to manage active schedules with trade direction.
- Introduced  interface to track wagon shortages in bookings.
- Updated  logic to ensure consistent UI state representation.
- Created migrations to drop the  column and add  and  columns to the  table.
- Added tests for the new booking window display logic and wagon planning functionality.
This commit is contained in:
Marshal
2026-07-15 09:13:02 +00:00
parent 9be7f356f0
commit 11771e5f92
39 changed files with 1731 additions and 269 deletions

View File

@@ -223,6 +223,49 @@ export function nextCycleOpensAt(
return opensAt.getTime() < departure.getTime() ? opensAt : null;
}
/**
* The desk-close instant of the office window containing `opensAt`; null for a
* round-the-clock desk. Same-day desk (open < close): closeHour on `opensAt`'s
* EAT day. Overnight desk (open > close): closeHour on the NEXT EAT day when
* `opensAt` sits in the evening half, closeHour the same day when it sits in the
* after-midnight half.
*/
export function officeCloseAfter(opensAt: Date, hours: OfficeHours): Date | null {
if (isRoundTheClock(hours)) return null;
const { hour, minute } = eatParts(opensAt);
const openMinutes = hour * 60 + minute;
if (
hours.windowOpenHour > hours.windowCloseHour &&
openMinutes >= hours.windowOpenHour * 60
) {
return eatDayToUtc(shiftEatDay(eatDay(opensAt), 1), hours.windowCloseHour);
}
return eatDayToUtc(eatDay(opensAt), hours.windowCloseHour);
}
/**
* Cap a window close at the desk-close hour that follows its open: the office
* hours end a running window early rather than letting the duration outlive the
* desk (open 16:00, 3h duration, desk 817 → closes 17:00, not 19:00). A
* round-the-clock desk never caps; a desk-close at/before the open (degenerate
* config) is ignored so the window is never clamped to zero length here.
*/
export function clampCloseToOfficeHours(
opensAt: Date,
closesAt: Date,
hours: OfficeHours,
): Date {
const deskClose = officeCloseAfter(opensAt, hours);
if (
deskClose != null &&
deskClose.getTime() > opensAt.getTime() &&
closesAt.getTime() > deskClose.getTime()
) {
return deskClose;
}
return closesAt;
}
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;
@@ -243,7 +286,8 @@ export interface InitialWindowTimes {
* • `now` before openHour that EAT day → opens at openHour that morning
* • `now` at/after closeHour → desk shut; opens openHour next morning
*
* `windowDurationHours` extends from that open, capped at departure.
* `windowDurationHours` extends from that open, capped at the desk close hour
* and at departure.
*/
export function computeImportWindowTimes(
departure: Date,
@@ -276,6 +320,10 @@ export function computeImportWindowTimes(
}
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
closesAt = clampCloseToOfficeHours(opensAt, closesAt, {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (closesAt.getTime() > departure.getTime()) {
closesAt = departure;
}
@@ -381,10 +429,11 @@ export function listBatchWindowsForBookings(
// ---------------------------------------------------------------------------
// Board-display windows: the REAL booking-window cycles derived from the
// train_scheduling_global_rules config (window open hour, lead days, duration,
// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle
// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after
// reopenDelayMinutes until departure). Export shows the single FCFS lead window.
// schedule's frozen window rule (open/close hour, lead days, duration, reopen
// gap = doc review + payment) — NOT a fixed clock grid. Import shows each
// booking-window cycle (opens at windowOpenHour EAT, lasts windowDurationHours
// capped at the desk close, reopens after the gap until departure). Export shows
// the single FCFS lead window.
// ---------------------------------------------------------------------------
/** A board window carries an EAT calendar date in addition to the slot times. */
@@ -402,8 +451,11 @@ export interface BoardWindowConfig {
/** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */
windowCloseHour: number;
windowDurationHours: number;
/** Gap between a cycle's close and its reopen (doc review + payment minutes). */
reopenDelayMinutes: number;
/**
* Gap between a cycle's close and its reopen — always doc review + payment
* minutes (the schedule's frozen snapshot, or the live sum for legacy rows).
*/
reopenGapMinutes: number;
exportBookingLeadHours: number;
}
@@ -435,10 +487,11 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* The real booking-window cycles for a schedule, straight from config.
*
* IMPORT: first window opens at `windowOpenHour` EAT on `departure importWindowLeadDays`
* for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes`
* after each close, on the same booking day, until departure. This mirrors
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs.
* for `windowDurationHours` (cut short by the desk close hour); if the train isn't
* full it reopens `reopenGapMinutes` (doc review + payment) after each close,
* honouring office hours, until departure. This mirrors `computeImportWindowTimes`
* + `concludeCycle`'s reopen math so the board shows the exact windows the engine
* runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure,
* with the open shifted to the next desk opening when it lands outside office hours
* (same math as `computeExportWindowTimes`).
@@ -464,7 +517,7 @@ export function listConfigBookingWindows(
const durationMs = cfg.windowDurationHours * 3_600_000;
// Post-close gap before the next cycle opens (doc review + payment), subject
// to office hours below.
const reopenMs = cfg.reopenDelayMinutes * 60_000;
const reopenMs = cfg.reopenGapMinutes * 60_000;
const officeHours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
@@ -484,6 +537,7 @@ export function listConfigBookingWindows(
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
windows.push(boardWindowFromInterval(opensAt, closesAt));