feat(merchants): scatter plot, merchant profiles, and per-merchant transaction history

- /merchants page with spend-vs-frequency scatter chart (click to open profile)
- Merchant profile drawer: stats, monthly trend line, full transaction history
- /api/analytics/merchants: split-adjusted merchant aggregates + monthly trends
- /api/analytics/merchants/[merchant]: per-merchant transaction list
- Add Merchants nav item to sidebar
This commit is contained in:
2026-03-10 00:05:48 +11:00
parent 2a10450c3e
commit 714c5a9b25
5 changed files with 647 additions and 0 deletions
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ merchant: string }> }
) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { merchant } = await params;
const decoded = decodeURIComponent(merchant);
const transactions = await queryRaw<{
id: number;
transaction_date: string;
description: string;
amount: number;
amount_aud: number | null;
my_amount: number;
transaction_type: string;
category: string;
bank_name: string;
statement_id: number;
}>(`
SELECT
t.id,
t.transaction_date::text,
t.description,
t.amount,
t.amount_aud,
CASE
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
ELSE COALESCE(t.amount_aud, t.amount)
END::numeric(10,2) as my_amount,
t.transaction_type,
COALESCE(o.category_override, t.category) as category,
s.bank_name,
t.statement_id
FROM transactions t
JOIN statements s ON s.id = t.statement_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
WHERE s.owner_id = $1
AND t.transaction_type IN ('debit', 'fee')
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
ORDER BY t.transaction_date DESC
LIMIT 500
`, [user.id, decoded]);
return NextResponse.json({ transactions });
}
+112
View File
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url);
const months = Math.min(24, Math.max(1, Number(searchParams.get("months") || "12")));
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - months);
const fromDate = cutoff.toISOString().slice(0, 10);
// Merchant aggregates — split-adjusted, debit transactions only
const rows = await queryRaw<{
merchant: string;
category: string;
transaction_count: number;
total_spend: number;
avg_transaction: number;
first_seen: string;
last_seen: string;
months_active: number;
}>(`
SELECT
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
MODE() WITHIN GROUP (ORDER BY COALESCE(o.category_override, t.category)) as category,
COUNT(*)::int as transaction_count,
SUM(
CASE
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
ELSE COALESCE(t.amount_aud, t.amount)
END
)::numeric(12,2) as total_spend,
AVG(
CASE
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
ELSE COALESCE(t.amount_aud, t.amount)
END
)::numeric(10,2) as avg_transaction,
MIN(t.transaction_date)::text as first_seen,
MAX(t.transaction_date)::text as last_seen,
COUNT(DISTINCT TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM'))::int as months_active
FROM transactions t
JOIN statements s ON s.id = t.statement_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
WHERE s.owner_id = $1
AND t.transaction_type IN ('debit', 'fee')
AND t.transaction_date >= $2
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
GROUP BY 1
HAVING SUM(
CASE
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
ELSE COALESCE(t.amount_aud, t.amount)
END
) > 0
ORDER BY total_spend DESC
LIMIT 200
`, [user.id, fromDate]);
// Monthly trend per merchant (top 50 by spend)
const topMerchants = rows.slice(0, 50).map((r) => r.merchant);
interface TrendRow { merchant: string; month: string; total: number }
let trendRows: TrendRow[] = [];
if (topMerchants.length > 0) {
trendRows = await queryRaw<TrendRow>(`
SELECT
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
SUM(
CASE
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
ELSE COALESCE(t.amount_aud, t.amount)
END
)::numeric(10,2) as total
FROM transactions t
JOIN statements s ON s.id = t.statement_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
WHERE s.owner_id = $1
AND t.transaction_type IN ('debit', 'fee')
AND t.transaction_date >= $2
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
GROUP BY 1, 2
ORDER BY 1, 2
`, [user.id, fromDate, topMerchants]);
}
// Group trend data by merchant
const trendByMerchant: Record<string, Record<string, number>> = {};
for (const tr of trendRows) {
if (!trendByMerchant[tr.merchant]) trendByMerchant[tr.merchant] = {};
trendByMerchant[tr.merchant][tr.month] = Number(tr.total);
}
const merchants = rows.map((r) => ({
...r,
transaction_count: Number(r.transaction_count),
total_spend: Number(r.total_spend),
avg_transaction: Number(r.avg_transaction),
months_active: Number(r.months_active),
monthly_trend: trendByMerchant[r.merchant] || {},
}));
return NextResponse.json({ merchants, months });
}
+430
View File
@@ -0,0 +1,430 @@
"use client";
import { useState, useMemo } from "react";
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
LineChart,
Line,
CartesianGrid,
} from "recharts";
import { useMerchants, useMerchantTransactions, MerchantRow } from "@/lib/hooks";
import { formatCategory } from "@/lib/categories";
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 + "T00:00:00").toLocaleDateString("en-AU", {
day: "numeric",
month: "short",
year: "numeric",
});
}
const CATEGORY_COLORS: Record<string, string> = {
groceries: "#4ade80",
dining: "#fb923c",
transport: "#60a5fa",
fuel: "#facc15",
shopping: "#f472b6",
utilities: "#a78bfa",
entertainment: "#34d399",
travel: "#38bdf8",
health: "#f87171",
insurance: "#94a3b8",
subscriptions: "#c084fc",
government: "#6b7280",
education: "#fbbf24",
rent: "#e879f9",
home_goods: "#67e8f9",
home_maintenance: "#c084fc",
personal_care: "#fb7185",
pets: "#a3e635",
gifts: "#f9a8d4",
charity: "#6ee7b7",
other: "#71717a",
};
// Custom scatter tooltip
function ScatterTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: MerchantRow }>;
}) {
if (!active || !payload?.length) return null;
const d = payload[0].payload;
return (
<div className="bg-zinc-800 border border-zinc-700 rounded-lg p-3 text-sm shadow-xl max-w-48">
<p className="font-semibold text-white truncate">{d.merchant}</p>
<p className="text-zinc-400 text-xs mt-0.5">{formatCategory(d.category)}</p>
<div className="mt-2 space-y-1 text-zinc-300">
<p>{fmt(d.total_spend)} total</p>
<p>{d.transaction_count}× transactions</p>
<p>{fmtExact(d.avg_transaction)} avg</p>
</div>
</div>
);
}
// Quadrant labels
function QuadrantLabels({ medianX, medianY }: { medianX: number; medianY: number }) {
return (
<div className="absolute inset-0 pointer-events-none select-none">
<div className="absolute top-2 right-4 text-xs text-zinc-600 text-right">
high cost · frequent
</div>
<div className="absolute top-2 left-16 text-xs text-zinc-600">
high cost · rare
</div>
<div className="absolute bottom-8 right-4 text-xs text-zinc-600 text-right">
low cost · frequent
</div>
<div className="absolute bottom-8 left-16 text-xs text-zinc-600">
low cost · rare
</div>
</div>
);
}
// Merchant profile drawer
function MerchantProfile({
merchant,
onClose,
}: {
merchant: MerchantRow;
onClose: () => void;
}) {
const { data } = useMerchantTransactions(merchant.merchant);
const transactions = data?.transactions ?? [];
// Build trend chart data from monthly_trend
const trendData = useMemo(() => {
const months = Object.keys(merchant.monthly_trend).sort();
return months.map((m) => ({
month: m,
label: new Date(m + "-01").toLocaleDateString("en-AU", { month: "short", year: "2-digit" }),
amount: merchant.monthly_trend[m],
}));
}, [merchant.monthly_trend]);
const color = CATEGORY_COLORS[merchant.category] || "#6366f1";
return (
<div className="fixed inset-0 z-50 flex justify-end">
{/* backdrop */}
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
{/* drawer */}
<div className="relative w-full max-w-lg bg-zinc-900 border-l border-zinc-700 h-full overflow-y-auto flex flex-col shadow-2xl">
{/* Header */}
<div className="flex items-start justify-between p-5 border-b border-zinc-800">
<div className="flex-1 min-w-0 pr-4">
<h2 className="text-lg font-semibold text-white truncate">{merchant.merchant}</h2>
<span
className="inline-block mt-1 px-2 py-0.5 rounded text-xs font-medium"
style={{ background: color + "33", color }}
>
{formatCategory(merchant.category)}
</span>
</div>
<button
onClick={onClose}
className="text-zinc-400 hover:text-white text-xl leading-none mt-0.5"
>
×
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3 p-5 border-b border-zinc-800">
<div className="bg-zinc-800 rounded-lg p-3 text-center">
<p className="text-xs text-zinc-400 mb-1">Total Spent</p>
<p className="text-white font-semibold">{fmt(merchant.total_spend)}</p>
</div>
<div className="bg-zinc-800 rounded-lg p-3 text-center">
<p className="text-xs text-zinc-400 mb-1">Transactions</p>
<p className="text-white font-semibold">{merchant.transaction_count}</p>
</div>
<div className="bg-zinc-800 rounded-lg p-3 text-center">
<p className="text-xs text-zinc-400 mb-1">Average</p>
<p className="text-white font-semibold">{fmtExact(merchant.avg_transaction)}</p>
</div>
</div>
{/* Trend chart */}
{trendData.length > 1 && (
<div className="p-5 border-b border-zinc-800">
<p className="text-sm font-medium text-zinc-300 mb-3">Monthly Spend</p>
<ResponsiveContainer width="100%" height={120}>
<LineChart data={trendData}>
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
<XAxis
dataKey="label"
tick={{ fill: "#71717a", fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: "#71717a", fontSize: 10 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${Math.round(v)}`}
width={45}
/>
<Tooltip
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: "8px" }}
labelStyle={{ color: "#a1a1aa" }}
/>
<Line
type="monotone"
dataKey="amount"
stroke={color}
strokeWidth={2}
dot={{ fill: color, r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Transaction history */}
<div className="p-5 flex-1">
<p className="text-sm font-medium text-zinc-300 mb-3">
Transactions{" "}
<span className="text-zinc-500 font-normal">({transactions.length})</span>
</p>
{transactions.length === 0 ? (
<p className="text-zinc-500 text-sm">Loading</p>
) : (
<div className="space-y-1">
{transactions.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between py-2 border-b border-zinc-800 text-sm"
>
<div className="flex-1 min-w-0 pr-3">
<p className="text-zinc-300 truncate">{tx.description}</p>
<p className="text-zinc-500 text-xs mt-0.5">
{fmtDate(tx.transaction_date)} · {tx.bank_name}
</p>
</div>
<p className="text-white font-medium whitespace-nowrap">
{fmtExact(tx.my_amount)}
</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
export default function MerchantsPage() {
const [months, setMonths] = useState(12);
const [selected, setSelected] = useState<MerchantRow | null>(null);
const [search, setSearch] = useState("");
const { data, isLoading } = useMerchants(months);
const merchants = data?.merchants ?? [];
// Stats for median lines
const { medianX, medianY } = useMemo(() => {
if (merchants.length === 0) return { medianX: 0, medianY: 0 };
const counts = [...merchants].sort((a, b) => a.transaction_count - b.transaction_count);
const spends = [...merchants].sort((a, b) => a.total_spend - b.total_spend);
const mid = Math.floor(merchants.length / 2);
return {
medianX: counts[mid]?.transaction_count ?? 0,
medianY: spends[mid]?.total_spend ?? 0,
};
}, [merchants]);
// Filtered merchants for the table
const filtered = useMemo(() => {
if (!search.trim()) return merchants;
const q = search.toLowerCase();
return merchants.filter((m) => m.merchant.toLowerCase().includes(q));
}, [merchants, search]);
// Top merchants for the scatter (max 150 for performance)
const scatterData = useMemo(() => merchants.slice(0, 150), [merchants]);
if (isLoading) {
return (
<div className="p-8 text-zinc-400">Loading merchant data</div>
);
}
return (
<div className="p-6 max-w-6xl space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">Merchants</h1>
<p className="text-zinc-400 text-sm mt-0.5">{merchants.length} merchants · last {months} months</p>
</div>
<select
value={months}
onChange={(e) => setMonths(Number(e.target.value))}
className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 px-3 py-1.5"
>
<option value={3}>3 months</option>
<option value={6}>6 months</option>
<option value={12}>12 months</option>
<option value={24}>24 months</option>
</select>
</div>
{/* Scatter plot */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h2 className="text-sm font-medium text-zinc-300 mb-1">Spend vs Frequency</h2>
<p className="text-xs text-zinc-500 mb-4">
Each dot = one merchant. Click to open profile.
</p>
<div className="relative">
<QuadrantLabels medianX={medianX} medianY={medianY} />
<ResponsiveContainer width="100%" height={360}>
<ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
<XAxis
dataKey="transaction_count"
name="Transactions"
type="number"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
label={{ value: "Transaction Count", position: "insideBottom", offset: -10, fill: "#52525b", fontSize: 11 }}
/>
<YAxis
dataKey="total_spend"
name="Total Spend"
type="number"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${Math.round(v / 1000)}k`}
width={48}
label={{ value: "Total Spend", angle: -90, position: "insideLeft", offset: 10, fill: "#52525b", fontSize: 11 }}
/>
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: "3 3", stroke: "#52525b" }} />
<Scatter
data={scatterData}
onClick={(d) => setSelected(d as unknown as MerchantRow)}
style={{ cursor: "pointer" }}
>
{scatterData.map((entry, idx) => (
<Cell
key={idx}
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
fillOpacity={0.75}
stroke={selected?.merchant === entry.merchant ? "#fff" : "transparent"}
strokeWidth={2}
/>
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
{/* Category legend */}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1">
{Object.entries(CATEGORY_COLORS)
.filter(([cat]) => merchants.some((m) => m.category === cat))
.slice(0, 12)
.map(([cat, color]) => (
<span key={cat} className="flex items-center gap-1.5 text-xs text-zinc-400">
<span className="w-2 h-2 rounded-full inline-block" style={{ background: color }} />
{formatCategory(cat)}
</span>
))}
</div>
</div>
{/* Merchant table */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
<h2 className="text-sm font-medium text-zinc-300">All Merchants</h2>
<input
type="text"
placeholder="Search…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 placeholder-zinc-500 px-3 py-1.5 w-48"
/>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-zinc-500 text-xs border-b border-zinc-800">
<th className="text-left px-4 py-2 font-medium">Merchant</th>
<th className="text-left px-4 py-2 font-medium">Category</th>
<th className="text-right px-4 py-2 font-medium">Total</th>
<th className="text-right px-4 py-2 font-medium">Count</th>
<th className="text-right px-4 py-2 font-medium">Avg</th>
<th className="text-right px-4 py-2 font-medium hidden sm:table-cell">Last Seen</th>
</tr>
</thead>
<tbody>
{filtered.map((m) => {
const color = CATEGORY_COLORS[m.category] || "#6366f1";
return (
<tr
key={m.merchant}
onClick={() => setSelected(m)}
className="border-b border-zinc-800 hover:bg-zinc-800 cursor-pointer transition-colors"
>
<td className="px-4 py-2.5 text-zinc-200 max-w-[200px]">
<span className="truncate block">{m.merchant}</span>
</td>
<td className="px-4 py-2.5">
<span
className="px-1.5 py-0.5 rounded text-xs"
style={{ background: color + "22", color }}
>
{formatCategory(m.category)}
</span>
</td>
<td className="px-4 py-2.5 text-right text-white font-medium">
{fmt(m.total_spend)}
</td>
<td className="px-4 py-2.5 text-right text-zinc-400">{m.transaction_count}</td>
<td className="px-4 py-2.5 text-right text-zinc-400">
{fmtExact(m.avg_transaction)}
</td>
<td className="px-4 py-2.5 text-right text-zinc-500 text-xs hidden sm:table-cell">
{fmtDate(m.last_seen)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Merchant profile drawer */}
{selected && (
<MerchantProfile merchant={selected} onClose={() => setSelected(null)} />
)}
</div>
);
}
+6
View File
@@ -9,6 +9,7 @@ const NAV_ITEMS = [
{ href: "/shared", label: "Shared", icon: "users" }, { href: "/shared", label: "Shared", icon: "users" },
{ href: "/budget", label: "Analytics", icon: "bar-chart" }, { href: "/budget", label: "Analytics", icon: "bar-chart" },
{ href: "/insights", label: "Insights", icon: "lightbulb" }, { href: "/insights", label: "Insights", icon: "lightbulb" },
{ href: "/merchants", label: "Merchants", icon: "store" },
{ href: "/tags", label: "Tags", icon: "tag" }, { href: "/tags", label: "Tags", icon: "tag" },
{ href: "/rules", label: "Rules", icon: "settings" }, { href: "/rules", label: "Rules", icon: "settings" },
]; ];
@@ -50,6 +51,11 @@ const ICONS: Record<string, React.ReactNode> = {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m1.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m1.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg> </svg>
), ),
store: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
}; };
export function Sidebar() { export function Sidebar() {
+46
View File
@@ -557,3 +557,49 @@ export function useFees() {
}, },
}); });
} }
export interface MerchantRow {
merchant: string;
category: string;
transaction_count: number;
total_spend: number;
avg_transaction: number;
first_seen: string;
last_seen: string;
months_active: number;
monthly_trend: Record<string, number>;
}
export function useMerchants(months = 12) {
return useQuery<{ merchants: MerchantRow[]; months: number }>({
queryKey: ["analytics", "merchants", months],
queryFn: async () => {
const res = await fetch(`/api/analytics/merchants?months=${months}`);
return res.json();
},
});
}
export interface MerchantTxnRow {
id: number;
transaction_date: string;
description: string;
amount: number;
amount_aud: number | null;
my_amount: number;
transaction_type: string;
category: string;
bank_name: string;
statement_id: number;
}
export function useMerchantTransactions(merchant: string | null) {
return useQuery<{ transactions: MerchantTxnRow[] }>({
queryKey: ["analytics", "merchant-txns", merchant],
queryFn: async () => {
const res = await fetch(`/api/analytics/merchants/${encodeURIComponent(merchant!)}`);
return res.json();
},
enabled: !!merchant,
});
}