313 lines
12 KiB
TypeScript
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 };
|
|
}
|