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); }); });