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