// 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 ( ); }; // 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 ( ); }; // 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 (