feat(analytics): stacked category chart, savings rate line, expandable rows

- Replace grouped cashflow BarChart with ComposedChart: expense categories
  as colour-coded stacked bars + amber savings-rate % line on right Y-axis
- Add category colour legend below chart (matches stacked bars)
- Horizontal category bar chart now uses per-category colours
- Breakdown table: click any category row to expand/collapse individual
  transactions; each transaction has an inline category dropdown that
  calls PATCH /api/transactions/:id → transaction_overrides, then
  invalidates analytics query so totals update immediately
This commit is contained in:
2026-03-08 20:53:55 +11:00
parent 90d8db4abe
commit 3cf67f6e2a
+356 -57
View File
@@ -2,17 +2,19 @@
import { useState } from "react";
import {
ComposedChart,
BarChart,
Bar,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
Legend,
} from "recharts";
import { useMonthlyAnalytics } from "@/lib/hooks";
import { formatCategory } from "@/lib/categories";
import { useQueryClient } from "@tanstack/react-query";
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
import { formatCategory, CATEGORIES } from "@/lib/categories";
function currentMonthStr(): string {
const now = new Date();
@@ -64,8 +66,165 @@ const TOOLTIP_STYLE = {
fontSize: 12,
};
const CATEGORY_COLORS: Record<string, string> = {
groceries: "#22c55e",
dining: "#f97316",
transport: "#06b6d4",
fuel: "#eab308",
shopping: "#ec4899",
utilities: "#8b5cf6",
entertainment: "#f43f5e",
travel: "#0ea5e9",
health: "#10b981",
insurance: "#64748b",
subscriptions: "#a78bfa",
cash_advance: "#dc2626",
government: "#78716c",
education: "#3b82f6",
rent: "#d97706",
transfers: "#6b7280",
income: "#34d399",
investment: "#818cf8",
personal_care: "#fb7185",
pets: "#86efac",
gifts: "#fcd34d",
charity: "#a3e635",
other: "#71717a",
};
interface TooltipPayload {
name: string;
value: number;
fill?: string;
stroke?: string;
}
function CashflowTooltip({
active,
payload,
label,
}: {
active?: boolean;
payload?: TooltipPayload[];
label?: string;
}) {
if (!active || !payload) return null;
const items = payload.filter((p) => p.value && Math.abs(p.value) > 0.01);
if (items.length === 0) return null;
return (
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs min-w-36">
<p className="text-zinc-400 mb-2 font-medium">{label}</p>
{items.map((item) => {
const name = item.name;
let label = name;
let valueStr = fmtExact(Number(item.value));
const color = item.fill || item.stroke || "#71717a";
if (name === "income") label = "Income";
else if (name === "savingsRate") {
label = "Savings Rate";
valueStr = `${Number(item.value).toFixed(1)}%`;
} else if (name.startsWith("cat_")) {
label = formatCategory(name.slice(4));
}
return (
<div key={name} className="flex items-center gap-2 mb-0.5">
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: color }} />
<span className="text-zinc-400">{label}:</span>
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{valueStr}</span>
</div>
);
})}
</div>
);
}
function CategoryPanel({
category,
selectedMonth,
}: {
category: string;
selectedMonth: string;
}) {
const qc = useQueryClient();
const updateTx = useUpdateTransaction();
const from = `${selectedMonth}-01`;
const [year, month] = selectedMonth.split("-").map(Number);
const nextDate = new Date(year, month, 1);
const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`;
const { data, isLoading } = useTransactions({ category, from, to, limit: 200 });
const txns = data?.data || [];
const handleCategoryChange = (id: number, newCategory: string) => {
updateTx.mutate(
{ id, category: newCategory },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["analytics"] });
},
}
);
};
return (
<tr>
<td colSpan={4} className="px-0 pb-2 bg-zinc-950/60 border-b border-zinc-800">
{isLoading ? (
<p className="text-xs text-zinc-500 px-6 py-2">Loading transactions...</p>
) : txns.length === 0 ? (
<p className="text-xs text-zinc-600 px-6 py-2">No transactions found</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-zinc-600">
<th className="text-left px-6 py-1 font-normal w-24">Date</th>
<th className="text-left px-2 py-1 font-normal">Description</th>
<th className="text-right px-2 py-1 font-normal w-24">Amount</th>
<th className="text-right px-4 py-1 font-normal w-36">Category</th>
</tr>
</thead>
<tbody>
{txns.map((tx) => (
<tr key={tx.id} className="border-t border-zinc-800/30 hover:bg-zinc-800/20">
<td className="px-6 py-1.5 text-zinc-500 tabular-nums">
{tx.transaction_date.slice(5).replace("-", "/")}
</td>
<td className="px-2 py-1.5 text-zinc-300 max-w-xs truncate">
{tx.effective_merchant || tx.description}
</td>
<td className="px-2 py-1.5 text-right tabular-nums text-zinc-300">
{fmtExact(tx.amount)}
</td>
<td className="px-4 py-1.5 text-right">
<select
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
defaultValue={tx.effective_category}
onChange={(e) => handleCategoryChange(tx.id, e.target.value)}
>
{CATEGORIES.map((cat) => (
<option key={cat} value={cat}>
{formatCategory(cat)}
</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
)}
</td>
</tr>
);
}
export default function AnalyticsPage() {
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
if (isLoading || !analytics) {
@@ -96,22 +255,34 @@ export default function AnalyticsPage() {
const largestCategory = categoryRows[0];
// Cashflow bar chart data (chronological)
const cashflowData = months.map((m) => ({
month: m,
label: formatShortMonth(m),
income: analytics.totals[m]?.income || 0,
expenses: analytics.totals[m]?.spent || 0,
investments: analytics.totals[m]?.investments || 0,
}));
// Category horizontal bar chart
const categoryChartData = [...categoryRows].reverse();
const chartHeight = Math.max(categoryChartData.length * 36, 120);
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
// Categories that appear in expense data
const expenseCategories = analytics.rows.map((r) => r.category);
// Cashflow chart data (chronological) — per-category amounts for stacking
const cashflowData = months.map((m) => {
const entry: Record<string, unknown> = {
month: m,
label: formatShortMonth(m),
income: analytics.income[m] || 0,
};
analytics.rows.forEach((row) => {
entry[`cat_${row.category}`] = row.spent[m] || 0;
});
const income = analytics.income[m] || 0;
const spent = analytics.totals[m]?.spent || 0;
const invested = analytics.investments[m] || 0;
entry.savingsRate =
income > 0 ? Math.round(((income - spent - invested) / income) * 100) : null;
return entry;
});
// Horizontal category bar chart for selected month
const categoryChartData = [...categoryRows].reverse();
const chartHeight = Math.max(categoryChartData.length * 36, 120);
return (
<div className="space-y-6">
{/* Header + month selector */}
@@ -136,7 +307,7 @@ export default function AnalyticsPage() {
</div>
</div>
{/* Cashflow summary cards */}
{/* Summary cards */}
<div className={`grid gap-4 ${hasIncome ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3"}`}>
{hasIncome && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
@@ -149,9 +320,11 @@ export default function AnalyticsPage() {
<p className="text-xs text-zinc-500 mb-1">Expenses</p>
<p className="text-2xl font-semibold">{fmtExact(totals.spent)}</p>
<p className={`text-xs mt-1 ${deltaColor(spentDelta)}`}>
{spentDelta === 0 || lastTotals.spent === 0
? <span className="text-zinc-500">split-adjusted</span>
: `${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`}
{spentDelta === 0 || lastTotals.spent === 0 ? (
<span className="text-zinc-500">split-adjusted</span>
) : (
`${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`
)}
</p>
</div>
{(hasInvestments || totals.investments > 0) && (
@@ -165,8 +338,11 @@ export default function AnalyticsPage() {
<p className="text-xs text-zinc-500 mb-1">{hasIncome ? "Net Cash" : "Largest Category"}</p>
{hasIncome ? (
<>
<p className={`text-2xl font-semibold ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
{totals.net >= 0 ? "+" : ""}{fmtExact(totals.net)}
<p
className={`text-2xl font-semibold ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}
>
{totals.net >= 0 ? "+" : ""}
{fmtExact(totals.net)}
</p>
<p className="text-xs text-zinc-500 mt-1">income expenses invested</p>
</>
@@ -181,13 +357,36 @@ export default function AnalyticsPage() {
</div>
</div>
{/* Cashflow bar chart — income vs expenses vs investments */}
{/* Cashflow chart — stacked expense categories + optional savings rate line */}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<h3 className="text-sm font-medium mb-4">
{hasIncome ? "Cashflow — 6 Months" : "Monthly Spending"}
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">
{hasIncome ? "Cashflow — 6 Months" : "Monthly Spending by Category"}
</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={cashflowData} margin={{ top: 0, right: 8, bottom: 0, left: 8 }}>
{hasIncome && (
<div className="flex items-center gap-3 text-xs text-zinc-500">
<span className="flex items-center gap-1">
<span className="w-3 h-0.5 bg-amber-400 inline-block" />
Savings Rate
</span>
<span className="flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-sm bg-emerald-500 inline-block" />
Income
</span>
</div>
)}
</div>
<ResponsiveContainer width="100%" height={240}>
<ComposedChart
data={cashflowData}
margin={{ top: 4, right: hasIncome ? 48 : 8, bottom: 0, left: 8 }}
onClick={(data) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const month = (data as any)?.activePayload?.[0]?.payload?.month as string | undefined;
if (month) setSelectedMonth(month);
}}
style={{ cursor: "pointer" }}
>
<XAxis
dataKey="label"
tick={{ fill: "#71717a", fontSize: 12 }}
@@ -195,43 +394,92 @@ export default function AnalyticsPage() {
tickLine={false}
/>
<YAxis
yAxisId="left"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${v}`}
width={56}
/>
<Tooltip
formatter={(value, name) => [fmtExact(Number(value)), name === "expenses" ? "Expenses" : name === "income" ? "Income" : "Invested"]}
contentStyle={TOOLTIP_STYLE}
labelStyle={{ color: "#a1a1aa" }}
cursor={{ fill: "rgba(255,255,255,0.04)" }}
/>
{hasIncome && (
<>
<Legend
formatter={(value) => value === "income" ? "Income" : value === "expenses" ? "Expenses" : "Invested"}
wrapperStyle={{ fontSize: 12, color: "#71717a" }}
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v}%`}
domain={[-20, 100]}
width={36}
/>
<Bar dataKey="income" fill="#34d399" radius={[4, 4, 0, 0]} />
{hasInvestments && <Bar dataKey="investments" fill="#818cf8" radius={[4, 4, 0, 0]} />}
</>
)}
<Tooltip content={<CashflowTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
{/* Income bar (ungrouped, green) */}
{hasIncome && (
<Bar
dataKey="expenses"
radius={[4, 4, 0, 0]}
onClick={(data) => setSelectedMonth((data as unknown as { month: string }).month)}
style={{ cursor: "pointer" }}
yAxisId="left"
dataKey="income"
fill="#22c55e"
radius={[3, 3, 0, 0]}
maxBarSize={32}
opacity={0.85}
/>
)}
{/* Stacked expense category bars */}
{expenseCategories.map((cat, i) => (
<Bar
key={cat}
yAxisId="left"
dataKey={`cat_${cat}`}
stackId="expenses"
fill={CATEGORY_COLORS[cat] || "#71717a"}
radius={i === expenseCategories.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]}
maxBarSize={32}
>
{cashflowData.map((entry) => (
<Cell
key={entry.month}
fill={entry.month === selectedMonth ? "#6366f1" : (hasIncome ? "#ef4444" : "#3f3f46")}
key={entry.month as string}
fill={
entry.month === selectedMonth
? CATEGORY_COLORS[cat] || "#71717a"
: `${CATEGORY_COLORS[cat] || "#71717a"}99`
}
/>
))}
</Bar>
</BarChart>
))}
{/* Savings rate line */}
{hasIncome && (
<Line
yAxisId="right"
dataKey="savingsRate"
stroke="#fbbf24"
strokeWidth={2}
dot={{ fill: "#fbbf24", r: 3 }}
activeDot={{ r: 5 }}
connectNulls={false}
/>
)}
</ComposedChart>
</ResponsiveContainer>
{/* Category color legend */}
{expenseCategories.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-3 pt-3 border-t border-zinc-800">
{expenseCategories.map((cat) => (
<span key={cat} className="flex items-center gap-1 text-xs text-zinc-500">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{ background: CATEGORY_COLORS[cat] || "#71717a" }}
/>
{formatCategory(cat)}
</span>
))}
</div>
)}
</div>
{/* Spending by category — horizontal bar chart */}
@@ -269,17 +517,26 @@ export default function AnalyticsPage() {
labelStyle={{ color: "#a1a1aa" }}
cursor={{ fill: "rgba(255,255,255,0.04)" }}
/>
<Bar dataKey="spent" fill="#6366f1" radius={[0, 4, 4, 0]} />
<Bar dataKey="spent" radius={[0, 4, 4, 0]}>
{categoryChartData.map((entry) => (
<Cell
key={entry.category}
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Category breakdown table */}
{/* Category breakdown table — with expandable rows */}
{categoryRows.length > 0 && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-zinc-800">
<h3 className="text-sm font-medium">Spending Breakdown {formatMonth(selectedMonth)}</h3>
<h3 className="text-sm font-medium">
Spending Breakdown {formatMonth(selectedMonth)}
</h3>
</div>
<table className="w-full text-sm">
<thead>
@@ -291,16 +548,47 @@ export default function AnalyticsPage() {
</tr>
</thead>
<tbody>
{categoryRows.map(({ category, spent, txCount }) => (
<tr key={category} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-2.5 font-medium">{formatCategory(category)}</td>
{categoryRows.map(({ category, spent, txCount }) => {
const isExpanded = expandedCategory === category;
return (
<>
<tr
key={category}
className={`border-b border-zinc-800/50 cursor-pointer select-none transition-colors ${
isExpanded ? "bg-zinc-800/40" : "hover:bg-zinc-800/30"
}`}
onClick={() =>
setExpandedCategory(isExpanded ? null : category)
}
>
<td className="px-4 py-2.5 font-medium">
<span className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{ background: CATEGORY_COLORS[category] || "#71717a" }}
/>
{formatCategory(category)}
<span className="text-zinc-600 text-xs ml-1">
{isExpanded ? "▲" : "▼"}
</span>
</span>
</td>
<td className="px-4 py-2.5 text-right tabular-nums">{fmtExact(spent)}</td>
<td className="px-4 py-2.5 text-right text-zinc-400">{txCount}</td>
<td className="px-4 py-2.5 text-right text-zinc-400 tabular-nums">
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
</td>
</tr>
))}
{isExpanded && (
<CategoryPanel
key={`panel-${category}`}
category={category}
selectedMonth={selectedMonth}
/>
)}
</>
);
})}
</tbody>
</table>
</div>
@@ -334,7 +622,13 @@ export default function AnalyticsPage() {
{analytics.rows.map((row) => (
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
<span className="flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-sm shrink-0"
style={{ background: CATEGORY_COLORS[row.category] || "#71717a" }}
/>
{formatCategory(row.category)}
</span>
</td>
{analytics.months.map((m) => {
const spent = row.spent[m];
@@ -361,7 +655,9 @@ export default function AnalyticsPage() {
return (
<td
key={m}
className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${
m === selectedMonth ? "bg-zinc-800/30" : ""
}`}
>
{inc ? fmt(inc) : "—"}
</td>
@@ -379,7 +675,9 @@ export default function AnalyticsPage() {
return (
<td
key={m}
className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${
m === selectedMonth ? "bg-zinc-800/30" : ""
}`}
>
{inv ? fmt(inv) : "—"}
</td>
@@ -416,7 +714,8 @@ export default function AnalyticsPage() {
net >= 0 ? "text-emerald-400" : "text-red-400"
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
>
{net >= 0 ? "+" : ""}{fmt(net)}
{net >= 0 ? "+" : ""}
{fmt(net)}
</td>
);
})}