diff --git a/client/pages/AnalyticsPage.jsx b/client/pages/AnalyticsPage.jsx index 2c5f121..01c1846 100644 --- a/client/pages/AnalyticsPage.jsx +++ b/client/pages/AnalyticsPage.jsx @@ -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 ; + + 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 ( + + {/* Grid */} + {[0, 0.25, 0.5, 0.75, 1].map(tick => { + const y = pad.top + chartH - tick * chartH; + return ( + + + {money(maxVal * tick)} + + ); + })} + + {/* Forecast region shading */} + {dividerX && ( + + )} + + {/* Confidence band */} + {bandPoly && } + + {/* Historical line + area fill */} + + + + {/* Forecast line (dashed, connects from last historical point) */} + {foreLine && ( + + )} + + {/* Divider tick */} + {dividerX && ( + + )} + + {/* Historical dots + labels */} + {histPoints.map((p, i) => ( + + + {showLabel(i) && ( + + {p.label} + + )} + {`${p.label}: ${fullMoney(p.total)}`} + + ))} + + {/* Forecast dots + labels */} + {forePoints.map((p, i) => ( + + + {showLabel(historical.length + i) && ( + + {p.label} + + )} + {`${p.label} (projected): ${fullMoney(p.total)} · range ${fullMoney(p.low)}–${fullMoney(p.high)}`} + + ))} + + {/* Legend */} + + + Historical + + + Projected ± 1 σ + + + ); +} + function Field({ label, children }) { return (