- 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>
147 lines
5.6 KiB
TypeScript
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");
|
|
});
|
|
});
|