- 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>
172 lines
5.5 KiB
TypeScript
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");
|
|
}
|