Compare commits

..

10 Commits

Author SHA1 Message Date
metatroncubeswdev
4eebcc5c78 chore: gitignore dev-log.txt (can contain bearer tokens from --verbose runs)
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 02:41:12 -04:00
metatroncubeswdev
0538980eb5 fix: drop unused read_customers scope — likely the real GDPR gate trigger
Commenting out the 3 compliance-topic webhook subscriptions (previous
commit) didn't clear the "not approved to subscribe to webhook topics
containing protected customer data" error — same 3 errors, same wording,
even with those blocks fully removed from shopify.app.toml. That means the
gate isn't about our webhook declarations at all; it's much more likely
triggered by the `read_customers` OAuth scope itself; Shopify's Protected
Customer Data Access requirement applies to the scope, and the CLI's error
message just reuses the same generic wording for the whole policy category
regardless of which part of the config triggered it.

Removed read_customers from shopify.app.toml, .env, and .env.example.
This is also independently correct per CLAUDE.md's "request the minimum
OAuth scopes needed" — nothing in the codebase actually calls the Customers
API; Booking.customerEmail/customerPhone come straight off the
orders/create webhook payload, which read_orders already covers. Add it
back only when a feature that genuinely needs it exists, and expect to
need Protected Customer Data Access granted at that point regardless.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 23:25:10 -04:00
metatroncubeswdev
cc3303d318 fix: disable GDPR compliance webhooks pending Protected Customer Data access
shopify app dev refused to push the 3 mandatory compliance-topic webhooks
(customers/data_request, customers/redact, shop/redact) with "This app is
not approved to subscribe to webhook topics containing protected customer
data" — subscribing to these requires the org to first request and be
granted Protected Customer Data Access in the Partner Dashboard, a manual
approval step outside the CLI/config entirely.

Commented out the three subscription blocks in shopify.app.toml (handlers
are untouched and still fully wired) so dev can proceed now. README.md
gets a new "Before public launch" section as the reminder to re-enable
them once access is granted — this is a hard requirement for BfS/public
submission per CLAUDE.md, not something to forget once dev is unblocked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 23:19:26 -04:00
metatroncubeswdev
e116a6c87a fix: Theme App Extension only allows assets/blocks/snippets/locales dirs
`shopify app dev` failed dev preview with "Only assets, blocks, snippets,
locales directories are allowed" — the widget's TypeScript source lived in
extensions/datetime-widget/src/, which isn't one of the four directories a
Theme App Extension may contain. Moved it to widget-src/datetime-widget/
(a plain, non-extension folder outside extensions/) and updated
build:widget's esbuild input path accordingly; the bundled output still
lands in the same place (extensions/datetime-widget/assets/).

Also fixed a theme-check warning surfaced during the same run:
`script_tag` renders a parser-blocking <script> with no way to defer it —
switched to a manual <script defer> tag for the widget's JS asset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:51:54 -04:00
metatroncubeswdev
381c52d01a fix: replace hand-scaffolded Functions with real CLI-generated ones
The user linked the app to a real Partner org and hit two live errors
running `shopify app dev`, which is exactly the verification the earlier
hand-scaffolded Functions couldn't get in this environment. Root-caused and
fixed both, then went further: regenerated both Functions from scratch via
`shopify app generate extension` (now possible — the user's session had
authenticated) instead of patching the guesses.

What broke and why:
- `shopify app config link` pulled a fresh app's (empty) remote config and
  overwrote shopify.app.toml, dropping the webhook subscriptions and
  app_proxy block — restored both, keeping the real client_id/name/scopes
  the CLI set.
- `[extensions.build.watch]` as a nested table was invalid TOML for this
  field — it's a plain `watch = [...]` array directly under
  `[extensions.build]`.
- The real failure ("doesn't have a build command or it's empty") turned
  out to be a red herring pointing at a stale filename
  (shopify.function.extension.toml, not the current shopify.extension.toml)
  — the actual problem was that `@shopify/shopify_function` was never
  installed for these extensions (confirmed: no node_modules), because
  hand-writing package.json doesn't run the install step
  `shopify app generate extension` does automatically.

