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'],
|
['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." />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue