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 { const buckets: Record = {}; 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 { const totals: Record = { 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(); 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 { 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"); }