Files
finance-app/src/app/insights/page.tsx
T
siddharthd 5206388958 feat(filters): smart query bar with amount operators and multi-select dropdowns
- Query bar parses >500, >=500, <500, <=500, 500-1500 into amount_min/max filters
- Parsed tokens shown as dismissable chips below the query bar
- Category, Bank, Tag, Type filters upgraded from single-select to multi-select
- MultiSelect dropdown component with checkbox list and active-state border
- Backend: TransactionFilters uses string[] for categories/bank_names/tag_ids/transaction_types
- SQL: ANY($n::text[]) / ANY($n::int[]) for array filters
2026-03-14 20:39:28 +11:00

500 lines
25 KiB
TypeScript

"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";
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
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<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 RegularTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null;
const regular = payload.find((p: any) => p.dataKey === "regular")?.value ?? 0;
const occasional = payload.find((p: any) => p.dataKey === "occasional")?.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">Regular</span><span>{fmt(regular)}</span></div>
<div className="flex justify-between gap-4"><span className="text-zinc-400">Occasional</span><span>{fmt(occasional)}</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(regular + occasional)}</span></div>
</div>
);
}
// ─── 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 (
<tr>
<td colSpan={2} className="px-4 py-3 text-xs text-zinc-500">Loading...</td>
</tr>
);
}
const txns = data?.data ?? [];
return (
<tr>
<td colSpan={2} className="px-0 py-0">
<div className="bg-zinc-950 border-t border-zinc-800/50">
{txns.length === 0 ? (
<p className="px-6 py-3 text-xs text-zinc-600">No transactions found.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-6 py-2 text-zinc-600 font-medium">Date</th>
<th className="text-left px-4 py-2 text-zinc-600 font-medium">Merchant</th>
<th className="text-right px-4 py-2 text-zinc-600 font-medium">My share</th>
<th className="text-left px-4 py-2 text-zinc-600 font-medium">Category</th>
<th className="text-right px-4 py-2 text-zinc-600 font-medium">% mine</th>
</tr>
</thead>
<tbody>
{txns.map((t) => {
const sharePct = t.my_share_percent ?? 100;
const effectiveAmt = t.amount * sharePct / 100;
const isDebit = SPEND_TYPES.has(t.transaction_type);
return (
<tr key={t.id} className="border-b border-zinc-800/30 hover:bg-zinc-900/30">
<td className="px-6 py-2 text-zinc-500 whitespace-nowrap">
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
</td>
<td className="px-4 py-2 text-zinc-300">{t.merchant_name || t.description}</td>
<td className={`px-4 py-2 text-right tabular-nums ${isDebit ? "text-zinc-200" : "text-green-400"}`}>
{fmtTx(effectiveAmt, t.transaction_type)}
{sharePct < 100 && (
<span className="text-zinc-600 ml-1 line-through">{fmtExact(t.amount)}</span>
)}
</td>
<td className="px-4 py-2">
<select
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500"
defaultValue={t.effective_category ?? "other"}
onChange={(e) => updateTx.mutate({ id: t.id, category: e.target.value })}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
</td>
<td className="px-4 py-2 text-right">
<select
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500"
defaultValue={t.my_share_percent ?? 100}
onChange={(e) => {
const val = Number(e.target.value);
updateTx.mutate({ id: t.id, my_share_percent: val === 100 ? null : val });
}}
>
<option value={100}>100%</option>
<option value={75}>75%</option>
<option value={67}>67%</option>
<option value={50}>50%</option>
<option value={33}>33%</option>
<option value={25}>25%</option>
</select>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</td>
</tr>
);
}
// ─── Monthly Spend Breakdown ─────────────────────────────────────────
function MonthlyBreakdown({ analytics }: { analytics: NonNullable<ReturnType<typeof useMonthlyAnalytics>["data"]> }) {
// analytics.months is newest-first; show last 6
const months = useMemo(() => analytics.months.slice(0, 6), [analytics.months]);
const [selectedMonth, setSelectedMonth] = useState<string>(months[0] ?? "");
const [expandedCategory, setExpandedCategory] = useState<string | null>(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.has(r.category as any));
const occasionalRows = categoryData.filter((r) => !REGULAR_CATEGORIES.has(r.category as any));
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) => (
<>
<tr
key={row.category}
className="border-b border-zinc-800/50 hover:bg-zinc-800/20 cursor-pointer transition-colors"
onClick={() => setExpandedCategory(expandedCategory === row.category ? null : row.category)}
>
<td className="px-4 py-2.5">
<span className="flex items-center gap-2 text-sm text-zinc-300">
<span className={`w-2 h-2 rounded-full inline-block flex-shrink-0 ${dotClass}`} />
{formatCategory(row.category)}
<span className="text-zinc-600 text-xs">{expandedCategory === row.category ? "▲" : "▼"}</span>
</span>
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-sm text-zinc-200">{fmt(row.amount)}</td>
</tr>
{expandedCategory === row.category && (
<DrillDownRow
key={`${row.category}-drill`}
category={row.category}
from={from}
to={to}
/>
)}
</>
));
}
return (
<div>
{/* Month tabs */}
<div className="flex gap-1 mb-3 flex-wrap">
{months.map((m) => (
<button
key={m}
onClick={() => handleSelectMonth(m)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
m === selectedMonth
? "bg-indigo-600 text-white"
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
}`}
>
{monthLabel(m)}
</button>
))}
</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">Category</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Spend</th>
</tr>
</thead>
<tbody>
{regularRows.length > 0 && (
<>
<tr className="bg-zinc-900/30">
<td className="px-4 py-1.5 text-xs text-indigo-400 font-medium tracking-wide uppercase">Regular</td>
<td className="px-4 py-1.5 text-right text-xs text-indigo-400 font-medium">{fmt(regularTotal)}</td>
</tr>
{renderRows(regularRows, "bg-indigo-500")}
</>
)}
{occasionalRows.length > 0 && (
<>
<tr className="bg-zinc-900/30">
<td className="px-4 py-1.5 text-xs text-zinc-500 font-medium tracking-wide uppercase">Occasional</td>
<td className="px-4 py-1.5 text-right text-xs text-zinc-500 font-medium">{fmt(occasionalTotal)}</td>
</tr>
{renderRows(occasionalRows, "bg-zinc-500")}
</>
)}
{categoryData.length === 0 && (
<tr>
<td colSpan={2} className="px-4 py-6 text-center text-xs text-zinc-600">No spend data for this month.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
// ─── Main page ────────────────────────────────────────────────────────
export default function InsightsPage() {
const { data: analytics } = useMonthlyAnalytics(12);
const { data: analytics6 } = useMonthlyAnalytics(6);
const { data: subData } = useSubscriptions();
const { data: feesData } = useFees();
// 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.has(row.category as any)) 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 (
<div className="max-w-4xl">
<h2 className="text-xl font-semibold mb-6">Insights</h2>
{/* ── 1. Regular vs Occasional ── */}
<Section title="Regular vs Occasional 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 regular spend</div>
<div className="text-xl font-semibold text-indigo-400">{fmt(latestRegular)}</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 regular</div>
<div className="text-xl font-semibold text-zinc-200">{fmt(avgRegular)}</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 ${regularTrend.dir === "up" ? "text-red-400" : regularTrend.dir === "down" ? "text-green-400" : "text-zinc-400"}`}>
{regularTrend.dir === "up" ? "↑" : regularTrend.dir === "down" ? "↓" : "→"} {regularTrend.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={<RegularTooltip />} />
<Bar dataKey="regular" stackId="a" fill="#6366f1" name="Regular" radius={[0, 0, 0, 0]} />
<Bar dataKey="occasional" stackId="a" fill="#3f3f46" name="Occasional" radius={[3, 3, 0, 0]} />
<Line type="monotone" dataKey="regular" 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" />Regular (groceries, dining, transport)</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" />Occasional</span>
</div>
</div>
</Section>
{/* ── 2. Monthly Spend Breakdown ── */}
<Section title="Monthly Spend Breakdown">
{!analytics6 ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : (
<MonthlyBreakdown analytics={analytics6} />
)}
</Section>
{/* ── 3. 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>
{/* ── 4. 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>
);
}