Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions src/components/atoms/cash-flow-bar-chart.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
Expand All @@ -21,7 +35,9 @@ export function CashFlowBarChart({ data, showTrendline = false }: CashFlowBarCha
}

return (
<ResponsiveContainer width="100%" height="100%">
// Every caller sizes this chart with an explicit-height parent, so fill it
// rather than taking ChartContainer's default 16:9 aspect ratio.
<ChartContainer config={chartConfig} className="aspect-auto h-full w-full">
<ComposedChart data={data} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<CartesianGrid vertical={false} stroke="var(--border)" />
<XAxis
Expand All @@ -39,25 +55,28 @@ export function CashFlowBarChart({ data, showTrendline = false }: CashFlowBarCha
tickLine={false}
tickCount={4}
/>
<Tooltip
formatter={(v) => centsToDisplay(Number(v))}
labelFormatter={(label) => formatMonthShort(String(label))}
<ChartTooltip
cursor={{ fill: "var(--muted)", opacity: 0.4 }}
content={
<ChartTooltipContent
labelFormatter={(label) => formatMonthShort(String(label))}
valueFormatter={(v) => centsToDisplay(Number(v))}
/>
}
/>
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="income" name="Income" fill={INCOME_COLOR} radius={[4, 4, 0, 0]} maxBarSize={24} />
<Bar dataKey="expenses" name="Spending" fill={SPENDING_COLOR} radius={[4, 4, 0, 0]} maxBarSize={24} />
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey="income" fill="var(--color-income)" radius={[4, 4, 0, 0]} maxBarSize={24} />
<Bar dataKey="expenses" fill="var(--color-expenses)" radius={[4, 4, 0, 0]} maxBarSize={24} />
{showTrendline && (
<Line
type="monotone"
dataKey="net"
name="Net"
stroke={PRIMARY_COLOR}
stroke="var(--color-net)"
strokeWidth={2}
dot={false}
/>
)}
</ComposedChart>
</ResponsiveContainer>
</ChartContainer>
);
}
102 changes: 61 additions & 41 deletions src/components/atoms/net-worth-area-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
"use client";

import * as React from "react";
import {
ComposedChart,
Area,
Line,
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";
Expand All @@ -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 (
<div className="rounded-md border bg-popover px-3 py-2 text-sm shadow-md">
<p className="font-medium">{formatDateShort(label ?? "")}</p>
{entries.map((entry: TooltipEntry) => (
<p key={entry.name} className="tabular-nums" style={{ color: entry.color }}>
{entry.name}: {centsToDisplay(entry.value)}
</p>
))}
</div>
);
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<typeof ChartTooltipContent>) {
const entries = payload?.filter((e) => e.value !== null && e.value !== undefined);
if (!entries?.length) return null;
return <ChartTooltipContent {...props} payload={entries} />;
}

export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }: NetWorthAreaChartProps) {
Expand Down Expand Up @@ -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 (
<ResponsiveContainer width="100%" height="100%">
<ChartContainer config={singleConfig} className="aspect-auto h-full w-full">
<ComposedChart data={split} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<defs>
<linearGradient id="portfolioGradient" x1="0" y1="0" x2="0" y2="1">
Expand All @@ -118,7 +127,14 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }
tickCount={4}
domain={["auto", "auto"]}
/>
<Tooltip content={<CustomTooltip />} />
<ChartTooltip
content={
<DenseTooltipContent
labelFormatter={(label) => formatDateShort(String(label))}
valueFormatter={(v) => centsToDisplay(Number(v))}
/>
}
/>
{boundary.hasPartial && boundary.date && (
<ReferenceArea
x1={points[0].date}
Expand All @@ -134,29 +150,27 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }
<Area
type="monotone"
dataKey="covered"
name={seriesName}
fill="url(#portfolioGradient)"
stroke={POSITIVE_COLOR}
stroke="var(--color-covered)"
strokeWidth={2}
connectNulls={false}
/>
<Line
type="monotone"
dataKey="partial"
name={boundary.hasPartial ? "Tracked accounts only" : seriesName}
stroke={boundary.hasPartial ? UNCOVERED_COLOR : POSITIVE_COLOR}
stroke="var(--color-partial)"
strokeWidth={boundary.hasPartial ? 1.75 : 2}
strokeDasharray={boundary.hasPartial ? "5 4" : undefined}
dot={false}
connectNulls={false}
/>
</ComposedChart>
</ResponsiveContainer>
</ChartContainer>
);
}

