App-wide retheme done at the token layer: Tailwind's zinc scale is remapped to warm ink/paper neutrals and indigo to copper in globals.css, so every page inherits the palette. Fraunces (serif display) added for page titles and hero figures; all figures now render in mono with tabular numerals. Analytics page redesigned around a month spine — twelve clickable columns scaled to each month's spend that act as hero, context, and period navigation. Adds a 'What changed' top-movers panel vs the previous month, replaces the 8-line category trend chart with per-category sparkline small multiples, heat-tints the six-month ledger table, and restyles the Pareto, pace chart, and cashflow strip. Kept: Pareto, cumulative-vs-typical pace, drill-downs, regular/occasional split. Insights and Merchants restyled to the same kit; chart tokens centralised in category-colors.ts (CHART). Cleared the pre-existing lint errors in insights (typed tooltip, removed any-casts).
This commit is contained in:
+282
-217
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, Fragment, useMemo } from "react";
|
import { useState, useEffect, Fragment, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
LineChart,
|
LineChart,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
Bar,
|
Bar,
|
||||||
Line,
|
Line,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -12,12 +14,11 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Cell,
|
Cell,
|
||||||
ReferenceLine,
|
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";
|
||||||
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||||||
import { CATEGORY_COLORS, TOOLTIP_STYLE } from "@/lib/category-colors";
|
import { CATEGORY_COLORS, CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||||
|
|
||||||
function currentMonthStr(): string {
|
function currentMonthStr(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -28,11 +29,6 @@ function prevMonth(m: string): string {
|
|||||||
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 {
|
|
||||||
const [year, month] = m.split("-").map(Number);
|
|
||||||
const d = new Date(year, month, 1);
|
|
||||||
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("-");
|
||||||
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "long", year: "numeric" });
|
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "long", year: "numeric" });
|
||||||
@@ -41,44 +37,21 @@ function formatShortMonth(m: string): string {
|
|||||||
const [year, month] = m.split("-");
|
const [year, month] = m.split("-");
|
||||||
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" });
|
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" });
|
||||||
}
|
}
|
||||||
function fmt(n: number): string { return `$${n.toFixed(0)}`; }
|
function fmt(n: number): string { return `$${Math.round(n).toLocaleString()}`; }
|
||||||
function fmtExact(n: number): string { return `$${n.toFixed(2)}`; }
|
function fmtExact(n: number): string { return `$${n.toFixed(2)}`; }
|
||||||
function deltaColor(n: number): string {
|
function fmtSigned(n: number): string { return `${n >= 0 ? "+" : "−"}$${Math.abs(n) >= 100 ? Math.round(Math.abs(n)).toLocaleString() : Math.abs(n).toFixed(0)}`; }
|
||||||
if (n > 0) return "text-red-400";
|
|
||||||
if (n < 0) return "text-emerald-400";
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ─── Tooltips ────────────────────────────────────────────────────────────────
|
// ─── Tooltips ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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 (
|
|
||||||
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs min-w-40">
|
|
||||||
<p className="text-zinc-400 mb-2 font-medium">{label}</p>
|
|
||||||
{items.map((p) => (
|
|
||||||
<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">{formatCategory(p.dataKey.replace("cat_", ""))}:</span>
|
|
||||||
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{fmtExact(p.value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ParetoTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string; spent: number; pct: number; cumulative: number } }[] }) {
|
function ParetoTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string; spent: number; pct: number; cumulative: number } }[] }) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
const d = payload[0].payload;
|
const d = payload[0].payload;
|
||||||
return (
|
return (
|
||||||
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
|
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
|
||||||
<p className="font-medium text-zinc-300 mb-1">{formatCategory(d.category)}</p>
|
<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">Spend</span><span className="font-mono">{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">Share</span><span className="font-mono">{d.pct}%</span></div>
|
||||||
<div className="flex justify-between gap-4"><span className="text-zinc-400">Cumulative</span><span>{d.cumulative}%</span></div>
|
<div className="flex justify-between gap-4"><span className="text-zinc-400">Cumulative</span><span className="font-mono">{d.cumulative}%</span></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,14 +65,59 @@ function CumulativeTooltip({ active, payload, label }: { active?: boolean; paylo
|
|||||||
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
|
<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="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-400">{p.dataKey === "actual" ? "Actual" : "Typical pace"}:</span>
|
||||||
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{fmtExact(p.value)}</span>
|
<span className="text-zinc-100 font-mono tabular-nums ml-auto pl-2">{fmtExact(p.value)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── CategoryPanel ────────────────────────────────────────────────────────────
|
// ─── Month spine ─────────────────────────────────────────────────────────────
|
||||||
|
// Twelve clickable columns, one per month, scaled to that month's spend.
|
||||||
|
// Doubles as period navigation and year-at-a-glance context.
|
||||||
|
|
||||||
|
function MonthSpine({
|
||||||
|
months,
|
||||||
|
totals,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
months: string[]; // ascending
|
||||||
|
totals: Record<string, { spent: number }>;
|
||||||
|
selected: string;
|
||||||
|
onSelect: (m: string) => void;
|
||||||
|
}) {
|
||||||
|
const max = Math.max(...months.map((m) => totals[m]?.spent || 0), 1);
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-1 sm:gap-1.5">
|
||||||
|
{months.map((m) => {
|
||||||
|
const spent = totals[m]?.spent || 0;
|
||||||
|
const h = Math.max(4, Math.round((spent / max) * 56));
|
||||||
|
const active = m === selected;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => onSelect(m)}
|
||||||
|
title={`${formatMonth(m)} — ${fmt(spent)}`}
|
||||||
|
className="group flex-1 flex flex-col items-center gap-1.5 min-w-0"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ height: h }}
|
||||||
|
className={`w-full max-w-9 rounded-t-sm transition-colors ${
|
||||||
|
active ? "bg-indigo-400" : "bg-zinc-700 group-hover:bg-zinc-600"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className={`text-[10px] leading-none uppercase tracking-wide ${active ? "text-indigo-300" : "text-zinc-500 group-hover:text-zinc-400"}`}>
|
||||||
|
{formatShortMonth(m)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CategoryPanel (drill-down) ──────────────────────────────────────────────
|
||||||
|
|
||||||
function CategoryPanel({ category, selectedMonth }: { category: string; selectedMonth: string }) {
|
function CategoryPanel({ category, selectedMonth }: { category: string; selectedMonth: string }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -115,7 +133,7 @@ function CategoryPanel({ category, selectedMonth }: { category: string; selected
|
|||||||
<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...</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</p>
|
<p className="text-xs text-zinc-600 px-6 py-2">No transactions</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -131,9 +149,9 @@ function CategoryPanel({ category, selectedMonth }: { category: string; selected
|
|||||||
<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">{tx.transaction_date.slice(5).replace("-", "/")}</td>
|
<td className="px-6 py-1.5 text-zinc-500 font-mono 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-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-2 py-1.5 text-right font-mono 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"
|
||||||
@@ -164,7 +182,18 @@ function CategoryPanel({ category, selectedMonth }: { category: string; selected
|
|||||||
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(12);
|
||||||
|
|
||||||
|
const months = useMemo(() => analytics ? [...analytics.months].reverse() : [], [analytics]);
|
||||||
|
|
||||||
|
// If the initial month has no data yet (e.g. the 1st of the month), land on
|
||||||
|
// the most recent month that does.
|
||||||
|
useEffect(() => {
|
||||||
|
if (months.length && !months.includes(selectedMonth)) {
|
||||||
|
setSelectedMonth(months[months.length - 1]);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [months]);
|
||||||
|
|
||||||
// Cumulative chart: fetch this month's transactions
|
// Cumulative chart: fetch this month's transactions
|
||||||
const smFrom = `${selectedMonth}-01`;
|
const smFrom = `${selectedMonth}-01`;
|
||||||
@@ -173,8 +202,6 @@ export default function AnalyticsPage() {
|
|||||||
const smTo = `${smNextDate.getFullYear()}-${String(smNextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
const smTo = `${smNextDate.getFullYear()}-${String(smNextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
const { data: monthTxData } = useTransactions({ from: smFrom, to: smTo, limit: 1000 });
|
const { data: monthTxData } = useTransactions({ from: smFrom, to: smTo, limit: 1000 });
|
||||||
|
|
||||||
const months = useMemo(() => analytics ? [...analytics.months].reverse() : [], [analytics]);
|
|
||||||
|
|
||||||
// Category rows for selected month
|
// Category rows for selected month
|
||||||
const categoryRows = useMemo(() => {
|
const categoryRows = useMemo(() => {
|
||||||
if (!analytics) return [];
|
if (!analytics) return [];
|
||||||
@@ -184,25 +211,37 @@ export default function AnalyticsPage() {
|
|||||||
.sort((a, b) => b.spent - a.spent);
|
.sort((a, b) => b.spent - a.spent);
|
||||||
}, [analytics, selectedMonth]);
|
}, [analytics, selectedMonth]);
|
||||||
|
|
||||||
// Trend line data — top 8 categories by total 6-month spend
|
// Small multiples — top 8 categories by 12-month total
|
||||||
const trendData = useMemo(() => {
|
const sparkData = useMemo(() => {
|
||||||
if (!analytics) return { data: [], categories: [] };
|
if (!analytics) return [];
|
||||||
const categoryTotals = analytics.rows
|
return analytics.rows
|
||||||
.map((r) => ({ category: r.category, total: months.reduce((s, m) => s + (r.spent[m] || 0), 0) }))
|
.map((r) => ({
|
||||||
|
category: r.category,
|
||||||
|
total: months.reduce((s, m) => s + (r.spent[m] || 0), 0),
|
||||||
|
thisMonth: r.spent[selectedMonth] || 0,
|
||||||
|
delta: (r.spent[selectedMonth] || 0) - (r.spent[prevMonth(selectedMonth)] || 0),
|
||||||
|
series: months.map((m) => ({ month: m, v: r.spent[m] || 0 })),
|
||||||
|
}))
|
||||||
.sort((a, b) => b.total - a.total)
|
.sort((a, b) => b.total - a.total)
|
||||||
.slice(0, 8)
|
.slice(0, 8);
|
||||||
.map((r) => r.category);
|
}, [analytics, months, selectedMonth]);
|
||||||
|
|
||||||
const data = months.map((m) => {
|
// Top movers vs previous month
|
||||||
const entry: Record<string, unknown> = { month: m, label: formatShortMonth(m) };
|
const movers = useMemo(() => {
|
||||||
for (const cat of categoryTotals) {
|
if (!analytics) return [];
|
||||||
const row = analytics.rows.find((r) => r.category === cat);
|
const pm = prevMonth(selectedMonth);
|
||||||
entry[`cat_${cat}`] = row?.spent[m] || 0;
|
if (!months.includes(pm)) return [];
|
||||||
}
|
return analytics.rows
|
||||||
return entry;
|
.map((r) => ({
|
||||||
});
|
category: r.category,
|
||||||
return { data, categories: categoryTotals };
|
delta: (r.spent[selectedMonth] || 0) - (r.spent[pm] || 0),
|
||||||
}, [analytics, months]);
|
now: r.spent[selectedMonth] || 0,
|
||||||
|
before: r.spent[pm] || 0,
|
||||||
|
}))
|
||||||
|
.filter((r) => Math.abs(r.delta) >= 1)
|
||||||
|
.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
|
||||||
|
.slice(0, 6);
|
||||||
|
}, [analytics, months, selectedMonth]);
|
||||||
|
|
||||||
// Pareto chart data
|
// Pareto chart data
|
||||||
const paretoData = useMemo(() => {
|
const paretoData = useMemo(() => {
|
||||||
@@ -226,7 +265,6 @@ export default function AnalyticsPage() {
|
|||||||
const today = new Date();
|
const today = new Date();
|
||||||
const lastDay = isCurrentMonth ? today.getDate() : daysInMonth;
|
const lastDay = isCurrentMonth ? today.getDate() : daysInMonth;
|
||||||
|
|
||||||
// Daily spend from transactions
|
|
||||||
const daily: Record<number, number> = {};
|
const daily: Record<number, number> = {};
|
||||||
(monthTxData?.data ?? [])
|
(monthTxData?.data ?? [])
|
||||||
.filter((tx) => tx.transaction_type === "debit" && !["transfers", "investment"].includes(tx.effective_category))
|
.filter((tx) => tx.transaction_type === "debit" && !["transfers", "investment"].includes(tx.effective_category))
|
||||||
@@ -235,7 +273,6 @@ export default function AnalyticsPage() {
|
|||||||
daily[day] = (daily[day] || 0) + Number(tx.amount_aud ?? tx.amount);
|
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 priorMonths = analytics?.months.filter((m) => m !== selectedMonth) ?? [];
|
||||||
const priorAvg = priorMonths.length > 0
|
const priorAvg = priorMonths.length > 0
|
||||||
? priorMonths.reduce((s, m) => s + (analytics?.totals[m]?.spent || 0), 0) / priorMonths.length
|
? priorMonths.reduce((s, m) => s + (analytics?.totals[m]?.spent || 0), 0) / priorMonths.length
|
||||||
@@ -255,131 +292,151 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
if (isLoading || !analytics) {
|
if (isLoading || !analytics) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 max-w-5xl">
|
||||||
<h2 className="text-xl font-semibold">Analytics</h2>
|
<h2 className="text-2xl font-display">Analytics</h2>
|
||||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
<p className="text-zinc-500 text-sm">Loading…</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 spentDelta = totals.spent - lastTotals.spent;
|
|
||||||
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);
|
||||||
|
|
||||||
// Pareto: find where cumulative crosses 80%
|
// Hero delta vs the average of the other months that have data
|
||||||
|
const otherMonths = months.filter((m) => m !== selectedMonth && (analytics.totals[m]?.spent || 0) > 0);
|
||||||
|
const avgSpend = otherMonths.length
|
||||||
|
? otherMonths.reduce((s, m) => s + (analytics.totals[m]?.spent || 0), 0) / otherMonths.length
|
||||||
|
: 0;
|
||||||
|
const avgDeltaPct = avgSpend > 0 ? Math.round(((totals.spent - avgSpend) / avgSpend) * 100) : 0;
|
||||||
|
const heroSentence =
|
||||||
|
avgSpend === 0 ? "" :
|
||||||
|
Math.abs(avgDeltaPct) <= 3 ? `in line with your ${otherMonths.length}-month average` :
|
||||||
|
`${Math.abs(avgDeltaPct)}% ${avgDeltaPct > 0 ? "above" : "below"} your ${otherMonths.length}-month average of ${fmt(avgSpend)}`;
|
||||||
|
|
||||||
const pareto80idx = paretoData.findIndex((r) => r.cumulative >= 80);
|
const pareto80idx = paretoData.findIndex((r) => r.cumulative >= 80);
|
||||||
|
const tableMonths = analytics.months.slice(0, 6); // newest-first, last 6
|
||||||
|
const maxMoverDelta = Math.max(...movers.map((m) => Math.abs(m.delta)), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 max-w-5xl">
|
||||||
|
|
||||||
{/* Header + month selector */}
|
{/* ── Hero + month spine ── */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="border-b border-zinc-800 pb-5">
|
||||||
<h2 className="text-xl font-semibold">Analytics</h2>
|
<p className="text-[11px] uppercase tracking-[0.18em] text-zinc-500 mb-2">Ledger · {formatMonth(selectedMonth)}</p>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||||
<button onClick={() => setSelectedMonth(prevMonth(selectedMonth))} className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none">‹</button>
|
<div>
|
||||||
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
|
<p className="font-display text-5xl text-zinc-50 leading-none">
|
||||||
<button onClick={() => setSelectedMonth(nextMonth(selectedMonth))} className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none">›</button>
|
{fmt(totals.spent)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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">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>
|
</p>
|
||||||
</div>
|
{heroSentence && (
|
||||||
{(hasInvestments || totals.investments > 0) && (
|
<p className="text-sm text-zinc-400 mt-2">
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
<span className={avgDeltaPct > 3 ? "text-indigo-300" : avgDeltaPct < -3 ? "text-emerald-400" : "text-zinc-400"}>
|
||||||
<p className="text-xs text-zinc-500 mb-1">Invested</p>
|
{heroSentence}
|
||||||
<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">{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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-2xl font-semibold text-zinc-600">—</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 1. Category trend lines */}
|
|
||||||
{trendData.categories.length > 0 && (
|
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
|
||||||
<h3 className="text-sm font-medium mb-4">Category Trends — 6 Months</h3>
|
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<LineChart
|
|
||||||
data={trendData.data}
|
|
||||||
margin={{ top: 4, right: 8, bottom: 0, left: 8 }}
|
|
||||||
onClick={(d) => {
|
|
||||||
const month = (d as any)?.activePayload?.[0]?.payload?.month as string | undefined;
|
|
||||||
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>
|
||||||
))}
|
</p>
|
||||||
{analytics.rows.length > 8 && (
|
|
||||||
<span className="text-xs text-zinc-600">+ {analytics.rows.length - 8} more in table below</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full sm:w-auto sm:min-w-80 sm:flex-1 sm:max-w-md">
|
||||||
|
<MonthSpine months={months} totals={analytics.totals} selected={selectedMonth} onSelect={(m) => { setSelectedMonth(m); setExpandedCategory(null); }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Cashflow strip ── */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl grid grid-cols-2 sm:grid-cols-4 divide-x divide-y sm:divide-y-0 divide-zinc-800 overflow-hidden">
|
||||||
|
<div className="px-4 py-3.5">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Income</p>
|
||||||
|
<p className={`text-xl font-mono tabular-nums ${hasIncome ? "text-emerald-400" : "text-zinc-600"}`}>{hasIncome ? fmt(totals.income) : "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3.5">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Expenses</p>
|
||||||
|
<p className="text-xl font-mono tabular-nums text-zinc-100">{fmt(totals.spent)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3.5">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Invested</p>
|
||||||
|
<p className={`text-xl font-mono tabular-nums ${hasInvestments ? "text-indigo-300" : "text-zinc-600"}`}>{hasInvestments ? fmt(totals.investments) : "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3.5">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Net cash</p>
|
||||||
|
{hasIncome ? (
|
||||||
|
<p className={`text-xl font-mono tabular-nums ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>{totals.net >= 0 ? "+" : ""}{fmt(totals.net)}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xl font-mono tabular-nums text-zinc-600">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Top movers vs last month ── */}
|
||||||
|
{movers.length > 0 && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-medium mb-1">What changed</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mb-4">Biggest category moves vs {formatShortMonth(prevMonth(selectedMonth))}</p>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-x-8 gap-y-2.5">
|
||||||
|
{movers.map((m) => (
|
||||||
|
<div key={m.category} className="flex items-center gap-3">
|
||||||
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[m.category] || CHART.axis }} />
|
||||||
|
<span className="text-sm text-zinc-300 w-32 truncate shrink-0">{formatCategory(m.category)}</span>
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-zinc-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${m.delta > 0 ? "bg-indigo-400" : "bg-emerald-500"}`}
|
||||||
|
style={{ width: `${Math.max(6, Math.round((Math.abs(m.delta) / maxMoverDelta) * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-mono tabular-nums w-16 text-right ${m.delta > 0 ? "text-indigo-300" : "text-emerald-400"}`}>
|
||||||
|
{fmtSigned(m.delta)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 2. Pareto chart */}
|
{/* ── Category small multiples ── */}
|
||||||
|
{sparkData.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-medium">Category trends</h3>
|
||||||
|
<span className="text-xs text-zinc-500">12 months · top {sparkData.length} by total spend</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{sparkData.map((s) => (
|
||||||
|
<div key={s.category} className="bg-zinc-900 border border-zinc-800 rounded-xl px-3.5 pt-3 pb-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-400 truncate">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[s.category] || CHART.axis }} />
|
||||||
|
{formatCategory(s.category)}
|
||||||
|
</span>
|
||||||
|
{Math.abs(s.delta) >= 1 && (
|
||||||
|
<span className={`text-[10px] font-mono tabular-nums shrink-0 ${s.delta > 0 ? "text-indigo-300" : "text-emerald-400"}`}>{fmtSigned(s.delta)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-mono tabular-nums text-zinc-100 mb-1">{fmt(s.thisMonth)}</p>
|
||||||
|
<div className="h-10 -mx-1">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={s.series} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<Area
|
||||||
|
dataKey="v"
|
||||||
|
stroke={CATEGORY_COLORS[s.category] || CHART.axis}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fill={CATEGORY_COLORS[s.category] || CHART.axis}
|
||||||
|
fillOpacity={0.12}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Pareto ── */}
|
||||||
{paretoData.length > 0 && (
|
{paretoData.length > 0 && (
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-medium">Spend Concentration — {formatMonth(selectedMonth)}</h3>
|
<h3 className="text-sm font-medium">Spend concentration</h3>
|
||||||
{pareto80idx >= 0 && (
|
{pareto80idx >= 0 && (
|
||||||
<span className="text-xs text-zinc-500">
|
<span className="text-xs text-zinc-500">
|
||||||
Top {pareto80idx + 1} categor{pareto80idx === 0 ? "y" : "ies"} = 80% of spend
|
Top {pareto80idx + 1} categor{pareto80idx === 0 ? "y" : "ies"} = 80% of spend
|
||||||
@@ -390,14 +447,14 @@ export default function AnalyticsPage() {
|
|||||||
<ComposedChart data={paretoData} margin={{ top: 4, right: 48, bottom: 0, left: 8 }}>
|
<ComposedChart data={paretoData} margin={{ top: 4, right: 48, bottom: 0, left: 8 }}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="category"
|
dataKey="category"
|
||||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
tick={{ fill: CHART.axis, fontSize: 11 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickFormatter={formatCategory}
|
tickFormatter={formatCategory}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
tick={{ fill: CHART.axis, fontSize: 11 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickFormatter={(v) => `$${v}`}
|
tickFormatter={(v) => `$${v}`}
|
||||||
@@ -406,66 +463,66 @@ export default function AnalyticsPage() {
|
|||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
tick={{ fill: CHART.axis, fontSize: 11 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickFormatter={(v) => `${v}%`}
|
tickFormatter={(v) => `${v}%`}
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
width={36}
|
width={36}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<ParetoTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
<Tooltip content={<ParetoTooltip />} cursor={{ fill: "rgba(232,224,204,0.04)" }} />
|
||||||
<ReferenceLine yAxisId="right" y={80} stroke="#52525b" strokeDasharray="4 2" label={{ value: "80%", fill: "#71717a", fontSize: 10, position: "right" }} />
|
<ReferenceLine yAxisId="right" y={80} stroke={CHART.faint} strokeDasharray="4 2" label={{ value: "80%", fill: CHART.axis, fontSize: 10, position: "right" }} />
|
||||||
<Bar yAxisId="left" dataKey="spent" radius={[3, 3, 0, 0]} maxBarSize={40}>
|
<Bar yAxisId="left" dataKey="spent" radius={[3, 3, 0, 0]} maxBarSize={40}>
|
||||||
{paretoData.map((entry, i) => (
|
{paretoData.map((entry, i) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={entry.category}
|
key={entry.category}
|
||||||
fill={i <= pareto80idx ? (CATEGORY_COLORS[entry.category] || "#6366f1") : "#3f3f46"}
|
fill={i <= pareto80idx ? (CATEGORY_COLORS[entry.category] || CHART.accent) : CHART.dim}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
<Line
|
<Line
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
dataKey="cumulative"
|
dataKey="cumulative"
|
||||||
stroke="#fbbf24"
|
stroke={CHART.accentSoft}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ fill: "#fbbf24", r: 3 }}
|
dot={{ fill: CHART.accentSoft, r: 3 }}
|
||||||
activeDot={{ r: 5 }}
|
activeDot={{ r: 5 }}
|
||||||
/>
|
/>
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="flex items-center gap-4 mt-2 justify-end">
|
<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-0.5 inline-block" style={{ background: CHART.accentSoft }} />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>
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm inline-block" style={{ background: CHART.dim }} />Beyond 80%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 3. Cumulative spend this month */}
|
{/* ── Cumulative pace ── */}
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-medium">Cumulative Spend — {formatMonth(selectedMonth)}</h3>
|
<h3 className="text-sm font-medium">Spend pace</h3>
|
||||||
<span className="text-xs text-zinc-500">vs avg monthly pace</span>
|
<span className="text-xs text-zinc-500">cumulative through the month, vs typical</span>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={180}>
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
<LineChart data={cumulativeData} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
|
<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} />
|
<XAxis dataKey="day" tick={{ fill: CHART.axis, 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} />
|
<YAxis tick={{ fill: CHART.axis, 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 }} />
|
<Tooltip content={<CumulativeTooltip />} cursor={{ stroke: "rgba(232,224,204,0.08)", strokeWidth: 1 }} />
|
||||||
<Line dataKey="typical" stroke="#3f3f46" strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="typical" />
|
<Line dataKey="typical" stroke={CHART.faint} 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" />
|
<Line dataKey="actual" stroke={CHART.accent} strokeWidth={2} dot={false} activeDot={{ r: 4 }} connectNulls={false} name="actual" />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="flex items-center gap-4 mt-2 justify-end">
|
<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 inline-block" style={{ background: CHART.accent }} />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>
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-4 h-0.5 inline-block border-t border-dashed" style={{ borderColor: CHART.faint }} />Typical pace</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category breakdown table — expandable rows */}
|
{/* ── 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-800 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">Spending Breakdown — {formatMonth(selectedMonth)}</h3>
|
<h3 className="text-sm font-medium">Where it went — {formatMonth(selectedMonth)}</h3>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -473,7 +530,7 @@ export default function AnalyticsPage() {
|
|||||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
|
||||||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Spent</th>
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Spent</th>
|
||||||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium"># Txns</th>
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium"># Txns</th>
|
||||||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">% of Total</th>
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">% of total</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -487,14 +544,14 @@ export default function AnalyticsPage() {
|
|||||||
>
|
>
|
||||||
<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 className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[category] || "#71717a" }} />
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[category] || CHART.axis }} />
|
||||||
{formatCategory(category)}
|
{formatCategory(category)}
|
||||||
<span className="text-zinc-600 text-xs ml-1">{isExpanded ? "▲" : "▼"}</span>
|
<span className="text-zinc-600 text-xs ml-1">{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 font-mono 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 font-mono tabular-nums">{txCount}</td>
|
||||||
<td className="px-4 py-2.5 text-right text-zinc-400 tabular-nums">
|
<td className="px-4 py-2.5 text-right text-zinc-400 font-mono tabular-nums">
|
||||||
{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>
|
||||||
@@ -507,19 +564,19 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 6-month trend table */}
|
{/* ── 6-month ledger table (heat-tinted) ── */}
|
||||||
{analytics.months.length > 0 && (
|
{tableMonths.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
|
<h3 className="text-sm font-medium text-zinc-400 mb-3">Six-month ledger</h3>
|
||||||
<div className="overflow-x-auto rounded-xl border border-zinc-700">
|
<div className="overflow-x-auto rounded-xl border border-zinc-800">
|
||||||
<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">Category</th>
|
<th className="text-left px-3 py-2 text-zinc-500 font-medium sticky left-0 bg-zinc-900 min-w-32">Category</th>
|
||||||
{analytics.months.map((m) => (
|
{tableMonths.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 ${m === selectedMonth ? "text-indigo-400" : "text-zinc-500"}`}
|
className={`text-right px-3 py-2 font-medium whitespace-nowrap cursor-pointer hover:text-zinc-300 ${m === selectedMonth ? "text-indigo-300" : "text-zinc-500"}`}
|
||||||
onClick={() => setSelectedMonth(m)}
|
onClick={() => setSelectedMonth(m)}
|
||||||
>
|
>
|
||||||
{formatShortMonth(m)}
|
{formatShortMonth(m)}
|
||||||
@@ -528,31 +585,39 @@ export default function AnalyticsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{analytics.rows.map((row) => (
|
{analytics.rows.map((row) => {
|
||||||
|
const rowMax = Math.max(...tableMonths.map((m) => row.spent[m] || 0), 1);
|
||||||
|
return (
|
||||||
<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 className="w-1.5 h-1.5 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[row.category] || "#71717a" }} />
|
<span className="w-1.5 h-1.5 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[row.category] || CHART.axis }} />
|
||||||
{formatCategory(row.category)}
|
{formatCategory(row.category)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{analytics.months.map((m) => {
|
{tableMonths.map((m) => {
|
||||||
const spent = row.spent[m];
|
const spent = row.spent[m];
|
||||||
|
const heat = spent !== undefined ? (spent / rowMax) * 0.28 : 0;
|
||||||
return (
|
return (
|
||||||
<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" : ""}`}>
|
<td
|
||||||
|
key={m}
|
||||||
|
className={`px-3 py-2 text-right font-mono tabular-nums ${spent === undefined ? "text-zinc-700" : "text-zinc-300"}`}
|
||||||
|
style={heat > 0.02 ? { background: `rgba(188, 111, 48, ${heat.toFixed(3)})` } : undefined}
|
||||||
|
>
|
||||||
{spent !== undefined ? fmt(spent) : "—"}
|
{spent !== undefined ? fmt(spent) : "—"}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{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">Income</td>
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-emerald-600">Income</td>
|
||||||
{analytics.months.map((m) => {
|
{tableMonths.map((m) => {
|
||||||
const inc = analytics.income[m];
|
const inc = analytics.income[m];
|
||||||
return (
|
return (
|
||||||
<td key={m} className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
|
<td key={m} className="px-3 py-2 text-right font-mono tabular-nums text-emerald-500">
|
||||||
{inc ? fmt(inc) : "—"}
|
{inc ? fmt(inc) : "—"}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -561,11 +626,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">Invested</td>
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-indigo-400">Invested</td>
|
||||||
{analytics.months.map((m) => {
|
{tableMonths.map((m) => {
|
||||||
const inv = analytics.investments[m];
|
const inv = analytics.investments[m];
|
||||||
return (
|
return (
|
||||||
<td key={m} className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
|
<td key={m} className="px-3 py-2 text-right font-mono tabular-nums text-indigo-300">
|
||||||
{inv ? fmt(inv) : "—"}
|
{inv ? fmt(inv) : "—"}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -574,10 +639,10 @@ export default function AnalyticsPage() {
|
|||||||
)}
|
)}
|
||||||
<tr className="border-t-2 border-zinc-700 font-semibold bg-zinc-900/50">
|
<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">Expenses</td>
|
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Expenses</td>
|
||||||
{analytics.months.map((m) => {
|
{tableMonths.map((m) => {
|
||||||
const t = analytics.totals[m];
|
const t = analytics.totals[m];
|
||||||
return (
|
return (
|
||||||
<td key={m} className={`px-3 py-2 text-right tabular-nums ${m === selectedMonth ? "bg-zinc-800/30 text-indigo-300" : ""}`}>
|
<td key={m} className={`px-3 py-2 text-right font-mono tabular-nums ${m === selectedMonth ? "text-indigo-300" : ""}`}>
|
||||||
{fmt(t?.spent || 0)}
|
{fmt(t?.spent || 0)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -585,12 +650,12 @@ export default function AnalyticsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
{hasIncome && (
|
{hasIncome && (
|
||||||
<tr className="font-semibold bg-zinc-900/50">
|
<tr className="font-semibold bg-zinc-900/50">
|
||||||
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Net Cash</td>
|
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Net cash</td>
|
||||||
{analytics.months.map((m) => {
|
{tableMonths.map((m) => {
|
||||||
const t = analytics.totals[m];
|
const t = analytics.totals[m];
|
||||||
const net = t?.net || 0;
|
const net = t?.net || 0;
|
||||||
return (
|
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" : ""}`}>
|
<td key={m} className={`px-3 py-2 text-right font-mono tabular-nums ${net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||||
{net >= 0 ? "+" : ""}{fmt(net)}
|
{net >= 0 ? "+" : ""}{fmt(net)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
|
|||||||
+49
-1
@@ -1,6 +1,54 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme inline {
|
/*
|
||||||
|
* Ink & copper ledger theme.
|
||||||
|
*
|
||||||
|
* The whole app is written against Tailwind's zinc (neutrals) and indigo
|
||||||
|
* (accent) scales, so the retheme happens here: zinc is remapped to warm
|
||||||
|
* ink/paper tones and indigo to copper. Pages therefore inherit the palette
|
||||||
|
* without per-page edits; chart hexes live in src/lib/category-colors.ts.
|
||||||
|
*/
|
||||||
|
@theme {
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--font-display: var(--font-fraunces), Georgia, serif;
|
||||||
|
|
||||||
|
/* Ink / paper neutrals (replaces zinc) */
|
||||||
|
--color-zinc-950: #0f0d0a;
|
||||||
|
--color-zinc-900: #171410;
|
||||||
|
--color-zinc-800: #242019;
|
||||||
|
--color-zinc-700: #332d23;
|
||||||
|
--color-zinc-600: #4d4536;
|
||||||
|
--color-zinc-500: #6e644f;
|
||||||
|
--color-zinc-400: #94896f;
|
||||||
|
--color-zinc-300: #b3a88e;
|
||||||
|
--color-zinc-200: #d1c7af;
|
||||||
|
--color-zinc-100: #e8e0cc;
|
||||||
|
--color-zinc-50: #f3edde;
|
||||||
|
|
||||||
|
/* Copper accent (replaces indigo) */
|
||||||
|
--color-indigo-950: #2a1708;
|
||||||
|
--color-indigo-900: #3e2410;
|
||||||
|
--color-indigo-800: #5c3517;
|
||||||
|
--color-indigo-700: #7c4820;
|
||||||
|
--color-indigo-600: #9c5b28;
|
||||||
|
--color-indigo-500: #bc6f30;
|
||||||
|
--color-indigo-400: #d28a47;
|
||||||
|
--color-indigo-300: #e3a968;
|
||||||
|
--color-indigo-200: #efc795;
|
||||||
|
--color-indigo-100: #f7e2c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: #bc6f30;
|
||||||
|
color: #0f0d0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-38
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useMonthlyAnalytics, useSubscriptions, useFees, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
import { useMonthlyAnalytics, useSubscriptions, useFees, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||||
import { CATEGORIES, REGULAR_CATEGORIES, formatCategory } from "@/lib/categories";
|
import { CATEGORIES, REGULAR_CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||||
|
|
||||||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||||
|
|
||||||
@@ -50,16 +51,16 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Custom tooltip ──────────────────────────────────────────────────
|
// ─── Custom tooltip ──────────────────────────────────────────────────
|
||||||
function RegularTooltip({ active, payload, label }: any) {
|
function RegularTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number }[]; label?: string }) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
const regular = payload.find((p: any) => p.dataKey === "regular")?.value ?? 0;
|
const regular = payload.find((p) => p.dataKey === "regular")?.value ?? 0;
|
||||||
const occasional = payload.find((p: any) => p.dataKey === "occasional")?.value ?? 0;
|
const occasional = payload.find((p) => p.dataKey === "occasional")?.value ?? 0;
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-xs space-y-1">
|
<div style={TOOLTIP_STYLE} className="px-3 py-2 text-xs space-y-1">
|
||||||
<div className="font-medium text-zinc-300 mb-1">{label}</div>
|
<div className="font-medium text-zinc-300 mb-1">{label}</div>
|
||||||
<div className="flex justify-between gap-4"><span className="text-indigo-400">Regular</span><span>{fmt(regular)}</span></div>
|
<div className="flex justify-between gap-4"><span className="text-indigo-300">Regular</span><span className="font-mono">{fmt(regular)}</span></div>
|
||||||
<div className="flex justify-between gap-4"><span className="text-zinc-400">Occasional</span><span>{fmt(occasional)}</span></div>
|
<div className="flex justify-between gap-4"><span className="text-zinc-400">Occasional</span><span className="font-mono">{fmt(occasional)}</span></div>
|
||||||
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span>{fmt(regular + occasional)}</span></div>
|
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span className="font-mono">{fmt(regular + occasional)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -185,8 +186,8 @@ function MonthlyBreakdown({ analytics }: { analytics: NonNullable<ReturnType<typ
|
|||||||
.sort((a, b) => b.amount - a.amount);
|
.sort((a, b) => b.amount - a.amount);
|
||||||
}, [analytics.rows, selectedMonth]);
|
}, [analytics.rows, selectedMonth]);
|
||||||
|
|
||||||
const regularRows = categoryData.filter((r) => REGULAR_CATEGORIES.has(r.category as any));
|
const regularRows = categoryData.filter((r) => (REGULAR_CATEGORIES as Set<string>).has(r.category));
|
||||||
const occasionalRows = categoryData.filter((r) => !REGULAR_CATEGORIES.has(r.category as any));
|
const occasionalRows = categoryData.filter((r) => !(REGULAR_CATEGORIES as Set<string>).has(r.category));
|
||||||
const regularTotal = regularRows.reduce((s, r) => s + r.amount, 0);
|
const regularTotal = regularRows.reduce((s, r) => s + r.amount, 0);
|
||||||
const occasionalTotal = occasionalRows.reduce((s, r) => s + r.amount, 0);
|
const occasionalTotal = occasionalRows.reduce((s, r) => s + r.amount, 0);
|
||||||
|
|
||||||
@@ -243,7 +244,7 @@ function MonthlyBreakdown({ analytics }: { analytics: NonNullable<ReturnType<typ
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
<div className="border border-zinc-800 rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||||
@@ -297,7 +298,7 @@ export default function InsightsPage() {
|
|||||||
let occasional = 0;
|
let occasional = 0;
|
||||||
for (const row of analytics.rows) {
|
for (const row of analytics.rows) {
|
||||||
const spend = Number(row.spent[month] ?? 0);
|
const spend = Number(row.spent[month] ?? 0);
|
||||||
if (REGULAR_CATEGORIES.has(row.category as any)) regular += spend;
|
if ((REGULAR_CATEGORIES as Set<string>).has(row.category)) regular += spend;
|
||||||
else occasional += spend;
|
else occasional += spend;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -321,46 +322,47 @@ export default function InsightsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<h2 className="text-xl font-semibold mb-6">Insights</h2>
|
<p className="text-[11px] uppercase tracking-[0.18em] text-zinc-500 mb-1">Ledger · patterns</p>
|
||||||
|
<h2 className="text-2xl font-display mb-6">Insights</h2>
|
||||||
|
|
||||||
{/* ── 1. Regular vs Occasional ── */}
|
{/* ── 1. Regular vs Occasional ── */}
|
||||||
<Section title="Regular vs Occasional Spend">
|
<Section title="Regular vs occasional spend">
|
||||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl grid grid-cols-3 divide-x divide-zinc-800 overflow-hidden mb-4">
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
<div className="px-4 py-3.5">
|
||||||
<div className="text-xs text-zinc-500 mb-1">This month — regular spend</div>
|
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">This month · regular</div>
|
||||||
<div className="text-xl font-semibold text-indigo-400">{fmt(latestRegular)}</div>
|
<div className="text-xl font-mono tabular-nums text-indigo-300">{fmt(latestRegular)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
<div className="px-4 py-3.5">
|
||||||
<div className="text-xs text-zinc-500 mb-1">12-month avg regular</div>
|
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">12-month average</div>
|
||||||
<div className="text-xl font-semibold text-zinc-200">{fmt(avgRegular)}</div>
|
<div className="text-xl font-mono tabular-nums text-zinc-200">{fmt(avgRegular)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
<div className="px-4 py-3.5">
|
||||||
<div className="text-xs text-zinc-500 mb-1">Trend (first 3 vs last 3 mo)</div>
|
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Trend · first 3 vs last 3</div>
|
||||||
<div className={`text-xl font-semibold ${regularTrend.dir === "up" ? "text-red-400" : regularTrend.dir === "down" ? "text-green-400" : "text-zinc-400"}`}>
|
<div className={`text-xl font-mono tabular-nums ${regularTrend.dir === "up" ? "text-red-400" : regularTrend.dir === "down" ? "text-emerald-400" : "text-zinc-400"}`}>
|
||||||
{regularTrend.dir === "up" ? "↑" : regularTrend.dir === "down" ? "↓" : "→"} {regularTrend.pct}%
|
{regularTrend.dir === "up" ? "↑" : regularTrend.dir === "down" ? "↓" : "→"} {regularTrend.pct}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||||
<XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} />
|
<XAxis dataKey="month" tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||||
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
|
<YAxis tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
|
||||||
<Tooltip content={<RegularTooltip />} />
|
<Tooltip content={<RegularTooltip />} cursor={{ fill: "rgba(232,224,204,0.04)" }} />
|
||||||
<Bar dataKey="regular" stackId="a" fill="#6366f1" name="Regular" radius={[0, 0, 0, 0]} />
|
<Bar dataKey="regular" stackId="a" fill={CHART.accent} name="Regular" radius={[0, 0, 0, 0]} />
|
||||||
<Bar dataKey="occasional" stackId="a" fill="#3f3f46" name="Occasional" radius={[3, 3, 0, 0]} />
|
<Bar dataKey="occasional" stackId="a" fill={CHART.dim} name="Occasional" radius={[3, 3, 0, 0]} />
|
||||||
<Line type="monotone" dataKey="regular" stroke="#818cf8" strokeWidth={2} dot={false} strokeDasharray="4 2" />
|
<Line type="monotone" dataKey="regular" stroke={CHART.accentSoft} strokeWidth={2} dot={false} strokeDasharray="4 2" />
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="flex gap-4 mt-2 justify-end">
|
<div className="flex gap-4 mt-2 justify-end">
|
||||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-indigo-500 inline-block" />Regular (groceries, dining, transport…)</span>
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm inline-block" style={{ background: CHART.accent }} />Regular (groceries, dining, transport…)</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" />Occasional</span>
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm inline-block" style={{ background: CHART.dim }} />Occasional</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ── 2. Monthly Spend Breakdown ── */}
|
{/* ── 2. Monthly Spend Breakdown ── */}
|
||||||
<Section title="Monthly Spend Breakdown">
|
<Section title="Monthly spend breakdown">
|
||||||
{!analytics6 ? (
|
{!analytics6 ? (
|
||||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -369,7 +371,7 @@ export default function InsightsPage() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ── 3. Recurring Charges ── */}
|
{/* ── 3. Recurring Charges ── */}
|
||||||
<Section title="Recurring Charges">
|
<Section title="Recurring charges">
|
||||||
{!subData ? (
|
{!subData ? (
|
||||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||||
) : subData.subscriptions.length === 0 ? (
|
) : subData.subscriptions.length === 0 ? (
|
||||||
@@ -380,7 +382,7 @@ export default function InsightsPage() {
|
|||||||
<span className="text-xs text-zinc-500">{activeSubscriptions.length} active · {inactiveSubscriptions.length} inactive</span>
|
<span className="text-xs text-zinc-500">{activeSubscriptions.length} active · {inactiveSubscriptions.length} inactive</span>
|
||||||
<span className="text-sm font-medium text-indigo-400">{fmtExact(subData.total_monthly_equiv)}<span className="text-xs text-zinc-500 font-normal ml-1">/ month committed</span></span>
|
<span className="text-sm font-medium text-indigo-400">{fmtExact(subData.total_monthly_equiv)}<span className="text-xs text-zinc-500 font-normal ml-1">/ month committed</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
<div className="border border-zinc-800 rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||||
@@ -419,7 +421,7 @@ export default function InsightsPage() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ── 4. Fees & Interest ── */}
|
{/* ── 4. Fees & Interest ── */}
|
||||||
<Section title="Fees & Interest">
|
<Section title="Fees & interest">
|
||||||
{!feesData ? (
|
{!feesData ? (
|
||||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||||
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
|
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
|
||||||
@@ -427,7 +429,7 @@ export default function InsightsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{feesData.by_bank.length > 0 && (
|
{feesData.by_bank.length > 0 && (
|
||||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
<div className="border border-zinc-800 rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||||
@@ -459,7 +461,7 @@ export default function InsightsPage() {
|
|||||||
{feesData.transactions.length > 0 && (
|
{feesData.transactions.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-zinc-500 mb-2">Individual fee / interest transactions</p>
|
<p className="text-xs text-zinc-500 mb-2">Individual fee / interest transactions</p>
|
||||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
<div className="border border-zinc-800 rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||||
|
|||||||
+8
-2
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono, Fraunces } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
import { Sidebar } from "@/components/sidebar";
|
import { Sidebar } from "@/components/sidebar";
|
||||||
@@ -14,6 +14,12 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fraunces = Fraunces({
|
||||||
|
variable: "--font-fraunces",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Finance",
|
title: "Finance",
|
||||||
description: "Personal Finance Dashboard",
|
description: "Personal Finance Dashboard",
|
||||||
@@ -27,7 +33,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-zinc-950 text-zinc-100`}
|
className={`${geistSans.variable} ${geistMono.variable} ${fraunces.variable} antialiased bg-zinc-950 text-zinc-100`}
|
||||||
>
|
>
|
||||||
<Providers>
|
<Providers>
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
|
|||||||
+15
-15
@@ -129,7 +129,7 @@ function MerchantProfile({
|
|||||||
}));
|
}));
|
||||||
}, [merchant.monthly_trend]);
|
}, [merchant.monthly_trend]);
|
||||||
|
|
||||||
const color = CATEGORY_COLORS[merchant.category] || "#6366f1";
|
const color = CATEGORY_COLORS[merchant.category] || "#bc6f30";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex justify-end">
|
<div className="fixed inset-0 z-50 flex justify-end">
|
||||||
@@ -190,23 +190,23 @@ function MerchantProfile({
|
|||||||
<p className="text-sm font-medium text-zinc-300 mb-3">Monthly Spend</p>
|
<p className="text-sm font-medium text-zinc-300 mb-3">Monthly Spend</p>
|
||||||
<ResponsiveContainer width="100%" height={120}>
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
<LineChart data={trendData}>
|
<LineChart data={trendData}>
|
||||||
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
<CartesianGrid stroke="#242019" strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fill: "#71717a", fontSize: 10 }}
|
tick={{ fill: "#94896f", fontSize: 10 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fill: "#71717a", fontSize: 10 }}
|
tick={{ fill: "#94896f", fontSize: 10 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickFormatter={(v) => `$${Math.round(v)}`}
|
tickFormatter={(v) => `$${Math.round(v)}`}
|
||||||
width={45}
|
width={45}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: "8px" }}
|
contentStyle={{ background: "#171410", border: "1px solid #332d23", borderRadius: "8px" }}
|
||||||
labelStyle={{ color: "#a1a1aa" }}
|
labelStyle={{ color: "#b3a88e" }}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
@@ -295,7 +295,7 @@ export default function MerchantsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-white">Merchants</h1>
|
<h1 className="text-2xl font-display text-zinc-50">Merchants</h1>
|
||||||
<p className="text-zinc-400 text-sm mt-0.5">{merchants.length} merchants · last {months} months</p>
|
<p className="text-zinc-400 text-sm mt-0.5">{merchants.length} merchants · last {months} months</p>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
@@ -320,28 +320,28 @@ export default function MerchantsPage() {
|
|||||||
<QuadrantLabels medianX={medianX} medianY={medianY} />
|
<QuadrantLabels medianX={medianX} medianY={medianY} />
|
||||||
<ResponsiveContainer width="100%" height={360}>
|
<ResponsiveContainer width="100%" height={360}>
|
||||||
<ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
|
<ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
|
||||||
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
<CartesianGrid stroke="#242019" strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="debit_count"
|
dataKey="debit_count"
|
||||||
name="Transactions"
|
name="Transactions"
|
||||||
type="number"
|
type="number"
|
||||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
tick={{ fill: "#94896f", fontSize: 11 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
label={{ value: "Transaction Count", position: "insideBottom", offset: -10, fill: "#52525b", fontSize: 11 }}
|
label={{ value: "Transaction Count", position: "insideBottom", offset: -10, fill: "#6e644f", fontSize: 11 }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
dataKey="net_spend"
|
dataKey="net_spend"
|
||||||
name="Net Spend"
|
name="Net Spend"
|
||||||
type="number"
|
type="number"
|
||||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
tick={{ fill: "#94896f", fontSize: 11 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickFormatter={(v) => `$${Math.round(v / 1000)}k`}
|
tickFormatter={(v) => `$${Math.round(v / 1000)}k`}
|
||||||
width={48}
|
width={48}
|
||||||
label={{ value: "Total Spend", angle: -90, position: "insideLeft", offset: 10, fill: "#52525b", fontSize: 11 }}
|
label={{ value: "Total Spend", angle: -90, position: "insideLeft", offset: 10, fill: "#6e644f", fontSize: 11 }}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: "3 3", stroke: "#52525b" }} />
|
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: "3 3", stroke: "#6e644f" }} />
|
||||||
<Scatter
|
<Scatter
|
||||||
data={scatterData}
|
data={scatterData}
|
||||||
onClick={(d) => setSelected(d as unknown as MerchantRow)}
|
onClick={(d) => setSelected(d as unknown as MerchantRow)}
|
||||||
@@ -350,7 +350,7 @@ export default function MerchantsPage() {
|
|||||||
{scatterData.map((entry, idx) => (
|
{scatterData.map((entry, idx) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={idx}
|
key={idx}
|
||||||
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
|
fill={CATEGORY_COLORS[entry.category] || "#bc6f30"}
|
||||||
fillOpacity={0.75}
|
fillOpacity={0.75}
|
||||||
stroke={selected?.merchant === entry.merchant ? "#fff" : "transparent"}
|
stroke={selected?.merchant === entry.merchant ? "#fff" : "transparent"}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
@@ -400,7 +400,7 @@ export default function MerchantsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map((m) => {
|
{filtered.map((m) => {
|
||||||
const color = CATEGORY_COLORS[m.category] || "#6366f1";
|
const color = CATEGORY_COLORS[m.category] || "#bc6f30";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={m.merchant}
|
key={m.merchant}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function ReconcilePage() {
|
|||||||
if (pending.length === 0) {
|
if (pending.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h2 className="text-xl font-semibold mb-2">Reconcile</h2>
|
<h2 className="text-2xl font-display mb-2">Reconcile</h2>
|
||||||
<p className="text-zinc-500 text-sm">No unreconciled manual transactions. Import a CSV to get started.</p>
|
<p className="text-zinc-500 text-sm">No unreconciled manual transactions. Import a CSV to get started.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -104,7 +104,7 @@ export default function ReconcilePage() {
|
|||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">Reconcile</h2>
|
<h2 className="text-2xl font-display">Reconcile</h2>
|
||||||
<p className="text-xs text-zinc-500 mt-0.5">
|
<p className="text-xs text-zinc-500 mt-0.5">
|
||||||
{pending.length} manual transaction{pending.length !== 1 ? "s" : ""} ·{" "}
|
{pending.length} manual transaction{pending.length !== 1 ? "s" : ""} ·{" "}
|
||||||
{withMatches.length} with potential matches
|
{withMatches.length} with potential matches
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export default function RulesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">Rules</h2>
|
<h2 className="text-2xl font-display">Rules</h2>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<label className="text-xs text-zinc-500 whitespace-nowrap">Splits from</label>
|
<label className="text-xs text-zinc-500 whitespace-nowrap">Splits from</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ export default function SharedPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-xl font-semibold">Shared Expenses</h2>
|
<h2 className="text-2xl font-display">Shared Expenses</h2>
|
||||||
<div className="flex items-center gap-2 ml-auto flex-wrap">
|
<div className="flex items-center gap-2 ml-auto flex-wrap">
|
||||||
<select
|
<select
|
||||||
value={participantId ?? ""}
|
value={participantId ?? ""}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function StatementsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-semibold">Statements</h2>
|
<h2 className="text-2xl font-display">Statements</h2>
|
||||||
{!isLoading && statements && (
|
{!isLoading && statements && (
|
||||||
<span className="text-xs text-zinc-500">
|
<span className="text-xs text-zinc-500">
|
||||||
{hasFilters ? `${filtered.length} of ${statements.length}` : statements.length} statements
|
{hasFilters ? `${filtered.length} of ${statements.length}` : statements.length} statements
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function TagsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-4">Tags</h2>
|
<h2 className="text-2xl font-display mb-4">Tags</h2>
|
||||||
|
|
||||||
{/* Create form */}
|
{/* Create form */}
|
||||||
<div className="mb-6 p-4 bg-zinc-900/50 border border-zinc-800 rounded-lg">
|
<div className="mb-6 p-4 bg-zinc-900/50 border border-zinc-800 rounded-lg">
|
||||||
|
|||||||
@@ -568,7 +568,7 @@ function TransactionsContent() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-semibold">Transactions</h2>
|
<h2 className="text-2xl font-display">Transactions</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowImportModal(true)}
|
onClick={() => setShowImportModal(true)}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function TripsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">Trips</h2>
|
<h2 className="text-2xl font-display">Trips</h2>
|
||||||
<p className="text-sm text-zinc-500 mt-0.5">Group and analyse expenses by trip</p>
|
<p className="text-sm text-zinc-500 mt-0.5">Group and analyse expenses by trip</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function Sidebar() {
|
|||||||
const navContent = (
|
const navContent = (
|
||||||
<>
|
<>
|
||||||
<div className="p-4 border-b border-zinc-800 flex items-center justify-between">
|
<div className="p-4 border-b border-zinc-800 flex items-center justify-between">
|
||||||
<h1 className="text-lg font-semibold text-white">Finance</h1>
|
<h1 className="text-xl text-zinc-100 font-display tracking-tight">Finance<span className="text-indigo-400">.</span></h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="md:hidden p-1 rounded text-zinc-400 hover:text-white hover:bg-zinc-800"
|
className="md:hidden p-1 rounded text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||||
@@ -110,10 +110,10 @@ export function Sidebar() {
|
|||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm mb-0.5 transition-colors ${
|
className={`relative flex items-center gap-3 px-3 py-2 rounded-md text-sm mb-0.5 transition-colors ${
|
||||||
active
|
active
|
||||||
? "bg-zinc-800 text-white"
|
? "bg-zinc-800 text-zinc-50 before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:rounded-full before:bg-indigo-400"
|
||||||
: "text-zinc-400 hover:text-white hover:bg-zinc-800/50"
|
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{ICONS[item.icon]}
|
{ICONS[item.icon]}
|
||||||
@@ -138,7 +138,7 @@ export function Sidebar() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span className="ml-3 text-base font-semibold text-white">Finance</span>
|
<span className="ml-3 text-lg text-zinc-100 font-display tracking-tight">Finance<span className="text-indigo-400">.</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile drawer overlay */}
|
{/* Mobile drawer overlay */}
|
||||||
|
|||||||
@@ -26,9 +26,21 @@ export const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
other: "#71717a",
|
other: "#71717a",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ink & copper chart tokens — keep in sync with the @theme scales in globals.css.
|
||||||
|
export const CHART = {
|
||||||
|
accent: "#bc6f30", // copper (indigo-500)
|
||||||
|
accentSoft: "#d28a47", // copper light (indigo-400)
|
||||||
|
positive: "#34d399",
|
||||||
|
negative: "#e35f4b",
|
||||||
|
axis: "#94896f", // muted paper (zinc-400)
|
||||||
|
faint: "#6e644f", // zinc-500
|
||||||
|
grid: "#242019", // zinc-800
|
||||||
|
dim: "#332d23", // zinc-700 — de-emphasised series
|
||||||
|
};
|
||||||
|
|
||||||
export const TOOLTIP_STYLE = {
|
export const TOOLTIP_STYLE = {
|
||||||
background: "#18181b",
|
background: "#171410",
|
||||||
border: "1px solid #3f3f46",
|
border: "1px solid #332d23",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user