feat: Phase 1 — core data model & admin CRUD

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>
This commit is contained in:
metatroncubeswdev 2026-08-23 17:47:59 -04:00
parent 0303eba07a
commit 21f0ee704b
15 changed files with 1309 additions and 39 deletions

View File

@ -6,11 +6,13 @@ SCOPES=read_products,read_customers,read_orders,write_orders,read_locations,read
SHOPIFY_APP_URL=https://replace-with-your-tunnel-url.example.com
SHOP_CUSTOM_DOMAIN=
# App DB (Prisma). SQLite locally, Postgres in staging/prod.
DATABASE_URL="file:dev.sqlite"
# App DB (Prisma/Postgres — same engine dev through prod so enum/array
# schema features behave identically everywhere). `docker compose up -d`
# starts a local instance on the port below.
DATABASE_URL="postgresql://app:app@localhost:5433/delivery_datetime_dev"
# Redis (slot-holds TTL, BullMQ queues).
REDIS_URL=redis://127.0.0.1:6379
# Redis (slot-holds TTL, BullMQ queues). `docker compose up -d` starts this too.
REDIS_URL=redis://127.0.0.1:6380
# Google Maps (Phase 5 — geocoding, radius/distance eligibility, map display).
GOOGLE_MAPS_API_KEY=

View File