Rather than keep guessing at the toolchain, regenerated both Functions for
real:
- `shopify app generate extension --template=cart_checkout_validation` and
  `--template=delivery_customization` (--flavor=vanilla-js), which produces
  a working vite/vitest-based build+test setup, real
  `@shopify/shopify-function-test-helpers` fixture testing (builds actual
  WASM and runs it via function-runner), and a generated GraphQL type file
  per extension.
- This surfaced several concrete corrections to what was hand-written
  before: the real target names are `cart.validations.generate.run` and
  `cart.delivery-options.transform.run` (not `purchase.validation.run` /
  `purchase.delivery-customization.run`), current api_version is 2026-07
  (not 2025-01), the validation output wraps errors in
  `operations: [{ validationAdd: { errors } }]` with a plain `message`
  field (not top-level `errors` with `localizedMessage`), and the rename
  operation is `deliveryOptionRename: { deliveryOptionHandle, title }` (not
  `rename: { deliveryOptionHandle, title }`).
- Rewrote each extension's `.graphql` input query to request our actual
  dd_* cart attributes (plus delivery option handles for the rename case),
  regenerated types via `npm run typegen` in each, and ported the pure
  evaluate.js decision logic (same exported function names/behavior as
  before, now proven correct against the live schema) into the adapter
  file the generator expects.
- Replaced each extension's demo fixture with ones matching our real
  logic; `npm test` inside each extension now compiles real WASM and runs
  function-runner against them — this is strictly stronger verification
  than the previous pure-JS-only unit tests (which are kept too, unchanged,
  since the evaluate.js files kept the same interface).

Repo-wide wiring: extensions/*/generated and extensions/*/dist are not
committed (matches the CLI's own per-extension .gitignore) — added
`npm run typegen:functions` (runs automatically before `npm run typecheck`
via a pretypecheck hook) and `npm run test:functions`, both now also in CI.
Root `npm install` picked these two folders up as proper npm workspace
members (they already have their own package.json from generation).

Verified: lint, typecheck, all 52 unit tests, all 8 integration tests, both
extensions' real WASM/function-runner test suites (5 fixtures total), and
both `npm run build` / `npm run build:widget` all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:14:21 -04:00
metatroncubeswdev
7a340ad135 feat: Phase 4 — enforcement Functions + slot-holds
The core competitive moat (PRODUCT_STRATEGY.md §3.1): checkout can no
longer complete without a valid, still-available slot, on any Shopify
plan. User confirmed writing Functions in JS rather than Rust — no Rust
toolchain was available in this environment, and IMPLEMENTATION_PLAN.md §1
explicitly allows JS as a fallback ("Rust preferred, JS acceptable").

- app/services/holds.server.ts: Redis-backed soft slot-holds with TTL.
  Hold creation is a Lua script (EVAL) — the "does this slot have room"
  check and the reservation itself have to be one atomic Redis operation,
  or two concurrent requests can both read "one spot left" and both
  succeed. Outstanding holds per slot live in a sorted set scored by expiry
  (so eviction is just ZREMRANGEBYSCORE, no separate expiry job needed to
  read a correct count), and a repeat request from the same cart renews
  its own hold instead of competing against the capacity gate again.
- app/routes/apps.scheduling.hold.tsx: public app-proxy endpoint the widget
  calls the moment a shopper picks a slot — reserves capacity BEFORE the
  cart attribute is written, since the attribute alone is just two
  shoppers racing to write the same field.
- extensions/datetime-widget: now fetches the cart token, requests a hold
  first, and only writes cart attributes on success; shows an error and
  refreshes the slot list if it loses the race.
- app/services/booking.server.ts + webhooks.orders.create.tsx: converts an
  order's dd_* cart attributes into a confirmed Booking (idempotent on
  orderId — webhooks redeliver), releases the matching hold, and writes a
  `delivery_datetime.booking` order metafield so the slot is visible on the
  order record natively (IMPLEMENTATION_PLAN.md §5.2/§2). Deliberately does
  NOT re-check capacity and reject at this point — by the time an order
  exists, payment has happened; that's the Function's job, earlier.
- webhooks.orders.cancelled.tsx: marks the Booking cancelled, freeing its
  capacity.
