feat(analytics): stacked category chart, savings rate line, expandable rows
- Replace grouped cashflow BarChart with ComposedChart: expense categories as colour-coded stacked bars + amber savings-rate % line on right Y-axis - Add category colour legend below chart (matches stacked bars) - Horizontal category bar chart now uses per-category colours - Breakdown table: click any category row to expand/collapse individual transactions; each transaction has an inline category dropdown that calls PATCH /api/transactions/:id → transaction_overrides, then invalidates analytics query so totals update immediately
This commit is contained in:
+372
-73
@@ -2,17 +2,19 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ComposedChart,
|
||||
BarChart,
|
||||
Bar,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { useMonthlyAnalytics } from "@/lib/hooks";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||||
|
||||
function currentMonthStr(): string {
|
||||
const now = new Date();
|
||||
@@ -64,8 +66,165 @@ const TOOLTIP_STYLE = {
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
groceries: "#22c55e",
|
||||
dining: "#f97316",
|
||||
transport: "#06b6d4",
|
||||
fuel: "#eab308",
|
||||
shopping: "#ec4899",
|
||||
utilities: "#8b5cf6",
|
||||
entertainment: "#f43f5e",
|
||||
travel: "#0ea5e9",
|
||||
health: "#10b981",
|
||||
insurance: "#64748b",
|
||||
subscriptions: "#a78bfa",
|
||||
cash_advance: "#dc2626",
|
||||
government: "#78716c",
|
||||
education: "#3b82f6",
|
||||
rent: "#d97706",
|
||||
transfers: "#6b7280",
|
||||
income: "#34d399",
|
||||
investment: "#818cf8",
|
||||
personal_care: "#fb7185",
|
||||
pets: "#86efac",
|
||||
gifts: "#fcd34d",
|
||||
charity: "#a3e635",
|
||||
other: "#71717a",
|
||||
};
|
||||
|
||||
interface TooltipPayload {
|
||||
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;
|
||||
|
||||
return (
|
||||
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs min-w-36">
|
||||
<p className="text-zinc-400 mb-2 font-medium">{label}</p>
|
||||
{items.map((item) => {
|
||||
const name = item.name;
|
||||
let label = name;
|
||||
let valueStr = fmtExact(Number(item.value));
|
||||
const color = item.fill || item.stroke || "#71717a";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryPanel({
|
||||
category,
|
||||
selectedMonth,
|
||||
}: {
|
||||
category: string;
|
||||
selectedMonth: string;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const updateTx = useUpdateTransaction();
|
||||
|
||||
const from = `${selectedMonth}-01`;
|
||||
const [year, month] = selectedMonth.split("-").map(Number);
|
||||
const nextDate = new Date(year, month, 1);
|
||||
const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
|
||||
const { data, isLoading } = useTransactions({ category, from, to, limit: 200 });
|
||||
const txns = data?.data || [];
|
||||
|
||||
const handleCategoryChange = (id: number, newCategory: string) => {
|
||||
updateTx.mutate(
|
||||
{ id, category: newCategory },
|
||||
{
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-0 pb-2 bg-zinc-950/60 border-b border-zinc-800">
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-zinc-500 px-6 py-2">Loading transactions...</p>
|
||||
) : txns.length === 0 ? (
|
||||
<p className="text-xs text-zinc-600 px-6 py-2">No transactions found</p>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-zinc-600">
|
||||
<th className="text-left px-6 py-1 font-normal w-24">Date</th>
|
||||
<th className="text-left px-2 py-1 font-normal">Description</th>
|
||||
<th className="text-right px-2 py-1 font-normal w-24">Amount</th>
|
||||
<th className="text-right px-4 py-1 font-normal w-36">Category</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{txns.map((tx) => (
|
||||
<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">
|
||||
{tx.transaction_date.slice(5).replace("-", "/")}
|
||||
</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(tx.amount)}
|
||||
</td>
|
||||
<td className="px-4 py-1.5 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 cursor-pointer"
|
||||
defaultValue={tx.effective_category}
|
||||
onChange={(e) => handleCategoryChange(tx.id, e.target.value)}
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{formatCategory(cat)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
||||
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||||
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
||||
|
||||
if (isLoading || !analytics) {
|
||||
@@ -96,22 +255,34 @@ export default function AnalyticsPage() {
|
||||
|
||||
const largestCategory = categoryRows[0];
|
||||
|
||||
// Cashflow bar chart data (chronological)
|
||||
const cashflowData = months.map((m) => ({
|
||||
month: m,
|
||||
label: formatShortMonth(m),
|
||||
income: analytics.totals[m]?.income || 0,
|
||||
expenses: analytics.totals[m]?.spent || 0,
|
||||
investments: analytics.totals[m]?.investments || 0,
|
||||
}));
|
||||
|
||||
// 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);
|
||||
|
||||
// Categories that appear in expense data
|
||||
const expenseCategories = analytics.rows.map((r) => r.category);
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header + month selector */}
|
||||
@@ -136,7 +307,7 @@ export default function AnalyticsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cashflow summary cards */}
|
||||
{/* 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">
|
||||
@@ -149,9 +320,11 @@ export default function AnalyticsPage() {
|
||||
<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))}`}
|
||||
{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) && (
|
||||
@@ -165,8 +338,11 @@ export default function AnalyticsPage() {
|
||||
<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
|
||||
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>
|
||||
</>
|
||||
@@ -181,13 +357,36 @@ export default function AnalyticsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cashflow bar chart — income vs expenses vs investments */}
|
||||
{/* Cashflow chart — stacked expense categories + optional savings rate line */}
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<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 }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
{hasIncome ? "Cashflow — 6 Months" : "Monthly Spending by Category"}
|
||||
</h3>
|
||||
{hasIncome && (
|
||||
<div className="flex items-center gap-3 text-xs text-zinc-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-0.5 bg-amber-400 inline-block" />
|
||||
Savings Rate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded-sm bg-emerald-500 inline-block" />
|
||||
Income
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<ComposedChart
|
||||
data={cashflowData}
|
||||
margin={{ top: 4, right: hasIncome ? 48 : 8, bottom: 0, left: 8 }}
|
||||
onClick={(data) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const month = (data as any)?.activePayload?.[0]?.payload?.month as string | undefined;
|
||||
if (month) setSelectedMonth(month);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: "#71717a", fontSize: 12 }}
|
||||
@@ -195,43 +394,92 @@ export default function AnalyticsPage() {
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `$${v}`}
|
||||
width={56}
|
||||
/>
|
||||
<Tooltip
|
||||
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]} />}
|
||||
</>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
domain={[-20, 100]}
|
||||
width={36}
|
||||
/>
|
||||
)}
|
||||
<Bar
|
||||
dataKey="expenses"
|
||||
radius={[4, 4, 0, 0]}
|
||||
onClick={(data) => setSelectedMonth((data as unknown as { month: string }).month)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{cashflowData.map((entry) => (
|
||||
<Cell
|
||||
key={entry.month}
|
||||
fill={entry.month === selectedMonth ? "#6366f1" : (hasIncome ? "#ef4444" : "#3f3f46")}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
<Tooltip content={<CashflowTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
||||
|
||||
{/* Income bar (ungrouped, green) */}
|
||||
{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
|
||||
key={entry.month as string}
|
||||
fill={
|
||||
entry.month === selectedMonth
|
||||
? CATEGORY_COLORS[cat] || "#71717a"
|
||||
: `${CATEGORY_COLORS[cat] || "#71717a"}99`
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
))}
|
||||
|
||||
{/* Savings rate line */}
|
||||
{hasIncome && (
|
||||
<Line
|
||||
yAxisId="right"
|
||||
dataKey="savingsRate"
|
||||
stroke="#fbbf24"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#fbbf24", r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls={false}
|
||||
/>
|
||||
)}
|
||||
</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 */}
|
||||
@@ -269,17 +517,26 @@ export default function AnalyticsPage() {
|
||||
labelStyle={{ color: "#a1a1aa" }}
|
||||
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
||||
/>
|
||||
<Bar dataKey="spent" fill="#6366f1" radius={[0, 4, 4, 0]} />
|
||||
<Bar dataKey="spent" radius={[0, 4, 4, 0]}>
|
||||
{categoryChartData.map((entry) => (
|
||||
<Cell
|
||||
key={entry.category}
|
||||
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category breakdown table */}
|
||||
{/* Category breakdown table — with expandable rows */}
|
||||
{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">Spending Breakdown — {formatMonth(selectedMonth)}</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
Spending Breakdown — {formatMonth(selectedMonth)}
|
||||
</h3>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -291,16 +548,47 @@ export default function AnalyticsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categoryRows.map(({ category, spent, txCount }) => (
|
||||
<tr key={category} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-4 py-2.5 font-medium">{formatCategory(category)}</td>
|
||||
<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">
|
||||
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{categoryRows.map(({ category, spent, txCount }) => {
|
||||
const isExpanded = expandedCategory === category;
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
key={category}
|
||||
className={`border-b border-zinc-800/50 cursor-pointer select-none transition-colors ${
|
||||
isExpanded ? "bg-zinc-800/40" : "hover:bg-zinc-800/30"
|
||||
}`}
|
||||
onClick={() =>
|
||||
setExpandedCategory(isExpanded ? null : category)
|
||||
}
|
||||
>
|
||||
<td className="px-4 py-2.5 font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-sm shrink-0"
|
||||
style={{ background: CATEGORY_COLORS[category] || "#71717a" }}
|
||||
/>
|
||||
{formatCategory(category)}
|
||||
<span className="text-zinc-600 text-xs ml-1">
|
||||
{isExpanded ? "▲" : "▼"}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<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">
|
||||
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<CategoryPanel
|
||||
key={`panel-${category}`}
|
||||
category={category}
|
||||
selectedMonth={selectedMonth}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -334,7 +622,13 @@ export default function AnalyticsPage() {
|
||||
{analytics.rows.map((row) => (
|
||||
<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">
|
||||
{formatCategory(row.category)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-sm shrink-0"
|
||||
style={{ background: CATEGORY_COLORS[row.category] || "#71717a" }}
|
||||
/>
|
||||
{formatCategory(row.category)}
|
||||
</span>
|
||||
</td>
|
||||
{analytics.months.map((m) => {
|
||||
const spent = row.spent[m];
|
||||
@@ -361,7 +655,9 @@ export default function AnalyticsPage() {
|
||||
return (
|
||||
<td
|
||||
key={m}
|
||||
className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
||||
className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${
|
||||
m === selectedMonth ? "bg-zinc-800/30" : ""
|
||||
}`}
|
||||
>
|
||||
{inc ? fmt(inc) : "—"}
|
||||
</td>
|
||||
@@ -379,7 +675,9 @@ export default function AnalyticsPage() {
|
||||
return (
|
||||
<td
|
||||
key={m}
|
||||
className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
||||
className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${
|
||||
m === selectedMonth ? "bg-zinc-800/30" : ""
|
||||
}`}
|
||||
>
|
||||
{inv ? fmt(inv) : "—"}
|
||||
</td>
|
||||
@@ -416,7 +714,8 @@ export default function AnalyticsPage() {
|
||||
net >= 0 ? "text-emerald-400" : "text-red-400"
|
||||
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
||||
>
|
||||
{net >= 0 ? "+" : ""}{fmt(net)}
|
||||
{net >= 0 ? "+" : ""}
|
||||
{fmt(net)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user