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

@@ -64,7 +64,7 @@ router.get('/summary/monthly', async (req, res) => {
const billsCount = billsRow.count || 0;
const billsPaidCount = billsRow.paid_count || 0;
// Actuals aggregates
// Actuals aggregates + per-category breakdown
const actualsResult = await pool.query(
`SELECT COUNT(*)::int AS count, COALESCE(SUM(amount), 0) AS total
FROM actuals
@@ -72,9 +72,24 @@ router.get('/summary/monthly', async (req, res) => {
[paycheckIds]
);
const actualsCategoryResult = await pool.query(
`SELECT COALESCE(ec.name, 'Uncategorized') AS category,
COALESCE(SUM(a.amount), 0) AS total
FROM actuals a
LEFT JOIN expense_categories ec ON ec.id = a.category_id
WHERE a.paycheck_id = ANY($1)
GROUP BY COALESCE(ec.name, 'Uncategorized')
ORDER BY total DESC`,
[paycheckIds]
);
const actualsRow = actualsResult.rows[0];
const actualsTotal = parseFloat(actualsRow.total) || 0;
const actualsCount = actualsRow.count || 0;
const variableByCategory = actualsCategoryResult.rows.map(r => ({
category: r.category,
total: parseFloat(r.total) || 0,
}));
// One-time expenses aggregates
const oteResult = await pool.query(
@@ -109,6 +124,7 @@ router.get('/summary/monthly', async (req, res) => {
actuals: {
total: parseFloat(actualsTotal.toFixed(2)),
count: actualsCount,
by_category: variableByCategory,
},
one_time_expenses: {
total: parseFloat(oteTotal.toFixed(2)),
@@ -125,4 +141,125 @@ router.get('/summary/monthly', async (req, res) => {
}
});
// GET /api/summary/annual?year=
router.get('/summary/annual', async (req, res) => {
const year = parseInt(req.query.year, 10);
if (isNaN(year)) {
return res.status(400).json({ error: 'year is required' });
}
try {
// All paychecks for the year
const pcResult = await pool.query(
`SELECT id, period_month, gross, net FROM paychecks
WHERE period_year = $1 ORDER BY period_month`,
[year]
);
// Per-month income totals
const monthIncome = {};
for (const r of pcResult.rows) {
const m = r.period_month;
if (!monthIncome[m]) monthIncome[m] = { gross: 0, net: 0 };
monthIncome[m].gross += parseFloat(r.gross) || 0;
monthIncome[m].net += parseFloat(r.net) || 0;
}
const paycheckIds = pcResult.rows.map(r => r.id);
if (paycheckIds.length === 0) {
return res.json({ year, months: [], categories: [] });
}
// Per-month bill totals
const billsResult = await pool.query(
`SELECT p.period_month,
COALESCE(SUM(CASE WHEN pb.amount_override IS NOT NULL THEN pb.amount_override ELSE b.amount END), 0) AS planned
FROM paycheck_bills pb
JOIN bills b ON b.id = pb.bill_id
JOIN paychecks p ON p.id = pb.paycheck_id
WHERE pb.paycheck_id = ANY($1)
GROUP BY p.period_month`,
[paycheckIds]
);
const monthBills = {};
for (const r of billsResult.rows) {
monthBills[r.period_month] = parseFloat(r.planned) || 0;
}
// Per-month one-time expense totals
const oteResult = await pool.query(
`SELECT p.period_month, COALESCE(SUM(ote.amount), 0) AS total
FROM one_time_expenses ote
JOIN paychecks p ON p.id = ote.paycheck_id
WHERE ote.paycheck_id = ANY($1)
GROUP BY p.period_month`,
[paycheckIds]
);
const monthOte = {};
for (const r of oteResult.rows) {
monthOte[r.period_month] = parseFloat(r.total) || 0;
}
// Per-month actuals by category
const actualsResult = await pool.query(
`SELECT p.period_month,
COALESCE(ec.name, 'Uncategorized') AS category,
COALESCE(SUM(a.amount), 0) AS total
FROM actuals a
JOIN paychecks p ON p.id = a.paycheck_id
LEFT JOIN expense_categories ec ON ec.id = a.category_id
WHERE a.paycheck_id = ANY($1)
GROUP BY p.period_month, COALESCE(ec.name, 'Uncategorized')
ORDER BY p.period_month, total DESC`,
[paycheckIds]
);
// Collect all unique categories
const categorySet = new Set();
const monthActualsByCategory = {};
for (const r of actualsResult.rows) {
const m = r.period_month;
categorySet.add(r.category);
if (!monthActualsByCategory[m]) monthActualsByCategory[m] = {};
monthActualsByCategory[m][r.category] = parseFloat(r.total) || 0;
}
const categories = [...categorySet];
// Build months array (only months with paychecks)
const monthSet = new Set(pcResult.rows.map(r => r.period_month));
const months = [...monthSet].sort((a, b) => a - b).map(m => {
const income = monthIncome[m] || { gross: 0, net: 0 };
const bills = monthBills[m] || 0;
const ote = monthOte[m] || 0;
const catData = monthActualsByCategory[m] || {};
const variable = Object.values(catData).reduce((s, v) => s + v, 0);
const spending = bills + variable + ote;
return {
month: m,
month_name: MONTH_NAMES[m - 1],
income_net: parseFloat(income.net.toFixed(2)),
total_bills: parseFloat(bills.toFixed(2)),
total_variable: parseFloat(variable.toFixed(2)),
total_one_time: parseFloat(ote.toFixed(2)),
total_spending: parseFloat(spending.toFixed(2)),
surplus_deficit: parseFloat((income.net - spending).toFixed(2)),
variable_by_category: categories.map(cat => ({
category: cat,
total: parseFloat((catData[cat] || 0).toFixed(2)),
})),
};
});
res.json({ year, months, categories });
} catch (err) {
console.error('GET /api/summary/annual error:', err);
res.status(500).json({ error: 'Failed to fetch annual summary' });
}
});
module.exports = router;