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

453 lines
15 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')}`;
}
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;
}
/**
* Import booking-day window: opens at `windowOpenHour` EAT on departure-day minus
* `importWindowLeadDays`, for `windowDurationHours`. A schedule created after its
* computed window has fully passed gets a same-day window starting now instead,
* capped at departure.
*/
export function computeImportWindowTimes(
departure: Date,
cfg: {
importWindowLeadDays: number;
windowOpenHour: number;
windowDurationHours: number;
},
now: Date,
): InitialWindowTimes {
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour);
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
if (closesAt.getTime() <= now.getTime()) {
opensAt = now;
closesAt = new Date(now.getTime() + cfg.windowDurationHours * 3_600_000);
}
if (closesAt.getTime() > departure.getTime()) {
closesAt = departure;
}
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
}
/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */
export function computeExportWindowTimes(
departure: Date,
cfg: { exportBookingLeadHours: number },
): InitialWindowTimes {
return {
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000),
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;
windowDurationHours: number;
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.
*
* `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 ??
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
return [boardWindowFromInterval(start, departure)];
}
const windows: BoardWindow[] = [];
const durationMs = cfg.windowDurationHours * 3_600_000;
const reopenMs = cfg.reopenDelayMinutes * 60_000;
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
let opensAt = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
for (let cycle = 0; cycle < 12; 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 nextOpensAt = new Date(closesAt.getTime() + reopenMs);
if (
nextOpensAt.getTime() >= departure.getTime() ||
eatDay(nextOpensAt) !== eatDay(opensAt)
) {
break;
}
opensAt = nextOpensAt;
}
// 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;
}