improve group window

This commit is contained in:
Marshal
2026-07-13 11:07:14 +00:00
parent 55bc36742b
commit 9e748d6291

View File

@@ -440,10 +440,14 @@ export class TrainSchedulingService {
* caller then computes its own times as before — nothing changes for the
* single-schedule case).
*
* The anchor is preferably a still-PRE_WINDOW sibling (its times are the live
* group clock). If every sibling has already opened, we still copy the earliest
* sibling's frozen times so the new train lines up with the group the customer
* already sees rather than drifting onto its own `now`-based clock.
* The anchor is the sibling that best represents where the GROUP currently is
* on its shared clock, so a train created mid-cycle joins the group AT its
* current phase (with the group's exact doc-review / payment deadlines) instead
* of restarting the whole cycle on its own `now`. When the group has advanced
* past PRE_WINDOW we pick the MOST-ADVANCED live (non-DONE) sibling — that is
* the phase the joiner must adopt to see the same "N minutes left" the group
* already shows. When every sibling is still PRE_WINDOW we pick the
* earliest-opening one (the group's frozen open clock).
*/
private async findGroupWindowAnchor(
manager: EntityManager,
@@ -460,37 +464,67 @@ export class TrainSchedulingService {
if (siblings.length === 0) return null;
const withWindow = siblings.filter((s) => s.windowOpensAt != null);
if (withWindow.length === 0) return null;
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
// not yet DONE) — the joiner must land in that same phase on the same clock.
const PHASE_ORDER = ['PRE_WINDOW', 'OPEN', 'DOC_REVIEW', 'PAYMENT'];
const live = withWindow.filter(
(s) => s.windowPhase != null && PHASE_ORDER.includes(s.windowPhase) &&
s.windowPhase !== 'PRE_WINDOW',
);
if (live.length > 0) {
// Most-advanced phase leads; ties broken by earliest open for determinism.
return live.reduce((best, s) => {
const a = PHASE_ORDER.indexOf(s.windowPhase!);
const b = PHASE_ORDER.indexOf(best.windowPhase!);
if (a !== b) return a > b ? s : best;
return s.windowOpensAt!.getTime() < best.windowOpensAt!.getTime() ? s : best;
});
}
// Otherwise the whole group is still PRE_WINDOW — earliest-opening sibling
// defines the group clock (the one a customer would have seen first).
const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW');
const pool = pending.length > 0 ? pending : withWindow;
// Earliest-opening sibling defines the group clock — deterministic and the
// one a customer would have seen first.
return pool.reduce((earliest, s) =>
s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest,
);
}
/**
* The window fields (open/close times + frozen rule snapshot) an anchor sibling
* hands down to the rest of its group. The shared open/close instants make every
* schedule in the group advance through the SAME open, doc-review, payment, and
* close instants on the shared 10s tick — doc-review/payment ends are derived
* live from these shared times during phase advance, so they fall in sync.
* The full window state an anchor sibling hands down to a schedule JOINING its
* group. Copies not just the open/close times + frozen rule snapshot but the
* anchor's LIVE PHASE and every phase-end timestamp (docReviewEndsAt,
* paymentPhaseEndsAt, bookingCycleNo, bookingWindowStatus). This is what makes a
* train created mid-cycle show the EXACT SAME "N minutes left" as the rest of
* the group: it enters directly at the group's current phase with the group's
* shared payment deadline, instead of restarting PRE_WINDOW→…→PAYMENT on its own
* `now` and stamping its own later `paymentPhaseEndsAt`.
*
* `targetDeparture` is the JOINING schedule's own departure: the shared times
* are clamped to it so a group whose trains depart at different times of the
* same day never hands an earlier-departing train a window that outlives its
* `targetDeparture` is the JOINING schedule's own departure: every time is
* clamped to it so a group whose trains depart at different times of the same
* day never hands an earlier-departing train a deadline that outlives its
* departure (computeImport/ExportWindowTimes clamp to departure at source; this
* preserves that invariant when the anchor departed later). A window that would
* be entirely after this train's departure collapses to a zero-length window at
* departure — truthful, not a window that never closes.
* preserves that invariant when the anchor departed later).
*/
private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) {
const cap = targetDeparture.getTime();
const clamp = (d: Date | null | undefined): Date | null =>
d == null ? null : d.getTime() > cap ? targetDeparture : d;
return {
// Live phase + its deadlines — a mid-cycle joiner lands here directly.
windowPhase: anchor.windowPhase,
bookingWindowStatus:
anchor.bookingWindowStatus === 'FULL'
? 'OPEN'
: anchor.bookingWindowStatus,
bookingCycleNo: anchor.bookingCycleNo,
windowOpensAt: clamp(anchor.windowOpensAt),
windowClosesAt: clamp(anchor.windowClosesAt),
docReviewEndsAt: clamp(anchor.docReviewEndsAt),
docReviewCompletedAt: clamp(anchor.docReviewCompletedAt),
paymentPhaseEndsAt: clamp(anchor.paymentPhaseEndsAt),
// Frozen rule snapshot.
ruleWindowOpenHour: anchor.ruleWindowOpenHour,
ruleWindowCloseHour: anchor.ruleWindowCloseHour,
ruleWindowDurationHours: anchor.ruleWindowDurationHours,