mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 17:50:54 +00:00
596 lines
21 KiB
TypeScript
596 lines
21 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')}`;
|
||
}
|
||
|
||
/**
|
||
* 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: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;
|
||
/** 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;
|
||
}
|