metatrondelivery/app/services/dashboard.server.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

172 lines
5.5 KiB
TypeScript

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