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, referenceDate: Date, ): BatchWindow[] { const byKey = new Map(); 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( items: T[], getTimestamp: (item: T) => Date | null | undefined, direction: string | null | undefined, departure: Date, cfg: BoardWindowConfig, pendingKey = 'pending-contract', anchorOpensAt?: Date | null, ): Map { const windows = listConfigBookingWindows(direction, departure, cfg, anchorOpensAt); const map = new Map(); 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( items: T[], getTimestamp: (item: T) => Date | null | undefined, referenceDate: Date, pendingKey = 'pending-contract', ): Map { const timestamps = items.map(getTimestamp); const windows = listBatchWindowsForBookings(timestamps, referenceDate); const map = new Map(); 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; }