// Add Product, Mark Consumed/Gone, Audit, Bins, Charts, Settings
const AddProductFlow = ({data, onClose, onSave}) => {
const [form, setForm] = React.useState({
name: "", brandId: data.brands[0].id, shopId: data.shops[0].id, type: "Flower",
weight: 3.5, countOriginal: 1, unitWeight: 0.7,
price: 45, thc: 22, cbd: 0.4, totalCannabinoids: 26,
purchaseDate: "2026-04-25", binId: data.bins[0].id,
sku: "", assetTag: ""
});
const update = (k, v) => setForm(f => ({...f, [k]: v}));
const cfg = data.types.find(t => t.id === form.type);
const isDiscrete = cfg?.kind === "discrete";
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
return (
e.stopPropagation()} style={{
width: "min(840px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
Identity
update("name", e.target.value)} />
update("sku", e.target.value)} />
update("assetTag", e.target.value)} />
Acquisition
{isDiscrete ? (
<>
update("countOriginal", +e.target.value)} />
update("unitWeight", +e.target.value)} />
>
) : (
update("weight", +e.target.value)} />
)}
update("price", +e.target.value)} />
update("purchaseDate", e.target.value)} />
{!isDiscrete && cpg > 0 && (
Cost per {cfg?.unit || "g"}: {fmt.money(cpg)}
)}
Cannabinoid profile
update("thc", +e.target.value)} />
update("cbd", +e.target.value)} />
update("totalCannabinoids", +e.target.value)} />
{form.name ? `"${form.name}" → ${data.bins.find(b=>b.id===form.binId)?.name}.` : "Fill in the name to continue."}
Cancel
onSave(form)}>Save product
);
};
// ─── CONSUME (mark finished) ──────────────────────────────────────
const ConsumeFlow = ({data, onClose, product: initialProduct}) => {
const active = data.products.filter(p => p.status === "active");
const [productId, setProductId] = React.useState(initialProduct?.id || active[0]?.id);
const [rating, setRating] = React.useState(4);
const [notes, setNotes] = React.useState("");
const [date, setDate] = React.useState("2026-04-25");
const product = data.products.find(p => p.id === productId);
if (!product) return null;
const bin = data.bins.find(b => b.id === product.binId);
const lifespan = Math.round((new Date(date) - new Date(product.purchaseDate))/86400000);
return (
e.stopPropagation()} style={{
width: "min(720px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
Archive · used up
Mark as finished
{product.name}
{H.brandName(data, product.brandId)} · {bin?.name} · purchased {fmt.dateShort(product.purchaseDate)}
Cancel
Mark finished
);
};
// ─── MARK GONE (lost / damaged / expired) ─────────────────────────
const MarkGoneFlow = ({data, onClose, product: initialProduct}) => {
const active = data.products.filter(p => p.status === "active");
const [productId, setProductId] = React.useState(initialProduct?.id || active[0]?.id);
const [reason, setReason] = React.useState("lost");
const [notes, setNotes] = React.useState("");
const [date, setDate] = React.useState("2026-04-25");
const product = data.products.find(p => p.id === productId);
if (!product) return null;
const reasons = [
["lost", "Lost / misplaced"],
["damaged", "Damaged"],
["expired", "Expired"],
["gifted", "Gifted away"],
["other", "Other"]
];
return (
e.stopPropagation()} style={{
width: "min(640px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
Archive · not consumed
Mark as gone
Use this when an item is lost, damaged, expired, or gifted away. Counts as spend but not as consumption, so daily averages stay accurate.
setDate(e.target.value)} />
Cancel
Mark gone
);
};
// ─── AUDIT MODAL ──────────────────────────────────────────────────
const AuditFlow = ({data, onClose, product: initialProduct}) => {
const overdueFirst = [...data.products]
.filter(p => p.status === "active")
.sort((a, b) => H.daysSinceCheck(b) - H.daysSinceCheck(a));
const [productId, setProductId] = React.useState(initialProduct?.id || overdueFirst[0]?.id);
const [date, setDate] = React.useState("2026-04-25");
const product = data.products.find(p => p.id === productId);
const cfg = product ? data.types.find(t => t.id === product.type) : null;
// For bulk: weighed/estimated value. For discrete: count + confirmedBy.
const last = product ? H.lastAudit(product) : null;
const initialValue = product
? (product.kind === "discrete"
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
: H.estimatedRemaining(product, "2026-04-25").toFixed(2))
: 0;
const [value, setValue] = React.useState(initialValue);
const [confirmedBy, setConfirmedBy] = React.useState("SKU");
React.useEffect(() => {
if (product) {
setValue(product.kind === "discrete"
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
: H.estimatedRemaining(product, "2026-04-25").toFixed(2));
}
}, [productId]);
if (!product) return null;
const auditMode = cfg?.auditMode || "weigh";
const prevValue = product.kind === "discrete"
? (product.countLastAudit != null ? product.countLastAudit : product.countOriginal)
: (last ? last.value : product.weight);
const auditModeLabels = {
weigh: { title: "Reweigh on a scale", desc: "Place the jar (minus tare) and record the new weight." },
estimate: { title: "Visual estimate", desc: "Eyeball the remaining amount — quick and approximate." },
presence: { title: "Confirm presence", desc: "Verify the item is still where you left it. Count units if applicable." }
};
const ml = auditModeLabels[auditMode];
return (
e.stopPropagation()} style={{
width: "min(720px, 96vw)", margin: "40px 20px", background: "var(--bg)",
border: "1px solid var(--line)", borderRadius: "var(--r-lg)", boxShadow: "var(--shadow-lg)"
}}>
{product.name}
{product.type} · {product.kind} · cadence every {cfg?.cadenceDays}d
LAST CHECKED
{last ? `${H.daysSinceCheck(product)}d ago` : "Never"}
{ml.desc}
setValue(e.target.value)}
/>
setDate(e.target.value)} />
{auditMode === "presence" && (
)}
Was
{prevValue} {cfg?.unit}
Δ since last
{(+value - +prevValue).toFixed(product.kind === "discrete" ? 0 : 2)} {cfg?.unit}
Next audit due in {cfg?.cadenceDays}d
Cancel
Save audit
);
};
// ─── BINS ─────────────────────────────────────────────────────────
const BinsView = ({data, onSelectProduct}) => {
return (
{data.bins.length} bins
Bins & storage
New bin
Where each active product physically lives. Archived items aren't assigned to a bin.
{data.bins.map(bin => {
const items = data.products.filter(p => p.binId === bin.id && p.status === "active");
const fillPct = items.length / bin.capacity;
const totalValue = items.reduce((s, p) => s + p.price * H.pctRemaining(p, "2026-04-25"), 0);
return (
{bin.name}
{items.length} / {bin.capacity}
{bin.location}
{fmt.money(totalValue)}
0.9 ? "var(--terracotta)" : fillPct > 0.7 ? "var(--amber)" : "var(--sage)"}} />
{items.length === 0 &&
Empty
}
{items.map(p => (
onSelectProduct(p)} style={{display: "flex", alignItems: "center", gap: 10, padding: "8px 14px", borderRadius: "var(--r-sm)", cursor: "pointer"}}>
{TYPE_GLYPHS[p.type]}
{p.name}
{H.brandName(data, p.brandId)}
{remainingShort(p)}
))}
);
})}
);
};
// ─── CHARTS ───────────────────────────────────────────────────────
const ChartsView = ({data, stats}) => {
const series = stats.series90.map(s => ({date: s.date, grams: s.grams, label: ""}));
const spendByMonth = {};
data.products.forEach(p => {
const k = p.purchaseDate.slice(0, 7);
spendByMonth[k] = (spendByMonth[k] || 0) + p.price;
});
const months = Object.entries(spendByMonth).sort();
const spendByShop = {};
data.products.forEach(p => {
const name = H.shopName(data, p.shopId);
spendByShop[name] = (spendByShop[name] || 0) + p.price;
});
const shopRanked = Object.entries(spendByShop).sort((a,b) => b[1]-a[1]);
return (
Last 90 days
Patterns & spend
Daily grams · 90 days
Total {series.reduce((s,e)=>s+e.grams,0).toFixed(1)} g
Avg {(series.reduce((s,e)=>s+e.grams,0)/90).toFixed(2)} g/day
Items finished {stats.consumedCount}
{stats.goneCount > 0 &&
Items gone {stats.goneCount}
}
({value: s.grams, label: ""}))} height={180} color="var(--sage)" />
Spend by month
{months.map(([m, v]) => {
const max = Math.max(...months.map(x => x[1]));
const d = new Date(m + "-01");
return (
{d.toLocaleDateString("en-US", {month: "short", year: "2-digit"})}
{fmt.moneyShort(v)}
);
})}
Spend by shop
{shopRanked.map(([s, v]) => {
const max = shopRanked[0][1];
return (
);
})}
Inferred consumption heatmap
13 weeks · darker = higher inferred daily use, prorated across each item's lifespan
);
};
const Heatmap = ({series}) => {
const first = new Date(series[0].date);
const offset = first.getDay();
const cells = [];
for (let i = 0; i < offset; i++) cells.push(null);
series.forEach(s => cells.push(s));
while (cells.length < 13 * 7) cells.push(null);
const max = Math.max(...series.map(s => s.grams), 0.001);
const colorFor = (g) => {
if (g === 0) return "var(--bg-3)";
const t = g / max;
return `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`;
};
const days = ["S","M","T","W","T","F","S"];
return (
{days.map((d, i) =>
{d}
)}
{Array.from({length: 13}).map((_, w) => {
const firstDay = cells[w * 7];
return
{firstDay && new Date(firstDay.date).getDate() <= 7 ? new Date(firstDay.date).toLocaleDateString("en-US", {month: "short"}) : ""}
;
})}
{cells.map((c, i) => (
))}
Less
{[0, 0.25, 0.5, 0.75, 1].map(t => (
))}
More
);
};
// ─── SETTINGS ─────────────────────────────────────────────────────
const SettingsView = ({data, tweaks, onTweakChange}) => {
return (
Appearance
{["light", "dark"].map(t => (
))}
{[["editorial","Editorial"], ["dense","Data-dense"], ["minimal","Minimal"]].map(([k,l]) => (
))}
{/* Shops */}
{data.shops.map((s, i) => {
const count = data.products.filter(p => p.shopId === s.id).length;
return (
{count} purchase{count===1?"":"s"}
);
})}
{/* Brands */}
{data.brands.map(b => {
const count = data.products.filter(p => p.brandId === b.id).length;
return (
);
})}
Library
p.status==="active").length} />
p.status==="consumed").length} />
p.status==="gone").length} />
All data is stored locally. Export anytime.
Export CSV
Export JSON
Reset all data
);
};
const SettingRow = ({label, hint, children}) => (
);
Object.assign(window, { AddProductFlow, ConsumeFlow, MarkGoneFlow, AuditFlow, BinsView, ChartsView, SettingsView });