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'], ]; 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 (
{children}
); } 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) => { const value = Number(row.total || 0); const dash = (value / total) * circumference; const segment = ( {`${row.category_name}: ${fullMoney(value)}`} ); cumulative += dash; return segment; })} Total {money(total)}
{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} ))}
); } 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, }); 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 (

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.

setMonth(Number(value))}> {MONTH_OPTIONS.map(([value, label]) => )} 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" /> setMonths(Number(value))}> {RANGE_OPTIONS.map(value => )} { setCategoryId(value); setBillId(''); }}> {(data?.categories || []).map(category => ( ))} {(data?.bills || []).map(bill => ( ))}
{CHART_OPTIONS.map(([key, label]) => ( ))}
{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 && (
)} {!Object.values(visible).some(Boolean) && (
)}
)}
Generated from BillTracker Analytics on {new Date(data?.generated_at || Date.now()).toLocaleString()}.
); }