"use client"; import { useState, Fragment } from "react"; import { ComposedChart, BarChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, } from "recharts"; import { useQueryClient } from "@tanstack/react-query"; import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks"; import { formatCategory, CATEGORIES } from "@/lib/categories"; function currentMonthStr(): string { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; } function prevMonth(m: string): string { const [year, month] = m.split("-").map(Number); const d = new Date(year, month - 2, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } function nextMonth(m: string): string { const [year, month] = m.split("-").map(Number); const d = new Date(year, month, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } function formatMonth(m: string): string { const [year, month] = m.split("-"); const date = new Date(Number(year), Number(month) - 1, 1); return date.toLocaleString("default", { month: "long", year: "numeric" }); } function formatShortMonth(m: string): string { const [year, month] = m.split("-"); const date = new Date(Number(year), Number(month) - 1, 1); return date.toLocaleString("default", { month: "short" }); } function fmt(n: number): string { return `$${n.toFixed(0)}`; } function fmtExact(n: number): string { return `$${n.toFixed(2)}`; } function deltaColor(n: number): string { if (n > 0) return "text-red-400"; if (n < 0) return "text-emerald-400"; return ""; } const TOOLTIP_STYLE = { background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12, }; const CATEGORY_COLORS: Record = { groceries: "#22c55e", dining: "#f97316", transport: "#06b6d4", fuel: "#eab308", shopping: "#ec4899", utilities: "#8b5cf6", entertainment: "#f43f5e", travel: "#0ea5e9", health: "#10b981", insurance: "#64748b", subscriptions: "#a78bfa", cash_advance: "#dc2626", government: "#78716c", education: "#3b82f6", rent: "#d97706", transfers: "#6b7280", income: "#34d399", investment: "#818cf8", personal_care: "#fb7185", pets: "#86efac", gifts: "#fcd34d", charity: "#a3e635", other: "#71717a", }; interface TooltipPayload { name: string; value: number; fill?: string; stroke?: string; } function CashflowTooltip({ active, payload, label, }: { active?: boolean; payload?: TooltipPayload[]; label?: string; }) { if (!active || !payload) return null; const items = payload.filter((p) => p.value && Math.abs(p.value) > 0.01); if (items.length === 0) return null; return (

{label}

{items.map((item) => { const name = item.name; let label = name; let valueStr = fmtExact(Number(item.value)); const color = item.fill || item.stroke || "#71717a"; if (name === "income") label = "Income"; else if (name === "savingsRate") { label = "Savings Rate"; valueStr = `${Number(item.value).toFixed(1)}%`; } else if (name.startsWith("cat_")) { label = formatCategory(name.slice(4)); } return (
{label}: {valueStr}
); })}
); } function CategoryPanel({ category, selectedMonth, }: { category: string; selectedMonth: string; }) { const qc = useQueryClient(); const updateTx = useUpdateTransaction(); const from = `${selectedMonth}-01`; const [year, month] = selectedMonth.split("-").map(Number); const nextDate = new Date(year, month, 1); const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`; const { data, isLoading } = useTransactions({ category, from, to, limit: 200 }); const txns = data?.data || []; const handleCategoryChange = (id: number, newCategory: string) => { updateTx.mutate( { id, category: newCategory }, { onSuccess: () => { qc.invalidateQueries({ queryKey: ["analytics"] }); }, } ); }; return ( {isLoading ? (

Loading transactions...

) : txns.length === 0 ? (

No transactions found

) : ( {txns.map((tx) => ( ))}
Date Description Amount Category
{tx.transaction_date.slice(5).replace("-", "/")} {tx.effective_merchant || tx.description} {fmtExact(tx.amount)}
)} ); } export default function AnalyticsPage() { const [selectedMonth, setSelectedMonth] = useState(currentMonthStr); const [expandedCategory, setExpandedCategory] = useState(null); const { data: analytics, isLoading } = useMonthlyAnalytics(6); if (isLoading || !analytics) { return (

Analytics

Loading...

); } const months = [...analytics.months].reverse(); // chronological const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 }; const lastTotals = analytics.totals[prevMonth(selectedMonth)] ?? { spent: 0, income: 0, investments: 0, net: 0 }; const spentDelta = totals.spent - lastTotals.spent; // Category rows for selected month const categoryRows = analytics.rows .filter((r) => (r.spent[selectedMonth] || 0) > 0) .map((r) => ({ category: r.category, spent: r.spent[selectedMonth] || 0, txCount: r.txCount[selectedMonth] || 0, })) .sort((a, b) => b.spent - a.spent); const largestCategory = categoryRows[0]; const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0); const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0); // Categories that appear in expense data const expenseCategories = analytics.rows.map((r) => r.category); // Cashflow chart data (chronological) — per-category amounts for stacking const cashflowData = months.map((m) => { const entry: Record = { month: m, label: formatShortMonth(m), income: analytics.income[m] || 0, }; analytics.rows.forEach((row) => { entry[`cat_${row.category}`] = row.spent[m] || 0; }); const income = analytics.income[m] || 0; const spent = analytics.totals[m]?.spent || 0; const invested = analytics.investments[m] || 0; entry.savingsRate = income > 0 ? Math.round(((income - spent - invested) / income) * 100) : null; return entry; }); // Horizontal category bar chart for selected month const categoryChartData = [...categoryRows].reverse(); const chartHeight = Math.max(categoryChartData.length * 36, 120); return (
{/* Header + month selector */}

Analytics

{formatMonth(selectedMonth)}
{/* Summary cards */}
{hasIncome && (

Income

{fmtExact(totals.income)}

received

)}

Expenses

{fmtExact(totals.spent)}

{spentDelta === 0 || lastTotals.spent === 0 ? ( split-adjusted ) : ( `${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}` )}

{(hasInvestments || totals.investments > 0) && (

Invested

{fmtExact(totals.investments)}

shares / ETFs

)}

{hasIncome ? "Net Cash" : "Largest Category"}

{hasIncome ? ( <>

= 0 ? "text-emerald-400" : "text-red-400"}`} > {totals.net >= 0 ? "+" : ""} {fmtExact(totals.net)}

