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