From 847b1cb4e1c112eb8760176b7537c506b5ba8ff9 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Mon, 7 Sep 2026 09:13:44 +0900 Subject: [PATCH 1/3] fix(charts): draw tooltips through shadcn Chart so they follow the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two charts passed Recharts' bare ``, which paints a hardcoded white box with dark text — unreadable in dark mode. The net worth chart had already hand-rolled a `CustomTooltip` to get around exactly that, and CLAUDE.md has claimed "Recharts v3 (via shadcn Chart)" all along, so the missing piece was the component itself. All four charts now render inside `ChartContainer` with `ChartTooltip` / `ChartLegendContent`, and series labels come from `ChartConfig` rather than per-series `name` props, which keeps legend and tooltip in step. Two local adaptations to the generated `ui/chart.tsx`: - The shadcn CLI emits `import { cn } from "cn"` and tries to install a package by that name. Repointed at `@/lib/utils`. - Added a `valueFormatter` prop. Every value this app charts is an integer cent count, and upstream renders values with `toLocaleString()` — 123456 where $1,234.56 belongs. Recharts' own `formatter` replaces the whole tooltip row, dropping the colour indicator and the series name with it. The net worth chart keeps a five-line wrapper for the one thing the shared content does not do: dropping the null half of the split coverage series, which would otherwise print the same date twice. --- src/components/atoms/cash-flow-bar-chart.tsx | 41 +- src/components/atoms/net-worth-area-chart.tsx | 102 +++-- src/components/atoms/spending-chart.tsx | 37 +- src/components/atoms/trend-line-chart.tsx | 32 +- src/components/ui/chart.tsx | 384 ++++++++++++++++++ 5 files changed, 532 insertions(+), 64 deletions(-) create mode 100644 src/components/ui/chart.tsx diff --git a/src/components/atoms/cash-flow-bar-chart.tsx b/src/components/atoms/cash-flow-bar-chart.tsx index ab62c592..ee6d2b53 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 f089044f..0906660a 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 ca248103..7822fb13 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 1d52df7d..d42a818e 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/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 00000000..89c1e29e --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,384 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + const uniqueId = React.useId() + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +