update window range

This commit is contained in:
Marshal
2026-06-13 07:59:48 +00:00
parent d3a479c142
commit b2f13c95c5
5 changed files with 426 additions and 16 deletions

View File

@@ -3,6 +3,9 @@ import {
listBatchWindowsForDate,
listBatchWindowsForBookings,
BATCH_WINDOW_START_HOURS,
boardWindowForTimestamp,
listBoardWindowsForRange,
groupBookingsIntoBoardWindows,
} from './batch-window.util';
describe('batch-window.util', () => {
@@ -50,3 +53,84 @@ describe('batch-window.util', () => {
expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key);
});
});
describe('batch-window board windows (midnight-based 3h slots)', () => {
it('maps 04:00 EAT to the 03:0006:00 slot', () => {
// 01:00 UTC = 04:00 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z'));
expect(w.label).toContain('03:00');
expect(w.label).toContain('06:00');
expect(w.date).toBe('2026-06-11');
expect(w.dateLabel).toContain('11 Jun');
});
it('maps 00:30 EAT to the 00:0003:00 slot of that EAT day', () => {
// 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z'));
expect(w.label).toContain('00:00');
expect(w.label).toContain('03:00');
expect(w.date).toBe('2026-06-11');
});
it('maps 23:00 EAT to the final 21:0024:00 slot', () => {
// 20:00 UTC = 23:00 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z'));
expect(w.label).toContain('21:00');
expect(w.label).toContain('24:00');
expect(w.date).toBe('2026-06-11');
});
it('lists a continuous range open→departure clamped at both ends', () => {
// open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC)
const open = new Date('2026-06-05T05:00:00.000Z');
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listBoardWindowsForRange(open, departure);
// Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5
expect(windows).toHaveLength(6 + 8 + 8 + 5);
expect(windows[0].date).toBe('2026-06-05');
expect(windows[0].label).toContain('06:00');
expect(windows[0].label).toContain('09:00');
const last = windows[windows.length - 1];
expect(last.date).toBe('2026-06-08');
expect(last.label).toContain('12:00');
expect(last.label).toContain('15:00');
// chronological + unique keys
const keys = windows.map((w) => w.key);
expect(new Set(keys).size).toBe(keys.length);
});
it('handles a same-day open→departure range', () => {
const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (0609 slot)
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (1215 slot)
const windows = listBoardWindowsForRange(open, departure);
// 06,09,12 = 3 slots
expect(windows).toHaveLength(3);
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
});
it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => {
const open = new Date('2026-06-05T05:00:00.000Z');
const departure = new Date('2026-06-06T11:00:00.000Z');
const items = [
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 0609 on 5th
{ id: 'b', ts: null }, // pending
];
const map = groupBookingsIntoBoardWindows(
items,
(i) => i.ts,
open,
departure,
'pending-contract',
);
const pending = map.get('pending-contract');
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
expect(withA?.window?.date).toBe('2026-06-05');
// empty slots are retained for the UI
const emptyCount = [...map.values()].filter(
(b) => b.window && b.items.length === 0,
).length;
expect(emptyCount).toBeGreaterThan(0);
});
});

View File

