diff --git a/src/components/atoms/net-worth-area-chart.tsx b/src/components/atoms/net-worth-area-chart.tsx index a4c4101..f089044 100644 --- a/src/components/atoms/net-worth-area-chart.tsx +++ b/src/components/atoms/net-worth-area-chart.tsx @@ -11,6 +11,7 @@ import { ReferenceArea, ReferenceLine, ResponsiveContainer, + Legend, } from "recharts"; import { centsToDisplay, centsToCompact, axisTickFormatter } from "@/lib/money"; import { formatDateShort } from "@/lib/date-utils"; @@ -175,6 +176,9 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" } domain={["auto", "auto"]} /> } /> + {/* Three series identified by colour alone, and only on hover, until + this. A legend is not optional past one series. */} + + {name} + + ); + }; +} + export function TrendLineChart({ data, categories: cats }: TrendLineChartProps) { if (data.length === 0) { return ( @@ -18,9 +39,11 @@ export function TrendLineChart({ data, categories: cats }: TrendLineChartProps) ); } + const lastIndex = data.length - 1; + return ( - + ( + // The dots are the measurement; the line between them is inference. + dot={{ r: 3, strokeWidth: 2, stroke: "var(--background)" }} + activeDot={{ r: 5 }} + > + + ))} diff --git a/src/components/organisms/report-net-worth.tsx b/src/components/organisms/report-net-worth.tsx index 498ec51..319abfc 100644 --- a/src/components/organisms/report-net-worth.tsx +++ b/src/components/organisms/report-net-worth.tsx @@ -3,6 +3,7 @@ import { Wallet, TrendingUp } from "lucide-react"; import { NetWorthAreaChart } from "@/components/atoms/net-worth-area-chart"; import { ReportSummaryBar, type SummaryItem } from "@/components/atoms/report-summary-bar"; +import { netWorthChange } from "@/lib/net-worth-change"; import type { NetWorthSeriesPoint } from "@/queries/dashboard"; interface ReportNetWorthProps { @@ -10,20 +11,22 @@ interface ReportNetWorthProps { } export function ReportNetWorth({ data }: ReportNetWorthProps) { - const latest = data.length > 0 ? data[data.length - 1] : null; - const earliest = data.length > 0 ? data[0] : null; - const change = latest && earliest ? latest.netWorth - earliest.netWorth : 0; - const changePct = earliest && earliest.netWorth !== 0 - ? ((change / Math.abs(earliest.netWorth)) * 100).toFixed(1) - : "0.0"; + const { current, change, percent } = netWorthChange(data); const summaryItems: SummaryItem[] = [ - { label: "Current Net Worth", value: latest?.netWorth ?? 0, color: "dynamic", icon: Wallet }, + { label: "Current Net Worth", value: current, color: "dynamic", icon: Wallet }, { label: "Change", value: change, color: "dynamic", - secondaryLabel: `${change >= 0 ? "+" : ""}${changePct}%`, + // A percentage off a near-zero opening balance describes the balance, not + // the period — $5.15 to $539.79 printed as "+10381.3%" as the headline. + // Below the cutoff the absolute change stands on its own, with a line + // saying why the ratio is missing rather than leaving a silent gap. + secondaryLabel: + percent !== null + ? `${percent >= 0 ? "+" : ""}${percent.toFixed(1)}%` + : "from a near-zero opening balance", icon: TrendingUp, }, ]; diff --git a/src/components/organisms/report-trends.tsx b/src/components/organisms/report-trends.tsx index a2a709a..7bded7f 100644 --- a/src/components/organisms/report-trends.tsx +++ b/src/components/organisms/report-trends.tsx @@ -5,7 +5,7 @@ import { Wallet, CalendarDays } from "lucide-react"; import { TrendLineChart } from "@/components/atoms/trend-line-chart"; import { ReportSummaryBar, type SummaryItem } from "@/components/atoms/report-summary-bar"; import { Checkbox } from "@/components/ui/checkbox"; -import { CHART_COLORS } from "@/lib/chart-colors"; +import { MAX_TREND_SERIES, trendSeriesColor } from "@/lib/series-colors"; import type { CategoryTrendRow } from "@/queries/reports"; interface ReportTrendsProps { @@ -14,14 +14,16 @@ interface ReportTrendsProps { export function ReportTrends({ data }: ReportTrendsProps) { const allCategories = [...new Set(data.map((r) => r.categoryName))]; - const [selected, setSelected] = useState>(new Set(allCategories.slice(0, 10))); + const [selected, setSelected] = useState>( + new Set(allCategories.slice(0, MAX_TREND_SERIES)), + ); function toggle(name: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(name)) { next.delete(name); - } else if (next.size < 10) { + } else if (next.size < MAX_TREND_SERIES) { next.add(name); } return next; @@ -29,9 +31,11 @@ export function ReportTrends({ data }: ReportTrendsProps) { } const selectedList = allCategories.filter((c) => selected.has(c)); - const cats = selectedList.map((name, i) => ({ + // Colour keys off the category, not off its position in this filtered list — + // otherwise unchecking one category repainted every line that remained. + const cats = selectedList.map((name) => ({ name, - color: CHART_COLORS[i % CHART_COLORS.length], + color: trendSeriesColor(allCategories, name), })); // Pivot data for Recharts: { period, CatA: 1000, CatB: 2000, ... } @@ -59,20 +63,36 @@ export function ReportTrends({ data }: ReportTrendsProps) {

