Initial planning docs for the delivery date/time scheduling app. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
26 KiB
Implementation Plan — Delivery Date & Time Shopify App
Audience: Claude Code (or any engineer) building this app from an empty repo. Read
PRODUCT_STRATEGY.mdfirst for the why and the feature rationale. This document is the how: stack, architecture, schema, file layout, and a phased, task-by-task build order with acceptance criteria. Target: All Shopify plans (Basic → Plus). Built-for-Shopify quality bar.
0. Ground rules for the build
- Stack is fixed (see §1). Do not swap frameworks. If a library choice is ambiguous, prefer the one Shopify's own app template uses.
- Build in phases (§6). Each phase ends with working, testable software and its own acceptance criteria. Do not start a phase before the previous one's acceptance criteria pass.
- Every phase includes tests. No phase is "done" with failing or absent tests for the logic it introduced.
- Scheduling correctness is sacred. Timezones, DST, capacity math, and slot-holds are where this app lives or dies. Write unit tests for every date/capacity function before wiring UI.
- Never trust the client. The storefront widget is a convenience; the Validation Function is the enforcement. Any rule that matters must be enforced server-side.
- Keep secrets out of git. Use
.env(gitignored) +shopify.app.toml. Never commit API keys. - Conventional commits, small PRs per task, feature-branch per phase.
1. Tech stack
| Layer | Choice | Notes |
|---|---|---|
| App framework | Remix via @shopify/shopify-app-template-remix |
Shopify's official embedded-app template |
| Admin UI | Polaris (@shopify/polaris) + App Bridge (@shopify/app-bridge-react) |
BfS requirement |
| App DB | PostgreSQL (prod) / SQLite (dev) via Prisma | Transactional scheduling data |
| Cache / queue / holds | Redis + BullMQ | Slot-hold TTLs, notification jobs, capacity recompute |
| Shopify data | GraphQL Admin API (latest stable version) | via @shopify/shopify-api in the template |
| Storefront widget | Theme App Extension (App Embed + blocks), Preact or vanilla TS | Small bundle, no CLS; all plans |
| Checkout | Checkout UI Extensions (React) | Native picker on Plus; Thank-you + Order-status blocks on all plans |
| Enforcement / rules | Shopify Functions (Rust preferred, JS acceptable) | Cart/Checkout Validation, Delivery Customization, Payment Customization, Cart Transform |
| POS | POS UI Extension | Same capacity pool |
| Auth/session | Template's Prisma session storage | |
| Billing | Shopify Billing API (managed pricing or GraphQL appSubscriptionCreate) |
Tiers from strategy §6 |
| Testing | Vitest (unit), Playwright (E2E admin), Function unit tests via function-runner |
|
| Language | TypeScript everywhere except Rust Functions | strict mode on |
2. High-level architecture
┌─────────────────────────────────────────────┐
│ Merchant (Admin) │
│ Remix embedded app · Polaris · App Bridge │
│ locations · slots · rules · dashboard · billing
└───────────────┬─────────────────────────────┘
│ GraphQL Admin API + app DB
┌───────────────▼─────────────────────────────┐
│ App Backend (Remix) │
Redis/BullMQ ◄──────┤ Scheduling service (single source of truth) │──────► Postgres (Prisma)
holds·jobs │ availability · capacity · holds · bookings │ locations, slots,
│ REST/GraphQL app API · webhooks · billing │ bookings, resources...
└───┬──────────────┬───────────────┬───────────┘
│ │ │ metafields / metaobjects write-back
storefront │ checkout │ POS │
┌───────────────────▼──┐ ┌────────▼─────────┐ ┌──▼────────────────┐
│ Theme App Extension │ │ Checkout UI Ext │ │ POS UI Extension │
│ date/time picker │ │ (Plus native / │ │ staff scheduling │
│ → cart attributes │ │ TY+status blocks)│ │ same pool │
└──────────┬───────────┘ └────────┬─────────┘ └───────────────────┘
│ cart attributes │ reads booking
┌──────────▼────────────────────────▼─────────────────────────────┐
│ Shopify Functions │
│ Cart/Checkout Validation → block completion if slot invalid │
│ Delivery Customization → rename/reorder/hide delivery opts │
│ Payment Customization → hide payment methods by method │
│ Cart Transform → line adjustments (deposits, fees) │
└──────────────────────────────────────────────────────────────────┘
The single rule: every surface (widget, checkout, POS) calls the same Scheduling Service for availability, and the same capacity pool for holds/bookings. Behavior never diverges between channels. Functions enforce; UIs merely collect.
Data flow for a booking (non-Plus):
- Widget calls Scheduling Service → gets available dates/slots for method+location+cart.
- Shopper picks slot → widget writes selection to cart attributes and requests a SlotHold (TTL).
- At checkout, Validation Function reads cart attributes, re-checks the hold/availability via a metafield-backed snapshot → blocks completion if invalid.
- On
orders/createwebhook → convert hold to Booking, write date/time/location back to the order via order metafield, consume capacity/resources, release the hold.
3. Repository layout
delivery-datetime-app/
├── CLAUDE.md # root agent instructions (see §7)
├── README.md
├── PRODUCT_STRATEGY.md
├── IMPLEMENTATION_PLAN.md # this file
├── shopify.app.toml # app config, scopes, webhooks
├── .env.example
├── package.json
├── prisma/
│ ├── schema.prisma # §4
│ └── migrations/
├── app/ # Remix app
│ ├── routes/
│ │ ├── app._index.tsx # dashboard home
│ │ ├── app.locations.*.tsx # location CRUD
│ │ ├── app.slots.*.tsx # slot templates / overrides
│ │ ├── app.rules.*.tsx # product/collection rules
│ │ ├── app.blackouts.*.tsx
│ │ ├── app.rates.*.tsx
│ │ ├── app.dashboard.tsx # ops/dispatch dashboard
│ │ ├── app.settings.*.tsx # widget/i18n/templates
│ │ ├── app.billing.tsx
│ │ ├── api.availability.tsx # PUBLIC app-proxy endpoint for widget
│ │ ├── api.hold.tsx # create/release slot holds
│ │ └── webhooks.*.tsx # orders/create, orders/updated, app/uninstalled, GDPR x3
│ ├── services/
│ │ ├── scheduling.server.ts # availability engine (PURE, unit-tested)
│ │ ├── capacity.server.ts # resource capacity math
│ │ ├── holds.server.ts # Redis-backed holds w/ TTL
│ │ ├── booking.server.ts # create/reschedule/cancel bookings
│ │ ├── zones.server.ts # geocode + radius/distance eligibility
│ │ ├── rates.server.ts
│ │ ├── notifications.server.ts
│ │ ├── templates.server.ts # vertical presets
│ │ └── shopify-sync.server.ts# metafield/metaobject write-back
│ ├── lib/
│ │ ├── time.ts # tz/DST-safe date math (Luxon) — PURE, unit-tested
│ │ └── db.server.ts # Prisma client
│ └── shopify.server.ts # template auth/init
├── extensions/
│ ├── datetime-widget/ # Theme App Extension (storefront)
│ ├── checkout-datetime/ # Checkout UI Extension (Plus native + TY/status)
│ ├── pos-datetime/ # POS UI Extension
│ ├── validation-slot/ # Function: cart/checkout validation (Rust)
│ ├── delivery-customization/ # Function: delivery options (Rust)
│ ├── payment-customization/ # Function: payment methods (Rust)
│ └── cart-transform/ # Function: line adjustments (Rust)
├── jobs/
│ └── worker.ts # BullMQ worker: hold-expiry, notifications, capacity recompute
└── tests/
├── unit/ # scheduling, capacity, time, zones
└── e2e/ # Playwright admin flows
4. Prisma schema (starting point)
Build incrementally per phase, but design toward this. shopDomain scopes every row (multi-tenant).
model Shop {
id String @id @default(cuid())
shopDomain String @unique
plan String // basic|shopify|advanced|plus
tier String @default("free") // free|starter|growth|pro
timezone String @default("UTC")
settings Json // widget copy, i18n, feature flags
locations Location[]
createdAt DateTime @default(now())
}
model Location {
id String @id @default(cuid())
shopDomain String
name String
address String
lat Float?
lng Float?
timezone String
active Boolean @default(true)
slotTemplates SlotTemplate[]
overrides SlotOverride[]
blackouts BlackoutDate[]
zones Zone[]
resources CapacityResource[]
@@index([shopDomain])
}
enum Method { SHIPPING LOCAL_DELIVERY PICKUP }
model SlotTemplate {
id String @id @default(cuid())
shopDomain String
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
method Method
weekday Int // 0-6
startMin Int // minutes from midnight, local
endMin Int
capacity Int // default order capacity if no resources
cutoffMin Int? // cutoff before slot (minutes)
leadTimeMin Int @default(0)
@@index([shopDomain, locationId, method, weekday])
}
model SlotOverride {
id String @id @default(cuid())
shopDomain String
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
date DateTime // date-only, location-local
method Method
closed Boolean @default(false)
startMin Int?
endMin Int?
capacity Int?
@@index([shopDomain, locationId, date])
}
model BlackoutDate {
id String @id @default(cuid())
shopDomain String
locationId String? // null = all locations
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
method Method? // null = all methods
date DateTime
reason String?
source String @default("manual") // manual|holiday-import
@@index([shopDomain, date])
}
model CapacityResource {
id String @id @default(cuid())
shopDomain String
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
name String // "oven", "driver", "picker"
perSlotMax Int
@@index([shopDomain, locationId])
}
model Zone {
id String @id @default(cuid())
shopDomain String
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
type String // postal|radius|distance
postalCodes String[]
radiusKm Float?
minOrders Int? // delivery-density threshold
@@index([shopDomain, locationId])
}
model ProductRule {
id String @id @default(cuid())
shopDomain String
scopeType String // product|collection|vendor|type|tag
scopeValue String
allowedMethods Method[]
leadTimeMin Int?
allowedLocationIds String[]
blockSlotRule Json? // cart-content constraints
@@index([shopDomain, scopeType])
}
model Rate {
id String @id @default(cuid())
shopDomain String
method Method
zoneId String?
priceCents Int
keyedBy String // zone|distance|weight|value
rules Json
@@index([shopDomain, method])
}
model SlotHold {
id String @id @default(cuid())
shopDomain String
cartToken String
locationId String
method Method
slotStart DateTime
resources Json // {resourceId: qty}
expiresAt DateTime
@@index([shopDomain, slotStart])
@@index([expiresAt])
}
model Booking {
id String @id @default(cuid())
shopDomain String
orderId String @unique
orderName String?
locationId String
method Method
slotStart DateTime
slotEnd DateTime
status String @default("confirmed") // confirmed|rescheduled|cancelled|fulfilled|no_show
resources Json
customerEmail String?
customerPhone String?
address Json?
reschedules Reschedule[]
createdAt DateTime @default(now())
@@index([shopDomain, slotStart])
@@index([shopDomain, status])
}
model Reschedule {
id String @id @default(cuid())
bookingId String
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
fromSlot DateTime
toSlot DateTime
reason String?
createdAt DateTime @default(now())
}
model Waitlist {
id String @id @default(cuid())
shopDomain String
locationId String
method Method
slotStart DateTime
customerEmail String
status String @default("waiting") // waiting|promoted|expired
createdAt DateTime @default(now())
@@index([shopDomain, slotStart])
}
model Notification {
id String @id @default(cuid())
shopDomain String
bookingId String?
channel String // email|sms
type String // reminder|eta|reschedule|waitlist
sendAt DateTime
status String @default("pending")
payload Json
@@index([shopDomain, sendAt, status])
}
(Deposit and RecurringBooking models are added in v2 — see strategy §3.3.)
5. Shopify configuration
5.1 Scopes (shopify.app.toml)
Request the minimum that covers the feature set (mirror DS but no more):
read_products, read_customers, read_orders / write_orders (draft orders + order metafields), read_locations, write_metaobjects/read_metaobjects, write_cart_transforms, write_delivery_customizations, write_payment_customizations, plus the Functions' own access. Add read_markets/read_locales for i18n. Do not request write_customers or discounts unless a feature actually needs it — fewer scopes = faster review + more merchant trust.
5.2 Webhooks (mandatory + functional)
app/uninstalled→ wipe shop data (or soft-delete + purge job)orders/create→ create Booking, consume capacity, write-back metafield, release holdorders/updated/orders/cancelled→ sync booking status, free capacitycustomers/data_request,customers/redact,shop/redact→ GDPR, required for BfS
5.3 App Proxy
Expose /apps/scheduling/availability and /apps/scheduling/hold via app proxy so the storefront widget can call the backend from the theme without CORS pain.
5.4 Functions — behavior contracts
- validation-slot (Cart & Checkout Validation): reject checkout if cart has schedulable items but no valid slot attribute, or if the slot's capacity snapshot shows it's full/blacked-out/past cutoff. This is the all-plan enforcement.
- delivery-customization: rename/reorder/hide delivery options to match chosen method; surface zone rate.
- payment-customization: hide payment methods incompatible with the method (e.g. no COD on shipping).
- cart-transform: add a deposit line or delivery fee where applicable (v2 for deposits).
6. Phased build order
Each phase: branch phase-N-..., ship working software, meet acceptance criteria, then merge.
Phase 0 — Scaffold & CI
- Init from
shopify-app-template-remix; TypeScript strict; ESLint/Prettier. - Prisma + Postgres/SQLite; Redis + BullMQ skeleton; Vitest + Playwright configured.
shopify.app.tomlwith scopes, app proxy, GDPR +app/uninstalledwebhooks wired (handlers can be stubs).- Dev store install works; embedded app loads with a Polaris shell.
- Accept:
shopify app devruns, app installs, blank dashboard renders, CI runs lint+tests green.
Phase 1 — Core data model & admin CRUD
- Implement Shop, Location, SlotTemplate, SlotOverride, BlackoutDate, Method in Prisma.
- Polaris CRUD screens: create/edit locations (address + geocode via
zones.serverstub), weekly slot template editor, blackout calendar. - Vertical templates service: "Bakery/Florist/Grocer" one-click preset seeds locations+slots+copy.
- Accept: merchant can fully configure a single location's weekly slots + blackouts in the admin; data persists; a template seeds a working config in one click. Unit tests for template seeding.
Phase 2 — Scheduling engine (the heart)
lib/time.ts: Luxon-based, tz/DST-safe helpers. 100% unit-tested (DST spring-forward/fall-back cases included).services/scheduling.server.ts: pure functiongetAvailability(shop, method, location, dateRange, cart)→ applies templates, overrides, blackouts, cutoffs, lead-times, product rules. No DB or Shopify calls inside the pure core (inject data).services/capacity.server.ts: compute remaining capacity per slot from Bookings + Holds + resources.- Accept: given a fixture config, availability output is correct across timezones, DST boundaries, cutoffs, and lead-times. Full unit-test suite green. No UI yet.
Phase 3 — Storefront widget + cart attributes
- Theme App Extension (App Embed + cart block), Preact/vanilla TS, no CLS, accessible, i18n-ready.
- Calls app-proxy
/availability; renders method → date → slot; hides unavailable; writes selection to cart attributes. - Method-specific, unambiguous copy (pickup vs delivery — fix DS's complaint).
- Accept: on a dev store theme, shopper picks method/date/slot; unavailable dates hidden; selection saved to cart attributes and visible in cart. Lighthouse: no significant CLS, fast widget load.
Phase 4 — Enforcement (Functions) + slot-holds
holds.server.ts+ Redis: create hold on slot select (TTL, e.g. 10 min); BullMQ job expires/releases holds.- validation-slot Function: block checkout when slot missing/invalid/full. Deploy and test on a non-Plus dev store to prove all-plan enforcement.
- delivery-customization + payment-customization Functions.
orders/createwebhook → Booking creation, capacity consumption, order metafield write-back, hold release.- Accept: on a non-Plus store, checkout is blocked without a valid slot; two concurrent carts cannot book the last unit of capacity (hold race test); completed order shows the slot on the order record. Function unit tests via
function-runner.
Phase 5 — Multi-location, zones, rates, auto-assignment
- Zone model +
zones.server: geocode address, radius/driving-distance eligibility, delivery-density thresholds, nearest-location auto-assign, inventory-based exclusion (query product stock per location). - Rate model → surfaced through delivery-customization Function.
- Google Maps display of pickup locations in the widget.
- Accept: delivery eligibility correctly gates by zone/distance; order auto-assigns to nearest valid, in-stock location; rates vary by zone/method at checkout.
Phase 6 — Ops/dispatch dashboard
app.dashboard.tsx: bookings by day/slot/location, capacity-utilization heatmap, revenue by method, upcoming-fulfillment list; filters + CSV export.- Booking status transitions (confirmed→fulfilled/no_show).
- Accept: merchant sees an accurate, filterable schedule of all bookings and can export a day's dispatch list.
Phase 7 — POS + Checkout UI extensions
- POS UI Extension: staff run the same availability/hold/booking flow against the same pool.
- Checkout UI Extension: native picker on Plus; Thank-you + Order-status confirmation blocks on all plans.
- Accept: a POS order consumes the same capacity as online (no double-book); Plus checkout shows native picker; all plans show confirmed slot on thank-you/order-status.
Phase 8 — Billing & Built-for-Shopify hardening
- Billing API: Free/Starter/Growth/Pro tiers (strategy §6) with feature gating.
- Performance budget, accessibility pass, empty/loading/error states, onboarding checklist, help docs.
- Verify all GDPR webhooks; data purge on uninstall.
- Accept: tier upgrade/downgrade works and gates features; app passes an internal BfS checklist review; GDPR flows verified.
Phase 9 — Fast-follow (v1.x) — separate milestones
Waitlists, self-service reschedule portal (order-status + magic link), email→SMS reminders (BullMQ + Notification model), printable run sheets/picking lists, holiday auto-import, headless/Storefront-API support.
Phase 10 — v2
Predictive prep-time (AI on order history), deposits (Cart Transform + draft orders), recurring/subscription slots, merchant REST/GraphQL API + webhooks, group/multi-drop catering orders.
7. CLAUDE.md (place at repo root)
Create this so future Claude Code sessions have standing context:
# Delivery Date & Time — Shopify App
Scheduling app for Shipping / Local Delivery / Store Pickup with date-time slots,
capacity intelligence, and all-plan checkout enforcement. See PRODUCT_STRATEGY.md
(why) and IMPLEMENTATION_PLAN.md (how). Build in the phases defined there.
## Stack
Remix + Polaris + App Bridge · Prisma/Postgres · Redis/BullMQ · GraphQL Admin API ·
Theme App Extension (Preact) · Checkout UI Extensions (React) · Shopify Functions (Rust) ·
POS UI Extension · TypeScript strict · Vitest + Playwright.
## Non-negotiables
- Enforcement is server-side (Validation Function), never client-only.
- All date math is timezone/DST-safe (Luxon) and unit-tested before UI.
- One Scheduling Service + one capacity pool feeds every surface (web/checkout/POS).
- Multi-tenant: every query scoped by shopDomain.
- Minimal OAuth scopes. Secrets in .env, never committed.
- Each phase ships tested, working software; don't skip acceptance criteria.
## Commands
- `shopify app dev` — local dev against a dev store
- `npm test` — unit tests · `npm run test:e2e` — Playwright
- `npx prisma migrate dev` — DB migrations
- `shopify app deploy` — deploy extensions/functions
## Conventions
Conventional commits · feature branch per phase · Polaris components only in admin ·
no inline secrets · pure functions in services/*.server.ts core (inject data, no I/O in the math).
8. Testing & acceptance strategy
- Unit (Vitest): time/DST helpers, availability engine, capacity math, zone eligibility, template seeding, rate resolution. These are pure — test exhaustively with fixtures.
- Function tests:
function-runnerwith recorded input JSON for validation/delivery/payment functions, including the "no slot," "slot now full," and "past cutoff" rejection cases. - Integration: webhook → booking creation → metafield write-back; hold create → expire → release.
- E2E (Playwright): admin CRUD flows; storefront pick-a-slot happy path on a dev theme.
- Concurrency test (critical): two simultaneous checkouts for the last capacity unit — exactly one succeeds. This is the slot-hold moat; it must have a dedicated test.
- Manual matrix: verify enforcement on a non-Plus dev store (the whole point) and native checkout on a Plus dev store.
9. Risks & watch-items
- Checkout extensibility limits (non-Plus): the picker cannot live in the core checkout steps on non-Plus — that is expected. Enforcement comes from the Validation Function + cart-attribute selection in the theme, not from a checkout-step UI. Do not architect around putting the picker inside non-Plus checkout.
- Aug 26 2026 script deprecation: non-Plus stores are fully on the extensibility model; there is no
checkout.liquid/ Additional Scripts fallback. Build to extensions + Functions from day one (we already do). - Function latency budget: Validation Functions run on the hot checkout path — keep them fast and side-effect-free; the capacity snapshot they read must be cheap (denormalize into a metafield the Function can read directly).
- Geocoding cost/quota: cache geocode results per address; don't call the maps API on every availability request.
- Timezone bugs are the #1 source of scheduling failures — treat
lib/time.tsas safety-critical. - BfS review: minimal scopes, GDPR webhooks, performance, and accessibility are gating. Bake them in, don't retrofit.