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]); }); describe("per-day order cap (dailyCap / consumedPerDate)", () => { it("hides an entire date once its bookings reach the cap", () => { const result = getAvailability({ timezone: ZONE, dateRange: { startDate: "2024-03-04", endDate: "2024-03-05" }, slotTemplates: WEEKDAY_TEMPLATE, now: now("2024-03-01", 0), dailyCap: 3, consumedPerDate: new Map([["2024-03-04", 3]]), }); expect(result["2024-03-04"]).toBeUndefined(); expect(result["2024-03-05"]).toHaveLength(1); }); it("caps a slot's remainingCapacity to the day's remaining budget", () => { const result = getAvailability({ timezone: ZONE, dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" }, slotTemplates: WEEKDAY_TEMPLATE, // slot capacity 5 now: now("2024-03-01", 0), dailyCap: 4, consumedPerDate: new Map([["2024-03-04", 3]]), // only 1 left for the whole day }); expect(result["2024-03-04"][0].remainingCapacity).toBe(1); }); it("is a no-op when dailyCap is null", () => { const result = getAvailability({ timezone: ZONE, dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" }, slotTemplates: WEEKDAY_TEMPLATE, now: now("2024-03-01", 0), dailyCap: null, consumedPerDate: new Map([["2024-03-04", 999]]), }); expect(result["2024-03-04"][0].remainingCapacity).toBe(5); }); }); describe("cart-content slot blocking (blockedStartMins)", () => { it("drops a slot whose start minute is blocked for the cart", () => { const templates: SlotTemplateLike[] = [ { weekday: 1, startMin: 9 * 60, endMin: 11 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 }, { weekday: 1, startMin: 14 * 60, endMin: 16 * 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), blockedStartMins: [9 * 60], }); expect(result["2024-03-04"].map((s) => s.startMin)).toEqual([14 * 60]); }); it("removes the date entirely if every slot on it is blocked", () => { const result = getAvailability({ timezone: ZONE, dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" }, slotTemplates: WEEKDAY_TEMPLATE, now: now("2024-03-01", 0), blockedStartMins: [9 * 60], }); expect(result["2024-03-04"]).toBeUndefined(); }); }); describe("SHIPPING arrival range (transitMinDays/transitMaxDays)", () => { it("computes arrivalRangeStart/End from the slot's start date, not left unset", () => { const templates: SlotTemplateLike[] = [ { weekday: 1, startMin: 9 * 60, endMin: 17 * 60, capacity: 5, cutoffMin: 0, leadTimeMin: 0, transitMinDays: 2, transitMaxDays: 4 }, ]; const result = getAvailability({ timezone: ZONE, dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" }, slotTemplates: templates, now: now("2024-03-01", 0), }); const slot = result["2024-03-04"][0]; expect(slot.arrivalRangeStart?.toISODate()).toBe("2024-03-06"); expect(slot.arrivalRangeEnd?.toISODate()).toBe("2024-03-08"); }); it("leaves arrivalRangeStart/End unset when no transit days are configured (PICKUP/LOCAL_DELIVERY, or SHIPPING without them)", () => { const result = getAvailability({ timezone: ZONE, dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" }, slotTemplates: WEEKDAY_TEMPLATE, now: now("2024-03-01", 0), }); const slot = result["2024-03-04"][0]; expect(slot.arrivalRangeStart).toBeUndefined(); expect(slot.arrivalRangeEnd).toBeUndefined(); }); it("supports only a min or only a max being configured independently", () => { const templates: SlotTemplateLike[] = [ { weekday: 1, startMin: 9 * 60, endMin: 17 * 60, capacity: 5, cutoffMin: 0, leadTimeMin: 0, transitMinDays: 3, transitMaxDays: null }, ]; const result = getAvailability({ timezone: ZONE, dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" }, slotTemplates: templates, now: now("2024-03-01", 0), }); const slot = result["2024-03-04"][0]; expect(slot.arrivalRangeStart?.toISODate()).toBe("2024-03-07"); expect(slot.arrivalRangeEnd).toBeUndefined(); }); }); });