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];
+}