Files
Apothecary/web/src/stats.ts
T
josh 02dc6e523f
Build and push image / build (push) Successful in 46s
Track inventory at the instance level, not by product
The products table conflated catalog ("kind of thing you scan") with
instance ("this jar I bought") — splitting it lets us record every
purchase as its own asset and autofill brand/shop/price/THC from the
last instance when scanning a known SKU.

- products: sku + strain + name + type + kind (catalog only)
- inventory_items: physical jars with short-UUID asset ids, per-batch
  brand/shop/bin/price/cannabinoids/weight, audits, lifecycle
- audits now key on inventory_id; strains lose brand_id and type
- migration: rename existing products/audits/strains to *_legacy on
  first boot so users keep historical reference, fresh start otherwise
- two-step add flow: scan SKU → select/create product → instance
  details (autofilled from last instance) → generated asset id shown
- ScanField matches asset id first, falls back to SKU
- inventory list defaults flat, "By product" toggle groups instances

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 05:59:46 -04:00

296 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, TODAY_STR, helpers, enrichItems } from "./types.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;
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 today = new Date(data.today || TODAY_STR);
const todayStr = today.toISOString().slice(0, 10);
const items = enrichItems(data);
const dayKey = (d: Date) => d.toISOString().slice(0, 10);
const active = items.filter((p) => p.status === "active");
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<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, 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<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.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: active.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"}`;
}