Files
Apothecary/web/src/stats.ts
T
josh 4044de7bfc
Build and push image / build (push) Successful in 56s
Add timezone preference and fix all date handling to be timezone-aware
Dates were computed using browser/server local time with no explicit timezone,
causing inconsistencies when server runs in UTC. Now all "today" computations
and date formatting use the user's chosen IANA timezone, persisted in
localStorage and selectable from Settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 22:30:01 -04:00

306 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, 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;
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<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: 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"}`;
}