diff --git a/src/app/budget/page.tsx b/src/app/budget/page.tsx index 20818a5..7868453 100644 --- a/src/app/budget/page.tsx +++ b/src/app/budget/page.tsx @@ -1,9 +1,11 @@ "use client"; -import { useState, Fragment, useMemo } from "react"; +import { useState, useEffect, Fragment, useMemo } from "react"; import { ComposedChart, LineChart, + AreaChart, + Area, Bar, Line, XAxis, @@ -12,12 +14,11 @@ import { ResponsiveContainer, Cell, ReferenceLine, - Legend, } from "recharts"; import { useQueryClient } from "@tanstack/react-query"; import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks"; 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 { const now = new Date(); @@ -28,11 +29,6 @@ function prevMonth(m: string): string { const d = new Date(year, month - 2, 1); 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 { const [year, month] = m.split("-"); 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("-"); 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 deltaColor(n: number): string { - if (n > 0) return "text-red-400"; - if (n < 0) return "text-emerald-400"; - return ""; -} - +function fmtSigned(n: number): string { return `${n >= 0 ? "+" : "−"}$${Math.abs(n) >= 100 ? Math.round(Math.abs(n)).toLocaleString() : Math.abs(n).toFixed(0)}`; } // ─── 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 ( -
-

{label}

- {items.map((p) => ( -
- - {formatCategory(p.dataKey.replace("cat_", ""))}: - {fmtExact(p.value)} -
- ))} -
- ); -} - function ParetoTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string; spent: number; pct: number; cumulative: number } }[] }) { if (!active || !payload?.length) return null; const d = payload[0].payload; return (

{formatCategory(d.category)}

-
Spend{fmtExact(d.spent)}
-
Share{d.pct}%
-
Cumulative{d.cumulative}%
+
Spend{fmtExact(d.spent)}
+
Share{d.pct}%
+
Cumulative{d.cumulative}%
); } @@ -92,14 +65,59 @@ function CumulativeTooltip({ active, payload, label }: { active?: boolean; paylo
{p.dataKey === "actual" ? "Actual" : "Typical pace"}: - {fmtExact(p.value)} + {fmtExact(p.value)}
))} ); } -// ─── 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; + selected: string; + onSelect: (m: string) => void; +}) { + const max = Math.max(...months.map((m) => totals[m]?.spent || 0), 1); + return ( +
+ {months.map((m) => { + const spent = totals[m]?.spent || 0; + const h = Math.max(4, Math.round((spent / max) * 56)); + const active = m === selected; + return ( + + ); + })} +
+ ); +} + +// ─── CategoryPanel (drill-down) ────────────────────────────────────────────── function CategoryPanel({ category, selectedMonth }: { category: string; selectedMonth: string }) { const qc = useQueryClient(); @@ -115,7 +133,7 @@ function CategoryPanel({ category, selectedMonth }: { category: string; selected {isLoading ? ( -

Loading...

+

Loading…

) : txns.length === 0 ? (

No transactions

) : ( @@ -131,9 +149,9 @@ function CategoryPanel({ category, selectedMonth }: { category: string; selected {txns.map((tx) => ( - {tx.transaction_date.slice(5).replace("-", "/")} + {tx.transaction_date.slice(5).replace("-", "/")} {tx.effective_merchant || tx.description} - {fmtExact(Number(tx.amount))} + {fmtExact(Number(tx.amount))} - + `$${Math.round(v / 1000)}k`} 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 }} /> - } cursor={{ strokeDasharray: "3 3", stroke: "#52525b" }} /> + } cursor={{ strokeDasharray: "3 3", stroke: "#6e644f" }} /> setSelected(d as unknown as MerchantRow)} @@ -350,7 +350,7 @@ export default function MerchantsPage() { {scatterData.map((entry, idx) => ( {filtered.map((m) => { - const color = CATEGORY_COLORS[m.category] || "#6366f1"; + const color = CATEGORY_COLORS[m.category] || "#bc6f30"; return ( -

Reconcile

+

Reconcile

No unreconciled manual transactions. Import a CSV to get started.

); @@ -104,7 +104,7 @@ export default function ReconcilePage() {
-

Reconcile

+

Reconcile

{pending.length} manual transaction{pending.length !== 1 ? "s" : ""} ·{" "} {withMatches.length} with potential matches diff --git a/src/app/rules/page.tsx b/src/app/rules/page.tsx index 8ea68c1..882f896 100644 --- a/src/app/rules/page.tsx +++ b/src/app/rules/page.tsx @@ -161,7 +161,7 @@ export default function RulesPage() { return (

-

Rules

+

Rules

-

Shared Expenses

+

Shared Expenses