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), }; } /** 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: full-day, midnight-based 3h slots over a date range. // These are used ONLY for the batch-board UI grouping (not persisted, and // independent of the cron intake hours above). // --------------------------------------------------------------------------- /** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */ export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const; /** 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; } 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'); } /** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */ function boardWindowFromEatStart( year: number, month: number, day: number, startHour: number, ): BoardWindow { const start = eatToUtc(year, month, day, startHour); const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over) const end = eatToUtc(year, month, day, endHour); const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`; return { key: start.toISOString(), start, end, label: formatWindowLabel(start, end, endLabel), date: `${year}-${pad2(month)}-${pad2(day)}`, dateLabel: dayLabelFmt.format(start), }; } /** Which midnight-based 3h EAT slot a timestamp falls in. */ export function boardWindowForTimestamp(date: Date): BoardWindow { const { year, month, day, hour } = eatParts(date); let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0; for (const h of BOARD_WINDOW_HOURS) { if (hour >= h) startHour = h; } return boardWindowFromEatStart(year, month, day, startHour); } /** * Continuous list of board windows from `openDate` to `departureDate` (inclusive), * clamped to the slot containing `openDate` on the first day and the slot * containing `departureDate` on the last day. Returned in chronological order. */ export function listBoardWindowsForRange( openDate: Date, departureDate: Date, ): BoardWindow[] { const startWin = boardWindowForTimestamp(openDate); const endWin = boardWindowForTimestamp(departureDate); // Guard against an inverted range (departure before open). if (endWin.start.getTime() < startWin.start.getTime()) { return [startWin]; } const windows: BoardWindow[] = []; const seen = new Set(); // Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to // avoid any boundary ambiguity, then filter to [startWin.start, endWin.start]. let cursor = new Date(eatToUtc( Number(startWin.date.slice(0, 4)), Number(startWin.date.slice(5, 7)), Number(startWin.date.slice(8, 10)), 12, )); const lastDayMs = eatToUtc( Number(endWin.date.slice(0, 4)), Number(endWin.date.slice(5, 7)), Number(endWin.date.slice(8, 10)), 12, ).getTime(); while (cursor.getTime() <= lastDayMs) { const { year, month, day } = eatParts(cursor); for (const h of BOARD_WINDOW_HOURS) { const w = boardWindowFromEatStart(year, month, day, h); if ( w.start.getTime() >= startWin.start.getTime() && w.start.getTime() <= endWin.start.getTime() && !seen.has(w.key) ) { seen.add(w.key); windows.push(w); } } cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000); } windows.sort(compareBatchWindows); return windows; } /** * Group items into board windows spanning [openDate, departureDate]. Empty * windows are kept so the UI shows every slot. Items whose timestamp falls * outside the range still get their own window (nothing hidden). Items without * a timestamp go to `pendingKey`. */ export function groupBookingsIntoBoardWindows( items: T[], getTimestamp: (item: T) => Date | null | undefined, openDate: Date, departureDate: Date, pendingKey = 'pending-contract', ): Map { const map = new Map(); for (const w of listBoardWindowsForRange(openDate, departureDate)) { 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 = boardWindowForTimestamp(ts); if (!map.has(w.key)) { map.set(w.key, { window: w, items: [] }); } 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; }