BillTracker/client/pages/AnalyticsPage.jsx

566 lines
22 KiB
React
Raw Normal View History

2026-05-04 13:14:32 -05:00
import { 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 { 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'],
];
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="space-y-4">
<div className="overflow-x-auto rounded-lg border border-border/60">
<div className="min-w-[760px]">
<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>
);
}
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,
});
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 reset = () => {
const next = currentMonth();
setYear(next.year);
setMonth(next.month);
setMonths(12);
setCategoryId('');
setBillId('');
setIncludeInactive(false);
setIncludeSkipped(true);
setTrendMode('line');
setVisible({ monthlyTrend: true, expectedActual: true, categorySpend: true, heatmap: 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-6">
<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>
</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>
)}
{!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>
);
}