Compare commits

..

No commits in common. "4eebcc5c78e4c900cbb67887b62d6c67882a9650" and "a35a4f89be5b6f43870e1d8e6a63f7273fcb8115" have entirely different histories.

64 changed files with 385 additions and 15165 deletions

View File

@ -2,7 +2,7 @@
# Never commit the real .env — this file is the template only.
SHOPIFY_API_KEY=
SHOPIFY_API_SECRET=
SCOPES=read_products,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales
SCOPES=read_products,read_customers,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales
SHOPIFY_APP_URL=https://replace-with-your-tunnel-url.example.com
SHOP_CUSTOM_DOMAIN=

View File

@ -4,5 +4,3 @@ public/build
shopify-app-remix
*/*.yml
.shopify
extensions/*/assets/*.js
extensions/*/dist

View File

@ -4,39 +4,13 @@ on: [push, pull_request]
jobs:
test:
name: Lint, Unit & Integration Tests
name: Lint & Unit Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: delivery_datetime_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://app:app@localhost:5432/delivery_datetime_test
REDIS_URL: redis://127.0.0.1:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
@ -48,13 +22,3 @@ jobs:
run: npm run typecheck
- name: Unit tests
run: npm test -- --run
- name: Function tests (real WASM build + function-runner against fixtures)
run: npm run test:functions
- name: Apply migrations
run: npx prisma migrate deploy
- name: Integration tests (Redis-backed slot-hold concurrency, Booking flow)
run: npm run test:integration
- name: Build admin app
run: npm run build
- name: Build storefront widget
run: npm run build:widget

3
.gitignore vendored
View File

