datewise/app/engine/availability.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

313 lines
12 KiB
TypeScript

/**
* 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<ShopSettings, "mixedPrepPolicy">;
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<number, number>();
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<string>();
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 };
}