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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user