// computeStats — derives daily/weekly/monthly grams from purchase + audit // history, using estimated remaining for active items and full weight for // consumed. Gone items contribute spend but NOT grams (so daily averages // stay clean). Operates on the enriched Item[] view, not raw products. import type { Bootstrap, Item } from "./types.js"; import { TYPES, helpers, enrichItems } from "./types.js"; import { getToday, getStoredTimezone } from "./tz.js"; export interface Stats { dailyAvg: number; weeklyAvg: number; monthlyAvg: number; totalSpend: number; avgPerGram: number; spend7: number; spend30: number; spend90: number; goneSpend: number; inventoryValue: number; inventoryGrams: number; totalGrams: number; thcLast7: number; thcLast30: number; avgLifespan: number; favShop: [string, number]; favBrand: [string, number]; typeBreakdown: Record; daysOfSupply: number; avgGap: number; series7: { date: string; grams: number }[]; series30: { date: string; grams: number }[]; series90: { date: string; grams: number }[]; activeCount: number; checkedOutCount: number; consumedCount: number; goneCount: number; archivedCount: number; purchaseCount: number; overdueAudits: Item[]; lowStockBulk: Item[]; lowStockDiscreteGroups: { key: string; name: string; type: string; brandId: string | null; items: Item[]; totalCount: number; }[]; } export function computeStats(data: Bootstrap): Stats { const tz = getStoredTimezone(); const todayStr = getToday(tz); const today = new Date(todayStr + "T12:00:00"); const items = enrichItems(data); const dayKeyFmt = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", }); const dayKey = (d: Date) => dayKeyFmt.format(d); const active = items.filter((p) => p.status === "active" || p.status === "checked-out"); const consumed = items.filter((p) => p.status === "consumed" && p.consumedDate); const gone = items.filter((p) => p.status === "gone"); const purchasesIn = (days: number) => { const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - days); return items.filter((p) => new Date(p.purchaseDate) >= cutoff); }; const last7p = purchasesIn(7); const last30p = purchasesIn(30); const last90p = purchasesIn(90); const bulkGrams = (p: Item): number => { if (p.type === "Tincture" || p.type === "Edible") return 0; if (p.kind === "bulk") return p.weight; return (p.countOriginal || 0) * (p.unitWeight || 0); }; const bulkGramsConsumed = (p: Item): number => { if (p.type === "Tincture" || p.type === "Edible") return 0; if (p.kind === "bulk") return p.weight; return (p.countOriginal || 0) * (p.unitWeight || 0); }; const bulkGramsUsedSoFar = (p: Item): number => { if (p.type === "Tincture" || p.type === "Edible") return 0; if (p.kind === "bulk") { const est = helpers.estimatedRemaining(p, todayStr); return Math.max(0, p.weight - est); } const cur = p.countLastAudit ?? p.countOriginal; return Math.max(0, p.countOriginal - cur) * (p.unitWeight || 0); }; const dailyGramsAttribution: Record = {}; consumed.forEach((p) => { const g = bulkGramsConsumed(p); if (g <= 0 || !p.consumedDate) return; const start = new Date(p.purchaseDate); const end = new Date(p.consumedDate); const days = Math.max(1, Math.round((+end - +start) / 86_400_000)); const perDay = g / days; for (let i = 0; i < days; i++) { const d = new Date(start); d.setDate(d.getDate() + i); const k = dayKey(d); dailyGramsAttribution[k] = (dailyGramsAttribution[k] || 0) + perDay; } }); active.forEach((p) => { const used = bulkGramsUsedSoFar(p); if (used <= 0) return; const start = new Date(p.purchaseDate); const days = Math.max(1, Math.round((+today - +start) / 86_400_000)); const perDay = used / days; for (let i = 0; i < days; i++) { const d = new Date(start); d.setDate(d.getDate() + i); const k = dayKey(d); dailyGramsAttribution[k] = (dailyGramsAttribution[k] || 0) + perDay; } }); const seriesFor = (days: number) => { const out: { date: string; grams: number }[] = []; for (let i = days - 1; i >= 0; i--) { const d = new Date(today); d.setDate(d.getDate() - i); const k = dayKey(d); out.push({ date: k, grams: dailyGramsAttribution[k] || 0 }); } return out; }; const series7 = seriesFor(7); const series30 = seriesFor(30); const series90 = seriesFor(90); const sumG = (xs: { grams: number }[]) => xs.reduce((s, x) => s + x.grams, 0); const dailyAvg = sumG(series30) / 30; const weeklyAvg = sumG(series30) / (30 / 7); const monthlyAvg = sumG(series90) / 3; const totalSpend = items.reduce((s, p) => s + p.price, 0); const goneSpend = gone.reduce((s, p) => s + p.price, 0); const totalGrams = items.reduce((s, p) => s + bulkGrams(p), 0); const avgPerGram = totalGrams ? totalSpend / totalGrams : 0; const spend30 = last30p.reduce((s, p) => s + p.price, 0); const spend7 = last7p.reduce((s, p) => s + p.price, 0); const spend90 = last90p.reduce((s, p) => s + p.price, 0); const inventoryValue = active.reduce( (s, p) => s + p.price * helpers.pctRemaining(p, todayStr), 0, ); // Grams currently on hand: bulk uses estimated remaining; discrete uses // (units × per-unit weight). Tincture (ml) and edibles (count) are excluded // to match the existing `bulkGrams` convention used for $/g and totals. const inventoryGrams = active.reduce((s, p) => { if (p.type === "Tincture" || p.type === "Edible") return s; if (p.kind === "bulk") return s + helpers.estimatedRemaining(p, todayStr); const cur = p.countLastAudit ?? p.countOriginal; return s + cur * (p.unitWeight || 0); }, 0); const avgThc = items.length > 0 ? items.reduce((s, p) => s + p.thc, 0) / items.length : 20; const thcLast7 = Math.round(sumG(series7) * avgThc * 10); const thcLast30 = Math.round(sumG(series30) * avgThc * 10); const lifespans = consumed.map((p) => Math.max( 1, Math.round((+new Date(p.consumedDate!) - +new Date(p.purchaseDate)) / 86_400_000), ), ); const avgLifespan = lifespans.length > 0 ? lifespans.reduce((a, b) => a + b, 0) / lifespans.length : 0; const shopCount: Record = {}; const brandCount: Record = {}; items.forEach((p) => { if (p.shopId) shopCount[p.shopId] = (shopCount[p.shopId] || 0) + 1; if (p.brandId) brandCount[p.brandId] = (brandCount[p.brandId] || 0) + 1; }); const topShopEntry = Object.entries(shopCount).sort((a, b) => b[1] - a[1])[0]; const topBrandEntry = Object.entries(brandCount).sort((a, b) => b[1] - a[1])[0]; const favShop: [string, number] = topShopEntry ? [helpers.shopName(data, topShopEntry[0]), topShopEntry[1]] : ["—", 0]; const favBrand: [string, number] = topBrandEntry ? [helpers.brandName(data, topBrandEntry[0]), topBrandEntry[1]] : ["—", 0]; const typeBreakdown: Record = {}; active.forEach((p) => { let g: number; if (p.type === "Tincture") g = helpers.estimatedRemaining(p, todayStr) * 0.5; else if (p.type === "Edible") g = (p.countLastAudit ?? p.countOriginal) * 0.3; else if (p.kind === "bulk") g = helpers.estimatedRemaining(p, todayStr); else g = (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0); if (g > 0) typeBreakdown[p.type] = (typeBreakdown[p.type] || 0) + g; }); const flowerEquivalent = active .filter((p) => p.type === "Flower" || p.type === "Pre-roll") .reduce((s, p) => { if (p.kind === "bulk") return s + helpers.estimatedRemaining(p, todayStr); return s + (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0); }, 0); const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0; const sortedDates = [...items] .sort((a, b) => +new Date(a.purchaseDate) - +new Date(b.purchaseDate)) .map((p) => new Date(p.purchaseDate)); const gaps: number[] = []; for (let i = 1; i < sortedDates.length; i++) { gaps.push((+sortedDates[i]! - +sortedDates[i - 1]!) / 86_400_000); } const avgGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0; const overdueAudits = active.filter((p) => helpers.auditOverdue(p, todayStr)); const lowStockBulk = active.filter( (p) => p.kind === "bulk" && helpers.pctRemaining(p, todayStr) < 0.25, ); // Group discrete instances by product so multiple jars of the same // pre-roll/edible product collapse into a single "running low" row. const discreteGroups: Record< string, { key: string; name: string; type: string; brandId: string | null; items: Item[]; totalCount: number } > = {}; active .filter((p) => p.kind === "discrete") .forEach((p) => { const k = p.productId; if (!discreteGroups[k]) { discreteGroups[k] = { key: k, name: p.name, type: p.type, brandId: p.brandId, items: [], totalCount: 0, }; } discreteGroups[k].items.push(p); discreteGroups[k].totalCount += p.countLastAudit ?? p.countOriginal; }); const lowStockDiscreteGroups = Object.values(discreteGroups).filter( (g) => g.totalCount <= 2, ); return { dailyAvg, weeklyAvg, monthlyAvg, totalSpend, avgPerGram, spend7, spend30, spend90, goneSpend, inventoryValue, inventoryGrams, totalGrams, thcLast7, thcLast30, avgLifespan, favShop, favBrand, typeBreakdown, daysOfSupply, avgGap, series7, series30, series90, activeCount: items.filter((p) => p.status === "active").length, checkedOutCount: items.filter((p) => p.status === "checked-out").length, consumedCount: consumed.length, goneCount: gone.length, archivedCount: consumed.length + gone.length, purchaseCount: items.length, overdueAudits, lowStockBulk, lowStockDiscreteGroups, }; } // Display helpers used throughout the UI export function remainingShort(p: Item): string { const cfg = TYPES.find((t) => t.id === p.type); if (p.kind === "discrete") { const cur = p.countLastAudit ?? p.countOriginal; return `${cur} ct`; } const est = helpers.estimatedRemaining(p); const trimmed = est.toFixed(2).replace(/\.?0+$/, "") || "0"; return `${trimmed} ${cfg?.unit ?? "g"}`; }