mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Added `unplaced` method in `BookingNotifierService` to log warnings for bookings that cannot be placed on any train. - Introduced `getAvailableDays` method in `TrainSchedulingService` to retrieve distinct days with open departures for a given route. - Created `AvailableDaysQueryDto` for querying available days based on origin and destination yards. - Updated `TrainSchedulingController` to expose an endpoint for available days. - Modified frontend components to support day-level booking, allowing customers to select only a day without pinning to a specific train. - Removed references to train schedules in booking forms and review steps, emphasizing day selection. - Added a database migration to create an index for efficient querying of bookings by route and day.
351 lines
11 KiB
TypeScript
351 lines
11 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),
|
||
};
|
||
}
|
||
|
||
/** 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: 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<string>();
|
||
// 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<T>(
|
||
items: T[],
|
||
getTimestamp: (item: T) => Date | null | undefined,
|
||
openDate: Date,
|
||
departureDate: Date,
|
||
pendingKey = 'pending-contract',
|
||
): Map<string, { window: BoardWindow | null; items: T[] }> {
|
||
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
|
||
|
||
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<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;
|
||
}
|