feat(analytics): cashflow view with income/investment/net — split-adjusted + multi-currency

- Add 'investment' category (shares, ETFs, super)
- Analytics API: separate income, investment, expense queries; use amount_aud for FX-aware sums
- Analytics page: cashflow summary (income/expenses/invested/net cash), grouped bar chart,
  income + invested rows in 6-month trend table
- MonthlyAnalytics interface: add income, investments, net fields to totals
- DB: amount_aud + exchange_rate_to_aud columns added and backfilled (in prior migration)
This commit is contained in:
2026-03-08 19:15:20 +11:00
parent 5dbeb0cb87
commit d1a0eedf03
4 changed files with 206 additions and 91 deletions
+141 -65
View File
@@ -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 (
<div className="space-y-6">
{/* Header + month selector */}
@@ -122,16 +136,41 @@ export default function AnalyticsPage() {
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-3 gap-4">
{/* Cashflow summary cards */}
<div className={`grid gap-4 ${hasIncome ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3"}`}>
{hasIncome && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Income</p>
<p className="text-2xl font-semibold text-emerald-400">{fmtExact(totals.income)}</p>
<p className="text-xs text-zinc-500 mt-1">received</p>
</div>
)}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Total Spent</p>
<p className="text-2xl font-semibold">{fmtExact(totalSpent)}</p>
<p className="text-xs text-zinc-500 mt-1">split-adjusted</p>
<p className="text-xs text-zinc-500 mb-1">Expenses</p>
<p className="text-2xl font-semibold">{fmtExact(totals.spent)}</p>
<p className={`text-xs mt-1 ${deltaColor(spentDelta)}`}>
{spentDelta === 0 || lastTotals.spent === 0
? <span className="text-zinc-500">split-adjusted</span>
: `${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`}
</p>
</div>
{(hasInvestments || totals.investments > 0) && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Invested</p>
<p className="text-2xl font-semibold text-indigo-400">{fmtExact(totals.investments)}</p>
<p className="text-xs text-zinc-500 mt-1">shares / ETFs</p>
</div>
)}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Largest Category</p>
{largestCategory ? (
<p className="text-xs text-zinc-500 mb-1">{hasIncome ? "Net Cash" : "Largest Category"}</p>
{hasIncome ? (
<>
<p className={`text-2xl font-semibold ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
{totals.net >= 0 ? "+" : ""}{fmtExact(totals.net)}
</p>
<p className="text-xs text-zinc-500 mt-1">income expenses invested</p>
</>
) : largestCategory ? (
<>
<p className="text-2xl font-semibold">{fmtExact(largestCategory.spent)}</p>
<p className="text-xs text-zinc-400 mt-1">{formatCategory(largestCategory.category)}</p>
@@ -140,33 +179,15 @@ export default function AnalyticsPage() {
<p className="text-2xl font-semibold text-zinc-600"></p>
)}
</div>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">vs Last Month</p>
{lastMonthSpent > 0 ? (
<>
<p
className={`text-2xl font-semibold ${
delta > 0 ? "text-red-400" : delta < 0 ? "text-emerald-400" : ""
}`}
>
{delta > 0 ? "+" : ""}
{fmtExact(delta)}
</p>
<p className="text-xs text-zinc-500 mt-1">
{delta > 0 ? "↑ more" : delta < 0 ? "↓ less" : "same"} than {formatShortMonth(lastMonth)}
</p>
</>
) : (
<p className="text-2xl font-semibold text-zinc-600"></p>
)}
</div>
</div>
{/* Monthly trend bar chart */}
{/* Cashflow bar chart — income vs expenses vs investments */}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<h3 className="text-sm font-medium mb-4">Monthly Spending</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={trendData} margin={{ top: 0, right: 8, bottom: 0, left: 8 }}>
<h3 className="text-sm font-medium mb-4">
{hasIncome ? "Cashflow — 6 Months" : "Monthly Spending"}
</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={cashflowData} margin={{ top: 0, right: 8, bottom: 0, left: 8 }}>
<XAxis
dataKey="label"
tick={{ fill: "#71717a", fontSize: 12 }}
@@ -181,26 +202,31 @@ export default function AnalyticsPage() {
width={56}
/>
<Tooltip
formatter={(value) => [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 && (
<>
<Legend
formatter={(value) => value === "income" ? "Income" : value === "expenses" ? "Expenses" : "Invested"}
wrapperStyle={{ fontSize: 12, color: "#71717a" }}
/>
<Bar dataKey="income" fill="#34d399" radius={[4, 4, 0, 0]} />
{hasInvestments && <Bar dataKey="investments" fill="#818cf8" radius={[4, 4, 0, 0]} />}
</>
)}
<Bar
dataKey="spent"
dataKey="expenses"
radius={[4, 4, 0, 0]}
onClick={(data) => setSelectedMonth((data as unknown as { month: string }).month)}
style={{ cursor: "pointer" }}
>
{trendData.map((entry) => (
{cashflowData.map((entry) => (
<Cell
key={entry.month}
fill={entry.month === selectedMonth ? "#6366f1" : "#3f3f46"}
fill={entry.month === selectedMonth ? "#6366f1" : (hasIncome ? "#ef4444" : "#3f3f46")}
/>
))}
</Bar>
@@ -238,12 +264,7 @@ export default function AnalyticsPage() {
/>
<Tooltip
formatter={(value) => [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 && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-zinc-800">
<h3 className="text-sm font-medium">Category Breakdown {formatMonth(selectedMonth)}</h3>
<h3 className="text-sm font-medium">Spending Breakdown {formatMonth(selectedMonth)}</h3>
</div>
<table className="w-full text-sm">
<thead>
@@ -276,7 +297,7 @@ export default function AnalyticsPage() {
<td className="px-4 py-2.5 text-right tabular-nums">{fmtExact(spent)}</td>
<td className="px-4 py-2.5 text-right text-zinc-400">{txCount}</td>
<td className="px-4 py-2.5 text-right text-zinc-400 tabular-nums">
{totalSpent > 0 ? ((spent / totalSpent) * 100).toFixed(1) : "0.0"}%
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
</td>
</tr>
))}
@@ -330,8 +351,44 @@ export default function AnalyticsPage() {
})}
</tr>
))}
{hasIncome && (
<tr className="border-b border-zinc-800/40">
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-emerald-600">
Income
</td>
{analytics.months.map((m) => {
const inc = analytics.income[m];
return (
<td
key={m}
className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
>
{inc ? fmt(inc) : "—"}
</td>
);
})}
</tr>
)}
{hasInvestments && (
<tr className="border-b border-zinc-800/40">
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-indigo-500">
Invested
</td>
{analytics.months.map((m) => {
const inv = analytics.investments[m];
return (
<td
key={m}
className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
>
{inv ? fmt(inv) : "—"}
</td>
);
})}
</tr>
)}
<tr className="border-t-2 border-zinc-700 font-semibold bg-zinc-900/50">
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Total</td>
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Expenses</td>
{analytics.months.map((m) => {
const t = analytics.totals[m];
return (
@@ -346,6 +403,25 @@ export default function AnalyticsPage() {
);
})}
</tr>
{hasIncome && (
<tr className="font-semibold bg-zinc-900/50">
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Net Cash</td>
{analytics.months.map((m) => {
const t = analytics.totals[m];
const net = t?.net || 0;
return (
<td
key={m}
className={`px-3 py-2 text-right tabular-nums ${
net >= 0 ? "text-emerald-400" : "text-red-400"
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
>
{net >= 0 ? "+" : ""}{fmt(net)}
</td>
);
})}
</tr>
)}
</tbody>
</table>
</div>