Initial commit: Apothecary v0.4.0

This commit is contained in:
2026-05-03 20:19:26 -04:00
commit 027cf032be
55 changed files with 14678 additions and 0 deletions
+457
View File
@@ -0,0 +1,457 @@
// Shared utilities + small primitives
// Exported via window at bottom
const fmt = {
g: (n) => (n == null ? "—" : `${(+n).toFixed(2).replace(/\.?0+$/, "") || "0"} g`),
money: (n) => (n == null ? "—" : `$${(+n).toFixed(2)}`),
moneyShort: (n) => (n == null ? "—" : n >= 100 ? `$${Math.round(n)}` : `$${(+n).toFixed(2)}`),
pct: (n) => (n == null ? "—" : `${(+n).toFixed(1)}%`),
date: (s) => {
if (!s) return "—";
const d = new Date(s);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
},
dateShort: (s) => {
if (!s) return "—";
const d = new Date(s);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
},
daysAgo: (s) => {
if (!s) return "—";
const ms = Date.now() - new Date(s).getTime();
const d = Math.floor(ms / 86400000);
if (d === 0) return "today";
if (d === 1) return "yesterday";
if (d < 30) return `${d}d ago`;
if (d < 365) return `${Math.floor(d/30)}mo ago`;
return `${Math.floor(d/365)}y ago`;
}
};
const TYPE_GLYPHS = {
"Flower": "✿",
"Concentrate": "◆",
"Edible": "◐",
"Vaporizer": "▢",
"Pre-roll": "│",
"Tincture": "◯"
};
// Compute aggregate stats — derived from purchases + consumed/gone records + audits
function computeStats(data) {
const today = new Date(data.today || "2026-04-25");
const todayStr = today.toISOString().slice(0, 10);
const products = data.products;
const H = window.DATA_HELPERS;
const dayKey = (d) => d.toISOString().slice(0, 10);
const active = products.filter(p => p.status === "active");
const consumed = products.filter(p => p.status === "consumed" && p.consumedDate);
const gone = products.filter(p => p.status === "gone");
// Window helper for purchases
const purchasesIn = (days) => {
const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - days);
return products.filter(p => new Date(p.purchaseDate) >= cutoff);
};
const last7p = purchasesIn(7);
const last30p = purchasesIn(30);
const last90p = purchasesIn(90);
// Bulk-grams equivalent: bulk uses weight (or ml for tincture, ignored from grams);
// discrete uses unitWeight × units consumed
const bulkGrams = (p) => {
if (p.type === "Tincture" || p.type === "Edible") return 0; // not "weed grams"
if (p.kind === "bulk") return p.weight;
return (p.countOriginal || 0) * (p.unitWeight || 0);
};
const bulkGramsConsumed = (p) => {
// For consumed products: full weight equivalent
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) => {
// For active products: estimated grams used to date
if (p.type === "Tincture" || p.type === "Edible") return 0;
if (p.kind === "bulk") {
const est = H.estimatedRemaining(p, todayStr);
return Math.max(0, p.weight - est);
}
// discrete: (original - current) × unitWeight
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
return Math.max(0, (p.countOriginal - cur)) * (p.unitWeight || 0);
};
// Daily attribution: spread used grams over (purchase → consumed/today) for consumed/active.
// GONE items contribute $0 NOT grams (they were lost, not used).
const dailyGramsAttribution = {};
consumed.forEach(p => {
const g = bulkGramsConsumed(p);
if (g <= 0) return;
const start = new Date(p.purchaseDate);
const end = new Date(p.consumedDate);
const days = Math.max(1, Math.round((end - start) / 86400000));
const perDay = g / days;
for (let i = 0; i < days; i++) {
const d = new Date(start); d.setDate(d.getDate() + i);
dailyGramsAttribution[dayKey(d)] = (dailyGramsAttribution[dayKey(d)] || 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) / 86400000));
const perDay = used / days;
for (let i = 0; i < days; i++) {
const d = new Date(start); d.setDate(d.getDate() + i);
dailyGramsAttribution[dayKey(d)] = (dailyGramsAttribution[dayKey(d)] || 0) + perDay;
}
});
const seriesFor = (days) => {
const out = [];
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) => xs.reduce((s, x) => s + x.grams, 0);
const dailyAvg = sumG(series30) / 30;
const weeklyAvg = sumG(series30) / (30/7);
const monthlyAvg = sumG(series90) / 3;
// Spend
const totalSpend = products.reduce((s, p) => s + p.price, 0);
const goneSpend = gone.reduce((s, p) => s + p.price, 0);
const totalGrams = products.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);
// Inventory value (active, prorated by est. remaining %)
const inventoryValue = active.reduce((s, p) => {
return s + p.price * H.pctRemaining(p, todayStr);
}, 0);
// THC mg using avg THC of products
const avgThc = products.length ? products.reduce((s,p)=>s+p.thc,0) / products.length : 20;
const thcLast7 = Math.round(sumG(series7) * avgThc * 10);
const thcLast30 = Math.round(sumG(series30) * avgThc * 10);
// Avg lifespan of consumed
const lifespans = consumed.map(p => Math.max(1, Math.round((new Date(p.consumedDate) - new Date(p.purchaseDate))/86400000)));
const avgLifespan = lifespans.length ? lifespans.reduce((a,b)=>a+b,0) / lifespans.length : 0;
// Favorite shop / brand — keyed by id, look up name
const shopCount = {};
const brandCount = {};
products.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 = topShopEntry ? [H.shopName(data, topShopEntry[0]), topShopEntry[1]] : ["—", 0];
const favBrand = topBrandEntry ? [H.brandName(data, topBrandEntry[0]), topBrandEntry[1]] : ["—", 0];
// Type breakdown by est. grams on hand (active only)
const typeBreakdown = {};
active.forEach(p => {
let g;
if (p.type === "Tincture") g = H.estimatedRemaining(p, todayStr) * 0.5; // ml → display weight rough
else if (p.type === "Edible") g = (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * 0.3; // each gummy ~0.3g for chart only
else if (p.kind === "bulk") g = H.estimatedRemaining(p, todayStr);
else g = (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * (p.unitWeight || 0);
if (g > 0) typeBreakdown[p.type] = (typeBreakdown[p.type] || 0) + g;
});
// Flower-equivalent supply
const flowerEquivalent = active
.filter(p => p.type === "Flower" || p.type === "Pre-roll")
.reduce((s, p) => {
if (p.kind === "bulk") return s + H.estimatedRemaining(p, todayStr);
return s + (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * (p.unitWeight || 0);
}, 0);
const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0;
// Avg days between buys
const sortedDates = [...products].sort((a,b)=>new Date(a.purchaseDate)-new Date(b.purchaseDate)).map(p => new Date(p.purchaseDate));
const gaps = [];
for (let i = 1; i < sortedDates.length; i++) {
gaps.push((sortedDates[i] - sortedDates[i-1]) / 86400000);
}
const avgGap = gaps.length ? gaps.reduce((a,b)=>a+b,0)/gaps.length : 0;
// ─── Audits & low stock ───────────────────────────────────────
const overdueAudits = active.filter(p => H.auditOverdue(data, p, todayStr));
// Low stock:
// - Bulk: pctRemaining < 0.25
// - Discrete: GROUPED BY (brand + type) — total count ≤ 2
const lowStockBulk = active.filter(p => p.kind === "bulk" && H.pctRemaining(p, todayStr) < 0.25);
const discreteBrandGroups = {};
active.filter(p => p.kind === "discrete").forEach(p => {
const k = `${p.brandId}|${p.type}|${p.name}`;
if (!discreteBrandGroups[k]) {
discreteBrandGroups[k] = {
key: k, name: p.name, type: p.type, brandId: p.brandId,
items: [], totalCount: 0
};
}
discreteBrandGroups[k].items.push(p);
discreteBrandGroups[k].totalCount += (p.countLastAudit != null ? p.countLastAudit : p.countOriginal);
});
const lowStockDiscreteGroups = Object.values(discreteBrandGroups).filter(g => g.totalCount <= 2);
return {
dailyAvg, weeklyAvg, monthlyAvg,
totalSpend, avgPerGram, spend7, spend30, spend90, goneSpend,
inventoryValue,
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,
overdueAudits,
lowStockBulk,
lowStockDiscreteGroups
};
}
// Subtle inline icons (1px stroke)
const Icon = ({name, size = 18, color = "currentColor"}) => {
const paths = {
home: "M3 11l9-8 9 8M5 9v12h5v-7h4v7h5V9",
box: "M3 7l9-4 9 4v10l-9 4-9-4V7zM3 7l9 4 9-4M12 11v10",
chart: "M3 21V3M3 21h18M7 17v-7M11 17v-4M15 17v-9M19 17v-2",
plus: "M12 5v14M5 12h14",
check: "M5 13l4 4L19 7",
settings: "M12 3v3M12 18v3M5 5l2 2M17 17l2 2M3 12h3M18 12h3M5 19l2-2M17 7l2-2M12 8a4 4 0 100 8 4 4 0 000-8z",
search: "M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4-4",
filter: "M3 5h18M6 12h12M10 19h4",
bin: "M4 7h16M9 7V4h6v3M6 7v13h12V7",
leaf: "M5 19c0-7 5-14 14-14 0 9-5 14-14 14zM5 19l7-7",
flame: "M12 3c1 4 4 5 4 9a4 4 0 11-8 0c0-2 2-3 2-6 0-1 1-2 2-3z",
droplet: "M12 3l5 7a5 5 0 11-10 0l5-7z",
arrow: "M5 12h14M13 5l7 7-7 7",
arrowDown: "M12 5v14M5 13l7 7 7-7",
close: "M6 6l12 12M18 6L6 18",
edit: "M4 20h4l10-10-4-4L4 16v4zM14 6l4 4",
star: "M12 3l3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z",
calendar: "M5 5h14v15H5zM3 10h18M9 3v4M15 3v4",
tag: "M3 12V3h9l9 9-9 9-9-9zM7 7h.01"
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{flexShrink: 0}}>
<path d={paths[name] || ""} />
</svg>
);
};
// Sparkline
const Sparkline = ({values, width = 120, height = 32, color = "var(--ink)", fill = false}) => {
if (!values || values.length === 0) return null;
const max = Math.max(...values, 0.001);
const min = Math.min(...values, 0);
const span = max - min || 1;
const step = width / (values.length - 1 || 1);
const pts = values.map((v, i) => [i * step, height - ((v - min) / span) * (height - 4) - 2]);
const path = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ");
const fillPath = fill ? path + ` L ${width} ${height} L 0 ${height} Z` : null;
return (
<svg width={width} height={height} style={{display: "block", overflow: "visible"}}>
{fillPath && <path d={fillPath} fill={color} opacity="0.12" />}
<path d={path} stroke={color} strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
};
// Bar chart
const BarChart = ({data, height = 160, color = "var(--sage)", labels = false}) => {
if (!data || !data.length) return null;
const max = Math.max(...data.map(d => d.value), 0.001);
return (
<div style={{display: "flex", alignItems: "flex-end", gap: 2, height, width: "100%"}}>
{data.map((d, i) => (
<div key={i} style={{flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4, minWidth: 0}}>
<div style={{
width: "100%",
height: `${(d.value / max) * 100}%`,
background: d.value > 0 ? color : "var(--line)",
borderRadius: "2px 2px 0 0",
minHeight: d.value > 0 ? 2 : 1,
opacity: d.muted ? 0.4 : 1
}} />
{labels && <div style={{fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{d.label}</div>}
</div>
))}
</div>
);
};
// Donut chart
const Donut = ({segments, size = 160, thickness = 22}) => {
const total = segments.reduce((s, x) => s + x.value, 0);
const r = size / 2 - thickness / 2;
const c = 2 * Math.PI * r;
let offset = 0;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{transform: "rotate(-90deg)"}}>
<circle cx={size/2} cy={size/2} r={r} stroke="var(--line)" strokeWidth={thickness} fill="none" opacity="0.3" />
{segments.map((s, i) => {
const len = (s.value / total) * c;
const dash = `${len} ${c - len}`;
const el = (
<circle key={i} cx={size/2} cy={size/2} r={r} stroke={s.color} strokeWidth={thickness} fill="none"
strokeDasharray={dash} strokeDashoffset={-offset} strokeLinecap="butt" />
);
offset += len;
return el;
})}
</svg>
);
};
// Reusable card
const Card = ({children, style, padded = true, ...rest}) => {
const { padded: _ignored, ...domProps } = rest;
return (
<div {...domProps} style={{
background: "var(--surface)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: padded ? 20 : 0,
...style
}}>{children}</div>
);
};
// Stat card
const Stat = ({label, value, unit, sub, spark, accent, big}) => (
<div style={{
background: "var(--surface)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: 18,
display: "flex",
flexDirection: "column",
gap: 8,
minWidth: 0
}}>
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{label}</div>
<div style={{display: "flex", alignItems: "baseline", gap: 6}}>
<div className="serif" style={{fontSize: big ? 44 : 32, lineHeight: 1, color: accent || "var(--ink)", fontWeight: 500, letterSpacing: "-0.01em"}}>{value}</div>
{unit && <div style={{fontSize: 13, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{unit}</div>}
</div>
{sub && <div style={{fontSize: 12, color: "var(--ink-3)"}}>{sub}</div>}
{spark && <div style={{marginTop: 4}}>{spark}</div>}
</div>
);
// Pill / badge
const Pill = ({children, tone = "neutral", style}) => {
const tones = {
neutral: { bg: "var(--bg-3)", color: "var(--ink-2)" },
sage: { bg: "var(--sage-soft)", color: "var(--sage)" },
terra: { bg: "var(--terracotta-soft)", color: "var(--terracotta)" },
amber: { bg: "var(--amber-soft)", color: "oklch(48% 0.10 75)" },
outline: { bg: "transparent", color: "var(--ink-2)", border: "1px solid var(--line-strong)" }
};
const t = tones[tone] || tones.neutral;
return (
<span style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "3px 8px",
borderRadius: 999,
background: t.bg,
color: t.color,
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.02em",
border: t.border || "1px solid transparent",
...style
}}>{children}</span>
);
};
// Button — high contrast across themes
const Btn = ({children, variant = "ghost", icon, onClick, style, type, disabled}) => {
// Disabled: keep solid surface, dim the text only — never become low-contrast on bg
const variants = {
primary: disabled
? { background: "var(--bg-3)", color: "var(--ink-3)", border: "1px solid var(--line-strong)" }
: { background: "var(--ink)", color: "var(--bg)", border: "1px solid var(--ink)" },
secondary: { background: "var(--surface)", color: "var(--ink)", border: "1px solid var(--line-strong)" },
ghost: { background: "transparent", color: "var(--ink-2)", border: "1px solid transparent" },
danger: { background: "var(--terracotta)", color: "oklch(98% 0.01 40)", border: "1px solid var(--terracotta)" },
sage: { background: "var(--sage)", color: "oklch(98% 0.01 145)", border: "1px solid var(--sage)" }
};
const v = variants[variant];
return (
<button type={type} onClick={onClick} disabled={disabled} style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
borderRadius: "var(--r-md)",
fontSize: 13,
fontWeight: 600,
transition: "all 120ms",
cursor: disabled ? "not-allowed" : "pointer",
...v,
...style
}}>
{icon && <Icon name={icon} size={14} />}
{children}
</button>
);
};
// Field
const Field = ({label, children, hint, span = 1}) => (
<label style={{display: "flex", flexDirection: "column", gap: 6, gridColumn: `span ${span}`}}>
<span className="smallcaps" style={{color: "var(--ink-3)"}}>{label}</span>
{children}
{hint && <span style={{fontSize: 11, color: "var(--ink-3)"}}>{hint}</span>}
</label>
);
const inputStyle = {
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
padding: "10px 12px",
fontSize: 13,
color: "var(--ink)",
outline: "none",
fontFamily: "var(--sans)",
width: "100%"
};
const Input = (props) => <input style={inputStyle} {...props} />;
const Select = ({children, ...rest}) => <select style={{...inputStyle, appearance: "auto"}} {...rest}>{children}</select>;
const Textarea = (props) => <textarea style={{...inputStyle, minHeight: 80, resize: "vertical"}} {...props} />;
Object.assign(window, {
fmt, computeStats, TYPE_GLYPHS,
Icon, Sparkline, BarChart, Donut,
Card, Stat, Pill, Btn, Field, Input, Select, Textarea, inputStyle
});