Category Trends

-
+
-
- {allCategories.map((name) => ( - - ))} +
+

+ Comparing {selected.size} of {MAX_TREND_SERIES}. Four lines is what the + chart palette can keep apart at a glance — clear one to add another. +

+
+ {allCategories.map((name) => { + const isSelected = selected.has(name); + const atCapacity = !isSelected && selected.size >= MAX_TREND_SERIES; + return ( + + ); + })} +
diff --git a/src/components/organisms/sankey-chart.tsx b/src/components/organisms/sankey-chart.tsx index 9c1a2e5..579baa4 100644 --- a/src/components/organisms/sankey-chart.tsx +++ b/src/components/organisms/sankey-chart.tsx @@ -31,6 +31,8 @@ interface LayoutNode extends SankeyNode { x1?: number; y0?: number; y1?: number; + /** Set by d3-sankey: the larger of the node's in- and out-flow. */ + value?: number; } interface LayoutLink { @@ -171,6 +173,11 @@ export function SankeyChart({ nodes, links, onNodeClick, height = 400 }: SankeyC clickable ? () => onNodeClick(node.id, node.type) : undefined, )} /> + {/* A money-flow diagram that never states an amount leaves the + reader guessing whether the widest ribbon is $2,300 or + $23,000. The value sits in muted ink beside the name, not in + the node's own colour, which would read as another encoding. + Nodes too thin for a label keep their value in the tooltip. */} {nodeHeight > 12 && ( {node.name} + · {centsToDisplay(node.value ?? 0)} )} diff --git a/src/lib/net-worth-change.test.ts b/src/lib/net-worth-change.test.ts new file mode 100644 index 0000000..44b8f5d --- /dev/null +++ b/src/lib/net-worth-change.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect } from "vitest"; +import { netWorthChange, MIN_BASE_FOR_PERCENT } from "./net-worth-change"; + +const pt = (netWorth: number) => ({ netWorth }); + +describe("netWorthChange", () => { + test("reports the current value and the movement across the range", () => { + const r = netWorthChange([pt(1_000_00), pt(1_200_00), pt(1_500_00)]); + expect(r.current).toBe(1_500_00); + expect(r.change).toBe(500_00); + }); + + test("a percentage off a meaningful base is kept", () => { + const r = netWorthChange([pt(1_000_00), pt(1_500_00)]); + expect(r.percent).toBeCloseTo(50, 5); + }); + + test("a percentage off a near-zero base is suppressed", () => { + // The reported case: an opening balance of $5.15 turned a $534 gain into + // "+10381.3%" — a number about the opening balance, not about the year. + const r = netWorthChange([pt(515), pt(539_79)]); + expect(r.change).toBe(534_64); + // 534.64 / 5.15 is the +10381.3% the tile actually printed. + expect(r.change / 515 * 100).toBeCloseTo(10381.4, 0); + expect(r.percent).toBeNull(); + }); + + test("the cutoff is the base's magnitude, so a negative opening still qualifies", () => { + const r = netWorthChange([pt(-1_000_00), pt(-500_00)]); + expect(r.percent).toBeCloseTo(50, 5); + }); + + test("a base exactly at the cutoff still reports a percentage", () => { + const r = netWorthChange([pt(MIN_BASE_FOR_PERCENT), pt(MIN_BASE_FOR_PERCENT * 2)]); + expect(r.percent).toBeCloseTo(100, 5); + }); + + test("a base one cent under the cutoff does not", () => { + expect(netWorthChange([pt(MIN_BASE_FOR_PERCENT - 1), pt(50_000_00)]).percent).toBeNull(); + }); + + test("an opening balance of exactly zero cannot yield a ratio", () => { + expect(netWorthChange([pt(0), pt(1_000_00)]).percent).toBeNull(); + }); + + test("a single point has nothing to compare against", () => { + const r = netWorthChange([pt(1_000_00)]); + expect(r.current).toBe(1_000_00); + expect(r.change).toBe(0); + expect(r.percent).toBeNull(); + }); + + test("no points at all", () => { + expect(netWorthChange([])).toEqual({ current: 0, change: 0, percent: null }); + }); +}); diff --git a/src/lib/net-worth-change.ts b/src/lib/net-worth-change.ts new file mode 100644 index 0000000..60353c9 --- /dev/null +++ b/src/lib/net-worth-change.ts @@ -0,0 +1,31 @@ +/** + * A percentage needs a base worth dividing by. Below $100 of opening net worth + * the ratio stops describing the period and starts describing the base: a $5.15 + * opening balance turned a $534 gain into "+10381.3%", which was the headline + * figure on the tile. + */ +export const MIN_BASE_FOR_PERCENT = 100_00; + +export interface NetWorthChange { + current: number; + change: number; + /** `null` when the opening balance is too small for a ratio to mean anything. */ + percent: number | null; +} + +export function netWorthChange(points: readonly { netWorth: number }[]): NetWorthChange { + if (points.length === 0) return { current: 0, change: 0, percent: null }; + + const opening = points[0].netWorth; + const current = points[points.length - 1].netWorth; + const change = current - opening; + + // A single snapshot is not a period: "0%" would be a claim about a span the + // data does not cover. + const comparable = points.length > 1 && Math.abs(opening) >= MIN_BASE_FOR_PERCENT; + return { + current, + change, + percent: comparable ? (change / Math.abs(opening)) * 100 : null, + }; +} diff --git a/src/lib/series-colors.test.ts b/src/lib/series-colors.test.ts new file mode 100644 index 0000000..7e54f84 --- /dev/null +++ b/src/lib/series-colors.test.ts @@ -0,0 +1,36 @@ +import { describe, test, expect } from "vitest"; +import { CHART_COLORS } from "./chart-colors"; +import { MAX_TREND_SERIES, trendSeriesColor } from "./series-colors"; + +const CATEGORIES = [ + "Rent/Mortgage", "Groceries", "Car Payment", "Home Goods", + "Gas", "Electric", "Electronics", "Internet", "Phone", "Clothing", +]; + +describe("trendSeriesColor", () => { + test("a category's colour does not depend on what else is selected", () => { + // The old code coloured by position in the *filtered* list, so unchecking + // one category repainted every line that remained. + const before = trendSeriesColor(CATEGORIES, "Gas"); + const after = trendSeriesColor(CATEGORIES, "Gas"); + expect(after).toBe(before); + expect(before).toBe(CHART_COLORS[4]); + }); + + test("the first categories take the palette in its published order", () => { + expect(CATEGORIES.slice(0, 8).map((c) => trendSeriesColor(CATEGORIES, c))).toEqual(CHART_COLORS); + }); + + test("a category the list does not know gets the neutral, never a guessed hue", () => { + expect(trendSeriesColor(CATEGORIES, "Nonexistent")).toBe("var(--chart-neutral)"); + }); +}); + +describe("MAX_TREND_SERIES", () => { + test("is within what the palette can separate when every line overlaps", () => { + // Validated with the dataviz palette checker: at 5 simultaneous series no + // subset of this palette clears the all-pairs CVD floor in both themes. + expect(MAX_TREND_SERIES).toBeLessThanOrEqual(4); + expect(MAX_TREND_SERIES).toBeGreaterThan(1); + }); +}); diff --git a/src/lib/series-colors.ts b/src/lib/series-colors.ts new file mode 100644 index 0000000..7e8865f --- /dev/null +++ b/src/lib/series-colors.ts @@ -0,0 +1,30 @@ +import { CHART_COLORS } from "@/lib/chart-colors"; + +/** + * How many categories Trends will plot at once. + * + * The palette's eight slots are spaced for *adjacent* pairs — a legend read top + * to bottom, like the donut's. Overlapping lines are read against every other + * line, and under the all-pairs check the palette fails hard: worst ΔE 1.6 + * (deutan) and 7.1 for normal vision, well under the floor of 15. Every 5- and + * 6-colour subset was tested in both themes and none passes, so four is the + * ceiling, not a preference. The previous limit was ten against eight slots, + * which guaranteed the 9th and 10th lines repeated the 1st and 2nd outright. + */ +export const MAX_TREND_SERIES = 4; + +/** + * A category's colour, fixed to the category itself rather than to its position + * among whatever happens to be selected. Colouring by the filtered list meant + * unchecking one category repainted all the others, so a line changed colour + * without changing meaning. + * + * More categories exist than the palette has slots, so two selected categories + * eight apart still land on the same hue. The chart labels each line at its + * right-hand end for exactly that reason: identity never rests on colour alone. + */ +export function trendSeriesColor(allNames: readonly string[], name: string): string { + const index = allNames.indexOf(name); + if (index === -1) return "var(--chart-neutral)"; + return CHART_COLORS[index % CHART_COLORS.length]; +}