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