@ -26,6 +26,3 @@ dump.rdb
# Ignore shopify files created during app dev
.shopify/*
.shopify.lock
# Local debug output (can contain bearer tokens from --verbose CLI runs)
dev-log.txt

View File

@ -34,49 +34,9 @@ runs the BullMQ worker (jobs/worker.ts) once Phase 4 makes it do anything.
| `npx prisma migrate dev` | DB migrations |
| `npm run deploy` | `shopify app deploy` — deploy extensions/functions |
| `npm run worker` | BullMQ worker (hold-expiry, notifications) |
| `npm run test:integration` | Redis/Postgres-backed tests (slot-hold concurrency, booking flow) — needs `docker compose up -d` |
| `npm run test:functions` | Real WASM build + `function-runner` tests for both Shopify Functions, against fixtures |
| `npm run typegen:functions` | Regenerate `extensions/*/generated/api.ts` from each Function's `schema.graphql` + `.graphql` query (also runs automatically before `npm run typecheck`) |
## Before public launch
The 3 mandatory GDPR compliance webhooks (`customers/data_request`,
`customers/redact`, `shop/redact`) are commented out in `shopify.app.toml`
— Shopify refuses to push them until the org requests and is granted
**Protected customer data access** in the Partner Dashboard (Apps → this
app → API access → Protected customer data), which is a manual
questionnaire/approval step. The handlers already exist and are fully
wired (`app/routes/webhooks.customers.*.tsx`, `webhooks.shop.redact.tsx`) —
once that access is granted, uncomment the three `[[webhooks.subscriptions]]`
blocks near the bottom of the webhooks section. **Required before any
public launch or Built-for-Shopify submission** — don't ship without it.
## Status
Phase 0 (scaffold & CI) through Phase 4 (enforcement Functions +
slot-holds) are complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased
build order and acceptance criteria — next up is Phase 5 (multi-location,
zones, rates, auto-assignment).
The storefront widget's TypeScript source lives in `widget-src/datetime-widget/`,
**not** inside `extensions/datetime-widget/` — a Theme App Extension's
directory may only contain `assets`, `blocks`, `snippets`, and `locales`
(the CLI hard-rejects anything else, e.g. a `src/` folder, with "Only
assets, blocks, snippets, locales directories are allowed"). Editing the
widget? Run `npm run build:widget` to bundle it into
`extensions/datetime-widget/assets/datetime-widget.js` — it also runs
automatically before `npm run dev` / `npm run deploy`.
**Functions are JavaScript, not Rust** (`extensions/validation-slot/`,
`extensions/delivery-customization/`) — no Rust toolchain was available in
the environment that built Phase 4, and `IMPLEMENTATION_PLAN.md` §1
explicitly allows JS as a fallback. Both were generated with
`shopify app generate extension` (once a real Partner login was available)
and their business logic (`src/evaluate.js` in each) is verified two ways:
plain Vitest unit tests at the repo root (`npm test`) and real
`function-runner` fixture tests that compile actual WASM
(`npm run test:functions`, also in CI). `extensions/*/generated/` and
`extensions/*/dist/` aren't committed (matching the CLI's own
`.gitignore` for these extensions) — `npm run typegen:functions`
regenerates the types from the committed `schema.graphql`, and building
runs automatically as part of `npm run dev` / `test:functions`.
Phase 0 (scaffold & CI) and Phase 1 (core data model & admin CRUD) complete.
See §6 of `IMPLEMENTATION_PLAN.md` for the phased build order and acceptance
criteria — next up is Phase 2 (the scheduling engine).

View File

@ -1,91 +0,0 @@
import { DateTime } from "luxon";
// All date math for scheduling lives here. Nothing in this file touches the
// DB, the clock, or Shopify — every function takes its inputs explicitly so
// it stays exhaustively unit-testable (CLAUDE.md: "pure functions... inject
// data, no I/O in the math").
const MINUTES_PER_DAY = 24 * 60;
export type IsoDate = string; // "YYYY-MM-DD", a calendar date with no timezone of its own
/**
* The exact instant a "minutes from midnight" wall-clock offset (as stored
* on SlotTemplate/SlotOverride) refers to, on a given calendar date, in a
* given IANA timezone.
*
* Deliberately built via `DateTime.fromObject({ hour, minute }, { zone })`
* rather than `midnight.plus({ minutes })`: `.plus()` on time units adds
* *elapsed* real time, so on a spring-forward day it would land an hour
* later than the intended wall-clock time (e.g. "9:00 AM" computed as
* "540 elapsed minutes past local midnight" becomes 10:00 AM on the day the
* clocks jump). Building from the wall-clock fields directly keeps "9:00 AM"
* meaning 9:00 AM regardless of what the clocks did earlier that day.
*/
export function slotDateTime(date: IsoDate, minutesFromMidnight: number, timezone: string): DateTime {
if (minutesFromMidnight < 0 || minutesFromMidnight > MINUTES_PER_DAY) {
throw new RangeError(`minutesFromMidnight out of range: ${minutesFromMidnight}`);
}
const base = DateTime.fromISO(date, { zone: timezone });
if (!base.isValid) {
throw new RangeError(`Invalid date "${date}" for zone "${timezone}": ${base.invalidReason}`);
}
const hour = Math.floor(minutesFromMidnight / 60);
const minute = minutesFromMidnight % 60;
const result = base.set({ hour, minute, second: 0, millisecond: 0 });
// A wall-clock time that doesn't exist (spring-forward gap, e.g. 2:30 AM
// on the day clocks jump from 2:00 to 3:00) stays `isValid` in Luxon —
// `.set()` silently rolls it forward past the gap (2:30 -> 3:30) instead
// of rejecting it. Detect that by checking the fields actually landed
// where asked; surface it rather than silently returning a shifted time
// a merchant never configured.
if (result.hour !== hour || result.minute !== minute) {
throw new RangeError(
`${date} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} does not exist in ${timezone} (DST spring-forward gap)`,
);
}
return result;
}
/** 0 (Sunday) .. 6 (Saturday), evaluated as a calendar date — independent of what time or timezone the server itself is running in. */
export function weekdayOf(date: IsoDate, timezone: string): number {
const dt = DateTime.fromISO(date, { zone: timezone });
// Luxon's weekday is 1 (Monday) .. 7 (Sunday); our schema uses 0 (Sunday) .. 6 (Saturday).
return dt.weekday % 7;
}
/**
* Inclusive list of calendar dates from start to end (both "YYYY-MM-DD").
* Pinned to UTC these are plain calendar dates, not instants, so stepping
* through them must not depend on the server process's own local timezone
* or DST (which would otherwise risk skipping/repeating a date on the
* server's own transition day).
*/
export function enumerateDates(startDate: IsoDate, endDate: IsoDate): IsoDate[] {
const start = DateTime.fromISO(startDate, { zone: "utc" });
const end = DateTime.fromISO(endDate, { zone: "utc" });
if (!start.isValid || !end.isValid) {
throw new RangeError(`Invalid date range: ${startDate}..${endDate}`);
}
const dates: IsoDate[] = [];
for (let d = start; d <= end; d = d.plus({ days: 1 })) {
dates.push(d.toISODate()!);
}
return dates;
}
/**
* Minutes between `now` and a slot's start, in real elapsed time (correctly
* reflecting a DST change if `now` and the slot fall on opposite sides of
* one e.g. a slot booked the evening before a fall-back has genuinely 60
* more minutes of lead time than the wall-clock difference would suggest).
*/
export function minutesUntil(now: DateTime, target: DateTime): number {
return target.diff(now, "minutes").minutes;
}

View File

@ -1,113 +0,0 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { getAvailability } from "../services/scheduling.server";
// Public endpoint, reachable only through Shopify's App Proxy (signature
// verified by authenticate.public.appProxy) — this is what the storefront
// Theme App Extension calls. Requests to https://{shop}/apps/scheduling/*
// forward here because shopify.app.toml's [app_proxy].url already includes
// the /apps/scheduling prefix, so this file's path (apps.scheduling.*)
// mirrors the shop-facing URL exactly.
//
// No capacity consumption is wired up yet (Booking/SlotHold don't exist
// until Phase 4), so every slot's `consumed` is implicitly 0 here — that's
// expected for Phase 3, not a bug to fix in this file.
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
const MAX_DAYS = 60;
const DEFAULT_DAYS = 14;
function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.public.appProxy(request);
if (!session) {
return Response.json({ error: "Shop not found" }, { status: 404 });
}
const url = new URL(request.url);
const methodParam = url.searchParams.get("method");
const locationIdParam = url.searchParams.get("locationId");
const daysParam = Number(url.searchParams.get("days") ?? DEFAULT_DAYS);
const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(daysParam, MAX_DAYS) : DEFAULT_DAYS;
if (!methodParam || !VALID_METHODS.has(methodParam as Method)) {
return Response.json({ error: "Invalid or missing method" }, { status: 400 });
}
const method = methodParam as Method;
const location = locationIdParam
? await db.location.findFirst({
where: { id: locationIdParam, shopDomain: session.shop, active: true },
})
: await db.location.findFirst({
where: { shopDomain: session.shop, active: true },
orderBy: { createdAt: "asc" },
});
if (!location) {
return Response.json({ error: "No active location configured" }, { status: 404 });
}
const now = DateTime.now().setZone(location.timezone);
const startDate = now.toISODate()!;
const endDate = now.plus({ days }).toISODate()!;
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).toJSDate();
const [slotTemplates, overrides, blackouts] = await Promise.all([
db.slotTemplate.findMany({
where: { shopDomain: session.shop, locationId: location.id, method },
}),
db.slotOverride.findMany({
where: {
shopDomain: session.shop,
locationId: location.id,
method,
date: { gte: rangeStart, lte: rangeEnd },
},
}),
db.blackoutDate.findMany({
where: {
shopDomain: session.shop,
date: { gte: rangeStart, lte: rangeEnd },
AND: [{ OR: [{ locationId: location.id }, { locationId: null }] }, { OR: [{ method }, { method: null }] }],
},
}),
]);
const availability = getAvailability({
timezone: location.timezone,
dateRange: { startDate, endDate },
slotTemplates: slotTemplates.map((t) => ({
weekday: t.weekday,
startMin: t.startMin,
endMin: t.endMin,
capacity: t.capacity,
cutoffMin: t.cutoffMin,
leadTimeMin: t.leadTimeMin,
})),
overrides: overrides.map((o) => ({
date: toIsoDate(o.date),
closed: o.closed,
startMin: o.startMin,
endMin: o.endMin,
capacity: o.capacity,
})),
blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })),
now,
});
return Response.json({
locationId: location.id,
locationName: location.name,
timezone: location.timezone,
method,
dates: availability,
});
};

View File

@ -1,98 +0,0 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { slotDateTime, weekdayOf } from "../lib/time";
import { remainingCapacity } from "../services/capacity.server";
import { tryCreateHold, releaseHold, countActiveHolds } from "../services/holds.server";
// Public app-proxy endpoint (see apps.scheduling.availability.tsx for the
// path-mirroring rationale). Called by the widget the moment a shopper
// picks a slot, before it writes the cart attribute — this is what
// actually reserves capacity (PRODUCT_STRATEGY.md §3.1, §4.1: "the
// last-slot race condition"). The cart attribute write alone would just be
// two shoppers racing to write the same free-text field; nothing would
// stop both orders from completing.
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.public.appProxy(request);
if (!session) {
return Response.json({ error: "Shop not found" }, { status: 404 });
}
const body = await request.json();
const { intent, locationId, method, date, startMin, cartToken } = body as {
intent?: "create" | "release";
locationId?: string;
method?: string;
date?: string;
startMin?: number;
cartToken?: string;
};
if (!locationId || !method || !VALID_METHODS.has(method as Method) || !date || typeof startMin !== "number" || !cartToken) {
return Response.json({ error: "Missing or invalid parameters" }, { status: 400 });
}
const location = await db.location.findFirst({
where: { id: locationId, shopDomain: session.shop, active: true },
});
if (!location) {
return Response.json({ error: "Location not found" }, { status: 404 });
}
const slotStart = slotDateTime(date, startMin, location.timezone);
const slot = {
shopDomain: session.shop,
locationId: location.id,
method: method as Method,
slotStartIso: slotStart.toUTC().toISO()!,
};
if (intent === "release") {
await releaseHold(slot, cartToken);
return Response.json({ ok: true });
}
const template = await db.slotTemplate.findFirst({
where: {
shopDomain: session.shop,
locationId: location.id,
method: slot.method,
weekday: weekdayOf(date, location.timezone),
startMin,
},
});
if (!template) {
return Response.json({ error: "Slot not found" }, { status: 404 });
}
const confirmedCount = await db.booking.count({
where: {
shopDomain: session.shop,
locationId: location.id,
method: slot.method,
slotStart: slotStart.toJSDate(),
status: "confirmed",
},
});
const capacityBudget = remainingCapacity({ capacity: template.capacity, consumed: confirmedCount });
if (capacityBudget <= 0) {
return Response.json({ success: false, error: "Slot is full" }, { status: 409 });
}
const result = await tryCreateHold(slot, cartToken, capacityBudget);
if (!result.success) {
return Response.json({ success: false, error: "Slot was just taken" }, { status: 409 });
}
const activeHolds = await countActiveHolds(slot);
return Response.json({
success: true,
expiresAt: result.expiresAt,
remaining: Math.max(0, capacityBudget - activeHolds),
});
};

View File

@ -1,19 +1,12 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import db from "../db.server";
// Marks the linked Booking cancelled, which frees its capacity for
// getAvailability()/the hold endpoint's confirmedCount check on the next
// request — see IMPLEMENTATION_PLAN.md §5.2.
// TODO (Phase 4): mark the linked Booking cancelled and free its
// capacity/resources. See IMPLEMENTATION_PLAN.md §5.2.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
const order = payload as unknown as { admin_graphql_api_id: string };
await db.booking.updateMany({
where: { shopDomain: shop, orderId: order.admin_graphql_api_id },
data: { status: "cancelled" },
});
console.log(`Received ${topic} webhook for ${shop}`, payload);
return new Response();
};

View File

@ -1,59 +1,13 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import { createBookingFromOrder, type OrderWebhookPayload } from "../services/booking.server";
// Converts the cart's dd_* attributes into a confirmed Booking, releases
// the matching Redis hold, and writes the slot back onto the order via a
// metafield so staff (and, later, POS) see it natively on the order record
// — IMPLEMENTATION_PLAN.md §5.2 / §2 data flow. Idempotent: Shopify
// redelivers webhooks, and booking.server.ts's upsert-on-orderId handles
// that safely.
// TODO (Phase 4): convert the cart's SlotHold into a confirmed Booking,
// consume capacity/resources, write the slot back onto the order via
// metafield, and release the hold. See IMPLEMENTATION_PLAN.md §5.2.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, admin } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
const { shop, topic, payload } = await authenticate.webhook(request);
const order = payload as unknown as OrderWebhookPayload;
await createBookingFromOrder(shop, order);
if (admin) {
const bookingSummary = summarizeBookingAttributes(order);
if (bookingSummary) {
await admin.graphql(
`#graphql
mutation setBookingMetafield($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
userErrors { field message }
}
}`,
{
variables: {
metafields: [
{
ownerId: order.admin_graphql_api_id,
namespace: "delivery_datetime",
key: "booking",
type: "json",
value: JSON.stringify(bookingSummary),
},
],
},
},
);
}
}
console.log(`Received ${topic} webhook for ${shop}`, payload);
return new Response();
};
function summarizeBookingAttributes(order: OrderWebhookPayload) {
const attrs = order.note_attributes ?? [];
const get = (key: string) => attrs.find((a) => a.name === key)?.value;
const method = get("dd_method");
const date = get("dd_date");
const startMin = get("dd_start_min");
const endMin = get("dd_end_min");
const locationId = get("dd_location_id");
if (!method || !date || !startMin || !endMin || !locationId) return null;
return { method, date, startMin: Number(startMin), endMin: Number(endMin), locationId };
}

View File

@ -1,87 +0,0 @@
import type { Method } from "@prisma/client";
import db from "../db.server";
import { slotDateTime } from "../lib/time";
import { releaseHold } from "./holds.server";
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
export interface OrderNoteAttribute {
name: string;
value: string;
}
export interface OrderWebhookPayload {
admin_graphql_api_id: string;
name?: string;
cart_token?: string | null;
email?: string | null;
phone?: string | null;
note_attributes?: OrderNoteAttribute[];
}
function readAttr(attrs: OrderNoteAttribute[], key: string): string | undefined {
return attrs.find((a) => a.name === key)?.value;
}
/**
* Converts a completed order's dd_* cart attributes (written by the
* storefront widget, see extensions/datetime-widget) into a confirmed
* Booking, and releases the Redis hold that reserved its capacity.
*
* Idempotent on orderId webhooks can and do redeliver, so this must be
* safe to run twice for the same order without double-booking capacity.
*
* Deliberately does NOT re-check capacity here and reject the order if
* over budget: by the time an order exists, payment has been taken and the
* Validation Function already had its chance to block checkout with a
* fresh capacity snapshot. This function's job is to record what happened,
* not to re-litigate it.
*/
export async function createBookingFromOrder(shopDomain: string, order: OrderWebhookPayload): Promise<void> {
const attrs = order.note_attributes ?? [];
const method = readAttr(attrs, "dd_method");
const date = readAttr(attrs, "dd_date");
const startMinRaw = readAttr(attrs, "dd_start_min");
const endMinRaw = readAttr(attrs, "dd_end_min");
const locationId = readAttr(attrs, "dd_location_id");
if (!method || !VALID_METHODS.has(method as Method) || !date || !startMinRaw || !endMinRaw || !locationId) {
return; // no scheduling selection on this order — nothing to book
}
const location = await db.location.findFirst({ where: { id: locationId, shopDomain } });
if (!location) return;
const startMin = Number(startMinRaw);
const endMin = Number(endMinRaw);
const slotStart = slotDateTime(date, startMin, location.timezone);
const slotEnd = slotDateTime(date, endMin, location.timezone);
await db.booking.upsert({
where: { orderId: order.admin_graphql_api_id },
create: {
shopDomain,
orderId: order.admin_graphql_api_id,
orderName: order.name,
locationId: location.id,
method: method as Method,
slotStart: slotStart.toJSDate(),
slotEnd: slotEnd.toJSDate(),
customerEmail: order.email ?? undefined,
customerPhone: order.phone ?? undefined,
},
update: {}, // redelivered webhook — the booking already exists, nothing to change
});
if (order.cart_token) {
await releaseHold(
{
shopDomain,
locationId: location.id,
method: method as Method,
slotStartIso: slotStart.toUTC().toISO()!,
},
order.cart_token,
);
}
}

View File

@ -1,18 +0,0 @@
// Capacity math is intentionally trivial today: `consumed` is whatever the
// caller already looked up (Bookings + active Holds, once those models
// exist from Phase 4 on). Keeping it as an explicit input rather than a
// query inside this function is what lets scheduling.server.ts's
// getAvailability stay a pure, DB-free function while this still slots in
// cleanly once real consumption exists.
export interface CapacityInput {
capacity: number;
consumed: number;
}
export function remainingCapacity({ capacity, consumed }: CapacityInput): number {
return Math.max(0, capacity - consumed);
}
export function hasCapacity(input: CapacityInput): boolean {
return remainingCapacity(input) > 0;
}

View File

@ -1,117 +0,0 @@
import type { Method } from "@prisma/client";
import redis from "../lib/redis.server";
// Soft slot-holds: when a shopper picks a slot, we reserve one unit of its
// capacity for a short TTL so a second shopper can't grab the same last
// unit while the first is still filling out checkout (PRODUCT_STRATEGY.md
// §3.1 — "the last-slot race condition"). Redis is the sole source of
// truth for holds (see the schema.prisma comment on why there's no
// matching Postgres table); its native key expiry is exactly the semantics
// a time-limited reservation needs.
//
// Each slot's outstanding holds live in one Redis sorted set, scored by
// expiry timestamp (ms). Creating a hold has to be atomic — read the
// current count, compare to the capacity budget, and add the new member —
// or two concurrent requests can both read "one spot left" and both
// succeed. That's done with a Lua script (EVAL), which Redis runs
// single-threaded and atomically; there is no equivalent way to get this
// right with separate round-trips from Node.
export const DEFAULT_HOLD_TTL_MS = 10 * 60 * 1000; // 10 minutes
const TRY_CREATE_HOLD_SCRIPT = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local expiresAt = tonumber(ARGV[2])
local member = ARGV[3]
local capacity = tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
-- The same cart re-confirming its own already-held slot (a retried
-- request, a back/forward navigation, a duplicate click) is a renewal, not
-- a new claim it must not have to compete against the capacity gate a
-- second time, or a shopper can lock themselves out of the slot they
-- already hold.
if redis.call('ZSCORE', key, member) then
redis.call('ZADD', key, expiresAt, member)
return 1
end
local count = redis.call('ZCARD', key)
if count < capacity then
redis.call('ZADD', key, expiresAt, member)
redis.call('PEXPIRE', key, 3600000)
return 1
else
return 0
end
`;
redis.defineCommand("tryCreateHoldScript", {
numberOfKeys: 1,
lua: TRY_CREATE_HOLD_SCRIPT,
});
// ioredis's defineCommand() adds the method at runtime without changing the
// client's static type, so this augments it for the one command we added.
declare module "ioredis" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- must match ioredis's own generic arity to merge
interface RedisCommander<Context> {
tryCreateHoldScript(
key: string,
now: number,
expiresAt: number,
member: string,
capacity: number,
): Promise<number>;
}
}
export interface SlotIdentity {
shopDomain: string;
locationId: string;
method: Method;
/** The slot's exact start instant, as an ISO string (stable, timezone-unambiguous cache key). */
slotStartIso: string;
}
function holdSetKey({ shopDomain, locationId, method, slotStartIso }: SlotIdentity): string {
return `hold:${shopDomain}:${locationId}:${method}:${slotStartIso}`;
}
export interface TryCreateHoldResult {
success: boolean;
expiresAt: number;
}
/**
* Attempts to reserve one unit of capacity for `cartToken` on the given
* slot. `capacityBudget` is the capacity still available to holds i.e.
* template capacity minus already-confirmed Bookings, computed by the
* caller from Postgres immediately before calling this. Returns
* success: false if the slot's outstanding holds already meet that budget.
*/
export async function tryCreateHold(
slot: SlotIdentity,
cartToken: string,
capacityBudget: number,
ttlMs: number = DEFAULT_HOLD_TTL_MS,
): Promise<TryCreateHoldResult> {
const now = Date.now();
const expiresAt = now + ttlMs;
const result = await redis.tryCreateHoldScript(holdSetKey(slot), now, expiresAt, cartToken, capacityBudget);
return { success: result === 1, expiresAt };
}
export async function releaseHold(slot: SlotIdentity, cartToken: string): Promise<void> {
await redis.zrem(holdSetKey(slot), cartToken);
}
/** Active (non-expired) hold count for a slot — evicts expired members first. */
export async function countActiveHolds(slot: SlotIdentity): Promise<number> {
const key = holdSetKey(slot);
await redis.zremrangebyscore(key, "-inf", Date.now());
return redis.zcard(key);
}

