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

124 lines
4.9 KiB
TypeScript

import { DateTime } from "luxon";
import { describe, expect, it } from "vitest";
import { enumerateDates, minutesUntil, slotDateTime, weekdayOf } from "../../app/lib/time";
// North American DST transitions used throughout (2nd Sunday of March /
// 1st Sunday of November — fixed historical facts, safe to hardcode):
// 2024-03-10: spring forward, 2:00 AM -> 3:00 AM (that hour doesn't exist)
// 2024-11-03: fall back, 2:00 AM -> 1:00 AM (1:00-2:00 AM happens twice)
const ZONE = "America/Toronto";
describe("slotDateTime", () => {
it("computes the correct UTC offset in winter (EST, UTC-5)", () => {
const dt = slotDateTime("2024-01-15", 9 * 60, ZONE);
expect(dt.hour).toBe(9);
expect(dt.toUTC().hour).toBe(14);
});
it("computes the correct UTC offset in summer (EDT, UTC-4)", () => {
const dt = slotDateTime("2024-07-15", 9 * 60, ZONE);
expect(dt.hour).toBe(9);
expect(dt.toUTC().hour).toBe(13);
});
it("keeps 9:00 AM meaning 9:00 AM on the spring-forward day itself", () => {
const dt = slotDateTime("2024-03-10", 9 * 60, ZONE);
expect(dt.hour).toBe(9);
expect(dt.toUTC().hour).toBe(13); // already EDT (UTC-4) by 9 AM
// Regression check: naively adding elapsed minutes to local midnight
// instead of setting wall-clock fields directly would land an hour
// late on this exact day, because the 2-3 AM hour never happened.
const naive = DateTime.fromISO("2024-03-10", { zone: ZONE }).startOf("day").plus({ minutes: 9 * 60 });
expect(naive.hour).toBe(10);
expect(naive.hour).not.toBe(dt.hour);
});
it("keeps 9:00 AM meaning 9:00 AM on the fall-back day itself", () => {
const dt = slotDateTime("2024-11-03", 9 * 60, ZONE);
expect(dt.hour).toBe(9);
expect(dt.toUTC().hour).toBe(14); // already back to EST (UTC-5) by 9 AM
});
it("throws for a wall-clock time that doesn't exist (spring-forward gap)", () => {
// 2:30 AM on 2024-03-10 was skipped entirely (clocks jumped 2:00->3:00).
expect(() => slotDateTime("2024-03-10", 2 * 60 + 30, ZONE)).toThrow(/does not exist/);
});
it("resolves an ambiguous wall-clock time on the fall-back day without throwing", () => {
// 1:30 AM on 2024-11-03 happened twice; either resolution is acceptable,
// it just must not throw and must report the wall-clock hour asked for.
const dt = slotDateTime("2024-11-03", 1 * 60 + 30, ZONE);
expect(dt.isValid).toBe(true);
expect(dt.hour).toBe(1);
expect(dt.minute).toBe(30);
});
it("throws for an invalid date string", () => {
expect(() => slotDateTime("not-a-date", 0, ZONE)).toThrow(RangeError);
});
it("throws for out-of-range minutes", () => {
expect(() => slotDateTime("2024-06-01", -1, ZONE)).toThrow(RangeError);
expect(() => slotDateTime("2024-06-01", 24 * 60 + 1, ZONE)).toThrow(RangeError);
});
});
describe("weekdayOf", () => {
it("matches known calendar weekdays (0 = Sunday .. 6 = Saturday)", () => {
expect(weekdayOf("2024-03-10", ZONE)).toBe(0); // Sunday
expect(weekdayOf("2024-01-01", ZONE)).toBe(1); // Monday
expect(weekdayOf("2024-01-06", ZONE)).toBe(6); // Saturday
});
it("is purely calendar-date based, independent of the timezone passed", () => {
expect(weekdayOf("2024-03-10", "America/Toronto")).toBe(weekdayOf("2024-03-10", "Pacific/Auckland"));
});
});
describe("enumerateDates", () => {
it("returns an inclusive, contiguous range across a DST transition", () => {
expect(enumerateDates("2024-03-08", "2024-03-11")).toEqual([
"2024-03-08",
"2024-03-09",
"2024-03-10",
"2024-03-11",
]);
});
it("returns a single date when start equals end", () => {
expect(enumerateDates("2024-06-01", "2024-06-01")).toEqual(["2024-06-01"]);
});
it("spans a month boundary correctly", () => {
expect(enumerateDates("2024-01-30", "2024-02-02")).toEqual([
"2024-01-30",
"2024-01-31",
"2024-02-01",
"2024-02-02",
]);
});
});
describe("minutesUntil", () => {
it("returns real elapsed minutes across a fall-back (an extra hour occurs)", () => {
const now = slotDateTime("2024-11-02", 23 * 60, ZONE); // 11 PM, day before
const target = slotDateTime("2024-11-03", 9 * 60, ZONE); // 9 AM, after fall-back
// Wall-clock difference looks like 10h, but 11 real hours passed.
expect(minutesUntil(now, target)).toBe(11 * 60);
});
it("returns real elapsed minutes across a spring-forward (an hour is lost)", () => {
const now = slotDateTime("2024-03-09", 23 * 60, ZONE); // 11 PM, day before
const target = slotDateTime("2024-03-10", 9 * 60, ZONE); // 9 AM, after spring-forward
// Wall-clock difference looks like 10h, but only 9 real hours passed.
expect(minutesUntil(now, target)).toBe(9 * 60);
});
it("returns 0 for the same instant and negative for a past target", () => {
const t = slotDateTime("2024-06-01", 12 * 60, ZONE);
expect(minutesUntil(t, t)).toBe(0);
expect(minutesUntil(t, slotDateTime("2024-06-01", 11 * 60, ZONE))).toBe(-60);
});
});