Remove estimated remaining decay, use audit values directly
Build and push image / build (push) Successful in 1m6s
Build and push image / build (push) Successful in 1m6s
Replace burn-rate estimation (linear decay between audits) with actual last audit values. Remaining is now always the last weigh-in value or original weight if no audits exist — no more speculative daily decay. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,8 +38,8 @@ export function ProductDetail({
|
|||||||
const bin = data.bins.find((b) => b.id === item.binId);
|
const bin = data.bins.find((b) => b.id === item.binId);
|
||||||
const cfg = TYPES.find((t) => t.id === item.type);
|
const cfg = TYPES.find((t) => t.id === item.type);
|
||||||
const product = data.products.find((p) => p.id === item.productId);
|
const product = data.products.find((p) => p.id === item.productId);
|
||||||
const pctRemaining = helpers.pctRemaining(item, getToday(getStoredTimezone()));
|
const pctRemaining = helpers.pctRemaining(item);
|
||||||
const est = helpers.estimatedRemaining(item, getToday(getStoredTimezone()));
|
const rem = helpers.remaining(item);
|
||||||
const last = helpers.lastAudit(item);
|
const last = helpers.lastAudit(item);
|
||||||
const overdue = helpers.auditOverdue(item, getToday(getStoredTimezone()));
|
const overdue = helpers.auditOverdue(item, getToday(getStoredTimezone()));
|
||||||
const sinceCheck = helpers.daysSinceCheck(item, getToday(getStoredTimezone()));
|
const sinceCheck = helpers.daysSinceCheck(item, getToday(getStoredTimezone()));
|
||||||
@@ -293,12 +293,12 @@ export function ProductDetail({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
{item.kind === "discrete" ? "Units remaining" : "Estimated remaining"}
|
{item.kind === "discrete" ? "Units remaining" : "Remaining"}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontFamily: "var(--mono)", fontSize: 13 }}>
|
<div style={{ fontFamily: "var(--mono)", fontSize: 13 }}>
|
||||||
{item.kind === "discrete"
|
{item.kind === "discrete"
|
||||||
? `${item.countLastAudit ?? item.countOriginal} of ${item.countOriginal}`
|
? `${item.countLastAudit ?? item.countOriginal} of ${item.countOriginal}`
|
||||||
: `${est.toFixed(2)} of ${item.weight} ${cfg?.unit ?? "g"}`}
|
: `${rem.toFixed(2)} of ${item.weight} ${cfg?.unit ?? "g"}`}
|
||||||
<span style={{ color: "var(--ink-3)", marginLeft: 8 }}>
|
<span style={{ color: "var(--ink-3)", marginLeft: 8 }}>
|
||||||
{Math.round(pctRemaining * 100)}%
|
{Math.round(pctRemaining * 100)}%
|
||||||
</span>
|
</span>
|
||||||
@@ -327,8 +327,7 @@ export function ProductDetail({
|
|||||||
fontStyle: "italic",
|
fontStyle: "italic",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Estimated by linear decay since last {last.mode} on {fmt.dateShort(last.date, getStoredTimezone())} ({last.value.toFixed(2)}
|
Last {last.mode} on {fmt.dateShort(last.date, getStoredTimezone())}
|
||||||
{cfg?.unit}). Re-audit to update.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.containerWeight != null && last && (
|
{item.containerWeight != null && last && (
|
||||||
@@ -340,7 +339,7 @@ export function ProductDetail({
|
|||||||
fontStyle: "italic",
|
fontStyle: "italic",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Expected container total: {((item.containerWeight - item.weight) + est).toFixed(2)}g
|
Expected container total: {((item.containerWeight - item.weight) + rem).toFixed(2)}g
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ export function AuditFlow({
|
|||||||
if (i.kind === "discrete") {
|
if (i.kind === "discrete") {
|
||||||
return String(i.countLastAudit ?? i.countOriginal);
|
return String(i.countLastAudit ?? i.countOriginal);
|
||||||
}
|
}
|
||||||
return helpers.estimatedRemaining(i, getToday(getStoredTimezone())).toFixed(2);
|
const last = helpers.lastAudit(i);
|
||||||
|
return (last ? last.value : i.weight).toFixed(2);
|
||||||
};
|
};
|
||||||
const [value, setValue] = useState<string>(initialValueFor(item));
|
const [value, setValue] = useState<string>(initialValueFor(item));
|
||||||
const isConcentrate = item?.type === "Concentrate";
|
const isConcentrate = item?.type === "Concentrate";
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export function CheckinFlow({
|
|||||||
|
|
||||||
const isBulk = item?.kind === "bulk";
|
const isBulk = item?.kind === "bulk";
|
||||||
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
|
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
|
||||||
const est = item ? helpers.estimatedRemaining(item, getToday(getStoredTimezone())) : 0;
|
const lastAudit = item ? helpers.lastAudit(item) : null;
|
||||||
|
const rem = item ? (lastAudit ? lastAudit.value : item.weight) : 0;
|
||||||
|
|
||||||
const checkin = useMutation({
|
const checkin = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
@@ -63,7 +64,8 @@ export function CheckinFlow({
|
|||||||
const scanned = allItems.find((i) => i.id === result.item.id);
|
const scanned = allItems.find((i) => i.id === result.item.id);
|
||||||
if (scanned?.prevBinId) setBinId(scanned.prevBinId);
|
if (scanned?.prevBinId) setBinId(scanned.prevBinId);
|
||||||
if (scanned?.kind === "bulk") {
|
if (scanned?.kind === "bulk") {
|
||||||
setRemaining(helpers.estimatedRemaining(scanned, getToday(getStoredTimezone())).toFixed(2));
|
const scanLast = helpers.lastAudit(scanned);
|
||||||
|
setRemaining((scanLast ? scanLast.value : scanned.weight).toFixed(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -157,7 +159,7 @@ export function CheckinFlow({
|
|||||||
{isBulk && (
|
{isBulk && (
|
||||||
<Field
|
<Field
|
||||||
label={`Weight now (${cfg?.unit ?? "g"})`}
|
label={`Weight now (${cfg?.unit ?? "g"})`}
|
||||||
hint={`Was ~${est.toFixed(2)} ${cfg?.unit ?? "g"}`}
|
hint={`Last audit: ${rem.toFixed(2)} ${cfg?.unit ?? "g"}`}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
+11
-14
@@ -1,5 +1,5 @@
|
|||||||
// computeStats — derives daily/weekly/monthly grams from purchase + audit
|
// computeStats — derives daily/weekly/monthly grams from purchase + audit
|
||||||
// history, using estimated remaining for active items and full weight for
|
// history, using last audit values for active items and full weight for
|
||||||
// consumed. Gone items contribute spend but NOT grams (so daily averages
|
// consumed. Gone items contribute spend but NOT grams (so daily averages
|
||||||
// stay clean). Operates on the enriched Item[] view, not raw products.
|
// stay clean). Operates on the enriched Item[] view, not raw products.
|
||||||
|
|
||||||
@@ -88,8 +88,8 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
const bulkGramsUsedSoFar = (p: Item): number => {
|
const bulkGramsUsedSoFar = (p: Item): number => {
|
||||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
||||||
if (p.kind === "bulk") {
|
if (p.kind === "bulk") {
|
||||||
const est = helpers.estimatedRemaining(p, todayStr);
|
const rem = helpers.remaining(p);
|
||||||
return Math.max(0, p.weight - est);
|
return Math.max(0, p.weight - rem);
|
||||||
}
|
}
|
||||||
const cur = p.countLastAudit ?? p.countOriginal;
|
const cur = p.countLastAudit ?? p.countOriginal;
|
||||||
return Math.max(0, p.countOriginal - cur) * (p.unitWeight || 0);
|
return Math.max(0, p.countOriginal - cur) * (p.unitWeight || 0);
|
||||||
@@ -152,16 +152,13 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
const spend90 = last90p.reduce((s, p) => s + p.price, 0);
|
const spend90 = last90p.reduce((s, p) => s + p.price, 0);
|
||||||
|
|
||||||
const inventoryValue = active.reduce(
|
const inventoryValue = active.reduce(
|
||||||
(s, p) => s + p.price * helpers.pctRemaining(p, todayStr),
|
(s, p) => s + p.price * helpers.pctRemaining(p),
|
||||||
0,
|
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) => {
|
const inventoryGrams = active.reduce((s, p) => {
|
||||||
if (p.type === "Tincture" || p.type === "Edible") return s;
|
if (p.type === "Tincture" || p.type === "Edible") return s;
|
||||||
if (p.kind === "bulk") return s + helpers.estimatedRemaining(p, todayStr);
|
if (p.kind === "bulk") return s + helpers.remaining(p);
|
||||||
const cur = p.countLastAudit ?? p.countOriginal;
|
const cur = p.countLastAudit ?? p.countOriginal;
|
||||||
return s + cur * (p.unitWeight || 0);
|
return s + cur * (p.unitWeight || 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -198,10 +195,10 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
const typeBreakdown: Record<string, number> = {};
|
const typeBreakdown: Record<string, number> = {};
|
||||||
active.forEach((p) => {
|
active.forEach((p) => {
|
||||||
let g: number;
|
let g: number;
|
||||||
if (p.type === "Tincture") g = helpers.estimatedRemaining(p, todayStr) * 0.5;
|
if (p.type === "Tincture") g = helpers.remaining(p) * 0.5;
|
||||||
else if (p.type === "Edible")
|
else if (p.type === "Edible")
|
||||||
g = (p.countLastAudit ?? p.countOriginal) * 0.3;
|
g = (p.countLastAudit ?? p.countOriginal) * 0.3;
|
||||||
else if (p.kind === "bulk") g = helpers.estimatedRemaining(p, todayStr);
|
else if (p.kind === "bulk") g = helpers.remaining(p);
|
||||||
else g = (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
else g = (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
||||||
if (g > 0) typeBreakdown[p.type] = (typeBreakdown[p.type] || 0) + g;
|
if (g > 0) typeBreakdown[p.type] = (typeBreakdown[p.type] || 0) + g;
|
||||||
});
|
});
|
||||||
@@ -209,7 +206,7 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
const flowerEquivalent = active
|
const flowerEquivalent = active
|
||||||
.filter((p) => p.type === "Flower" || p.type === "Pre-roll")
|
.filter((p) => p.type === "Flower" || p.type === "Pre-roll")
|
||||||
.reduce((s, p) => {
|
.reduce((s, p) => {
|
||||||
if (p.kind === "bulk") return s + helpers.estimatedRemaining(p, todayStr);
|
if (p.kind === "bulk") return s + helpers.remaining(p);
|
||||||
return s + (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
return s + (p.countLastAudit ?? p.countOriginal) * (p.unitWeight || 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0;
|
const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0;
|
||||||
@@ -226,7 +223,7 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
const overdueAudits = active.filter((p) => helpers.auditOverdue(p, todayStr));
|
const overdueAudits = active.filter((p) => helpers.auditOverdue(p, todayStr));
|
||||||
|
|
||||||
const lowStockBulk = active.filter(
|
const lowStockBulk = active.filter(
|
||||||
(p) => p.kind === "bulk" && helpers.pctRemaining(p, todayStr) < 0.25,
|
(p) => p.kind === "bulk" && helpers.pctRemaining(p) < 0.25,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group discrete instances by product so multiple jars of the same
|
// Group discrete instances by product so multiple jars of the same
|
||||||
@@ -299,7 +296,7 @@ export function remainingShort(p: Item): string {
|
|||||||
const cur = p.countLastAudit ?? p.countOriginal;
|
const cur = p.countLastAudit ?? p.countOriginal;
|
||||||
return `${cur} ct`;
|
return `${cur} ct`;
|
||||||
}
|
}
|
||||||
const est = helpers.estimatedRemaining(p);
|
const rem = helpers.remaining(p);
|
||||||
const trimmed = est.toFixed(2).replace(/\.?0+$/, "") || "0";
|
const trimmed = rem.toFixed(2).replace(/\.?0+$/, "") || "0";
|
||||||
return `${trimmed} ${cfg?.unit ?? "g"}`;
|
return `${trimmed} ${cfg?.unit ?? "g"}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-13
@@ -206,28 +206,20 @@ export const helpers = {
|
|||||||
if (!cfg) return false;
|
if (!cfg) return false;
|
||||||
return this.daysSinceCheck(p, today) >= cfg.cadenceDays;
|
return this.daysSinceCheck(p, today) >= cfg.cadenceDays;
|
||||||
},
|
},
|
||||||
estimatedRemaining(p: Item, today = TODAY_STR): number {
|
remaining(p: Item): number {
|
||||||
if (p.status !== "active" && p.status !== "checked-out") return 0;
|
if (p.status !== "active" && p.status !== "checked-out") return 0;
|
||||||
if (p.kind === "discrete") {
|
if (p.kind === "discrete") {
|
||||||
return p.countLastAudit ?? p.countOriginal;
|
return p.countLastAudit ?? p.countOriginal;
|
||||||
}
|
}
|
||||||
const last = this.lastAudit(p);
|
const last = this.lastAudit(p);
|
||||||
if (!last) return p.weight;
|
return last ? last.value : p.weight;
|
||||||
const daysSinceBase = Math.max(
|
|
||||||
0,
|
|
||||||
Math.floor((+new Date(today) - +new Date(last.date)) / 86_400_000),
|
|
||||||
);
|
|
||||||
const expectedLifespan =
|
|
||||||
p.type === "Flower" ? 35 : p.type === "Concentrate" ? 40 : 90;
|
|
||||||
const dailyBurn = p.weight / expectedLifespan;
|
|
||||||
return Math.max(0, last.value - dailyBurn * daysSinceBase);
|
|
||||||
},
|
},
|
||||||
pctRemaining(p: Item, today = TODAY_STR): number {
|
pctRemaining(p: Item): number {
|
||||||
if (p.kind === "discrete") {
|
if (p.kind === "discrete") {
|
||||||
const cur = p.countLastAudit ?? p.countOriginal;
|
const cur = p.countLastAudit ?? p.countOriginal;
|
||||||
return p.countOriginal > 0 ? cur / p.countOriginal : 0;
|
return p.countOriginal > 0 ? cur / p.countOriginal : 0;
|
||||||
}
|
}
|
||||||
const est = this.estimatedRemaining(p, today);
|
const rem = this.remaining(p);
|
||||||
return p.weight > 0 ? est / p.weight : 0;
|
return p.weight > 0 ? rem / p.weight : 0;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useMemo, useState } from "react";
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Bin, Item } from "../types.js";
|
import type { Bootstrap, Bin, Item } from "../types.js";
|
||||||
import { helpers, enrichItems } from "../types.js";
|
import { helpers, enrichItems } from "../types.js";
|
||||||
import { getToday, getStoredTimezone } from "../tz.js";
|
|
||||||
import { remainingShort } from "../stats.js";
|
import { remainingShort } from "../stats.js";
|
||||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
@@ -124,7 +123,7 @@ export function BinsView({
|
|||||||
);
|
);
|
||||||
const fillPct = slotsUsed / bin.capacity;
|
const fillPct = slotsUsed / bin.capacity;
|
||||||
const totalValue = binItems.reduce(
|
const totalValue = binItems.reduce(
|
||||||
(s, i) => s + i.price * helpers.pctRemaining(i, getToday(getStoredTimezone())),
|
(s, i) => s + i.price * helpers.pctRemaining(i),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { Bootstrap, Item } from "../types.js";
|
import type { Bootstrap, Item } from "../types.js";
|
||||||
import { helpers, enrichItems } from "../types.js";
|
import { helpers, enrichItems } from "../types.js";
|
||||||
import { getToday, getStoredTimezone } from "../tz.js";
|
import { getStoredTimezone } from "../tz.js";
|
||||||
import { remainingShort } from "../stats.js";
|
import { remainingShort } from "../stats.js";
|
||||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
import { Btn, Card, Icon } from "../components/primitives/index.js";
|
import { Btn, Card, Icon } from "../components/primitives/index.js";
|
||||||
@@ -128,7 +128,7 @@ function CustodyRow({
|
|||||||
onMarkGone: () => void;
|
onMarkGone: () => void;
|
||||||
}) {
|
}) {
|
||||||
const glyph = TYPE_GLYPHS[item.type] ?? "·";
|
const glyph = TYPE_GLYPHS[item.type] ?? "·";
|
||||||
const pct = helpers.pctRemaining(item, getToday(getStoredTimezone()));
|
const pct = helpers.pctRemaining(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function Dashboard({
|
|||||||
label="Inventory on hand"
|
label="Inventory on hand"
|
||||||
value={stats.inventoryGrams.toFixed(stats.inventoryGrams >= 10 ? 1 : 2)}
|
value={stats.inventoryGrams.toFixed(stats.inventoryGrams >= 10 ? 1 : 2)}
|
||||||
unit="g"
|
unit="g"
|
||||||
sub="Estimated remaining across active jars"
|
sub="Remaining across active jars"
|
||||||
/>
|
/>
|
||||||
<Stat
|
<Stat
|
||||||
label="Spent all-time"
|
label="Spent all-time"
|
||||||
@@ -340,7 +340,7 @@ export function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{lowBulk.slice(0, 3).map((i) => {
|
{lowBulk.slice(0, 3).map((i) => {
|
||||||
const pct = helpers.pctRemaining(i, getToday(getStoredTimezone()));
|
const pct = helpers.pctRemaining(i);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i.id}
|
key={i.id}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function Inventory({
|
|||||||
if (sortBy === "name") return dir * a.name.localeCompare(b.name);
|
if (sortBy === "name") return dir * a.name.localeCompare(b.name);
|
||||||
if (sortBy === "thc") return dir * (b.thc - a.thc);
|
if (sortBy === "thc") return dir * (b.thc - a.thc);
|
||||||
if (sortBy === "remaining")
|
if (sortBy === "remaining")
|
||||||
return dir * (helpers.estimatedRemaining(b, getToday(getStoredTimezone())) - helpers.estimatedRemaining(a, getToday(getStoredTimezone())));
|
return dir * (helpers.remaining(b) - helpers.remaining(a));
|
||||||
if (sortBy === "price") return dir * (b.price - a.price);
|
if (sortBy === "price") return dir * (b.price - a.price);
|
||||||
if (sortBy === "audit")
|
if (sortBy === "audit")
|
||||||
return dir * (helpers.daysSinceCheck(b, getToday(getStoredTimezone())) - helpers.daysSinceCheck(a, getToday(getStoredTimezone())));
|
return dir * (helpers.daysSinceCheck(b, getToday(getStoredTimezone())) - helpers.daysSinceCheck(a, getToday(getStoredTimezone())));
|
||||||
@@ -512,7 +512,7 @@ function GroupHeader({
|
|||||||
}) {
|
}) {
|
||||||
const active = group.items.filter((i) => i.status === "active");
|
const active = group.items.filter((i) => i.status === "active");
|
||||||
const totalRemaining = active.reduce((s, i) => {
|
const totalRemaining = active.reduce((s, i) => {
|
||||||
if (i.kind === "bulk") return s + helpers.estimatedRemaining(i, getToday(getStoredTimezone()));
|
if (i.kind === "bulk") return s + helpers.remaining(i);
|
||||||
return s + (i.countLastAudit ?? i.countOriginal);
|
return s + (i.countLastAudit ?? i.countOriginal);
|
||||||
}, 0);
|
}, 0);
|
||||||
const lastBuy = group.items.reduce((max, i) => {
|
const lastBuy = group.items.reduce((max, i) => {
|
||||||
@@ -591,7 +591,7 @@ function ItemRow({
|
|||||||
onToggle: (id: string, shiftKey: boolean) => void;
|
onToggle: (id: string, shiftKey: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const bin = data.bins.find((b) => b.id === i.binId);
|
const bin = data.bins.find((b) => b.id === i.binId);
|
||||||
const pctRemaining = helpers.pctRemaining(i, getToday(getStoredTimezone()));
|
const pctRemaining = helpers.pctRemaining(i);
|
||||||
const overdue = helpers.auditOverdue(i, getToday(getStoredTimezone()));
|
const overdue = helpers.auditOverdue(i, getToday(getStoredTimezone()));
|
||||||
const sinceCheck = helpers.daysSinceCheck(i, getToday(getStoredTimezone()));
|
const sinceCheck = helpers.daysSinceCheck(i, getToday(getStoredTimezone()));
|
||||||
const last = helpers.lastAudit(i);
|
const last = helpers.lastAudit(i);
|
||||||
|
|||||||
Reference in New Issue
Block a user