Files
Apothecary/weed-tracker/project/primitives.jsx
T

458 lines
18 KiB
React
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.
// 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
});