/** * Availability: which dates and slots a specific cart can book at a specific * location with a specific method, right now. * * Rule precedence (each step only runs if the previous passed): * 1. Location / method eligibility (incl. product method & location rules) * 2. Product date restrictions (weekday/date blocks, windows, freshness) * 3. Holidays / closures / special openings * 4. Cutoffs (same-day, next-day, peak-day early cutoff) * 5. Preparation time (base + product rules + peak extra) vs slot start * 6. Daily and slot capacity (bookings + active holds), with waitlist on peak days */ import type { AvailabilityResult, Cart, DateResult, LocationConfig, Method, MethodConfig, PeakDay, ProductRule, Reason, ShopSettings, SlotResult, SlotTemplate, TimeRange, UsageSnapshot, } from "./types"; import { type CartConstraints, checkFreshness, evaluateRules } from "./rules"; import { addDays, fromMinutes, iso, localParts, toMinutes, weekdayOf, zonedToUtc } from "./time"; export const usageKey = (locationId: string, method: Method, date: string) => `${locationId}|${method}|${date}`; export const slotId = (start: string, end: string) => `${start}-${end}`; export interface AvailabilityInput { location: LocationConfig; method: Method; cart: Cart; rules: ProductRule[]; peakDays: PeakDay[]; usage: UsageSnapshot; settings: Pick; now: number; constraints?: CartConstraints; /** Limit computed days (e.g. calendar month paging). Defaults to the method horizon. */ maxDays?: number; } /** Hours for a date after applying closures and special openings. */ export function hoursFor( loc: LocationConfig, method: Method, date: string, ): { hours: TimeRange[]; exception?: Reason; special?: boolean } { const cfg = loc.methods[method]; const ex = loc.exceptions.find((e) => e.date === date && (!e.methods?.length || e.methods.includes(method))); if (ex?.closed) return { hours: [], exception: { code: "CLOSED_EXCEPTION", message: ex.note || "Closed on this date" } }; if (ex?.hours?.length) return { hours: ex.hours, special: true }; return { hours: cfg?.weeklyHours[weekdayOf(date)] ?? [] }; } export function peakFor(peaks: PeakDay[], locationId: string, method: Method, date: string): PeakDay | null { return ( peaks.find( (p) => p.dates.includes(date) && (!p.locationIds?.length || p.locationIds.includes(locationId)) && (!p.methods?.length || p.methods.includes(method)), ) ?? null ); } function slotTemplates(cfg: MethodConfig, date: string, hours: TimeRange[], usingException: boolean): SlotTemplate[] { if (!hours.length) return []; if (cfg.slotMode === "dateOnly") return [{ start: hours[0].start, end: hours[hours.length - 1].end }]; if (cfg.slotMode === "explicit" && !usingException) { const list = cfg.slots?.[weekdayOf(date)] ?? []; // Only keep explicit slots that fall within opening hours. return list.filter((s) => hours.some((h) => toMinutes(s.start) >= toMinutes(h.start) && toMinutes(s.end) <= toMinutes(h.end)), ); } const step = cfg.slotMinutes || 60; const out: SlotTemplate[] = []; for (const h of hours) { for (let t = toMinutes(h.start); t + step <= toMinutes(h.end); t += step) out.push({ start: fromMinutes(t), end: fromMinutes(t + step) }); } return out; } /** * Add preparation minutes counting only operating hours of this method at this location. */ export function addOperatingMinutes( startMs: number, minutes: number, loc: LocationConfig, method: Method, ): number { if (minutes <= 0) return startMs; let remaining = minutes * 60000; let date = localParts(startMs, loc.timezone).date; for (let i = 0; i < 400; i++, date = addDays(date, 1)) { for (const h of hoursFor(loc, method, date).hours) { const rs = zonedToUtc(date, h.start, loc.timezone); const re = zonedToUtc(date, h.end, loc.timezone); if (re <= startMs) continue; const from = Math.max(rs, startMs); const avail = re - from; if (remaining <= avail) return from + remaining; remaining -= avail; } } return Number.POSITIVE_INFINITY; } export function computeAvailability(input: AvailabilityInput): AvailabilityResult { const { location: loc, method, now, usage } = input; const tz = loc.timezone; const cfg = loc.methods[method]; const constraints = input.constraints ?? evaluateRules(input.cart, input.rules, input.settings.mixedPrepPolicy); const blockers: Reason[] = []; if (!loc.enabled) blockers.push({ code: "LOCATION_DISABLED", message: `${loc.name} isn't taking bookings right now` }); if (!cfg?.enabled) blockers.push({ code: "METHOD_DISABLED", message: `${loc.name} doesn't offer ${method}` }); if (!constraints.allowedMethods.has(method)) blockers.push(...(constraints.methodReasons.get(method) ?? [])); if (constraints.allowedLocations && !constraints.allowedLocations.has(loc.id)) blockers.push(...constraints.locationReasons); const basePrep = (cfg?.prepMinutes ?? 0) + constraints.extraPrepMinutes; const readyCache = new Map(); const readyAt = (prep: number) => { if (!readyCache.has(prep)) readyCache.set( prep, cfg?.prepMode === "operating" ? addOperatingMinutes(now, prep, loc, method) : now + prep * 60000, ); return readyCache.get(prep)!; }; const result: AvailabilityResult = { locationId: loc.id, method, timezone: tz, dateOnly: cfg?.slotMode === "dateOnly", prepMinutes: basePrep, earliestReadyUtc: iso(readyAt(basePrep)), blockers, dates: [], }; if (blockers.length || !cfg) return result; const nowLocal = localParts(now, tz); const days = Math.min(cfg.horizonDays, input.maxDays ?? cfg.horizonDays); for (let d = 0; d < days; d++) { const date = addDays(nowLocal.date, d); const weekday = weekdayOf(date); const peak = peakFor(input.peakDays, loc.id, method, date); const dr: DateResult = { date, weekday, available: false, remaining: null, peak: peak ? { id: peak.id, name: peak.name, message: peak.message } : null, surcharge: peak?.surcharge ?? 0, waitlist: false, reasons: [], slots: [], }; result.dates.push(dr); // 2. product date restrictions const wd = constraints.blockedWeekdays.get(weekday); if (wd) dr.reasons.push(wd); const bd = constraints.blockedDates.get(date); if (bd) dr.reasons.push(bd); for (const w of constraints.windows) if ((w.from && date < w.from) || (w.to && date > w.to)) dr.reasons.push(w.reason); const fresh = checkFreshness(constraints.freshness, date, loc.id); if (fresh) dr.reasons.push(fresh); if (dr.reasons.length) continue; // 3. closures const { hours, exception, special } = hoursFor(loc, method, date); if (exception) { dr.reasons.push(exception); continue; } if (!hours.length) { dr.reasons.push({ code: "CLOSED_WEEKLY", message: "Closed on this day" }); continue; } // 4. cutoffs if (d === 0 && cfg.sameDayCutoff && nowLocal.minutes >= toMinutes(cfg.sameDayCutoff)) { dr.reasons.push({ code: "PAST_CUTOFF", message: `Same-day orders close at ${cfg.sameDayCutoff}` }); continue; } if (d === 1 && cfg.nextDayCutoff && nowLocal.minutes >= toMinutes(cfg.nextDayCutoff)) { dr.reasons.push({ code: "PAST_CUTOFF", message: `Next-day orders close at ${cfg.nextDayCutoff}` }); continue; } if (peak?.cutoff) { const deadline = zonedToUtc(addDays(date, -peak.cutoff.daysBefore), peak.cutoff.time, tz); if (now >= deadline) { dr.reasons.push({ code: "PEAK_CUTOFF", message: `${peak.name} orders closed on ${addDays(date, -peak.cutoff.daysBefore)} at ${peak.cutoff.time}`, }); continue; } } // 5 + 6. slots const ready = readyAt(basePrep + (peak?.extraPrepMinutes ?? 0)); const key = usageKey(loc.id, method, date); const used = usage[key] ?? { day: 0, slots: {} }; const dayCap = peak?.dailyCapacity ?? cfg.dailyCapacity ?? null; const dayLeft = dayCap == null ? null : Math.max(0, dayCap - used.day); dr.remaining = dayLeft; const templates = slotTemplates(cfg, date, hours, !!special); if (!templates.length) { dr.reasons.push({ code: "CLOSED_WEEKLY", message: "No time slots on this day" }); continue; } for (const t of templates) { const startUtc = zonedToUtc(date, t.start, tz); const endUtc = zonedToUtc(date, t.end, tz); const id = slotId(t.start, t.end); const sr: SlotResult = { id, start: t.start, end: t.end, startUtc: iso(startUtc), endUtc: iso(endUtc), available: false, remaining: null, surcharge: t.surcharge ?? 0, waitlist: false, reasons: [], }; dr.slots.push(sr); if (result.dateOnly) { // Dispatch date: the order must be ready before the location closes. if (endUtc <= now) sr.reasons.push({ code: "SLOT_IN_PAST", message: "Closed for today" }); else if (ready > endUtc) sr.reasons.push({ code: "PREP_TIME", message: "Not enough preparation time" }); } else if (startUtc <= now) { sr.reasons.push({ code: "SLOT_IN_PAST", message: "This time has passed" }); } else if (ready > startUtc) { sr.reasons.push({ code: "PREP_TIME", message: "Not enough preparation time" }); } if (sr.reasons.length) continue; const slotCap = peak?.slotCapacity ?? t.capacity ?? cfg.slotCapacity ?? null; const slotUsed = used.slots[id] ?? 0; const slotLeft = slotCap == null ? null : Math.max(0, slotCap - slotUsed); sr.remaining = slotLeft == null ? dayLeft : dayLeft == null ? slotLeft : Math.min(slotLeft, dayLeft); if (dayLeft === 0) sr.reasons.push({ code: "DAY_FULL", message: "Fully booked for this day" }); else if (slotLeft === 0) sr.reasons.push({ code: "SLOT_FULL", message: "This time slot is full" }); if (sr.reasons.length) { sr.waitlist = !!peak?.waitlist; continue; } sr.available = true; } dr.available = dr.slots.some((s) => s.available); dr.waitlist = !dr.available && dr.slots.some((s) => s.waitlist); if (!dr.available) { const seen = new Set(); for (const s of dr.slots) for (const r of s.reasons) if (!seen.has(r.code)) { seen.add(r.code); dr.reasons.push(r); } } } return result; } export function firstAvailable(r: AvailabilityResult): { date: string; slot: SlotResult } | null { for (const d of r.dates) { const s = d.slots.find((x) => x.available); if (s) return { date: d.date, slot: s }; } return null; } /** * Re-validate a specific selection (used before hold, at order creation and on reschedule). */ export function validateSelection( input: AvailabilityInput, date: string, slot: string, ): { ok: true; slot: SlotResult; date: DateResult } | { ok: false; reasons: Reason[] } { const r = computeAvailability(input); if (r.blockers.length) return { ok: false, reasons: r.blockers }; const dr = r.dates.find((d) => d.date === date); if (!dr) return { ok: false, reasons: [{ code: "BEYOND_HORIZON", message: "This date can't be booked" }] }; const sr = dr.slots.find((s) => s.id === slot); if (!sr) return { ok: false, reasons: dr.reasons.length ? dr.reasons : [{ code: "CLOSED_WEEKLY", message: "Slot not offered" }] }; if (!sr.available) return { ok: false, reasons: sr.reasons.length ? sr.reasons : dr.reasons }; return { ok: true, slot: sr, date: dr }; }