diff --git a/src/components/atoms/cash-flow-bar-chart.tsx b/src/components/atoms/cash-flow-bar-chart.tsx
index ab62c59..ee6d2b5 100644
--- a/src/components/atoms/cash-flow-bar-chart.tsx
+++ b/src/components/atoms/cash-flow-bar-chart.tsx
@@ -1,6 +1,14 @@
"use client";
-import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
+import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid } from "recharts";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ ChartLegend,
+ ChartLegendContent,
+ type ChartConfig,
+} from "@/components/ui/chart";
import { centsToDisplay, centsToCompact } from "@/lib/money";
import { formatMonthShort } from "@/lib/date-utils";
import { INCOME_COLOR, SPENDING_COLOR, PRIMARY_COLOR } from "@/lib/chart-colors";
@@ -11,6 +19,12 @@ interface CashFlowBarChartProps {
showTrendline?: boolean;
}
+const chartConfig = {
+ income: { label: "Income", color: INCOME_COLOR },
+ expenses: { label: "Spending", color: SPENDING_COLOR },
+ net: { label: "Net", color: PRIMARY_COLOR },
+} satisfies ChartConfig;
+
export function CashFlowBarChart({ data, showTrendline = false }: CashFlowBarChartProps) {
if (data.length === 0) {
return (
@@ -21,7 +35,9 @@ export function CashFlowBarChart({ data, showTrendline = false }: CashFlowBarCha
}
return (
-
+ // Every caller sizes this chart with an explicit-height parent, so fill it
+ // rather than taking ChartContainer's default 16:9 aspect ratio.
+
- centsToDisplay(Number(v))}
- labelFormatter={(label) => formatMonthShort(String(label))}
+ formatMonthShort(String(label))}
+ valueFormatter={(v) => centsToDisplay(Number(v))}
+ />
+ }
/>
-
-
-
+ } />
+
+
{showTrendline && (
)}
-
+
);
}
diff --git a/src/components/atoms/net-worth-area-chart.tsx b/src/components/atoms/net-worth-area-chart.tsx
index f089044..0906660 100644
--- a/src/components/atoms/net-worth-area-chart.tsx
+++ b/src/components/atoms/net-worth-area-chart.tsx
@@ -1,5 +1,6 @@
"use client";
+import * as React from "react";
import {
ComposedChart,
Area,
@@ -7,12 +8,17 @@ import {
XAxis,
YAxis,
CartesianGrid,
- Tooltip,
ReferenceArea,
ReferenceLine,
- ResponsiveContainer,
- Legend,
} from "recharts";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ ChartLegend,
+ ChartLegendContent,
+ type ChartConfig,
+} from "@/components/ui/chart";
import { centsToDisplay, centsToCompact, axisTickFormatter } from "@/lib/money";
import { formatDateShort } from "@/lib/date-utils";
import { INCOME_COLOR, EXPENSE_COLOR, POSITIVE_COLOR, UNCOVERED_COLOR } from "@/lib/chart-colors";
@@ -36,31 +42,26 @@ interface NetWorthAreaChartProps {
seriesName?: string;
}
-interface TooltipEntry {
- name: string;
- value: number;
- color: string;
-}
-
const AXIS_TICK = { fontSize: 11, fill: "var(--muted-foreground)" };
-function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: TooltipEntry[]; label?: string }) {
- if (!active || !payload?.length) return null;
- // The split single-mode series leaves one key null on either side of the
- // coverage boundary; Recharts still reports it, so drop the empty half
- // rather than rendering the same date twice.
- const entries = payload.filter((e) => e.value !== null && e.value !== undefined);
- if (entries.length === 0) return null;
- return (
-
-
{formatDateShort(label ?? "")}
- {entries.map((entry: TooltipEntry) => (
-
- {entry.name}: {centsToDisplay(entry.value)}
-
- ))}
-
- );
+const MULTI_CONFIG = {
+ netWorth: { label: "Net Worth", color: POSITIVE_COLOR },
+ assets: { label: "Assets", color: INCOME_COLOR },
+ liabilities: { label: "Liabilities", color: EXPENSE_COLOR },
+} satisfies ChartConfig;
+
+/**
+ * The split single-mode series leaves one key null on either side of the
+ * coverage boundary; Recharts still reports it, so drop the empty half rather
+ * than rendering the same date twice with a blank value.
+ */
+function DenseTooltipContent({
+ payload,
+ ...props
+}: React.ComponentProps) {
+ const entries = payload?.filter((e) => e.value !== null && e.value !== undefined);
+ if (!entries?.length) return null;
+ return ;
}
export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }: NetWorthAreaChartProps) {
@@ -95,8 +96,16 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }
};
});
+ const singleConfig: ChartConfig = {
+ covered: { label: seriesName, color: POSITIVE_COLOR },
+ partial: {
+ label: boundary.hasPartial ? "Tracked accounts only" : seriesName,
+ color: boundary.hasPartial ? UNCOVERED_COLOR : POSITIVE_COLOR,
+ },
+ };
+
return (
-
+
@@ -118,7 +127,14 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }
tickCount={4}
domain={["auto", "auto"]}
/>
- } />
+ formatDateShort(String(label))}
+ valueFormatter={(v) => centsToDisplay(Number(v))}
+ />
+ }
+ />
{boundary.hasPartial && boundary.date && (
-
+
);
}
return (
-
+
@@ -175,21 +189,27 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }
tickCount={4}
domain={["auto", "auto"]}
/>
- } />
+ formatDateShort(String(label))}
+ valueFormatter={(v) => centsToDisplay(Number(v))}
+ />
+ }
+ />
{/* Three series identified by colour alone, and only on hover, until
this. A legend is not optional past one series. */}
-
+ } />
-
-
+
+
-
+
);
}
diff --git a/src/components/atoms/spending-chart.tsx b/src/components/atoms/spending-chart.tsx
index ca24810..7822fb1 100644
--- a/src/components/atoms/spending-chart.tsx
+++ b/src/components/atoms/spending-chart.tsx
@@ -1,6 +1,12 @@
"use client";
-import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
+import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis } from "recharts";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/components/ui/chart";
import { centsToDisplay } from "@/lib/money";
import { activateOnKey } from "@/lib/a11y";
import { CHART_COLORS } from "@/lib/chart-colors";
@@ -25,6 +31,10 @@ interface SpendingChartProps {
// category either — it is the absence of one, and as the largest slice in most
// households it was taking CHART_COLORS[0], the loudest blue, making "we do not
// know" the visual hero of the chart.
+// In bar mode every row is the same series, so the row label names the measure
+// and the tooltip header carries the category.
+const BAR_CONFIG: ChartConfig = { value: { label: "Amount" } };
+
function colorAt(item: SpendingChartItem, i: number): string {
if (item.synthetic || item.id === null) return "var(--chart-neutral)";
return CHART_COLORS[i % CHART_COLORS.length];
@@ -56,10 +66,17 @@ export function SpendingChart({ data, viewMode, onItemClick }: SpendingChartProp
}
if (viewMode === "donut") {
+ // Slices are user categories, so their names are the series keys — they
+ // carry spaces and ampersands and cannot be emitted as `--color-`
+ // custom properties. The config names each slice; `Cell` keeps the colour.
+ const donutConfig: ChartConfig = Object.fromEntries(
+ chartData.map((item) => [item.name, { label: item.name }]),
+ );
+
return (
-
+
))}
- centsToDisplay(Number(v))} />
+ centsToDisplay(Number(v))} />
+ }
+ />
-
+
{chartData.map((row, i) => (
@@ -97,7 +118,7 @@ export function SpendingChart({ data, viewMode, onItemClick }: SpendingChartProp
}
return (
-
+
- centsToDisplay(Number(v))} />
+ centsToDisplay(Number(v))} />}
+ />
handleClick(index)}
@@ -116,7 +139,7 @@ export function SpendingChart({ data, viewMode, onItemClick }: SpendingChartProp
))}
-
+
);
}
diff --git a/src/components/atoms/trend-line-chart.tsx b/src/components/atoms/trend-line-chart.tsx
index 1d52df7..d42a818 100644
--- a/src/components/atoms/trend-line-chart.tsx
+++ b/src/components/atoms/trend-line-chart.tsx
@@ -1,6 +1,14 @@
"use client";
-import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, LabelList } from "recharts";
+import { LineChart, Line, XAxis, YAxis, CartesianGrid, LabelList } from "recharts";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ ChartLegend,
+ ChartLegendContent,
+ type ChartConfig,
+} from "@/components/ui/chart";
import { centsToDisplay } from "@/lib/money";
import { formatMonthShort } from "@/lib/date-utils";
@@ -40,9 +48,16 @@ export function TrendLineChart({ data, categories: cats }: TrendLineChartProps)
}
const lastIndex = data.length - 1;
+ // Categories are user data, so their names are the series keys. They carry
+ // spaces and ampersands, which would not survive being emitted as
+ // `--color-
` custom properties — so the config names the series and the
+ // stroke stays inline.
+ const chartConfig: ChartConfig = Object.fromEntries(
+ cats.map((cat) => [cat.name, { label: cat.name }]),
+ );
return (
-
+
@@ -51,8 +66,15 @@ export function TrendLineChart({ data, categories: cats }: TrendLineChartProps)
tick={{ fontSize: 11 }}
width={60}
/>
- centsToDisplay(Number(v))} labelFormatter={(l) => formatMonthShort(String(l))} />
-
+ formatMonthShort(String(l))}
+ valueFormatter={(v) => centsToDisplay(Number(v))}
+ />
+ }
+ />
+ } />
{cats.map((cat) => (
))}
-
+
);
}
diff --git a/src/components/molecules/bill-search.tsx b/src/components/molecules/bill-search.tsx
index 8de3ca2..a98e0d9 100644
--- a/src/components/molecules/bill-search.tsx
+++ b/src/components/molecules/bill-search.tsx
@@ -3,7 +3,11 @@
import { useCallback, useRef, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Search } from "lucide-react";
-import { Input } from "@/components/ui/input";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from "@/components/ui/input-group";
export function BillSearch() {
const router = useRouter();
@@ -32,14 +36,15 @@ export function BillSearch() {
}
return (
-
-
-
+
+
+
+ handleChange(e.target.value)}
- className="pl-8 h-8"
/>
-
+
);
}
diff --git a/src/components/organisms/budget-page-header.tsx b/src/components/organisms/budget-page-header.tsx
index 6215c8e..4e7986a 100644
--- a/src/components/organisms/budget-page-header.tsx
+++ b/src/components/organisms/budget-page-header.tsx
@@ -4,9 +4,14 @@ import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { BudgetMonthNav } from "@/components/molecules/budget-month-nav";
import { updateBudgetType, copyBudgetFromMonth } from "@/actions/budgets";
-import { cn } from "@/lib/utils";
+
+const BUDGET_TYPES = [
+ { value: "category", label: "Category" },
+ { value: "flex", label: "Flex" },
+] as const;
interface BudgetPageHeaderProps {
month: string;
@@ -26,8 +31,12 @@ export function BudgetPageHeader({
const [isPending, startTransition] = useTransition();
const router = useRouter();
- function handleTypeToggle(type: "category" | "flex") {
- if (!budgetId || type === budgetType) return;
+ // Base UI models a toggle group as an array even when only one item can be
+ // active, and hands back an empty array when the active item is clicked
+ // again. Ignoring anything that isn't a budget type keeps one option selected.
+ function handleTypeToggle(groupValue: string[]) {
+ const type = groupValue[0];
+ if (!budgetId || (type !== "category" && type !== "flex") || type === budgetType) return;
startTransition(async () => {
await updateBudgetType(budgetId, type);
router.refresh();
@@ -49,32 +58,29 @@ export function BudgetPageHeader({
{budgetId && (
-
-
-
-
+
+ {BUDGET_TYPES.map(({ value, label }) => (
+
+ {label}
+
+ ))}
+
)}
{hasPreviousMonthBudget && budgetId && (