metatrondelivery/app/services/scheduling.server.ts
metatroncubeswdev 8c2b8a9e1c 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>
2026-08-23 17:53:39 -04:00

151 lines
5.1 KiB
TypeScript

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