From 2a10450c3eb4ce5f6a74f2104dfa2452c000fdbc Mon Sep 17 00:00:00 2001 From: siddharthd Date: Mon, 9 Mar 2026 23:59:07 +1100 Subject: [PATCH] feat(analytics): replace charts with category trend lines, Pareto chart, and cumulative spend - Category spend trend lines (top 8 categories, 6-month view) replacing stacked bar chart - Pareto chart showing 80/20 spend concentration with cumulative % line - Cumulative spend chart tracking actual vs typical monthly pace - Fix: add amount_aud to TransactionRow interface --- src/app/budget/page.tsx | 674 +++++++++++++++++----------------------- src/lib/queries.ts | 1 + 2 files changed, 290 insertions(+), 385 deletions(-) diff --git a/src/app/budget/page.tsx b/src/app/budget/page.tsx index 3f56eb3..2b0886c 100644 --- a/src/app/budget/page.tsx +++ b/src/app/budget/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState, Fragment } from "react"; +import { useState, Fragment, useMemo } from "react"; import { ComposedChart, - BarChart, + LineChart, Bar, Line, XAxis, @@ -11,6 +11,8 @@ import { Tooltip, ResponsiveContainer, Cell, + ReferenceLine, + Legend, } from "recharts"; import { useQueryClient } from "@tanstack/react-query"; import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks"; @@ -20,51 +22,33 @@ function currentMonthStr(): string { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; } - function prevMonth(m: string): string { const [year, month] = m.split("-").map(Number); const d = new Date(year, month - 2, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } - function nextMonth(m: string): string { const [year, month] = m.split("-").map(Number); const d = new Date(year, month, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } - function formatMonth(m: string): string { const [year, month] = m.split("-"); - const date = new Date(Number(year), Number(month) - 1, 1); - return date.toLocaleString("default", { month: "long", year: "numeric" }); + return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "long", year: "numeric" }); } - function formatShortMonth(m: string): string { const [year, month] = m.split("-"); - const date = new Date(Number(year), Number(month) - 1, 1); - return date.toLocaleString("default", { month: "short" }); + return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" }); } - -function fmt(n: number): string { - return `$${n.toFixed(0)}`; -} - -function fmtExact(n: number): string { - return `$${n.toFixed(2)}`; -} - +function fmt(n: number): string { return `$${n.toFixed(0)}`; } +function fmtExact(n: number): string { return `$${n.toFixed(2)}`; } function deltaColor(n: number): string { if (n > 0) return "text-red-400"; if (n < 0) return "text-emerald-400"; return ""; } -const TOOLTIP_STYLE = { - background: "#18181b", - border: "1px solid #3f3f46", - borderRadius: 8, - fontSize: 12, -}; +const TOOLTIP_STYLE = { background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 }; const CATEGORY_COLORS: Record = { groceries: "#22c55e", @@ -90,95 +74,78 @@ const CATEGORY_COLORS: Record = { gifts: "#fcd34d", charity: "#a3e635", home_goods: "#67e8f9", - home_maintenance: "#a78bfa", + home_maintenance: "#c084fc", 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; +// ─── Tooltips ──────────────────────────────────────────────────────────────── +function TrendTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number; stroke: string }[]; label?: string }) { + if (!active || !payload?.length) return null; + const items = payload.filter((p) => p.value > 0.01).sort((a, b) => b.value - a.value); + if (!items.length) 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} -
- ); - })} + {items.map((p) => ( +
+ + {formatCategory(p.dataKey.replace("cat_", ""))}: + {fmtExact(p.value)} +
+ ))}
); } -function CategoryPanel({ - category, - selectedMonth, -}: { - category: string; - selectedMonth: string; -}) { +function ParetoTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string; spent: number; pct: number; cumulative: number } }[] }) { + if (!active || !payload?.length) return null; + const d = payload[0].payload; + return ( +
+

{formatCategory(d.category)}

+
Spend{fmtExact(d.spent)}
+
Share{d.pct}%
+
Cumulative{d.cumulative}%
+
+ ); +} + +function CumulativeTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number; stroke: string }[]; label?: number }) { + if (!active || !payload?.length) return null; + return ( +
+

Day {label}

+ {payload.map((p) => p.value != null && ( +
+ + {p.dataKey === "actual" ? "Actual" : "Typical pace"}: + {fmtExact(p.value)} +
+ ))} +
+ ); +} + +// ─── CategoryPanel ──────────────────────────────────────────────────────────── + +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...

