4044de7bfc
Build and push image / build (push) Successful in 56s
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>
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
// 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"}`;
|
||
}
|