import { getAdminPocketBase } from "./pocketbase-admin"; /** * Server-side dashboard read model for `/dashboard`. * * Why this exists: * * The page used to fire 11 parallel PocketBase calls straight from the browser, * each pulling the full record shape (`email_body`, `migration_meta`, * `qb_invoice_pdf`, etc.) even though the dashboard only consumes a few * fields per row. On the live PB (`pocketbase-gnqc88suy8fqk6wyryjjdpvl… * sslip.io`) that cost ~720ms cold wall-clock and ~500KB of useless payload * just to draw charts. * * This service collapses the dashboard into a single server round-trip that * returns a small, fixed-shape payload (~10KB) and applies all the * bucketing / aggregation work on the server side using `Promise.all`. * * The page can then render from one request, and realtime re-fires are * debounced on the client so a burst of changes doesn't re-pull 11 queries * back-to-back. * * Hard rules: * * - Every list query uses `fields=` projection to drop the heavy blobs. * If you add a field to the page, add it here too — never widen a query * to "all fields by default". * - Status buckets + top customers are computed from the SAME 500-row * sample the historical charts use, with a `sampled` flag so the page * can show "n of N" when the buckets are incomplete. The previous * dashboard showed 6,076 active quotes on the KPI while the donut * showed 97 approved — a clear mismatch we're fixing in one place. * - Counts use `getList(1, 1)` with a filter and the `id` field * projected, never `getFullList`. `getFullList` defaults to 500 * records and transfers them all just to compute the count. * - Follow-ups use a tight `perPage=50` ceiling (we only display 8 cards). * - Order-item weeks use `fields="created,quantity,order_qty,selling_price,item_status"` * so each row is ~70 bytes instead of ~1KB. */ const CUSTOMERS_COLLECTION = "customers"; const QUOTES_COLLECTION = "quotes"; const ORDERS_COLLECTION = "orders"; const ORDER_ITEMS_COLLECTION = "order_items"; const STOCK_ITEMS_COLLECTION = "stock_items"; const SAMPLE_WINDOW_DAYS = 84; // 12 weeks const WEEK_BUCKETS = 12; const ORDERS_BUCKETS = 8; const QUOTE_SAMPLE_LIMIT = 500; const ORDER_SAMPLE_LIMIT = 500; const CUSTOMER_SAMPLE_LIMIT = 500; const ORDER_ITEM_SAMPLE_LIMIT = 500; const ORDER_ITEM_MONTH_LIMIT = 500; const FOLLOW_UP_LIMIT = 50; const TOP_CUSTOMERS = 6; export interface DashboardStats { totalCustomers: number; activeQuotes: number; ordersThisMonth: number; totalRevenue: number; /** True if the 500-row sample was a subset of a larger matching set. */ sampled: boolean; } export interface WeekPoint { weekStart: string; // ISO date (YYYY-MM-DD) count: number; } export interface RevenuePoint { weekStart: string; count: number; } export interface QuoteStatusBucket { status: string; label: string; count: number; } export interface TopCustomer { name: string; count: number; color: string; } export interface StockSummary { totalItems: number; totalQty: number; lowStockCount: number; outOfStockCount: number; } export interface FollowUpQuote { id: string; quote_number: string; follow_up_due: string; customer_name: string | null; } export interface DashboardData { stats: DashboardStats; /** 12-week customer creation count, oldest → newest. */ customersByWeek: WeekPoint[]; /** 8-week order creation count, oldest → newest. */ ordersByWeek: WeekPoint[]; /** 8-week fulfilled item count (delivered/invoiced/completed/checked_in). */ fulfilledByWeek: WeekPoint[]; /** 12-week quote creation count, oldest → newest. */ quotesByWeek: WeekPoint[]; /** 12-week revenue (selling_price × qty), oldest → newest. */ revenueByWeek: RevenuePoint[]; /** Sorted by count desc, capped at 6. */ quoteStatusBuckets: QuoteStatusBucket[]; /** Top customers by quote count (within the 12-week sample). */ topCustomers: TopCustomer[]; stockSummary: StockSummary; followUps: FollowUpQuote[]; } /* ============================================================================ * Status metadata (kept in sync with the page render) * ========================================================================== */ const QUOTE_STATUS_META: Record = { new: { label: "New" }, claimed: { label: "Claimed" }, processed: { label: "Processed" }, emailed: { label: "Emailed" }, approval_pending: { label: "Approval pending" }, pending_approvals: { label: "Approval pending" }, approved: { label: "Approved" }, complete: { label: "Complete" }, rejected: { label: "Rejected" }, cancelled: { label: "Cancelled" }, problem: { label: "Problem" }, email_failed: { label: "Email failed" }, follow_up: { label: "Follow-up" }, template: { label: "Template" }, }; const TOP_PALETTE = [ "#06B6D4", // cyan "#EC4899", // pink "#F59E0B", // gold "#F97316", // orange "#A855F7", // purple "#10B981", // green "#3B82F6", // blue "#EF4444", // red ] as const; const FULFILLED_STATUSES = new Set([ "delivered", "invoiced", "completed", "checked_in", ]); /* ============================================================================ * Week helpers * ========================================================================== */ function startOfWeekUtc(d: Date): Date { const out = new Date(d); const day = out.getUTCDay(); const diff = day === 0 ? -6 : 1 - day; // Monday-anchored week out.setUTCDate(out.getUTCDate() + diff); out.setUTCHours(0, 0, 0, 0); return out; } function isoWeekKey(d: Date): string { return startOfWeekUtc(d).toISOString().slice(0, 10); } function buildWeekBuckets(n: number, now: Date): Record { const nowWeek = startOfWeekUtc(now); const out: Record = {}; for (let i = n - 1; i >= 0; i--) { const w = new Date(nowWeek); w.setUTCDate(w.getUTCDate() - i * 7); out[isoWeekKey(w)] = 0; } return out; } function bucketsToWeekPoints(b: Record): WeekPoint[] { return Object.entries(b) .sort(([a], [z]) => a.localeCompare(z)) .map(([weekStart, count]) => ({ weekStart, count })); } /** * Accept the optional `pb` from the caller so the test suite can inject a * mock without touching the admin auth cache. Production callers omit it. */ export async function getDashboardData( now: Date = new Date(), pbArg?: Awaited>, ): Promise { const pb = pbArg ?? (await getAdminPocketBase()); pb.autoCancellation(false); const startOfMonth = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), ).toISOString(); const sampleSince = new Date( now.getTime() - SAMPLE_WINDOW_DAYS * 24 * 60 * 60 * 1000, ).toISOString(); const [ customersTotal, activeQuotesCount, ordersMonthCount, quotesAllSample, ordersSample, customersSample, orderItemsMonth, orderItemsSample, quotesSample, stockItems, followUps, ] = await Promise.all([ // KPI counts — `fields="id"` so the response is one id + totalItems, // never the full record. pb.collection(CUSTOMERS_COLLECTION).getList(1, 1, { fields: "id" }), pb.collection(QUOTES_COLLECTION).getList(1, 1, { filter: '(status = "new" || status = "claimed" || status = "processed" || status = "emailed" || status = "approval_pending" || status = "pending_approvals" || status = "follow_up" || status = "problem") && is_template != true', fields: "id", }), pb.collection(ORDERS_COLLECTION).getList(1, 1, { filter: `created >= "${startOfMonth}"`, fields: "id", }), // Sampled status buckets + top-customers source. // `customer.expand` only needs the customer's display name — projects // the customer's `customer` field so the full customer record isn't // returned. pb.collection(QUOTES_COLLECTION).getList(1, QUOTE_SAMPLE_LIMIT, { filter: "is_template != true", fields: "id,status,created", expand: "customer.customer", }), pb.collection(ORDERS_COLLECTION).getList(1, ORDER_SAMPLE_LIMIT, { sort: "-created", filter: `created >= "${sampleSince}"`, fields: "id,created", }), pb.collection(CUSTOMERS_COLLECTION).getList(1, CUSTOMER_SAMPLE_LIMIT, { sort: "-created", filter: `created >= "${sampleSince}"`, fields: "id,created", }), pb.collection(ORDER_ITEMS_COLLECTION).getList(1, ORDER_ITEM_MONTH_LIMIT, { filter: `created >= "${startOfMonth}"`, fields: "id,quantity,order_qty,selling_price", }), pb.collection(ORDER_ITEMS_COLLECTION).getList(1, ORDER_ITEM_SAMPLE_LIMIT, { filter: `created >= "${sampleSince}"`, fields: "id,created,item_status,quantity,order_qty,selling_price", }), pb.collection(QUOTES_COLLECTION).getList(1, QUOTE_SAMPLE_LIMIT, { filter: `created >= "${sampleSince}" && is_template != true`, fields: "id,created", }), // Stock items: 115 rows on live, but use fields projection so we don't // ever ship the `cost_history` blob (which can grow over time). pb.collection(STOCK_ITEMS_COLLECTION).getList(1, 200, { filter: "is_active = true", fields: "id,sku,name,description,location,current_qty,min_qty,target_qty,is_active", }), pb.collection(QUOTES_COLLECTION).getList(1, FOLLOW_UP_LIMIT, { sort: "follow_up_due", filter: `follow_up_due != "" && follow_up_due <= "${now.toISOString()}" && status != "complete" && status != "cancelled" && status != "rejected" && is_template != true`, fields: "id,quote_number,follow_up_due", expand: "customer.customer", }), ]); /* ---------- Month revenue + sampled flag ---------- */ let monthRevenue = 0; for (const it of orderItemsMonth.items as Array<{ selling_price?: number | null; quantity?: number | null; order_qty?: number | null; }>) { monthRevenue += Number(it.selling_price ?? 0) * Number(it.quantity ?? it.order_qty ?? 0); } const totalQuotesInSample = (quotesAllSample.items as Array).length; const sampleLimitReached = totalQuotesInSample >= QUOTE_SAMPLE_LIMIT && (quotesAllSample.totalItems ?? 0) > QUOTE_SAMPLE_LIMIT; /* ---------- Week buckets ---------- */ const customerWeeks = buildWeekBuckets(WEEK_BUCKETS, now); for (const c of customersSample.items as unknown as Array<{ created: string }>) { const k = isoWeekKey(new Date(c.created)); if (k in customerWeeks) customerWeeks[k] += 1; } const orderWeeks = buildWeekBuckets(ORDERS_BUCKETS, now); for (const o of ordersSample.items as unknown as Array<{ created: string }>) { const k = isoWeekKey(new Date(o.created)); if (k in orderWeeks) orderWeeks[k] += 1; } const fulfilledWeeks = buildWeekBuckets(ORDERS_BUCKETS, now); const revenueWeeks = buildWeekBuckets(WEEK_BUCKETS, now); for (const it of orderItemsSample.items as unknown as Array<{ created: string; item_status?: string; selling_price?: number | null; quantity?: number | null; order_qty?: number | null; }>) { const k = isoWeekKey(new Date(it.created)); if (FULFILLED_STATUSES.has(it.item_status ?? "") && k in fulfilledWeeks) { fulfilledWeeks[k] += 1; } if (k in revenueWeeks) { revenueWeeks[k] += Number(it.selling_price ?? 0) * Number(it.quantity ?? it.order_qty ?? 0); } } const quoteWeeks = buildWeekBuckets(WEEK_BUCKETS, now); for (const q of quotesSample.items as unknown as Array<{ created: string }>) { const k = isoWeekKey(new Date(q.created)); if (k in quoteWeeks) quoteWeeks[k] += 1; } /* ---------- Status buckets + top customers ---------- */ const statusCounts: Record = {}; const customerQuoteCount: Record = {}; for (const q of quotesAllSample.items as unknown as Array<{ status: string; expand?: { customer?: { customer?: string } }; }>) { statusCounts[q.status] = (statusCounts[q.status] ?? 0) + 1; const name = q.expand?.customer?.customer; if (name) { customerQuoteCount[name] = (customerQuoteCount[name] ?? 0) + 1; } } const quoteStatusBuckets: QuoteStatusBucket[] = Object.entries(statusCounts) .map(([status, count]) => ({ status, label: QUOTE_STATUS_META[status]?.label ?? status, count, })) .sort((a, b) => b.count - a.count); const topCustomers: TopCustomer[] = Object.entries(customerQuoteCount) .map(([name, count], i) => ({ name, count, color: TOP_PALETTE[i % TOP_PALETTE.length], })) .sort((a, b) => b.count - a.count) .slice(0, TOP_CUSTOMERS); /* ---------- Stock summary ---------- */ let totalQty = 0; let outOfStockCount = 0; let lowStockCount = 0; for (const s of stockItems.items as unknown as Array<{ current_qty?: number | null; min_qty?: number | null; }>) { totalQty += Number(s.current_qty ?? 0); const cur = Number(s.current_qty ?? 0); const min = Number(s.min_qty ?? 0); if (cur <= 0) outOfStockCount += 1; else if (cur <= min) lowStockCount += 1; } /* ---------- Follow-ups ---------- */ const followUpsList: FollowUpQuote[] = (followUps.items as unknown as Array<{ id: string; quote_number: string; follow_up_due: string; expand?: { customer?: { customer?: string } }; }>).map((q) => ({ id: q.id, quote_number: q.quote_number, follow_up_due: q.follow_up_due, customer_name: q.expand?.customer?.customer ?? null, })); return { stats: { totalCustomers: customersTotal.totalItems, activeQuotes: activeQuotesCount.totalItems, ordersThisMonth: ordersMonthCount.totalItems, totalRevenue: Math.round(monthRevenue * 100) / 100, sampled: sampleLimitReached, }, customersByWeek: bucketsToWeekPoints(customerWeeks), ordersByWeek: bucketsToWeekPoints(orderWeeks), fulfilledByWeek: bucketsToWeekPoints(fulfilledWeeks), quotesByWeek: bucketsToWeekPoints(quoteWeeks), revenueByWeek: bucketsToWeekPoints(revenueWeeks).map((p) => ({ weekStart: p.weekStart, count: Math.round(p.count * 100) / 100, })), quoteStatusBuckets, topCustomers, stockSummary: { totalItems: stockItems.totalItems, totalQty, lowStockCount, outOfStockCount, }, followUps: followUpsList, }; } /** * Internal helper for tests + the daily cron that warms the cache. Not * exposed via the API yet — included so future cache-aware callers don't * reinvent it. */ export const _internals = { startOfWeekUtc, isoWeekKey, buildWeekBuckets, bucketsToWeekPoints, QUOTE_STATUS_META, FULFILLED_STATUSES, };