Redesign Patterns page with insights, heatmap, and strain leaderboard
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:
2026-05-11 20:38:52 -04:00
parent fc7b3d5de2
commit 1194cafb37
+575 -153
View File
@@ -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>
);
}