Redesign Patterns page with insights, heatmap, and strain leaderboard
Build and push image / build (push) Successful in 1m13s
Build and push image / build (push) Successful in 1m13s
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 <noreply@anthropic.com>
This commit is contained in:
+575
-153
@@ -1,28 +1,129 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import type { Bootstrap } from "../types.js";
|
import type { Bootstrap } from "../types.js";
|
||||||
import { helpers } from "../types.js";
|
import { helpers, enrichItems } from "../types.js";
|
||||||
import type { Stats } from "../stats.js";
|
import type { Stats } from "../stats.js";
|
||||||
import { fmt } from "../format.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";
|
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 }) {
|
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 series = stats.series90.map((s) => ({ date: s.date, grams: s.grams }));
|
||||||
|
|
||||||
const spendByMonth: Record<string, number> = {};
|
const items = useMemo(() => enrichItems(data), [data]);
|
||||||
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 spendByShop: Record<string, number> = {};
|
// ── Peak day ──────────────────────────────────────────────────────
|
||||||
data.inventoryItems.forEach((i) => {
|
const peakDay = useMemo(() => {
|
||||||
const name = helpers.shopName(data, i.shopId);
|
if (!series.length) return null;
|
||||||
spendByShop[name] = (spendByShop[name] ?? 0) + i.price;
|
return series.reduce((best, d) => (d.grams > best.grams ? d : best), series[0]!);
|
||||||
});
|
}, [series]);
|
||||||
const shopRanked = Object.entries(spendByShop).sort((a, b) => b[1] - a[1]);
|
|
||||||
const shopMax = shopRanked[0]?.[1] ?? 1;
|
// ── Day-of-week averages ──────────────────────────────────────────
|
||||||
const monthMax = Math.max(...months.map((x) => x[1]), 1);
|
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<string, number> = {};
|
||||||
|
const spendByShop: Record<string, number> = {};
|
||||||
|
const shopItems: Record<string, number> = {};
|
||||||
|
|
||||||
|
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<string, { name: string; count: number; totalRating: number; ratedCount: number }> = {};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -32,139 +133,324 @@ export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
|
|||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* ── 1. Header ──────────────────────────────────────────────── */}
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Last 90 days</div>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
|
Last 90 days
|
||||||
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="serif"
|
className="serif"
|
||||||
style={{ fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
|
style={{ fontSize: 44, margin: "6px 0 0", fontWeight: 500, letterSpacing: "-0.02em" }}
|
||||||
>
|
>
|
||||||
Patterns & spend
|
Patterns
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card style={{ marginBottom: 14 }}>
|
{stats.purchaseCount === 0 ? (
|
||||||
<div
|
<Card style={{ textAlign: "center", padding: "60px 32px" }}>
|
||||||
style={{
|
<div className="serif" style={{ fontSize: 28, fontWeight: 500, marginBottom: 8 }}>
|
||||||
display: "flex",
|
No data yet
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "baseline",
|
|
||||||
marginBottom: 18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="serif" style={{ fontSize: 22 }}>Daily grams · 90 days</div>
|
|
||||||
<div style={{ display: "flex", gap: 24, fontSize: 12, color: "var(--ink-3)" }}>
|
|
||||||
<div>
|
|
||||||
Total{" "}
|
|
||||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
|
||||||
{series.reduce((s, e) => s + e.grams, 0).toFixed(1)} g
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Avg{" "}
|
|
||||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
|
||||||
{(series.reduce((s, e) => s + e.grams, 0) / 90).toFixed(2)} g/day
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Items finished{" "}
|
|
||||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>{stats.consumedCount}</span>
|
|
||||||
</div>
|
|
||||||
{stats.goneCount > 0 && (
|
|
||||||
<div>
|
|
||||||
Items gone{" "}
|
|
||||||
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>{stats.goneCount}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
<BarChart data={series.map((s) => ({ value: s.grams }))} height={180} color="var(--sage)" />
|
style={{
|
||||||
</Card>
|
fontSize: 14,
|
||||||
|
color: "var(--ink-3)",
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
|
maxWidth: 400,
|
||||||
<Card>
|
margin: "0 auto",
|
||||||
<div className="serif" style={{ fontSize: 22, marginBottom: 18 }}>Spend by month</div>
|
}}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
>
|
||||||
{months.map(([m, v]) => {
|
Add inventory items to start seeing consumption patterns and spending insights.
|
||||||
const d = new Date(m + "-01");
|
|
||||||
return (
|
|
||||||
<div key={m} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)", width: 60 }}>
|
|
||||||
{d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: getStoredTimezone() })}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: 24,
|
|
||||||
background: "var(--bg-2)",
|
|
||||||
borderRadius: 4,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: `${(v / monthMax) * 100}%`,
|
|
||||||
height: "100%",
|
|
||||||
background: "var(--terracotta)",
|
|
||||||
borderRadius: 4,
|
|
||||||
opacity: 0.85,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{ width: 70, textAlign: "right", fontSize: 13 }}>
|
|
||||||
{fmt.moneyShort(v)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── 2. Insight strip ──────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||||
|
gap: 14,
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stat
|
||||||
|
label="Peak day"
|
||||||
|
value={peakDay ? peakDay.grams.toFixed(1) : "—"}
|
||||||
|
unit="g"
|
||||||
|
sub={peakDay ? fmt.dateShort(peakDay.date, tz) : undefined}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Busiest day"
|
||||||
|
value={DOW_FULL[busiestDow]}
|
||||||
|
sub={`${dowAvgs[busiestDow]!.toFixed(2)}g average`}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="30-day trend"
|
||||||
|
value={`${trend.up ? "+" : ""}${trend.pct.toFixed(0)}%`}
|
||||||
|
accent={trend.up ? "var(--sage)" : "var(--terracotta)"}
|
||||||
|
sub="vs previous 30 days"
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Avg rating"
|
||||||
|
value={ratingInfo ? ratingInfo.avg.toFixed(1) : "—"}
|
||||||
|
sub={
|
||||||
|
ratingInfo ? (
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||||
|
<Icon name="star" size={11} color="var(--amber)" />
|
||||||
|
{ratingInfo.count} rated item{ratingInfo.count === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"No ratings yet"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
{/* ── 3. Heatmap ───────────────────────────────────────── */}
|
||||||
<div className="serif" style={{ fontSize: 22, marginBottom: 18 }}>Spend by shop</div>
|
<Card style={{ marginBottom: 14 }}>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
<div style={{ marginBottom: 18 }}>
|
||||||
{shopRanked.map(([s, v]) => (
|
<div className="serif" style={{ fontSize: 22 }}>Consumption heatmap</div>
|
||||||
<div key={s} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 4 }}>
|
||||||
<div style={{ flex: 1.5, fontSize: 13, color: "var(--ink-2)" }}>{s}</div>
|
13 weeks — darker cells indicate higher daily use
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Heatmap series={series} tz={tz} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 4. Day-of-week pattern ───────────────────────────── */}
|
||||||
|
<Card style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{ marginBottom: 18 }}>
|
||||||
|
<div className="serif" style={{ fontSize: 22 }}>Day of week</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 4 }}>
|
||||||
|
Average daily consumption by weekday
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DayOfWeekBars dowAvgs={dowAvgs} busiestDow={busiestDow} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 5. 90-day trend ───────────────────────────────────── */}
|
||||||
|
<Card style={{ marginBottom: 14 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
|
marginBottom: 18,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="serif" style={{ fontSize: 22 }}>Daily consumption</div>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginTop: 4 }}>
|
||||||
|
90 days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 24, fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
<div>
|
||||||
|
Total{" "}
|
||||||
|
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
||||||
|
{totalGrams90.toFixed(1)} g
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Avg{" "}
|
||||||
|
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
||||||
|
{dailyAvg90.toFixed(2)} g/day
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Finished{" "}
|
||||||
|
<span className="serif" style={{ fontSize: 18, color: "var(--ink)" }}>
|
||||||
|
{stats.consumedCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<BarChart data={series.map((s) => ({ value: s.grams }))} height={180} color="var(--sage)" />
|
||||||
|
<RollingAvgLine values={rolling7} max={Math.max(...series.map((s) => s.grams), 0.001)} height={180} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 10,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{fmt.dateShort(series[0]?.date, tz)}</span>
|
||||||
|
<span>{fmt.dateShort(series[Math.floor(series.length / 2)]?.date, tz)}</span>
|
||||||
|
<span>today</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 6. Spending ──────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))",
|
||||||
|
gap: 14,
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<div className="serif" style={{ fontSize: 22, marginBottom: 18 }}>Spend by month</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{months.map(([m, v]) => {
|
||||||
|
const d = new Date(m + "-01");
|
||||||
|
return (
|
||||||
|
<div key={m} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div className="smallcaps" style={{ color: "var(--ink-3)", width: 60 }}>
|
||||||
|
{d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: tz })}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 20,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
borderRadius: 4,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(v / monthMax) * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "var(--terracotta)",
|
||||||
|
borderRadius: 4,
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ width: 70, textAlign: "right", fontSize: 13 }}>
|
||||||
|
{fmt.moneyShort(v)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="serif" style={{ fontSize: 22, marginBottom: 18 }}>Spend by shop</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{shopRanked.map(([s, v]) => (
|
||||||
|
<div key={s} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1.5,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 2,
|
||||||
|
height: 8,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
borderRadius: 4,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(v / shopMax) * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "var(--sage)",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "baseline", gap: 4, flexShrink: 0 }}>
|
||||||
|
<span className="mono" style={{ fontSize: 13 }}>
|
||||||
|
{fmt.moneyShort(v)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||||
|
({shopItemCount[s]} items)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 7. Strain leaderboard ────────────────────────────── */}
|
||||||
|
<Card padded={false}>
|
||||||
|
<div style={{ padding: "24px 24px 14px" }}>
|
||||||
|
<div className="serif" style={{ fontSize: 22 }}>Top strains</div>
|
||||||
|
</div>
|
||||||
|
{topStrains.length === 0 ? (
|
||||||
|
<div style={{ padding: "0 24px 24px", fontSize: 13, color: "var(--ink-3)" }}>
|
||||||
|
No strain data yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
topStrains.map((s, i) => (
|
||||||
<div
|
<div
|
||||||
|
key={s.name}
|
||||||
style={{
|
style={{
|
||||||
flex: 2,
|
padding: "12px 24px",
|
||||||
height: 8,
|
borderTop: "1px solid var(--line)",
|
||||||
background: "var(--bg-2)",
|
display: "flex",
|
||||||
borderRadius: 4,
|
alignItems: "center",
|
||||||
position: "relative",
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
className="mono"
|
||||||
width: `${(v / shopMax) * 100}%`,
|
style={{ width: 24, fontSize: 14, color: "var(--ink-3)", textAlign: "center" }}
|
||||||
height: "100%",
|
>
|
||||||
background: "var(--sage)",
|
{i + 1}
|
||||||
borderRadius: 4,
|
</div>
|
||||||
}}
|
<div style={{ flex: 1, fontWeight: 500, fontSize: 13, minWidth: 0 }}>
|
||||||
/>
|
<div
|
||||||
|
style={{
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ fontSize: 12, color: "var(--ink-2)", flexShrink: 0 }}>
|
||||||
|
{s.count} purchase{s.count === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
{s.avgRating != null && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
||||||
|
<Icon name="star" size={12} color="var(--amber)" />
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>
|
||||||
|
{s.avgRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mono" style={{ width: 70, textAlign: "right", fontSize: 13 }}>
|
))
|
||||||
{fmt.moneyShort(v)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<div className="serif" style={{ fontSize: 22, marginBottom: 6 }}>Inferred consumption heatmap</div>
|
|
||||||
<div style={{ fontSize: 12, color: "var(--ink-3)", marginBottom: 18 }}>
|
|
||||||
13 weeks · darker = higher inferred daily use, prorated across each item's lifespan
|
|
||||||
</div>
|
|
||||||
<Heatmap series={series} />
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 offset = first.getDay();
|
||||||
const cells: ({ date: string; grams: number } | null)[] = [];
|
const cells: ({ date: string; grams: number } | null)[] = [];
|
||||||
for (let i = 0; i < offset; i++) cells.push(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 max = Math.max(...series.map((s) => s.grams), 0.001);
|
||||||
const colorFor = (g: number) => {
|
const colorFor = (g: number) => {
|
||||||
if (g === 0) return "var(--bg-3)";
|
if (g === 0) return "var(--bg-3)";
|
||||||
const t = g / max;
|
const t = Math.min(g / max, 1);
|
||||||
return `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`;
|
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"];
|
const days = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
|
<div style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 3, paddingTop: 18 }}>
|
{/* Day-of-week labels */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, paddingTop: 22 }}>
|
||||||
{days.map((d, i) => (
|
{days.map((d, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
style={{ height: 14, fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)" }}
|
style={{
|
||||||
|
height: 20,
|
||||||
|
lineHeight: "20px",
|
||||||
|
fontSize: 10,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{d}
|
{d}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{/* Month labels */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(13, 1fr)",
|
gridTemplateColumns: "repeat(13, 1fr)",
|
||||||
gap: 3,
|
gap: 4,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -206,46 +514,55 @@ function Heatmap({ series }: { series: { date: string; grams: number }[] }) {
|
|||||||
<div
|
<div
|
||||||
key={w}
|
key={w}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
fontSize: 10,
|
||||||
color: "var(--ink-3)",
|
color: "var(--ink-3)",
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
height: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{firstDay && new Date(firstDay.date).getDate() <= 7
|
{firstDay && new Date(firstDay.date + "T12:00:00").getDate() <= 7
|
||||||
? new Date(firstDay.date).toLocaleDateString("en-US", { month: "short", timeZone: getStoredTimezone() })
|
? new Date(firstDay.date + "T12:00:00").toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
timeZone: tz,
|
||||||
|
})
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cells */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateRows: "repeat(7, 1fr)",
|
gridTemplateRows: "repeat(7, 1fr)",
|
||||||
gridAutoFlow: "column",
|
gridAutoFlow: "column",
|
||||||
gap: 3,
|
gap: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cells.map((c, i) => (
|
{cells.map((c, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
title={c ? `${c.date}: ${c.grams.toFixed(2)}g` : ""}
|
title={c ? tipFor(c) : ""}
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: "1",
|
aspectRatio: "1",
|
||||||
minHeight: 14,
|
minHeight: 20,
|
||||||
background: c ? colorFor(c.grams) : "transparent",
|
background: c ? colorFor(c.grams) : "transparent",
|
||||||
borderRadius: 2,
|
borderRadius: 3,
|
||||||
|
transition: "opacity 120ms",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 6,
|
gap: 8,
|
||||||
marginTop: 14,
|
marginTop: 14,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: "var(--ink-3)",
|
color: "var(--ink-3)",
|
||||||
@@ -253,20 +570,125 @@ function Heatmap({ series }: { series: { date: string; grams: number }[] }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>Less</span>
|
<span>Less</span>
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((t) => (
|
<div
|
||||||
<div
|
style={{
|
||||||
key={t}
|
width: 120,
|
||||||
style={{
|
height: 12,
|
||||||
width: 14,
|
borderRadius: 3,
|
||||||
height: 14,
|
background: "linear-gradient(to right, oklch(85% 0.02 145), oklch(42% 0.08 145))",
|
||||||
background: t === 0 ? "var(--bg-3)" : `oklch(${72 - t * 30}% ${0.04 + t * 0.06} 145)`,
|
}}
|
||||||
borderRadius: 2,
|
/>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<span>More</span>
|
<span>More</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{DOW_ORDER.map((dowIdx, i) => {
|
||||||
|
const avg = dowAvgs[dowIdx]!;
|
||||||
|
const isBusiest = dowIdx === busiestDow;
|
||||||
|
return (
|
||||||
|
<div key={dowIdx} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<div
|
||||||
|
className="smallcaps"
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
color: isBusiest ? "var(--ink)" : "var(--ink-3)",
|
||||||
|
fontWeight: isBusiest ? 600 : 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{labels[i]}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 20,
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
borderRadius: 4,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(avg / maxAvg) * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: "var(--sage)",
|
||||||
|
borderRadius: 4,
|
||||||
|
opacity: isBusiest ? 1 : 0.7,
|
||||||
|
minWidth: avg > 0 ? 4 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
textAlign: "right",
|
||||||
|
fontSize: 13,
|
||||||
|
color: isBusiest ? "var(--ink)" : "var(--ink-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avg.toFixed(2)}g
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<svg
|
||||||
|
width="100%"
|
||||||
|
height={height}
|
||||||
|
viewBox={`0 0 ${w} ${height}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points={pts}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--terracotta)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
opacity="0.8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user