datewise/app/engine/capacity.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

256 lines
9.1 KiB
TypeScript

/**
* Capacity, holds and bookings.
*
* `MemoryReservationStore` is the reference implementation of the reservation
* semantics. The production store (app/services/reservations.server.ts) implements
* the same contract on Postgres with row-level locking; both are covered by the
* same behavioural tests.
*
* Capacity unit (v1): orders, not items.
*/
import type { Booking, LocationConfig, Method, PeakDay, UsageSnapshot } from "./types";
import { peakFor, usageKey } from "./availability";
export interface Caps {
dayCap: number | null;
slotCap: number | null;
}
export function capsFor(
loc: LocationConfig,
method: Method,
date: string,
slot: string,
peaks: PeakDay[],
): Caps {
const cfg = loc.methods[method];
const peak = peakFor(peaks, loc.id, method, date);
let tplCap: number | null | undefined;
if (cfg?.slotMode === "explicit") {
const [start, end] = slot.split("-");
for (const list of Object.values(cfg.slots ?? {}))
for (const s of list ?? []) if (s.start === start && s.end === end && s.capacity != null) tplCap = s.capacity;
}
return {
dayCap: peak?.dailyCapacity ?? cfg?.dailyCapacity ?? null,
slotCap: peak?.slotCapacity ?? tplCap ?? cfg?.slotCapacity ?? null,
};
}
export interface Selection {
locationId: string;
method: Method;
date: string;
slot: string; // "HH:MM-HH:MM"
}
export interface Hold extends Selection {
token: string;
expiresAt: number;
}
export type HoldResult =
| { ok: true; hold: Hold }
| { ok: false; code: "DAY_FULL" | "SLOT_FULL"; message: string };
export type ConfirmResult =
| { ok: true; booking: Booking; duplicate: boolean; reReserved?: boolean }
| { ok: false; booking: Booking; code: "NEEDS_REVIEW"; message: string };
export interface WaitlistEntry extends Selection {
id: string;
contact: { email?: string; phone?: string; name?: string };
createdAt: number;
notifiedAt?: number;
}
export class MemoryReservationStore {
holds = new Map<string, Hold>();
bookings = new Map<string, Booking>();
waitlist: WaitlistEntry[] = [];
audit: { at: number; actor: string; bookingId: string; action: string; before?: unknown; after?: unknown }[] = [];
private seq = 0;
private counts(sel: Selection, now: number, excludeToken?: string, excludeBooking?: string) {
let day = 0;
let slot = 0;
const same = (s: Selection) => s.locationId === sel.locationId && s.method === sel.method && s.date === sel.date;
for (const b of this.bookings.values()) {
// Only confirmed bookings consume capacity; flagged ones wait for a merchant decision.
if (b.id === excludeBooking || b.status !== "confirmed") continue;
const s = { locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` };
if (same(s)) {
day++;
if (s.slot === sel.slot) slot++;
}
}
for (const h of this.holds.values()) {
if (h.token === excludeToken || h.expiresAt <= now) continue;
if (same(h)) {
day++;
if (h.slot === sel.slot) slot++;
}
}
return { day, slot };
}
usage(now: number): UsageSnapshot {
const out: UsageSnapshot = {};
const add = (s: Selection) => {
const k = usageKey(s.locationId, s.method, s.date);
out[k] ??= { day: 0, slots: {} };
out[k].day++;
out[k].slots[s.slot] = (out[k].slots[s.slot] ?? 0) + 1;
};
for (const b of this.bookings.values())
if (b.status === "confirmed")
add({ locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` });
for (const h of this.holds.values()) if (h.expiresAt > now) add(h);
return out;
}
private fits(sel: Selection, caps: Caps, now: number, excludeToken?: string, excludeBooking?: string) {
const c = this.counts(sel, now, excludeToken, excludeBooking);
if (caps.dayCap != null && c.day >= caps.dayCap) return "DAY_FULL" as const;
if (caps.slotCap != null && c.slot >= caps.slotCap) return "SLOT_FULL" as const;
return null;
}
/** Atomic check-and-reserve. Re-holding with the same token moves the hold. */
hold(token: string, sel: Selection, caps: Caps, now: number, holdMinutes: number): HoldResult {
const full = this.fits(sel, caps, now, token);
if (full) return { ok: false, code: full, message: full === "DAY_FULL" ? "Fully booked for this day" : "This time slot was just taken" };
const hold: Hold = { ...sel, token, expiresAt: now + holdMinutes * 60000 };
this.holds.set(token, hold);
return { ok: true, hold };
}
release(token: string) {
this.holds.delete(token);
}
sweep(now: number): number {
let n = 0;
for (const [t, h] of this.holds) if (h.expiresAt <= now) (this.holds.delete(t), n++);
return n;
}
/**
* Confirm a hold against an order. Idempotent per orderId (duplicate webhooks are safe).
* Expired hold: re-reserve if capacity still allows, otherwise flag for review —
* never silently overbook.
*/
confirm(
orderId: string,
token: string | null,
fallbackSel: Selection | null,
caps: Caps,
now: number,
details: Partial<Booking> & { timezone: string },
): ConfirmResult {
const existing = [...this.bookings.values()].find((b) => b.orderId === orderId);
if (existing) return { ok: true, booking: existing, duplicate: true };
const hold = token ? this.holds.get(token) : undefined;
const sel = hold ?? fallbackSel;
const id = `bk_${++this.seq}`;
const base = (status: Booking["status"]): Booking => ({
...details,
id,
orderId,
status,
method: sel!.method,
locationId: sel!.locationId,
date: sel!.date,
slotStart: sel!.slot.split("-")[0],
slotEnd: sel!.slot.split("-")[1],
rescheduleCount: 0,
});
if (!sel) {
const b: Booking = { ...details, id, orderId, status: "needs_review", method: "pickup", locationId: "", date: "" };
this.bookings.set(id, b);
return { ok: false, booking: b, code: "NEEDS_REVIEW", message: "Order has no scheduling data" };
}
const live = hold && hold.expiresAt > now;
if (!live) {
const full = this.fits(sel, caps, now, token ?? undefined);
if (full) {
const b = base("needs_review");
this.bookings.set(id, b);
if (token) this.holds.delete(token);
this.log(now, "system", id, "flagged", undefined, { reason: full });
return { ok: false, booking: b, code: "NEEDS_REVIEW", message: "Hold expired and the slot filled up" };
}
}
const b = base("confirmed");
this.bookings.set(id, b);
if (token) this.holds.delete(token);
this.log(now, "system", id, "confirmed");
return { ok: true, booking: b, duplicate: false, reReserved: !live };
}
/** Atomic move: validates the new slot, then releases old + takes new together. */
reschedule(
bookingId: string,
sel: Selection,
caps: Caps,
now: number,
actor: string,
): { ok: true; booking: Booking; freed: Selection } | { ok: false; message: string } {
const b = this.bookings.get(bookingId);
if (!b || b.status !== "confirmed") return { ok: false, message: "Booking can't be rescheduled" };
const full = this.fits(sel, caps, now, undefined, bookingId);
if (full) return { ok: false, message: full === "DAY_FULL" ? "That day is fully booked" : "That slot is full" };
const before = { ...b };
const freed: Selection = { locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` };
const [s, e] = sel.slot.split("-");
Object.assign(b, {
locationId: sel.locationId,
method: sel.method,
date: sel.date,
slotStart: s,
slotEnd: e,
rescheduleCount: (b.rescheduleCount ?? 0) + 1,
});
this.log(now, actor, bookingId, "rescheduled", pick(before), pick(b));
return { ok: true, booking: b, freed };
}
cancel(orderId: string, now: number, releaseCapacity = true): Booking | null {
const b = [...this.bookings.values()].find((x) => x.orderId === orderId);
if (!b || b.status === "cancelled") return b ?? null;
const before = { ...b };
b.status = releaseCapacity ? "cancelled" : b.status;
this.log(now, "system", b.id, "cancelled", pick(before), pick(b));
return b;
}
joinWaitlist(sel: Selection, contact: WaitlistEntry["contact"], now: number): WaitlistEntry {
const e: WaitlistEntry = { ...sel, id: `wl_${++this.seq}`, contact, createdAt: now };
this.waitlist.push(e);
return e;
}
/** Who to notify when capacity frees up for a selection (FIFO, one at a time). */
nextWaitlisted(sel: Selection): WaitlistEntry | null {
return (
this.waitlist.find(
(w) =>
!w.notifiedAt &&
w.locationId === sel.locationId &&
w.method === sel.method &&
w.date === sel.date &&
(w.slot === sel.slot || w.slot === "*"),
) ?? null
);
}
private log(at: number, actor: string, bookingId: string, action: string, before?: unknown, after?: unknown) {
this.audit.push({ at, actor, bookingId, action, before, after });
}
}
function pick(b: Booking) {
return { locationId: b.locationId, method: b.method, date: b.date, slotStart: b.slotStart, slotEnd: b.slotEnd, status: b.status };
}