feat(analytics): replace charts with category trend lines, Pareto chart, and cumulative spend
- Category spend trend lines (top 8 categories, 6-month view) replacing stacked bar chart - Pareto chart showing 80/20 spend concentration with cumulative % line - Cumulative spend chart tracking actual vs typical monthly pace - Fix: add amount_aud to TransactionRow interface
This commit is contained in:
+289
-385
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, Fragment } from "react";
|
import { useState, Fragment, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
BarChart,
|
LineChart,
|
||||||
Bar,
|
Bar,
|
||||||
Line,
|
Line,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Cell,
|
Cell,
|
||||||
|
ReferenceLine,
|
||||||
|
Legend,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||||
@@ -20,51 +22,33 @@ function currentMonthStr(): string {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prevMonth(m: string): string {
|
function prevMonth(m: string): string {
|
||||||
const [year, month] = m.split("-").map(Number);
|
const [year, month] = m.split("-").map(Number);
|
||||||
const d = new Date(year, month - 2, 1);
|
const d = new Date(year, month - 2, 1);
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextMonth(m: string): string {
|
function nextMonth(m: string): string {
|
||||||
const [year, month] = m.split("-").map(Number);
|
const [year, month] = m.split("-").map(Number);
|
||||||
const d = new Date(year, month, 1);
|
const d = new Date(year, month, 1);
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMonth(m: string): string {
|
function formatMonth(m: string): string {
|
||||||
const [year, month] = m.split("-");
|
const [year, month] = m.split("-");
|
||||||
const date = new Date(Number(year), Number(month) - 1, 1);
|
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "long", year: "numeric" });
|
||||||
return date.toLocaleString("default", { month: "long", year: "numeric" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatShortMonth(m: string): string {
|
function formatShortMonth(m: string): string {
|
||||||
const [year, month] = m.split("-");
|
const [year, month] = m.split("-");
|
||||||
const date = new Date(Number(year), Number(month) - 1, 1);
|
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" });
|
||||||
return date.toLocaleString("default", { month: "short" });
|
|
||||||
}
|
}
|
||||||
|
function fmt(n: number): string { return `$${n.toFixed(0)}`; }
|
||||||
function fmt(n: number): string {
|
function fmtExact(n: number): string { return `$${n.toFixed(2)}`; }
|
||||||
return `$${n.toFixed(0)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtExact(n: number): string {
|
|
||||||
return `$${n.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deltaColor(n: number): string {
|
function deltaColor(n: number): string {
|
||||||
if (n > 0) return "text-red-400";
|
if (n > 0) return "text-red-400";
|
||||||
if (n < 0) return "text-emerald-400";
|
if (n < 0) return "text-emerald-400";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOLTIP_STYLE = {
|
const TOOLTIP_STYLE = { background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 };
|
||||||
background: "#18181b",
|
|
||||||
border: "1px solid #3f3f46",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
groceries: "#22c55e",
|
groceries: "#22c55e",
|
||||||
@@ -90,95 +74,78 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
gifts: "#fcd34d",
|
gifts: "#fcd34d",
|
||||||
charity: "#a3e635",
|
charity: "#a3e635",
|
||||||
home_goods: "#67e8f9",
|
home_goods: "#67e8f9",
|
||||||
home_maintenance: "#a78bfa",
|
home_maintenance: "#c084fc",
|
||||||
other: "#71717a",
|
other: "#71717a",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TooltipPayload {
|
// ─── Tooltips ────────────────────────────────────────────────────────────────
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
fill?: string;
|
|
||||||
stroke?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CashflowTooltip({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: TooltipPayload[];
|
|
||||||
label?: string;
|
|
||||||
}) {
|
|
||||||
if (!active || !payload) return null;
|
|
||||||
const items = payload.filter((p) => p.value && Math.abs(p.value) > 0.01);
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
|
|
||||||
|
function TrendTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number; stroke: string }[]; label?: string }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const items = payload.filter((p) => p.value > 0.01).sort((a, b) => b.value - a.value);
|
||||||
|
if (!items.length) return null;
|
||||||
return (
|
return (
|
||||||
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs min-w-36">
|
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs min-w-40">
|
||||||
<p className="text-zinc-400 mb-2 font-medium">{label}</p>
|
<p className="text-zinc-400 mb-2 font-medium">{label}</p>
|
||||||
{items.map((item) => {
|
{items.map((p) => (
|
||||||
const name = item.name;
|
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
|
||||||
let label = name;
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: p.stroke }} />
|
||||||
let valueStr = fmtExact(Number(item.value));
|
<span className="text-zinc-400">{formatCategory(p.dataKey.replace("cat_", ""))}:</span>
|
||||||
const color = item.fill || item.stroke || "#71717a";
|
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{fmtExact(p.value)}</span>
|
||||||
|
</div>
|
||||||
if (name === "income") label = "Income";
|
))}
|
||||||
else if (name === "savingsRate") {
|
|
||||||
label = "Savings Rate";
|
|
||||||
valueStr = `${Number(item.value).toFixed(1)}%`;
|
|
||||||
} else if (name.startsWith("cat_")) {
|
|
||||||
label = formatCategory(name.slice(4));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={name} className="flex items-center gap-2 mb-0.5">
|
|
||||||
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: color }} />
|
|
||||||
<span className="text-zinc-400">{label}:</span>
|
|
||||||
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{valueStr}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryPanel({
|
function ParetoTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string; spent: number; pct: number; cumulative: number } }[] }) {
|
||||||
category,
|
if (!active || !payload?.length) return null;
|
||||||
selectedMonth,
|
const d = payload[0].payload;
|
||||||
}: {
|
return (
|
||||||
category: string;
|
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
|
||||||
selectedMonth: string;
|
<p className="font-medium text-zinc-300 mb-1">{formatCategory(d.category)}</p>
|
||||||
}) {
|
<div className="flex justify-between gap-4"><span className="text-zinc-400">Spend</span><span>{fmtExact(d.spent)}</span></div>
|
||||||
|
<div className="flex justify-between gap-4"><span className="text-zinc-400">Share</span><span>{d.pct}%</span></div>
|
||||||
|
<div className="flex justify-between gap-4"><span className="text-zinc-400">Cumulative</span><span>{d.cumulative}%</span></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CumulativeTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number; stroke: string }[]; label?: number }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
|
||||||
|
<p className="text-zinc-400 mb-1 font-medium">Day {label}</p>
|
||||||
|
{payload.map((p) => p.value != null && (
|
||||||
|
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: p.stroke }} />
|
||||||
|
<span className="text-zinc-400">{p.dataKey === "actual" ? "Actual" : "Typical pace"}:</span>
|
||||||
|
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{fmtExact(p.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CategoryPanel ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CategoryPanel({ category, selectedMonth }: { category: string; selectedMonth: string }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const updateTx = useUpdateTransaction();
|
const updateTx = useUpdateTransaction();
|
||||||
|
|
||||||
const from = `${selectedMonth}-01`;
|
const from = `${selectedMonth}-01`;
|
||||||
const [year, month] = selectedMonth.split("-").map(Number);
|
const [year, month] = selectedMonth.split("-").map(Number);
|
||||||
const nextDate = new Date(year, month, 1);
|
const nextDate = new Date(year, month, 1);
|
||||||
const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
|
||||||
const { data, isLoading } = useTransactions({ category, from, to, limit: 200 });
|
const { data, isLoading } = useTransactions({ category, from, to, limit: 200 });
|
||||||
const txns = data?.data || [];
|
const txns = data?.data || [];
|
||||||
|
|
||||||
const handleCategoryChange = (id: number, newCategory: string) => {
|
|
||||||
updateTx.mutate(
|
|
||||||
{ id, category: newCategory },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["analytics"] });
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-0 pb-2 bg-zinc-950/60 border-b border-zinc-800">
|
<td colSpan={4} className="px-0 pb-2 bg-zinc-950/60 border-b border-zinc-800">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-xs text-zinc-500 px-6 py-2">Loading transactions...</p>
|
<p className="text-xs text-zinc-500 px-6 py-2">Loading...</p>
|
||||||
) : txns.length === 0 ? (
|
) : txns.length === 0 ? (
|
||||||
<p className="text-xs text-zinc-600 px-6 py-2">No transactions found</p>
|
<p className="text-xs text-zinc-600 px-6 py-2">No transactions</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -192,25 +159,21 @@ function CategoryPanel({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{txns.map((tx) => (
|
{txns.map((tx) => (
|
||||||
<tr key={tx.id} className="border-t border-zinc-800/30 hover:bg-zinc-800/20">
|
<tr key={tx.id} className="border-t border-zinc-800/30 hover:bg-zinc-800/20">
|
||||||
<td className="px-6 py-1.5 text-zinc-500 tabular-nums">
|
<td className="px-6 py-1.5 text-zinc-500 tabular-nums">{tx.transaction_date.slice(5).replace("-", "/")}</td>
|
||||||
{tx.transaction_date.slice(5).replace("-", "/")}
|
<td className="px-2 py-1.5 text-zinc-300 max-w-xs truncate">{tx.effective_merchant || tx.description}</td>
|
||||||
</td>
|
<td className="px-2 py-1.5 text-right tabular-nums text-zinc-300">{fmtExact(Number(tx.amount))}</td>
|
||||||
<td className="px-2 py-1.5 text-zinc-300 max-w-xs truncate">
|
|
||||||
{tx.effective_merchant || tx.description}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-right tabular-nums text-zinc-300">
|
|
||||||
{fmtExact(Number(tx.amount))}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-1.5 text-right">
|
<td className="px-4 py-1.5 text-right">
|
||||||
<select
|
<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 cursor-pointer"
|
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 cursor-pointer"
|
||||||
defaultValue={tx.effective_category}
|
defaultValue={tx.effective_category}
|
||||||
onChange={(e) => handleCategoryChange(tx.id, e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateTx.mutate({ id: tx.id, category: e.target.value }, {
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["analytics"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{CATEGORIES.map((cat) => (
|
{CATEGORIES.map((cat) => (
|
||||||
<option key={cat} value={cat}>
|
<option key={cat} value={cat}>{formatCategory(cat)}</option>
|
||||||
{formatCategory(cat)}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@@ -224,11 +187,100 @@ function CategoryPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
export default function AnalyticsPage() {
|
||||||
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
||||||
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||||||
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
||||||
|
|
||||||
|
// Cumulative chart: fetch this month's transactions
|
||||||
|
const smFrom = `${selectedMonth}-01`;
|
||||||
|
const [smYear, smMonth] = selectedMonth.split("-").map(Number);
|
||||||
|
const smNextDate = new Date(smYear, smMonth, 1);
|
||||||
|
const smTo = `${smNextDate.getFullYear()}-${String(smNextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
const { data: monthTxData } = useTransactions({ from: smFrom, to: smTo, limit: 1000 });
|
||||||
|
|
||||||
|
const months = useMemo(() => analytics ? [...analytics.months].reverse() : [], [analytics]);
|
||||||
|
|
||||||
|
// Category rows for selected month
|
||||||
|
const categoryRows = useMemo(() => {
|
||||||
|
if (!analytics) return [];
|
||||||
|
return analytics.rows
|
||||||
|
.filter((r) => (r.spent[selectedMonth] || 0) > 0)
|
||||||
|
.map((r) => ({ category: r.category, spent: r.spent[selectedMonth] || 0, txCount: r.txCount[selectedMonth] || 0 }))
|
||||||
|
.sort((a, b) => b.spent - a.spent);
|
||||||
|
}, [analytics, selectedMonth]);
|
||||||
|
|
||||||
|
// Trend line data — top 8 categories by total 6-month spend
|
||||||
|
const trendData = useMemo(() => {
|
||||||
|
if (!analytics) return { data: [], categories: [] };
|
||||||
|
const categoryTotals = analytics.rows
|
||||||
|
.map((r) => ({ category: r.category, total: months.reduce((s, m) => s + (r.spent[m] || 0), 0) }))
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((r) => r.category);
|
||||||
|
|
||||||
|
const data = months.map((m) => {
|
||||||
|
const entry: Record<string, unknown> = { month: m, label: formatShortMonth(m) };
|
||||||
|
for (const cat of categoryTotals) {
|
||||||
|
const row = analytics.rows.find((r) => r.category === cat);
|
||||||
|
entry[`cat_${cat}`] = row?.spent[m] || 0;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
return { data, categories: categoryTotals };
|
||||||
|
}, [analytics, months]);
|
||||||
|
|
||||||
|
// Pareto chart data
|
||||||
|
const paretoData = useMemo(() => {
|
||||||
|
const total = categoryRows.reduce((s, r) => s + r.spent, 0);
|
||||||
|
let running = 0;
|
||||||
|
return categoryRows.map((r) => {
|
||||||
|
running += r.spent;
|
||||||
|
return {
|
||||||
|
category: r.category,
|
||||||
|
spent: r.spent,
|
||||||
|
pct: total > 0 ? Math.round((r.spent / total) * 1000) / 10 : 0,
|
||||||
|
cumulative: total > 0 ? Math.round((running / total) * 1000) / 10 : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [categoryRows]);
|
||||||
|
|
||||||
|
// Cumulative spend chart data
|
||||||
|
const cumulativeData = useMemo(() => {
|
||||||
|
const daysInMonth = new Date(smYear, smMonth, 0).getDate();
|
||||||
|
const isCurrentMonth = selectedMonth === currentMonthStr();
|
||||||
|
const today = new Date();
|
||||||
|
const lastDay = isCurrentMonth ? today.getDate() : daysInMonth;
|
||||||
|
|
||||||
|
// Daily spend from transactions
|
||||||
|
const daily: Record<number, number> = {};
|
||||||
|
(monthTxData?.data ?? [])
|
||||||
|
.filter((tx) => tx.transaction_type === "debit" && !["transfers", "investment"].includes(tx.effective_category))
|
||||||
|
.forEach((tx) => {
|
||||||
|
const day = new Date(tx.transaction_date).getDate();
|
||||||
|
daily[day] = (daily[day] || 0) + Number(tx.amount_aud ?? tx.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Typical pace: avg of prior months (straight-line ramp)
|
||||||
|
const priorMonths = analytics?.months.filter((m) => m !== selectedMonth) ?? [];
|
||||||
|
const priorAvg = priorMonths.length > 0
|
||||||
|
? priorMonths.reduce((s, m) => s + (analytics?.totals[m]?.spent || 0), 0) / priorMonths.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let cum = 0;
|
||||||
|
return Array.from({ length: daysInMonth }, (_, i) => {
|
||||||
|
const day = i + 1;
|
||||||
|
if (day <= lastDay) cum += daily[day] || 0;
|
||||||
|
return {
|
||||||
|
day,
|
||||||
|
actual: day <= lastDay ? Math.round(cum * 100) / 100 : null,
|
||||||
|
typical: Math.round((priorAvg * day / daysInMonth) * 100) / 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [monthTxData, analytics, selectedMonth, smYear, smMonth]);
|
||||||
|
|
||||||
if (isLoading || !analytics) {
|
if (isLoading || !analytics) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -238,74 +290,26 @@ export default function AnalyticsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const months = [...analytics.months].reverse(); // chronological
|
|
||||||
|
|
||||||
const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 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 };
|
const lastTotals = analytics.totals[prevMonth(selectedMonth)] ?? { spent: 0, income: 0, investments: 0, net: 0 };
|
||||||
|
|
||||||
const spentDelta = totals.spent - lastTotals.spent;
|
const spentDelta = totals.spent - lastTotals.spent;
|
||||||
|
|
||||||
// Category rows for selected month
|
|
||||||
const categoryRows = analytics.rows
|
|
||||||
.filter((r) => (r.spent[selectedMonth] || 0) > 0)
|
|
||||||
.map((r) => ({
|
|
||||||
category: r.category,
|
|
||||||
spent: r.spent[selectedMonth] || 0,
|
|
||||||
txCount: r.txCount[selectedMonth] || 0,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.spent - a.spent);
|
|
||||||
|
|
||||||
const largestCategory = categoryRows[0];
|
const largestCategory = categoryRows[0];
|
||||||
|
|
||||||
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
|
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
|
||||||
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
|
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
|
||||||
|
|
||||||
// Categories that appear in expense data
|
// Pareto: find where cumulative crosses 80%
|
||||||
const expenseCategories = analytics.rows.map((r) => r.category);
|
const pareto80idx = paretoData.findIndex((r) => r.cumulative >= 80);
|
||||||
|
|
||||||
// Cashflow chart data (chronological) — per-category amounts for stacking
|
|
||||||
const cashflowData = months.map((m) => {
|
|
||||||
const entry: Record<string, unknown> = {
|
|
||||||
month: m,
|
|
||||||
label: formatShortMonth(m),
|
|
||||||
income: analytics.income[m] || 0,
|
|
||||||
};
|
|
||||||
analytics.rows.forEach((row) => {
|
|
||||||
entry[`cat_${row.category}`] = row.spent[m] || 0;
|
|
||||||
});
|
|
||||||
const income = analytics.income[m] || 0;
|
|
||||||
const spent = analytics.totals[m]?.spent || 0;
|
|
||||||
const invested = analytics.investments[m] || 0;
|
|
||||||
entry.savingsRate =
|
|
||||||
income > 0 ? Math.round(((income - spent - invested) / income) * 100) : null;
|
|
||||||
return entry;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Horizontal category bar chart for selected month
|
|
||||||
const categoryChartData = [...categoryRows].reverse();
|
|
||||||
const chartHeight = Math.max(categoryChartData.length * 36, 120);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* Header + month selector */}
|
{/* Header + month selector */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">Analytics</h2>
|
<h2 className="text-xl font-semibold">Analytics</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button onClick={() => setSelectedMonth(prevMonth(selectedMonth))} className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none">‹</button>
|
||||||
onClick={() => setSelectedMonth(prevMonth(selectedMonth))}
|
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
|
||||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
<button onClick={() => setSelectedMonth(nextMonth(selectedMonth))} className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none">›</button>
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<span className="text-sm font-medium min-w-36 text-center">
|
|
||||||
{formatMonth(selectedMonth)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedMonth(nextMonth(selectedMonth))}
|
|
||||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -322,11 +326,9 @@ export default function AnalyticsPage() {
|
|||||||
<p className="text-xs text-zinc-500 mb-1">Expenses</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-2xl font-semibold">{fmtExact(totals.spent)}</p>
|
||||||
<p className={`text-xs mt-1 ${deltaColor(spentDelta)}`}>
|
<p className={`text-xs mt-1 ${deltaColor(spentDelta)}`}>
|
||||||
{spentDelta === 0 || lastTotals.spent === 0 ? (
|
{spentDelta === 0 || lastTotals.spent === 0
|
||||||
<span className="text-zinc-500">split-adjusted</span>
|
? <span className="text-zinc-500">split-adjusted</span>
|
||||||
) : (
|
: `${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`}
|
||||||
`${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{(hasInvestments || totals.investments > 0) && (
|
{(hasInvestments || totals.investments > 0) && (
|
||||||
@@ -340,11 +342,8 @@ export default function AnalyticsPage() {
|
|||||||
<p className="text-xs text-zinc-500 mb-1">{hasIncome ? "Net Cash" : "Largest Category"}</p>
|
<p className="text-xs text-zinc-500 mb-1">{hasIncome ? "Net Cash" : "Largest Category"}</p>
|
||||||
{hasIncome ? (
|
{hasIncome ? (
|
||||||
<>
|
<>
|
||||||
<p
|
<p className={`text-2xl font-semibold ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||||
className={`text-2xl font-semibold ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}
|
{totals.net >= 0 ? "+" : ""}{fmtExact(totals.net)}
|
||||||
>
|
|
||||||
{totals.net >= 0 ? "+" : ""}
|
|
||||||
{fmtExact(totals.net)}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-zinc-500 mt-1">income − expenses − invested</p>
|
<p className="text-xs text-zinc-500 mt-1">income − expenses − invested</p>
|
||||||
</>
|
</>
|
||||||
@@ -359,51 +358,79 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cashflow chart — stacked expense categories + optional savings rate line */}
|
{/* 1. Category trend lines */}
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
{trendData.categories.length > 0 && (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
<h3 className="text-sm font-medium">
|
<h3 className="text-sm font-medium mb-4">Category Trends — 6 Months</h3>
|
||||||
{hasIncome ? "Cashflow — 6 Months" : "Monthly Spending by Category"}
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
</h3>
|
<LineChart
|
||||||
{hasIncome && (
|
data={trendData.data}
|
||||||
<div className="flex items-center gap-3 text-xs text-zinc-500">
|
margin={{ top: 4, right: 8, bottom: 0, left: 8 }}
|
||||||
<span className="flex items-center gap-1">
|
onClick={(d) => {
|
||||||
<span className="w-3 h-0.5 bg-amber-400 inline-block" />
|
const month = (d as any)?.activePayload?.[0]?.payload?.month as string | undefined;
|
||||||
Savings Rate
|
if (month) setSelectedMonth(month);
|
||||||
|
}}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<XAxis dataKey="label" tick={{ fill: "#71717a", fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${v}`} width={52} />
|
||||||
|
<Tooltip content={<TrendTooltip />} cursor={{ stroke: "rgba(255,255,255,0.08)", strokeWidth: 1 }} />
|
||||||
|
{trendData.categories.map((cat) => (
|
||||||
|
<Line
|
||||||
|
key={cat}
|
||||||
|
dataKey={`cat_${cat}`}
|
||||||
|
name={cat}
|
||||||
|
stroke={CATEGORY_COLORS[cat] || "#71717a"}
|
||||||
|
strokeWidth={selectedMonth && trendData.data.some((d) => (d as any).month === selectedMonth) ? 2 : 2}
|
||||||
|
dot={{ fill: CATEGORY_COLORS[cat] || "#71717a", r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-3 pt-3 border-t border-zinc-800">
|
||||||
|
{trendData.categories.map((cat) => (
|
||||||
|
<span key={cat} className="flex items-center gap-1 text-xs text-zinc-500">
|
||||||
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[cat] || "#71717a" }} />
|
||||||
|
{formatCategory(cat)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
))}
|
||||||
<span className="w-2.5 h-2.5 rounded-sm bg-emerald-500 inline-block" />
|
{analytics.rows.length > 8 && (
|
||||||
Income
|
<span className="text-xs text-zinc-600">+ {analytics.rows.length - 8} more in table below</span>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
)}
|
||||||
<ComposedChart
|
|
||||||
data={cashflowData}
|
{/* 2. Pareto chart */}
|
||||||
margin={{ top: 4, right: hasIncome ? 48 : 8, bottom: 0, left: 8 }}
|
{paretoData.length > 0 && (
|
||||||
onClick={(data) => {
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
<div className="flex items-center justify-between mb-4">
|
||||||
const month = (data as any)?.activePayload?.[0]?.payload?.month as string | undefined;
|
<h3 className="text-sm font-medium">Spend Concentration — {formatMonth(selectedMonth)}</h3>
|
||||||
if (month) setSelectedMonth(month);
|
{pareto80idx >= 0 && (
|
||||||
}}
|
<span className="text-xs text-zinc-500">
|
||||||
style={{ cursor: "pointer" }}
|
Top {pareto80idx + 1} categor{pareto80idx === 0 ? "y" : "ies"} = 80% of spend
|
||||||
>
|
</span>
|
||||||
<XAxis
|
)}
|
||||||
dataKey="label"
|
</div>
|
||||||
tick={{ fill: "#71717a", fontSize: 12 }}
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
axisLine={false}
|
<ComposedChart data={paretoData} margin={{ top: 4, right: 48, bottom: 0, left: 8 }}>
|
||||||
tickLine={false}
|
<XAxis
|
||||||
/>
|
dataKey="category"
|
||||||
<YAxis
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
yAxisId="left"
|
axisLine={false}
|
||||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
tickLine={false}
|
||||||
axisLine={false}
|
tickFormatter={formatCategory}
|
||||||
tickLine={false}
|
/>
|
||||||
tickFormatter={(v) => `$${v}`}
|
<YAxis
|
||||||
width={56}
|
yAxisId="left"
|
||||||
/>
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
{hasIncome && (
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v) => `$${v}`}
|
||||||
|
width={52}
|
||||||
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
@@ -411,134 +438,62 @@ export default function AnalyticsPage() {
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickFormatter={(v) => `${v}%`}
|
tickFormatter={(v) => `${v}%`}
|
||||||
domain={[-20, 100]}
|
domain={[0, 100]}
|
||||||
width={36}
|
width={36}
|
||||||
/>
|
/>
|
||||||
)}
|
<Tooltip content={<ParetoTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
||||||
<Tooltip content={<CashflowTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
<ReferenceLine yAxisId="right" y={80} stroke="#52525b" strokeDasharray="4 2" label={{ value: "80%", fill: "#71717a", fontSize: 10, position: "right" }} />
|
||||||
|
<Bar yAxisId="left" dataKey="spent" radius={[3, 3, 0, 0]} maxBarSize={40}>
|
||||||
{/* Income bar (ungrouped, green) */}
|
{paretoData.map((entry, i) => (
|
||||||
{hasIncome && (
|
|
||||||
<Bar
|
|
||||||
yAxisId="left"
|
|
||||||
dataKey="income"
|
|
||||||
fill="#22c55e"
|
|
||||||
radius={[3, 3, 0, 0]}
|
|
||||||
maxBarSize={32}
|
|
||||||
opacity={0.85}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stacked expense category bars */}
|
|
||||||
{expenseCategories.map((cat, i) => (
|
|
||||||
<Bar
|
|
||||||
key={cat}
|
|
||||||
yAxisId="left"
|
|
||||||
dataKey={`cat_${cat}`}
|
|
||||||
stackId="expenses"
|
|
||||||
fill={CATEGORY_COLORS[cat] || "#71717a"}
|
|
||||||
radius={i === expenseCategories.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]}
|
|
||||||
maxBarSize={32}
|
|
||||||
>
|
|
||||||
{cashflowData.map((entry) => (
|
|
||||||
<Cell
|
<Cell
|
||||||
key={entry.month as string}
|
key={entry.category}
|
||||||
fill={
|
fill={i <= pareto80idx ? (CATEGORY_COLORS[entry.category] || "#6366f1") : "#3f3f46"}
|
||||||
entry.month === selectedMonth
|
|
||||||
? CATEGORY_COLORS[cat] || "#71717a"
|
|
||||||
: `${CATEGORY_COLORS[cat] || "#71717a"}99`
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Savings rate line */}
|
|
||||||
{hasIncome && (
|
|
||||||
<Line
|
<Line
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
dataKey="savingsRate"
|
dataKey="cumulative"
|
||||||
stroke="#fbbf24"
|
stroke="#fbbf24"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ fill: "#fbbf24", r: 3 }}
|
dot={{ fill: "#fbbf24", r: 3 }}
|
||||||
activeDot={{ r: 5 }}
|
activeDot={{ r: 5 }}
|
||||||
connectNulls={false}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</ComposedChart>
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
|
|
||||||
{/* Category color legend */}
|
|
||||||
{expenseCategories.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-3 pt-3 border-t border-zinc-800">
|
|
||||||
{expenseCategories.map((cat) => (
|
|
||||||
<span key={cat} className="flex items-center gap-1 text-xs text-zinc-500">
|
|
||||||
<span
|
|
||||||
className="w-2 h-2 rounded-sm shrink-0"
|
|
||||||
style={{ background: CATEGORY_COLORS[cat] || "#71717a" }}
|
|
||||||
/>
|
|
||||||
{formatCategory(cat)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spending by category — horizontal bar chart */}
|
|
||||||
{categoryChartData.length > 0 && (
|
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
|
||||||
<h3 className="text-sm font-medium mb-4">
|
|
||||||
Spending by Category — {formatMonth(selectedMonth)}
|
|
||||||
</h3>
|
|
||||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
|
||||||
<BarChart
|
|
||||||
data={categoryChartData}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ top: 0, right: 8, bottom: 0, left: 8 }}
|
|
||||||
>
|
|
||||||
<XAxis
|
|
||||||
type="number"
|
|
||||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
tickFormatter={(v) => `$${v}`}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="category"
|
|
||||||
width={120}
|
|
||||||
tick={{ fill: "#a1a1aa", fontSize: 12 }}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
tickFormatter={formatCategory}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value) => [fmtExact(Number(value)), "Spent"]}
|
|
||||||
contentStyle={TOOLTIP_STYLE}
|
|
||||||
labelFormatter={(label) => formatCategory(String(label))}
|
|
||||||
labelStyle={{ color: "#a1a1aa" }}
|
|
||||||
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="spent" radius={[0, 4, 4, 0]}>
|
|
||||||
{categoryChartData.map((entry) => (
|
|
||||||
<Cell
|
|
||||||
key={entry.category}
|
|
||||||
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
<div className="flex items-center gap-4 mt-2 justify-end">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-0.5 bg-amber-400 inline-block" />Cumulative %</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" />Below 80% threshold</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Category breakdown table — with expandable rows */}
|
{/* 3. Cumulative spend this month */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium">Cumulative Spend — {formatMonth(selectedMonth)}</h3>
|
||||||
|
<span className="text-xs text-zinc-500">vs avg monthly pace</span>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<LineChart data={cumulativeData} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
|
||||||
|
<XAxis dataKey="day" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `${v}`} interval={4} />
|
||||||
|
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(1)}k`} width={44} />
|
||||||
|
<Tooltip content={<CumulativeTooltip />} cursor={{ stroke: "rgba(255,255,255,0.08)", strokeWidth: 1 }} />
|
||||||
|
<Line dataKey="typical" stroke="#3f3f46" strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="typical" />
|
||||||
|
<Line dataKey="actual" stroke="#6366f1" strokeWidth={2} dot={false} activeDot={{ r: 4 }} connectNulls={false} name="actual" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex items-center gap-4 mt-2 justify-end">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-4 h-0.5 bg-indigo-500 inline-block" />This month</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-4 h-0.5 bg-zinc-600 inline-block" style={{ borderTop: "1px dashed #52525b", display: "inline-block" }} />Typical pace</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category breakdown table — expandable rows */}
|
||||||
{categoryRows.length > 0 && (
|
{categoryRows.length > 0 && (
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-zinc-800">
|
<div className="px-4 py-3 border-b border-zinc-800">
|
||||||
<h3 className="text-sm font-medium">
|
<h3 className="text-sm font-medium">Spending Breakdown — {formatMonth(selectedMonth)}</h3>
|
||||||
Spending Breakdown — {formatMonth(selectedMonth)}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -555,23 +510,14 @@ export default function AnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<Fragment key={category}>
|
<Fragment key={category}>
|
||||||
<tr
|
<tr
|
||||||
className={`border-b border-zinc-800/50 cursor-pointer select-none transition-colors ${
|
className={`border-b border-zinc-800/50 cursor-pointer select-none transition-colors ${isExpanded ? "bg-zinc-800/40" : "hover:bg-zinc-800/30"}`}
|
||||||
isExpanded ? "bg-zinc-800/40" : "hover:bg-zinc-800/30"
|
onClick={() => setExpandedCategory(isExpanded ? null : category)}
|
||||||
}`}
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedCategory(isExpanded ? null : category)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2.5 font-medium">
|
<td className="px-4 py-2.5 font-medium">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[category] || "#71717a" }} />
|
||||||
className="w-2 h-2 rounded-sm shrink-0"
|
|
||||||
style={{ background: CATEGORY_COLORS[category] || "#71717a" }}
|
|
||||||
/>
|
|
||||||
{formatCategory(category)}
|
{formatCategory(category)}
|
||||||
<span className="text-zinc-600 text-xs ml-1">
|
<span className="text-zinc-600 text-xs ml-1">{isExpanded ? "▲" : "▼"}</span>
|
||||||
{isExpanded ? "▲" : "▼"}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right tabular-nums">{fmtExact(spent)}</td>
|
<td className="px-4 py-2.5 text-right tabular-nums">{fmtExact(spent)}</td>
|
||||||
@@ -580,12 +526,7 @@ export default function AnalyticsPage() {
|
|||||||
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
|
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExpanded && (
|
{isExpanded && <CategoryPanel category={category} selectedMonth={selectedMonth} />}
|
||||||
<CategoryPanel
|
|
||||||
category={category}
|
|
||||||
selectedMonth={selectedMonth}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -602,15 +543,11 @@ export default function AnalyticsPage() {
|
|||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||||
<th className="text-left px-3 py-2 text-zinc-500 font-medium sticky left-0 bg-zinc-900 min-w-32">
|
<th className="text-left px-3 py-2 text-zinc-500 font-medium sticky left-0 bg-zinc-900 min-w-32">Category</th>
|
||||||
Category
|
|
||||||
</th>
|
|
||||||
{analytics.months.map((m) => (
|
{analytics.months.map((m) => (
|
||||||
<th
|
<th
|
||||||
key={m}
|
key={m}
|
||||||
className={`text-right px-3 py-2 font-medium whitespace-nowrap cursor-pointer hover:text-zinc-300 ${
|
className={`text-right px-3 py-2 font-medium whitespace-nowrap cursor-pointer hover:text-zinc-300 ${m === selectedMonth ? "text-indigo-400" : "text-zinc-500"}`}
|
||||||
m === selectedMonth ? "text-indigo-400" : "text-zinc-500"
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedMonth(m)}
|
onClick={() => setSelectedMonth(m)}
|
||||||
>
|
>
|
||||||
{formatShortMonth(m)}
|
{formatShortMonth(m)}
|
||||||
@@ -623,22 +560,14 @@ export default function AnalyticsPage() {
|
|||||||
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
|
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
|
||||||
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span
|
<span className="w-1.5 h-1.5 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[row.category] || "#71717a" }} />
|
||||||
className="w-1.5 h-1.5 rounded-sm shrink-0"
|
|
||||||
style={{ background: CATEGORY_COLORS[row.category] || "#71717a" }}
|
|
||||||
/>
|
|
||||||
{formatCategory(row.category)}
|
{formatCategory(row.category)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{analytics.months.map((m) => {
|
{analytics.months.map((m) => {
|
||||||
const spent = row.spent[m];
|
const spent = row.spent[m];
|
||||||
return (
|
return (
|
||||||
<td
|
<td key={m} className={`px-3 py-2 text-right tabular-nums ${spent === undefined ? "text-zinc-700" : "text-zinc-300"} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
|
||||||
key={m}
|
|
||||||
className={`px-3 py-2 text-right tabular-nums ${
|
|
||||||
spent === undefined ? "text-zinc-700" : "text-zinc-300"
|
|
||||||
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
|
||||||
>
|
|
||||||
{spent !== undefined ? fmt(spent) : "—"}
|
{spent !== undefined ? fmt(spent) : "—"}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -647,18 +576,11 @@ export default function AnalyticsPage() {
|
|||||||
))}
|
))}
|
||||||
{hasIncome && (
|
{hasIncome && (
|
||||||
<tr className="border-b border-zinc-800/40">
|
<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">
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-emerald-600">Income</td>
|
||||||
Income
|
|
||||||
</td>
|
|
||||||
{analytics.months.map((m) => {
|
{analytics.months.map((m) => {
|
||||||
const inc = analytics.income[m];
|
const inc = analytics.income[m];
|
||||||
return (
|
return (
|
||||||
<td
|
<td key={m} className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
|
||||||
key={m}
|
|
||||||
className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${
|
|
||||||
m === selectedMonth ? "bg-zinc-800/30" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{inc ? fmt(inc) : "—"}
|
{inc ? fmt(inc) : "—"}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -667,18 +589,11 @@ export default function AnalyticsPage() {
|
|||||||
)}
|
)}
|
||||||
{hasInvestments && (
|
{hasInvestments && (
|
||||||
<tr className="border-b border-zinc-800/40">
|
<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">
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-indigo-500">Invested</td>
|
||||||
Invested
|
|
||||||
</td>
|
|
||||||
{analytics.months.map((m) => {
|
{analytics.months.map((m) => {
|
||||||
const inv = analytics.investments[m];
|
const inv = analytics.investments[m];
|
||||||
return (
|
return (
|
||||||
<td
|
<td key={m} className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
|
||||||
key={m}
|
|
||||||
className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${
|
|
||||||
m === selectedMonth ? "bg-zinc-800/30" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{inv ? fmt(inv) : "—"}
|
{inv ? fmt(inv) : "—"}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -690,12 +605,7 @@ export default function AnalyticsPage() {
|
|||||||
{analytics.months.map((m) => {
|
{analytics.months.map((m) => {
|
||||||
const t = analytics.totals[m];
|
const t = analytics.totals[m];
|
||||||
return (
|
return (
|
||||||
<td
|
<td key={m} className={`px-3 py-2 text-right tabular-nums ${m === selectedMonth ? "bg-zinc-800/30 text-indigo-300" : ""}`}>
|
||||||
key={m}
|
|
||||||
className={`px-3 py-2 text-right tabular-nums ${
|
|
||||||
m === selectedMonth ? "bg-zinc-800/30 text-indigo-300" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{fmt(t?.spent || 0)}
|
{fmt(t?.spent || 0)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -708,14 +618,8 @@ export default function AnalyticsPage() {
|
|||||||
const t = analytics.totals[m];
|
const t = analytics.totals[m];
|
||||||
const net = t?.net || 0;
|
const net = t?.net || 0;
|
||||||
return (
|
return (
|
||||||
<td
|
<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" : ""}`}>
|
||||||
key={m}
|
{net >= 0 ? "+" : ""}{fmt(net)}
|
||||||
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>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface TransactionRow {
|
|||||||
transaction_date: string;
|
transaction_date: string;
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
amount_aud: number | null;
|
||||||
transaction_type: string;
|
transaction_type: string;
|
||||||
merchant_name: string | null;
|
merchant_name: string | null;
|
||||||
merchant_normalized: string | null;
|
merchant_normalized: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user