diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index f2d3eda25..e765e5694 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -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:00–06: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:00–03: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:00–24: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 (06–09 slot) + const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 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 → 06–09 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index e837a6d48..38ab407bc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -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 (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[], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 8170c3bcf..771422fa2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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), diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 91849ee3e..afbad003e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -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 }) { - {window.label} + {timeLabelOf(window.label)} {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() { Batch windows (EAT) - 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. - {data.windows.map((window) => ( - + {dayGroups.map((day) => ( + + + + + + + + + + {day.dateLabel} + + + {day.totalBookings + ? `${day.totalBookings} booking${day.totalBookings === 1 ? "" : "s"} · ${day.windows.length} windows` + : `${day.windows.length} windows · no bookings`} + + + + + {day.hasIssues ? ( + } + > + Issues + + ) : null} + + + + + + + {day.windows.map((window) => ( + + ))} + + + ))} {data.pendingContract.bookings.length ? ( diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 7eae19bef..3cf69b78e 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -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: {