View File

@ -1,150 +0,0 @@
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;
}
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;
}
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">> =
override
? [
{
startMin: override.startMin ?? 0,
endMin: override.endMin ?? 24 * 60,
capacity: override.capacity ?? 0,
cutoffMin: null,
leadTimeMin: 0,
},
]
: (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,
});
}
daySlots.sort((a, b) => a.startMin - b.startMin);
if (daySlots.length > 0) {
result[date] = daySlots;
}
}
return result;
}

View File

@ -1,57 +0,0 @@
.dd-widget {
display: flex;
flex-direction: column;
gap: 0.75rem;
font-family: inherit;
min-height: 3.5rem; /* reserves space before JS renders content, to avoid layout shift */
}
.dd-widget__heading {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.dd-widget__row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.dd-widget__pill {
border: 1px solid currentColor;
border-radius: 999px;
background: transparent;
padding: 0.4rem 0.9rem;
font-size: 0.875rem;
cursor: pointer;
color: inherit;
}
.dd-widget__pill[aria-pressed="true"] {
background: currentColor;
color: Canvas;
}
.dd-widget__status {
font-size: 0.875rem;
opacity: 0.75;
margin: 0;
}
.dd-widget__confirmation {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.9rem;
}
.dd-widget__link {
background: none;
border: none;
padding: 0;
text-decoration: underline;
cursor: pointer;
color: inherit;
font: inherit;
}

File diff suppressed because one or more lines are too long

View File

@ -1,10 +0,0 @@
{{ 'datetime-widget.css' | asset_url | stylesheet_tag }}
<script src="{{ 'datetime-widget.js' | asset_url }}" defer="defer"></script>
{% schema %}
{
"name": "t:app_embed.name",
"target": "body",
"settings": []
}
{% endschema %}

View File

@ -1,64 +0,0 @@
<div
class="dd-widget"
data-dd-widget
data-heading="{{ block.settings.heading | escape }}"
data-show-shipping="{{ block.settings.show_shipping }}"
data-show-local-delivery="{{ block.settings.show_local_delivery }}"
data-show-pickup="{{ block.settings.show_pickup }}"
data-label-shipping="{{ 'widget.method_shipping' | t | escape }}"
data-label-local-delivery="{{ 'widget.method_local_delivery' | t | escape }}"
data-label-pickup="{{ 'widget.method_pickup' | t | escape }}"
data-attr-label-shipping="{{ 'widget.date_label_shipping' | t | escape }}"
data-attr-label-local-delivery="{{ 'widget.date_label_local_delivery' | t | escape }}"
data-attr-label-pickup="{{ 'widget.date_label_pickup' | t | escape }}"
data-label-choose-date="{{ 'widget.choose_date' | t | escape }}"
data-label-choose-time="{{ 'widget.choose_time' | t | escape }}"
data-label-no-dates="{{ 'widget.no_dates' | t | escape }}"
data-label-confirmed="{{ 'widget.confirmed' | t | escape }}"
data-label-change="{{ 'widget.change' | t | escape }}"
data-label-loading="{{ 'widget.loading' | t | escape }}"
data-label-error="{{ 'widget.error' | t | escape }}"
{% if block.settings.location_id != blank %}data-location-id="{{ block.settings.location_id | escape }}"{% endif %}
{{ block.shopify_attributes }}
>
<noscript>{{ 'widget.enable_js' | t | escape }}</noscript>
</div>
{% schema %}
{
"name": "t:datetime_picker.name",
"target": "section",
"settings": [
{
"type": "text",
"id": "heading",
"label": "t:datetime_picker.heading_label",
"default": "Choose your delivery date"
},
{
"type": "checkbox",
"id": "show_shipping",
"label": "t:datetime_picker.show_shipping_label",
"default": true
},
{
"type": "checkbox",
"id": "show_local_delivery",
"label": "t:datetime_picker.show_local_delivery_label",
"default": true
},
{
"type": "checkbox",
"id": "show_pickup",
"label": "t:datetime_picker.show_pickup_label",
"default": true
},
{
"type": "text",
"id": "location_id",
"label": "t:datetime_picker.location_id_label",
"info": "t:datetime_picker.location_id_info"
}
]
}
{% endschema %}

View File

@ -1,18 +0,0 @@
{
"widget": {
"method_shipping": "Shipping",
"method_local_delivery": "Local delivery",
"method_pickup": "Pickup",
"date_label_shipping": "Shipping date",
"date_label_local_delivery": "Delivery date",
"date_label_pickup": "Pickup date",
"choose_date": "Choose a date",
"choose_time": "Choose a time",
"no_dates": "No dates are available right now.",
"confirmed": "Confirmed for",
"change": "Change",
"loading": "Loading available dates…",
"error": "Couldn't load available dates. Please try again.",
"enable_js": "Please enable JavaScript to choose a delivery date and time."
}
}

View File

@ -1,14 +0,0 @@
{
"app_embed": {
"name": "Delivery Date & Time"
},
"datetime_picker": {
"name": "Date & Time Picker",
"heading_label": "Heading",
"show_shipping_label": "Show Shipping",
"show_local_delivery_label": "Show Local Delivery",
"show_pickup_label": "Show Pickup",
"location_id_label": "Location ID (advanced)",
"location_id_info": "Leave blank to use the shop's default location."
}
}

View File

@ -1,3 +0,0 @@
name = "Delivery Date & Time"
type = "theme"
uid = "4935f147-fc17-827b-2629-385b36917fa08faa33de"

View File

@ -1,2 +0,0 @@
dist
generated

View File

@ -1,4 +0,0 @@
{
"name": "Delivery Method Labeling",
"description": "Relabels delivery options at checkout with the shopper's chosen pickup/delivery date and time."
}

View File

@ -1,35 +0,0 @@
{
"name": "delivery-customization",
"version": "0.0.1",
"license": "UNLICENSED",
"type": "module",
"scripts": {
"shopify": "npm exec -- shopify",
"typegen": "npm exec -- shopify app function typegen",
"build": "npm exec -- shopify app function build",
"preview": "npm exec -- shopify app function run",
"test": "vitest"
},
"codegen": {
"schema": "schema.graphql",
"documents": "src/*.graphql",
"generates": {
"./generated/api.ts": {
"plugins": [
"typescript",
"typescript-operations"
]
}
},
"config": {
"omitOperationSuffix": true
}
},
"dependencies": {
"@shopify/shopify_function": "^2.0.1"
},
"devDependencies": {
"@shopify/shopify-function-test-helpers": "^1.0.0",
"vitest": "^3.2.4"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +0,0 @@
api_version = "2026-07"
[[extensions]]
name = "t:name"
handle = "delivery-customization"
type = "function"
uid = "7caef09d-bc92-c4fe-9c8c-5e43dc1b38bce5a7fa55"
description = "t:description"
[[extensions.targeting]]
target = "cart.delivery-options.transform.run"
input_query = "src/cart_delivery_options_transform_run.graphql"
export = "cart-delivery-options-transform-run"
[extensions.build]
command = ""
path = "dist/function.wasm"
[extensions.ui.paths]
create = "/"
details = "/"

View File

@ -1,15 +0,0 @@
query CartDeliveryOptionsTransformRunInput {
cart {
ddMethod: attribute(key: "dd_method") {
value
}
ddDate: attribute(key: "dd_date") {
value
}
deliveryGroups {
deliveryOptions {
handle
}
}
}
}

View File

@ -1,48 +0,0 @@
// @ts-check
import { renameLabelFor } from "./evaluate.js";
/**
* @typedef {import("../generated/api").CartDeliveryOptionsTransformRunInput} CartDeliveryOptionsTransformRunInput
* @typedef {import("../generated/api").CartDeliveryOptionsTransformRunResult} CartDeliveryOptionsTransformRunResult
*/
/**
* @type {CartDeliveryOptionsTransformRunResult}
*/
const NO_CHANGES = {
operations: [],
};
/**
* Relabels every presented delivery option to the shopper's actual chosen
* method + date, so checkout never shows a generic carrier label
* ("Standard", "Economy") that could be mistaken for a shipping ETA on
* what's actually a pickup or local-delivery order. Phase 4 doesn't yet
* have a per-option method mapping (that needs Phase 5's zones/rates
* work), so every option in the cart gets the same clarified label
* reasonable since a single order only has one chosen fulfillment method
* today.
* @param {CartDeliveryOptionsTransformRunInput} input
* @returns {CartDeliveryOptionsTransformRunResult}
*/
export function cartDeliveryOptionsTransformRun(input) {
const decision = renameLabelFor({
dd_method: input.cart.ddMethod?.value,
dd_date: input.cart.ddDate?.value,
});
if (!decision.rename) {
return NO_CHANGES;
}
const operations = input.cart.deliveryGroups.flatMap((group) =>
group.deliveryOptions.map((option) => ({
deliveryOptionRename: {
deliveryOptionHandle: option.handle,
title: decision.title,
},
})),
);
return { operations };
};

View File

@ -1,33 +0,0 @@
// @ts-check
// Pure decision logic, kept separate from the run.js adapter so it's
// unit-testable directly with plain Vitest — no WASM build needed to verify
// the actual rule (see tests/unit/delivery-customization.test.ts at the
// repo root).
//
// This directly targets the DS review complaint PRODUCT_STRATEGY.md §3.1
// names: "a stray 'estimated delivery date' on a pickup order." Renaming
// the delivery-method line at checkout to show the shopper's actual chosen
// method/date/time removes any ambiguity about what they're getting.
/** @type {Record<string, string>} */
const METHOD_LABEL = {
PICKUP: "Pickup",
LOCAL_DELIVERY: "Local delivery",
SHIPPING: "Shipping",
};
/**
* @param {Record<string, string | null | undefined>} attributes
* @returns {{ rename: true, title: string } | { rename: false }}
*/
export function renameLabelFor(attributes) {
const method = attributes.dd_method;
const date = attributes.dd_date;
if (!method || !date || !(method in METHOD_LABEL)) {
return { rename: false };
}
const label = METHOD_LABEL[method];
return { rename: true, title: `${label}${date}` };
}

View File

@ -1 +0,0 @@
export * from './cart_delivery_options_transform_run';

View File

@ -1,45 +0,0 @@
import path from "path";
import fs from "fs";
import { describe, beforeAll, test, expect } from "vitest";
import { buildFunction, getFunctionInfo, loadSchema, loadInputQuery, loadFixture, validateTestAssets, runFunction } from "@shopify/shopify-function-test-helpers";
describe("Default Integration Test", () => {
let schema;
let functionDir;
let functionInfo;
let schemaPath;
let targeting;
let functionRunnerPath;
let wasmPath;
beforeAll(async () => {
functionDir = path.dirname(__dirname);
await buildFunction(functionDir);
functionInfo = await getFunctionInfo(functionDir);
({ schemaPath, functionRunnerPath, wasmPath, targeting } = functionInfo);
schema = await loadSchema(schemaPath);
}, 45000);
const fixturesDir = path.join(__dirname, "fixtures");
const fixtureFiles = fs
.readdirSync(fixturesDir)
.filter((file) => file.endsWith(".json"))
.map((file) => path.join(fixturesDir, file));
fixtureFiles.forEach((fixtureFile) => {
test(`runs ${path.relative(fixturesDir, fixtureFile)}`, async () => {
const fixture = await loadFixture(fixtureFile);
const targetInputQueryPath = targeting[fixture.target].inputQueryPath;
const inputQueryAST = await loadInputQuery(targetInputQueryPath);
const validationResult = await validateTestAssets({ schema, fixture, inputQueryAST });
expect(validationResult.inputQuery.errors).toEqual([]);
expect(validationResult.inputFixture.errors).toEqual([]);
expect(validationResult.outputFixture.errors).toEqual([]);
const runResult = await runFunction(fixture, functionRunnerPath, wasmPath, targetInputQueryPath, schemaPath);
expect(runResult.error).toBeNull();
expect(runResult.result.output).toEqual(fixture.expectedOutput);
}, 10000);
});
});

View File

@ -1,20 +0,0 @@
{
"payload": {
"export": "cart-delivery-options-transform-run",
"target": "cart.delivery-options.transform.run",
"input": {
"cart": {
"ddMethod": null,
"ddDate": null,
"deliveryGroups": [
{
"deliveryOptions": [{ "handle": "standard-shipping" }]
}
]
}
},
"output": {
"operations": []
}
}
}

View File

@ -1,33 +0,0 @@
{
"payload": {
"export": "cart-delivery-options-transform-run",
"target": "cart.delivery-options.transform.run",
"input": {
"cart": {
"ddMethod": { "value": "PICKUP" },
"ddDate": { "value": "2026-08-25" },
"deliveryGroups": [
{
"deliveryOptions": [{ "handle": "standard-shipping" }, { "handle": "express-shipping" }]
}
]
}
},
"output": {
"operations": [
{
"deliveryOptionRename": {
"deliveryOptionHandle": "standard-shipping",
"title": "Pickup — 2026-08-25"
}
},
{
"deliveryOptionRename": {
"deliveryOptionHandle": "express-shipping",
"title": "Pickup — 2026-08-25"
}
}
]
}
}
}

View File

@ -1 +0,0 @@
// Prevents inheritance from parent Remix project

View File

@ -1,8 +0,0 @@
export default {
test: {
forceRerunTriggers: [
'**/tests/fixtures/**',
'**/src/**',
],
},
};

View File

@ -1,2 +0,0 @@
dist
generated

View File

@ -1,4 +0,0 @@
{
"name": "Delivery Slot Validation",
"description": "Blocks checkout if no delivery date/time slot was selected."
}

View File

@ -1,35 +0,0 @@
{
"name": "validation-slot",
"version": "0.0.1",
"license": "UNLICENSED",
"type": "module",
"scripts": {
"shopify": "npm exec -- shopify",
"typegen": "npm exec -- shopify app function typegen",
"build": "npm exec -- shopify app function build",
"preview": "npm exec -- shopify app function run",
"test": "vitest"
},
"codegen": {
"schema": "schema.graphql",
"documents": "src/*.graphql",
"generates": {
"./generated/api.ts": {
"plugins": [
"typescript",
"typescript-operations"
]
}
},
"config": {
"omitOperationSuffix": true
}
},
"dependencies": {
"@shopify/shopify_function": "^2.0.1"
},
"devDependencies": {
"@shopify/shopify-function-test-helpers": "^1.0.0",
"vitest": "^3.2.4"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +0,0 @@
api_version = "2026-07"
[[extensions]]
name = "t:name"
handle = "validation-slot"
type = "function"
uid = "f5265178-4064-010b-5acf-a7b68096a1d29be8d5da"
description = "t:description"
[[extensions.targeting]]
target = "cart.validations.generate.run"
input_query = "src/cart_validations_generate_run.graphql"
export = "cart-validations-generate-run"
[extensions.build]
command = ""
path = "dist/function.wasm"

View File

@ -1,19 +0,0 @@
query CartValidationsGenerateRunInput {
cart {
ddMethod: attribute(key: "dd_method") {
value
}
ddDate: attribute(key: "dd_date") {
value
}
ddStartMin: attribute(key: "dd_start_min") {
value
}
ddEndMin: attribute(key: "dd_end_min") {
value
}
ddLocationId: attribute(key: "dd_location_id") {
value
}
}
}

View File

@ -1,40 +0,0 @@
// @ts-check
import { evaluateCartAttributes } from "./evaluate.js";
/**
* @typedef {import("../generated/api").CartValidationsGenerateRunInput} CartValidationsGenerateRunInput
* @typedef {import("../generated/api").CartValidationsGenerateRunResult} CartValidationsGenerateRunResult
*/
const ERROR_MESSAGE = {
no_slot_selected: "Please choose a delivery date and time before checking out.",
incomplete_slot_selection: "Your delivery date/time selection is incomplete — please choose it again.",
};
/**
* Cart & Checkout Validation Function the server-side enforcement half of
* the widget/Function split (see evaluate.js for the full scope note).
* @param {CartValidationsGenerateRunInput} input
* @returns {CartValidationsGenerateRunResult}
*/
export function cartValidationsGenerateRun(input) {
const attributes = {
dd_method: input.cart.ddMethod?.value,
dd_date: input.cart.ddDate?.value,
dd_start_min: input.cart.ddStartMin?.value,
dd_end_min: input.cart.ddEndMin?.value,
dd_location_id: input.cart.ddLocationId?.value,
};
const result = evaluateCartAttributes(attributes);
return {
operations: [
{
validationAdd: {
errors: result.valid ? [] : [{ message: ERROR_MESSAGE[result.reason], target: "$.cart" }],
},
},
],
};
};

View File

@ -1,42 +0,0 @@
// @ts-check
// Pure decision logic, kept separate from the run.js adapter so it can be
// unit-tested directly with plain Vitest (see
// tests/unit/validation-slot.test.ts at the repo root) as well as through
// the real function-runner fixtures in tests/fixtures/ here.
//
// This is the enforcement half of the "widget collects, Function enforces"
// split (CLAUDE.md non-negotiable): a shopper who clears the cart
// attribute, calls the cart API directly, or otherwise bypasses the
// storefront widget still cannot complete checkout, because this runs
// server-side inside Shopify's own checkout, not in the browser.
//
// Scope note (Phase 4): every order on shops that activate this Function is
// currently treated as requiring a schedule selection — there's no
// ProductRule yet (that's Phase 5) to scope enforcement to specific
// products/collections. Merchants who haven't finished configuring
// locations/slots simply shouldn't activate this Function in
// Settings > Checkout yet, the same way any other checkout validation
// works. Also out of scope for now: re-validating against a *live*
// capacity snapshot at the moment checkout completes ("has since been
// taken" in PRODUCT_STRATEGY.md §3.1) — Functions can't call our DB, and
// building a metafield-snapshot refresh pipeline for that is real,
// unscoped work. The 10-minute hold TTL (app/services/holds.server.ts) is
// the mitigation for that specific race in the meantime.
const REQUIRED_ATTRIBUTES = ["dd_method", "dd_date", "dd_start_min", "dd_end_min", "dd_location_id"];
/**
* @param {Record<string, string | null | undefined>} attributes
* @returns {{ valid: true } | { valid: false, reason: "no_slot_selected" | "incomplete_slot_selection", missing: string[] }}
*/
export function evaluateCartAttributes(attributes) {
const missing = REQUIRED_ATTRIBUTES.filter((key) => !attributes[key]);
if (missing.length === 0) {
return { valid: true };
}
if (missing.length === REQUIRED_ATTRIBUTES.length) {
return { valid: false, reason: "no_slot_selected", missing };
}
return { valid: false, reason: "incomplete_slot_selection", missing };
}

View File

@ -1 +0,0 @@
export * from './cart_validations_generate_run';

View File

@ -1,45 +0,0 @@
import path from "path";
import fs from "fs";
import { describe, beforeAll, test, expect } from "vitest";
import { buildFunction, getFunctionInfo, loadSchema, loadInputQuery, loadFixture, validateTestAssets, runFunction } from "@shopify/shopify-function-test-helpers";
describe("Default Integration Test", () => {
let schema;
let functionDir;
let functionInfo;
let schemaPath;
let targeting;
let functionRunnerPath;
let wasmPath;
beforeAll(async () => {
functionDir = path.dirname(__dirname);
await buildFunction(functionDir);
functionInfo = await getFunctionInfo(functionDir);
({ schemaPath, functionRunnerPath, wasmPath, targeting } = functionInfo);
schema = await loadSchema(schemaPath);
}, 45000);
const fixturesDir = path.join(__dirname, "fixtures");
const fixtureFiles = fs
.readdirSync(fixturesDir)
.filter((file) => file.endsWith(".json"))
.map((file) => path.join(fixturesDir, file));
fixtureFiles.forEach((fixtureFile) => {
test(`runs ${path.relative(fixturesDir, fixtureFile)}`, async () => {
const fixture = await loadFixture(fixtureFile);
const targetInputQueryPath = targeting[fixture.target].inputQueryPath;
const inputQueryAST = await loadInputQuery(targetInputQueryPath);
const validationResult = await validateTestAssets({ schema, fixture, inputQueryAST });
expect(validationResult.inputQuery.errors).toEqual([]);
expect(validationResult.inputFixture.errors).toEqual([]);
expect(validationResult.outputFixture.errors).toEqual([]);
const runResult = await runFunction(fixture, functionRunnerPath, wasmPath, targetInputQueryPath, schemaPath);
expect(runResult.error).toBeNull();
expect(runResult.result.output).toEqual(fixture.expectedOutput);
}, 10000);
});
});

View File

@ -1,24 +0,0 @@
{
"payload": {
"export": "cart-validations-generate-run",
"target": "cart.validations.generate.run",
"input": {
"cart": {
"ddMethod": { "value": "PICKUP" },
"ddDate": { "value": "2026-08-25" },
"ddStartMin": { "value": "540" },
"ddEndMin": { "value": "600" },
"ddLocationId": { "value": "loc_123" }
}
},
"output": {
"operations": [
{
"validationAdd": {
"errors": []
}
}
]
}
}
}

View File

@ -1,29 +0,0 @@
{
"payload": {
"export": "cart-validations-generate-run",
"target": "cart.validations.generate.run",
"input": {
"cart": {
"ddMethod": { "value": "PICKUP" },
"ddDate": { "value": "2026-08-25" },
"ddStartMin": null,
"ddEndMin": null,
"ddLocationId": { "value": "loc_123" }
}
},
"output": {
"operations": [
{
"validationAdd": {
"errors": [
{
"message": "Your delivery date/time selection is incomplete — please choose it again.",
"target": "$.cart"
}
]
}
}
]
}
}
}

View File

@ -1,29 +0,0 @@
{
"payload": {
"export": "cart-validations-generate-run",
"target": "cart.validations.generate.run",
"input": {
"cart": {
"ddMethod": null,
"ddDate": null,
"ddStartMin": null,
"ddEndMin": null,
"ddLocationId": null
}
},
"output": {
"operations": [
{
"validationAdd": {
"errors": [
{
"message": "Please choose a delivery date and time before checking out.",
"target": "$.cart"
}
]
}
}
]
}
}
}

View File

@ -1 +0,0 @@
// Prevents inheritance from parent Remix project

View File

@ -1,8 +0,0 @@
export default {
test: {
forceRerunTriggers: [
'**/tests/fixtures/**',
'**/src/**',
],
},
};

2159
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,7 @@
"private": true,
"scripts": {
"build": "remix vite:build",
"predev": "npm run build:widget",
"dev": "shopify app dev",
"predeploy": "npm run build:widget",
"config:link": "shopify app config link",
"generate": "shopify app generate",
"deploy": "shopify app deploy",
@ -15,14 +13,9 @@
"docker-start": "npm run setup && npm run start",
"setup": "prisma generate && prisma migrate deploy",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"typegen:functions": "npm --prefix extensions/validation-slot run typegen && npm --prefix extensions/delivery-customization run typegen",
"pretypecheck": "npm run typegen:functions",
"typecheck": "tsc --noEmit",
"test:functions": "npm --prefix extensions/validation-slot test && npm --prefix extensions/delivery-customization test",
"test": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:e2e": "playwright test",
"build:widget": "esbuild widget-src/datetime-widget/datetime-widget.ts --bundle --minify --target=es2019 --outfile=extensions/datetime-widget/assets/datetime-widget.js",
"worker": "tsx jobs/worker.ts",
"shopify": "shopify",
"prisma": "prisma",
@ -63,8 +56,6 @@
"@types/node": "^22.2.0",
"@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14",
"dotenv": "^16.4.7",
"esbuild": "^0.24.2",
"eslint": "^8.42.0",
"eslint-config-prettier": "^10.0.1",
"prettier": "^3.2.4",

View File

@ -1,29 +0,0 @@
-- CreateTable
CREATE TABLE "Booking" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"orderName" TEXT,
"locationId" TEXT NOT NULL,
"method" "Method" NOT NULL,
"slotStart" TIMESTAMP(3) NOT NULL,
"slotEnd" TIMESTAMP(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'confirmed',
"customerEmail" TEXT,
"customerPhone" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Booking_orderId_key" ON "Booking"("orderId");
-- CreateIndex
CREATE INDEX "Booking_shopDomain_locationId_method_slotStart_idx" ON "Booking"("shopDomain", "locationId", "method", "slotStart");
-- CreateIndex
CREATE INDEX "Booking_shopDomain_status_idx" ON "Booking"("shopDomain", "status");
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -66,7 +66,6 @@ model Location {
slotTemplates SlotTemplate[]
overrides SlotOverride[]
blackouts BlackoutDate[]
bookings Booking[]
createdAt DateTime @default(now())
@@index([shopDomain])
@ -118,28 +117,3 @@ model BlackoutDate {
@@index([shopDomain, date])
}
model Booking {
id String @id @default(cuid())
shopDomain String
orderId String @unique // Shopify order GID — also our idempotency key for webhook retries
orderName String? // e.g. "#1001", for display only
locationId String
location Location @relation(fields: [locationId], references: [id])
method Method
slotStart DateTime
slotEnd DateTime
status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show
customerEmail String?
customerPhone String?
createdAt DateTime @default(now())
@@index([shopDomain, locationId, method, slotStart])
@@index([shopDomain, status])
}
// SlotHold is intentionally NOT a Prisma model — per IMPLEMENTATION_PLAN.md
// §4 it's "App DB (Redis-backed)": Redis's native TTL/expiry is exactly the
// semantics a soft, time-limited reservation needs, so it's the sole source
// of truth for holds (app/services/holds.server.ts). Mirroring it into
// Postgres too would only add a sync-consistency burden with no benefit.

View File

@ -1,26 +1,25 @@
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
# This file stores configurations for your Shopify app.
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration
client_id = "890f611da9f31c1a8e3183b5300b2f53"
name = "Metatron-delivery"
application_url = "https://shopify.dev/apps/default-app-home"
client_id = ""
name = "delivery-datetime-app"
application_url = "https://replace-with-your-tunnel-url.example.com"
embedded = true
[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
# read_customers deliberately omitted: no feature currently calls the
# Customers API (Booking.customerEmail/customerPhone come straight off the
# orders/create webhook payload, covered by read_orders) — requesting it
# unused would also gate this app behind Shopify's Protected Customer Data
# Access approval for no reason. Add it back only when a feature (e.g. the
# "recognize returning customers" parity item) actually needs it, and
# expect to need that approval granted at that point.
scopes = "read_locales,read_locations,read_markets,read_metaobjects,read_orders,read_products,write_cart_transforms,write_delivery_customizations,write_metaobjects,write_orders,write_payment_customizations"
# Minimum scopes for the v1 (Phase 0-4) feature set. Add more only when a
# feature in IMPLEMENTATION_PLAN.md actually needs it.
scopes = "read_products,read_customers,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales"
[auth]
redirect_urls = [ "https://shopify.dev/apps/default-app-home/api/auth" ]
redirect_urls = [
"https://replace-with-your-tunnel-url.example.com/auth/callback",
"https://replace-with-your-tunnel-url.example.com/auth/shopify/callback",
"https://replace-with-your-tunnel-url.example.com/api/auth/callback"
]
[webhooks]
api_version = "2026-10"
api_version = "2024-10"
# Handled by: app/routes/webhooks.app.uninstalled.tsx
[[webhooks.subscriptions]]
@ -32,51 +31,39 @@ api_version = "2026-10"
uri = "/webhooks/app/scopes_update"
topics = ["app/scopes_update"]
# Handled by: app/routes/webhooks.orders.create.tsx
# Handled by: app/routes/webhooks.orders.create.tsx (Phase 4)
[[webhooks.subscriptions]]
uri = "/webhooks/orders/create"
topics = ["orders/create"]
# Handled by: app/routes/webhooks.orders.updated.tsx
# Handled by: app/routes/webhooks.orders.updated.tsx (Phase 4)
[[webhooks.subscriptions]]
uri = "/webhooks/orders/updated"
topics = ["orders/updated"]
# Handled by: app/routes/webhooks.orders.cancelled.tsx
# Handled by: app/routes/webhooks.orders.cancelled.tsx (Phase 4)
[[webhooks.subscriptions]]
uri = "/webhooks/orders/cancelled"
topics = ["orders/cancelled"]
# Mandatory GDPR compliance topics — required for Built-for-Shopify /
# public app review. TEMPORARILY DISABLED: `shopify app dev`/`deploy`
# refuses to push these until the org has requested and been granted
# "Protected customer data access" in the Partner Dashboard (Apps ->
# this app -> API access -> Protected customer data) — that's a manual
# questionnaire/approval step, not something the CLI or config can
# bypass. RE-ENABLE these three blocks (handlers already exist and are
# wired: app/routes/webhooks.customers.data_request.tsx,
# webhooks.customers.redact.tsx, webhooks.shop.redact.tsx) once that
# access is granted, and before any public launch/BfS submission.
# Mandatory GDPR compliance topics — required for Built-for-Shopify / public app review.
# Handled by: app/routes/webhooks.customers.data_request.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/customers/data_request"
compliance_topics = ["customers/data_request"]
# [[webhooks.subscriptions]]
# uri = "/webhooks/customers/data_request"
# compliance_topics = ["customers/data_request"]
# Handled by: app/routes/webhooks.customers.redact.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/customers/redact"
compliance_topics = ["customers/redact"]
# [[webhooks.subscriptions]]
# uri = "/webhooks/customers/redact"
# compliance_topics = ["customers/redact"]
# [[webhooks.subscriptions]]
# uri = "/webhooks/shop/redact"
# compliance_topics = ["shop/redact"]
# Handled by: app/routes/webhooks.shop.redact.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/shop/redact"
compliance_topics = ["shop/redact"]
# App proxy so the storefront Theme App Extension can call our backend
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3). `shopify app dev`
# points this at your dev tunnel automatically when
# automatically_update_urls_on_dev is true (see [build] below); if it
# doesn't, set it manually to `<your-tunnel-url>/apps/scheduling` — the
# Remix routes it forwards to (apps.scheduling.*.tsx) assume that exact
# prefix.
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3).
[app_proxy]
url = "https://replace-with-your-tunnel-url.example.com/apps/scheduling"
subpath = "scheduling"
@ -84,4 +71,3 @@ prefix = "apps"
[build]
include_config_on_deploy = true
automatically_update_urls_on_dev = true

View File

@ -1,90 +0,0 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import db from "../../app/db.server";
import redis from "../../app/lib/redis.server";
import { createBookingFromOrder } from "../../app/services/booking.server";
import { countActiveHolds, tryCreateHold } from "../../app/services/holds.server";
const shopDomain = "booking-integration-test.myshopify.com";
async function cleanup() {
await db.booking.deleteMany({ where: { shopDomain } });
await db.location.deleteMany({ where: { shopDomain } });
await db.shop.deleteMany({ where: { shopDomain } });
}
describe("createBookingFromOrder", () => {
beforeEach(cleanup);
afterAll(async () => {
await cleanup();
await db.$disconnect();
await redis.quit();
});
it("creates a Booking from an order's dd_* attributes and releases the matching hold", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
});
const slot = {
shopDomain,
locationId: location.id,
method: "PICKUP" as const,
slotStartIso: "2026-08-25T13:00:00.000Z",
};
await tryCreateHold(slot, "cart_abc123", 5);
expect(await countActiveHolds(slot)).toBe(1);
await createBookingFromOrder(shopDomain, {
admin_graphql_api_id: "gid://shopify/Order/1",
name: "#1001",
cart_token: "cart_abc123",
email: "shopper@example.com",
note_attributes: [
{ name: "dd_method", value: "PICKUP" },
{ name: "dd_date", value: "2026-08-25" },
{ name: "dd_start_min", value: "540" },
{ name: "dd_end_min", value: "600" },
{ name: "dd_location_id", value: location.id },
],
});
const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/1" } });
expect(booking).not.toBeNull();
expect(booking?.method).toBe("PICKUP");
expect(booking?.status).toBe("confirmed");
expect(booking?.customerEmail).toBe("shopper@example.com");
expect(booking?.slotStart.toISOString()).toBe("2026-08-25T13:00:00.000Z"); // 9 AM EDT
// The hold this order consumed should now be released.
expect(await countActiveHolds(slot)).toBe(0);
});
it("is idempotent — a redelivered webhook does not create a second Booking", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
});
const order = {
admin_graphql_api_id: "gid://shopify/Order/2",
note_attributes: [
{ name: "dd_method", value: "PICKUP" },
{ name: "dd_date", value: "2026-08-25" },
{ name: "dd_start_min", value: "540" },
{ name: "dd_end_min", value: "600" },
{ name: "dd_location_id", value: location.id },
],
};
await createBookingFromOrder(shopDomain, order);
await createBookingFromOrder(shopDomain, order); // redelivery
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/2" } });
expect(count).toBe(1);
});
it("does nothing for an order with no scheduling attributes", async () => {
await createBookingFromOrder(shopDomain, { admin_graphql_api_id: "gid://shopify/Order/3", note_attributes: [] });
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } });
expect(count).toBe(0);
});
});