+

Loading...

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

No transactions found

+

No transactions

) : ( @@ -192,25 +159,21 @@ function CategoryPanel({ {txns.map((tx) => ( - - - + + + @@ -224,11 +187,100 @@ function CategoryPanel({ ); } +// ─── Page ───────────────────────────────────────────────────────────────────── + export default function AnalyticsPage() { const [selectedMonth, setSelectedMonth] = useState(currentMonthStr); const [expandedCategory, setExpandedCategory] = useState(null); const { data: analytics, isLoading } = useMonthlyAnalytics(6); + // Cumulative chart: fetch this month's transactions + const smFrom = `${selectedMonth}-01`; + const [smYear, smMonth] = selectedMonth.split("-").map(Number); + const smNextDate = new Date(smYear, smMonth, 1); + const smTo = `${smNextDate.getFullYear()}-${String(smNextDate.getMonth() + 1).padStart(2, "0")}-01`; + const { data: monthTxData } = useTransactions({ from: smFrom, to: smTo, limit: 1000 }); + + const months = useMemo(() => analytics ? [...analytics.months].reverse() : [], [analytics]); + + // Category rows for selected month + const categoryRows = useMemo(() => { + if (!analytics) return []; + return analytics.rows + .filter((r) => (r.spent[selectedMonth] || 0) > 0) + .map((r) => ({ category: r.category, spent: r.spent[selectedMonth] || 0, txCount: r.txCount[selectedMonth] || 0 })) + .sort((a, b) => b.spent - a.spent); + }, [analytics, selectedMonth]); + + // Trend line data — top 8 categories by total 6-month spend + const trendData = useMemo(() => { + if (!analytics) return { data: [], categories: [] }; + const categoryTotals = analytics.rows + .map((r) => ({ category: r.category, total: months.reduce((s, m) => s + (r.spent[m] || 0), 0) })) + .sort((a, b) => b.total - a.total) + .slice(0, 8) + .map((r) => r.category); + + const data = months.map((m) => { + const entry: Record = { month: m, label: formatShortMonth(m) }; + for (const cat of categoryTotals) { + const row = analytics.rows.find((r) => r.category === cat); + entry[`cat_${cat}`] = row?.spent[m] || 0; + } + return entry; + }); + return { data, categories: categoryTotals }; + }, [analytics, months]); + + // Pareto chart data + const paretoData = useMemo(() => { + const total = categoryRows.reduce((s, r) => s + r.spent, 0); + let running = 0; + return categoryRows.map((r) => { + running += r.spent; + return { + category: r.category, + spent: r.spent, + pct: total > 0 ? Math.round((r.spent / total) * 1000) / 10 : 0, + cumulative: total > 0 ? Math.round((running / total) * 1000) / 10 : 0, + }; + }); + }, [categoryRows]); + + // Cumulative spend chart data + const cumulativeData = useMemo(() => { + const daysInMonth = new Date(smYear, smMonth, 0).getDate(); + const isCurrentMonth = selectedMonth === currentMonthStr(); + const today = new Date(); + const lastDay = isCurrentMonth ? today.getDate() : daysInMonth; + + // Daily spend from transactions + const daily: Record = {}; + (monthTxData?.data ?? []) + .filter((tx) => tx.transaction_type === "debit" && !["transfers", "investment"].includes(tx.effective_category)) + .forEach((tx) => { + const day = new Date(tx.transaction_date).getDate(); + daily[day] = (daily[day] || 0) + Number(tx.amount_aud ?? tx.amount); + }); + + // Typical pace: avg of prior months (straight-line ramp) + const priorMonths = analytics?.months.filter((m) => m !== selectedMonth) ?? []; + const priorAvg = priorMonths.length > 0 + ? priorMonths.reduce((s, m) => s + (analytics?.totals[m]?.spent || 0), 0) / priorMonths.length + : 0; + + let cum = 0; + return Array.from({ length: daysInMonth }, (_, i) => { + const day = i + 1; + if (day <= lastDay) cum += daily[day] || 0; + return { + day, + actual: day <= lastDay ? Math.round(cum * 100) / 100 : null, + typical: Math.round((priorAvg * day / daysInMonth) * 100) / 100, + }; + }); + }, [monthTxData, analytics, selectedMonth, smYear, smMonth]); + if (isLoading || !analytics) { return (
@@ -238,74 +290,26 @@ export default function AnalyticsPage() { ); } - const months = [...analytics.months].reverse(); // chronological - const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 }; const lastTotals = analytics.totals[prevMonth(selectedMonth)] ?? { spent: 0, income: 0, investments: 0, net: 0 }; - const spentDelta = totals.spent - lastTotals.spent; - - // Category rows for selected month - const categoryRows = analytics.rows - .filter((r) => (r.spent[selectedMonth] || 0) > 0) - .map((r) => ({ - category: r.category, - spent: r.spent[selectedMonth] || 0, - txCount: r.txCount[selectedMonth] || 0, - })) - .sort((a, b) => b.spent - a.spent); - const largestCategory = categoryRows[0]; - 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); + // Pareto: find where cumulative crosses 80% + const pareto80idx = paretoData.findIndex((r) => r.cumulative >= 80); return (
+ {/* Header + month selector */}

Analytics

- - - {formatMonth(selectedMonth)} - - + + {formatMonth(selectedMonth)} +
@@ -322,11 +326,9 @@ 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) && ( @@ -340,11 +342,8 @@ 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

@@ -359,51 +358,79 @@ export default function AnalyticsPage() {
- {/* Cashflow chart — stacked expense categories + optional savings rate line */} -
-
-

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

- {hasIncome && ( -
- - - Savings Rate + {/* 1. Category trend lines */} + {trendData.categories.length > 0 && ( +
+

Category Trends — 6 Months

+ + { + const month = (d as any)?.activePayload?.[0]?.payload?.month as string | undefined; + if (month) setSelectedMonth(month); + }} + style={{ cursor: "pointer" }} + > + + `$${v}`} width={52} /> + } cursor={{ stroke: "rgba(255,255,255,0.08)", strokeWidth: 1 }} /> + {trendData.categories.map((cat) => ( + (d as any).month === selectedMonth) ? 2 : 2} + dot={{ fill: CATEGORY_COLORS[cat] || "#71717a", r: 3 }} + activeDot={{ r: 5 }} + connectNulls + /> + ))} + + +
+ {trendData.categories.map((cat) => ( + + + {formatCategory(cat)} - - - Income - -
- )} + ))} + {analytics.rows.length > 8 && ( + + {analytics.rows.length - 8} more in table below + )} +
- - { - // 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} - /> - {hasIncome && ( + )} + + {/* 2. Pareto chart */} + {paretoData.length > 0 && ( +
+
+

Spend Concentration — {formatMonth(selectedMonth)}

+ {pareto80idx >= 0 && ( + + Top {pareto80idx + 1} categor{pareto80idx === 0 ? "y" : "ies"} = 80% of spend + + )} +
+ + + + `$${v}`} + width={52} + /> `${v}%`} - domain={[-20, 100]} + domain={[0, 100]} width={36} /> - )} - } cursor={{ fill: "rgba(255,255,255,0.04)" }} /> - - {/* Income bar (ungrouped, green) */} - {hasIncome && ( - - )} - - {/* Stacked expense category bars */} - {expenseCategories.map((cat, i) => ( - - {cashflowData.map((entry) => ( + } cursor={{ fill: "rgba(255,255,255,0.04)" }} /> + + + {paretoData.map((entry, i) => ( ))} - ))} - - {/* Savings rate line */} - {hasIncome && ( - )} - - - - {/* Category color legend */} - {expenseCategories.length > 0 && ( -
- {expenseCategories.map((cat) => ( - - - {formatCategory(cat)} - - ))} -
- )} -
- - {/* Spending by category — horizontal bar chart */} - {categoryChartData.length > 0 && ( -
-

- Spending by Category — {formatMonth(selectedMonth)} -

- - - `$${v}`} - /> - - [fmtExact(Number(value)), "Spent"]} - contentStyle={TOOLTIP_STYLE} - labelFormatter={(label) => formatCategory(String(label))} - labelStyle={{ color: "#a1a1aa" }} - cursor={{ fill: "rgba(255,255,255,0.04)" }} - /> - - {categoryChartData.map((entry) => ( - - ))} - - + +
+ Cumulative % + Below 80% threshold +
)} - {/* Category breakdown table — with expandable rows */} + {/* 3. Cumulative spend this month */} +
+
+