- extensions/validation-slot (Cart/Checkout Validation Function): blocks
  checkout when the cart's dd_* attributes are missing or incomplete — a
  shopper who bypasses the widget entirely (clears the attribute, calls the
  cart API directly) still cannot check out, because this runs inside
  Shopify's own checkout, not the browser. Scope note documented in
  evaluate.js: this doesn't yet re-validate against a live capacity
  snapshot at the moment checkout completes ("has since been taken" in
  §3.1) — Functions can't call our DB, and a metafield-snapshot refresh
  pipeline for that is unscoped work; the 10-minute hold TTL is the interim
  mitigation for that specific race.
- extensions/delivery-customization: relabels every delivery option to the
  shopper's actual chosen method + date ("Pickup — Aug 25" instead of a
  generic carrier label), directly fixing the "estimated delivery date on a
  pickup order" complaint §3.1 names. payment-customization is deliberately
  NOT built yet — there's no configurable payment-method rule for it to
  enforce until later phases add one; shipping a no-op Function serves
  nothing.
- Prisma: added Booking (no SlotHold table — Redis is the sole source of
  truth for holds, per §4's own "(Redis-backed)" annotation; mirroring it
  into Postgres would just be a sync-consistency burden with no benefit).
- Both Function extensions are hand-scaffolded from Shopify's documented
  JS Function structure (same interactive-login limitation as the Theme
  App Extension) — README.md flags that `shopify app function schema`
  should be run before deploying to confirm the input queries still match
  the live schema.

New tests/integration/ suite (separate vitest config, needs live
Redis+Postgres — `docker compose up -d` locally, a services: block in CI)
holds the tests that can't be meaningfully mocked: the slot-hold
concurrency test CLAUDE.md calls out as non-negotiable (20 concurrent
requests for 1 unit of capacity → exactly 1 succeeds; 30 for 5 → exactly 5;
release-then-retry; TTL expiry; same-cart renewal) and the full
hold-to-booking lifecycle including idempotency on webhook redelivery.
Both Functions' pure decision logic is separately unit-tested (11 tests).
60 total tests now pass (52 unit + 8 integration).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:22:51 -04:00
metatroncubeswdev
039b1539ab docs: update README for Phase 3 completion
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:02:47 -04:00
metatroncubeswdev
6c633af598 feat: Phase 3 — storefront widget + cart attributes
Theme App Extension (extensions/datetime-widget/) hand-scaffolded from
Shopify's documented structure, since `shopify app generate extension`
needs the same interactive Partner login `shopify app init`/`dev` do
(unavailable in this session) — user confirmed this approach.

- blocks/app-embed.liquid: site-wide toggle that loads the widget's JS/CSS
  once (target: body)
- blocks/datetime-picker.liquid: the actual app block merchants add to a
  cart/product page section, with per-method show/hide toggles and an
  optional location override, all theme-editor-configurable
