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