diff --git a/app/lib/time.ts b/app/lib/time.ts new file mode 100644 index 0000000..9a6a6ac --- /dev/null +++ b/app/lib/time.ts @@ -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; +} diff --git a/app/services/capacity.server.ts b/app/services/capacity.server.ts new file mode 100644 index 0000000..332623a --- /dev/null +++ b/app/services/capacity.server.ts @@ -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; +} diff --git a/app/services/scheduling.server.ts b/app/services/scheduling.server.ts new file mode 100644 index 0000000..df4f174 --- /dev/null +++ b/app/services/scheduling.server.ts @@ -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; +} + +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 { + const { timezone, dateRange, slotTemplates, now } = input; + const overrides = input.overrides ?? []; + const blackoutDates = input.blackoutDates ?? []; + const consumed = input.consumed ?? new Map(); + + const blackoutSet = new Set(blackoutDates.map((b) => b.date)); + const overridesByDate = new Map(overrides.map((o) => [o.date, o])); + const templatesByWeekday = new Map(); + for (const template of slotTemplates) { + const list = templatesByWeekday.get(template.weekday) ?? []; + list.push(template); + templatesByWeekday.set(template.weekday, list); + } + + const result: Record = {}; + + 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> = + 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; +} diff --git a/tests/unit/capacity.test.ts b/tests/unit/capacity.test.ts new file mode 100644 index 0000000..cf3d505 --- /dev/null +++ b/tests/unit/capacity.test.ts @@ -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); + }); +}); diff --git a/tests/unit/scheduling.test.ts b/tests/unit/scheduling.test.ts new file mode 100644 index 0000000..7017cf3 --- /dev/null +++ b/tests/unit/scheduling.test.ts @@ -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]); + }); +}); diff --git a/tests/unit/time.test.ts b/tests/unit/time.test.ts new file mode 100644 index 0000000..c149764 --- /dev/null +++ b/tests/unit/time.test.ts @@ -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); + }); +});