diff --git a/src/app/api/analytics/fees/route.ts b/src/app/api/analytics/fees/route.ts index 48cc459..96b8911 100644 --- a/src/app/api/analytics/fees/route.ts +++ b/src/app/api/analytics/fees/route.ts @@ -42,10 +42,12 @@ export async function GET(req: NextRequest) { t.transaction_type, 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::numeric(12,2) AS my_amount, s.bank_name 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 JOIN statements s ON s.id = t.statement_id WHERE s.owner_id = $1 diff --git a/src/app/api/analytics/merchants/[merchant]/route.ts b/src/app/api/analytics/merchants/[merchant]/route.ts index 614da38..af2d65b 100644 --- a/src/app/api/analytics/merchants/[merchant]/route.ts +++ b/src/app/api/analytics/merchants/[merchant]/route.ts @@ -32,9 +32,9 @@ export async function GET( t.amount_aud, 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) + -(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 - (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, t.transaction_type, COALESCE(o.category_override, t.category) as category, diff --git a/src/app/api/analytics/merchants/route.ts b/src/app/api/analytics/merchants/route.ts index 2cd7fad..dd38a46 100644 --- a/src/app/api/analytics/merchants/route.ts +++ b/src/app/api/analytics/merchants/route.ts @@ -3,12 +3,11 @@ import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; // 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 = ` 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) - 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) + WHEN t.transaction_type IN ('refund', 'credit') THEN -(${MY_AMOUNT}) + ELSE (${MY_AMOUNT}) END `; @@ -44,18 +43,18 @@ export async function GET(req: NextRequest) { COUNT(*) FILTER (WHERE t.transaction_type IN ('refund', 'credit'))::int as refund_count, COALESCE(SUM( 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 ), 0)::numeric(12,2) as gross_spend, COALESCE(SUM( 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 ), 0)::numeric(12,2) as total_refunds, SUM(${SPEND_EXPR})::numeric(12,2) as net_spend, AVG( 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 )::numeric(10,2) as avg_debit, MIN(t.transaction_date)::text as first_seen, diff --git a/src/app/api/analytics/monthly/route.ts b/src/app/api/analytics/monthly/route.ts index d58886c..c286bef 100644 --- a/src/app/api/analytics/monthly/route.ts +++ b/src/app/api/analytics/monthly/route.ts @@ -29,6 +29,7 @@ export async function GET(req: NextRequest) { SUM( 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 )::numeric(12,2) as total_spent, diff --git a/src/app/api/analytics/subscriptions/route.ts b/src/app/api/analytics/subscriptions/route.ts index d5f3208..a8b2917 100644 --- a/src/app/api/analytics/subscriptions/route.ts +++ b/src/app/api/analytics/subscriptions/route.ts @@ -24,6 +24,7 @@ export async function GET(req: NextRequest) { t.transaction_date, 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 AS my_amount FROM transactions t diff --git a/src/app/api/merchants/route.ts b/src/app/api/merchants/route.ts index 3739d90..e4d4a35 100644 --- a/src/app/api/merchants/route.ts +++ b/src/app/api/merchants/route.ts @@ -7,7 +7,7 @@ export async function GET(req: NextRequest) { if (type === "banks") { const banks = await getBankNames(); - return NextResponse.json(banks.map((b) => b.bank_name)); + return NextResponse.json(banks); } if (!search) return NextResponse.json([]); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 3e42a4f..d79b32f 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -1,17 +1,21 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { - ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, Legend, + ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, } from "recharts"; -import { useMonthlyAnalytics, useSubscriptions, useFees } from "@/lib/hooks"; -import { formatCategory } from "@/lib/categories"; +import { useMonthlyAnalytics, useSubscriptions, useFees, useTransactions, useUpdateTransaction } from "@/lib/hooks"; +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) { 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); } @@ -46,16 +50,234 @@ function Section({ title, children }: { title: string; children: React.ReactNode } // ─── Custom tooltip ────────────────────────────────────────────────── -function CommittedTooltip({ active, payload, label }: any) { +function RegularTooltip({ 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; + const regular = payload.find((p: any) => p.dataKey === "regular")?.value ?? 0; + const occasional = payload.find((p: any) => p.dataKey === "occasional")?.value ?? 0; return (
{label}
-
Committed{fmt(committed)}
-
Discretionary{fmt(discretionary)}
-
Total{fmt(committed + discretionary)}
+
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({ 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) => { + const sharePct = t.my_share_percent ?? 100; + const effectiveAmt = t.amount * sharePct / 100; + const isDebit = SPEND_TYPES.has(t.transaction_type); + return ( + + + + + + + + ); + })} + +
DateMerchantMy shareCategory% mine
+ {new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })} + {t.merchant_name || t.description} + {fmtTx(effectiveAmt, t.transaction_type)} + {sharePct < 100 && ( + {fmtExact(t.amount)} + )} + + + + +
+ )} +
+ + + ); +} + +// ─── 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.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) => ( + <> + 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 && ( + + + + )} + +
CategorySpend
Regular{fmt(regularTotal)}
Occasional{fmt(occasionalTotal)}
No spend data for this month.
+
); } @@ -63,35 +285,36 @@ function CommittedTooltip({ active, payload, label }: any) { // ─── 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 committed/discretionary chart data + // Build regular/occasional chart data const chartData = useMemo(() => { if (!analytics) return []; return [...analytics.months].reverse().map((month) => { - let committed = 0; - let discretionary = 0; + let regular = 0; + let occasional = 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; + if (REGULAR_CATEGORIES.has(row.category as any)) regular += spend; + else occasional += spend; } return { month: month.slice(5) + "/" + month.slice(2, 4), - committed: Math.round(committed), - discretionary: Math.round(discretionary), - total: Math.round(committed + discretionary), + regular: Math.round(regular), + occasional: Math.round(occasional), + total: Math.round(regular + occasional), }; }); }, [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) + 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 latestCommitted = committedValues[committedValues.length - 1] ?? 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) ?? []; @@ -100,21 +323,21 @@ export default function InsightsPage() {

Insights

- {/* ── 1. Committed vs Discretionary ── */} -
+ {/* ── 1. Regular vs Occasional ── */} +
-
This month — committed floor
-
{fmt(latestCommitted)}
+
This month — regular spend
+
{fmt(latestRegular)}
-
12-month avg committed
-
{fmt(avgCommitted)}
+
12-month avg regular
+
{fmt(avgRegular)}
Trend (first 3 vs last 3 mo)
-
- {committedTrend.dir === "up" ? "↑" : committedTrend.dir === "down" ? "↓" : "→"} {committedTrend.pct}% +
+ {regularTrend.dir === "up" ? "↑" : regularTrend.dir === "down" ? "↓" : "→"} {regularTrend.pct}%
@@ -123,20 +346,29 @@ export default function InsightsPage() { `$${(v / 1000).toFixed(0)}k`} width={44} /> - } /> - - - + } /> + + +
- Committed (rent, utilities, insurance, subscriptions) - Discretionary + Regular (groceries, dining, transport…) + Occasional
- {/* ── 2. Recurring Charges ── */} + {/* ── 2. Monthly Spend Breakdown ── */} +
+ {!analytics6 ? ( +

Loading...

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

Loading...

@@ -186,7 +418,7 @@ export default function InsightsPage() { )}
- {/* ── 3. Fees & Interest ── */} + {/* ── 4. Fees & Interest ── */}
{!feesData ? (

Loading...

diff --git a/src/lib/categories.ts b/src/lib/categories.ts index 2ad4ce4..838e24e 100644 --- a/src/lib/categories.ts +++ b/src/lib/categories.ts @@ -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 = [ "groceries", "dining",