View File

@ -1,98 +0,0 @@
import { afterAll, describe, expect, it } from "vitest";
import redis from "../../app/lib/redis.server";
import { countActiveHolds, releaseHold, tryCreateHold, type SlotIdentity } from "../../app/services/holds.server";
// The concurrency test CLAUDE.md calls out as non-negotiable: "Slot-holds
// (Redis, TTL) prevent last-slot double-booking — this has a dedicated
// concurrency test that must pass." This needs a real Redis — the
// atomicity guarantee comes from a Lua script Redis runs single-threaded,
// which a mock can't meaningfully exercise. Run via `npm run test:integration`
// against the docker-compose Redis (or CI's redis service).
function uniqueSlot(): SlotIdentity {
return {
shopDomain: "concurrency-test.myshopify.com",
locationId: "loc_test",
method: "PICKUP",
slotStartIso: `2026-08-25T13:00:00.000Z#${Math.random().toString(36).slice(2)}`,
};
}
describe("tryCreateHold concurrency", () => {
afterAll(async () => {
await redis.quit();
});
it("lets exactly one of many concurrent requests claim the last unit of capacity", async () => {
const slot = uniqueSlot();
const capacity = 1;
const contenders = 20;
const results = await Promise.all(
Array.from({ length: contenders }, (_, i) => tryCreateHold(slot, `cart-${i}`, capacity)),
);
const successes = results.filter((r) => r.success);
expect(successes).toHaveLength(1);
const activeCount = await countActiveHolds(slot);
expect(activeCount).toBe(1);
});
it("allows exactly `capacity` concurrent holds, no more, no fewer", async () => {
const slot = uniqueSlot();
const capacity = 5;
const contenders = 30;
const results = await Promise.all(
Array.from({ length: contenders }, (_, i) => tryCreateHold(slot, `cart-${i}`, capacity)),
);
expect(results.filter((r) => r.success)).toHaveLength(capacity);
expect(await countActiveHolds(slot)).toBe(capacity);
});
it("releasing a hold frees capacity for a subsequent request", async () => {
const slot = uniqueSlot();
const capacity = 1;
const first = await tryCreateHold(slot, "cart-a", capacity);
expect(first.success).toBe(true);
const blocked = await tryCreateHold(slot, "cart-b", capacity);
expect(blocked.success).toBe(false);
await releaseHold(slot, "cart-a");
const afterRelease = await tryCreateHold(slot, "cart-b", capacity);
expect(afterRelease.success).toBe(true);
});
it("an expired hold no longer counts against capacity", async () => {
const slot = uniqueSlot();
const capacity = 1;
// A negative TTL means it's already expired the instant it's created.
const first = await tryCreateHold(slot, "cart-expired", capacity, -1000);
expect(first.success).toBe(true);
const second = await tryCreateHold(slot, "cart-fresh", capacity);
expect(second.success).toBe(true);
expect(await countActiveHolds(slot)).toBe(1);
});
it("the same cart re-requesting the same slot does not consume a second unit", async () => {
const slot = uniqueSlot();
const capacity = 1;
const first = await tryCreateHold(slot, "cart-repeat", capacity);
expect(first.success).toBe(true);
// ZADD on an existing member updates its score rather than adding a
// second entry, so a shopper re-confirming the same slot (e.g. a retried
// request) doesn't burn extra capacity against themselves.
const second = await tryCreateHold(slot, "cart-repeat", capacity);
expect(second.success).toBe(true);
expect(await countActiveHolds(slot)).toBe(1);
});
});

