Add annual overview page

Year-at-a-glance table with monthly income, bills, variable
spending, and surplus/deficit. Fetches all 12 months in parallel.
Summary cards show annual totals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:15:08 -04:00
parent 368786a9e1
commit 3d8cdc9068

View File

@@ -1,5 +1,214 @@
function AnnualOverview() { import { useState, useEffect } from 'react';
return <div><h1>Annual Overview</h1><p>Placeholder coming soon.</p></div>;
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
function fmt(value) {
if (value == null) return '—';
const num = Number(value);
if (isNaN(num)) return '—';
const abs = Math.abs(num).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return num < 0 ? `-$${abs}` : `$${abs}`;
} }
export default AnnualOverview; function surplusStyle(value) {
if (value == null || isNaN(Number(value))) return {};
return { color: Number(value) >= 0 ? '#2a9d2a' : '#cc2222', fontWeight: 500 };
}
function sumField(rows, field) {
return rows.reduce((acc, row) => {
const v = row && row[field] != null ? Number(row[field]) : 0;
return acc + (isNaN(v) ? 0 : v);
}, 0);
}
export default function AnnualOverview() {
const currentYear = new Date().getFullYear();
const [year, setYear] = useState(currentYear);
const [monthData, setMonthData] = useState(Array(12).fill(null));
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
Promise.all(
Array.from({ length: 12 }, (_, i) =>
fetch(`/api/summary/monthly?year=${year}&month=${i + 1}`)
.then(r => {
if (!r.ok) return null;
return r.json();
})
.catch(() => null)
)
).then(results => {
if (!cancelled) {
setMonthData(results);
setLoading(false);
}
}).catch(err => {
if (!cancelled) {
setError(err.message || 'Failed to load data');
setLoading(false);
}
});
return () => { cancelled = true; };
}, [year]);
const hasData = monthData.some(row => row != null);
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 cardStyle = {
background: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: 6,
padding: '1rem 1.25rem',
minWidth: 160,
flex: '1 1 160px',
};
const cardLabelStyle = {
fontSize: '0.75rem',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 4,
};
const cardValueStyle = {
fontSize: '1.4rem',
fontWeight: 700,
};
return (
<div style={{ maxWidth: 1000 }}>
{/* Year navigation */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem' }}>
<button
onClick={() => setYear(y => y - 1)}
style={{ fontSize: '1.2rem', cursor: 'pointer', background: 'none', border: '1px solid #ccc', borderRadius: 4, padding: '0.2rem 0.6rem' }}
aria-label="Previous year"
>
</button>
<h1 style={{ margin: 0, fontSize: '1.5rem' }}>{year} Annual Overview</h1>
<button
onClick={() => setYear(y => y + 1)}
style={{ fontSize: '1.2rem', cursor: 'pointer', background: 'none', border: '1px solid #ccc', borderRadius: 4, padding: '0.2rem 0.6rem' }}
aria-label="Next year"
>
</button>
</div>
{/* Summary cards */}
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '2rem' }}>
<div style={cardStyle}>
<div style={cardLabelStyle}>Annual Income (net)</div>
<div style={cardValueStyle}>{hasData ? fmt(annualIncome) : '—'}</div>
</div>
<div style={cardStyle}>
<div style={cardLabelStyle}>Annual Bills</div>
<div style={cardValueStyle}>{hasData ? fmt(annualBills) : '—'}</div>
</div>
<div style={cardStyle}>
<div style={cardLabelStyle}>Annual Variable Spending</div>
<div style={cardValueStyle}>{hasData ? fmt(annualVariable) : '—'}</div>
</div>
<div style={{ ...cardStyle }}>
<div style={cardLabelStyle}>Annual Surplus / Deficit</div>
<div style={{ ...cardValueStyle, ...surplusStyle(annualSurplus) }}>
{hasData ? fmt(annualSurplus) : '—'}
</div>
</div>
</div>
{/* Status messages */}
{loading && <p style={{ color: '#666' }}>Loading</p>}
{error && <p style={{ color: '#cc2222' }}>Error: {error}</p>}
{/* Monthly table */}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
<thead>
<tr style={{ background: '#f0f0f0', textAlign: 'right' }}>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Month</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Income (net)</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Bills</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Variable</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>One-time</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Total Spending</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>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}
style={{ borderBottom: '1px solid #eee' }}
>
<td style={{ padding: '0.5rem 0.75rem' }}>{name}</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasRow ? fmt(row.total_income) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasRow ? fmt(row.total_bills) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasRow ? fmt(row.total_variable) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasRow ? fmt(row.total_one_time) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasRow ? fmt(row.total_spending) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem', ...surplusStyle(surplus) }}>
{hasRow ? fmt(surplus) : '—'}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid #ccc', fontWeight: 700, background: '#fafafa' }}>
<td style={{ padding: '0.5rem 0.75rem' }}>Total</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualIncome) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualBills) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualVariable) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualOneTime) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualSpending) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem', ...surplusStyle(annualSurplus) }}>
{hasData ? fmt(annualSurplus) : '—'}
</td>
</tr>
</tfoot>
</table>
</div>
);
}