metatroncubeswdev c5ec8f368c 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>
2026-08-24 03:33:21 -04:00

132 lines
4.9 KiB
TypeScript

import { afterAll, beforeEach, describe, expect, it } from "vitest";
import db from "../../app/db.server";
import redis from "../../app/lib/redis.server";
import { createBookingFromOrder } from "../../app/services/booking.server";
import { countActiveHolds, tryCreateHold } from "../../app/services/holds.server";
const shopDomain = "booking-integration-test.myshopify.com";
async function cleanup() {
await db.booking.deleteMany({ where: { shopDomain } });
await db.location.deleteMany({ where: { shopDomain } });
await db.shop.deleteMany({ where: { shopDomain } });
}
describe("createBookingFromOrder", () => {
beforeEach(cleanup);
afterAll(async () => {
await cleanup();
await db.$disconnect();
await redis.quit();
});
it("creates a Booking from an order's dd_* attributes and releases the matching hold", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
});
const slot = {
shopDomain,
locationId: location.id,
method: "PICKUP" as const,
slotStartIso: "2026-08-25T13:00:00.000Z",
};
await tryCreateHold(slot, "cart_abc123", 5);
expect(await countActiveHolds(slot)).toBe(1);
await createBookingFromOrder(shopDomain, {
admin_graphql_api_id: "gid://shopify/Order/1",
name: "#1001",
cart_token: "cart_abc123",
email: "shopper@example.com",
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/1" } });
expect(booking).not.toBeNull();
expect(booking?.method).toBe("PICKUP");
expect(booking?.status).toBe("confirmed");
expect(booking?.customerEmail).toBe("shopper@example.com");
expect(booking?.slotStart.toISOString()).toBe("2026-08-25T13:00:00.000Z"); // 9 AM EDT
// The hold this order consumed should now be released.
expect(await countActiveHolds(slot)).toBe(0);
});
it("is idempotent — a redelivered webhook does not create a second Booking", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
});
const order = {
admin_graphql_api_id: "gid://shopify/Order/2",
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 },
],
};
await createBookingFromOrder(shopDomain, order);
await createBookingFromOrder(shopDomain, order); // redelivery
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/2" } });
expect(count).toBe(1);
});
it("does nothing for an order with no scheduling attributes", async () => {
await createBookingFromOrder(shopDomain, { admin_graphql_api_id: "gid://shopify/Order/3", note_attributes: [] });
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } });
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();
});
});