View File

@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { hasCapacity, remainingCapacity } from "../../app/services/capacity.server";
describe("remainingCapacity", () => {
it("subtracts consumed from capacity", () => {
expect(remainingCapacity({ capacity: 10, consumed: 3 })).toBe(7);
});
it("floors at 0 rather than going negative (overbooking never surfaces as negative capacity)", () => {
expect(remainingCapacity({ capacity: 10, consumed: 15 })).toBe(0);
});
it("treats zero consumed as full capacity", () => {
expect(remainingCapacity({ capacity: 10, consumed: 0 })).toBe(10);
});
});
describe("hasCapacity", () => {
it("is true when remaining capacity is positive", () => {
expect(hasCapacity({ capacity: 10, consumed: 9 })).toBe(true);
});
it("is false when exactly full", () => {
expect(hasCapacity({ capacity: 10, consumed: 10 })).toBe(false);
});
it("is false when over capacity", () => {
expect(hasCapacity({ capacity: 10, consumed: 11 })).toBe(false);
});
});

View File

@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { renameLabelFor } from "../../extensions/delivery-customization/src/evaluate.js";
describe("renameLabelFor", () => {
it("relabels a pickup order unambiguously", () => {
const result = renameLabelFor({ dd_method: "PICKUP", dd_date: "2026-08-25" });
expect(result).toEqual({ rename: true, title: "Pickup — 2026-08-25" });
});
it("relabels a local delivery order distinctly from pickup", () => {
const result = renameLabelFor({ dd_method: "LOCAL_DELIVERY", dd_date: "2026-08-25" });
expect(result.rename).toBe(true);
if (!result.rename) throw new Error("unreachable");
expect(result.title).toContain("Local delivery");
expect(result.title).not.toContain("Pickup");
});
it("relabels shipping too, for consistency across all three methods", () => {
const result = renameLabelFor({ dd_method: "SHIPPING", dd_date: "2026-08-25" });
expect(result).toEqual({ rename: true, title: "Shipping — 2026-08-25" });
});
it("does nothing when no method was selected", () => {
expect(renameLabelFor({})).toEqual({ rename: false });
});
it("does nothing for an unrecognized method value", () => {
expect(renameLabelFor({ dd_method: "TELEPORT", dd_date: "2026-08-25" })).toEqual({ rename: false });
});
it("does nothing when the date is missing even if method is present", () => {
expect(renameLabelFor({ dd_method: "PICKUP" })).toEqual({ rename: false });
});
});

