Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts

596 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { BATCH_TIMEZONE } from './booking-batch.constants';
/** EAT intake boundaries — cron runs at these hours; each window spans to the next. */
export const BATCH_WINDOW_START_HOURS = [7, 10, 13, 16, 19, 22] as const;
export interface BatchWindow {
key: string;
label: string;
start: Date;
end: Date;
}
type EatDateParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
};
const dateFmt = new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
timeZone: BATCH_TIMEZONE,
});
const timeFmt = new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: BATCH_TIMEZONE,
});
function eatParts(date: Date): EatDateParts {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: BATCH_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((p) => p.type === type)?.value ?? 0);
return {
year: get('year'),
month: get('month'),
day: get('day'),
hour: get('hour'),
minute: get('minute'),
};
}
/**
* The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day
* key for day-level booking pools — it must match the day the portal calendar
* renders, so always derive day keys through this (never `toISOString().slice`).
*/
export function eatDay(date: Date): string {
const { year, month, day } = eatParts(date);
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
function eatToUtc(
year: number,
month: number,
day: number,
hour: number,
minute = 0,
): Date {
// EAT is UTC+3 year-round (no DST). Binary search would be safer across DST zones;
// for Africa/Addis_Ababa the offset is fixed.
const utcMs = Date.UTC(year, month - 1, day, hour - 3, minute, 0, 0);
return new Date(utcMs);
}
function formatWindowLabel(start: Date, end: Date, endHourLabel?: string): string {
const endTime = endHourLabel ?? timeFmt.format(new Date(end.getTime() - 60_000));
return `${dateFmt.format(start)} · ${timeFmt.format(start)} ${endTime} EAT`;
}
function windowFromEatStart(
year: number,
month: number,
day: number,
startHour: number,
): BatchWindow {
const start = eatToUtc(year, month, day, startHour);
let endYear = year;
let endMonth = month;
let endDay = day;
let endHour: number;
let endHourLabel: string;
const idx = BATCH_WINDOW_START_HOURS.indexOf(startHour as (typeof BATCH_WINDOW_START_HOURS)[number]);
if (idx === BATCH_WINDOW_START_HOURS.length - 1) {
endHour = 7;
endHourLabel = '07:00';
const next = new Date(eatToUtc(year, month, day, 0));
next.setUTCDate(next.getUTCDate() + 1);
const nextParts = eatParts(next);
endYear = nextParts.year;
endMonth = nextParts.month;
endDay = nextParts.day;
} else {
endHour = BATCH_WINDOW_START_HOURS[idx + 1];
endHourLabel = `${String(endHour).padStart(2, '0')}:00`;
}
const end = eatToUtc(endYear, endMonth, endDay, endHour);
return {
key: start.toISOString(),
start,
end,
label: formatWindowLabel(start, end, endHourLabel),
};
}
/** Build a UTC Date for an EAT wall-clock time on a `yyyy-MM-dd` EAT calendar day. */
export function eatDayToUtc(day: string, hour: number, minute = 0): Date {
const [year, month, dayOfMonth] = day.split('-').map(Number);
return eatToUtc(year, month, dayOfMonth, hour, minute);
}
/** Shift a `yyyy-MM-dd` EAT day key by whole days. */
export function shiftEatDay(day: string, deltaDays: number): string {
// Noon UTC keeps the +3h EAT offset from crossing a day boundary.
const [year, month, dayOfMonth] = day.split('-').map(Number);
const shifted = new Date(Date.UTC(year, month - 1, dayOfMonth + deltaDays, 12));
return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String(
shifted.getUTCDate(),
).padStart(2, '0')}`;
}
/**
* The daily office window `[openHour, closeHour)` in EAT: after `closeHour` the
* booking desk is shut and reopens `openHour` the next morning. `openHour ===
* closeHour` means a 24-hour desk that never breaks for the day.
*/
export interface OfficeHours {
windowOpenHour: number;
windowCloseHour: number;
}
/** True when the desk runs round the clock (open hour equals close hour). */
export function isRoundTheClock(hours: OfficeHours): boolean {
return hours.windowOpenHour === hours.windowCloseHour;
}
/**
* Where the NEXT booking cycle opens after a cycle closes at `closedAt`, given a
* not-yet-full train and a daily office window. `earliestNextOpen` is the raw
* ready time (close + doc-review + payment); the desk honours it only while
* inside office hours:
*
* • round-the-clock desk → opens at `earliestNextOpen` (no day break)
* • ready time before closeHour → opens at `earliestNextOpen`, same day
* • ready time at/after closeHour → desk shut; opens next morning at openHour
*
* Returns `null` when the next open would fall on/after `departure` — the train
* leaves before another cycle could run, so the window is done.
*
* The desk may run within one EAT day (`closeHour > openHour`), round the clock
* (`openHour === closeHour`), or overnight across midnight (`openHour >
* closeHour`, e.g. 08:00 → 07:00). `officeHoursOpen` handles all three.
*/
/**
* The EAT instant a booking cycle would open if it became ready at `readyAt`,
* honouring the daily office window but WITHOUT any departure bound:
*
* • round-the-clock desk → opens at `readyAt` (no day break)
* • ready before openHour → opens at openHour that EAT morning
* • ready inside office hours → opens at `readyAt`
* • ready at/after closeHour → opens at openHour the next morning
*
* `nextCycleOpensAt` layers the "before departure" gate on top of this; the first
* import window uses it directly and lets its own departure cap apply.
*/
export function officeHoursOpen(readyAt: Date, hours: OfficeHours): Date {
if (isRoundTheClock(hours)) {
return readyAt;
}
const { hour, minute } = eatParts(readyAt);
const readyMinutes = hour * 60 + minute;
const openMinutes = hours.windowOpenHour * 60;
const closeMinutes = hours.windowCloseHour * 60;
if (hours.windowOpenHour > hours.windowCloseHour) {
// Overnight desk, e.g. open 08:00 → close 07:00 next morning. The desk is
// open across midnight: [openHour, 24:00) on this EAT day and [00:00,
// closeHour) on the next. Only the daytime gap [closeHour, openHour) is shut.
if (readyMinutes >= openMinutes || readyMinutes < closeMinutes) {
// Inside the overnight window (either side of midnight) → open when ready.
return readyAt;
}
// In the daytime gap → the desk opens again at openHour this EAT morning.
return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour);
}
if (readyMinutes < openMinutes) {
// Ready before the desk opens on its own EAT calendar day → open this morning.
return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour);
}
if (readyMinutes < closeMinutes) {
// Inside office hours → open as soon as ready.
return readyAt;
}
// Desk shut for the day → open tomorrow morning.
return eatDayToUtc(shiftEatDay(eatDay(readyAt), 1), hours.windowOpenHour);
}
export function nextCycleOpensAt(
earliestNextOpen: Date,
hours: OfficeHours,
departure: Date,
): Date | null {
const opensAt = officeHoursOpen(earliestNextOpen, hours);
return opensAt.getTime() < departure.getTime() ? opensAt : null;
}
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;
}
/**
* Import booking-day window. The natural anchor is `windowOpenHour` EAT on
* departure-day minus `importWindowLeadDays`. When `now` is at/before that anchor
* (we're still before the lead window) the window opens at the anchor — the normal
* morning wait.
*
* Once `now` is PAST the anchor we're already inside the lead window, so the desk's
* office hours decide the open the same way a reopen cycle does (via
* `nextCycleOpensAt`):
*
* • 24-hour desk (open === close) → opens at `now`, any hour, day or night
* • `now` inside [openHour, closeHour) → opens at `now` (desk is open right now)
* • `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.
*/
export function computeImportWindowTimes(
departure: Date,
cfg: {
importWindowLeadDays: number;
windowOpenHour: number;
windowCloseHour: number;
windowDurationHours: number;
},
now: Date,
): InitialWindowTimes {
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
const anchor = eatDayToUtc(windowDay, cfg.windowOpenHour);
let opensAt: Date;
if (now.getTime() <= anchor.getTime()) {
// Before the lead window → normal morning wait at the anchor.
opensAt = anchor;
} else {
// Inside the lead window → the office-hours rule decides the open, exactly as a
// reopen cycle does: open now if the desk is open now (or round-the-clock),
// else at the next open hour. We use the same primitive as reopen cycles but
// WITHOUT its `< departure` null-gate — when the next open lands on/after
// departure the shared cap below clamps the (zero-length) window to departure,
// which is truthful, rather than masking it as "open now".
opensAt = officeHoursOpen(now, {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
}
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
if (closesAt.getTime() > departure.getTime()) {
closesAt = departure;
}
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
}
/**
* Export booking window: a single FCFS window from `exportBookingLeadHours`
* before departure until departure. The open honours the daily desk hours —
* when the raw lead instant lands while the desk is shut, the window opens at
* the next desk opening instead (capped at departure, so a config whose desk
* never opens before the train leaves yields a zero-length window rather than
* one that outlives the train).
*/
export function computeExportWindowTimes(
departure: Date,
cfg: {
exportBookingLeadHours: number;
windowOpenHour: number;
windowCloseHour: number;
},
): InitialWindowTimes {
const rawOpen = new Date(
departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
);
let opensAt = officeHoursOpen(rawOpen, {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (opensAt.getTime() > departure.getTime()) {
opensAt = departure;
}
return { windowOpensAt: opensAt, windowClosesAt: departure };
}
/**
* Earliest departure a train may be scheduled for — staff cannot schedule inside
* the lead window. IMPORT/DOMESTIC lead is in whole EAT days: with lead 3 and
* today the 11th, the 12th and 13th are blocked and the 14th is the first
* allowed departure day (00:00 EAT). EXPORT lead is in hours: earliest departure
* is `now + exportBookingLeadHours` (24h = 1 day). Mirrors the booking-window
* math so a schedulable date always has a real booking window before it.
*/
export function earliestSchedulableDeparture(
direction: string | null | undefined,
cfg: { importWindowLeadDays: number; exportBookingLeadHours: number },
now: Date,
): Date {
if (direction === 'EXPORT') {
return new Date(now.getTime() + cfg.exportBookingLeadHours * 3_600_000);
}
const earliestDay = shiftEatDay(eatDay(now), cfg.importWindowLeadDays);
return eatDayToUtc(earliestDay, 0);
}
/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
const { year, month, day, hour } = eatParts(date);
if (hour < 7) {
const prev = new Date(eatToUtc(year, month, day, 0));
prev.setUTCDate(prev.getUTCDate() - 1);
const prevParts = eatParts(prev);
return windowFromEatStart(prevParts.year, prevParts.month, prevParts.day, 22);
}
let startHour: (typeof BATCH_WINDOW_START_HOURS)[number] = 7;
for (const h of BATCH_WINDOW_START_HOURS) {
if (hour >= h) startHour = h;
}
return windowFromEatStart(year, month, day, startHour);
}
/** All six intake windows for an EAT calendar day (includes overnight 22:0007:00). */
export function listBatchWindowsForDate(reference: Date): BatchWindow[] {
const { year, month, day } = eatParts(reference);
return BATCH_WINDOW_START_HOURS.map((startHour) =>
windowFromEatStart(year, month, day, startHour),
);
}
export function compareBatchWindows(a: BatchWindow, b: BatchWindow): number {
return a.start.getTime() - b.start.getTime();
}
/** Schedule-day windows plus any extra windows that contain booking timestamps (cross-day). */
export function listBatchWindowsForBookings(
timestamps: Array<Date | null | undefined>,
referenceDate: Date,
): BatchWindow[] {
const byKey = new Map<string, BatchWindow>();
for (const w of listBatchWindowsForDate(referenceDate)) {
byKey.set(w.key, w);
}
for (const ts of timestamps) {
if (!ts) continue;
const w = getBatchWindowForTimestamp(ts);
byKey.set(w.key, w);
}
return [...byKey.values()].sort(compareBatchWindows);
}
// ---------------------------------------------------------------------------
// 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.
// ---------------------------------------------------------------------------
/** A board window carries an EAT calendar date in addition to the slot times. */
export interface BoardWindow extends BatchWindow {
/** EAT calendar day as ISO `YYYY-MM-DD`. */
date: string;
/** Human label for the day, e.g. `Thu, 05 Jun`. */
dateLabel: string;
}
/** Config fields the board needs to reconstruct booking-window cycles. */
export interface BoardWindowConfig {
importWindowLeadDays: number;
windowOpenHour: number;
/** 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;
exportBookingLeadHours: number;
}
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
month: 'short',
timeZone: BATCH_TIMEZONE,
});
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */
function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
const { year, month, day } = eatParts(start);
return {
key: start.toISOString(),
start,
end,
label: formatWindowLabel(start, end),
date: `${year}-${pad2(month)}-${pad2(day)}`,
dateLabel: dayLabelFmt.format(start),
};
}
/**
* 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.
* 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`).
*
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
* `windowOpensAt` instead of recomputing it from config. Pass it so the board
* shows the real frozen window (and reopen cycles projected from it) even after
* the global rule changed — the recomputed open time would otherwise drift.
*/
export function listConfigBookingWindows(
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
anchorOpensAt?: Date | null,
): BoardWindow[] {
if (direction === 'EXPORT') {
const start =
anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
return [boardWindowFromInterval(start, departure)];
}
const windows: BoardWindow[] = [];
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 officeHours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// 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;
windows.push(boardWindowFromInterval(opensAt, closesAt));
const earliestNextOpen = new Date(closesAt.getTime() + reopenMs);
opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure);
if (opensAt == null) break;
}
// Degenerate config (no window before departure) — surface a single window
// clamped to departure so the board still renders something meaningful.
if (windows.length === 0) {
windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure));
}
return windows;
}
/** Which config booking-window a timestamp falls in; null if before/after all of them. */
function configWindowForTimestamp(
windows: BoardWindow[],
date: Date,
): BoardWindow | null {
const ms = date.getTime();
for (const w of windows) {
if (ms >= w.start.getTime() && ms < w.end.getTime()) return w;
}
return null;
}
/**
* Group items into the real config booking-window cycles for a schedule. Empty
* windows are kept so the UI shows every cycle. Items whose timestamp falls
* outside every window (e.g. a booking created before the window opened) are
* attached to the nearest window by start time so nothing is hidden. Items
* without a timestamp go to `pendingKey`.
*/
export function groupBookingsIntoBoardWindows<T>(
items: T[],
getTimestamp: (item: T) => Date | null | undefined,
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
pendingKey = 'pending-contract',
anchorOpensAt?: Date | null,
): Map<string, { window: BoardWindow | null; items: T[] }> {
const windows = listConfigBookingWindows(direction, departure, cfg, anchorOpensAt);
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
for (const w of windows) {
map.set(w.key, { window: w, items: [] });
}
map.set(pendingKey, { window: null, items: [] });
const firstWindow = windows[0] ?? null;
const lastWindow = windows[windows.length - 1] ?? null;
for (const item of items) {
const ts = getTimestamp(item);
if (!ts) {
map.get(pendingKey)!.items.push(item);
continue;
}
let w = configWindowForTimestamp(windows, ts);
if (!w) {
// Booked before the window opened → first cycle; after it closed → last cycle.
w =
firstWindow && ts.getTime() < firstWindow.start.getTime()
? firstWindow
: lastWindow;
}
if (!w) {
map.get(pendingKey)!.items.push(item);
continue;
}
map.get(w.key)!.items.push(item);
}
return map;
}
/** Group items by batch window key; items without a timestamp go to `pendingKey`. */
export function groupByBatchWindow<T>(
items: T[],
getTimestamp: (item: T) => Date | null | undefined,
referenceDate: Date,
pendingKey = 'pending-contract',
): Map<string, { window: BatchWindow | null; items: T[] }> {
const timestamps = items.map(getTimestamp);
const windows = listBatchWindowsForBookings(timestamps, referenceDate);
const map = new Map<string, { window: BatchWindow | null; items: T[] }>();
for (const w of windows) {
map.set(w.key, { window: w, items: [] });
}
map.set(pendingKey, { window: null, items: [] });
for (const item of items) {
const ts = getTimestamp(item);
if (!ts) {
map.get(pendingKey)!.items.push(item);
continue;
}
const w = getBatchWindowForTimestamp(ts);
if (!map.has(w.key)) {
map.set(w.key, { window: w, items: [] });
}
map.get(w.key)!.items.push(item);
}
return map;
}