BillTracker/client/pages/AnalyticsPage.jsx

792 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/20 px-4 text-center text-sm text-muted-foreground">
{label}
</div>
);
}
function ChartCard({ title, subtitle, children, summary }) {
return (
<section className="analytics-chart surface-elevated p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-muted-foreground">{subtitle}</p>}
</div>
{summary && <div className="shrink-0 text-right text-sm font-semibold tabular-nums">{summary}</div>}
</div>
{children}
</section>
);
}
function SvgFrame({ children, height = 260 }) {
return (
<div className="w-full overflow-hidden rounded-lg border border-border/60 bg-background/60">
<svg viewBox={`0 0 720 ${height}`} role="img" className="h-auto w-full">
{children}
</svg>
</div>
);
}
function LineChart({ rows, area = false }) {
if (!hasData(rows, ['total'])) return <EmptyState />;
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 (
<SvgFrame height={height}>
{[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.09" />
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">{money(max * tick)}</text>
</g>
);
})}
{area && <polygon points={areaPoints} fill="#7c3aed" opacity="0.16" />}
<polyline points={line} fill="none" stroke="#7c3aed" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
{points.map((point, index) => (
<g key={point.month}>
<circle cx={point.x} cy={point.y} r="4.5" fill="#7c3aed" />
{(rows.length <= 12 || index % 3 === 0) && (
<text x={point.x} y={height - 18} fontSize="12" fill="currentColor" opacity="0.65" textAnchor="middle">
{point.label}
</text>
)}
<title>{`${point.label}: ${fullMoney(point.total)}`}</title>
</g>
))}
</SvgFrame>
);
}
function GroupedBarChart({ rows }) {
if (!hasData(rows, ['expected', 'actual'])) return <EmptyState />;
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 (
<SvgFrame height={height}>
{[0, 0.5, 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.09" />
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">{money(max * tick)}</text>
</g>
);
})}
{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 (
<g key={row.month}>
<rect x={center - barW - 1} y={pad.top + chartH - expectedH} width={barW} height={expectedH} rx="4" fill="#8b5cf6">
<title>{`${row.label} expected: ${fullMoney(row.expected)}`}</title>
</rect>
<rect x={center + 1} y={pad.top + chartH - actualH} width={barW} height={actualH} rx="4" fill="#10b981">
<title>{`${row.label} actual: ${fullMoney(row.actual)}`}</title>
</rect>
{(rows.length <= 12 || index % 3 === 0) && (
<text x={center} y={height - 18} fontSize="12" fill="currentColor" opacity="0.65" textAnchor="middle">
{row.label}
</text>
)}
</g>
);
})}
<g transform={`translate(${width - 190}, 18)`} fontSize="12" fill="currentColor">
<rect width="10" height="10" rx="2" fill="#8b5cf6" /><text x="16" y="10">Expected</text>
<rect x="92" width="10" height="10" rx="2" fill="#10b981" /><text x="108" y="10">Actual</text>
</g>
</SvgFrame>
);
}
function DonutChart({ rows }) {
const total = rows.reduce((sum, row) => sum + Number(row.total || 0), 0);
if (!total) return <EmptyState />;
let cumulative = 0;
const radius = 78;
const circumference = 2 * Math.PI * radius;
return (
<div className="grid gap-5 md:grid-cols-[260px_1fr] md:items-center">
<div className="flex justify-center">
<svg viewBox="0 0 220 220" role="img" className="h-56 w-56">
<circle cx="110" cy="110" r={radius} fill="none" stroke="currentColor" strokeWidth="30" opacity="0.08" />
{rows.map((row, index) => {
const value = Number(row.total || 0);
const dash = (value / total) * circumference;
const segment = (
<circle
key={row.category_name}
cx="110"
cy="110"
r={radius}
fill="none"
stroke={PALETTE[index % PALETTE.length]}
strokeWidth="30"
strokeDasharray={`${dash} ${circumference - dash}`}
strokeDashoffset={-cumulative}
transform="rotate(-90 110 110)"
>
<title>{`${row.category_name}: ${fullMoney(value)}`}</title>
</circle>
);
cumulative += dash;
return segment;
})}
<text x="110" y="104" textAnchor="middle" fontSize="13" fill="currentColor" opacity="0.65">Total</text>
<text x="110" y="126" textAnchor="middle" fontSize="22" fontWeight="700" fill="currentColor">{money(total)}</text>
</svg>
</div>
<div className="space-y-2">
{rows.map((row, index) => (
<div key={row.category_name} className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-sm">
<span className="flex min-w-0 items-center gap-2">
<span className="h-3 w-3 shrink-0 rounded-sm" style={{ backgroundColor: PALETTE[index % PALETTE.length] }} />
<span className="truncate">{row.category_name}</span>
</span>
<span className="shrink-0 font-medium tabular-nums">{fullMoney(row.total)}</span>
</div>
))}
</div>
</div>
);
}
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 <EmptyState />;
return (
<div className="overflow-x-auto">
<div className="space-y-4 min-w-[760px]">
<div className="rounded-lg border border-border/60">
<div
className="grid border-b border-border/60 bg-muted/30 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
style={{ gridTemplateColumns: `180px repeat(${months.length}, minmax(38px, 1fr))` }}
>
<div className="px-3 py-2">Bill</div>
{months.map(month => <div key={month.key} className="px-1 py-2 text-center">{month.label}</div>)}
</div>
{rows.map(row => (
<div
key={row.bill_id}
className="grid border-b border-border/40 last:border-b-0"
style={{ gridTemplateColumns: `180px repeat(${months.length}, minmax(38px, 1fr))` }}
>
<div className="min-w-0 px-3 py-2">
<p className="truncate text-sm font-medium">{row.bill_name}</p>
<p className="truncate text-[11px] text-muted-foreground">{row.category_name}</p>
</div>
{months.map(month => {
const cell = row.cells.find(item => item.month === month.key) || { status: 'no_data', amount_paid: 0 };
return (
<div key={`${row.bill_id}-${month.key}`} className="flex items-center justify-center px-1 py-2">
<span
className={cn('h-5 w-5 rounded border', HEATMAP_CLASS[cell.status] || HEATMAP_CLASS.no_data)}
title={`${row.bill_name}, ${month.label}: ${cell.status.replace('_', ' ')}${cell.amount_paid ? ` (${fullMoney(cell.amount_paid)})` : ''}`}
/>
</div>
);
})}
</div>
))}
</div>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{[
['paid', 'Paid'],
['skipped', 'Skipped'],
['missed', 'Missed'],
['no_data', 'No data'],
].map(([status, label]) => (
<span key={status} className="inline-flex items-center gap-1.5">
<span className={cn('h-3 w-3 rounded border', HEATMAP_CLASS[status])} />
{label}
</span>
))}
</div>
</div>
);
}
// ─── 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">
<span className="block text-xs font-medium text-muted-foreground">{label}</span>
{children}
</label>
);
}
function ControlSelect({ value, onChange, children, className }) {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className={cn('h-9 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-[3px] focus:ring-ring/50', className)}
>
{children}
</select>
);
}
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 (
<div className="analytics-page space-y-6">
<div className="analytics-report-meta hidden">
<h1>BillTracker Analytics</h1>
<p>{formatRange(data?.range)}</p>
<p>{filterSummary}</p>
<p>Visible charts: {activeCharts}</p>
<p>Generated {new Date(data?.generated_at || Date.now()).toLocaleString()}</p>
</div>
<div className="analytics-screen-header flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Analytics</h1>
<p className="mt-0.5 text-sm text-muted-foreground">
Spending trends, category breakdowns, and payment history.
</p>
</div>
<div className="analytics-actions flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="flex-1 sm:flex-none">
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
Refresh
</Button>
<Button variant="outline" size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none">
<Printer className="h-3.5 w-3.5" />
Print
</Button>
<Button size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none">
<Printer className="h-3.5 w-3.5" />
Print / Save PDF
</Button>
</div>
</div>
<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-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>)}
</ControlSelect>
</Field>
<Field label="Ending year">
<input
type="number"
min="2000"
max="2100"
value={year}
onChange={e => setYear(Number(e.target.value))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-[3px] focus:ring-ring/50"
/>
</Field>
<Field label="Range">
<ControlSelect value={String(months)} onChange={value => setMonths(Number(value))}>
{RANGE_OPTIONS.map(value => <option key={value} value={value}>{value} months</option>)}
</ControlSelect>
</Field>
<Field label="Category">
<ControlSelect value={categoryId} onChange={value => { setCategoryId(value); setBillId(''); }}>
<option value="">All categories</option>
{(data?.categories || []).map(category => (
<option key={category.id} value={category.id}>{category.name}</option>
))}
</ControlSelect>
</Field>
<Field label="Bill">
<ControlSelect value={billId} onChange={setBillId}>
<option value="">All bills</option>
{(data?.bills || []).map(bill => (
<option key={bill.id} value={bill.id}>{bill.name}{bill.active ? '' : ' (inactive)'}</option>
))}
</ControlSelect>
</Field>
<Field label="Trend style">
<ControlSelect value={trendMode} onChange={setTrendMode}>
<option value="line">Line</option>
<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" />
Reset filters
</Button>
</div>
<div className="mt-4 grid gap-3 border-t border-border/60 pt-4 md:grid-cols-2 xl:grid-cols-4">
{CHART_OPTIONS.map(([key, label]) => (
<label key={key} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={visible[key]}
onChange={e => setVisible(prev => ({ ...prev, [key]: e.target.checked }))}
className="h-4 w-4 rounded border-input bg-background accent-primary"
/>
{label}
</label>
))}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeInactive}
onChange={e => setIncludeInactive(e.target.checked)}
className="h-4 w-4 rounded border-input bg-background accent-primary"
/>
Include inactive bills
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeSkipped}
onChange={e => setIncludeSkipped(e.target.checked)}
className="h-4 w-4 rounded border-input bg-background accent-primary"
/>
Show skipped months
</label>
</div>
</section>
<div className="analytics-range text-sm text-muted-foreground">
{data ? (
<>
Reporting on <span className="font-medium text-foreground">{formatRange(data.range)}</span>.
<span className="ml-2">{filterSummary}</span>
</>
) : 'Preparing analytics...'}
</div>
{loading && (
<div className="grid gap-5 lg:grid-cols-2">
{[1, 2, 3, 4].map(item => <div key={item} className="h-80 animate-pulse rounded-2xl bg-muted/50" />)}
</div>
)}
{!loading && error && (
<div className="rounded-lg border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{!loading && !error && data && (
<div className="analytics-chart-grid grid gap-5 xl:grid-cols-2">
{visible.monthlyTrend && (
<ChartCard title="Monthly spending trend" subtitle="Actual payments grouped by paid month.">
<LineChart rows={data.monthly_spending || []} area={trendMode === 'area'} />
</ChartCard>
)}
{visible.expectedActual && (
<ChartCard title="Expected vs actual spend" subtitle="Expected uses monthly override amount when present, otherwise the bill estimate.">
<GroupedBarChart rows={data.expected_vs_actual || []} />
</ChartCard>
)}
{visible.categorySpend && (
<ChartCard title="Spending by category" subtitle="Payments grouped by bill category." summary={fullMoney(totalCategorySpend)}>
<DonutChart rows={data.category_spend || []} />
</ChartCard>
)}
{visible.heatmap && (
<div className="xl:col-span-2">
<ChartCard title="Pay-on-time heatmap" subtitle="Bill status by month. Future/current unpaid months show as no data.">
<Heatmap heatmap={data.heatmap} />
</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." />
</div>
)}
</div>
)}
<div className="analytics-print-footer hidden text-xs text-muted-foreground">
Generated from BillTracker Analytics on {new Date(data?.generated_at || Date.now()).toLocaleString()}.
</div>
</div>
);
}