# Implementation Plan — Delivery Date & Time Shopify App > **Audience:** Claude Code (or any engineer) building this app from an empty repo. > **Read `PRODUCT_STRATEGY.md` first** 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):** 1. Widget calls Scheduling Service → gets available dates/slots for method+location+cart. 2. Shopper picks slot → widget writes selection to **cart attributes** and requests a **SlotHold** (TTL). 3. At checkout, **Validation Function** reads cart attributes, re-checks the hold/availability via a metafield-backed snapshot → blocks completion if invalid. 4. On `orders/create` webhook → 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). ```prisma 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 hold - `orders/updated` / `orders/cancelled` → sync booking status, free capacity - `customers/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.toml` with scopes, app proxy, GDPR + `app/uninstalled` webhooks wired (handlers can be stubs). - Dev store install works; embedded app loads with a Polaris shell. - **Accept:** `shopify app dev` runs, 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.server` stub), 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 function `getAvailability(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/create` webhook → 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: ```markdown # 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-runner` with 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.ts` as safety-critical. - **BfS review:** minimal scopes, GDPR webhooks, performance, and accessibility are gating. Bake them in, don't retrofit.