From 1194cafb37b2eded219a3963911987d68c0388a9 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 11 May 2026 20:38:52 -0400 Subject: [PATCH] Redesign Patterns page with insights, heatmap, and strain leaderboard Replace the bare-bones 4-chart layout with 7 sections: insight stat strip (peak day, busiest weekday, 30-day trend, avg rating), improved 13-week heatmap with gradient legend, day-of-week consumption bars, 90-day trend with rolling average overlay, spending breakdowns with item counts, and a top-5 strain leaderboard. Co-Authored-By: Claude Opus 4.6 --- web/src/views/ChartsView.tsx | 728 +++++++++++++++++++++++++++-------- 1 file changed, 575 insertions(+), 153 deletions(-) diff --git a/web/src/views/ChartsView.tsx b/web/src/views/ChartsView.tsx index 364e312..bd88617 100644 --- a/web/src/views/ChartsView.tsx +++ b/web/src/views/ChartsView.tsx @@ -1,28 +1,129 @@ +import { useMemo } from "react"; import type { Bootstrap } from "../types.js"; -import { helpers } from "../types.js"; +import { helpers, enrichItems } from "../types.js"; import type { Stats } from "../stats.js"; import { fmt } from "../format.js"; -import { BarChart, Card } from "../components/primitives/index.js"; +import { BarChart, Card, Stat, Icon } from "../components/primitives/index.js"; import { getStoredTimezone } from "../tz.js"; +const DOW_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const DOW_FULL = ["Sundays", "Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays"]; +const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0]; // Mon→Sun + export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) { + const tz = getStoredTimezone(); const series = stats.series90.map((s) => ({ date: s.date, grams: s.grams })); - const spendByMonth: Record = {}; - data.inventoryItems.forEach((i) => { - const k = i.purchaseDate.slice(0, 7); - spendByMonth[k] = (spendByMonth[k] ?? 0) + i.price; - }); - const months = Object.entries(spendByMonth).sort(); + const items = useMemo(() => enrichItems(data), [data]); - const spendByShop: Record = {}; - data.inventoryItems.forEach((i) => { - const name = helpers.shopName(data, i.shopId); - spendByShop[name] = (spendByShop[name] ?? 0) + i.price; - }); - const shopRanked = Object.entries(spendByShop).sort((a, b) => b[1] - a[1]); - const shopMax = shopRanked[0]?.[1] ?? 1; - const monthMax = Math.max(...months.map((x) => x[1]), 1); + // ── Peak day ────────────────────────────────────────────────────── + const peakDay = useMemo(() => { + if (!series.length) return null; + return series.reduce((best, d) => (d.grams > best.grams ? d : best), series[0]!); + }, [series]); + + // ── Day-of-week averages ────────────────────────────────────────── + const dowAvgs = useMemo(() => { + const totals = [0, 0, 0, 0, 0, 0, 0]; + const counts = [0, 0, 0, 0, 0, 0, 0]; + series.forEach((s) => { + const dow = new Date(s.date + "T12:00:00").getDay(); + totals[dow] += s.grams; + counts[dow]++; + }); + return totals.map((t, i) => t / (counts[i] || 1)); + }, [series]); + + const busiestDow = useMemo(() => { + let maxIdx = 0; + dowAvgs.forEach((v, i) => { + if (v > dowAvgs[maxIdx]!) maxIdx = i; + }); + return maxIdx; + }, [dowAvgs]); + + // ── 30-day trend ────────────────────────────────────────────────── + const trend = useMemo(() => { + const recent = series.slice(-30); + const prev = series.slice(-60, -30); + const recentSum = recent.reduce((s, d) => s + d.grams, 0); + const prevSum = prev.reduce((s, d) => s + d.grams, 0); + const pct = prevSum > 0 ? ((recentSum - prevSum) / prevSum) * 100 : 0; + return { pct, up: pct >= 0 }; + }, [series]); + + // ── Average rating ──────────────────────────────────────────────── + const ratingInfo = useMemo(() => { + const rated = items.filter((i) => i.rating != null); + if (!rated.length) return null; + const avg = rated.reduce((s, i) => s + i.rating!, 0) / rated.length; + return { avg, count: rated.length }; + }, [items]); + + // ── Spending ────────────────────────────────────────────────────── + const { months, monthMax, shopRanked, shopMax, shopItemCount } = useMemo(() => { + const spendByMonth: Record = {}; + const spendByShop: Record = {}; + const shopItems: Record = {}; + + data.inventoryItems.forEach((i) => { + const mk = i.purchaseDate.slice(0, 7); + spendByMonth[mk] = (spendByMonth[mk] ?? 0) + i.price; + + const name = helpers.shopName(data, i.shopId); + spendByShop[name] = (spendByShop[name] ?? 0) + i.price; + shopItems[name] = (shopItems[name] ?? 0) + 1; + }); + + const m = Object.entries(spendByMonth).sort(); + const sr = Object.entries(spendByShop).sort((a, b) => b[1] - a[1]); + + return { + months: m, + monthMax: Math.max(...m.map((x) => x[1]), 1), + shopRanked: sr, + shopMax: sr[0]?.[1] ?? 1, + shopItemCount: shopItems, + }; + }, [data]); + + // ── Strain leaderboard ──────────────────────────────────────────── + const topStrains = useMemo(() => { + const strainMap = new Map(data.strains.map((s) => [s.id, s.name])); + const acc: Record = {}; + + items.forEach((i) => { + const name = strainMap.get(i.strainId); + if (!name) return; + if (!acc[i.strainId]) acc[i.strainId] = { name, count: 0, totalRating: 0, ratedCount: 0 }; + acc[i.strainId].count++; + if (i.rating != null) { + acc[i.strainId].totalRating += i.rating; + acc[i.strainId].ratedCount++; + } + }); + + return Object.values(acc) + .map((d) => ({ + name: d.name, + count: d.count, + avgRating: d.ratedCount > 0 ? d.totalRating / d.ratedCount : null, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + }, [items, data.strains]); + + // ── Rolling 7-day average ───────────────────────────────────────── + const rolling7 = useMemo(() => { + return series.map((_, i) => { + const window = series.slice(Math.max(0, i - 6), i + 1); + return window.reduce((s, d) => s + d.grams, 0) / window.length; + }); + }, [series]); + + // ── Summary stats ───────────────────────────────────────────────── + const totalGrams90 = series.reduce((s, d) => s + d.grams, 0); + const dailyAvg90 = totalGrams90 / 90; return (
+ {/* ── 1. Header ──────────────────────────────────────────────── */}
-
Last 90 days
+
+ Last 90 days +

