Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
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>
204 lines
7.9 KiB
TypeScript
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;
|
|
}
|