Cumulative Spend — {formatMonth(selectedMonth)}

+ vs avg monthly pace +
+ + + `${v}`} interval={4} /> + `$${(v / 1000).toFixed(1)}k`} width={44} /> + } cursor={{ stroke: "rgba(255,255,255,0.08)", strokeWidth: 1 }} /> + + + + +
+ This month + Typical pace +
+
+ + {/* Category breakdown table — expandable rows */} {categoryRows.length > 0 && (
-

- Spending Breakdown — {formatMonth(selectedMonth)} -

+

Spending Breakdown — {formatMonth(selectedMonth)}

- {tx.transaction_date.slice(5).replace("-", "/")} - - {tx.effective_merchant || tx.description} - - {fmtExact(Number(tx.amount))} - {tx.transaction_date.slice(5).replace("-", "/")}{tx.effective_merchant || tx.description}{fmtExact(Number(tx.amount))}
@@ -555,23 +510,14 @@ export default function AnalyticsPage() { return ( - setExpandedCategory(isExpanded ? null : 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)} > @@ -580,12 +526,7 @@ export default function AnalyticsPage() { {totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}% - {isExpanded && ( - - )} + {isExpanded && } ); })} @@ -602,15 +543,11 @@ export default function AnalyticsPage() {
- + {formatCategory(category)} - - {isExpanded ? "▲" : "▼"} - + {isExpanded ? "▲" : "▼"} {fmtExact(spent)}
- + {analytics.months.map((m) => ( {analytics.months.map((m) => { const spent = row.spent[m]; return ( - ); @@ -647,18 +576,11 @@ export default function AnalyticsPage() { ))} {hasIncome && ( - + {analytics.months.map((m) => { const inc = analytics.income[m]; return ( - ); @@ -667,18 +589,11 @@ export default function AnalyticsPage() { )} {hasInvestments && ( - + {analytics.months.map((m) => { const inv = analytics.investments[m]; return ( - ); @@ -690,12 +605,7 @@ export default function AnalyticsPage() { {analytics.months.map((m) => { const t = analytics.totals[m]; return ( - ); @@ -708,14 +618,8 @@ export default function AnalyticsPage() { const t = analytics.totals[m]; const net = t?.net || 0; return ( - ); })} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 6e0597b..e94b405 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -12,6 +12,7 @@ export interface TransactionRow { transaction_date: string; description: string; amount: number; + amount_aud: number | null; transaction_type: string; merchant_name: string | null; merchant_normalized: string | null;
- Category - Category setSelectedMonth(m)} > {formatShortMonth(m)} @@ -623,22 +560,14 @@ export default function AnalyticsPage() {
- + {formatCategory(row.category)} + {spent !== undefined ? fmt(spent) : "—"}
- Income - Income + {inc ? fmt(inc) : "—"}
- Invested - Invested + {inv ? fmt(inv) : "—"} + {fmt(t?.spent || 0)} = 0 ? "text-emerald-400" : "text-red-400" - } ${m === selectedMonth ? "bg-zinc-800/30" : ""}`} - > - {net >= 0 ? "+" : ""} - {fmt(net)} + = 0 ? "text-emerald-400" : "text-red-400"} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}> + {net >= 0 ? "+" : ""}{fmt(net)}