feat: Phase 6 — ops/dispatch dashboard

- 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>
This commit is contained in:
metatroncubeswdev 2026-08-24 03:33:21 -04:00
parent 6598691372
commit c5ec8f368c
10 changed files with 771 additions and 2 deletions

View File

@ -53,9 +53,9 @@ public launch or Built-for-Shopify submission** — don't ship without it.
## Status ## Status
Phase 0 (scaffold & CI) through Phase 5 (zones, rates, auto-assignment) are Phase 0 (scaffold & CI) through Phase 6 (ops/dispatch dashboard) are
complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased build order and complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased build order and
acceptance criteria — next up is Phase 6 (ops/dispatch dashboard). acceptance criteria — next up is Phase 7 (POS + Checkout UI extensions).
Phase 5's Google Maps / geocoding features (pickup-location map in the Phase 5's Google Maps / geocoding features (pickup-location map in the
widget, radius-zone eligibility, address auto-geocoding on Save Location) widget, radius-zone eligibility, address auto-geocoding on Save Location)

View File

@ -0,0 +1,58 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { bookingsToCsv, type BookingSummary } from "../services/dashboard.server";
// Resource route (no default export/component) — safe to hit directly for
// a file download, and safe to import dashboard.server.ts freely since
// there's no client component here for Remix to worry about bundling it into.
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const url = new URL(request.url);
const locationIdFilter = url.searchParams.get("locationId") || undefined;
const methodFilter = (url.searchParams.get("method") as Method | null) || undefined;
const statusFilter = url.searchParams.get("status") || undefined;
const startDate = url.searchParams.get("startDate") || DateTime.now().toISODate()!;
const endDate = url.searchParams.get("endDate") || DateTime.now().plus({ days: 7 }).toISODate()!;
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate();
const bookings = await db.booking.findMany({
where: {
shopDomain: session.shop,
locationId: locationIdFilter,
method: methodFilter,
status: statusFilter,
slotStart: { gte: rangeStart, lt: rangeEnd },
},
include: { location: true },
orderBy: { slotStart: "asc" },
});
const summaries: BookingSummary[] = bookings.map((b) => ({
id: b.id,
orderName: b.orderName,
locationId: b.locationId,
locationName: b.location.name,
method: b.method,
slotStart: b.slotStart,
slotEnd: b.slotEnd,
timezone: b.location.timezone,
status: b.status,
totalPriceCents: b.totalPriceCents,
customerEmail: b.customerEmail,
}));
const csv = bookingsToCsv(summaries);
return new Response(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="dispatch-${startDate}-to-${endDate}.csv"`,
},
});
};

View File

@ -0,0 +1,341 @@
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,
Badge,
Box,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import {
bucketByLocalDate,
revenueByMethod,
upcomingFulfillments,
utilizationByDateLocation,
type BookingSummary,
} from "../services/dashboard.server";
import { formatPriceLabel } from "../lib/currency";
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
const STATUSES = ["confirmed", "fulfilled", "no_show", "cancelled"];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const url = new URL(request.url);
const locationIdFilter = url.searchParams.get("locationId") || undefined;
const methodFilter = (url.searchParams.get("method") as Method | null) || undefined;
const statusFilter = url.searchParams.get("status") || undefined;
const startDate = url.searchParams.get("startDate") || DateTime.now().toISODate()!;
const endDate = url.searchParams.get("endDate") || DateTime.now().plus({ days: 7 }).toISODate()!;
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate();
const [locations, bookings, slotTemplates] = await Promise.all([
db.location.findMany({ where: { shopDomain: session.shop }, orderBy: { createdAt: "asc" } }),
db.booking.findMany({
where: {
shopDomain: session.shop,
locationId: locationIdFilter,
method: methodFilter,
status: statusFilter,
slotStart: { gte: rangeStart, lt: rangeEnd },
},
include: { location: true },
orderBy: { slotStart: "asc" },
}),
db.slotTemplate.findMany({
where: { shopDomain: session.shop, locationId: locationIdFilter, method: methodFilter },
}),
]);
const summaries: BookingSummary[] = bookings.map((b) => ({
id: b.id,
orderName: b.orderName,
locationId: b.locationId,
locationName: b.location.name,
method: b.method,
slotStart: b.slotStart,
slotEnd: b.slotEnd,
timezone: b.location.timezone,
status: b.status,
totalPriceCents: b.totalPriceCents,
customerEmail: b.customerEmail,
}));
const byDate = bucketByLocalDate(summaries);
const revenue = revenueByMethod(summaries);
const utilization = utilizationByDateLocation(
summaries,
slotTemplates.map((t) => ({ locationId: t.locationId, method: t.method, weekday: t.weekday, capacity: t.capacity })),
);
const upcoming = upcomingFulfillments(summaries);
return {
locations,
filters: { locationId: locationIdFilter ?? "", method: methodFilter ?? "", status: statusFilter ?? "", startDate, endDate },
byDate,
revenue,
utilization,
upcoming,
totalBookings: summaries.length,
};
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const bookingId = String(formData.get("bookingId") || "");
const status = String(formData.get("status") || "");
if (!bookingId || !STATUSES.includes(status)) {
return data({ error: "Invalid request" }, { status: 400 });
}
await db.booking.updateMany({ where: { id: bookingId, shopDomain: session.shop }, data: { status } });
return data({ ok: true });
};
export default function Dashboard() {
const { locations, filters, byDate, revenue, utilization, upcoming, totalBookings } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const setFilter = (key: string, value: string) => {
const next = new URLSearchParams(searchParams);
if (value) next.set(key, value);
else next.delete(key);
setSearchParams(next);
};
const dates = Object.keys(byDate).sort();
const exportQuery = new URLSearchParams(searchParams).toString();
return (
<Page>
<TitleBar title="Dispatch dashboard" />
<BlockStack gap="400">
<Card>
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Filters
</Text>
<InlineStack gap="300" wrap>
<TextField
label="Start date"
labelHidden={false}
type="date"
autoComplete="off"
value={filters.startDate}
onChange={(v) => setFilter("startDate", v)}
/>
<TextField
label="End date"
type="date"
autoComplete="off"
value={filters.endDate}
onChange={(v) => setFilter("endDate", v)}
/>
<Select
label="Location"
options={[{ label: "All locations", value: "" }, ...locations.map((l) => ({ label: l.name, value: l.id }))]}
value={filters.locationId}
onChange={(v) => setFilter("locationId", v)}
/>
<Select
label="Method"
options={[{ label: "All methods", value: "" }, ...METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))]}
value={filters.method}
onChange={(v) => setFilter("method", v)}
/>
<Select
label="Status"
options={[{ label: "All statuses", value: "" }, ...STATUSES.map((s) => ({ label: s, value: s }))]}
value={filters.status}
onChange={(v) => setFilter("status", v)}
/>
</InlineStack>
<InlineStack gap="200">
<Button url={`/app/dashboard/export?${exportQuery}`} target="_blank">
Export CSV
</Button>
<Text as="span" tone="subdued">
{totalBookings} booking{totalBookings === 1 ? "" : "s"} in range
</Text>
</InlineStack>
</BlockStack>
</Card>
<InlineStack gap="400" wrap>
{METHODS.map((m) => (
<Card key={m}>
<BlockStack gap="100">
<Text as="span" tone="subdued">
{m.replace("_", " ")} revenue
</Text>
<Text as="span" variant="headingLg">
{formatPriceLabel(revenue[m])}
</Text>
</BlockStack>
</Card>
))}
</InlineStack>
<Card>
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Capacity utilization
</Text>
{utilization.length === 0 ? (
<Text as="p" tone="subdued">
No bookings in this range yet.
</Text>
) : (
<BlockStack gap="150">
{utilization.map((row) => (
<InlineStack key={`${row.date}-${row.locationId}-${row.method}`} align="space-between" blockAlign="center">
<Text as="span">
{row.date} · {row.locationName} · {row.method.replace("_", " ")}
</Text>
<InlineStack gap="200" blockAlign="center">
<Box
minWidth="120px"
background={row.utilizationPct >= 90 ? "bg-fill-critical" : row.utilizationPct >= 60 ? "bg-fill-caution" : "bg-fill-success"}
padding="100"
borderRadius="100"
>
<Text as="span" alignment="center">
{row.booked}/{row.capacity} ({row.utilizationPct}%)
</Text>
</Box>
</InlineStack>
</InlineStack>
))}
</BlockStack>
)}
</BlockStack>
</Card>
<Card padding="0">
<Box padding="400">
<Text as="h3" variant="headingSm">
Upcoming fulfillments
</Text>
</Box>
{upcoming.length === 0 ? (
<Box padding="400">
<Text as="p" tone="subdued">
Nothing confirmed and upcoming in this range.
</Text>
</Box>
) : (
<IndexTable
itemCount={upcoming.length}
headings={[
{ title: "Order" },
{ title: "When" },
{ title: "Method" },
{ title: "Location" },
{ title: "Customer" },
{ title: "" },
]}
selectable={false}
>
{upcoming.map((b, index) => {
const local = DateTime.fromISO(b.slotStart, { zone: "utc" }).setZone(b.timezone);
return (
<IndexTable.Row id={b.id} key={b.id} position={index}>
<IndexTable.Cell>{b.orderName ?? "—"}</IndexTable.Cell>
<IndexTable.Cell>{local.toFormat("ccc LLL d, h:mm a")}</IndexTable.Cell>
<IndexTable.Cell>{b.method.replace("_", " ")}</IndexTable.Cell>
<IndexTable.Cell>{b.locationName}</IndexTable.Cell>
<IndexTable.Cell>{b.customerEmail ?? "—"}</IndexTable.Cell>
<IndexTable.Cell>
<InlineStack gap="150">
<Form method="post">
<input type="hidden" name="bookingId" value={b.id} />
<input type="hidden" name="status" value="fulfilled" />
<Button submit size="slim" loading={isSubmitting}>
Fulfilled
</Button>
</Form>
<Form method="post">
<input type="hidden" name="bookingId" value={b.id} />
<input type="hidden" name="status" value="no_show" />
<Button submit size="slim" tone="critical" loading={isSubmitting}>
No-show
</Button>
</Form>
</InlineStack>
</IndexTable.Cell>
</IndexTable.Row>
);
})}
</IndexTable>
)}
</Card>
<Card padding="0">
<Box padding="400">
<Text as="h3" variant="headingSm">
All bookings by day
</Text>
</Box>
{dates.length === 0 ? (
<Box padding="400">
<Text as="p" tone="subdued">
No bookings in this range.
</Text>
</Box>
) : (
dates.map((date) => (
<Box key={date} padding="400" borderBlockStartWidth="025" borderColor="border">
<BlockStack gap="200">
<Text as="h4" variant="headingXs">
{date}
</Text>
{byDate[date].map((b) => {
const local = DateTime.fromISO(b.slotStart, { zone: "utc" }).setZone(b.timezone);
return (
<InlineStack key={b.id} align="space-between">
<Text as="span">
{local.toFormat("h:mm a")} · {b.orderName ?? "—"} · {b.method.replace("_", " ")} · {b.locationName}
</Text>
<Badge
tone={
b.status === "confirmed"
? "info"
: b.status === "fulfilled"
? "success"
: b.status === "no_show"
? "critical"
: "read-only"
}
>
{b.status}
</Badge>
</InlineStack>
);
})}
</BlockStack>
</Box>
))
)}
</Card>
</BlockStack>
</Page>
);
}

View File

@ -29,6 +29,7 @@ export default function App() {
<Link to="/app/blackouts">Blackout dates</Link> <Link to="/app/blackouts">Blackout dates</Link>
<Link to="/app/zones">Delivery zones</Link> <Link to="/app/zones">Delivery zones</Link>
<Link to="/app/rates">Delivery rates</Link> <Link to="/app/rates">Delivery rates</Link>
<Link to="/app/dashboard">Dispatch dashboard</Link>
</NavMenu> </NavMenu>
<Outlet /> <Outlet />
</AppProvider> </AppProvider>

View File

@ -16,9 +16,16 @@ export interface OrderWebhookPayload {
cart_token?: string | null; cart_token?: string | null;
email?: string | null; email?: string | null;
phone?: string | null; phone?: string | null;
total_price?: string | null; // e.g. "49.99" — Shopify sends this as a decimal string, not cents
note_attributes?: OrderNoteAttribute[]; note_attributes?: OrderNoteAttribute[];
} }
function parsePriceCents(totalPrice: string | null | undefined): number | undefined {
if (!totalPrice) return undefined;
const value = Number(totalPrice);
return Number.isFinite(value) ? Math.round(value * 100) : undefined;
}
function readAttr(attrs: OrderNoteAttribute[], key: string): string | undefined { function readAttr(attrs: OrderNoteAttribute[], key: string): string | undefined {
return attrs.find((a) => a.name === key)?.value; return attrs.find((a) => a.name === key)?.value;
} }
@ -71,6 +78,7 @@ export async function createBookingFromOrder(shopDomain: string, order: OrderWeb
slotEnd: slotEnd.toJSDate(), slotEnd: slotEnd.toJSDate(),
customerEmail: order.email ?? undefined, customerEmail: order.email ?? undefined,
customerPhone: order.phone ?? undefined, customerPhone: order.phone ?? undefined,
totalPriceCents: parsePriceCents(order.total_price),
}, },
update: {}, // redelivered webhook — the booking already exists, nothing to change update: {}, // redelivered webhook — the booking already exists, nothing to change
}); });

View File

@ -0,0 +1,171 @@
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
// Pure aggregation for the ops/dispatch dashboard (IMPLEMENTATION_PLAN.md
// Phase 6). No DB calls — the route loader fetches Bookings/SlotTemplates
// and passes plain data in here, per CLAUDE.md's "inject data" rule.
export interface BookingSummary {
id: string;
orderName: string | null;
locationId: string;
locationName: string;
method: Method;
slotStart: Date; // UTC instant
slotEnd: Date;
timezone: string; // the booking's location's timezone, for local-date bucketing
status: string;
totalPriceCents: number | null;
customerEmail: string | null;
}
export interface CapacitySummary {
locationId: string;
method: Method;
weekday: number;
capacity: number;
}
export interface DailyLocationUtilization {
date: string; // location-local ISO date
locationId: string;
locationName: string;
method: Method;
booked: number;
capacity: number;
utilizationPct: number; // 0-100, capped even if overbooked past capacity
}
function localDate(booking: BookingSummary): string {
return DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(booking.timezone).toISODate()!;
}
function localWeekday(booking: BookingSummary): number {
return DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(booking.timezone).weekday % 7;
}
/** Groups bookings by their own location's local calendar date — the basis for "bookings by day/slot/location". */
export function bucketByLocalDate(bookings: BookingSummary[]): Record<string, BookingSummary[]> {
const buckets: Record<string, BookingSummary[]> = {};
for (const booking of bookings) {
const key = localDate(booking);
(buckets[key] ??= []).push(booking);
}
for (const list of Object.values(buckets)) {
list.sort((a, b) => a.slotStart.getTime() - b.slotStart.getTime());
}
return buckets;
}
/** Confirmed/fulfilled revenue, summed per method — cancelled/no_show bookings never counted. */
export function revenueByMethod(bookings: BookingSummary[]): Record<Method, number> {
const totals: Record<Method, number> = { SHIPPING: 0, LOCAL_DELIVERY: 0, PICKUP: 0 };
for (const booking of bookings) {
if (booking.status !== "confirmed" && booking.status !== "fulfilled") continue;
totals[booking.method] += booking.totalPriceCents ?? 0;
}
return totals;
}
/**
* Capacity-utilization view: for each date/location/method that has at
* least one booking, what fraction of that weekday's total slot capacity
* (summed across every matching SlotTemplate) is booked. Dates/locations
* with zero bookings are omitted there's nothing to show a merchant for
* an empty day.
*/
export function utilizationByDateLocation(
bookings: BookingSummary[],
capacities: CapacitySummary[],
): DailyLocationUtilization[] {
const capacityByKey = new Map<string, number>();
for (const c of capacities) {
const key = `${c.locationId}|${c.method}|${c.weekday}`;
capacityByKey.set(key, (capacityByKey.get(key) ?? 0) + c.capacity);
}
const bookedByKey = new Map<
string,
{ date: string; locationId: string; locationName: string; method: Method; weekday: number; count: number }
>();
for (const booking of bookings) {
if (booking.status === "cancelled") continue;
const date = localDate(booking);
const weekday = localWeekday(booking);
const key = `${date}|${booking.locationId}|${booking.method}`;
const existing = bookedByKey.get(key);
if (existing) {
existing.count += 1;
} else {
bookedByKey.set(key, {
date,
locationId: booking.locationId,
locationName: booking.locationName,
method: booking.method,
weekday,
count: 1,
});
}
}
return [...bookedByKey.values()]
.map((entry) => {
const capacity = capacityByKey.get(`${entry.locationId}|${entry.method}|${entry.weekday}`) ?? 0;
const utilizationPct = capacity > 0 ? Math.min(100, Math.round((entry.count / capacity) * 100)) : 0;
return {
date: entry.date,
locationId: entry.locationId,
locationName: entry.locationName,
method: entry.method,
booked: entry.count,
capacity,
utilizationPct,
};
})
.sort((a, b) => a.date.localeCompare(b.date) || a.locationName.localeCompare(b.locationName));
}
/** Nearest-first list of not-yet-fulfilled bookings, for the "upcoming fulfillment" panel. */
export function upcomingFulfillments(bookings: BookingSummary[], limit = 25): BookingSummary[] {
return bookings
.filter((b) => b.status === "confirmed")
.sort((a, b) => a.slotStart.getTime() - b.slotStart.getTime())
.slice(0, limit);
}
export function toCsvRow(values: Array<string | number>): string {
return values
.map((v) => {
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
})
.join(",");
}
export function bookingsToCsv(bookings: BookingSummary[]): string {
const header = toCsvRow([
"Order",
"Date",
"Time",
"Method",
"Location",
"Status",
"Customer email",
"Total",
]);
const rows = bookings.map((b) => {
const local = DateTime.fromJSDate(b.slotStart, { zone: "utc" }).setZone(b.timezone);
return toCsvRow([
b.orderName ?? "",
local.toISODate() ?? "",
local.toFormat("h:mm a"),
b.method,
b.locationName,
b.status,
b.customerEmail ?? "",
b.totalPriceCents != null ? (b.totalPriceCents / 100).toFixed(2) : "",
]);
});
return [header, ...rows].join("\n");
}

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "totalPriceCents" INTEGER;

View File

@ -144,6 +144,7 @@ model Booking {
status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show
customerEmail String? customerEmail String?
customerPhone String? customerPhone String?
totalPriceCents Int? // order's total_price at booking time, for the dispatch dashboard's revenue-by-method view
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([shopDomain, locationId, method, slotStart]) @@index([shopDomain, locationId, method, slotStart])

View File

@ -87,4 +87,45 @@ describe("createBookingFromOrder", () => {
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } }); const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } });
expect(count).toBe(0); expect(count).toBe(0);
}); });
it("converts total_price to integer cents for the dashboard's revenue view", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
});
await createBookingFromOrder(shopDomain, {
admin_graphql_api_id: "gid://shopify/Order/4",
total_price: "49.99",
note_attributes: [
{ name: "dd_method", value: "PICKUP" },
{ name: "dd_date", value: "2026-08-25" },
{ name: "dd_start_min", value: "540" },
{ name: "dd_end_min", value: "600" },
{ name: "dd_location_id", value: location.id },
],
});
const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/4" } });
expect(booking?.totalPriceCents).toBe(4999);
});
it("leaves totalPriceCents unset when total_price is missing", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
});
await createBookingFromOrder(shopDomain, {
admin_graphql_api_id: "gid://shopify/Order/5",
note_attributes: [
{ name: "dd_method", value: "PICKUP" },
{ name: "dd_date", value: "2026-08-25" },
{ name: "dd_start_min", value: "540" },
{ name: "dd_end_min", value: "600" },
{ name: "dd_location_id", value: location.id },
],
});
const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/5" } });
expect(booking?.totalPriceCents).toBeNull();
});
}); });

View File

@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
bookingsToCsv,
bucketByLocalDate,
revenueByMethod,
toCsvRow,
upcomingFulfillments,
utilizationByDateLocation,
type BookingSummary,
type CapacitySummary,
} from "../../app/services/dashboard.server";
const ZONE = "America/Toronto";
function booking(overrides: Partial<BookingSummary> = {}): BookingSummary {
return {
id: "b1",
orderName: "#1001",
locationId: "loc_1",
locationName: "Main Bakery",
method: "PICKUP",
slotStart: new Date("2026-08-25T13:00:00.000Z"), // 9 AM EDT, a Tuesday
slotEnd: new Date("2026-08-25T14:00:00.000Z"),
timezone: ZONE,
status: "confirmed",
totalPriceCents: 2500,
customerEmail: "shopper@example.com",
...overrides,
};
}
describe("bucketByLocalDate", () => {
it("groups bookings under their location-local calendar date", () => {
const buckets = bucketByLocalDate([booking()]);
expect(Object.keys(buckets)).toEqual(["2026-08-25"]);
});
it("sorts bookings within a day by slot start time", () => {
const late = booking({ id: "late", slotStart: new Date("2026-08-25T20:00:00.000Z") });
const early = booking({ id: "early", slotStart: new Date("2026-08-25T13:00:00.000Z") });
const buckets = bucketByLocalDate([late, early]);
expect(buckets["2026-08-25"].map((b) => b.id)).toEqual(["early", "late"]);
});
it("puts a booking near midnight into the correct location-local day, not the UTC day", () => {
// 11 PM EDT on Aug 25 is already Aug 26 in UTC.
const b = booking({ slotStart: new Date("2026-08-26T03:00:00.000Z") });
const buckets = bucketByLocalDate([b]);
expect(Object.keys(buckets)).toEqual(["2026-08-25"]);
});
});
describe("revenueByMethod", () => {
it("sums confirmed and fulfilled bookings per method", () => {
const bookings = [
booking({ method: "PICKUP", totalPriceCents: 1000, status: "confirmed" }),
booking({ method: "PICKUP", totalPriceCents: 500, status: "fulfilled" }),
booking({ method: "LOCAL_DELIVERY", totalPriceCents: 2000, status: "confirmed" }),
];
expect(revenueByMethod(bookings)).toEqual({ SHIPPING: 0, LOCAL_DELIVERY: 2000, PICKUP: 1500 });
});
it("excludes cancelled and no_show bookings", () => {
const bookings = [
booking({ totalPriceCents: 1000, status: "cancelled" }),
booking({ totalPriceCents: 1000, status: "no_show" }),
];
expect(revenueByMethod(bookings).PICKUP).toBe(0);
});
it("treats a missing totalPriceCents as 0, not an error", () => {
expect(revenueByMethod([booking({ totalPriceCents: null })]).PICKUP).toBe(0);
});
});
describe("utilizationByDateLocation", () => {
it("computes booked/capacity percentage for a date+location+method with bookings", () => {
// 2026-08-25 is a Tuesday -> weekday 2.
const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }];
const bookings = [booking(), booking({ id: "b2" })];
const result = utilizationByDateLocation(bookings, capacities);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ date: "2026-08-25", booked: 2, capacity: 4, utilizationPct: 50 });
});
it("sums capacity across multiple slot templates for the same weekday", () => {
const capacities: CapacitySummary[] = [
{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 },
{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 6 },
];
const result = utilizationByDateLocation([booking()], capacities);
expect(result[0].capacity).toBe(10);
});
it("caps utilization at 100% even if overbooked", () => {
const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 1 }];
const bookings = [booking(), booking({ id: "b2" }), booking({ id: "b3" })];
expect(utilizationByDateLocation(bookings, capacities)[0].utilizationPct).toBe(100);
});
it("excludes cancelled bookings from the booked count", () => {
const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }];
const result = utilizationByDateLocation([booking({ status: "cancelled" })], capacities);
expect(result).toHaveLength(0);
});
it("omits a date/location/method with no bookings at all", () => {
expect(utilizationByDateLocation([], [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }])).toEqual([]);
});
});
describe("upcomingFulfillments", () => {
it("returns only confirmed bookings, nearest-first", () => {
const bookings = [
booking({ id: "later", slotStart: new Date("2026-08-26T13:00:00.000Z") }),
booking({ id: "sooner", slotStart: new Date("2026-08-25T13:00:00.000Z") }),
booking({ id: "fulfilled-already", status: "fulfilled" }),
];
const result = upcomingFulfillments(bookings);
expect(result.map((b) => b.id)).toEqual(["sooner", "later"]);
});
it("respects the limit", () => {
const bookings = Array.from({ length: 5 }, (_, i) => booking({ id: `b${i}` }));
expect(upcomingFulfillments(bookings, 2)).toHaveLength(2);
});
});
describe("toCsvRow / bookingsToCsv", () => {
it("quotes a field containing a comma", () => {
expect(toCsvRow(["a,b", "c"])).toBe('"a,b",c');
});
it("escapes an embedded quote by doubling it", () => {
expect(toCsvRow(['say "hi"'])).toBe('"say ""hi"""');
});
it("produces a header row plus one row per booking", () => {
const csv = bookingsToCsv([booking()]);
const lines = csv.split("\n");
expect(lines).toHaveLength(2);
expect(lines[0]).toContain("Order");
expect(lines[1]).toContain("#1001");
expect(lines[1]).toContain("25.00");
});
});