From 3cf67f6e2aab5c040b6239cb8f937d8baa865ded Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 8 Mar 2026 20:53:55 +1100 Subject: [PATCH] feat(analytics): stacked category chart, savings rate line, expandable rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/budget/page.tsx | 445 +++++++++++++++++++++++++++++++++------- 1 file changed, 372 insertions(+), 73 deletions(-) diff --git a/src/app/budget/page.tsx b/src/app/budget/page.tsx index 73b433d..bc7871a 100644 --- a/src/app/budget/page.tsx +++ b/src/app/budget/page.tsx @@ -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 = { + 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 ( +
+

{label}

+ {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 ( +
+ + {label}: + {valueStr} +
+ ); + })} +
+ ); +} + +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 ( + + + {isLoading ? ( +

Loading transactions...

+ ) : txns.length === 0 ? ( +

No transactions found

+ ) : ( + + + + + + + + + + + {txns.map((tx) => ( + + + + + + + ))} + +
DateDescriptionAmountCategory
+ {tx.transaction_date.slice(5).replace("-", "/")} + + {tx.effective_merchant || tx.description} + + {fmtExact(tx.amount)} + + +
+ )} + + + ); +} + export default function AnalyticsPage() { const [selectedMonth, setSelectedMonth] = useState(currentMonthStr); + const [expandedCategory, setExpandedCategory] = useState(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 = { + 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 (
{/* Header + month selector */} @@ -136,7 +307,7 @@ export default function AnalyticsPage() {
- {/* Cashflow summary cards */} + {/* Summary cards */}
{hasIncome && (
@@ -149,9 +320,11 @@ export default function AnalyticsPage() {

Expenses

{fmtExact(totals.spent)}

- {spentDelta === 0 || lastTotals.spent === 0 - ? split-adjusted - : `${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`} + {spentDelta === 0 || lastTotals.spent === 0 ? ( + split-adjusted + ) : ( + `${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}` + )}

{(hasInvestments || totals.investments > 0) && ( @@ -165,8 +338,11 @@ export default function AnalyticsPage() {

{hasIncome ? "Net Cash" : "Largest Category"}

{hasIncome ? ( <> -

= 0 ? "text-emerald-400" : "text-red-400"}`}> - {totals.net >= 0 ? "+" : ""}{fmtExact(totals.net)} +

= 0 ? "text-emerald-400" : "text-red-400"}`} + > + {totals.net >= 0 ? "+" : ""} + {fmtExact(totals.net)}

income − expenses − invested

@@ -181,13 +357,36 @@ export default function AnalyticsPage() {
- {/* Cashflow bar chart — income vs expenses vs investments */} + {/* Cashflow chart — stacked expense categories + optional savings rate line */}
-

- {hasIncome ? "Cashflow — 6 Months" : "Monthly Spending"} -

- - +
+

+ {hasIncome ? "Cashflow — 6 Months" : "Monthly Spending by Category"} +

+ {hasIncome && ( +
+ + + Savings Rate + + + + Income + +
+ )} +
+ + { + // 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" }} + > `$${v}`} width={56} /> - [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 && ( - <> - value === "income" ? "Income" : value === "expenses" ? "Expenses" : "Invested"} - wrapperStyle={{ fontSize: 12, color: "#71717a" }} - /> - - {hasInvestments && } - + `${v}%`} + domain={[-20, 100]} + width={36} + /> )} - setSelectedMonth((data as unknown as { month: string }).month)} - style={{ cursor: "pointer" }} - > - {cashflowData.map((entry) => ( - - ))} - -
+ } cursor={{ fill: "rgba(255,255,255,0.04)" }} /> + + {/* Income bar (ungrouped, green) */} + {hasIncome && ( + + )} + + {/* Stacked expense category bars */} + {expenseCategories.map((cat, i) => ( + + {cashflowData.map((entry) => ( + + ))} + + ))} + + {/* Savings rate line */} + {hasIncome && ( + + )} +
+ + {/* Category color legend */} + {expenseCategories.length > 0 && ( +
+ {expenseCategories.map((cat) => ( + + + {formatCategory(cat)} + + ))} +
+ )}
{/* 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)" }} /> - + + {categoryChartData.map((entry) => ( + + ))} + )} - {/* Category breakdown table */} + {/* Category breakdown table — with expandable rows */} {categoryRows.length > 0 && (
-

Spending Breakdown — {formatMonth(selectedMonth)}

+

+ Spending Breakdown — {formatMonth(selectedMonth)} +

@@ -291,16 +548,47 @@ export default function AnalyticsPage() { - {categoryRows.map(({ category, spent, txCount }) => ( - - - - - - - ))} + {categoryRows.map(({ category, spent, txCount }) => { + const isExpanded = expandedCategory === category; + return ( + <> + + setExpandedCategory(isExpanded ? null : category) + } + > + + + + + + {isExpanded && ( + + )} + + ); + })}
{formatCategory(category)}{fmtExact(spent)}{txCount} - {totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}% -
+ + + {formatCategory(category)} + + {isExpanded ? "▲" : "▼"} + + + {fmtExact(spent)}{txCount} + {totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}% +
@@ -334,7 +622,13 @@ export default function AnalyticsPage() { {analytics.rows.map((row) => ( - {formatCategory(row.category)} + + + {formatCategory(row.category)} + {analytics.months.map((m) => { const spent = row.spent[m]; @@ -361,7 +655,9 @@ export default function AnalyticsPage() { return ( {inc ? fmt(inc) : "—"} @@ -379,7 +675,9 @@ export default function AnalyticsPage() { return ( {inv ? fmt(inv) : "—"} @@ -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)} ); })}