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; } /** * 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 8–17 → 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; } /** * The instant a schedule stops accepting bookings. By default that is departure, * but a configured close offset (import/export, minutes) pulls it earlier: * `departure − offset`. This is the single bound every window close, reopen * cycle and export FCFS close is capped at — swap it in wherever the logic used * to cap at departure. A non-positive/absent offset yields departure unchanged. */ export function bookingCloseCutoff( departure: Date, direction: string | null | undefined, cfg: { importCloseOffsetMinutes?: number | null; exportCloseOffsetMinutes?: number | null; }, ): Date { const offsetMinutes = direction === 'EXPORT' ? cfg.exportCloseOffsetMinutes : cfg.importCloseOffsetMinutes; if (offsetMinutes == null || !(offsetMinutes > 0)) return departure; return new Date(departure.getTime() - offsetMinutes * 60_000); } 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 the desk close hour * and at departure. */ export function computeImportWindowTimes( departure: Date, cfg: { importWindowLeadDays: number; windowOpenHour: number; windowCloseHour: number; windowDurationHours: number; importCloseOffsetMinutes?: number | null; }, now: Date, ): InitialWindowTimes { // The window opens off the REAL departure (open day = departure − leadDays), // but shuts at the configured cutoff (departure − closeOffset, or departure). const cutoff = bookingCloseCutoff(departure, 'IMPORT', cfg); 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); closesAt = clampCloseToOfficeHours(opensAt, closesAt, { windowOpenHour: cfg.windowOpenHour, windowCloseHour: cfg.windowCloseHour, }); if (closesAt.getTime() > cutoff.getTime()) { closesAt = cutoff; } 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; exportCloseOffsetMinutes?: number | null; }, ): InitialWindowTimes { // Opens off the real departure (lead hours), shuts at the cutoff // (departure − closeOffset, or departure when no offset is set). const cutoff = bookingCloseCutoff(departure, 'EXPORT', cfg); const rawOpen = new Date( departure.getTime() - cfg.exportBookingLeadHours * 3_600_000, ); let opensAt = officeHoursOpen(rawOpen, { windowOpenHour: cfg.windowOpenHour, windowCloseHour: cfg.windowCloseHour, }); // Open can't outlive the cutoff (a huge offset would otherwise leave a // negative-length window); clamp to a zero-length window at the cutoff. if (opensAt.getTime() > cutoff.getTime()) { opensAt = cutoff; } return { windowOpensAt: opensAt, windowClosesAt: cutoff }; } /** * 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 // 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. */ 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 — always doc review + payment * minutes (the schedule's frozen snapshot, or the live sum for legacy rows). */ reopenGapMinutes: number; exportBookingLeadHours: number; /** Minutes before departure the import window shuts; NULL/0 ⇒ close at departure. */ importCloseOffsetMinutes?: number | null; /** Minutes before departure the export window shuts; NULL/0 ⇒ close at departure. */ exportCloseOffsetMinutes?: number | null; } 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` (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`). * * `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[] { // Bookings shut at the cutoff (departure − closeOffset), not departure. The // window opens still key off the real departure below; only closes are capped // here, so the board draws the exact windows the engine runs. const cutoff = bookingCloseCutoff(departure, direction, cfg); if (direction === 'EXPORT') { const start = anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt; return [boardWindowFromInterval(start, cutoff)]; } 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.reopenGapMinutes * 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 the cutoff. maxCycles is a derived runaway backstop sized to the real // span (first open → cutoff) over the smallest possible advance, so a // legitimate config is never silently truncated — only a pathological // zero-length one would hit it. const spanMs = cutoff.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() >= cutoff.getTime()) break; let closesAt = new Date(opensAt.getTime() + durationMs); closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours); if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff; windows.push(boardWindowFromInterval(opensAt, closesAt)); const earliestNextOpen = new Date(closesAt.getTime() + reopenMs); opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, cutoff); if (opensAt == null) break; } // Degenerate config (no window before the cutoff) — surface a single window // clamped to the cutoff so the board still renders something meaningful. if (windows.length === 0) { windows.push(boardWindowFromInterval(new Date(cutoff.getTime() - durationMs), cutoff)); } 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; }