diff --git a/src/app/api/analytics/monthly/route.ts b/src/app/api/analytics/monthly/route.ts index dfae150..f72943d 100644 --- a/src/app/api/analytics/monthly/route.ts +++ b/src/app/api/analytics/monthly/route.ts @@ -16,6 +16,7 @@ export async function GET(req: NextRequest) { const startStr = startDate.toISOString().slice(0, 10); const endStr = endDate.toISOString().slice(0, 10); + // Expenses: debits excluding transfers and investments, split-adjusted const spendRows = await queryRaw<{ month: string; category: string; @@ -27,8 +28,8 @@ export async function GET(req: NextRequest) { COALESCE(o.category_override, t.category) as category, SUM( CASE - WHEN ts.share_percent IS NOT NULL THEN t.amount * ts.share_percent / 100 - ELSE t.amount + 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_spent, COUNT(*)::int as transaction_count @@ -38,6 +39,7 @@ export async function GET(req: NextRequest) { JOIN statements s ON s.id = t.statement_id WHERE s.owner_id = $1 AND t.transaction_type = 'debit' + AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment') AND t.transaction_date >= $2 AND t.transaction_date < $3 GROUP BY 1, 2 @@ -45,10 +47,48 @@ export async function GET(req: NextRequest) { [user.id, startStr, endStr] ); - const budgetRows = await queryRaw<{ month: string; category: string; amount_limit: number }>( - `SELECT TO_CHAR(month, 'YYYY-MM') as month, category, amount_limit::numeric - FROM budgets - WHERE owner_id = $1 AND month >= $2::date AND month < $3::date`, + // Income: credits/payments categorised as income + const incomeRows = await queryRaw<{ + month: string; + total_income: number; + transaction_count: number; + }>( + `SELECT + TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month, + SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_income, + COUNT(*)::int as transaction_count + FROM transactions t + LEFT JOIN transaction_overrides o ON o.transaction_id = t.id + JOIN statements s ON s.id = t.statement_id + WHERE s.owner_id = $1 + AND t.transaction_type IN ('credit', 'payment') + AND COALESCE(o.category_override, t.category) = 'income' + AND t.transaction_date >= $2 + AND t.transaction_date < $3 + GROUP BY 1 + ORDER BY 1 DESC`, + [user.id, startStr, endStr] + ); + + // Investments: any transaction categorised as investment + const investmentRows = await queryRaw<{ + month: string; + total_invested: number; + transaction_count: number; + }>( + `SELECT + TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month, + SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_invested, + COUNT(*)::int as transaction_count + FROM transactions t + LEFT JOIN transaction_overrides o ON o.transaction_id = t.id + JOIN statements s ON s.id = t.statement_id + WHERE s.owner_id = $1 + AND COALESCE(o.category_override, t.category) = 'investment' + AND t.transaction_date >= $2 + AND t.transaction_date < $3 + GROUP BY 1 + ORDER BY 1 DESC`, [user.id, startStr, endStr] ); @@ -62,50 +102,46 @@ export async function GET(req: NextRequest) { const spendMap = new Map(); const countMap = new Map(); - const budgetMap = new Map(); + const incomeMap = new Map(); + const investMap = new Map(); for (const r of spendRows) { spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent)); countMap.set(`${r.category}:${r.month}`, r.transaction_count); } - for (const r of budgetRows) { - budgetMap.set(`${r.category}:${r.month}`, Number(r.amount_limit)); - } + for (const r of incomeRows) incomeMap.set(r.month, Number(r.total_income)); + for (const r of investmentRows) investMap.set(r.month, Number(r.total_invested)); const allCategories = new Set(); for (const r of spendRows) allCategories.add(r.category); - for (const r of budgetRows) allCategories.add(r.category); const rows = Array.from(allCategories) .sort() .map((cat) => { const spent: Record = {}; - const budget: Record = {}; const txCount: Record = {}; for (const m of months) { const s = spendMap.get(`${cat}:${m}`); - const b = budgetMap.get(`${cat}:${m}`); const c = countMap.get(`${cat}:${m}`); if (s !== undefined) spent[m] = s; - if (b !== undefined) budget[m] = b; if (c !== undefined) txCount[m] = c; } - return { category: cat, spent, budget, txCount }; + return { category: cat, spent, txCount }; }); - const totals: Record = {}; + const totals: Record = {}; for (const m of months) { - let s = 0; - let b = 0; - for (const row of rows) { - s += row.spent[m] || 0; - b += row.budget[m] || 0; - } + let spent = 0; + for (const row of rows) spent += row.spent[m] || 0; + const income = incomeMap.get(m) || 0; + const investments = investMap.get(m) || 0; totals[m] = { - spent: Math.round(s * 100) / 100, - budget: Math.round(b * 100) / 100, + spent: Math.round(spent * 100) / 100, + income: Math.round(income * 100) / 100, + investments: Math.round(investments * 100) / 100, + net: Math.round((income - spent - investments) * 100) / 100, }; } - return NextResponse.json({ months, rows, totals }); + return NextResponse.json({ months, rows, income: Object.fromEntries(incomeMap), investments: Object.fromEntries(investMap), totals }); } diff --git a/src/app/budget/page.tsx b/src/app/budget/page.tsx index c510325..73b433d 100644 --- a/src/app/budget/page.tsx +++ b/src/app/budget/page.tsx @@ -9,6 +9,7 @@ import { Tooltip, ResponsiveContainer, Cell, + Legend, } from "recharts"; import { useMonthlyAnalytics } from "@/lib/hooks"; import { formatCategory } from "@/lib/categories"; @@ -50,6 +51,19 @@ function fmtExact(n: number): string { return `$${n.toFixed(2)}`; } +function deltaColor(n: number): string { + if (n > 0) return "text-red-400"; + if (n < 0) return "text-emerald-400"; + return ""; +} + +const TOOLTIP_STYLE = { + background: "#18181b", + border: "1px solid #3f3f46", + borderRadius: 8, + fontSize: 12, +}; + export default function AnalyticsPage() { const [selectedMonth, setSelectedMonth] = useState(currentMonthStr); const { data: analytics, isLoading } = useMonthlyAnalytics(6); @@ -63,18 +77,14 @@ export default function AnalyticsPage() { ); } - // Ensure selectedMonth is within the available range - const months = [...analytics.months].reverse(); // chronological order + const months = [...analytics.months].reverse(); // chronological - // Per-month totals (selected month) - const totalSpent = analytics.totals[selectedMonth]?.spent || 0; + const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 }; + const lastTotals = analytics.totals[prevMonth(selectedMonth)] ?? { spent: 0, income: 0, investments: 0, net: 0 }; - // Last month for comparison - const lastMonth = prevMonth(selectedMonth); - const lastMonthSpent = analytics.totals[lastMonth]?.spent || 0; - const delta = totalSpent - lastMonthSpent; + const spentDelta = totals.spent - lastTotals.spent; - // Category rows for selected month, sorted by spend + // Category rows for selected month const categoryRows = analytics.rows .filter((r) => (r.spent[selectedMonth] || 0) > 0) .map((r) => ({ @@ -86,18 +96,22 @@ export default function AnalyticsPage() { const largestCategory = categoryRows[0]; - // Data for monthly trend bar chart - const trendData = months.map((m) => ({ + // Cashflow bar chart data (chronological) + const cashflowData = months.map((m) => ({ month: m, label: formatShortMonth(m), - spent: analytics.totals[m]?.spent || 0, + income: analytics.totals[m]?.income || 0, + expenses: analytics.totals[m]?.spent || 0, + investments: analytics.totals[m]?.investments || 0, })); - // Data for horizontal category bar chart - const categoryChartData = [...categoryRows].reverse(); // smallest at top for readability - + // Category horizontal bar chart + const categoryChartData = [...categoryRows].reverse(); const chartHeight = Math.max(categoryChartData.length * 36, 120); + const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0); + const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0); + return (
{/* Header + month selector */} @@ -122,16 +136,41 @@ export default function AnalyticsPage() {
- {/* Summary cards */} -
+ {/* Cashflow summary cards */} +
+ {hasIncome && ( +
+

Income

+

{fmtExact(totals.income)}

+

received

+
+ )}
-

Total Spent

-

{fmtExact(totalSpent)}

-

split-adjusted

+

Expenses

+

{fmtExact(totals.spent)}

+

+ {spentDelta === 0 || lastTotals.spent === 0 + ? split-adjusted + : `${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`} +

+ {(hasInvestments || totals.investments > 0) && ( +
+

Invested

+

{fmtExact(totals.investments)}

+

shares / ETFs

+
+ )}
-

Largest Category

- {largestCategory ? ( +

{hasIncome ? "Net Cash" : "Largest Category"}

+ {hasIncome ? ( + <> +

= 0 ? "text-emerald-400" : "text-red-400"}`}> + {totals.net >= 0 ? "+" : ""}{fmtExact(totals.net)} +

+

income − expenses − invested

+ + ) : largestCategory ? ( <>

{fmtExact(largestCategory.spent)}

{formatCategory(largestCategory.category)}

@@ -140,33 +179,15 @@ export default function AnalyticsPage() {

)}
-
-

vs Last Month

- {lastMonthSpent > 0 ? ( - <> -

0 ? "text-red-400" : delta < 0 ? "text-emerald-400" : "" - }`} - > - {delta > 0 ? "+" : ""} - {fmtExact(delta)} -

-

- {delta > 0 ? "↑ more" : delta < 0 ? "↓ less" : "same"} than {formatShortMonth(lastMonth)} -

- - ) : ( -

- )} -
- {/* Monthly trend bar chart */} + {/* Cashflow bar chart — income vs expenses vs investments */}
-

Monthly Spending

- - +

+ {hasIncome ? "Cashflow — 6 Months" : "Monthly Spending"} +

+ + [fmtExact(Number(value)), "Spent"]} - contentStyle={{ - background: "#18181b", - border: "1px solid #3f3f46", - borderRadius: 8, - fontSize: 12, - }} + formatter={(value, name) => [fmtExact(Number(value)), name === "expenses" ? "Expenses" : name === "income" ? "Income" : "Invested"]} + contentStyle={TOOLTIP_STYLE} labelStyle={{ color: "#a1a1aa" }} cursor={{ fill: "rgba(255,255,255,0.04)" }} /> + {hasIncome && ( + <> + value === "income" ? "Income" : value === "expenses" ? "Expenses" : "Invested"} + wrapperStyle={{ fontSize: 12, color: "#71717a" }} + /> + + {hasInvestments && } + + )} setSelectedMonth((data as unknown as { month: string }).month)} style={{ cursor: "pointer" }} > - {trendData.map((entry) => ( + {cashflowData.map((entry) => ( ))} @@ -238,12 +264,7 @@ export default function AnalyticsPage() { /> [fmtExact(Number(value)), "Spent"]} - contentStyle={{ - background: "#18181b", - border: "1px solid #3f3f46", - borderRadius: 8, - fontSize: 12, - }} + contentStyle={TOOLTIP_STYLE} labelFormatter={(label) => formatCategory(String(label))} labelStyle={{ color: "#a1a1aa" }} cursor={{ fill: "rgba(255,255,255,0.04)" }} @@ -258,7 +279,7 @@ export default function AnalyticsPage() { {categoryRows.length > 0 && (
-

Category Breakdown — {formatMonth(selectedMonth)}

+

Spending Breakdown — {formatMonth(selectedMonth)}

@@ -276,7 +297,7 @@ export default function AnalyticsPage() { ))} @@ -330,8 +351,44 @@ export default function AnalyticsPage() { })} ))} + {hasIncome && ( + + + {analytics.months.map((m) => { + const inc = analytics.income[m]; + return ( + + ); + })} + + )} + {hasInvestments && ( + + + {analytics.months.map((m) => { + const inv = analytics.investments[m]; + return ( + + ); + })} + + )} - + {analytics.months.map((m) => { const t = analytics.totals[m]; return ( @@ -346,6 +403,25 @@ export default function AnalyticsPage() { ); })} + {hasIncome && ( + + + {analytics.months.map((m) => { + const t = analytics.totals[m]; + const net = t?.net || 0; + return ( + + ); + })} + + )}
{fmtExact(spent)} {txCount} - {totalSpent > 0 ? ((spent / totalSpent) * 100).toFixed(1) : "0.0"}% + {totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
+ Income + + {inc ? fmt(inc) : "—"} +
+ Invested + + {inv ? fmt(inv) : "—"} +
TotalExpenses
Net Cash= 0 ? "text-emerald-400" : "text-red-400" + } ${m === selectedMonth ? "bg-zinc-800/30" : ""}`} + > + {net >= 0 ? "+" : ""}{fmt(net)} +
diff --git a/src/lib/categories.ts b/src/lib/categories.ts index bc34f62..655e6bb 100644 --- a/src/lib/categories.ts +++ b/src/lib/categories.ts @@ -16,6 +16,7 @@ export const CATEGORIES = [ "rent", "transfers", "income", + "investment", "personal_care", "pets", "gifts", diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 490c041..75d260f 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -451,7 +451,9 @@ export interface MonthlyAnalyticsRow { export interface MonthlyAnalytics { months: string[]; rows: MonthlyAnalyticsRow[]; - totals: Record; + income: Record; + investments: Record; + totals: Record; } export function useBudgets(month: string) {