"use client"; import { useMemo, useState } from "react"; import { ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, } from "recharts"; import { useMonthlyAnalytics, useSubscriptions, useFees, useTransactions, useUpdateTransaction } from "@/lib/hooks"; import { CATEGORIES, REGULAR_CATEGORIES, formatCategory } from "@/lib/categories"; import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors"; const SPEND_TYPES = new Set(["debit", "fee", "interest"]); /** * The API returns an exclusive upper bound (first day of the month after the * window). Showing that date verbatim would claim a month the totals exclude, * so `exclusive` steps back a day for display. */ function formatPeriodBound(iso: string | null, exclusive = false): string { if (!iso) return "—"; const d = new Date(`${iso}T00:00:00`); if (exclusive) d.setDate(d.getDate() - 1); return d.toLocaleDateString("default", { month: "short", year: "numeric" }); } function fmt(n: number) { return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n); } function fmtTx(amount: number, type: string) { const formatted = new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(amount); return SPEND_TYPES.has(type) ? formatted : `+${formatted}`; } function fmtExact(n: number) { return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(n); } function fmtDate(d: string) { return new Date(d).toLocaleDateString("en-AU", { month: "short", year: "numeric" }); } function trend(values: number[]): { pct: number; dir: "up" | "down" | "flat" } { if (values.length < 2) return { pct: 0, dir: "flat" }; const recent = values.slice(-3).reduce((a, b) => a + b, 0) / 3; const prior = values.slice(0, 3).reduce((a, b) => a + b, 0) / 3; if (prior === 0) return { pct: 0, dir: "flat" }; const pct = Math.round(((recent - prior) / prior) * 100); return { pct: Math.abs(pct), dir: pct > 2 ? "up" : pct < -2 ? "down" : "flat" }; } const FREQ_LABEL: Record = { weekly: "Weekly", fortnightly: "Fortnightly", monthly: "Monthly", quarterly: "Quarterly", annual: "Annual", }; // ─── Section wrapper ──────────────────────────────────────────────── function Section({ title, aside, children }: { title: string; aside?: React.ReactNode; children: React.ReactNode }) { return (

{title}

{aside}
{children}
); } // ─── Custom tooltip ────────────────────────────────────────────────── function RegularTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number }[]; label?: string }) { if (!active || !payload?.length) return null; const regular = payload.find((p) => p.dataKey === "regular")?.value ?? 0; const occasional = payload.find((p) => p.dataKey === "occasional")?.value ?? 0; return (
{label}
Regular{fmt(regular)}
Occasional{fmt(occasional)}
Total{fmt(regular + occasional)}
); } // ─── Drill-down row ────────────────────────────────────────────────── function DrillDownRow({ category, from, to, }: { category: string; from: string; to: string; }) { const { data, isLoading } = useTransactions({ categories: category ? [category] : [], from, to, limit: 200 }); const updateTx = useUpdateTransaction(); if (isLoading) { return ( Loading... ); } const txns = data?.data ?? []; return (
{txns.length === 0 ? (

No transactions found.

) : ( {txns.map((t) => { // Share is resolved server-side (see getTransactions) so this // matches the category total being drilled into. The old // `my_share_percent ?? 100` missed transaction_splits rows // entirely and assumed 100% when a transaction was allocated // wholly to someone else. const sharePct = Number(t.my_share_pct ?? 100); const grossAmt = Number(t.amount_aud ?? t.amount); const effectiveAmt = Number(t.my_amount ?? grossAmt); const isDebit = SPEND_TYPES.has(t.transaction_type); return ( ); })}
Date Merchant Total My share Category % mine
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })} {t.merchant_name || t.description} {fmtExact(grossAmt)} {fmtTx(effectiveAmt, t.transaction_type)} {sharePct < 100 && ( ({sharePct}%) )}
)}
); } // ─── Monthly Spend Breakdown ───────────────────────────────────────── function MonthlyBreakdown({ analytics }: { analytics: NonNullable["data"]> }) { // analytics.months is newest-first; show last 6 const months = useMemo(() => analytics.months.slice(0, 6), [analytics.months]); const [selectedMonth, setSelectedMonth] = useState(months[0] ?? ""); const [expandedCategory, setExpandedCategory] = useState(null); // Reset expanded when month changes const handleSelectMonth = (m: string) => { setSelectedMonth(m); setExpandedCategory(null); }; const from = selectedMonth + "-01"; const lastDay = new Date(parseInt(selectedMonth.slice(0, 4)), parseInt(selectedMonth.slice(5, 7)), 0).getDate(); const to = selectedMonth + "-" + String(lastDay).padStart(2, "0"); const categoryData = useMemo(() => { return analytics.rows .map((row) => ({ category: row.category, amount: Number(row.spent[selectedMonth] ?? 0) })) .filter((r) => r.amount > 0) .sort((a, b) => b.amount - a.amount); }, [analytics.rows, selectedMonth]); const regularRows = categoryData.filter((r) => (REGULAR_CATEGORIES as Set).has(r.category)); const occasionalRows = categoryData.filter((r) => !(REGULAR_CATEGORIES as Set).has(r.category)); const regularTotal = regularRows.reduce((s, r) => s + r.amount, 0); const occasionalTotal = occasionalRows.reduce((s, r) => s + r.amount, 0); function monthLabel(m: string) { const [year, month] = m.split("-"); return new Date(parseInt(year), parseInt(month) - 1).toLocaleDateString("en-AU", { month: "short", year: "2-digit" }); } function renderRows(rows: typeof categoryData, dotClass: string) { return rows.map((row) => ( <> setExpandedCategory(expandedCategory === row.category ? null : row.category)} > {formatCategory(row.category)} {expandedCategory === row.category ? "▲" : "▼"} {fmt(row.amount)} {expandedCategory === row.category && ( )} )); } return (
{/* Month tabs */}
{months.map((m) => ( ))}
{regularRows.length > 0 && ( <> {renderRows(regularRows, "bg-indigo-500")} )} {occasionalRows.length > 0 && ( <> {renderRows(occasionalRows, "bg-zinc-500")} )} {categoryData.length === 0 && ( )}
Category Spend
Regular {fmt(regularTotal)}
Occasional {fmt(occasionalTotal)}
No spend data for this month.
); } // ─── Main page ──────────────────────────────────────────────────────── export default function InsightsPage() { const { data: analytics } = useMonthlyAnalytics(12); const { data: analytics6 } = useMonthlyAnalytics(6); const { data: subData } = useSubscriptions(); const [feeMonths, setFeeMonths] = useState(12); const { data: feesData } = useFees(feeMonths); // Build regular/occasional chart data const chartData = useMemo(() => { if (!analytics) return []; return [...analytics.months].reverse().map((month) => { let regular = 0; let occasional = 0; for (const row of analytics.rows) { const spend = Number(row.spent[month] ?? 0); if ((REGULAR_CATEGORIES as Set).has(row.category)) regular += spend; else occasional += spend; } return { month: month.slice(5) + "/" + month.slice(2, 4), regular: Math.round(regular), occasional: Math.round(occasional), total: Math.round(regular + occasional), }; }); }, [analytics]); const regularValues = chartData.map((d) => d.regular); const regularTrend = trend(regularValues); const avgRegular = regularValues.length ? Math.round(regularValues.reduce((a, b) => a + b, 0) / regularValues.length) : 0; const latestRegular = regularValues[regularValues.length - 1] ?? 0; const activeSubscriptions = subData?.subscriptions.filter((s) => s.is_active) ?? []; const inactiveSubscriptions = subData?.subscriptions.filter((s) => !s.is_active) ?? []; return (

