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>
This commit is contained in:
parent
a35a4f89be
commit
8c2b8a9e1c
91
app/lib/time.ts
Normal file
91
app/lib/time.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
// All date math for scheduling lives here. Nothing in this file touches the
|
||||
// DB, the clock, or Shopify — every function takes its inputs explicitly so
|
||||
// it stays exhaustively unit-testable (CLAUDE.md: "pure functions... inject
|
||||
// data, no I/O in the math").
|
||||
|
||||
const MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
export type IsoDate = string; // "YYYY-MM-DD", a calendar date with no timezone of its own
|
||||
|
||||
/**
|
||||
* The exact instant a "minutes from midnight" wall-clock offset (as stored
|
||||
* on SlotTemplate/SlotOverride) refers to, on a given calendar date, in a
|
||||
* given IANA timezone.
|
||||
*
|
||||
* Deliberately built via `DateTime.fromObject({ hour, minute }, { zone })`
|
||||
* rather than `midnight.plus({ minutes })`: `.plus()` on time units adds
|
||||
* *elapsed* real time, so on a spring-forward day it would land an hour
|
||||
* later than the intended wall-clock time (e.g. "9:00 AM" computed as
|
||||
* "540 elapsed minutes past local midnight" becomes 10:00 AM on the day the
|
||||
* clocks jump). Building from the wall-clock fields directly keeps "9:00 AM"
|
||||
* meaning 9:00 AM regardless of what the clocks did earlier that day.
|
||||
*/
|
||||
export function slotDateTime(date: IsoDate, minutesFromMidnight: number, timezone: string): DateTime {
|
||||
if (minutesFromMidnight < 0 || minutesFromMidnight > MINUTES_PER_DAY) {
|
||||
throw new RangeError(`minutesFromMidnight out of range: ${minutesFromMidnight}`);
|
||||
}
|
||||
|
||||
const base = DateTime.fromISO(date, { zone: timezone });
|
||||
if (!base.isValid) {
|
||||
throw new RangeError(`Invalid date "${date}" for zone "${timezone}": ${base.invalidReason}`);
|
||||
}
|
||||
|
||||
const hour = Math.floor(minutesFromMidnight / 60);
|
||||
const minute = minutesFromMidnight % 60;
|
||||
|
||||
const result = base.set({ hour, minute, second: 0, millisecond: 0 });
|
||||
|
||||
// A wall-clock time that doesn't exist (spring-forward gap, e.g. 2:30 AM
|
||||
// on the day clocks jump from 2:00 to 3:00) stays `isValid` in Luxon —
|
||||
// `.set()` silently rolls it forward past the gap (2:30 -> 3:30) instead
|
||||
// of rejecting it. Detect that by checking the fields actually landed
|
||||
// where asked; surface it rather than silently returning a shifted time
|
||||
// a merchant never configured.
|
||||
if (result.hour !== hour || result.minute !== minute) {
|
||||
throw new RangeError(
|
||||
`${date} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} does not exist in ${timezone} (DST spring-forward gap)`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 0 (Sunday) .. 6 (Saturday), evaluated as a calendar date — independent of what time or timezone the server itself is running in. */
|
||||
export function weekdayOf(date: IsoDate, timezone: string): number {
|
||||
const dt = DateTime.fromISO(date, { zone: timezone });
|
||||
// Luxon's weekday is 1 (Monday) .. 7 (Sunday); our schema uses 0 (Sunday) .. 6 (Saturday).
|
||||
return dt.weekday % 7;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive list of calendar dates from start to end (both "YYYY-MM-DD").
|
||||
* Pinned to UTC — these are plain calendar dates, not instants, so stepping
|
||||
* through them must not depend on the server process's own local timezone
|
||||
* or DST (which would otherwise risk skipping/repeating a date on the
|
||||
* server's own transition day).
|
||||
*/
|
||||
export function enumerateDates(startDate: IsoDate, endDate: IsoDate): IsoDate[] {
|
||||
const start = DateTime.fromISO(startDate, { zone: "utc" });
|
||||
const end = DateTime.fromISO(endDate, { zone: "utc" });
|
||||
if (!start.isValid || !end.isValid) {
|
||||
throw new RangeError(`Invalid date range: ${startDate}..${endDate}`);
|
||||
}
|
||||
|
||||
const dates: IsoDate[] = [];
|
||||
for (let d = start; d <= end; d = d.plus({ days: 1 })) {
|
||||
dates.push(d.toISODate()!);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes between `now` and a slot's start, in real elapsed time (correctly
|
||||
* reflecting a DST change if `now` and the slot fall on opposite sides of
|
||||
* one — e.g. a slot booked the evening before a fall-back has genuinely 60
|
||||
* more minutes of lead time than the wall-clock difference would suggest).
|
||||
*/
|
||||
export function minutesUntil(now: DateTime, target: DateTime): number {
|
||||
return target.diff(now, "minutes").minutes;
|
||||
}
|
||||
18
app/services/capacity.server.ts
Normal file
18
app/services/capacity.server.ts
Normal file
@ -0,0 +1,18 @@
|
||||
// Capacity math is intentionally trivial today: `consumed` is whatever the
|
||||
// caller already looked up (Bookings + active Holds, once those models
|
||||
// exist from Phase 4 on). Keeping it as an explicit input rather than a
|
||||
// query inside this function is what lets scheduling.server.ts's
|
||||
// getAvailability stay a pure, DB-free function while this still slots in
|
||||
// cleanly once real consumption exists.
|
||||
export interface CapacityInput {
|
||||
capacity: number;
|
||||
consumed: number;
|
||||
}
|
||||
|
||||
export function remainingCapacity({ capacity, consumed }: CapacityInput): number {
|
||||
return Math.max(0, capacity - consumed);
|
||||
}
|
||||
|
||||
export function hasCapacity(input: CapacityInput): boolean {
|
||||
return remainingCapacity(input) > 0;
|
||||
}
|
||||
150
app/services/scheduling.server.ts
Normal file
150
app/services/scheduling.server.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import type { DateTime } from "luxon";
|
||||
import { enumerateDates, minutesUntil, slotDateTime, weekdayOf, type IsoDate } from "../lib/time";
|
||||
import { remainingCapacity } from "./capacity.server";
|
||||
|
||||
export type Method = "SHIPPING" | "LOCAL_DELIVERY" | "PICKUP";
|
||||
|
||||
export interface SlotTemplateLike {
|
||||
weekday: number; // 0-6, matches lib/time.ts#weekdayOf
|
||||
startMin: number;
|
||||
endMin: number;
|
||||
capacity: number;
|
||||
cutoffMin: number | null;
|
||||
leadTimeMin: number;
|
||||
}
|
||||
|
||||
export interface SlotOverrideLike {
|
||||
date: IsoDate;
|
||||
closed: boolean;
|
||||
startMin: number | null;
|
||||
endMin: number | null;
|
||||
capacity: number | null;
|
||||
}
|
||||
|
||||
export interface BlackoutDateLike {
|
||||
date: IsoDate;
|
||||
}
|
||||
|
||||
export interface AvailableSlot {
|
||||
date: IsoDate;
|
||||
startMin: number;
|
||||
endMin: number;
|
||||
start: DateTime;
|
||||
end: DateTime;
|
||||
capacity: number;
|
||||
remainingCapacity: number;
|
||||
}
|
||||
|
||||
export interface GetAvailabilityInput {
|
||||
timezone: string;
|
||||
dateRange: { startDate: IsoDate; endDate: IsoDate };
|
||||
/**
|
||||
* One method, one location's worth of candidates. `SlotTemplate`,
|
||||
* `SlotOverride`, and `BlackoutDate` are all scoped by (location, method)
|
||||
* in the schema, plus method-agnostic rows (BlackoutDate.method === null)
|
||||
* — the caller is responsible for resolving that down to the flat lists
|
||||
* below before calling this function, so the availability math itself
|
||||
* never has to branch on method.
|
||||
*/
|
||||
slotTemplates: SlotTemplateLike[];
|
||||
overrides?: SlotOverrideLike[];
|
||||
blackoutDates?: BlackoutDateLike[];
|
||||
/** Injected "now" so this stays a pure function — never `DateTime.now()` internally. */
|
||||
now: DateTime;
|
||||
/**
|
||||
* Capacity already consumed per slot, keyed by `${date}|${startMin}`.
|
||||
* Omitted entirely until Phase 4 wires up real Booking/Hold counts —
|
||||
* every slot is treated as unconsumed until then.
|
||||
*/
|
||||
consumed?: Map<string, number>;
|
||||
}
|
||||
|
||||
function slotKey(date: IsoDate, startMin: number): string {
|
||||
return `${date}|${startMin}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The availability engine: given a location's weekly slot templates,
|
||||
* date-specific overrides, and blackout dates, returns the bookable slots
|
||||
* per calendar date in the requested range — already filtered down to what
|
||||
* a shopper should be shown (past-cutoff, under-lead-time, blacked-out, and
|
||||
* fully-consumed slots are all excluded here, not just flagged, per
|
||||
* PRODUCT_STRATEGY.md §2 "unavailable dates/slots hidden, not just
|
||||
* rejected"). No DB or Shopify calls — every input is injected so this is
|
||||
* exhaustively unit-testable (CLAUDE.md non-negotiable).
|
||||
*/
|
||||
export function getAvailability(input: GetAvailabilityInput): Record<IsoDate, AvailableSlot[]> {
|
||||
const { timezone, dateRange, slotTemplates, now } = input;
|
||||
const overrides = input.overrides ?? [];
|
||||
const blackoutDates = input.blackoutDates ?? [];
|
||||
const consumed = input.consumed ?? new Map<string, number>();
|
||||
|
||||
const blackoutSet = new Set(blackoutDates.map((b) => b.date));
|
||||
const overridesByDate = new Map(overrides.map((o) => [o.date, o]));
|
||||
const templatesByWeekday = new Map<number, SlotTemplateLike[]>();
|
||||
for (const template of slotTemplates) {
|
||||
const list = templatesByWeekday.get(template.weekday) ?? [];
|
||||
list.push(template);
|
||||
templatesByWeekday.set(template.weekday, list);
|
||||
}
|
||||
|
||||
const result: Record<IsoDate, AvailableSlot[]> = {};
|
||||
|
||||
for (const date of enumerateDates(dateRange.startDate, dateRange.endDate)) {
|
||||
if (blackoutSet.has(date)) continue;
|
||||
|
||||
const override = overridesByDate.get(date);
|
||||
if (override?.closed) continue;
|
||||
|
||||
const daySlotDefs: Array<Pick<SlotTemplateLike, "startMin" | "endMin" | "capacity" | "cutoffMin" | "leadTimeMin">> =
|
||||
override
|
||||
? [
|
||||
{
|
||||
startMin: override.startMin ?? 0,
|
||||
endMin: override.endMin ?? 24 * 60,
|
||||
capacity: override.capacity ?? 0,
|
||||
cutoffMin: null,
|
||||
leadTimeMin: 0,
|
||||
},
|
||||
]
|
||||
: (templatesByWeekday.get(weekdayOf(date, timezone)) ?? []);
|
||||
|
||||
const daySlots: AvailableSlot[] = [];
|
||||
|
||||
for (const def of daySlotDefs) {
|
||||
const start = slotDateTime(date, def.startMin, timezone);
|
||||
const end = slotDateTime(date, def.endMin, timezone);
|
||||
|
||||
// Both cutoffMin (a fixed "orders close N minutes before this slot")
|
||||
// and leadTimeMin (a floor on how soon after ordering the slot can be)
|
||||
// ultimately gate the same thing — the minimum real-time gap required
|
||||
// between `now` and slot start — so the effective threshold is
|
||||
// whichever is larger.
|
||||
const minimumLeadMinutes = Math.max(def.cutoffMin ?? 0, def.leadTimeMin);
|
||||
if (minutesUntil(now, start) < minimumLeadMinutes) continue;
|
||||
|
||||
const remaining = remainingCapacity({
|
||||
capacity: def.capacity,
|
||||
consumed: consumed.get(slotKey(date, def.startMin)) ?? 0,
|
||||
});
|
||||
if (remaining <= 0) continue;
|
||||
|
||||
daySlots.push({
|
||||
date,
|
||||
startMin: def.startMin,
|
||||
endMin: def.endMin,
|
||||
start,
|
||||
end,
|
||||
capacity: def.capacity,
|
||||
remainingCapacity: remaining,
|
||||
});
|
||||
}
|
||||
|
||||
daySlots.sort((a, b) => a.startMin - b.startMin);
|
||||
if (daySlots.length > 0) {
|
||||
result[date] = daySlots;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
30
tests/unit/capacity.test.ts
Normal file
30
tests/unit/capacity.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasCapacity, remainingCapacity } from "../../app/services/capacity.server";
|
||||
|
||||
describe("remainingCapacity", () => {
|
||||
it("subtracts consumed from capacity", () => {
|
||||
expect(remainingCapacity({ capacity: 10, consumed: 3 })).toBe(7);
|
||||
});
|
||||
|
||||
it("floors at 0 rather than going negative (overbooking never surfaces as negative capacity)", () => {
|
||||
expect(remainingCapacity({ capacity: 10, consumed: 15 })).toBe(0);
|
||||
});
|
||||
|
||||
it("treats zero consumed as full capacity", () => {
|
||||
expect(remainingCapacity({ capacity: 10, consumed: 0 })).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasCapacity", () => {
|
||||
it("is true when remaining capacity is positive", () => {
|
||||
expect(hasCapacity({ capacity: 10, consumed: 9 })).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when exactly full", () => {
|
||||
expect(hasCapacity({ capacity: 10, consumed: 10 })).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when over capacity", () => {
|
||||
expect(hasCapacity({ capacity: 10, consumed: 11 })).toBe(false);
|
||||
});
|
||||
});
|
||||
162
tests/unit/scheduling.test.ts
Normal file
162
tests/unit/scheduling.test.ts
Normal file
@ -0,0 +1,162 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
123
tests/unit/time.test.ts
Normal file
123
tests/unit/time.test.ts
Normal file
@ -0,0 +1,123 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user