feat(insights): committed/discretionary chart, recurring charge detection, fees & interest audit
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, Legend,
|
||||
} from "recharts";
|
||||
import { useMonthlyAnalytics, useSubscriptions, useFees } from "@/lib/hooks";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
|
||||
const COMMITTED_CATEGORIES = new Set(["rent", "utilities", "insurance", "subscriptions"]);
|
||||
|
||||
function fmt(n: number) {
|
||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n);
|
||||
}
|
||||
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<string, string> = {
|
||||
weekly: "Weekly",
|
||||
fortnightly: "Fortnightly",
|
||||
monthly: "Monthly",
|
||||
quarterly: "Quarterly",
|
||||
annual: "Annual",
|
||||
};
|
||||
|
||||
// ─── Section wrapper ────────────────────────────────────────────────
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-base font-semibold text-zinc-200 mb-3">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom tooltip ──────────────────────────────────────────────────
|
||||
function CommittedTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const committed = payload.find((p: any) => p.dataKey === "committed")?.value ?? 0;
|
||||
const discretionary = payload.find((p: any) => p.dataKey === "discretionary")?.value ?? 0;
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-xs space-y-1">
|
||||
<div className="font-medium text-zinc-300 mb-1">{label}</div>
|
||||
<div className="flex justify-between gap-4"><span className="text-indigo-400">Committed</span><span>{fmt(committed)}</span></div>
|
||||
<div className="flex justify-between gap-4"><span className="text-zinc-400">Discretionary</span><span>{fmt(discretionary)}</span></div>
|
||||
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span>{fmt(committed + discretionary)}</span></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────
|
||||
export default function InsightsPage() {
|
||||
const { data: analytics } = useMonthlyAnalytics(12);
|
||||
const { data: subData } = useSubscriptions();
|
||||
const { data: feesData } = useFees();
|
||||
|
||||
// Build committed/discretionary chart data
|
||||
const chartData = useMemo(() => {
|
||||
if (!analytics) return [];
|
||||
return [...analytics.months].reverse().map((month) => {
|
||||
let committed = 0;
|
||||
let discretionary = 0;
|
||||
for (const row of analytics.rows) {
|
||||
const spend = Number(row.spent[month] ?? 0);
|
||||
if (COMMITTED_CATEGORIES.has(row.category)) committed += spend;
|
||||
else discretionary += spend;
|
||||
}
|
||||
return {
|
||||
month: month.slice(5) + "/" + month.slice(2, 4),
|
||||
committed: Math.round(committed),
|
||||
discretionary: Math.round(discretionary),
|
||||
total: Math.round(committed + discretionary),
|
||||
};
|
||||
});
|
||||
}, [analytics]);
|
||||
|
||||
const committedValues = chartData.map((d) => d.committed);
|
||||
const committedTrend = trend(committedValues);
|
||||
const avgCommitted = committedValues.length
|
||||
? Math.round(committedValues.reduce((a, b) => a + b, 0) / committedValues.length)
|
||||
: 0;
|
||||
const latestCommitted = committedValues[committedValues.length - 1] ?? 0;
|
||||
|
||||
const activeSubscriptions = subData?.subscriptions.filter((s) => s.is_active) ?? [];
|
||||
const inactiveSubscriptions = subData?.subscriptions.filter((s) => !s.is_active) ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<h2 className="text-xl font-semibold mb-6">Insights</h2>
|
||||
|
||||
{/* ── 1. Committed vs Discretionary ── */}
|
||||
<Section title="Committed vs Discretionary Spend">
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">This month — committed floor</div>
|
||||
<div className="text-xl font-semibold text-indigo-400">{fmt(latestCommitted)}</div>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">12-month avg committed</div>
|
||||
<div className="text-xl font-semibold text-zinc-200">{fmt(avgCommitted)}</div>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">Trend (first 3 vs last 3 mo)</div>
|
||||
<div className={`text-xl font-semibold ${committedTrend.dir === "up" ? "text-red-400" : committedTrend.dir === "down" ? "text-green-400" : "text-zinc-400"}`}>
|
||||
{committedTrend.dir === "up" ? "↑" : committedTrend.dir === "down" ? "↓" : "→"} {committedTrend.pct}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
|
||||
<Tooltip content={<CommittedTooltip />} />
|
||||
<Bar dataKey="committed" stackId="a" fill="#6366f1" name="Committed" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="discretionary" stackId="a" fill="#3f3f46" name="Discretionary" radius={[3, 3, 0, 0]} />
|
||||
<Line type="monotone" dataKey="committed" stroke="#818cf8" strokeWidth={2} dot={false} strokeDasharray="4 2" />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex gap-4 mt-2 justify-end">
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-indigo-500 inline-block" />Committed (rent, utilities, insurance, subscriptions)</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-zinc-600 inline-block" />Discretionary</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── 2. Recurring Charges ── */}
|
||||
<Section title="Recurring Charges">
|
||||
{!subData ? (
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
) : subData.subscriptions.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm">No recurring patterns detected yet — more transaction history needed.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-zinc-500">{activeSubscriptions.length} active · {inactiveSubscriptions.length} inactive</span>
|
||||
<span className="text-sm font-medium text-indigo-400">{fmtExact(subData.total_monthly_equiv)}<span className="text-xs text-zinc-500 font-normal ml-1">/ month committed</span></span>
|
||||
</div>
|
||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Merchant</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Category</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Frequency</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">My $/mo equiv</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Avg charge</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Since</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total paid</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...activeSubscriptions, ...inactiveSubscriptions].map((s) => (
|
||||
<tr key={s.merchant} className={`border-b border-zinc-800/50 ${s.is_active ? "hover:bg-zinc-800/20" : "opacity-40"} transition-colors`}>
|
||||
<td className="px-4 py-3 font-medium">{s.merchant}</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{formatCategory(s.category ?? "other")}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${s.is_active ? "bg-indigo-900/40 text-indigo-300" : "bg-zinc-800 text-zinc-500"}`}>
|
||||
{FREQ_LABEL[s.frequency] ?? s.frequency}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-200">{fmtExact(s.monthly_equiv)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.avg_amount)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-500 text-xs whitespace-nowrap">{fmtDate(s.first_seen)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.total_paid)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-500">{s.occurrences}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── 3. Fees & Interest ── */}
|
||||
<Section title="Fees & Interest">
|
||||
{!feesData ? (
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm">No fees or interest recorded across your statements.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{feesData.by_bank.length > 0 && (
|
||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Fees</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Interest</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{feesData.by_bank.map((r) => (
|
||||
<tr key={r.bank_name} className="border-b border-zinc-800/50">
|
||||
<td className="px-4 py-3 font-medium">{r.bank_name}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.fees > 0 ? fmtExact(r.fees) : <span className="text-zinc-700">—</span>}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.interest > 0 ? fmtExact(r.interest) : <span className="text-zinc-700">—</span>}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-red-400 font-medium">{fmtExact(r.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-zinc-900/50">
|
||||
<td className="px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_fees)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_interest)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-red-400 font-medium">{fmtExact(feesData.total_fees + feesData.total_interest)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{feesData.transactions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2">Individual fee / interest transactions</p>
|
||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Date</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Description</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Type</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{feesData.transactions.map((t) => (
|
||||
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20">
|
||||
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
|
||||
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-400 text-xs">{t.bank_name}</td>
|
||||
<td className="px-4 py-2.5 text-zinc-300">{t.description}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${t.transaction_type === "interest" ? "bg-orange-900/40 text-orange-300" : "bg-zinc-800 text-zinc-400"}`}>
|
||||
{t.transaction_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-red-400">{fmtExact(t.my_amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user