return (
<ResponsiveContainer width="100%" height="100%">
<ChartContainer config={MULTI_CONFIG} className="aspect-auto h-full w-full">
<ComposedChart data={data as unknown as ChartDataPoint[]} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<defs>
<linearGradient id="netWorthGradient" x1="0" y1="0" x2="0" y2="1">
Expand All @@ -175,21 +189,27 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }
tickCount={4}
domain={["auto", "auto"]}
/>
<Tooltip content={<CustomTooltip />} />
<ChartTooltip
content={
<DenseTooltipContent
labelFormatter={(label) => 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. */}
<Legend wrapperStyle={{ fontSize: 12 }} />
<ChartLegend content={<ChartLegendContent />} />
<Area
type="monotone"
dataKey="netWorth"
name="Net Worth"
fill="url(#netWorthGradient)"
stroke={POSITIVE_COLOR}
stroke="var(--color-netWorth)"
strokeWidth={2}
/>
<Line type="monotone" dataKey="assets" name="Assets" stroke={INCOME_COLOR} strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
<Line type="monotone" dataKey="liabilities" name="Liabilities" stroke={EXPENSE_COLOR} strokeWidth={1.5} dot={false} />
<Line type="monotone" dataKey="assets" stroke="var(--color-assets)" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
<Line type="monotone" dataKey="liabilities" stroke="var(--color-liabilities)" strokeWidth={1.5} dot={false} />
</ComposedChart>
</ResponsiveContainer>
</ChartContainer>
);
}
37 changes: 30 additions & 7 deletions src/components/atoms/spending-chart.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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];
Expand Down Expand Up @@ -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-<key>`
// 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 (
<div className="flex gap-3 h-full">
<div className="w-2/5 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<ChartContainer config={donutConfig} className="aspect-auto h-full w-full">
<PieChart>
<Pie
data={chartData}
Expand All @@ -76,9 +93,13 @@ export function SpendingChart({ data, viewMode, onItemClick }: SpendingChartProp
<Cell key={i} fill={colorAt(item, i)} />
))}
</Pie>
<Tooltip formatter={(v) => centsToDisplay(Number(v))} />
<ChartTooltip
content={
<ChartTooltipContent hideLabel valueFormatter={(v) => centsToDisplay(Number(v))} />
}
/>
</PieChart>
</ResponsiveContainer>
</ChartContainer>
</div>
<div className="w-3/5 overflow-y-auto overflow-x-hidden">
{chartData.map((row, i) => (
Expand All @@ -97,15 +118,17 @@ export function SpendingChart({ data, viewMode, onItemClick }: SpendingChartProp
}

return (
<ResponsiveContainer width="100%" height="100%">
<ChartContainer config={BAR_CONFIG} className="aspect-auto h-full w-full">
<BarChart data={chartData} layout="vertical" margin={{ left: 80 }}>
<XAxis
type="number"
tickFormatter={(v) => centsToDisplay(v).replace(/\.00$/, "")}
tick={{ fontSize: 11 }}
/>
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={75} />
<Tooltip formatter={(v) => centsToDisplay(Number(v))} />
<ChartTooltip
content={<ChartTooltipContent valueFormatter={(v) => centsToDisplay(Number(v))} />}
/>
<Bar
dataKey="value"
onClick={(_, index) => handleClick(index)}
Expand All @@ -116,7 +139,7 @@ export function SpendingChart({ data, viewMode, onItemClick }: SpendingChartProp
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartContainer>
);
}

Expand Down
Loading