View File

@ -1,162 +0,0 @@
import { describe, expect, it } from "vitest";
import { slotDateTime } from "../../app/lib/time";
import { getAvailability, type SlotTemplateLike } from "../../app/services/scheduling.server";
const ZONE = "America/Toronto";
function now(date: string, minutesFromMidnight: number) {
return slotDateTime(date, minutesFromMidnight, ZONE);
}
// A simple weekday-only (Mon-Fri) 9-5 template, 1hr cutoff, no extra lead time.
const WEEKDAY_TEMPLATE: SlotTemplateLike[] = [1, 2, 3, 4, 5].map((weekday) => ({
weekday,
startMin: 9 * 60,
endMin: 17 * 60,
capacity: 5,
cutoffMin: 60,
leadTimeMin: 0,
}));
describe("getAvailability", () => {
it("returns a slot for each weekday template date in range, none for weekends", () => {
// 2024-03-04 (Mon) .. 2024-03-10 (Sun)
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-10" },
slotTemplates: WEEKDAY_TEMPLATE,
now: now("2024-03-01", 0),
});
expect(Object.keys(result).sort()).toEqual(["2024-03-04", "2024-03-05", "2024-03-06", "2024-03-07", "2024-03-08"]);
expect(result["2024-03-04"]).toHaveLength(1);
expect(result["2024-03-04"][0]).toMatchObject({ startMin: 9 * 60, endMin: 17 * 60, remainingCapacity: 5 });
});
it("hides a slot once now is within its cutoff window", () => {
// Slot is 2024-03-04 09:00, cutoff 60 min -> unavailable from 08:00 on.
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
now: now("2024-03-04", 8 * 60 + 1),
});
expect(result["2024-03-04"]).toBeUndefined();
});
it("shows a slot exactly at the cutoff boundary", () => {
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
now: now("2024-03-04", 8 * 60), // exactly 60 min before 9:00
});
expect(result["2024-03-04"]).toHaveLength(1);
});
it("enforces leadTimeMin even when it exceeds cutoffMin", () => {
const template: SlotTemplateLike[] = [
{ weekday: 1, startMin: 9 * 60, endMin: 17 * 60, capacity: 5, cutoffMin: 60, leadTimeMin: 24 * 60 },
];
// Only 2 hours before slot start — passes the 60-min cutoff but fails the 24h lead time.
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: template,
now: now("2024-03-04", 7 * 60),
});
expect(result["2024-03-04"]).toBeUndefined();
});
it("excludes a blacked-out date entirely, even if a template would otherwise apply", () => {
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-05" },
slotTemplates: WEEKDAY_TEMPLATE,
blackoutDates: [{ date: "2024-03-04" }],
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"]).toBeUndefined();
expect(result["2024-03-05"]).toHaveLength(1);
});
it("a closed override removes the date even though a template exists", () => {
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
overrides: [{ date: "2024-03-04", closed: true, startMin: null, endMin: null, capacity: null }],
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"]).toBeUndefined();
});
it("a non-closed override replaces the day's window/capacity instead of the template's", () => {
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
overrides: [
{ date: "2024-03-04", closed: false, startMin: 12 * 60, endMin: 14 * 60, capacity: 2 },
],
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"]).toHaveLength(1);
expect(result["2024-03-04"][0]).toMatchObject({ startMin: 12 * 60, endMin: 14 * 60, capacity: 2 });
});
it("hides a slot once consumed capacity reaches the template capacity", () => {
const consumed = new Map([[`2024-03-04|${9 * 60}`, 5]]);
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
consumed,
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"]).toBeUndefined();
});
it("reduces remainingCapacity but keeps the slot visible when partially consumed", () => {
const consumed = new Map([[`2024-03-04|${9 * 60}`, 3]]);
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
consumed,
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"][0].remainingCapacity).toBe(2);
});
it("computes correct instants for a range spanning the spring-forward DST transition", () => {
// 2024-03-08 (Fri) and 2024-03-11 (Mon) bracket the 2024-03-10 transition.
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-08", endDate: "2024-03-11" },
slotTemplates: WEEKDAY_TEMPLATE,
now: now("2024-03-01", 0),
});
const friday = result["2024-03-08"][0];
const monday = result["2024-03-11"][0];
expect(friday.start.toUTC().hour).toBe(14); // EST, UTC-5
expect(monday.start.toUTC().hour).toBe(13); // EDT, UTC-4 — offset already changed
expect(friday.start.hour).toBe(9);
expect(monday.start.hour).toBe(9); // still wall-clock 9 AM despite the offset shift
});
it("returns multiple slots per day sorted by start time when several templates match", () => {
const templates: SlotTemplateLike[] = [
{ weekday: 1, startMin: 14 * 60, endMin: 16 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
{ weekday: 1, startMin: 9 * 60, endMin: 11 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
];
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: templates,
now: now("2024-03-01", 0),
});
expect(result["2024-03-04"].map((s) => s.startMin)).toEqual([9 * 60, 14 * 60]);
});
});

