feat(ui): ink & copper ledger retheme + analytics redesign
ci / lint-test (push) Successful in 43s

App-wide retheme done at the token layer: Tailwind's zinc scale is remapped
to warm ink/paper neutrals and indigo to copper in globals.css, so every
page inherits the palette. Fraunces (serif display) added for page titles
and hero figures; all figures now render in mono with tabular numerals.

Analytics page redesigned around a month spine — twelve clickable columns
scaled to each month's spend that act as hero, context, and period
navigation. Adds a 'What changed' top-movers panel vs the previous month,
replaces the 8-line category trend chart with per-category sparkline small
multiples, heat-tints the six-month ledger table, and restyles the Pareto,
pace chart, and cashflow strip. Kept: Pareto, cumulative-vs-typical pace,
drill-downs, regular/occasional split.

Insights and Merchants restyled to the same kit; chart tokens centralised
in category-colors.ts (CHART). Cleared the pre-existing lint errors in
insights (typed tooltip, removed any-casts).
This commit is contained in:
2026-07-19 20:37:49 +10:00
parent 99af10f9ea
commit 831d30669b
14 changed files with 426 additions and 293 deletions
+40 -38
View File
@@ -6,6 +6,7 @@ import {
} from "recharts";
import { useMonthlyAnalytics, useSubscriptions, useFees, useTransactions, useUpdateTransaction } from "@/lib/hooks";
import { CATEGORIES, REGULAR_CATEGORIES, formatCategory } from "@/lib/categories";
import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
@@ -50,16 +51,16 @@ function Section({ title, children }: { title: string; children: React.ReactNode
}
// ─── Custom tooltip ──────────────────────────────────────────────────
function RegularTooltip({ active, payload, label }: any) {
function RegularTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number }[]; label?: string }) {
if (!active || !payload?.length) return null;
const regular = payload.find((p: any) => p.dataKey === "regular")?.value ?? 0;
const occasional = payload.find((p: any) => p.dataKey === "occasional")?.value ?? 0;
const regular = payload.find((p) => p.dataKey === "regular")?.value ?? 0;
const occasional = payload.find((p) => p.dataKey === "occasional")?.value ?? 0;
return (
<div className="bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-xs space-y-1">
<div style={TOOLTIP_STYLE} className="px-3 py-2 text-xs space-y-1">
<div className="font-medium text-zinc-300 mb-1">{label}</div>
<div className="flex justify-between gap-4"><span className="text-indigo-400">Regular</span><span>{fmt(regular)}</span></div>
<div className="flex justify-between gap-4"><span className="text-zinc-400">Occasional</span><span>{fmt(occasional)}</span></div>
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span>{fmt(regular + occasional)}</span></div>
<div className="flex justify-between gap-4"><span className="text-indigo-300">Regular</span><span className="font-mono">{fmt(regular)}</span></div>
<div className="flex justify-between gap-4"><span className="text-zinc-400">Occasional</span><span className="font-mono">{fmt(occasional)}</span></div>
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span className="font-mono">{fmt(regular + occasional)}</span></div>
</div>
);
}
@@ -185,8 +186,8 @@ function MonthlyBreakdown({ analytics }: { analytics: NonNullable<ReturnType<typ
.sort((a, b) => b.amount - a.amount);
}, [analytics.rows, selectedMonth]);
const regularRows = categoryData.filter((r) => REGULAR_CATEGORIES.has(r.category as any));
const occasionalRows = categoryData.filter((r) => !REGULAR_CATEGORIES.has(r.category as any));
const regularRows = categoryData.filter((r) => (REGULAR_CATEGORIES as Set<string>).has(r.category));
const occasionalRows = categoryData.filter((r) => !(REGULAR_CATEGORIES as Set<string>).has(r.category));
const regularTotal = regularRows.reduce((s, r) => s + r.amount, 0);
const occasionalTotal = occasionalRows.reduce((s, r) => s + r.amount, 0);
@@ -243,7 +244,7 @@ function MonthlyBreakdown({ analytics }: { analytics: NonNullable<ReturnType<typ
))}
</div>
<div className="border border-zinc-700 rounded-xl overflow-hidden">
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
@@ -297,7 +298,7 @@ export default function InsightsPage() {
let occasional = 0;
for (const row of analytics.rows) {
const spend = Number(row.spent[month] ?? 0);
if (REGULAR_CATEGORIES.has(row.category as any)) regular += spend;
if ((REGULAR_CATEGORIES as Set<string>).has(row.category)) regular += spend;
else occasional += spend;
}
return {
@@ -321,46 +322,47 @@ export default function InsightsPage() {
return (
<div className="max-w-4xl">
<h2 className="text-xl font-semibold mb-6">Insights</h2>
<p className="text-[11px] uppercase tracking-[0.18em] text-zinc-500 mb-1">Ledger · patterns</p>
<h2 className="text-2xl font-display mb-6">Insights</h2>
{/* ── 1. Regular vs Occasional ── */}
<Section title="Regular vs Occasional Spend">
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
<div className="text-xs text-zinc-500 mb-1">This month regular spend</div>
<div className="text-xl font-semibold text-indigo-400">{fmt(latestRegular)}</div>
<Section title="Regular vs occasional spend">
<div className="bg-zinc-900 border border-zinc-800 rounded-xl grid grid-cols-3 divide-x divide-zinc-800 overflow-hidden mb-4">
<div className="px-4 py-3.5">
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">This month · regular</div>
<div className="text-xl font-mono tabular-nums text-indigo-300">{fmt(latestRegular)}</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
<div className="text-xs text-zinc-500 mb-1">12-month avg regular</div>
<div className="text-xl font-semibold text-zinc-200">{fmt(avgRegular)}</div>
<div className="px-4 py-3.5">
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">12-month average</div>
<div className="text-xl font-mono tabular-nums text-zinc-200">{fmt(avgRegular)}</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
<div className="text-xs text-zinc-500 mb-1">Trend (first 3 vs last 3 mo)</div>
<div className={`text-xl font-semibold ${regularTrend.dir === "up" ? "text-red-400" : regularTrend.dir === "down" ? "text-green-400" : "text-zinc-400"}`}>
<div className="px-4 py-3.5">
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Trend · first 3 vs last 3</div>
<div className={`text-xl font-mono tabular-nums ${regularTrend.dir === "up" ? "text-red-400" : regularTrend.dir === "down" ? "text-emerald-400" : "text-zinc-400"}`}>
{regularTrend.dir === "up" ? "↑" : regularTrend.dir === "down" ? "↓" : "→"} {regularTrend.pct}%
</div>
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
<ResponsiveContainer width="100%" height={220}>
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
<Tooltip content={<RegularTooltip />} />
<Bar dataKey="regular" stackId="a" fill="#6366f1" name="Regular" radius={[0, 0, 0, 0]} />
<Bar dataKey="occasional" stackId="a" fill="#3f3f46" name="Occasional" radius={[3, 3, 0, 0]} />
<Line type="monotone" dataKey="regular" stroke="#818cf8" strokeWidth={2} dot={false} strokeDasharray="4 2" />
<XAxis dataKey="month" tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
<Tooltip content={<RegularTooltip />} cursor={{ fill: "rgba(232,224,204,0.04)" }} />
<Bar dataKey="regular" stackId="a" fill={CHART.accent} name="Regular" radius={[0, 0, 0, 0]} />
<Bar dataKey="occasional" stackId="a" fill={CHART.dim} name="Occasional" radius={[3, 3, 0, 0]} />
<Line type="monotone" dataKey="regular" stroke={CHART.accentSoft} strokeWidth={2} dot={false} strokeDasharray="4 2" />
</ComposedChart>
</ResponsiveContainer>
<div className="flex gap-4 mt-2 justify-end">
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-indigo-500 inline-block" />Regular (groceries, dining, transport)</span>
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-zinc-600 inline-block" />Occasional</span>
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm inline-block" style={{ background: CHART.accent }} />Regular (groceries, dining, transport)</span>
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm inline-block" style={{ background: CHART.dim }} />Occasional</span>
</div>
</div>
</Section>
{/* ── 2. Monthly Spend Breakdown ── */}
<Section title="Monthly Spend Breakdown">
<Section title="Monthly spend breakdown">
{!analytics6 ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : (
@@ -369,7 +371,7 @@ export default function InsightsPage() {
</Section>
{/* ── 3. Recurring Charges ── */}
<Section title="Recurring Charges">
<Section title="Recurring charges">
{!subData ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : subData.subscriptions.length === 0 ? (
@@ -380,7 +382,7 @@ export default function InsightsPage() {
<span className="text-xs text-zinc-500">{activeSubscriptions.length} active · {inactiveSubscriptions.length} inactive</span>
<span className="text-sm font-medium text-indigo-400">{fmtExact(subData.total_monthly_equiv)}<span className="text-xs text-zinc-500 font-normal ml-1">/ month committed</span></span>
</div>
<div className="border border-zinc-700 rounded-xl overflow-hidden">
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
@@ -419,7 +421,7 @@ export default function InsightsPage() {
</Section>
{/* ── 4. Fees & Interest ── */}
<Section title="Fees & Interest">
<Section title="Fees & interest">
{!feesData ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
@@ -427,7 +429,7 @@ export default function InsightsPage() {
) : (
<div className="space-y-4">
{feesData.by_bank.length > 0 && (
<div className="border border-zinc-700 rounded-xl overflow-hidden">
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
@@ -459,7 +461,7 @@ export default function InsightsPage() {
{feesData.transactions.length > 0 && (
<div>
<p className="text-xs text-zinc-500 mb-2">Individual fee / interest transactions</p>
<div className="border border-zinc-700 rounded-xl overflow-hidden">
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">