mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 00:50:56 +00:00
453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
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:00–07: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;
|
||
}
|