View File

@ -1,123 +0,0 @@
import { DateTime } from "luxon";
import { describe, expect, it } from "vitest";
import { enumerateDates, minutesUntil, slotDateTime, weekdayOf } from "../../app/lib/time";
// North American DST transitions used throughout (2nd Sunday of March /
// 1st Sunday of November — fixed historical facts, safe to hardcode):
// 2024-03-10: spring forward, 2:00 AM -> 3:00 AM (that hour doesn't exist)
// 2024-11-03: fall back, 2:00 AM -> 1:00 AM (1:00-2:00 AM happens twice)
const ZONE = "America/Toronto";
describe("slotDateTime", () => {
it("computes the correct UTC offset in winter (EST, UTC-5)", () => {
const dt = slotDateTime("2024-01-15", 9 * 60, ZONE);
expect(dt.hour).toBe(9);
expect(dt.toUTC().hour).toBe(14);
});
it("computes the correct UTC offset in summer (EDT, UTC-4)", () => {
const dt = slotDateTime("2024-07-15", 9 * 60, ZONE);
expect(dt.hour).toBe(9);
expect(dt.toUTC().hour).toBe(13);
});
it("keeps 9:00 AM meaning 9:00 AM on the spring-forward day itself", () => {
const dt = slotDateTime("2024-03-10", 9 * 60, ZONE);
expect(dt.hour).toBe(9);
expect(dt.toUTC().hour).toBe(13); // already EDT (UTC-4) by 9 AM
// Regression check: naively adding elapsed minutes to local midnight
// instead of setting wall-clock fields directly would land an hour
// late on this exact day, because the 2-3 AM hour never happened.
const naive = DateTime.fromISO("2024-03-10", { zone: ZONE }).startOf("day").plus({ minutes: 9 * 60 });
expect(naive.hour).toBe(10);
expect(naive.hour).not.toBe(dt.hour);
});
it("keeps 9:00 AM meaning 9:00 AM on the fall-back day itself", () => {
const dt = slotDateTime("2024-11-03", 9 * 60, ZONE);
expect(dt.hour).toBe(9);
expect(dt.toUTC().hour).toBe(14); // already back to EST (UTC-5) by 9 AM
});
it("throws for a wall-clock time that doesn't exist (spring-forward gap)", () => {
// 2:30 AM on 2024-03-10 was skipped entirely (clocks jumped 2:00->3:00).
expect(() => slotDateTime("2024-03-10", 2 * 60 + 30, ZONE)).toThrow(/does not exist/);
});
it("resolves an ambiguous wall-clock time on the fall-back day without throwing", () => {
// 1:30 AM on 2024-11-03 happened twice; either resolution is acceptable,
// it just must not throw and must report the wall-clock hour asked for.
const dt = slotDateTime("2024-11-03", 1 * 60 + 30, ZONE);
expect(dt.isValid).toBe(true);
expect(dt.hour).toBe(1);
expect(dt.minute).toBe(30);
});
it("throws for an invalid date string", () => {
expect(() => slotDateTime("not-a-date", 0, ZONE)).toThrow(RangeError);
});
it("throws for out-of-range minutes", () => {
expect(() => slotDateTime("2024-06-01", -1, ZONE)).toThrow(RangeError);
expect(() => slotDateTime("2024-06-01", 24 * 60 + 1, ZONE)).toThrow(RangeError);
});
});
describe("weekdayOf", () => {
it("matches known calendar weekdays (0 = Sunday .. 6 = Saturday)", () => {
expect(weekdayOf("2024-03-10", ZONE)).toBe(0); // Sunday
expect(weekdayOf("2024-01-01", ZONE)).toBe(1); // Monday
expect(weekdayOf("2024-01-06", ZONE)).toBe(6); // Saturday
});
it("is purely calendar-date based, independent of the timezone passed", () => {
expect(weekdayOf("2024-03-10", "America/Toronto")).toBe(weekdayOf("2024-03-10", "Pacific/Auckland"));
});
});
describe("enumerateDates", () => {
it("returns an inclusive, contiguous range across a DST transition", () => {
expect(enumerateDates("2024-03-08", "2024-03-11")).toEqual([
"2024-03-08",
"2024-03-09",
"2024-03-10",
"2024-03-11",
]);
});
it("returns a single date when start equals end", () => {
expect(enumerateDates("2024-06-01", "2024-06-01")).toEqual(["2024-06-01"]);
});
it("spans a month boundary correctly", () => {
expect(enumerateDates("2024-01-30", "2024-02-02")).toEqual([
"2024-01-30",
"2024-01-31",
"2024-02-01",
"2024-02-02",
]);
});
});
describe("minutesUntil", () => {
it("returns real elapsed minutes across a fall-back (an extra hour occurs)", () => {
const now = slotDateTime("2024-11-02", 23 * 60, ZONE); // 11 PM, day before
const target = slotDateTime("2024-11-03", 9 * 60, ZONE); // 9 AM, after fall-back
// Wall-clock difference looks like 10h, but 11 real hours passed.
expect(minutesUntil(now, target)).toBe(11 * 60);
});
it("returns real elapsed minutes across a spring-forward (an hour is lost)", () => {
const now = slotDateTime("2024-03-09", 23 * 60, ZONE); // 11 PM, day before
const target = slotDateTime("2024-03-10", 9 * 60, ZONE); // 9 AM, after spring-forward
// Wall-clock difference looks like 10h, but only 9 real hours passed.
expect(minutesUntil(now, target)).toBe(9 * 60);
});
it("returns 0 for the same instant and negative for a past target", () => {
const t = slotDateTime("2024-06-01", 12 * 60, ZONE);
expect(minutesUntil(t, t)).toBe(0);
expect(minutesUntil(t, slotDateTime("2024-06-01", 11 * 60, ZONE))).toBe(-60);
});
});

View File

@ -1,40 +0,0 @@
import { describe, expect, it } from "vitest";
import { evaluateCartAttributes } from "../../extensions/validation-slot/src/evaluate.js";
const COMPLETE = {
dd_method: "PICKUP",
dd_date: "2026-08-25",
dd_start_min: "540",
dd_end_min: "600",
dd_location_id: "loc_123",
};
describe("evaluateCartAttributes", () => {
it("is valid when every required attribute is present", () => {
expect(evaluateCartAttributes(COMPLETE)).toEqual({ valid: true });
});
it("rejects a cart with no scheduling attributes at all", () => {
const result = evaluateCartAttributes({});
expect(result).toMatchObject({ valid: false, reason: "no_slot_selected" });
});
it("rejects a cart missing just one required attribute", () => {
const { dd_start_min, ...rest } = COMPLETE;
const result = evaluateCartAttributes(rest);
expect(result).toMatchObject({ valid: false, reason: "incomplete_slot_selection", missing: ["dd_start_min"] });
});
it("treats an empty-string attribute the same as missing", () => {
const result = evaluateCartAttributes({ ...COMPLETE, dd_date: "" });
expect(result.valid).toBe(false);
});
it("treats null/undefined attribute values as missing", () => {
const result = evaluateCartAttributes({ ...COMPLETE, dd_location_id: null, dd_method: undefined });
expect(result.valid).toBe(false);
if (result.valid) throw new Error("unreachable");
expect(result.reason).toBe("incomplete_slot_selection");
expect([...result.missing].sort()).toEqual(["dd_location_id", "dd_method"]);
});
});

View File