- Patterns & spend + Patterns

- -
-
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} -
- )} + {stats.purchaseCount === 0 ? ( + +
+ No data yet
-
- ({ value: s.grams }))} height={180} color="var(--sage)" /> - - -
- -
Spend by month
-
- {months.map(([m, v]) => { - const d = new Date(m + "-01"); - return ( -
-
- {d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: getStoredTimezone() })} -
-
-
-
-
- {fmt.moneyShort(v)} -
-
- ); - })} +
+ Add inventory items to start seeing consumption patterns and spending insights.
+ ) : ( + <> + {/* ── 2. Insight strip ──────────────────────────────────── */} +
+ + + + + + {ratingInfo.count} rated item{ratingInfo.count === 1 ? "" : "s"} + + ) : ( + "No ratings yet" + ) + } + /> +
- -
Spend by shop
-
- {shopRanked.map(([s, v]) => ( -
-
{s}
+ {/* ── 3. Heatmap ───────────────────────────────────────── */} + +
+
Consumption heatmap
+
+ 13 weeks — darker cells indicate higher daily use +
+
+ +
+ + {/* ── 4. Day-of-week pattern ───────────────────────────── */} + +
+
Day of week
+
+ Average daily consumption by weekday +
+
+ +
+ + {/* ── 5. 90-day trend ───────────────────────────────────── */} + +
+
+
Daily consumption
+
+ 90 days +
+
+
+
+ Total{" "} + + {totalGrams90.toFixed(1)} g + +
+
+ Avg{" "} + + {dailyAvg90.toFixed(2)} g/day + +
+
+ Finished{" "} + + {stats.consumedCount} + +
+
+
+ +
+ ({ value: s.grams }))} height={180} color="var(--sage)" /> + s.grams), 0.001)} height={180} /> +
+ +
+ {fmt.dateShort(series[0]?.date, tz)} + {fmt.dateShort(series[Math.floor(series.length / 2)]?.date, tz)} + today +
+
+ + {/* ── 6. Spending ──────────────────────────────────────── */} +
+ +
Spend by month
+
+ {months.map(([m, v]) => { + const d = new Date(m + "-01"); + return ( +
+
+ {d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: tz })} +
+
+
+
+
+ {fmt.moneyShort(v)} +
+
+ ); + })} +
+ + + +
Spend by shop
+
+ {shopRanked.map(([s, v]) => ( +
+
+ {s} +
+
+
+
+
+ + {fmt.moneyShort(v)} + + + ({shopItemCount[s]} items) + +
+
+ ))} +
+ +
+ + {/* ── 7. Strain leaderboard ────────────────────────────── */} + +
+
Top strains
+
+ {topStrains.length === 0 ? ( +
+ No strain data yet. +
+ ) : ( + topStrains.map((s, i) => (
+ className="mono" + style={{ width: 24, fontSize: 14, color: "var(--ink-3)", textAlign: "center" }} + > + {i + 1} +
+
+
+ {s.name} +
+
+
+ {s.count} purchase{s.count === 1 ? "" : "s"} +
+ {s.avgRating != null && ( +
+ + + {s.avgRating.toFixed(1)} + +
+ )}
-
- {fmt.moneyShort(v)} -
-
- ))} -
- -
- - -
Inferred consumption heatmap
-
- 13 weeks · darker = higher inferred daily use, prorated across each item's lifespan -
- -
+ )) + )} + + + )}
); } -function Heatmap({ series }: { series: { date: string; grams: number }[] }) { - const first = new Date(series[0]!.date); +// ═══════════════════════════════════════════════════════════════════════ +// Sub-components +// ═══════════════════════════════════════════════════════════════════════ + +function Heatmap({ series, tz }: { series: { date: string; grams: number }[]; tz: string }) { + const first = new Date(series[0]!.date + "T12:00:00"); const offset = first.getDay(); const cells: ({ date: string; grams: number } | null)[] = []; for (let i = 0; i < offset; i++) cells.push(null); @@ -174,29 +460,51 @@ function Heatmap({ series }: { series: { date: string; grams: number }[] }) { const max = Math.max(...series.map((s) => s.grams), 0.001); const colorFor = (g: number) => { if (g === 0) return "var(--bg-3)"; - const t = g / max; - return `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`; + const t = Math.min(g / max, 1); + return `oklch(${85 - t * 43}% ${0.02 + t * 0.06} 145)`; + }; + + const tipFor = (c: { date: string; grams: number }) => { + const d = new Date(c.date + "T12:00:00"); + const label = d.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + timeZone: tz, + }); + return `${label} — ${c.grams.toFixed(2)}g`; }; const days = ["S", "M", "T", "W", "T", "F", "S"]; + return ( -
-
+
+ {/* Day-of-week labels */} +
{days.map((d, i) => (
{d}
))}
-
+ + {/* Grid */} +
+ {/* Month labels */}
@@ -206,46 +514,55 @@ function Heatmap({ series }: { series: { date: string; grams: number }[] }) {
- {firstDay && new Date(firstDay.date).getDate() <= 7 - ? new Date(firstDay.date).toLocaleDateString("en-US", { month: "short", timeZone: getStoredTimezone() }) + {firstDay && new Date(firstDay.date + "T12:00:00").getDate() <= 7 + ? new Date(firstDay.date + "T12:00:00").toLocaleDateString("en-US", { + month: "short", + timeZone: tz, + }) : ""}
); })}
+ + {/* Cells */}
{cells.map((c, i) => (
))}
+ + {/* Legend */}
Less - {[0, 0.25, 0.5, 0.75, 1].map((t) => ( -
- ))} +
More
); } + +function DayOfWeekBars({ dowAvgs, busiestDow }: { dowAvgs: number[]; busiestDow: number }) { + const maxAvg = Math.max(...dowAvgs, 0.001); + const labels = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]; + + return ( +
+ {DOW_ORDER.map((dowIdx, i) => { + const avg = dowAvgs[dowIdx]!; + const isBusiest = dowIdx === busiestDow; + return ( +
+
+ {labels[i]} +
+
+
0 ? 4 : 0, + }} + /> +
+
+ {avg.toFixed(2)}g +
+
+ ); + })} +
+ ); +} + +function RollingAvgLine({ + values, + max, + height, +}: { + values: number[]; + max: number; + height: number; +}) { + if (values.length < 2) return null; + + const w = 1000; + const pad = 4; + const step = w / (values.length - 1 || 1); + const pts = values + .map((v, i) => { + const x = i * step; + const y = height - (v / max) * (height - pad) - pad / 2; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + + return ( + + + + ); +}