feat: spending forecast with linear regression chart

Analytics page additions:
- linearForecast(rows, horizonMonths) — OLS regression producing projected,
  low, and high (±1σ residual) for each future month
- ForecastChart — SVG line chart: solid historical line + area fill, dashed
  projected line, translucent confidence band, divider line at forecast start,
  legend for Historical and Projected ± 1σ
- Forecast added to CHART_OPTIONS (on by default)
- Forecast dropdown: 3/6/12 month horizon (default 6)
- Controls grid expanded to 7 columns
- Forecast card spans full width below heatmap
- Projection table: Month / Projected / Low / High columns
- Reset filters resets forecast to 6 months
This commit is contained in:
null 2026-05-28 21:11:29 -05:00
parent c0cb02dbd9
commit 994b5c1e17
1 changed files with 227 additions and 2 deletions

View File

@ -17,6 +17,7 @@ const CHART_OPTIONS = [
['expectedActual', 'Expected vs actual'], ['expectedActual', 'Expected vs actual'],
['categorySpend', 'Category spend'], ['categorySpend', 'Category spend'],
['heatmap', 'Pay heatmap'], ['heatmap', 'Pay heatmap'],
['forecast', 'Spending forecast'],
]; ];
const PALETTE = ['#7c3aed', '#10b981', '#ec4899', '#3b82f6', '#f59e0b', '#14b8a6', '#ef4444', '#8b5cf6']; const PALETTE = ['#7c3aed', '#10b981', '#ec4899', '#3b82f6', '#f59e0b', '#14b8a6', '#ef4444', '#8b5cf6'];
@ -298,6 +299,173 @@ function Heatmap({ heatmap }) {
); );
} }
// Forecast
function linearForecast(rows, horizonMonths) {
if (rows.length < 3) return [];
const n = rows.length;
const ys = rows.map(r => Number(r.total) || 0);
const xMean = (n - 1) / 2;
const yMean = ys.reduce((a, b) => a + b, 0) / n;
const sxy = ys.reduce((sum, y, i) => sum + (i - xMean) * (y - yMean), 0);
const sxx = ys.reduce((sum, _, i) => sum + (i - xMean) ** 2, 0);
const slope = sxy / sxx;
const intercept = yMean - slope * xMean;
const residuals = ys.map((y, i) => y - (slope * i + intercept));
const stdDev = Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / n);
const lastRow = rows[n - 1];
const [lastYear, lastMonthNum] = lastRow.month.split('-').map(Number);
return Array.from({ length: horizonMonths }, (_, i) => {
const x = n + i;
const projected = Math.max(0, slope * x + intercept);
const d = new Date(Date.UTC(lastYear, lastMonthNum - 1 + i + 1, 1));
const month = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
const label = d.toLocaleString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
return {
month,
label,
total: Number(projected.toFixed(2)),
low: Number(Math.max(0, projected - stdDev).toFixed(2)),
high: Number((projected + stdDev).toFixed(2)),
};
});
}
function ForecastChart({ historical, forecast }) {
const allRows = [...historical, ...forecast];
if (!allRows.length) return <EmptyState />;
const width = 720;
const height = 260;
const pad = { left: 58, right: 24, top: 36, bottom: 46 };
const chartW = width - pad.left - pad.right;
const chartH = height - pad.top - pad.bottom;
const maxVal = Math.max(...historical.map(r => r.total), ...forecast.map(r => r.high), 1);
function toXY(index, value) {
const x = pad.left + (allRows.length === 1 ? chartW / 2 : (index / (allRows.length - 1)) * chartW);
const y = pad.top + chartH - (value / maxVal) * chartH;
return { x, y };
}
const histPoints = historical.map((row, i) => ({ ...row, ...toXY(i, row.total) }));
const forePoints = forecast.map((row, i) => ({
...row,
...toXY(historical.length + i, row.total),
yHigh: toXY(historical.length + i, row.high).y,
yLow: toXY(historical.length + i, row.low).y,
}));
const histLine = histPoints.map(p => `${p.x},${p.y}`).join(' ');
const dividerX = histPoints.length ? histPoints[histPoints.length - 1].x : null;
const foreLine = forePoints.length && histPoints.length
? `${histPoints[histPoints.length - 1].x},${histPoints[histPoints.length - 1].y} ` +
forePoints.map(p => `${p.x},${p.y}`).join(' ')
: '';
const bandPoly = forePoints.length > 1
? [...forePoints.map(p => `${p.x},${p.yHigh}`), ...forePoints.slice().reverse().map(p => `${p.x},${p.yLow}`)].join(' ')
: '';
const showLabel = (index) => allRows.length <= 14 || index % Math.ceil(allRows.length / 14) === 0;
return (
<SvgFrame height={height}>
{/* Grid */}
{[0, 0.25, 0.5, 0.75, 1].map(tick => {
const y = pad.top + chartH - tick * chartH;
return (
<g key={tick}>
<line x1={pad.left} x2={pad.left + chartW} y1={y} y2={y} stroke="currentColor" opacity="0.08" />
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">{money(maxVal * tick)}</text>
</g>
);
})}
{/* Forecast region shading */}
{dividerX && (
<rect
x={dividerX} y={pad.top}
width={pad.left + chartW - dividerX} height={chartH}
fill="currentColor" opacity="0.025"
/>
)}
{/* Confidence band */}
{bandPoly && <polygon points={bandPoly} fill="#7c3aed" opacity="0.13" />}
{/* Historical line + area fill */}
<polygon
points={`${pad.left},${pad.top + chartH} ${histLine} ${histPoints[histPoints.length - 1]?.x ?? pad.left},${pad.top + chartH}`}
fill="#7c3aed" opacity="0.10"
/>
<polyline points={histLine} fill="none" stroke="#7c3aed" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
{/* Forecast line (dashed, connects from last historical point) */}
{foreLine && (
<polyline
points={foreLine}
fill="none" stroke="#7c3aed" strokeWidth="2.5"
strokeDasharray="9,6" strokeLinecap="round" strokeLinejoin="round"
opacity="0.7"
/>
)}
{/* Divider tick */}
{dividerX && (
<line
x1={dividerX} x2={dividerX}
y1={pad.top} y2={pad.top + chartH}
stroke="currentColor" strokeDasharray="4,4" opacity="0.2"
/>
)}
{/* Historical dots + labels */}
{histPoints.map((p, i) => (
<g key={p.month}>
<circle cx={p.x} cy={p.y} r="4" fill="#7c3aed" />
{showLabel(i) && (
<text x={p.x} y={height - 18} fontSize="11" fill="currentColor" opacity="0.6" textAnchor="middle">
{p.label}
</text>
)}
<title>{`${p.label}: ${fullMoney(p.total)}`}</title>
</g>
))}
{/* Forecast dots + labels */}
{forePoints.map((p, i) => (
<g key={p.month}>
<circle cx={p.x} cy={p.y} r="4" fill="none" stroke="#7c3aed" strokeWidth="2" opacity="0.75" />
{showLabel(historical.length + i) && (
<text x={p.x} y={height - 18} fontSize="11" fill="currentColor" opacity="0.45" textAnchor="middle">
{p.label}
</text>
)}
<title>{`${p.label} (projected): ${fullMoney(p.total)} · range ${fullMoney(p.low)}${fullMoney(p.high)}`}</title>
</g>
))}
{/* Legend */}
<g transform={`translate(${pad.left + 8}, 18)`} fontSize="11" fill="currentColor" opacity="0.7">
<line x1="0" x2="16" y1="5" y2="5" stroke="#7c3aed" strokeWidth="3" />
<text x="21" y="9">Historical</text>
<line x1="97" x2="113" y1="5" y2="5" stroke="#7c3aed" strokeWidth="2.5" strokeDasharray="5,4" />
<rect x="120" y="0" width="10" height="10" rx="2" fill="#7c3aed" opacity="0.15" />
<text x="135" y="9">Projected ± 1 σ</text>
</g>
</SvgFrame>
);
}
function Field({ label, children }) { function Field({ label, children }) {
return ( return (
<label className="space-y-1.5"> <label className="space-y-1.5">
@ -337,7 +505,9 @@ export default function AnalyticsPage() {
expectedActual: true, expectedActual: true,
categorySpend: true, categorySpend: true,
heatmap: true, heatmap: true,
forecast: true,
}); });
const [forecastHorizon, setForecastHorizon] = useState(6);
const params = useMemo(() => ({ const params = useMemo(() => ({
year, year,
@ -365,6 +535,11 @@ export default function AnalyticsPage() {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const forecastRows = useMemo(
() => linearForecast(data?.monthly_spending || [], forecastHorizon),
[data?.monthly_spending, forecastHorizon],
);
const reset = () => { const reset = () => {
const next = currentMonth(); const next = currentMonth();
setYear(next.year); setYear(next.year);
@ -375,7 +550,8 @@ export default function AnalyticsPage() {
setIncludeInactive(false); setIncludeInactive(false);
setIncludeSkipped(true); setIncludeSkipped(true);
setTrendMode('line'); setTrendMode('line');
setVisible({ monthlyTrend: true, expectedActual: true, categorySpend: true, heatmap: true }); setForecastHorizon(6);
setVisible({ monthlyTrend: true, expectedActual: true, categorySpend: true, heatmap: true, forecast: true });
}; };
const totalCategorySpend = data?.category_spend?.reduce((sum, row) => sum + Number(row.total || 0), 0) || 0; const totalCategorySpend = data?.category_spend?.reduce((sum, row) => sum + Number(row.total || 0), 0) || 0;
@ -422,7 +598,7 @@ export default function AnalyticsPage() {
<section className="analytics-controls surface-elevated p-4"> <section className="analytics-controls surface-elevated p-4">
<div className="grid gap-4 lg:grid-cols-[1fr_auto] lg:items-end"> <div className="grid gap-4 lg:grid-cols-[1fr_auto] lg:items-end">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-6"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-7">
<Field label="Ending month"> <Field label="Ending month">
<ControlSelect value={String(month)} onChange={value => setMonth(Number(value))}> <ControlSelect value={String(month)} onChange={value => setMonth(Number(value))}>
{MONTH_OPTIONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)} {MONTH_OPTIONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
@ -465,6 +641,13 @@ export default function AnalyticsPage() {
<option value="area">Area</option> <option value="area">Area</option>
</ControlSelect> </ControlSelect>
</Field> </Field>
<Field label="Forecast">
<ControlSelect value={String(forecastHorizon)} onChange={v => setForecastHorizon(Number(v))}>
<option value="3">3 months</option>
<option value="6">6 months</option>
<option value="12">12 months</option>
</ControlSelect>
</Field>
</div> </div>
<Button type="button" variant="outline" onClick={reset}> <Button type="button" variant="outline" onClick={reset}>
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
@ -550,6 +733,48 @@ export default function AnalyticsPage() {
</ChartCard> </ChartCard>
</div> </div>
)} )}
{visible.forecast && (
<div className="xl:col-span-2">
<ChartCard
title={`Spending forecast · next ${forecastHorizon} months`}
subtitle={
(data.monthly_spending || []).length < 3
? 'Need at least 3 months of history to project.'
: 'Linear extrapolation from historical payments. Shaded band shows ± 1 standard deviation of past residuals.'
}
>
{(data.monthly_spending || []).length < 3 ? (
<EmptyState label="Not enough history to project — select a wider range." />
) : (
<>
<ForecastChart historical={data.monthly_spending || []} forecast={forecastRows} />
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/60 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<th className="py-2 pr-4 text-left">Month</th>
<th className="py-2 pr-4 text-right">Projected</th>
<th className="py-2 pr-4 text-right">Low estimate</th>
<th className="py-2 text-right">High estimate</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{forecastRows.map(row => (
<tr key={row.month} className="text-muted-foreground hover:text-foreground transition-colors">
<td className="py-2 pr-4 font-medium text-foreground">{row.label}</td>
<td className="py-2 pr-4 text-right font-mono tabular-nums">{fullMoney(row.total)}</td>
<td className="py-2 pr-4 text-right font-mono tabular-nums opacity-70">{fullMoney(row.low)}</td>
<td className="py-2 text-right font-mono tabular-nums opacity-70">{fullMoney(row.high)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</ChartCard>
</div>
)}
{!Object.values(visible).some(Boolean) && ( {!Object.values(visible).some(Boolean) && (
<div className="xl:col-span-2"> <div className="xl:col-span-2">
<EmptyState label="Select at least one chart to show analytics." /> <EmptyState label="Select at least one chart to show analytics." />