Add Recharts charts to Monthly Summary and Annual Overview

Monthly Summary:
- Spending breakdown donut (bills / variable / one-time)
- Variable spending by category bar chart
- Added actuals.by_category to /api/summary/monthly response

Annual Overview:
- Income vs. spending grouped bar chart
- Surplus/deficit bar chart (green/red per month)
- Stacked variable spending by category across all months
- New /api/summary/annual endpoint (single DB round trip for full year)
- AnnualOverview now uses /api/summary/annual instead of 12 parallel calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:58:50 -04:00
parent ea2ee9c5e6
commit 195a36c8a5
5 changed files with 820 additions and 80 deletions

View File

@@ -1,10 +1,18 @@
import { useState, useEffect } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
Cell, ResponsiveContainer, ReferenceLine,
} from 'recharts';
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const PALETTE = ['#3b82f6', '#8b5cf6', '#ec4899', '#f97316', '#22c55e', '#14b8a6', '#eab308', '#64748b'];
function fmt(value) {
if (value == null) return '—';
const num = Number(value);
@@ -13,6 +21,14 @@ function fmt(value) {
return num < 0 ? `-$${abs}` : `$${abs}`;
}
function formatCurrencyShort(value) {
if (value == null || isNaN(value)) return '';
const abs = Math.abs(value);
const sign = value < 0 ? '-' : '';
if (abs >= 1000) return `${sign}$${(abs / 1000).toFixed(1)}k`;
return `${sign}$${abs.toFixed(0)}`;
}
function surplusClass(value) {
if (value == null || isNaN(Number(value))) return '';
return Number(value) >= 0 ? 'text-success' : 'text-danger';
@@ -25,10 +41,98 @@ function sumField(rows, field) {
}, 0);
}
// Income vs Spending grouped bar chart
function IncomeVsSpendingChart({ months }) {
const data = MONTH_SHORT.map((label, i) => {
const m = months.find(r => r.month === i + 1);
return {
month: label,
Income: m ? m.income_net : 0,
Spending: m ? m.total_spending : 0,
};
});
return (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={data} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
<YAxis tickFormatter={formatCurrencyShort} tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
<Tooltip formatter={(val) => fmt(val)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="Income" fill="#22c55e" radius={[3, 3, 0, 0]} />
<Bar dataKey="Spending" fill="#3b82f6" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
// Surplus / Deficit bar chart — green positive, red negative
function SurplusChart({ months }) {
const data = MONTH_SHORT.map((label, i) => {
const m = months.find(r => r.month === i + 1);
return { month: label, value: m ? m.surplus_deficit : null };
});
return (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={data} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
<YAxis tickFormatter={formatCurrencyShort} tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
<Tooltip formatter={(val) => fmt(val)} label="Surplus / Deficit" />
<ReferenceLine y={0} stroke="var(--border-strong)" />
<Bar dataKey="value" name="Surplus / Deficit" radius={[3, 3, 0, 0]}>
{data.map((entry, i) => (
<Cell
key={i}
fill={entry.value == null ? 'var(--border)' : entry.value >= 0 ? '#22c55e' : '#ef4444'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
// Stacked bar chart — variable spending by category across months
function StackedCategoryChart({ months, categories }) {
if (!categories || categories.length === 0) {
return <p className="empty-state">No variable spending data</p>;
}
const data = MONTH_SHORT.map((label, i) => {
const m = months.find(r => r.month === i + 1);
const entry = { month: label };
if (m) {
for (const cat of m.variable_by_category) {
entry[cat.category] = cat.total;
}
}
return entry;
});
return (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={data} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
<YAxis tickFormatter={formatCurrencyShort} tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
<Tooltip formatter={(val) => fmt(val)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{categories.map((cat, i) => (
<Bar key={cat} dataKey={cat} stackId="a" fill={PALETTE[i % PALETTE.length]}
radius={i === categories.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]} />
))}
</BarChart>
</ResponsiveContainer>
);
}
export default function AnnualOverview() {
const currentYear = new Date().getFullYear();
const [year, setYear] = useState(currentYear);
const [monthData, setMonthData] = useState(Array(12).fill(null));
const [annualData, setAnnualData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -36,42 +140,35 @@ export default function AnnualOverview() {
let cancelled = false;
setLoading(true);
setError(null);
setAnnualData(null);
function normalize(data) {
if (!data) return null;
return {
total_income: data.income?.net ?? 0,
total_bills: data.bills?.planned ?? 0,
total_variable: data.actuals?.total ?? 0,
total_one_time: data.one_time_expenses?.total ?? 0,
total_spending: data.summary?.total_spending ?? 0,
surplus_deficit: data.summary?.surplus_deficit ?? 0,
};
}
Promise.all(
Array.from({ length: 12 }, (_, i) =>
fetch(`/api/summary/monthly?year=${year}&month=${i + 1}`)
.then(r => r.ok ? r.json().then(normalize) : null)
.catch(() => null)
)
).then(results => {
if (!cancelled) { setMonthData(results); setLoading(false); }
}).catch(err => {
if (!cancelled) { setError(err.message || 'Failed to load data'); setLoading(false); }
});
fetch(`/api/summary/annual?year=${year}`)
.then(r => {
if (!r.ok) throw new Error(`Server error: ${r.status}`);
return r.json();
})
.then(data => { if (!cancelled) { setAnnualData(data); setLoading(false); } })
.catch(err => { if (!cancelled) { setError(err.message); setLoading(false); } });
return () => { cancelled = true; };
}, [year]);
const hasData = monthData.some(row => row != null);
const months = annualData?.months ?? [];
const categories = annualData?.categories ?? [];
const hasData = months.length > 0;
const annualIncome = sumField(monthData, 'total_income');
const annualBills = sumField(monthData, 'total_bills');
const annualVariable = sumField(monthData, 'total_variable');
const annualOneTime = sumField(monthData, 'total_one_time');
const annualSpending = sumField(monthData, 'total_spending');
const annualSurplus = sumField(monthData, 'surplus_deficit');
const annualIncome = sumField(months, 'income_net');
const annualBills = sumField(months, 'total_bills');
const annualVariable = sumField(months, 'total_variable');
const annualOneTime = sumField(months, 'total_one_time');
const annualSpending = sumField(months, 'total_spending');
const annualSurplus = sumField(months, 'surplus_deficit');
// Build full 12-row table (show — for months without data)
const tableRows = MONTH_NAMES.map((name, i) => {
const row = months.find(m => m.month === i + 1) || null;
return { name, row };
});
return (
<div>
@@ -105,54 +202,71 @@ export default function AnnualOverview() {
{loading && <p className="text-muted">Loading</p>}
{error && <div className="alert alert-error">Error: {error}</div>}
<div className="card" style={{ overflow: 'hidden' }}>
<table className="data-table">
<thead>
<tr>
<th>Month</th>
<th className="text-right">Income (net)</th>
<th className="text-right">Bills</th>
<th className="text-right">Variable</th>
<th className="text-right">One-time</th>
<th className="text-right">Total Spending</th>
<th className="text-right">Surplus / Deficit</th>
</tr>
</thead>
<tbody>
{MONTH_NAMES.map((name, i) => {
const row = monthData[i];
const hasRow = row != null;
const surplus = hasRow ? row.surplus_deficit : null;
return (
<tr key={name}>
<td>{name}</td>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_income) : '—'}</td>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_bills) : '—'}</td>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_variable) : '—'}</td>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_one_time) : '—'}</td>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_spending) : '—'}</td>
<td className={`text-right font-tabular ${surplusClass(surplus)}`}>
{hasRow ? fmt(surplus) : '—'}
{!loading && hasData && (
<>
{/* Charts */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.25rem', marginBottom: '1.25rem' }}>
<div className="card card-body">
<div className="section-title" style={{ marginBottom: '0.75rem' }}>Income vs. Spending</div>
<IncomeVsSpendingChart months={months} />
</div>
<div className="card card-body">
<div className="section-title" style={{ marginBottom: '0.75rem' }}>Surplus / Deficit by Month</div>
<SurplusChart months={months} />
</div>
</div>
<div className="card card-body" style={{ marginBottom: '1.25rem' }}>
<div className="section-title" style={{ marginBottom: '0.75rem' }}>Variable Spending by Category</div>
<StackedCategoryChart months={months} categories={categories} />
</div>
{/* Table */}
<div className="card" style={{ overflow: 'hidden' }}>
<table className="data-table">
<thead>
<tr>
<th>Month</th>
<th className="text-right">Income (net)</th>
<th className="text-right">Bills</th>
<th className="text-right">Variable</th>
<th className="text-right">One-time</th>
<th className="text-right">Total Spending</th>
<th className="text-right">Surplus / Deficit</th>
</tr>
</thead>
<tbody>
{tableRows.map(({ name, row }) => (
<tr key={name}>
<td>{name}</td>
<td className="text-right font-tabular">{row ? fmt(row.income_net) : '—'}</td>
<td className="text-right font-tabular">{row ? fmt(row.total_bills) : '—'}</td>
<td className="text-right font-tabular">{row ? fmt(row.total_variable) : '—'}</td>
<td className="text-right font-tabular">{row ? fmt(row.total_one_time) : '—'}</td>
<td className="text-right font-tabular">{row ? fmt(row.total_spending) : '—'}</td>
<td className={`text-right font-tabular ${row ? surplusClass(row.surplus_deficit) : ''}`}>
{row ? fmt(row.surplus_deficit) : '—'}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td className="font-bold">Total</td>
<td className="text-right font-tabular">{fmt(annualIncome)}</td>
<td className="text-right font-tabular">{fmt(annualBills)}</td>
<td className="text-right font-tabular">{fmt(annualVariable)}</td>
<td className="text-right font-tabular">{fmt(annualOneTime)}</td>
<td className="text-right font-tabular">{fmt(annualSpending)}</td>
<td className={`text-right font-tabular font-bold ${surplusClass(annualSurplus)}`}>
{fmt(annualSurplus)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td className="font-bold">Total</td>
<td className="text-right font-tabular">{hasData ? fmt(annualIncome) : '—'}</td>
<td className="text-right font-tabular">{hasData ? fmt(annualBills) : '—'}</td>
<td className="text-right font-tabular">{hasData ? fmt(annualVariable) : '—'}</td>
<td className="text-right font-tabular">{hasData ? fmt(annualOneTime) : '—'}</td>
<td className="text-right font-tabular">{hasData ? fmt(annualSpending) : '—'}</td>
<td className={`text-right font-tabular font-bold ${surplusClass(annualSurplus)}`}>
{hasData ? fmt(annualSurplus) : '—'}
</td>
</tr>
</tfoot>
</table>
</div>
</tfoot>
</table>
</div>
</>
)}
</div>
);
}

View File

@@ -1,4 +1,8 @@
import { useState, useEffect } from 'react';
import {
PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
} from 'recharts';
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
@@ -10,6 +14,15 @@ function formatCurrency(value) {
return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatCurrencyShort(value) {
const num = parseFloat(value) || 0;
if (Math.abs(num) >= 1000) return '$' + (num / 1000).toFixed(1) + 'k';
return '$' + num.toFixed(0);
}
// Accessible palette that works in light and dark
const PALETTE = ['#3b82f6', '#8b5cf6', '#ec4899', '#f97316', '#22c55e', '#14b8a6', '#eab308', '#64748b'];
function StatCard({ label, value, valueClass }) {
return (
<div className="stat-card">
@@ -19,6 +32,58 @@ function StatCard({ label, value, valueClass }) {
);
}
function SpendingDonut({ bills, variable, oneTime }) {
const data = [
{ name: 'Bills', value: bills },
{ name: 'Variable', value: variable },
{ name: 'One-time', value: oneTime },
].filter(d => d.value > 0);
if (data.length === 0) return <p className="empty-state">No spending data</p>;
return (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
>
{data.map((_, i) => (
<Cell key={i} fill={PALETTE[i % PALETTE.length]} />
))}
</Pie>
<Tooltip formatter={(val) => formatCurrency(val)} />
<Legend />
</PieChart>
</ResponsiveContainer>
);
}
function CategoryBar({ data }) {
if (!data || data.length === 0) return <p className="empty-state">No variable spending data</p>;
return (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={data} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="category" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
<YAxis tickFormatter={formatCurrencyShort} tick={{ fontSize: 11, fill: 'var(--text-muted)' }} />
<Tooltip formatter={(val) => formatCurrency(val)} />
<Bar dataKey="total" name="Spending" radius={[4, 4, 0, 0]}>
{data.map((_, i) => (
<Cell key={i} fill={PALETTE[i % PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
function MonthlySummary() {
const now = new Date();
const [year, setYear] = useState(now.getFullYear());
@@ -82,6 +147,22 @@ function MonthlySummary() {
<StatCard label="Bills Paid" value={`${data.bills.paid_count} of ${data.bills.count}`} />
</div>
{/* Charts row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.25rem', marginBottom: '1.25rem' }}>
<div className="card card-body">
<div className="section-title" style={{ marginBottom: '0.75rem' }}>Spending Breakdown</div>
<SpendingDonut
bills={data.bills.planned}
variable={data.actuals.total}
oneTime={data.one_time_expenses.total}
/>
</div>
<div className="card card-body">
<div className="section-title" style={{ marginBottom: '0.75rem' }}>Variable Spending by Category</div>
<CategoryBar data={data.actuals.by_category} />
</div>
</div>
<div className="card" style={{ overflow: 'hidden' }}>
<table className="data-table">
<thead>