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 && (