import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils';
const RANGE_OPTIONS = [6, 12, 24, 36];
const MONTH_OPTIONS = [
['1', 'January'], ['2', 'February'], ['3', 'March'], ['4', 'April'],
['5', 'May'], ['6', 'June'], ['7', 'July'], ['8', 'August'],
['9', 'September'], ['10', 'October'], ['11', 'November'], ['12', 'December'],
];
const CHART_OPTIONS = [
['monthlyTrend', 'Monthly trend'],
['expectedActual', 'Expected vs actual'],
['categorySpend', 'Category spend'],
['heatmap', 'Pay heatmap'],
['forecast', 'Spending forecast'],
];
const PALETTE = ['#7c3aed', '#10b981', '#ec4899', '#3b82f6', '#f59e0b', '#14b8a6', '#ef4444', '#8b5cf6'];
function currentMonth() {
const now = new Date();
return { year: now.getFullYear(), month: now.getMonth() + 1 };
}
function money(value) {
return (Number(value) || 0).toLocaleString(undefined, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
});
}
function fullMoney(value) {
return (Number(value) || 0).toLocaleString(undefined, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2,
});
}
function formatRange(range) {
if (!range?.start || !range?.end) return 'Selected range';
return `${range.start.slice(0, 7)} through ${range.end.slice(0, 7)}`;
}
function hasData(rows, keys) {
return rows?.some(row => keys.some(key => Number(row[key]) > 0));
}
function EmptyState({ label = 'No analytics data for this selection.' }) {
return (
{label}
);
}
function ChartCard({ title, subtitle, children, summary }) {
return (
{title}
{subtitle &&
{subtitle}
}
{summary &&
{summary}
}
{children}
);
}
function SvgFrame({ children, height = 260 }) {
return (
);
}
function LineChart({ rows, area = false }) {
if (!hasData(rows, ['total'])) return ;
const width = 720;
const height = 260;
const pad = { left: 58, right: 24, top: 24, bottom: 46 };
const chartW = width - pad.left - pad.right;
const chartH = height - pad.top - pad.bottom;
const max = Math.max(...rows.map(r => r.total), 1);
const points = rows.map((row, index) => {
const x = pad.left + (rows.length === 1 ? chartW / 2 : (index / (rows.length - 1)) * chartW);
const y = pad.top + chartH - (row.total / max) * chartH;
return { ...row, x, y };
});
const line = points.map(p => `${p.x},${p.y}`).join(' ');
const areaPoints = `${pad.left},${pad.top + chartH} ${line} ${pad.left + chartW},${pad.top + chartH}`;
return (
{[0, 0.25, 0.5, 0.75, 1].map(tick => {
const y = pad.top + chartH - tick * chartH;
return (
{money(max * tick)}
);
})}
{area && }
{points.map((point, index) => (
{(rows.length <= 12 || index % 3 === 0) && (
{point.label}
)}
{`${point.label}: ${fullMoney(point.total)}`}
))}
);
}
function GroupedBarChart({ rows }) {
if (!hasData(rows, ['expected', 'actual'])) return ;
const width = 720;
const height = 280;
const pad = { left: 58, right: 24, top: 24, bottom: 50 };
const chartW = width - pad.left - pad.right;
const chartH = height - pad.top - pad.bottom;
const max = Math.max(...rows.flatMap(r => [r.expected, r.actual]), 1);
const groupW = chartW / rows.length;
const barW = Math.max(5, Math.min(17, groupW * 0.28));
return (
{[0, 0.5, 1].map(tick => {
const y = pad.top + chartH - tick * chartH;
return (
{money(max * tick)}
);
})}
{rows.map((row, index) => {
const center = pad.left + index * groupW + groupW / 2;
const expectedH = (row.expected / max) * chartH;
const actualH = (row.actual / max) * chartH;
return (
{`${row.label} expected: ${fullMoney(row.expected)}`}
{`${row.label} actual: ${fullMoney(row.actual)}`}
{(rows.length <= 12 || index % 3 === 0) && (
{row.label}
)}
);
})}
Expected
Actual
);
}
function DonutChart({ rows }) {
const total = rows.reduce((sum, row) => sum + Number(row.total || 0), 0);
if (!total) return ;
let cumulative = 0;
const radius = 78;
const circumference = 2 * Math.PI * radius;
return (
{rows.map((row, index) => (
{row.category_name}
{fullMoney(row.total)}
))}
);
}
const HEATMAP_CLASS = {
paid: 'bg-emerald-500/85 border-emerald-400/40',
skipped: 'bg-sky-500/70 border-sky-400/40',
missed: 'bg-red-500/75 border-red-400/40',
no_data: 'bg-muted border-border',
};
function Heatmap({ heatmap }) {
const rows = heatmap?.rows || [];
const months = heatmap?.months || [];
if (!rows.length || !months.length) return ;
return (
Bill
{months.map(month =>
{month.label}
)}
{rows.map(row => (
{row.bill_name}
{row.category_name}
{months.map(month => {
const cell = row.cells.find(item => item.month === month.key) || { status: 'no_data', amount_paid: 0 };
return (
);
})}
))}
{[
['paid', 'Paid'],
['skipped', 'Skipped'],
['missed', 'Missed'],
['no_data', 'No data'],
].map(([status, label]) => (
{label}
))}
);
}
// ─── 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 (
);
}
function ControlSelect({ value, onChange, children, className }) {
return (
);
}
export default function AnalyticsPage() {
const initial = currentMonth();
const [year, setYear] = useState(initial.year);
const [month, setMonth] = useState(initial.month);
const [months, setMonths] = useState(12);
const [categoryId, setCategoryId] = useState('');
const [billId, setBillId] = useState('');
const [includeInactive, setIncludeInactive] = useState(false);
const [includeSkipped, setIncludeSkipped] = useState(true);
const [trendMode, setTrendMode] = useState('line');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [visible, setVisible] = useState({
monthlyTrend: true,
expectedActual: true,
categorySpend: true,
heatmap: true,
forecast: true,
});
const [forecastHorizon, setForecastHorizon] = useState(6);
const params = useMemo(() => ({
year,
month,
months,
category_id: categoryId,
bill_id: billId,
include_inactive: includeInactive,
include_skipped: includeSkipped,
}), [billId, categoryId, includeInactive, includeSkipped, month, months, year]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await api.analyticsSummary(params);
setData(result);
} catch (err) {
setError(err.message || 'Failed to load analytics.');
toast.error(err.message || 'Failed to load analytics.');
} finally {
setLoading(false);
}
}, [params]);
useEffect(() => { load(); }, [load]);
const forecastRows = useMemo(
() => linearForecast(data?.monthly_spending || [], forecastHorizon),
[data?.monthly_spending, forecastHorizon],
);
const reset = () => {
const next = currentMonth();
setYear(next.year);
setMonth(next.month);
setMonths(12);
setCategoryId('');
setBillId('');
setIncludeInactive(false);
setIncludeSkipped(true);
setTrendMode('line');
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 activeCharts = CHART_OPTIONS.filter(([key]) => visible[key]).map(([, label]) => label).join(', ') || 'None';
const filterSummary = [
categoryId ? `Category: ${data?.categories?.find(c => String(c.id) === String(categoryId))?.name || categoryId}` : 'All categories',
billId ? `Bill: ${data?.bills?.find(b => String(b.id) === String(billId))?.name || billId}` : 'All bills',
includeInactive ? 'Includes inactive bills' : 'Active bills only',
includeSkipped ? 'Shows skipped months' : 'Hides skipped months',
].join(' | ');
return (
BillTracker Analytics
{formatRange(data?.range)}
{filterSummary}
Visible charts: {activeCharts}
Generated {new Date(data?.generated_at || Date.now()).toLocaleString()}
Analytics
Spending trends, category breakdowns, and payment history.
{data ? (
<>
Reporting on {formatRange(data.range)}.
{filterSummary}
>
) : 'Preparing analytics...'}
{loading && (
{[1, 2, 3, 4].map(item =>
)}
)}
{!loading && error && (
{error}
)}
{!loading && !error && data && (
{visible.monthlyTrend && (
)}
{visible.expectedActual && (
)}
{visible.categorySpend && (
)}
{visible.heatmap && (
)}
{visible.forecast && (
{(data.monthly_spending || []).length < 3 ? (
) : (
<>
| Month |
Projected |
Low estimate |
High estimate |
{forecastRows.map(row => (
| {row.label} |
{fullMoney(row.total)} |
{fullMoney(row.low)} |
{fullMoney(row.high)} |
))}
>
)}
)}
{!Object.values(visible).some(Boolean) && (
)}
)}
Generated from BillTracker Analytics on {new Date(data?.generated_at || Date.now()).toLocaleString()}.
);
}