Ledger · patterns

Insights

{/* ── 1. Regular vs Occasional ── */}
This month · regular
{fmt(latestRegular)}
12-month average
{fmt(avgRegular)}
Trend · first 3 vs last 3
{regularTrend.dir === "up" ? "↑" : regularTrend.dir === "down" ? "↓" : "→"} {regularTrend.pct}%
`$${(v / 1000).toFixed(0)}k`} width={44} /> } cursor={{ fill: "rgba(232,224,204,0.04)" }} />
Regular (groceries, dining, transport…) Occasional
{/* ── 2. Monthly Spend Breakdown ── */}
{!analytics6 ? (

Loading...

) : ( )}
{/* ── 3. Recurring Charges ── */}
{!subData ? (

Loading...

) : subData.subscriptions.length === 0 ? (

No recurring patterns detected yet — more transaction history needed.

) : ( <>
{activeSubscriptions.length} active · {inactiveSubscriptions.length} inactive {fmtExact(subData.total_monthly_equiv)}/ month committed
{[...activeSubscriptions, ...inactiveSubscriptions].map((s) => ( ))}
Merchant Category Frequency My $/mo equiv Avg charge Since Total paid Count
{s.merchant} {formatCategory(s.category ?? "other")} {FREQ_LABEL[s.frequency] ?? s.frequency} {fmtExact(s.monthly_equiv)} {fmtExact(s.avg_amount)} {fmtDate(s.first_seen)} {fmtExact(s.total_paid)} {s.occurrences}
)}
{/* ── 4. Fees & Interest ── */}
{feesData?.period && ( {feesData.period.all_time ? "All time" : `${formatPeriodBound(feesData.period.from)} – ${formatPeriodBound(feesData.period.to, true)}`} )}
} > {!feesData ? (

Loading...

) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (

No fees or interest recorded {feesData.period?.all_time ? "on any statement" : "in this period"}.

) : (
{feesData.by_bank.length > 0 && (
{feesData.by_bank.map((r) => ( ))}
Bank Fees Interest Total
{r.bank_name} {r.fees > 0 ? fmtExact(r.fees) : } {r.interest > 0 ? fmtExact(r.interest) : } {fmtExact(r.total)}
Total {fmtExact(feesData.total_fees)} {fmtExact(feesData.total_interest)} {fmtExact(feesData.total_fees + feesData.total_interest)}
)} {feesData.transactions.length > 0 && (

Individual fee / interest transactions

{feesData.transactions.map((t) => ( ))}
Date Bank Description Type Amount
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })} {t.bank_name} {t.description} {t.transaction_type} {fmtExact(t.my_amount)}
)}
)} ); }