@ -1,15 +1,24 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { Page, Layout, Text, Card, BlockStack, EmptyState } from "@shopify/polaris";
import { useLoaderData } from "@remix-run/react";
import { Page, Layout, Text, Card, BlockStack, EmptyState, InlineStack, Badge } from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
const { session } = await authenticate.admin(request);
return null;
const locations = await db.location.findMany({
where: { shopDomain: session.shop },
include: { _count: { select: { slotTemplates: true } } },
});
return { locations };
};
export default function Index() {
const { locations } = useLoaderData<typeof loader>();
return (
<Page>
<TitleBar title="Delivery Date & Time" />
@ -17,16 +26,30 @@ export default function Index() {
<Layout>
<Layout.Section>
<Card>
<EmptyState
heading="No locations configured yet"
action={{ content: "Add a location", url: "/app/locations" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p" variant="bodyMd">
Set up a fulfillment location and its weekly slots to start
taking scheduled Shipping, Local Delivery, or Pickup orders.
</Text>
</EmptyState>
{locations.length === 0 ? (
<EmptyState
heading="No locations configured yet"
action={{ content: "Add a location", url: "/app/locations" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p" variant="bodyMd">
Set up a fulfillment location and its weekly slots to start
taking scheduled Shipping, Local Delivery, or Pickup orders.
</Text>
</EmptyState>
) : (
<BlockStack gap="300">
<Text as="h2" variant="headingMd">
{locations.length} location{locations.length === 1 ? "" : "s"} configured
</Text>
{locations.map((location) => (
<InlineStack key={location.id} align="space-between">
<Text as="span">{location.name}</Text>
<Badge>{`${location._count.slotTemplates} slot templates`}</Badge>
</InlineStack>
))}
</BlockStack>
)}
</Card>
</Layout.Section>
</Layout>

View File

@ -0,0 +1,170 @@
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation } from "@remix-run/react";
import {
Page,
Card,
BlockStack,
InlineStack,
Text,
Button,
Select,
TextField,
IndexTable,
EmptyState,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const [locations, blackouts] = await Promise.all([
db.location.findMany({ where: { shopDomain: session.shop }, orderBy: { createdAt: "asc" } }),
db.blackoutDate.findMany({
where: { shopDomain: session.shop },
include: { location: true },
orderBy: { date: "asc" },
}),
]);
return { locations, blackouts };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
const id = String(formData.get("id") || "");
await db.blackoutDate.deleteMany({ where: { id, shopDomain: session.shop } });
return data({ ok: true });
}
const date = String(formData.get("date") || "");
const locationId = String(formData.get("locationId") || "") || null;
const method = (String(formData.get("method") || "") || null) as Method | null;
const reason = String(formData.get("reason") || "").trim() || null;
if (!date) {
return data({ errors: { date: "Date is required" } });
}
await db.blackoutDate.create({
data: {
shopDomain: session.shop,
date: new Date(`${date}T00:00:00.000Z`),
locationId,
method,
reason,
},
});
return data({ ok: true });
};
export default function BlackoutsIndex() {
const { locations, blackouts } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (locations.length === 0) {
return (
<Page>
<TitleBar title="Blackout dates" />
<Card>
<EmptyState
heading="Add a location first"
action={{ content: "Add location", url: "/app/locations/new" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p">Blackout dates can be scoped to one location, or left blank for all locations.</Text>
</EmptyState>
</Card>
</Page>
);
}
return (
<Page>
<TitleBar title="Blackout dates" />
<BlockStack gap="400">
<Card padding="0">
{blackouts.length === 0 ? (
<div style={{ padding: 16 }}>
<Text as="p" tone="subdued">
No blackout dates yet.
</Text>
</div>
) : (
<IndexTable
itemCount={blackouts.length}
headings={[
{ title: "Date" },
{ title: "Location" },
{ title: "Method" },
{ title: "Reason" },
{ title: "" },
]}
selectable={false}
>
{blackouts.map((b, index) => (
<IndexTable.Row id={b.id} key={b.id} position={index}>
<IndexTable.Cell>{b.date.slice(0, 10)}</IndexTable.Cell>
<IndexTable.Cell>{b.location?.name ?? "All locations"}</IndexTable.Cell>
<IndexTable.Cell>{b.method?.replace("_", " ") ?? "All methods"}</IndexTable.Cell>
<IndexTable.Cell>{b.reason ?? "—"}</IndexTable.Cell>
<IndexTable.Cell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={b.id} />
<Button submit variant="plain" tone="critical">
Remove
</Button>
</Form>
</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
)}
</Card>
<Card>
<Form method="post">
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Add a blackout date
</Text>
<InlineStack gap="300" wrap>
<TextField label="Date" name="date" type="date" autoComplete="off" />
<Select
label="Location"
name="locationId"
options={[{ label: "All locations", value: "" }, ...locations.map((l) => ({ label: l.name, value: l.id }))]}
/>
<Select
label="Method"
name="method"
options={[
{ label: "All methods", value: "" },
...METHODS.map((m) => ({ label: m.replace("_", " "), value: m })),
]}
/>
<TextField label="Reason (optional)" name="reason" autoComplete="off" />
</InlineStack>
<div>
<Button submit variant="primary" loading={isSubmitting}>
Add blackout date
</Button>
</div>
</BlockStack>
</Form>
</Card>
</BlockStack>
</Page>
);
}

View File

@ -0,0 +1,141 @@
import { useState } from "react";
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import {
Page,
Card,
BlockStack,
FormLayout,
TextField,
Button,
InlineStack,
Checkbox,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const location = await db.location.findFirst({
where: { id: params.id, shopDomain: session.shop },
});
if (!location) {
throw new Response("Not found", { status: 404 });
}
return { location };
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
await db.location.deleteMany({ where: { id: params.id, shopDomain: session.shop } });
return redirect("/app/locations");
}
const name = String(formData.get("name") || "").trim();
const address = String(formData.get("address") || "").trim();
const timezone = String(formData.get("timezone") || "").trim();
const active = formData.get("active") === "true";
const errors: Record<string, string> = {};
if (!name) errors.name = "Name is required";
if (!timezone) errors.timezone = "Timezone is required";
if (Object.keys(errors).length > 0) {
return { errors };
}
await db.location.updateMany({
where: { id: params.id, shopDomain: session.shop },
data: { name, address, timezone, active },
});
return { errors };
};
export default function EditLocation() {
const { location } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Page backAction={{ url: "/app/locations" }}>
<TitleBar title={location.name} />
<LocationForm key={location.id} location={location} errors={actionData?.errors} isSubmitting={isSubmitting} />
</Page>
);
}
function LocationForm({
location,
errors,
isSubmitting,
}: {
location: { id: string; name: string; address: string; timezone: string; active: boolean };
errors?: Record<string, string>;
isSubmitting: boolean;
}) {
const [name, setName] = useState(location.name);
const [address, setAddress] = useState(location.address);
const [timezone, setTimezone] = useState(location.timezone);
const [active, setActive] = useState(location.active);
return (
<BlockStack gap="400">
<Card>
<Form method="post">
<FormLayout>
<TextField
label="Location name"
name="name"
autoComplete="off"
value={name}
onChange={setName}
error={errors?.name}
requiredIndicator
/>
<TextField label="Address" name="address" autoComplete="off" multiline={2} value={address} onChange={setAddress} />
<TextField
label="Timezone (IANA)"
name="timezone"
autoComplete="off"
value={timezone}
onChange={setTimezone}
error={errors?.timezone}
requiredIndicator
/>
<Checkbox label="Active" name="active" value="true" checked={active} onChange={setActive} />
<InlineStack gap="200">
<Button submit variant="primary" loading={isSubmitting}>
Save
</Button>
<Button url={`/app/slots?locationId=${location.id}`}>Manage weekly slots</Button>
<Button url={`/app/blackouts?locationId=${location.id}`}>Manage blackout dates</Button>
</InlineStack>
</FormLayout>
</Form>
</Card>
<Card>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<InlineStack align="space-between" blockAlign="center">
<BlockStack gap="050">
<strong>Delete this location</strong>
<span>Removes all its slot templates and blackout dates.</span>
</BlockStack>
<Button submit tone="critical" variant="secondary">
Delete location
</Button>
</InlineStack>
</Form>
</Card>
</BlockStack>
);
}

View File

@ -0,0 +1,152 @@
import { useState } from "react";
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation } from "@remix-run/react";
import {
Page,
Layout,
Card,
BlockStack,
InlineStack,
Text,
Button,
ButtonGroup,
ResourceList,
ResourceItem,
Badge,
EmptyState,
Select,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { listVerticalTemplates, seedVerticalTemplate, type VerticalKey } from "../services/templates.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const locations = await db.location.findMany({
where: { shopDomain: session.shop },
include: { _count: { select: { slotTemplates: true, blackouts: true } } },
orderBy: { createdAt: "asc" },
});
return { locations, verticals: listVerticalTemplates() };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "seed-template") {
const vertical = formData.get("vertical") as VerticalKey;
await seedVerticalTemplate(session.shop, vertical);
return data({ ok: true });
}
if (intent === "delete-location") {
const id = formData.get("id") as string;
await db.location.deleteMany({ where: { id, shopDomain: session.shop } });
return data({ ok: true });
}
return data({ ok: false }, { status: 400 });
};
export default function LocationsIndex() {
const { locations, verticals } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const [vertical, setVertical] = useState<string>(verticals[0]);
return (
<Page>
<TitleBar title="Locations" />
<BlockStack gap="500">
<Layout>
<Layout.Section>
{locations.length === 0 ? (
<Card>
<EmptyState
heading="Set up your first location"
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<BlockStack gap="400">
<Text as="p" variant="bodyMd">
Start from a vertical template it seeds a location with a
realistic weekly Pickup/Delivery schedule you can then edit,
or add a location from scratch.
</Text>
<InlineStack gap="300" blockAlign="center">
<div style={{ minWidth: 180 }}>
<Select
label="Vertical"
labelHidden
options={verticals.map((v) => ({ label: capitalize(v), value: v }))}
value={vertical}
onChange={setVertical}
/>
</div>
<Form method="post">
<input type="hidden" name="intent" value="seed-template" />
<input type="hidden" name="vertical" value={vertical} />
<Button submit variant="primary" loading={isSubmitting}>
Seed {capitalize(vertical)} template
</Button>
</Form>
<Button url="/app/locations/new">Add location manually</Button>
</InlineStack>
</BlockStack>
</EmptyState>
</Card>
) : (
<Card padding="0">
<ResourceList
items={locations}
resourceName={{ singular: "location", plural: "locations" }}
renderItem={(location) => (
<ResourceItem
id={location.id}
url={`/app/locations/${location.id}`}
accessibilityLabel={`View ${location.name}`}
>
<InlineStack align="space-between" blockAlign="center">
<BlockStack gap="100">
<Text as="h3" variant="bodyMd" fontWeight="bold">
{location.name}
</Text>
<Text as="span" variant="bodySm" tone="subdued">
{location.address || "No address set"} · {location.timezone}
</Text>
</BlockStack>
<InlineStack gap="200" blockAlign="center">
<Badge>{`${location._count.slotTemplates} slot templates`}</Badge>
<Badge tone={location.active ? "success" : "critical"}>
{location.active ? "Active" : "Inactive"}
</Badge>
</InlineStack>
</InlineStack>
</ResourceItem>
)}
/>
</Card>
)}
</Layout.Section>
{locations.length > 0 && (
<Layout.Section>
<ButtonGroup>
<Button url="/app/locations/new" variant="primary">
Add location
</Button>
</ButtonGroup>
</Layout.Section>
)}
</Layout>
</BlockStack>
</Page>
);
}
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}

View File

@ -0,0 +1,70 @@
import { useState } from "react";
import { redirect, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { Page, Card, BlockStack, FormLayout, TextField, Button } from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const name = String(formData.get("name") || "").trim();
const address = String(formData.get("address") || "").trim();
const timezone = String(formData.get("timezone") || "").trim();
const errors: Record<string, string> = {};
if (!name) errors.name = "Name is required";
if (!timezone) errors.timezone = "Timezone is required";
if (Object.keys(errors).length > 0) {
return { errors };
}
const location = await db.location.create({
data: { shopDomain: session.shop, name, address, timezone },
});
return redirect(`/app/locations/${location.id}`);
};
export default function NewLocation() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone);
return (
<Page>
<TitleBar title="Add location" />
<Card>
<Form method="post">
<FormLayout>
<TextField
label="Location name"
name="name"
autoComplete="off"
error={actionData?.errors?.name}
requiredIndicator
/>
<TextField label="Address" name="address" autoComplete="off" multiline={2} />
<TextField
label="Timezone (IANA, e.g. America/Toronto)"
name="timezone"
autoComplete="off"
value={timezone}
onChange={setTimezone}
error={actionData?.errors?.timezone}
requiredIndicator
helpText="All slot math for this location is computed in this timezone."
/>
<BlockStack>
<Button submit variant="primary" loading={navigation.state === "submitting"}>
Create location
</Button>
</BlockStack>
</FormLayout>
</Form>
</Card>
</Page>
);
}

View File

@ -0,0 +1,268 @@
import { useState } from "react";
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation, useSearchParams } from "@remix-run/react";
import {
Page,
Card,
BlockStack,
InlineStack,
Text,
Button,
Select,
TextField,
IndexTable,
EmptyState,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
function minutesToTime(min: number): string {
const h = Math.floor(min / 60)
.toString()
.padStart(2, "0");
const m = (min % 60).toString().padStart(2, "0");
return `${h}:${m}`;
}
function timeToMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return h * 60 + m;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const url = new URL(request.url);
const locationId = url.searchParams.get("locationId");
const locations = await db.location.findMany({
where: { shopDomain: session.shop },
orderBy: { createdAt: "asc" },
});
const activeLocationId = locationId || locations[0]?.id || null;
const slotTemplates = activeLocationId
? await db.slotTemplate.findMany({
where: { shopDomain: session.shop, locationId: activeLocationId },
orderBy: [{ weekday: "asc" }, { startMin: "asc" }],
})
: [];
return { locations, activeLocationId, slotTemplates };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const intent = formData.get("intent");
const locationId = String(formData.get("locationId") || "");
if (intent === "delete") {
const id = String(formData.get("id") || "");
await db.slotTemplate.deleteMany({ where: { id, shopDomain: session.shop } });
return data({ ok: true });
}
const method = formData.get("method") as Method;
const weekday = Number(formData.get("weekday"));
const startMin = timeToMinutes(String(formData.get("startTime")));
const endMin = timeToMinutes(String(formData.get("endTime")));
const capacity = Number(formData.get("capacity"));
const cutoffMin = Number(formData.get("cutoffMin") || 0);
const leadTimeMin = Number(formData.get("leadTimeMin") || 0);
const errors: Record<string, string> = {};
if (!locationId) errors.locationId = "Choose a location";
if (startMin >= endMin) errors.endTime = "End time must be after start time";
if (!capacity || capacity < 1) errors.capacity = "Capacity must be at least 1";
if (Object.keys(errors).length > 0) {
return data({ errors });
}
await db.slotTemplate.create({
data: {
shopDomain: session.shop,
locationId,
method,
weekday,
startMin,
endMin,
capacity,
cutoffMin,
leadTimeMin,
},
});
return data({ ok: true });
};
export default function SlotsIndex() {
const { locations, activeLocationId, slotTemplates } = useLoaderData<typeof loader>();
const [, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (locations.length === 0) {
return (
<Page>
<TitleBar title="Weekly slots" />
<Card>
<EmptyState
heading="Add a location first"
action={{ content: "Add location", url: "/app/locations/new" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p">Weekly slot templates belong to a location.</Text>
</EmptyState>
</Card>
</Page>
);
}
return (
<Page>
<TitleBar title="Weekly slots" />
<BlockStack gap="400">
<Card>
<Select
label="Location"
options={locations.map((l) => ({ label: l.name, value: l.id }))}
value={activeLocationId ?? undefined}
onChange={(value) => setSearchParams({ locationId: value })}
/>
</Card>
<Card padding="0">
{slotTemplates.length === 0 ? (
<div style={{ padding: 16 }}>
<Text as="p" tone="subdued">
No slot templates yet for this location. Add one below.
</Text>
</div>
) : (
<IndexTable
itemCount={slotTemplates.length}
headings={[
{ title: "Weekday" },
{ title: "Method" },
{ title: "Window" },
{ title: "Capacity" },
{ title: "Cutoff (min)" },
{ title: "Lead time (min)" },
{ title: "" },
]}
selectable={false}
>
{slotTemplates.map((slot, index) => (
<IndexTable.Row id={slot.id} key={slot.id} position={index}>
<IndexTable.Cell>{WEEKDAY_NAMES[slot.weekday]}</IndexTable.Cell>
<IndexTable.Cell>{slot.method.replace("_", " ")}</IndexTable.Cell>
<IndexTable.Cell>
{minutesToTime(slot.startMin)}{minutesToTime(slot.endMin)}
</IndexTable.Cell>
<IndexTable.Cell>{slot.capacity}</IndexTable.Cell>
<IndexTable.Cell>{slot.cutoffMin}</IndexTable.Cell>
<IndexTable.Cell>{slot.leadTimeMin}</IndexTable.Cell>
<IndexTable.Cell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={slot.id} />
<Button submit variant="plain" tone="critical">
Remove
</Button>
</Form>
</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
)}
</Card>
<AddSlotForm key={activeLocationId} locationId={activeLocationId ?? ""} isSubmitting={isSubmitting} />
</BlockStack>
</Page>
);
}
function AddSlotForm({ locationId, isSubmitting }: { locationId: string; isSubmitting: boolean }) {
const [startTime, setStartTime] = useState("09:00");
const [endTime, setEndTime] = useState("17:00");
const [capacity, setCapacity] = useState("10");
const [cutoffMin, setCutoffMin] = useState("60");
const [leadTimeMin, setLeadTimeMin] = useState("0");
return (
<Card>
<Form method="post">
<input type="hidden" name="locationId" value={locationId} />
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Add a slot template
</Text>
<InlineStack gap="300" wrap>
<Select
label="Weekday"
name="weekday"
options={WEEKDAY_NAMES.map((name, value) => ({ label: name, value: String(value) }))}
/>
<Select
label="Method"
name="method"
options={METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))}
/>
<TextField
label="Start time"
name="startTime"
type="time"
value={startTime}
onChange={setStartTime}
autoComplete="off"
/>
<TextField
label="End time"
name="endTime"
type="time"
value={endTime}
onChange={setEndTime}
autoComplete="off"
/>
<TextField
label="Capacity"
name="capacity"
type="number"
value={capacity}
onChange={setCapacity}
autoComplete="off"
/>
<TextField
label="Cutoff (min)"
name="cutoffMin"
type="number"
value={cutoffMin}
onChange={setCutoffMin}
autoComplete="off"
/>
<TextField
label="Lead time (min)"
name="leadTimeMin"
type="number"
value={leadTimeMin}
onChange={setLeadTimeMin}
autoComplete="off"
/>
</InlineStack>
<div>
<Button submit variant="primary" loading={isSubmitting}>
Add slot template
</Button>
</div>
</BlockStack>
</Form>
</Card>
);
}

View File

@ -24,6 +24,9 @@ export default function App() {
<Link to="/app" rel="home">
Home
</Link>
<Link to="/app/locations">Locations</Link>
<Link to="/app/slots">Weekly slots</Link>
<Link to="/app/blackouts">Blackout dates</Link>
</NavMenu>
<Outlet />
</AppProvider>

View File

@ -0,0 +1,186 @@
import type { Method } from "@prisma/client";
import db from "../db.server";
export type VerticalKey = "bakery" | "florist" | "grocer";
export interface SlotTemplateInput {
method: Method;
weekday: number; // 0 = Sunday .. 6 = Saturday
startMin: number;
endMin: number;
capacity: number;
cutoffMin: number;
leadTimeMin: number;
}
export interface VerticalTemplate {
vertical: VerticalKey;
location: {
name: string;
timezone: string;
};
slotTemplates: SlotTemplateInput[];
widgetCopy: {
pickupLabel: string;
deliveryLabel: string;
shippingLabel: string;
};
}
const HOUR = 60;
// Tue-Sat, pickup + local delivery. Bakers need same-day cutoff buffer for
// remaining-batch prep (strategy §3.2 resource capacity), hence the shorter
// weekday window and a same-day cutoff.
function bakeryTemplate(): VerticalTemplate {
const weekdays = [2, 3, 4, 5, 6]; // Tue-Sat
return {
vertical: "bakery",
location: { name: "Main Bakery", timezone: "America/Toronto" },
slotTemplates: weekdays.flatMap((weekday) => [
{
method: "PICKUP" as Method,
weekday,
startMin: 9 * HOUR,
endMin: 17 * HOUR,
capacity: 20,
cutoffMin: 2 * HOUR,
leadTimeMin: 24 * HOUR,
},
{
method: "LOCAL_DELIVERY" as Method,
weekday,
startMin: 11 * HOUR,
endMin: 16 * HOUR,
capacity: 10,
cutoffMin: 3 * HOUR,
leadTimeMin: 24 * HOUR,
},
]),
widgetCopy: {
pickupLabel: "Pickup date",
deliveryLabel: "Delivery date",
shippingLabel: "Shipping date",
},
};
}
// Mon-Sat, pickup + local delivery. Shorter lead time than bakery (florists
// typically make up arrangements same-day) but tighter per-slot capacity
// (single arranger/driver bottleneck).
function floristTemplate(): VerticalTemplate {
const weekdays = [1, 2, 3, 4, 5, 6]; // Mon-Sat
return {
vertical: "florist",
location: { name: "Main Shop", timezone: "America/Toronto" },
slotTemplates: weekdays.flatMap((weekday) => [
{
method: "PICKUP" as Method,
weekday,
startMin: 9 * HOUR,
endMin: 18 * HOUR,
capacity: 15,
cutoffMin: HOUR,
leadTimeMin: 4 * HOUR,
},
{
method: "LOCAL_DELIVERY" as Method,
weekday,
startMin: 10 * HOUR,
endMin: 17 * HOUR,
capacity: 8,
cutoffMin: 2 * HOUR,
leadTimeMin: 4 * HOUR,
},
]),
widgetCopy: {
pickupLabel: "Pickup date",
deliveryLabel: "Delivery date",
shippingLabel: "Shipping date",
},
};
}
// Every day, delivery + pickup + shipping, longer hours and higher
// per-slot capacity (route-based, strategy §3.2 delivery-density).
function grocerTemplate(): VerticalTemplate {
const weekdays = [0, 1, 2, 3, 4, 5, 6]; // every day
return {
vertical: "grocer",
location: { name: "Main Store", timezone: "America/Toronto" },
slotTemplates: weekdays.flatMap((weekday) => [
{
method: "LOCAL_DELIVERY" as Method,
weekday,
startMin: 8 * HOUR,
endMin: 20 * HOUR,
capacity: 40,
cutoffMin: 2 * HOUR,
leadTimeMin: 2 * HOUR,
},
{
method: "PICKUP" as Method,
weekday,
startMin: 8 * HOUR,
endMin: 20 * HOUR,
capacity: 60,
cutoffMin: HOUR,
leadTimeMin: HOUR,
},
]),
widgetCopy: {
pickupLabel: "Pickup date",
deliveryLabel: "Delivery date",
shippingLabel: "Shipping date",
},
};
}
const TEMPLATES: Record<VerticalKey, () => VerticalTemplate> = {
bakery: bakeryTemplate,
florist: floristTemplate,
grocer: grocerTemplate,
};
export function getVerticalTemplate(vertical: VerticalKey): VerticalTemplate {
return TEMPLATES[vertical]();
}
export function listVerticalTemplates(): VerticalKey[] {
return Object.keys(TEMPLATES) as VerticalKey[];
}
// I/O wrapper: applies a vertical's preset by creating a Location + its
// SlotTemplates and merging widgetCopy into Shop.settings, all in one
// transaction. Keeps the actual preset shape (above) pure and unit-testable.
export async function seedVerticalTemplate(shopDomain: string, vertical: VerticalKey) {
const template = getVerticalTemplate(vertical);
return db.$transaction(async (tx) => {
// Only seed widgetCopy for a brand-new shop — never clobber a merchant's
// existing customized settings by re-running/switching templates.
await tx.shop.upsert({
where: { shopDomain },
create: { shopDomain, settings: { widgetCopy: template.widgetCopy } },
update: {},
});
const location = await tx.location.create({
data: {
shopDomain,
name: template.location.name,
address: "",
timezone: template.location.timezone,
slotTemplates: {
create: template.slotTemplates.map((slot) => ({
shopDomain,
...slot,
})),
},
},
include: { slotTemplates: true },
});
return location;
});
}

23
docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
name: delivery-datetime-app
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: delivery_datetime_dev
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "6380:6379"
volumes:
postgres-data:

View File

@ -1,20 +0,0 @@
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"shop" TEXT NOT NULL,
"state" TEXT NOT NULL,
"isOnline" BOOLEAN NOT NULL DEFAULT false,
"scope" TEXT,
"expires" DATETIME,
"accessToken" TEXT NOT NULL,
"userId" BIGINT,
"firstName" TEXT,
"lastName" TEXT,
"email" TEXT,
"accountOwner" BOOLEAN NOT NULL DEFAULT false,
"locale" TEXT,
"collaborator" BOOLEAN DEFAULT false,
"emailVerified" BOOLEAN DEFAULT false,
"refreshToken" TEXT,
"refreshTokenExpires" DATETIME
);

View File

@ -0,0 +1,124 @@
-- CreateEnum
CREATE TYPE "Method" AS ENUM ('SHIPPING', 'LOCAL_DELIVERY', 'PICKUP');
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"shop" TEXT NOT NULL,
"state" TEXT NOT NULL,
"isOnline" BOOLEAN NOT NULL DEFAULT false,
"scope" TEXT,
"expires" TIMESTAMP(3),
"accessToken" TEXT NOT NULL,
"userId" BIGINT,
"firstName" TEXT,
"lastName" TEXT,
"email" TEXT,
"accountOwner" BOOLEAN NOT NULL DEFAULT false,
"locale" TEXT,
"collaborator" BOOLEAN DEFAULT false,
"emailVerified" BOOLEAN DEFAULT false,
"refreshToken" TEXT,
"refreshTokenExpires" TIMESTAMP(3),
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Shop" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"plan" TEXT NOT NULL DEFAULT 'basic',
"tier" TEXT NOT NULL DEFAULT 'free',
"timezone" TEXT NOT NULL DEFAULT 'UTC',
"settings" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Shop_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Location" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT NOT NULL,
"lat" DOUBLE PRECISION,
"lng" DOUBLE PRECISION,
"timezone" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SlotTemplate" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"method" "Method" NOT NULL,
"weekday" INTEGER NOT NULL,
"startMin" INTEGER NOT NULL,
"endMin" INTEGER NOT NULL,
"capacity" INTEGER NOT NULL,
"cutoffMin" INTEGER,
"leadTimeMin" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SlotTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SlotOverride" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"method" "Method" NOT NULL,
"closed" BOOLEAN NOT NULL DEFAULT false,
"startMin" INTEGER,
"endMin" INTEGER,
"capacity" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SlotOverride_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BlackoutDate" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"locationId" TEXT,
"method" "Method",
"date" TIMESTAMP(3) NOT NULL,
"reason" TEXT,
"source" TEXT NOT NULL DEFAULT 'manual',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BlackoutDate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Shop_shopDomain_key" ON "Shop"("shopDomain");
-- CreateIndex
CREATE INDEX "Location_shopDomain_idx" ON "Location"("shopDomain");
-- CreateIndex
CREATE INDEX "SlotTemplate_shopDomain_locationId_method_weekday_idx" ON "SlotTemplate"("shopDomain", "locationId", "method", "weekday");
-- CreateIndex
CREATE INDEX "SlotOverride_shopDomain_locationId_date_idx" ON "SlotOverride"("shopDomain", "locationId", "date");
-- CreateIndex
CREATE INDEX "BlackoutDate_shopDomain_date_idx" ON "BlackoutDate"("shopDomain", "date");
-- AddForeignKey
ALTER TABLE "SlotTemplate" ADD CONSTRAINT "SlotTemplate_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SlotOverride" ADD CONSTRAINT "SlotOverride_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BlackoutDate" ADD CONSTRAINT "BlackoutDate_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -9,8 +9,8 @@ generator client {
// enough when changing adapters.
// See https://www.prisma.io/docs/orm/reference/prisma-schema-reference#string for more information
datasource db {
provider = "sqlite"
url = "file:dev.sqlite"
provider = "postgresql"
url = env("DATABASE_URL")
}
model Session {
@ -32,3 +32,88 @@ model Session {
refreshToken String?
refreshTokenExpires DateTime?
}
// --- Scheduling domain (IMPLEMENTATION_PLAN.md §4) -------------------------
// Every model below is scoped by shopDomain (multi-tenant, per CLAUDE.md).
// Built incrementally per phase; see the plan for models not yet added
// (CapacityResource, Zone, ProductRule, Rate, SlotHold, Booking, ...).
enum Method {
SHIPPING
LOCAL_DELIVERY
PICKUP
}
model Shop {
id String @id @default(cuid())
shopDomain String @unique
plan String @default("basic") // basic|shopify|advanced|plus
tier String @default("free") // free|starter|growth|pro
timezone String @default("UTC")
settings Json @default("{}") // widget copy, i18n, feature flags
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[]
createdAt DateTime @default(now())
@@index([shopDomain])
}
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 (0 = Sunday), location-local
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)
createdAt DateTime @default(now())
@@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?
createdAt DateTime @default(now())
@@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
createdAt DateTime @default(now())
@@index([shopDomain, date])
}

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { getVerticalTemplate, listVerticalTemplates } from "../../app/services/templates.server";
describe("getVerticalTemplate", () => {
it("lists exactly the three Phase 1 vertical presets", () => {
expect(listVerticalTemplates().sort()).toEqual(["bakery", "florist", "grocer"]);
});
it.each(listVerticalTemplates())("%s: every slot has a valid weekday and start < end", (vertical) => {
const template = getVerticalTemplate(vertical);
expect(template.slotTemplates.length).toBeGreaterThan(0);
for (const slot of template.slotTemplates) {
expect(slot.weekday).toBeGreaterThanOrEqual(0);
expect(slot.weekday).toBeLessThanOrEqual(6);
expect(slot.startMin).toBeGreaterThanOrEqual(0);
expect(slot.endMin).toBeLessThanOrEqual(24 * 60);
expect(slot.startMin).toBeLessThan(slot.endMin);
expect(slot.capacity).toBeGreaterThan(0);
expect(slot.cutoffMin).toBeGreaterThanOrEqual(0);
expect(slot.leadTimeMin).toBeGreaterThanOrEqual(0);
}
});
it("bakery covers Tue-Sat only (closed Sun/Mon)", () => {
const weekdays = new Set(getVerticalTemplate("bakery").slotTemplates.map((s) => s.weekday));
expect(weekdays.has(0)).toBe(false);
expect(weekdays.has(1)).toBe(false);
expect(weekdays.has(2)).toBe(true);
expect(weekdays.has(6)).toBe(true);
});
it("grocer is the only preset open every day of the week", () => {
const weekdays = new Set(getVerticalTemplate("grocer").slotTemplates.map((s) => s.weekday));
expect(weekdays.size).toBe(7);
});
it("is deterministic (calling twice yields an equivalent template)", () => {
expect(getVerticalTemplate("florist")).toEqual(getVerticalTemplate("florist"));
});
});