- Prisma: Booking.totalPriceCents (parsed from the order webhook's
total_price string), populated in booking.server.ts so "revenue by
method" is real data, not a placeholder.
- app/services/dashboard.server.ts: pure aggregation over injected booking
data (CLAUDE.md — no DB calls in the math) — bucketByLocalDate (each
booking grouped under its own location's local calendar day, not a
shared UTC day), revenueByMethod (confirmed/fulfilled only),
utilizationByDateLocation (booked vs. summed SlotTemplate capacity for
that weekday, capped at 100%), upcomingFulfillments, and a CSV
writer/formatter with proper quote-escaping.
- /app/dashboard: filterable (date range, location, method, status) view
with revenue-by-method cards, a capacity-utilization list (color-coded
by load), an upcoming-fulfillments table with one-click
confirmed->fulfilled/no_show status transitions, and a full by-day
booking list.
- /app/dashboard/export: a resource route (loader only, no component)
streaming the same filtered bookings as a downloadable CSV — kept
separate from the dashboard route specifically so it can import
dashboard.server.ts freely without the client-bundling constraint the
main route has to respect (see below).
Fixed the same class of server/client bundling bug from the Phase 5 commit
before it could ship, this time by construction: dashboard.server.ts's
aggregation functions are called only inside app.dashboard.tsx's `loader`,
never referenced by the default-exported component (which only reads
useLoaderData() output) — verified this holds by actually running
`npm run build`, not just tsc/vitest, which both stay silent about this
class of error. Also hit (and fixed) the same "loader Dates arrive as
strings on the client" issue from Phase 1: swapped DateTime.fromJSDate for
DateTime.fromISO in the two places the component formats a booking's
slotStart.
Verified: lint, typecheck, 102 unit tests (+16 new for dashboard.server.ts,
+2 new for totalPriceCents parsing), 18 integration tests, both builds, and
a live script exercising the full aggregation pipeline (revenue exclusion
of cancelled bookings, utilization math, CSV output) against the Postgres
container.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Prisma: Zone (postal-code list or radius), Rate (zone- or distance-band
keyed), GeocodeCache (permanent address->lat/lng cache per
IMPLEMENTATION_PLAN.md §9), Location.shopifyLocationId (maps to
Shopify's own Location resource for inventory checks), Booking.zoneId
(needed for per-zone delivery-density counts, not just per-location).
- app/lib/geo.ts: pure haversine distance + postal-code matching, unit
tested against known city-to-city distances.
- app/services/zones.server.ts: geocoding (Google Maps Geocoding API,
cached — never re-geocodes the same address twice), zone eligibility,
nearest-location auto-assignment ranked by distance, delivery-density
threshold checks (a sparse zone doesn't unlock until minOrders bookings
have already routed through it), and inventory-based location exclusion
via Shopify's InventoryLevel API (locations without a mapped
shopifyLocationId are left in rather than false-negative excluded).
- app/services/rates.server.ts: pure rate resolution by zone or distance
band, cheapest-match-wins when bands overlap.
- apps.scheduling.availability.tsx: LOCAL_DELIVERY requests with a
postalCode/address now auto-assign to the nearest eligible,
density-qualified zone/location instead of the shop's default location;
response includes the matched rate. Also fixed a real gap left over from
Phase 4: this route never actually read Booking counts into
getAvailability's `consumed` map, so capacity always showed as fully
available regardless of existing bookings — now it does.
- extensions/datetime-widget: LOCAL_DELIVERY now asks for a postal code
before showing dates; PICKUP shows a Google Maps pin for the location
(both gated on an optional Maps API key — a block setting in the theme
editor, since it needs to be public/client-side, not an app secret);
confirmation display and cart attributes (dd_zone_id, dd_rate_label)
carry the resolved zone/rate through to checkout.
- extensions/delivery-customization: now appends the resolved rate to the
relabeled delivery option ("Local delivery — Aug 25 ($5.99)") when one's
configured — real Cart Transform-based fee *charging* stays deferred to
v2 per IMPLEMENTATION_PLAN.md §5.4, this is display-only.
- Admin: /app/zones and /app/rates (Polaris CRUD, mirroring Phase 1's
patterns), plus shopifyLocationId and auto-geocode-on-save added to the
location edit form.
Fixed one real bug caught only by `npm run build` (not tsc/vitest, which
both passed clean): app.rates._index.tsx's component called
formatPriceLabel from rates.server.ts, and Remix correctly refuses to
bundle anything imported from a .server.ts path for the client. Moved the
pure (no I/O, no Prisma) formatter to app/lib/currency.ts.
Verified: lint, typecheck, 86 unit tests (+21 new: geo, zones, rates,
delivery-customization's rate-label case with a real WASM fixture run),
16 integration tests against live Postgres (+8 new: geocode caching,
postal/radius zone matching, nearest-first ranking, density thresholds),
both builds, and a live script exercising the full
zone-match -> density-check -> rate-resolve -> availability pipeline
together against the Postgres container.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
Adds the scheduling core to Prisma (Shop, Location, Method enum,
SlotTemplate, SlotOverride, BlackoutDate — IMPLEMENTATION_PLAN.md §4) and
Polaris admin screens to manage them:
- /app/locations: list/create/edit/delete locations, one-click vertical
template seeding (bakery/florist/grocer)
- /app/slots: weekly slot template editor, scoped per location
- /app/blackouts: blackout dates, scoped to one location or all of them
services/templates.server.ts keeps the vertical presets as a pure,
unit-tested function (getVerticalTemplate) separate from the thin I/O
wrapper (seedVerticalTemplate) that does the actual Prisma writes, per
CLAUDE.md's "pure functions, inject data, no I/O in the math" rule.
Switched dev DB from SQLite to Postgres (docker-compose.yml, ports 5433/6380
to avoid clashing with other local projects already on 5432/6379): SQLite
doesn't support Prisma enums at all, and IMPLEMENTATION_PLAN.md's schema
relies on them (Method) plus Postgres-only array fields in later phases
(Zone.postalCodes, ProductRule.allowedLocationIds). Only one throwaway
migration existed, so switching now avoids compounding the rework later.
Also fixed a Remix/Polaris integration issue hit while building these forms:
this Polaris version's TextField/Checkbox are fully controlled (no
defaultValue/defaultChecked), and `data()` imported from `@remix-run/react`
(rather than `@remix-run/node`) breaks useActionData's type inference —
both are now handled correctly across the new routes.
Verified: lint, typecheck, unit tests (incl. template-seeding fixtures),
build, and a live end-to-end run of seedVerticalTemplate against the
Postgres container all pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bootstraps from Shopify's official shopify-app-template-remix (cloned
directly rather than via `shopify app init`, which requires an interactive
Partner login unavailable in this session):
- Prisma (SQLite dev / Postgres-ready) with baseline Session model + migration
- Vitest configured for unit tests, Playwright configured for E2E
- Redis client + BullMQ worker skeleton (app/lib/redis.server.ts, jobs/worker.ts)
- shopify.app.toml: minimal scopes, GDPR + orders webhooks wired (stub handlers),
app proxy config for the future storefront widget
- Stripped template-repo-only meta files (CLA, issue templates, demo product
page) and replaced CI with a lint+typecheck+test workflow
- Bumped @shopify/shopify-app-session-storage-prisma to resolve a duplicate
@shopify/shopify-api install that broke typecheck
- Dropped the Jest-only ESLint config (template default) since the project
standardizes on Vitest per IMPLEMENTATION_PLAN.md
Verified: npm install, lint, typecheck, unit tests, prisma migrate dev, and
npm run build all pass on Node 22 LTS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>