@@ -159,6 +159,154 @@ export function listBatchWindowsForBookings(
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 (0003, 0306, … 2124). */
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[],

View File

@@ -19,9 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import {
groupByBatchWindow,
} from './batch-window.util';
import { groupBookingsIntoBoardWindows } from './batch-window.util';
import {
BATCH_CRON,
BATCH_TIMEZONE,
@@ -82,6 +80,10 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
export interface BatchWindowGroup {
key: string;
label: string;
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
date: string;
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
dateLabel: string;
start: string;
end: string;
counts: {
@@ -401,11 +403,15 @@ export class BookingBatchService implements OnModuleInit {
const loco = s.trainSet?.locomotive ?? null;
const referenceDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupByBatchWindow(
// Display windows span the whole booking window: from when it opened
// (schedule creation) through the scheduled departure, in 3-hour EAT slots.
const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date();
const departureDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupBookingsIntoBoardWindows(
items,
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
referenceDate,
openDate,
departureDate,
);
const emptyCounts = () => ({
@@ -437,6 +443,8 @@ export class BookingBatchService implements OnModuleInit {
windows.push({
key: w.key,
label: w.label,
date: w.date,
dateLabel: w.dateLabel,
start: w.start.toISOString(),
end: w.end.toISOString(),
counts: countFor(bucket.items),
@@ -477,6 +485,8 @@ export class BookingBatchService implements OnModuleInit {
pendingContract: {
key: 'pending-contract',
label: 'Pending contract',
date: '',
dateLabel: '',
start: '',
end: '',
counts: countFor(pendingBookings),

View File

@@ -318,6 +318,40 @@ function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
);
}
/** "05 Jun 2026 · 06:00 09:00 EAT" → "06:00 09:00 EAT" (date lives in the day header). */
function timeLabelOf(label: string): string {
const idx = label.indexOf("·");
return idx >= 0 ? label.slice(idx + 1).trim() : label;
}
const EAT_TZ = "Africa/Addis_Ababa";
const dateKeyFmt = new Intl.DateTimeFormat("en-CA", {
timeZone: EAT_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const dateLabelFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TZ,
weekday: "short",
day: "2-digit",
month: "short",
});
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
function windowDateKey(w: BatchWindowGroup): string {
if (w.date) return w.date;
if (w.start) return dateKeyFmt.format(new Date(w.start));
return "undated";
}
/** Human day label for a window — prefers the API field, falls back to `start`. */
function windowDateLabel(w: BatchWindowGroup): string {
if (w.dateLabel) return w.dateLabel;
if (w.start) return dateLabelFmt.format(new Date(w.start));
return "Undated";
}
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
const total = window.bookings.length;
const hasIssues = window.bookings.some(
@@ -347,7 +381,7 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
</Box>
<Box style={{ minWidth: 0 }}>
<Text fw={700} size="sm" truncate>
{window.label}
{timeLabelOf(window.label)}
</Text>
<Text size="xs" c="dimmed">
{total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"}
@@ -459,13 +493,81 @@ export default function BatchScheduleDetailPage() {
"CONTAINER",
);
const defaultOpen = useMemo(() => {
// Group the flat window list into per-day sections (one per EAT calendar date).
const dayGroups = useMemo(() => {
if (!data) return [];
const withBookings = data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key);
if (data.pendingContract.bookings.length) withBookings.push("pending-contract");
return withBookings.length ? withBookings : [data.windows[0]?.key].filter(Boolean);
const byDate = new Map<
string,
{
date: string;
dateLabel: string;
windows: BatchWindowGroup[];
totalBookings: number;
counts: BatchWindowGroup["counts"];
hasIssues: boolean;
}
>();
for (const w of data.windows) {
const dateKey = windowDateKey(w);
let group = byDate.get(dateKey);
if (!group) {
group = {
date: dateKey,
dateLabel: windowDateLabel(w),
windows: [],
totalBookings: 0,
counts: {
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
},
hasIssues: false,
};
byDate.set(dateKey, group);
}
group.windows.push(w);
group.totalBookings += w.bookings.length;
group.counts.allocated += w.counts.allocated;
group.counts.selectedForBatch += w.counts.selectedForBatch;
group.counts.ready += w.counts.ready;
group.counts.waiting += w.counts.waiting;
group.counts.expired += w.counts.expired;
group.counts.pendingContract += w.counts.pendingContract;
group.hasIssues =
group.hasIssues ||
w.bookings.some(
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
);
}
return [...byDate.values()];
}, [data]);
// Windows with bookings open by default (inside an expanded day).
const openWindowKeys = useMemo(
() => (data ? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key) : []),
[data],
);
// Open today's day, any day with bookings, and pending-contract; else the last day.
const openDays = useMemo(() => {
if (!dayGroups.length && !data?.pendingContract.bookings.length) return [];
const todayEat = new Intl.DateTimeFormat("en-CA", {
timeZone: "Africa/Addis_Ababa",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
const open = dayGroups
.filter((d) => d.totalBookings > 0 || d.date === todayEat)
.map((d) => d.date);
if (!open.length && dayGroups.length) open.push(dayGroups[dayGroups.length - 1].date);
if (data?.pendingContract.bookings.length) open.push("pending-contract");
return open;
}, [dayGroups, data]);
const handleRunAllocation = () => {
runAllocation
.mutateAsync()
@@ -706,22 +808,84 @@ export default function BatchScheduleDetailPage() {
<Box>
<Title order={4}>Batch windows (EAT)</Title>
<Text size="sm" c="dimmed">
Bookings grouped by contract signing time in 3-hour windows expand one to
see bookings and wagon allocation issues.
3-hour windows for every day from when the booking window opened through the
departure date. Bookings appear under the date their contract was signed open a
day to see its windows.
</Text>
</Box>
</Group>
<Accordion
multiple
defaultValue={defaultOpen}
defaultValue={openDays}
variant="separated"
radius="md"
mt="md"
className="bb-window-accordion"
>
{data.windows.map((window) => (
<WindowAccordionItem key={window.key} window={window} />
{dayGroups.map((day) => (
<Accordion.Item key={day.date} value={day.date}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 10,
flexShrink: 0,
background: day.totalBookings ? "#FEF1D5" : "var(--mantine-color-gray-0)",
border: day.totalBookings
? "1px solid #FBD171"
: "1px solid var(--mantine-color-gray-2)",
color: day.totalBookings ? "#B26C09" : "var(--mantine-color-gray-5)",
}}
>
<CalendarDays size={16} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fw={700} size="sm" truncate>
{day.dateLabel}
</Text>
<Text size="xs" c="dimmed">
{day.totalBookings
? `${day.totalBookings} booking${day.totalBookings === 1 ? "" : "s"} · ${day.windows.length} windows`
: `${day.windows.length} windows · no bookings`}
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{day.hasIssues ? (
<Badge
variant="light"
color="red"
size="sm"
leftSection={<AlertTriangle size={10} />}
>
Issues
</Badge>
) : null}
<WindowCountChips counts={day.counts} />
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Accordion
multiple
defaultValue={openWindowKeys}
variant="separated"
radius="md"
className="bb-window-accordion"
>
{day.windows.map((window) => (
<WindowAccordionItem key={window.key} window={window} />
))}
</Accordion>
</Accordion.Panel>
</Accordion.Item>
))}
{data.pendingContract.bookings.length ? (
<Accordion.Item value="pending-contract">

View File

@@ -247,6 +247,10 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
export interface BatchWindowGroup {
key: string;
label: string;
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
date: string;
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
dateLabel: string;
start: string;
end: string;
counts: {