metatrondelivery/app/services/scheduling.server.ts
metatroncubeswdev a2c78d703f
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
feat: close DS study coverage gaps (inventory exclusion, live checkout re-validation, per-day cap, payment fn, checkout ext)
Audited the implementation against DS_Delivery_Date_Time_App_Study.docx and
closed the actionable gaps (see IMPLEMENTATION_REVIEW_2026-09-04.md).

Core (code + unit tests, 156 green):
- Wire excludeLocationsWithoutStock into resolveAvailabilityRequest; widget
  now sends variantIds so inventory-based location exclusion actually runs.
- Live slot re-validation at checkout: new checkout-snapshot.server.ts writes
  a shop-metafield capacity snapshot; validation-slot's evaluateCheckout
  rejects a complete selection that has since filled / blacked out / closed /
  hit the daily cap / left the schedule. Refreshed on order webhooks and
  slot/blackout/location/enforcement edits.
- Scopable checkout enforcement: Shop.enforcementMode (all|tagged|off) +
  enforcementTag, new app.settings.tsx admin page, honoured via the snapshot.
- Per-day order cap: Location.dailyOrderCap threaded through getAvailability
  (dailyCap + consumedPerDate); admin field on the location screen.
- Product-rule slot blocking: ProductRule.blockedStartMins, unioned in
  resolveProductRuleConstraints, enforced in the engine and resolveHoldRequest;
  admin field on the product rules screen.
- Product-page placement: product-availability.liquid block + widget
  data-mode="preview" (read-only earliest-date line).
- Second locale: datetime-widget fr.json / fr.schema.json.
- Migration 20260904120000_review_gaps (apply with prisma migrate deploy).

New Functions (source + unit tests; need `shopify app deploy` to ship):
- extensions/payment-customization: cart.payment-methods.transform.run — hides
  cash-on-delivery / pay-in-store gateways on SHIPPING orders.
- extensions/checkout-datetime/src: restored from a gitignored dist-only state
  — Plus native picker + Thank you / Order status confirmation blocks, all
  calling the existing checkout.scheduling.* routes (one capacity pool).
  tsconfig ships checkJs:false pending reconciliation with live checkout types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 01:31:02 -04:00

204 lines
7.9 KiB
TypeScript

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;
/** SHIPPING-only "date range" parity item (PRODUCT_STRATEGY.md §2) — see AvailableSlot.arrivalRangeStart/End. */
transitMinDays?: number | null;
transitMaxDays?: number | null;
}
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;
/**
* SHIPPING-only "date range" parity item (PRODUCT_STRATEGY.md §2): a
* shipping slot's own start/end is a ship-out window, not something the
* shopper cares about — what they need is an estimated ARRIVAL range,
* computed from this ship date + the template's transit-day spread. Unset
* for PICKUP/LOCAL_DELIVERY (and for SHIPPING templates with no transit
* days configured), where start/end is already the meaningful window.
*/
arrivalRangeStart?: DateTime;
arrivalRangeEnd?: DateTime;
}
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<string, number>;
/**
* Per-day order cap (study §3.3 "a maximum number of orders … allowed in a
* given slot **or day**"): once a calendar date's own booking count reaches
* this, *every* slot on that date is hidden regardless of individual slot
* capacity. Null/undefined = no daily cap.
*/
dailyCap?: number | null;
/**
* Bookings already taken per calendar date, keyed by `IsoDate`. Only
* consulted when `dailyCap` is set. Omitted = nothing booked yet.
*/
consumedPerDate?: Map<IsoDate, number>;
/**
* Slot start-minutes the current cart's contents may not use (study §3.5
* "rules can block a specific date or time slot at checkout based on which
* products are in the cart"). Resolved from ProductRule.blockedStartMins by
* the caller. Empty/undefined = nothing blocked.
*/
blockedStartMins?: number[];
}
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<IsoDate, AvailableSlot[]> {
const { timezone, dateRange, slotTemplates, now } = input;
const overrides = input.overrides ?? [];
const blackoutDates = input.blackoutDates ?? [];
const consumed = input.consumed ?? new Map<string, number>();
const consumedPerDate = input.consumedPerDate ?? new Map<IsoDate, number>();
const dailyCap = input.dailyCap ?? null;
const blockedStartMins = new Set(input.blockedStartMins ?? []);
const blackoutSet = new Set(blackoutDates.map((b) => b.date));
const overridesByDate = new Map(overrides.map((o) => [o.date, o]));
const templatesByWeekday = new Map<number, SlotTemplateLike[]>();
for (const template of slotTemplates) {
const list = templatesByWeekday.get(template.weekday) ?? [];
list.push(template);
templatesByWeekday.set(template.weekday, list);
}
const result: Record<IsoDate, AvailableSlot[]> = {};
for (const date of enumerateDates(dateRange.startDate, dateRange.endDate)) {
if (blackoutSet.has(date)) continue;
// Per-day cap: the whole date is closed once its own bookings reach the cap.
if (dailyCap != null && dailyCap > 0 && (consumedPerDate.get(date) ?? 0) >= dailyCap) continue;
const override = overridesByDate.get(date);
if (override?.closed) continue;
const daySlotDefs: Array<
Pick<SlotTemplateLike, "startMin" | "endMin" | "capacity" | "cutoffMin" | "leadTimeMin" | "transitMinDays" | "transitMaxDays">
> = override
? [
{
startMin: override.startMin ?? 0,
endMin: override.endMin ?? 24 * 60,
capacity: override.capacity ?? 0,
cutoffMin: null,
leadTimeMin: 0,
// An override day has no template to inherit transit days from —
// the arrival range just isn't shown for that one exceptional day.
transitMinDays: null,
transitMaxDays: null,
},
]
: (templatesByWeekday.get(weekdayOf(date, timezone)) ?? []);
const daySlots: AvailableSlot[] = [];
const dayRemainingBudget =
dailyCap != null && dailyCap > 0 ? Math.max(0, dailyCap - (consumedPerDate.get(date) ?? 0)) : Infinity;
for (const def of daySlotDefs) {
// Cart-content slot block (study §3.5): this start time is off-limits for
// what's currently in the cart.
if (blockedStartMins.has(def.startMin)) continue;
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 slotRemaining = remainingCapacity({
capacity: def.capacity,
consumed: consumed.get(slotKey(date, def.startMin)) ?? 0,
});
// The slot can never sell more than the day has left, when a daily cap applies.
const remaining = Math.min(slotRemaining, dayRemainingBudget);
if (remaining <= 0) continue;
daySlots.push({
date,
startMin: def.startMin,
endMin: def.endMin,
start,
end,
capacity: def.capacity,
remainingCapacity: remaining,
arrivalRangeStart: def.transitMinDays != null ? start.plus({ days: def.transitMinDays }) : undefined,
arrivalRangeEnd: def.transitMaxDays != null ? start.plus({ days: def.transitMaxDays }) : undefined,
});
}
daySlots.sort((a, b) => a.startMin - b.startMin);
if (daySlots.length > 0) {
result[date] = daySlots;
}
}
return result;
}