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>
187 lines
5.0 KiB
TypeScript
187 lines
5.0 KiB
TypeScript
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;
|
|
});
|
|
}
|