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>
151 lines
5.1 KiB
TypeScript
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;
|
|
}
|