income − expenses − invested

) : largestCategory ? ( <>

{fmtExact(largestCategory.spent)}

{formatCategory(largestCategory.category)}

) : (

)}
{/* Cashflow chart — stacked expense categories + optional savings rate line */}

{hasIncome ? "Cashflow — 6 Months" : "Monthly Spending by Category"}

{hasIncome && (
Savings Rate Income
)}
{ // eslint-disable-next-line @typescript-eslint/no-explicit-any const month = (data as any)?.activePayload?.[0]?.payload?.month as string | undefined; if (month) setSelectedMonth(month); }} style={{ cursor: "pointer" }} > `$${v}`} width={56} /> {hasIncome && ( `${v}%`} domain={[-20, 100]} width={36} /> )} } cursor={{ fill: "rgba(255,255,255,0.04)" }} /> {/* Income bar (ungrouped, green) */} {hasIncome && ( )} {/* Stacked expense category bars */} {expenseCategories.map((cat, i) => ( {cashflowData.map((entry) => ( ))} ))} {/* Savings rate line */} {hasIncome && ( )} {/* Category color legend */} {expenseCategories.length > 0 && (
{expenseCategories.map((cat) => ( {formatCategory(cat)} ))}
)}
{/* Spending by category — horizontal bar chart */} {categoryChartData.length > 0 && (

Spending by Category — {formatMonth(selectedMonth)}

`$${v}`} /> [fmtExact(Number(value)), "Spent"]} contentStyle={TOOLTIP_STYLE} labelFormatter={(label) => formatCategory(String(label))} labelStyle={{ color: "#a1a1aa" }} cursor={{ fill: "rgba(255,255,255,0.04)" }} /> {categoryChartData.map((entry) => ( ))}
)} {/* Category breakdown table — with expandable rows */} {categoryRows.length > 0 && (

Spending Breakdown — {formatMonth(selectedMonth)}

{categoryRows.map(({ category, spent, txCount }) => { const isExpanded = expandedCategory === category; return ( setExpandedCategory(isExpanded ? null : category) } > {isExpanded && ( )} ); })}
Category Spent # Txns % of Total
{formatCategory(category)} {isExpanded ? "▲" : "▼"} {fmtExact(spent)} {txCount} {totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
)} {/* 6-month trend table */} {analytics.months.length > 0 && (

6-Month Trend

{analytics.months.map((m) => ( ))} {analytics.rows.map((row) => ( {analytics.months.map((m) => { const spent = row.spent[m]; return ( ); })} ))} {hasIncome && ( {analytics.months.map((m) => { const inc = analytics.income[m]; return ( ); })} )} {hasInvestments && ( {analytics.months.map((m) => { const inv = analytics.investments[m]; return ( ); })} )} {analytics.months.map((m) => { const t = analytics.totals[m]; return ( ); })} {hasIncome && ( {analytics.months.map((m) => { const t = analytics.totals[m]; const net = t?.net || 0; return ( ); })} )}
Category setSelectedMonth(m)} > {formatShortMonth(m)}
{formatCategory(row.category)} {spent !== undefined ? fmt(spent) : "—"}
Income {inc ? fmt(inc) : "—"}
Invested {inv ? fmt(inv) : "—"}
Expenses {fmt(t?.spent || 0)}
Net Cash= 0 ? "text-emerald-400" : "text-red-400" } ${m === selectedMonth ? "bg-zinc-800/30" : ""}`} > {net >= 0 ? "+" : ""} {fmt(net)}
)}
); }