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:
metatroncubeswdev 2026-08-23 17:53:39 -04:00
parent a35a4f89be
commit 8c2b8a9e1c
6 changed files with 574 additions and 0 deletions

91
app/lib/time.ts Normal file
View 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;
}

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

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

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

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