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>
124 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|