Closes remaining DS-parity gaps from the feature audit: - ProductRule model (product/collection/vendor/type/tag scoping) with real server-side enforcement in hold-request.server.ts, plus shaped availability in availability-request.server.ts. Covers per-product prep time, cart-content-based slot blocking, and product-restricted locations in one mechanism. New /app/rules admin page (Growth+). - Driving-distance delivery zones via Google's Distance Matrix API, cached like existing geocoding results. - SHIPPING-only estimated arrival range (transitMinDays/transitMaxDays on SlotTemplate) — widget shows "Arrives Thu-Sat" instead of a meaningless ship-out time slot; carried through to the order metafield write-back. Storefront widget and POS extension now send cart contents (vendor/ type from cart.js, product ids for Admin-API-resolved collection/tag rules) to both availability and hold endpoints. checkout-datetime remains excluded from this deploy pending Shopify's Network Access approval (unrelated to this work) — re-add from ../checkout-datetime-disabled and redeploy once granted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
171 lines
6.2 KiB
TypeScript
171 lines
6.2 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>;
|
|
}
|
|
|
|
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 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;
|
|
|
|
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[] = [];
|
|
|
|
for (const def of daySlotDefs) {
|
|
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 remaining = remainingCapacity({
|
|
capacity: def.capacity,
|
|
consumed: consumed.get(slotKey(date, def.startMin)) ?? 0,
|
|
});
|
|
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;
|
|
}
|