metatrondelivery/tests/unit/scheduling.test.ts
metatroncubeswdev 8c2b8a9e1c feat: Phase 2 — scheduling engine (timezone/DST-safe, pure, unit-tested)
Implements the "heart" of the app per IMPLEMENTATION_PLAN.md §6 Phase 2,
no UI yet by design:

- app/lib/time.ts: Luxon-based wall-clock helpers. slotDateTime() builds a
  slot's instant from calendar-date + minutes-from-midnight by setting
  hour/minute fields directly rather than adding an elapsed-time duration
  to midnight — the latter is wrong by exactly one hour for any wall-clock
  time on a spring-forward day, since a real elapsed-time addition crosses
  the lost hour. Also detects and rejects wall-clock times that don't exist
  in a spring-forward gap (Luxon silently rolls these forward instead of
  invalidating them, so this required an explicit post-set field check).
- app/services/scheduling.server.ts: getAvailability(), a pure function
  (no DB/Shopify calls — every input injected, including `now`) that
  applies slot templates, date overrides, blackout dates, cutoff/lead-time,
  and capacity to produce the bookable slots per date. Excludes unavailable
  slots entirely rather than flagging them, per PRODUCT_STRATEGY.md §2.
- app/services/capacity.server.ts: remainingCapacity()/hasCapacity() — kept
  as pure functions over an injected `consumed` count so this doesn't need
  to change once Phase 4 adds real Booking/SlotHold-backed consumption.

41 unit tests total (up from 8), including DST regression tests that would
fail against a naive "midnight + elapsed minutes" implementation, an
ambiguous-time (fall-back) case, a nonexistent-time (spring-forward gap)
case, and a getAvailability run across the actual 2024 spring-forward date
verifying both the UTC offset change and that wall-clock hours stay correct
on both sides of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:53:39 -04:00

163 lines
6.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { slotDateTime } from "../../app/lib/time";
import { getAvailability, type SlotTemplateLike } from "../../app/services/scheduling.server";
const ZONE = "America/Toronto";
function now(date: string, minutesFromMidnight: number) {
return slotDateTime(date, minutesFromMidnight, ZONE);
}
// A simple weekday-only (Mon-Fri) 9-5 template, 1hr cutoff, no extra lead time.
const WEEKDAY_TEMPLATE: SlotTemplateLike[] = [1, 2, 3, 4, 5].map((weekday) => ({
weekday,
startMin: 9 * 60,
endMin: 17 * 60,
capacity: 5,
cutoffMin: 60,
leadTimeMin: 0,
}));
describe("getAvailability", () => {
it("returns a slot for each weekday template date in range, none for weekends", () => {
// 2024-03-04 (Mon) .. 2024-03-10 (Sun)
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-10" },
slotTemplates: WEEKDAY_TEMPLATE,
now: now("2024-03-01", 0),
});
expect(Object.keys(result).sort()).toEqual(["2024-03-04", "2024-03-05", "2024-03-06", "2024-03-07", "2024-03-08"]);
expect(result["2024-03-04"]).toHaveLength(1);
expect(result["2024-03-04"][0]).toMatchObject({ startMin: 9 * 60, endMin: 17 * 60, remainingCapacity: 5 });
});
it("hides a slot once now is within its cutoff window", () => {
// Slot is 2024-03-04 09:00, cutoff 60 min -> unavailable from 08:00 on.
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
now: now("2024-03-04", 8 * 60 + 1),
});
expect(result["2024-03-04"]).toBeUndefined();
});
it("shows a slot exactly at the cutoff boundary", () => {
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
now: now("2024-03-04", 8 * 60), // exactly 60 min before 9:00
});
expect(result["2024-03-04"]).toHaveLength(1);
});
it("enforces leadTimeMin even when it exceeds cutoffMin", () => {
const template: SlotTemplateLike[] = [
{ weekday: 1, startMin: 9 * 60, endMin: 17 * 60, capacity: 5, cutoffMin: 60, leadTimeMin: 24 * 60 },
];
// Only 2 hours before slot start — passes the 60-min cutoff but fails the 24h lead time.
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: template,
now: now("2024-03-04", 7 * 60),
});
expect(result["2024-03-04"]).toBeUndefined();
});
it("excludes a blacked-out date entirely, even if a template would otherwise apply", () => {
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-05" },
slotTemplates: WEEKDAY_TEMPLATE,
blackoutDates: [{ date: "2024-03-04" }],
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"]).toBeUndefined();
expect(result["2024-03-05"]).toHaveLength(1);
});
it("a closed override removes the date even though a template exists", () => {
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
overrides: [{ date: "2024-03-04", closed: true, startMin: null, endMin: null, capacity: null }],
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"]).toBeUndefined();
});
it("a non-closed override replaces the day's window/capacity instead of the template's", () => {
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
overrides: [
{ date: "2024-03-04", closed: false, startMin: 12 * 60, endMin: 14 * 60, capacity: 2 },
],
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"]).toHaveLength(1);
expect(result["2024-03-04"][0]).toMatchObject({ startMin: 12 * 60, endMin: 14 * 60, capacity: 2 });
});
it("hides a slot once consumed capacity reaches the template capacity", () => {
const consumed = new Map([[`2024-03-04|${9 * 60}`, 5]]);
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
consumed,
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"]).toBeUndefined();
});
it("reduces remainingCapacity but keeps the slot visible when partially consumed", () => {
const consumed = new Map([[`2024-03-04|${9 * 60}`, 3]]);
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
consumed,
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"][0].remainingCapacity).toBe(2);
});
it("computes correct instants for a range spanning the spring-forward DST transition", () => {
// 2024-03-08 (Fri) and 2024-03-11 (Mon) bracket the 2024-03-10 transition.
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-08", endDate: "2024-03-11" },
slotTemplates: WEEKDAY_TEMPLATE,
now: now("2024-03-01", 0),
});
const friday = result["2024-03-08"][0];
const monday = result["2024-03-11"][0];
expect(friday.start.toUTC().hour).toBe(14); // EST, UTC-5
expect(monday.start.toUTC().hour).toBe(13); // EDT, UTC-4 — offset already changed
expect(friday.start.hour).toBe(9);
expect(monday.start.hour).toBe(9); // still wall-clock 9 AM despite the offset shift
});
it("returns multiple slots per day sorted by start time when several templates match", () => {
const templates: SlotTemplateLike[] = [
{ weekday: 1, startMin: 14 * 60, endMin: 16 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
{ weekday: 1, startMin: 9 * 60, endMin: 11 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
];
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: templates,
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"].map((s) => s.startMin)).toEqual([9 * 60, 14 * 60]);
});
});