feat(analytics): replace budget page with spending analytics + split-adjusted amounts

- Rename 'Budget' → 'Analytics' in sidebar
- Rewrite /budget page: summary cards, recharts bar charts (monthly trend + category breakdown), 6-month trend table
- Fix analytics API to count only user's share for split transactions (CASE WHEN ts.share_percent IS NOT NULL THEN amount * share_percent / 100 ELSE amount END)
- Install recharts
This commit is contained in:
2026-03-08 17:58:33 +11:00
parent 1e79ada6d8
commit 30a7857d13
5 changed files with 646 additions and 272 deletions
+241 -267
View File
@@ -2,17 +2,20 @@
import { useState } from "react";
import {
useBudgets,
useUpsertBudget,
useDeleteBudget,
useMonthlyAnalytics,
} from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { useMonthlyAnalytics } from "@/lib/hooks";
import { formatCategory } from "@/lib/categories";
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" });
function currentMonthStr(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
function prevMonth(m: string): string {
@@ -27,66 +30,79 @@ function nextMonth(m: string): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
function currentMonthStr(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.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" });
}
function barColor(pct: number): string {
if (pct > 100) return "bg-red-500";
if (pct > 80) return "bg-yellow-400";
return "bg-emerald-500";
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" });
}
export default function BudgetPage() {
function fmt(n: number): string {
return `$${n.toFixed(0)}`;
}
function fmtExact(n: number): string {
return `$${n.toFixed(2)}`;
}
export default function AnalyticsPage() {
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
const [editingCategory, setEditingCategory] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
const { data: budgets = [], isLoading: budgetsLoading } = useBudgets(selectedMonth);
const { data: analytics, isLoading: analyticsLoading } = useMonthlyAnalytics(6);
const upsertBudget = useUpsertBudget();
const deleteBudget = useDeleteBudget();
const budgetMap = new Map(budgets.map((b) => [b.category, b]));
// Categories with spend this month or a budget set
const currentMonthRows = analytics?.rows.filter((r) => r.spent[selectedMonth] !== undefined) || [];
const allCategories = new Set<string>([
...currentMonthRows.map((r) => r.category),
...budgets.map((b) => b.category),
]);
const tableRows = Array.from(allCategories)
.sort()
.map((cat) => {
const analyticsRow = analytics?.rows.find((r) => r.category === cat);
const spent = analyticsRow?.spent[selectedMonth] || 0;
const txCount = analyticsRow?.txCount[selectedMonth] || 0;
const budget = budgetMap.get(cat);
const limit = budget ? Number(budget.amount_limit) : null;
const remaining = limit !== null ? limit - spent : null;
const pct = limit !== null && limit > 0 ? (spent / limit) * 100 : null;
return { cat, spent, txCount, budget, limit, remaining, pct };
});
const totalBudgeted = budgets.reduce((s, b) => s + Number(b.amount_limit), 0);
const totalSpent = tableRows.reduce((s, r) => s + r.spent, 0);
const overBudgetCount = tableRows.filter((r) => r.pct !== null && r.pct > 100).length;
async function handleBudgetSave(cat: string) {
const val = parseFloat(editValue);
if (isNaN(val) || val < 0) return;
await upsertBudget.mutateAsync({ category: cat, month: selectedMonth, amount_limit: val });
setEditingCategory(null);
setEditValue("");
if (isLoading || !analytics) {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold">Analytics</h2>
<p className="text-zinc-500 text-sm">Loading...</p>
</div>
);
}
// Ensure selectedMonth is within the available range
const months = [...analytics.months].reverse(); // chronological order
// Per-month totals (selected month)
const totalSpent = analytics.totals[selectedMonth]?.spent || 0;
// Last month for comparison
const lastMonth = prevMonth(selectedMonth);
const lastMonthSpent = analytics.totals[lastMonth]?.spent || 0;
const delta = totalSpent - lastMonthSpent;
// Category rows for selected month, sorted by spend
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];
// Data for monthly trend bar chart
const trendData = months.map((m) => ({
month: m,
label: formatShortMonth(m),
spent: analytics.totals[m]?.spent || 0,
}));
// Data for horizontal category bar chart
const categoryChartData = [...categoryRows].reverse(); // smallest at top for readability
const chartHeight = Math.max(categoryChartData.length * 36, 120);
return (
<div className="space-y-6">
{/* Month selector */}
{/* Header + month selector */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Budget</h2>
<h2 className="text-xl font-semibold">Analytics</h2>
<div className="flex items-center gap-3">
<button
onClick={() => setSelectedMonth(prevMonth(selectedMonth))}
@@ -94,7 +110,9 @@ export default function BudgetPage() {
>
</button>
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
<span className="text-sm font-medium min-w-36 text-center">
{formatMonth(selectedMonth)}
</span>
<button
onClick={() => setSelectedMonth(nextMonth(selectedMonth))}
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
@@ -106,210 +124,169 @@ export default function BudgetPage() {
{/* Summary cards */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Total Budgeted</p>
<p className="text-2xl font-semibold">${totalBudgeted.toFixed(2)}</p>
</div>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Total Spent</p>
<p
className={`text-2xl font-semibold ${
totalBudgeted > 0 && totalSpent > totalBudgeted ? "text-red-400" : ""
}`}
>
${totalSpent.toFixed(2)}
</p>
<p className="text-2xl font-semibold">{fmtExact(totalSpent)}</p>
<p className="text-xs text-zinc-500 mt-1">split-adjusted</p>
</div>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Over Budget</p>
<p
className={`text-2xl font-semibold ${
overBudgetCount > 0 ? "text-red-400" : "text-emerald-400"
}`}
>
{overBudgetCount} {overBudgetCount === 1 ? "category" : "categories"}
</p>
<p className="text-xs text-zinc-500 mb-1">Largest Category</p>
{largestCategory ? (
<>
<p className="text-2xl font-semibold">{fmtExact(largestCategory.spent)}</p>
<p className="text-xs text-zinc-400 mt-1">{formatCategory(largestCategory.category)}</p>
</>
) : (
<p className="text-2xl font-semibold text-zinc-600"></p>
)}
</div>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">vs Last Month</p>
{lastMonthSpent > 0 ? (
<>
<p
className={`text-2xl font-semibold ${
delta > 0 ? "text-red-400" : delta < 0 ? "text-emerald-400" : ""
}`}
>
{delta > 0 ? "+" : ""}
{fmtExact(delta)}
</p>
<p className="text-xs text-zinc-500 mt-1">
{delta > 0 ? "↑ more" : delta < 0 ? "↓ less" : "same"} than {formatShortMonth(lastMonth)}
</p>
</>
) : (
<p className="text-2xl font-semibold text-zinc-600"></p>
)}
</div>
</div>
{/* Budget table for current month */}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-3 text-xs text-zinc-500 font-medium">Category</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Budget</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Spent</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Remaining</th>
<th className="px-4 py-3 text-xs text-zinc-500 font-medium w-36">Progress</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium"># Txns</th>
</tr>
</thead>
<tbody>
{budgetsLoading || analyticsLoading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
Loading...
</td>
</tr>
) : tableRows.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
No spending data for this month. Set a budget for any category below.
</td>
</tr>
) : (
tableRows.map(({ cat, spent, txCount, budget, limit, remaining, pct }) => (
<tr key={cat} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3 font-medium">{formatCategory(cat)}</td>
<td className="px-4 py-3 text-right">
{editingCategory === cat ? (
<div className="flex items-center justify-end gap-1">
<input
autoFocus
type="number"
min="0"
step="0.01"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleBudgetSave(cat);
if (e.key === "Escape") {
setEditingCategory(null);
setEditValue("");
}
}}
className="w-24 bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-right text-sm"
placeholder="0.00"
/>
<button
onClick={() => handleBudgetSave(cat)}
className="text-xs text-emerald-400 hover:text-emerald-300 px-1"
>
</button>
<button
onClick={() => {
setEditingCategory(null);
setEditValue("");
}}
className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
>
</button>
</div>
) : (
<div className="flex items-center justify-end gap-1">
<button
onClick={() => {
setEditingCategory(cat);
setEditValue(limit?.toString() || "");
}}
className="text-zinc-300 hover:text-white underline-offset-2 hover:underline"
>
{limit !== null ? (
`$${limit.toFixed(2)}`
) : (
<span className="text-zinc-600 italic">Set...</span>
)}
</button>
{budget && (
<button
onClick={() => deleteBudget.mutate(budget.id)}
className="text-zinc-600 hover:text-red-400 text-xs ml-1"
>
×
</button>
)}
</div>
)}
</td>
<td className="px-4 py-3 text-right">${spent.toFixed(2)}</td>
<td
className={`px-4 py-3 text-right ${
remaining !== null
? remaining < 0
? "text-red-400"
: "text-emerald-400"
: "text-zinc-600"
}`}
>
{remaining !== null ? `$${remaining.toFixed(2)}` : "—"}
</td>
<td className="px-4 py-3">
{pct !== null ? (
<div className="flex items-center gap-2">
<div className="flex-1 bg-zinc-800 rounded-full h-2 overflow-hidden">
<div
className={`h-full rounded-full ${barColor(pct)}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<span className="text-xs text-zinc-400 w-10 text-right">
{pct.toFixed(0)}%
</span>
</div>
) : (
<span className="text-zinc-600 text-xs"></span>
)}
</td>
<td className="px-4 py-3 text-right text-zinc-400">{txCount}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Add budget for any category not yet shown */}
<div>
<p className="text-xs text-zinc-500 mb-2">Set budget for another category:</p>
<div className="flex flex-wrap gap-2">
{CATEGORIES.filter((c) => !allCategories.has(c)).map((cat) => (
<button
key={cat}
onClick={() => {
setEditingCategory(cat);
setEditValue("");
}}
className="px-3 py-1.5 text-xs rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
>
{formatCategory(cat)}
</button>
))}
</div>
{editingCategory && !allCategories.has(editingCategory) && (
<div className="flex items-center gap-2 mt-3">
<span className="text-sm font-medium">{formatCategory(editingCategory)}</span>
<input
autoFocus
type="number"
min="0"
step="0.01"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleBudgetSave(editingCategory);
if (e.key === "Escape") {
setEditingCategory(null);
setEditValue("");
}
}}
className="w-28 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
placeholder="Budget amount"
{/* Monthly trend bar chart */}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<h3 className="text-sm font-medium mb-4">Monthly Spending</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={trendData} margin={{ top: 0, right: 8, bottom: 0, left: 8 }}>
<XAxis
dataKey="label"
tick={{ fill: "#71717a", fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<button
onClick={() => handleBudgetSave(editingCategory)}
className="px-3 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg"
<YAxis
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${v}`}
width={56}
/>
<Tooltip
formatter={(value) => [fmtExact(Number(value)), "Spent"]}
contentStyle={{
background: "#18181b",
border: "1px solid #3f3f46",
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: "#a1a1aa" }}
cursor={{ fill: "rgba(255,255,255,0.04)" }}
/>
<Bar
dataKey="spent"
radius={[4, 4, 0, 0]}
onClick={(data) => setSelectedMonth((data as unknown as { month: string }).month)}
style={{ cursor: "pointer" }}
>
Save
</button>
</div>
)}
{trendData.map((entry) => (
<Cell
key={entry.month}
fill={entry.month === selectedMonth ? "#6366f1" : "#3f3f46"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Spending by category — horizontal bar chart */}
{categoryChartData.length > 0 && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<h3 className="text-sm font-medium mb-4">
Spending by Category {formatMonth(selectedMonth)}
</h3>
<ResponsiveContainer width="100%" height={chartHeight}>
<BarChart
data={categoryChartData}
layout="vertical"
margin={{ top: 0, right: 8, bottom: 0, left: 8 }}
>
<XAxis
type="number"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${v}`}
/>
<YAxis
type="category"
dataKey="category"
width={120}
tick={{ fill: "#a1a1aa", fontSize: 12 }}
axisLine={false}
tickLine={false}
tickFormatter={formatCategory}
/>
<Tooltip
formatter={(value) => [fmtExact(Number(value)), "Spent"]}
contentStyle={{
background: "#18181b",
border: "1px solid #3f3f46",
borderRadius: 8,
fontSize: 12,
}}
labelFormatter={(label) => formatCategory(String(label))}
labelStyle={{ color: "#a1a1aa" }}
cursor={{ fill: "rgba(255,255,255,0.04)" }}
/>
<Bar dataKey="spent" fill="#6366f1" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Category breakdown table */}
{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">Category Breakdown {formatMonth(selectedMonth)}</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Spent</th>
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium"># Txns</th>
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">% of Total</th>
</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>
<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">
{totalSpent > 0 ? ((spent / totalSpent) * 100).toFixed(1) : "0.0"}%
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 6-month trend table */}
{analytics && analytics.months.length > 0 && (
{analytics.months.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
<div className="overflow-x-auto rounded-xl border border-zinc-700">
@@ -322,9 +299,12 @@ export default function BudgetPage() {
{analytics.months.map((m) => (
<th
key={m}
className="text-right px-3 py-2 text-zinc-500 font-medium whitespace-nowrap"
className={`text-right px-3 py-2 font-medium whitespace-nowrap cursor-pointer hover:text-zinc-300 ${
m === selectedMonth ? "text-indigo-400" : "text-zinc-500"
}`}
onClick={() => setSelectedMonth(m)}
>
{m}
{formatShortMonth(m)}
</th>
))}
</tr>
@@ -337,21 +317,14 @@ export default function BudgetPage() {
</td>
{analytics.months.map((m) => {
const spent = row.spent[m];
const budget = row.budget[m];
const overBudget =
spent !== undefined && budget !== undefined && spent > budget;
return (
<td
key={m}
className={`px-3 py-2 text-right tabular-nums ${
spent === undefined
? "text-zinc-700"
: overBudget
? "text-red-300 bg-red-950/40"
: "text-zinc-300"
}`}
spent === undefined ? "text-zinc-700" : "text-zinc-300"
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
>
{spent !== undefined ? `$${Number(spent).toFixed(0)}` : "—"}
{spent !== undefined ? fmt(spent) : "—"}
</td>
);
})}
@@ -361,13 +334,14 @@ export default function BudgetPage() {
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Total</td>
{analytics.months.map((m) => {
const t = analytics.totals[m];
const over = t && t.budget > 0 && t.spent > t.budget;
return (
<td
key={m}
className={`px-3 py-2 text-right tabular-nums ${over ? "text-red-300" : ""}`}
className={`px-3 py-2 text-right tabular-nums ${
m === selectedMonth ? "bg-zinc-800/30 text-indigo-300" : ""
}`}
>
${(t?.spent || 0).toFixed(0)}
{fmt(t?.spent || 0)}
</td>
);
})}