remove reopen delay minutes from global rules and update related types

- Removed the  field from  and related components.
- Updated  to reflect the removal of the reopen delay input field.
- Modified  to include new train number fields:  and .
- Added  interface to manage active schedules with trade direction.
- Introduced  interface to track wagon shortages in bookings.
- Updated  logic to ensure consistent UI state representation.
- Created migrations to drop the  column and add  and  columns to the  table.
- Added tests for the new booking window display logic and wagon planning functionality.
This commit is contained in:
Marshal
2026-07-15 09:13:02 +00:00
parent 9be7f356f0
commit 11771e5f92
39 changed files with 1731 additions and 269 deletions

View File

@@ -0,0 +1,264 @@
import { describe, expect, it } from "vitest";
import { bookingWindowUiState } from "@edr/ui-common";
import type {
BookingWindowStateInput,
BookingWindowUiState,
} from "@edr/ui-common";
/**
* Scenario table for the shared badge/countdown state. This is the logic that
* previously let a full export train show an "Upcoming" badge above a live
* "Window closes in …" countdown — every row asserts badge kind, countdown
* target, and bookability TOGETHER, so they can never disagree again.
*/
const OPENS = "2026-07-26T05:00:00.000Z";
const CLOSES = "2026-07-27T05:00:00.000Z";
const DOC_ENDS = "2026-07-24T08:30:00.000Z";
const PAY_ENDS = "2026-07-24T09:30:00.000Z";
/** A full row with every timestamp present; scenarios override what they test. */
function row(over: Partial<BookingWindowStateInput>): BookingWindowStateInput {
return {
windowPhase: "OPEN",
bookingWindowStatus: "OPEN",
windowOpensAt: OPENS,
windowClosesAt: CLOSES,
docReviewEndsAt: DOC_ENDS,
paymentPhaseEndsAt: PAY_ENDS,
...over,
};
}
interface Scenario {
name: string;
input: BookingWindowStateInput;
expected: BookingWindowUiState;
}
const scenarios: Scenario[] = [
// ---- export FCFS lifecycle -------------------------------------------------
{
name: "export announced, before lead window (PRE_WINDOW/CLOSED)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "export window open, space left (OPEN/OPEN)",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "export filled mid-window (OPEN/FULL) — the reported bug",
input: row({ bookingWindowStatus: "FULL" }),
expected: { kind: "FULL", countdownTo: null, isBookable: false },
},
{
name: "export space freed after an expiry cleared FULL (OPEN/OPEN again)",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "export window over (DONE/CLOSED)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "export departed while full (DONE/FULL)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "FULL" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- import daily cycle ----------------------------------------------------
{
name: "import before booking day (PRE_WINDOW/CLOSED)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "import window open (OPEN/OPEN)",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "import window closed, staff reviewing docs (DOC_REVIEW/CLOSED)",
input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
},
{
name: "import payment phase, selected customers paying (PAYMENT/CLOSED)",
input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
},
{
name: "import batch tentatively filled the train (PAYMENT/FULL) — phase wins, unpaid may still free space",
input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "FULL" }),
expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
},
{
name: "import doc review while flag already FULL (DOC_REVIEW/FULL) — phase wins",
input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "FULL" }),
expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
},
{
name: "import reopen cycle scheduled (PRE_WINDOW/CLOSED, cycle 2)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "import reopen refused while train still FULL (PRE_WINDOW/FULL)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "FULL" }),
expected: { kind: "FULL", countdownTo: null, isBookable: false },
},
{
name: "import train full and finalized (DONE/FULL)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "FULL" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "import no cycle fits before departure (DONE/CLOSED)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "legacy closed-for-the-day row (CLOSED_FOR_DAY/CLOSED)",
input: row({ windowPhase: "CLOSED_FOR_DAY", bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "legacy closed-for-the-day row while full (CLOSED_FOR_DAY/FULL)",
input: row({ windowPhase: "CLOSED_FOR_DAY", bookingWindowStatus: "FULL" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- desync / stale rows ---------------------------------------------------
{
name: "phase OPEN but desk flag CLOSED (desync) — closed, no countdown",
input: row({ bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "dispatched train stuck at OPEN/CLOSED (tick skips non-scheduled rows)",
input: row({ bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "FULL flag with no phase at all (legacy pre-window-engine row)",
input: row({ windowPhase: null, bookingWindowStatus: "FULL" }),
expected: { kind: "FULL", countdownTo: null, isBookable: false },
},
{
name: "legacy row, no phase, desk open (null/OPEN) — not phase-driven, shows closed",
input: row({ windowPhase: null }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "unknown future phase value — safe fallback to closed",
input: row({ windowPhase: "SOMETHING_NEW" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- missing timestamps (no countdown, badge still right) -------------------
{
name: "PRE_WINDOW without an opens-at timestamp",
input: row({
windowPhase: "PRE_WINDOW",
bookingWindowStatus: "CLOSED",
windowOpensAt: null,
}),
expected: { kind: "PRE_WINDOW", countdownTo: null, isBookable: false },
},
{
name: "OPEN without a closes-at timestamp",
input: row({ windowClosesAt: null }),
expected: { kind: "OPEN", countdownTo: null, isBookable: true },
},
{
name: "DOC_REVIEW without an ends-at timestamp",
input: row({
windowPhase: "DOC_REVIEW",
bookingWindowStatus: "CLOSED",
docReviewEndsAt: null,
}),
expected: { kind: "DOC_REVIEW", countdownTo: null, isBookable: false },
},
{
name: "PAYMENT without an ends-at timestamp",
input: row({
windowPhase: "PAYMENT",
bookingWindowStatus: "CLOSED",
paymentPhaseEndsAt: null,
}),
expected: { kind: "PAYMENT", countdownTo: null, isBookable: false },
},
{
name: "row with every field null",
input: {
windowPhase: null,
bookingWindowStatus: null,
windowOpensAt: null,
windowClosesAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
},
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "row with every field undefined (structural minimum)",
input: {},
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- countdown targets track the right deadline per phase -------------------
{
name: "PRE_WINDOW counts to opens-at, not closes-at",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "OPEN counts to closes-at, not doc review",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "DOC_REVIEW counts to review end, not payment end",
input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
},
{
name: "PAYMENT counts to payment end, not window close",
input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
},
];
describe("bookingWindowUiState", () => {
it.each(scenarios)("$name", ({ input, expected }) => {
expect(bookingWindowUiState(input)).toEqual(expected);
});
it("never yields a countdown on a non-bookable FULL state, whatever else is set", () => {
for (const phase of ["OPEN", "PRE_WINDOW", null, "ANYTHING"]) {
const state = bookingWindowUiState(row({ windowPhase: phase, bookingWindowStatus: "FULL" }));
expect(state.kind).toBe("FULL");
expect(state.countdownTo).toBeNull();
expect(state.isBookable).toBe(false);
}
});
it("is bookable ONLY when phase and desk flag are both OPEN", () => {
const combos: Array<[string | null, string | null]> = [];
for (const phase of ["PRE_WINDOW", "OPEN", "DOC_REVIEW", "PAYMENT", "DONE", "CLOSED_FOR_DAY", null]) {
for (const status of ["OPEN", "CLOSED", "FULL", null]) {
combos.push([phase, status]);
}
}
for (const [phase, status] of combos) {
const state = bookingWindowUiState(
row({ windowPhase: phase, bookingWindowStatus: status }),
);
expect(state.isBookable).toBe(phase === "OPEN" && status === "OPEN");
}
});
});