Files
Apothecary/web/src/stats.ts
T
josh 5fa1e34914
Build and push image / build (push) Successful in 57s
Split audits into Weigh Ins (bulk) and Bin Checks (discrete)
Replaces the unified audit system with two purpose-built flows:
- Weigh Ins: rebranded audit flow for bulk products (Flower, Concentrate,
  Tincture) with scale weigh, container weigh, and estimate modes
- Bin Checks: new bin-level presence check — select a bin, scan every item,
  resolve discrepancies (wrong bin, unknown, missing), auto-records presence
  audits on verified items

Adds cadence_days and last_checked to bins table, with per-bin overdue
tracking on the dashboard and bins view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 18:28:55 -04:00

306 lines
10 KiB
TypeScript

// computeStats — derives daily/weekly/monthly grams from purchase + audit
// history, using last audit values 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 { Bin, 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<string, number>;
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;
overdueWeighIns: Item[];
overdueBinChecks: Bin[];
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 rem = helpers.remaining(p);
return Math.max(0, p.weight - rem);
}
const cur = p.countLastAudit ?? p.countOriginal;
return Math.max(0, p.countOriginal - cur) * (p.unitWeight || 0);
};
const dailyGramsAttribution: Record<string, number> = {};
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),
0,
);
const inventoryGrams = active.reduce((s, p) => {
if (p.type === "Tincture" || p.type === "Edible") return s;
if (p.kind === "bulk") return s + helpers.remaining(p);
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<string, number> = {};
const brandCount: Record<string, number> = {};
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<string, number> = {};
active.forEach((p) => {
let g: number;
if (p.type === "Tincture") g = helpers.remaining(p) * 0.5;
else if (p.type === "Edible")
g = (p.countLastAudit ?? p.countOriginal) * 0.3;
else if (p.kind === "bulk") g = helpers.remaining(p);
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.remaining(p);
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 overdueWeighIns = active.filter((p) => helpers.auditOverdue(p, todayStr));
const overdueBinChecks = data.bins.filter((b) => helpers.binCheckOverdue(b, todayStr));
const lowStockBulk = active.filter(
(p) => p.kind === "bulk" && helpers.pctRemaining(p) < 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,
overdueWeighIns,
overdueBinChecks,
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 rem = helpers.remaining(p);
const trimmed = rem.toFixed(2).replace(/\.?0+$/, "") || "0";
return `${trimmed} ${cfg?.unit ?? "g"}`;
}