metatrondelivery/tests/unit/dashboard.test.ts
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

147 lines
5.6 KiB
TypeScript

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");
});
});