- src/datetime-widget.ts: vanilla TS (no framework, ~2kb gzipped) — renders
  method -> date -> slot, calls the app-proxy availability endpoint, and on
  selection writes to /cart/update.js cart attributes using a *method-
  specific* attribute name ("Pickup date" vs "Delivery date" vs "Shipping
  date", from locales/en.default.json) rather than one generic label — this
  is the direct fix for the "estimated delivery date on a pickup order"
  complaint PRODUCT_STRATEGY.md §3.1 calls out. Only collects a selection;
  never enforces anything itself (Phase 4's Validation Function does that).
- locales/en.default.json + en.default.schema.json: i18n from day one
- app/routes/apps.scheduling.availability.tsx: the public app-proxy
  endpoint the widget calls. Path is `apps.scheduling.availability` (not
  the plan's suggested `api.availability`) because shopify.app.toml's
  [app_proxy].url already includes the /apps/scheduling prefix, and
  Shopify forwards a shop-facing /apps/scheduling/availability request to
  {url}/availability against that full url — so the Remix route path has
  to mirror the proxy path exactly for the forwarding to land correctly.
  Resolves (location, method) -> DB rows -> getAvailability(), scoped by
  shopDomain throughout. No consumption wired up (Booking doesn't exist
  until Phase 4), so this correctly shows full capacity everywhere for now.

Theme App Extensions have no CLI build step, so `npm run build:widget`
(esbuild, added as a devDependency) bundles src/ into assets/ and is wired
as a predev/predeploy hook so `shopify app dev`/`deploy` never ship a stale
bundle. CI now also runs both `npm run build` and `npm run build:widget`.

Verified: lint, typecheck, unit tests, both builds pass; a live script
exercising the exact DB-query + getAvailability path the availability route
uses (bypassing HTTP, since real app-proxy signature verification needs a
live tunnel) returned correct results against the Postgres container —
correct EDT offset, correct capacity, and exactly the weekday-filtered set
of open dates for the seeded bakery template.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:02:33 -04:00
metatroncubeswdev
24980b8e03 docs: update README status for Phase 2 completion
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:53:49 -04:00
metatroncubeswdev
8c2b8a9e1c feat: Phase 2 — scheduling engine (timezone/DST-safe, pure, unit-tested)
Implements the "heart" of the app per IMPLEMENTATION_PLAN.md §6 Phase 2,
no UI yet by design:

- app/lib/time.ts: Luxon-based wall-clock helpers. slotDateTime() builds a
  slot's instant from calendar-date + minutes-from-midnight by setting
  hour/minute fields directly rather than adding an elapsed-time duration
  to midnight — the latter is wrong by exactly one hour for any wall-clock
  time on a spring-forward day, since a real elapsed-time addition crosses
  the lost hour. Also detects and rejects wall-clock times that don't exist
  in a spring-forward gap (Luxon silently rolls these forward instead of
  invalidating them, so this required an explicit post-set field check).
- app/services/scheduling.server.ts: getAvailability(), a pure function
  (no DB/Shopify calls — every input injected, including `now`) that
  applies slot templates, date overrides, blackout dates, cutoff/lead-time,
  and capacity to produce the bookable slots per date. Excludes unavailable
  slots entirely rather than flagging them, per PRODUCT_STRATEGY.md §2.
- app/services/capacity.server.ts: remainingCapacity()/hasCapacity() — kept
  as pure functions over an injected `consumed` count so this doesn't need
  to change once Phase 4 adds real Booking/SlotHold-backed consumption.

41 unit tests total (up from 8), including DST regression tests that would
fail against a naive "midnight + elapsed minutes" implementation, an
ambiguous-time (fall-back) case, a nonexistent-time (spring-forward gap)
case, and a getAvailability run across the actual 2024 spring-forward date
verifying both the UTC offset change and that wall-clock hours stay correct
on both sides of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:53:39 -04:00
64 changed files with 15165 additions and 385 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_customers,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_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,3 +4,5 @@ public/build
shopify-app-remix
*/*.yml
.shopify
extensions/*/assets/*.js
extensions/*/dist

View File

@ -4,13 +4,39 @@ on: [push, pull_request]
jobs:
test:
name: Lint & Unit Tests
name: Lint, Unit & Integration 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: 20
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
@ -22,3 +48,13 @@ 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,3 +26,6 @@ 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,9 +34,49 @@ 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) 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).
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`.

91
app/lib/time.ts Normal file
View File

@ -0,0 +1,91 @@
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

@ -0,0 +1,113 @@
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

@ -0,0 +1,98 @@
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,12 +1,19 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import db from "../db.server";
// TODO (Phase 4): mark the linked Booking cancelled and free its
// capacity/resources. See IMPLEMENTATION_PLAN.md §5.2.
// 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.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
console.log(`Received ${topic} webhook for ${shop}`, payload);
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" },
});
return new Response();
};

View File

@ -1,13 +1,59 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import { createBookingFromOrder, type OrderWebhookPayload } from "../services/booking.server";
// 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.
// 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.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
const { shop, topic, payload, admin } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
console.log(`Received ${topic} webhook for ${shop}`, payload);
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),
},
],
},
},
);
}
}
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

@ -0,0 +1,87 @@
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

@ -0,0 +1,18 @@
// 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

@ -0,0 +1,117 @@
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

@ -0,0 +1,150 @@
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

@ -0,0 +1,57 @@
.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

@ -0,0 +1,10 @@
{{ '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

@ -0,0 +1,64 @@
<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

@ -0,0 +1,18 @@
{
"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

@ -0,0 +1,14 @@
{
"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

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

View File

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

View File

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

View File

@ -0,0 +1,35 @@
{
"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

@ -0,0 +1,21 @@
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

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

View File

@ -0,0 +1,48 @@
// @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

@ -0,0 +1,33 @@
// @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

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

View File

@ -0,0 +1,45 @@
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

@ -0,0 +1,20 @@
{
"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

@ -0,0 +1,33 @@
{
"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

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

View File

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

2
extensions/validation-slot/.gitignore vendored Normal file
View File

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

View File

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

View File

@ -0,0 +1,35 @@
{
"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

@ -0,0 +1,17 @@
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

@ -0,0 +1,19 @@
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

@ -0,0 +1,40 @@
// @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

@ -0,0 +1,42 @@
// @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

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

View File

@ -0,0 +1,45 @@
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

@ -0,0 +1,24 @@
{
"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

@ -0,0 +1,29 @@
{
"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

@ -0,0 +1,29 @@
{
"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

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

View File

@ -0,0 +1,8 @@
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,7 +3,9 @@
"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",
@ -13,9 +15,14 @@
"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",
@ -56,6 +63,8 @@
"@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

@ -0,0 +1,29 @@
-- 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,6 +66,7 @@ model Location {
slotTemplates SlotTemplate[]
overrides SlotOverride[]
blackouts BlackoutDate[]
bookings Booking[]
createdAt DateTime @default(now())
@@index([shopDomain])
@ -117,3 +118,28 @@ 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,25 +1,26 @@
# This file stores configurations for your Shopify app.
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
client_id = ""
name = "delivery-datetime-app"
application_url = "https://replace-with-your-tunnel-url.example.com"
client_id = "890f611da9f31c1a8e3183b5300b2f53"
name = "Metatron-delivery"
application_url = "https://shopify.dev/apps/default-app-home"
embedded = true
[access_scopes]
# 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"
# 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"
[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"
]
redirect_urls = [ "https://shopify.dev/apps/default-app-home/api/auth" ]
[webhooks]
api_version = "2024-10"
api_version = "2026-10"
# Handled by: app/routes/webhooks.app.uninstalled.tsx
[[webhooks.subscriptions]]
@ -31,39 +32,51 @@ api_version = "2024-10"
uri = "/webhooks/app/scopes_update"
topics = ["app/scopes_update"]
# Handled by: app/routes/webhooks.orders.create.tsx (Phase 4)
# Handled by: app/routes/webhooks.orders.create.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/orders/create"
topics = ["orders/create"]
# Handled by: app/routes/webhooks.orders.updated.tsx (Phase 4)
# Handled by: app/routes/webhooks.orders.updated.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/orders/updated"
topics = ["orders/updated"]
# Handled by: app/routes/webhooks.orders.cancelled.tsx (Phase 4)
# Handled by: app/routes/webhooks.orders.cancelled.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/orders/cancelled"
topics = ["orders/cancelled"]
# 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"]
# 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.
# Handled by: app/routes/webhooks.customers.redact.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/customers/redact"
compliance_topics = ["customers/redact"]
# [[webhooks.subscriptions]]
# uri = "/webhooks/customers/data_request"
# compliance_topics = ["customers/data_request"]
# Handled by: app/routes/webhooks.shop.redact.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/shop/redact"
compliance_topics = ["shop/redact"]
# [[webhooks.subscriptions]]
# uri = "/webhooks/customers/redact"
# compliance_topics = ["customers/redact"]
# [[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).
# 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.
[app_proxy]
url = "https://replace-with-your-tunnel-url.example.com/apps/scheduling"
subpath = "scheduling"
@ -71,3 +84,4 @@ prefix = "apps"
[build]
include_config_on_deploy = true
automatically_update_urls_on_dev = true

View File

@ -0,0 +1,90 @@
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

@ -0,0 +1,98 @@
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

@ -0,0 +1,30 @@
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

@ -0,0 +1,34 @@
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

@ -0,0 +1,162 @@
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]);
});
});

123
tests/unit/time.test.ts Normal file
View File

@ -0,0 +1,123 @@
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

@ -0,0 +1,40 @@
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

@ -0,0 +1,16 @@
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

@ -0,0 +1,359 @@
// 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();
}