feat(insights): analytics drill-down, fee tracking, and category improvements
- Monthly spend chart with category breakdown drill-down - Merchant frequency and spend analytics with per-merchant history - Subscription detection and recurring charge tracking - Fee and interest analytics endpoint - Expanded category list with formatCategory display helper
This commit is contained in:
@@ -42,10 +42,12 @@ export async function GET(req: NextRequest) {
|
|||||||
t.transaction_type,
|
t.transaction_type,
|
||||||
CASE
|
CASE
|
||||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||||
|
WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100
|
||||||
ELSE COALESCE(t.amount_aud, t.amount)
|
ELSE COALESCE(t.amount_aud, t.amount)
|
||||||
END::numeric(12,2) AS my_amount,
|
END::numeric(12,2) AS my_amount,
|
||||||
s.bank_name
|
s.bank_name
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
|
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
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||||
JOIN statements s ON s.id = t.statement_id
|
JOIN statements s ON s.id = t.statement_id
|
||||||
WHERE s.owner_id = $1
|
WHERE s.owner_id = $1
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ export async function GET(
|
|||||||
t.amount_aud,
|
t.amount_aud,
|
||||||
CASE
|
CASE
|
||||||
WHEN t.transaction_type IN ('refund', 'credit') THEN
|
WHEN t.transaction_type IN ('refund', 'credit') THEN
|
||||||
-(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)
|
-(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||||
ELSE
|
ELSE
|
||||||
(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)
|
(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||||
END::numeric(10,2) as my_amount,
|
END::numeric(10,2) as my_amount,
|
||||||
t.transaction_type,
|
t.transaction_type,
|
||||||
COALESCE(o.category_override, t.category) as category,
|
COALESCE(o.category_override, t.category) as category,
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { getCurrentUser } from "@/lib/auth";
|
|||||||
import { queryRaw } from "@/lib/db";
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
// Split-adjusted amount helper (positive for spend, negative for refunds)
|
// Split-adjusted amount helper (positive for spend, negative for refunds)
|
||||||
|
const MY_AMOUNT = `CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END`;
|
||||||
const SPEND_EXPR = `
|
const SPEND_EXPR = `
|
||||||
CASE
|
CASE
|
||||||
WHEN t.transaction_type IN ('refund', 'credit') THEN
|
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${MY_AMOUNT})
|
||||||
-(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)
|
ELSE (${MY_AMOUNT})
|
||||||
ELSE
|
|
||||||
(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)
|
|
||||||
END
|
END
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -44,18 +43,18 @@ export async function GET(req: NextRequest) {
|
|||||||
COUNT(*) FILTER (WHERE t.transaction_type IN ('refund', 'credit'))::int as refund_count,
|
COUNT(*) FILTER (WHERE t.transaction_type IN ('refund', 'credit'))::int as refund_count,
|
||||||
COALESCE(SUM(
|
COALESCE(SUM(
|
||||||
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
|
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
|
||||||
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
|
${MY_AMOUNT}
|
||||||
ELSE 0 END
|
ELSE 0 END
|
||||||
), 0)::numeric(12,2) as gross_spend,
|
), 0)::numeric(12,2) as gross_spend,
|
||||||
COALESCE(SUM(
|
COALESCE(SUM(
|
||||||
CASE WHEN t.transaction_type IN ('refund', 'credit') THEN
|
CASE WHEN t.transaction_type IN ('refund', 'credit') THEN
|
||||||
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
|
${MY_AMOUNT}
|
||||||
ELSE 0 END
|
ELSE 0 END
|
||||||
), 0)::numeric(12,2) as total_refunds,
|
), 0)::numeric(12,2) as total_refunds,
|
||||||
SUM(${SPEND_EXPR})::numeric(12,2) as net_spend,
|
SUM(${SPEND_EXPR})::numeric(12,2) as net_spend,
|
||||||
AVG(
|
AVG(
|
||||||
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
|
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
|
||||||
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
|
${MY_AMOUNT}
|
||||||
END
|
END
|
||||||
)::numeric(10,2) as avg_debit,
|
)::numeric(10,2) as avg_debit,
|
||||||
MIN(t.transaction_date)::text as first_seen,
|
MIN(t.transaction_date)::text as first_seen,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export async function GET(req: NextRequest) {
|
|||||||
SUM(
|
SUM(
|
||||||
CASE
|
CASE
|
||||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||||
|
WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100
|
||||||
ELSE COALESCE(t.amount_aud, t.amount)
|
ELSE COALESCE(t.amount_aud, t.amount)
|
||||||
END
|
END
|
||||||
)::numeric(12,2) as total_spent,
|
)::numeric(12,2) as total_spent,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export async function GET(req: NextRequest) {
|
|||||||
t.transaction_date,
|
t.transaction_date,
|
||||||
CASE
|
CASE
|
||||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||||
|
WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100
|
||||||
ELSE COALESCE(t.amount_aud, t.amount)
|
ELSE COALESCE(t.amount_aud, t.amount)
|
||||||
END AS my_amount
|
END AS my_amount
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
if (type === "banks") {
|
if (type === "banks") {
|
||||||
const banks = await getBankNames();
|
const banks = await getBankNames();
|
||||||
return NextResponse.json(banks.map((b) => b.bank_name));
|
return NextResponse.json(banks);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!search) return NextResponse.json([]);
|
if (!search) return NextResponse.json([]);
|
||||||
|
|||||||
+272
-40
@@ -1,17 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, Legend,
|
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useMonthlyAnalytics, useSubscriptions, useFees } from "@/lib/hooks";
|
import { useMonthlyAnalytics, useSubscriptions, useFees, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||||
import { formatCategory } from "@/lib/categories";
|
import { CATEGORIES, REGULAR_CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
|
||||||
const COMMITTED_CATEGORIES = new Set(["rent", "utilities", "insurance", "subscriptions"]);
|
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||||
|
|
||||||
function fmt(n: number) {
|
function fmt(n: number) {
|
||||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n);
|
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) {
|
function fmtExact(n: number) {
|
||||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(n);
|
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(n);
|
||||||
}
|
}
|
||||||
@@ -46,16 +50,234 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Custom tooltip ──────────────────────────────────────────────────
|
// ─── Custom tooltip ──────────────────────────────────────────────────
|
||||||
function CommittedTooltip({ active, payload, label }: any) {
|
function RegularTooltip({ active, payload, label }: any) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
const committed = payload.find((p: any) => p.dataKey === "committed")?.value ?? 0;
|
const regular = payload.find((p: any) => p.dataKey === "regular")?.value ?? 0;
|
||||||
const discretionary = payload.find((p: any) => p.dataKey === "discretionary")?.value ?? 0;
|
const occasional = payload.find((p: any) => p.dataKey === "occasional")?.value ?? 0;
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-xs space-y-1">
|
<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="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-indigo-400">Regular</span><span>{fmt(regular)}</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"><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(committed + 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(regular + occasional)}</span></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Drill-down row ──────────────────────────────────────────────────
|
||||||
|
function DrillDownRow({
|
||||||
|
category,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}: {
|
||||||
|
category: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading } = useTransactions({ 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,35 +285,36 @@ function CommittedTooltip({ active, payload, label }: any) {
|
|||||||
// ─── Main page ────────────────────────────────────────────────────────
|
// ─── Main page ────────────────────────────────────────────────────────
|
||||||
export default function InsightsPage() {
|
export default function InsightsPage() {
|
||||||
const { data: analytics } = useMonthlyAnalytics(12);
|
const { data: analytics } = useMonthlyAnalytics(12);
|
||||||
|
const { data: analytics6 } = useMonthlyAnalytics(6);
|
||||||
const { data: subData } = useSubscriptions();
|
const { data: subData } = useSubscriptions();
|
||||||
const { data: feesData } = useFees();
|
const { data: feesData } = useFees();
|
||||||
|
|
||||||
// Build committed/discretionary chart data
|
// Build regular/occasional chart data
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (!analytics) return [];
|
if (!analytics) return [];
|
||||||
return [...analytics.months].reverse().map((month) => {
|
return [...analytics.months].reverse().map((month) => {
|
||||||
let committed = 0;
|
let regular = 0;
|
||||||
let discretionary = 0;
|
let occasional = 0;
|
||||||
for (const row of analytics.rows) {
|
for (const row of analytics.rows) {
|
||||||
const spend = Number(row.spent[month] ?? 0);
|
const spend = Number(row.spent[month] ?? 0);
|
||||||
if (COMMITTED_CATEGORIES.has(row.category)) committed += spend;
|
if (REGULAR_CATEGORIES.has(row.category as any)) regular += spend;
|
||||||
else discretionary += spend;
|
else occasional += spend;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
month: month.slice(5) + "/" + month.slice(2, 4),
|
month: month.slice(5) + "/" + month.slice(2, 4),
|
||||||
committed: Math.round(committed),
|
regular: Math.round(regular),
|
||||||
discretionary: Math.round(discretionary),
|
occasional: Math.round(occasional),
|
||||||
total: Math.round(committed + discretionary),
|
total: Math.round(regular + occasional),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [analytics]);
|
}, [analytics]);
|
||||||
|
|
||||||
const committedValues = chartData.map((d) => d.committed);
|
const regularValues = chartData.map((d) => d.regular);
|
||||||
const committedTrend = trend(committedValues);
|
const regularTrend = trend(regularValues);
|
||||||
const avgCommitted = committedValues.length
|
const avgRegular = regularValues.length
|
||||||
? Math.round(committedValues.reduce((a, b) => a + b, 0) / committedValues.length)
|
? Math.round(regularValues.reduce((a, b) => a + b, 0) / regularValues.length)
|
||||||
: 0;
|
: 0;
|
||||||
const latestCommitted = committedValues[committedValues.length - 1] ?? 0;
|
const latestRegular = regularValues[regularValues.length - 1] ?? 0;
|
||||||
|
|
||||||
const activeSubscriptions = subData?.subscriptions.filter((s) => s.is_active) ?? [];
|
const activeSubscriptions = subData?.subscriptions.filter((s) => s.is_active) ?? [];
|
||||||
const inactiveSubscriptions = subData?.subscriptions.filter((s) => !s.is_active) ?? [];
|
const inactiveSubscriptions = subData?.subscriptions.filter((s) => !s.is_active) ?? [];
|
||||||
@@ -100,21 +323,21 @@ export default function InsightsPage() {
|
|||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<h2 className="text-xl font-semibold mb-6">Insights</h2>
|
<h2 className="text-xl font-semibold mb-6">Insights</h2>
|
||||||
|
|
||||||
{/* ── 1. Committed vs Discretionary ── */}
|
{/* ── 1. Regular vs Occasional ── */}
|
||||||
<Section title="Committed vs Discretionary Spend">
|
<Section title="Regular vs Occasional Spend">
|
||||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
<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="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-xs text-zinc-500 mb-1">This month — regular spend</div>
|
||||||
<div className="text-xl font-semibold text-indigo-400">{fmt(latestCommitted)}</div>
|
<div className="text-xl font-semibold text-indigo-400">{fmt(latestRegular)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
<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-xs text-zinc-500 mb-1">12-month avg regular</div>
|
||||||
<div className="text-xl font-semibold text-zinc-200">{fmt(avgCommitted)}</div>
|
<div className="text-xl font-semibold text-zinc-200">{fmt(avgRegular)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
<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-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"}`}>
|
<div className={`text-xl font-semibold ${regularTrend.dir === "up" ? "text-red-400" : regularTrend.dir === "down" ? "text-green-400" : "text-zinc-400"}`}>
|
||||||
{committedTrend.dir === "up" ? "↑" : committedTrend.dir === "down" ? "↓" : "→"} {committedTrend.pct}%
|
{regularTrend.dir === "up" ? "↑" : regularTrend.dir === "down" ? "↓" : "→"} {regularTrend.pct}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,20 +346,29 @@ export default function InsightsPage() {
|
|||||||
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||||
<XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} />
|
<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} />
|
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
|
||||||
<Tooltip content={<CommittedTooltip />} />
|
<Tooltip content={<RegularTooltip />} />
|
||||||
<Bar dataKey="committed" stackId="a" fill="#6366f1" name="Committed" radius={[0, 0, 0, 0]} />
|
<Bar dataKey="regular" stackId="a" fill="#6366f1" name="Regular" radius={[0, 0, 0, 0]} />
|
||||||
<Bar dataKey="discretionary" stackId="a" fill="#3f3f46" name="Discretionary" radius={[3, 3, 0, 0]} />
|
<Bar dataKey="occasional" stackId="a" fill="#3f3f46" name="Occasional" radius={[3, 3, 0, 0]} />
|
||||||
<Line type="monotone" dataKey="committed" stroke="#818cf8" strokeWidth={2} dot={false} strokeDasharray="4 2" />
|
<Line type="monotone" dataKey="regular" stroke="#818cf8" strokeWidth={2} dot={false} strokeDasharray="4 2" />
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="flex gap-4 mt-2 justify-end">
|
<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-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" />Discretionary</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>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ── 2. Recurring Charges ── */}
|
{/* ── 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">
|
<Section title="Recurring Charges">
|
||||||
{!subData ? (
|
{!subData ? (
|
||||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||||
@@ -186,7 +418,7 @@ export default function InsightsPage() {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ── 3. Fees & Interest ── */}
|
{/* ── 4. Fees & Interest ── */}
|
||||||
<Section title="Fees & Interest">
|
<Section title="Fees & Interest">
|
||||||
{!feesData ? (
|
{!feesData ? (
|
||||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
export const REGULAR_CATEGORIES = new Set([
|
||||||
|
"rent", "utilities", "insurance", "subscriptions",
|
||||||
|
"groceries", "dining", "transport", "fuel",
|
||||||
|
"health", "personal_care", "government", "charity", "pets",
|
||||||
|
] as const);
|
||||||
|
|
||||||
export const CATEGORIES = [
|
export const CATEGORIES = [
|
||||||
"groceries",
|
"groceries",
|
||||||
"dining",
|
"dining",
|
||||||
|
|||||||
Reference in New Issue
Block a user