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:
parent
c0cb02dbd9
commit
994b5c1e17
|
|
@ -17,6 +17,7 @@ const CHART_OPTIONS = [
|
|||
['expectedActual', 'Expected vs actual'],
|
||||
['categorySpend', 'Category spend'],
|
||||
['heatmap', 'Pay heatmap'],
|
||||
['forecast', 'Spending forecast'],
|
||||
];
|
||||
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 }) {
|
||||
return (
|
||||
<label className="space-y-1.5">
|
||||
|
|
@ -337,7 +505,9 @@ export default function AnalyticsPage() {
|
|||
expectedActual: true,
|
||||
categorySpend: true,
|
||||
heatmap: true,
|
||||
forecast: true,
|
||||
});
|
||||
const [forecastHorizon, setForecastHorizon] = useState(6);
|
||||
|
||||
const params = useMemo(() => ({
|
||||
year,
|
||||
|
|
@ -365,6 +535,11 @@ export default function AnalyticsPage() {
|
|||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const forecastRows = useMemo(
|
||||
() => linearForecast(data?.monthly_spending || [], forecastHorizon),
|
||||
[data?.monthly_spending, forecastHorizon],
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
const next = currentMonth();
|
||||
setYear(next.year);
|
||||
|
|
@ -375,7 +550,8 @@ export default function AnalyticsPage() {
|
|||
setIncludeInactive(false);
|
||||
setIncludeSkipped(true);
|
||||
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;
|
||||
|
|
@ -422,7 +598,7 @@ export default function AnalyticsPage() {
|
|||
|
||||
<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-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">
|
||||
<ControlSelect value={String(month)} onChange={value => setMonth(Number(value))}>
|
||||
{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>
|
||||
</ControlSelect>
|
||||
</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>
|
||||
<Button type="button" variant="outline" onClick={reset}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
|
|
@ -550,6 +733,48 @@ export default function AnalyticsPage() {
|
|||
</ChartCard>
|
||||
</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) && (
|
||||
<div className="xl:col-span-2">
|
||||
<EmptyState label="Select at least one chart to show analytics." />
|
||||
|
|
|
|||
Loading…
Reference in New Issue