@ -1,16 +0,0 @@
import "dotenv/config";
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
// Separate from vitest.config.ts on purpose: these tests hit a real Redis
// (and, later, Postgres) instance rather than pure functions, so they're
// kept out of the fast `npm test` unit loop and run explicitly via
// `npm run test:integration` (locally: docker compose up -d; in CI: a
// redis service — see .github/workflows/ci.yml).
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "node",
include: ["tests/integration/**/*.test.ts"],
},
});

View File

@ -1,359 +0,0 @@
// Storefront widget for the Theme App Extension. Vanilla TS, no framework
// (IMPLEMENTATION_PLAN.md §1 sanctions "Preact or vanilla TS" — vanilla
// keeps the bundle tiny and avoids a runtime dependency for something this
// small). Bundled to ../assets/datetime-widget.js via esbuild
// (`npm run build:widget` at the repo root) — Theme App Extensions ship
// static assets as-is, there's no CLI build step for this extension type.
//
// The widget only *collects* a selection and writes it to cart attributes.
// It never enforces anything — that's the Validation Function's job
// (Phase 4), per CLAUDE.md's non-negotiable that enforcement is
// server-side. Losing network, JS, or an ad-blocker here should degrade to
// "no slot picked" (which the Function then rejects at checkout), not to a
// bypass.
type Method = "SHIPPING" | "LOCAL_DELIVERY" | "PICKUP";
interface SlotDto {
date: string;
startMin: number;
endMin: number;
capacity: number;
remainingCapacity: number;
}
interface AvailabilityResponse {
locationId: string;
locationName: string;
timezone: string;
method: Method;
dates: Record<string, SlotDto[]>;
error?: string;
}
interface WidgetConfig {
root: HTMLElement;
heading: string;
locationId: string | null;
methods: Array<{ value: Method; label: string; attrLabel: string }>;
labels: {
chooseDate: string;
chooseTime: string;
noDates: string;
confirmed: string;
change: string;
loading: string;
error: string;
};
}
const PROXY_BASE = "/apps/scheduling";
function minutesToDisplayTime(minutes: number): string {
const h24 = Math.floor(minutes / 60);
const m = minutes % 60;
const period = h24 < 12 ? "AM" : "PM";
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
return `${h12}:${m.toString().padStart(2, "0")} ${period}`;
}
function formatDateLabel(dateIso: string): string {
// Parsed as a plain calendar date (no timezone conversion) — this string
// already represents the location-local calendar day from the API.
const [year, month, day] = dateIso.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" });
}
function readConfig(root: HTMLElement): WidgetConfig {
const d = root.dataset;
const methods: WidgetConfig["methods"] = [];
if (d.showShipping === "true") {
methods.push({ value: "SHIPPING", label: d.labelShipping || "Shipping", attrLabel: d.attrLabelShipping || "Shipping date" });
}
if (d.showLocalDelivery === "true") {
methods.push({
value: "LOCAL_DELIVERY",
label: d.labelLocalDelivery || "Local delivery",
attrLabel: d.attrLabelLocalDelivery || "Delivery date",
});
}
if (d.showPickup === "true") {
methods.push({ value: "PICKUP", label: d.labelPickup || "Pickup", attrLabel: d.attrLabelPickup || "Pickup date" });
}
return {
root,
heading: d.heading || "",
locationId: d.locationId || null,
methods,
labels: {
chooseDate: d.labelChooseDate || "Choose a date",
chooseTime: d.labelChooseTime || "Choose a time",
noDates: d.labelNoDates || "No dates are available right now.",
confirmed: d.labelConfirmed || "Confirmed for",
change: d.labelChange || "Change",
loading: d.labelLoading || "Loading available dates…",
error: d.labelError || "Couldn't load available dates. Please try again.",
},
};
}
async function fetchAvailability(method: Method, locationId: string | null): Promise<AvailabilityResponse> {
const params = new URLSearchParams({ method, days: "14" });
if (locationId) params.set("locationId", locationId);
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
headers: { Accept: "application/json" },
});
const body = (await res.json()) as AvailabilityResponse;
if (!res.ok) throw new Error(body.error || `Request failed (${res.status})`);
return body;
}
interface HoldResponse {
success: boolean;
expiresAt?: number;
error?: string;
}
async function getCartToken(): Promise<string> {
const res = await fetch("/cart.js", { headers: { Accept: "application/json" } });
const cart = (await res.json()) as { token: string };
return cart.token;
}
async function requestHold(params: {
intent: "create" | "release";
locationId: string;
method: Method;
date: string;
startMin: number;
cartToken: string;
}): Promise<HoldResponse> {
const res = await fetch(`${PROXY_BASE}/hold`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(params),
});
const body = (await res.json()) as HoldResponse;
if (!res.ok && params.intent === "create") return { success: false, error: body.error || "Slot unavailable" };
return body;
}
async function writeCartAttribute(key: string, machine: Record<string, string>, display: string): Promise<void> {
await fetch("/cart/update.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ attributes: { [key]: display, ...machine } }),
});
}
class DateTimeWidget {
private config: WidgetConfig;
private el = {
heading: document.createElement("h3"),
methodRow: document.createElement("div"),
dateRow: document.createElement("div"),
timeRow: document.createElement("div"),
status: document.createElement("p"),
confirmation: document.createElement("div"),
};
private selectedMethod: WidgetConfig["methods"][number] | null = null;
private selectedDate: string | null = null;
private availability: AvailabilityResponse | null = null;
private heldSlot: { locationId: string; method: Method; date: string; startMin: number; cartToken: string } | null =
null;
constructor(config: WidgetConfig) {
this.config = config;
}
mount() {
const { root, heading, methods } = this.config;
root.classList.add("dd-widget--ready");
root.innerHTML = "";
if (methods.length === 0) return; // merchant disabled every method — render nothing
if (heading) {
this.el.heading.className = "dd-widget__heading";
this.el.heading.textContent = heading;
root.appendChild(this.el.heading);
}
this.el.methodRow.className = "dd-widget__row dd-widget__methods";
this.el.dateRow.className = "dd-widget__row dd-widget__dates";
this.el.timeRow.className = "dd-widget__row dd-widget__times";
this.el.status.className = "dd-widget__status";
this.el.confirmation.className = "dd-widget__confirmation";
this.el.confirmation.hidden = true;
root.append(this.el.confirmation, this.el.methodRow, this.el.dateRow, this.el.timeRow, this.el.status);
if (methods.length === 1) {
this.selectMethod(methods[0]);
} else {
this.renderMethods();
}
}
private renderMethods() {
this.el.methodRow.innerHTML = "";
for (const method of this.config.methods) {
const button = document.createElement("button");
button.type = "button";
button.className = "dd-widget__pill";
button.textContent = method.label;
button.setAttribute("aria-pressed", String(this.selectedMethod?.value === method.value));
button.addEventListener("click", () => this.selectMethod(method));
this.el.methodRow.appendChild(button);
}
}
private async selectMethod(method: WidgetConfig["methods"][number]) {
this.selectedMethod = method;
this.selectedDate = null;
this.el.timeRow.innerHTML = "";
this.el.confirmation.hidden = true;
if (this.config.methods.length > 1) this.renderMethods();
this.el.status.textContent = this.config.labels.loading;
this.el.dateRow.innerHTML = "";
try {
this.availability = await fetchAvailability(method.value, this.config.locationId);
this.renderDates();
} catch {
this.el.status.textContent = this.config.labels.error;
}
}
private renderDates() {
const dates = Object.keys(this.availability?.dates ?? {}).sort();
this.el.dateRow.innerHTML = "";
if (dates.length === 0) {
this.el.status.textContent = this.config.labels.noDates;
return;
}
this.el.status.textContent = this.config.labels.chooseDate;
for (const date of dates) {
const button = document.createElement("button");
button.type = "button";
button.className = "dd-widget__pill";
button.textContent = formatDateLabel(date);
button.setAttribute("aria-pressed", String(this.selectedDate === date));
button.addEventListener("click", () => this.selectDate(date));
this.el.dateRow.appendChild(button);
}
}
private selectDate(date: string) {
this.selectedDate = date;
for (const child of Array.from(this.el.dateRow.children)) {
child.setAttribute("aria-pressed", String(child.textContent === formatDateLabel(date)));
}
const slots = this.availability?.dates[date] ?? [];
this.el.timeRow.innerHTML = "";
this.el.status.textContent = this.config.labels.chooseTime;
for (const slot of slots) {
const button = document.createElement("button");
button.type = "button";
button.className = "dd-widget__pill";
button.textContent = `${minutesToDisplayTime(slot.startMin)}${minutesToDisplayTime(slot.endMin)}`;
button.addEventListener("click", () => this.selectSlot(date, slot));
this.el.timeRow.appendChild(button);
}
}
private async selectSlot(date: string, slot: SlotDto) {
const method = this.selectedMethod!;
const availability = this.availability!;
const display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}${minutesToDisplayTime(slot.endMin)}`;
this.el.status.textContent = this.config.labels.loading;
try {
const cartToken = await getCartToken();
// Reserve capacity FIRST. Writing the cart attribute alone would just
// be two shoppers racing to write the same free-text field — nothing
// would stop both checkouts from completing for the last slot. The
// hold is what the Validation Function (Phase 4) actually enforces
// against at checkout.
const hold = await requestHold({
intent: "create",
locationId: availability.locationId,
method: method.value,
date,
startMin: slot.startMin,
cartToken,
});
if (!hold.success) {
this.el.status.textContent = this.config.labels.error;
// The slot we just tried is gone — refresh so the list reflects reality.
await this.selectMethod(method);
return;
}
this.heldSlot = { locationId: availability.locationId, method: method.value, date, startMin: slot.startMin, cartToken };
await writeCartAttribute(
method.attrLabel,
{
dd_method: method.value,
dd_date: date,
dd_start_min: String(slot.startMin),
dd_end_min: String(slot.endMin),
dd_location_id: availability.locationId,
},
display,
);
this.el.status.textContent = "";
this.el.methodRow.hidden = true;
this.el.dateRow.hidden = true;
this.el.timeRow.hidden = true;
this.el.confirmation.hidden = false;
this.el.confirmation.innerHTML = "";
const summary = document.createElement("p");
summary.textContent = `${this.config.labels.confirmed} ${display}`;
const changeButton = document.createElement("button");
changeButton.type = "button";
changeButton.className = "dd-widget__link";
changeButton.textContent = this.config.labels.change;
changeButton.addEventListener("click", () => {
if (this.heldSlot) {
void requestHold({ intent: "release", ...this.heldSlot });
this.heldSlot = null;
}
this.el.methodRow.hidden = false;
this.el.dateRow.hidden = false;
this.el.timeRow.hidden = false;
this.el.confirmation.hidden = true;
});
this.el.confirmation.append(summary, changeButton);
} catch {
this.el.status.textContent = this.config.labels.error;
}
}
}
function init() {
const roots = document.querySelectorAll<HTMLElement>("[data-dd-widget]");
roots.forEach((root) => {
new DateTimeWidget(readConfig(root)).mount();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}