From 213b138bcea476f56e8e9312d1a2e58a493cac66 Mon Sep 17 00:00:00 2001
From: Lucas Winther
Date: Sat, 3 Oct 2026 20:15:58 +0200
Subject: [PATCH] fix: keep pie chart colors stable across selections
---
src/ui/components/MonthPieShowcase.test.tsx | 51 +++++++++++++++++++++
src/ui/components/MonthPieShowcase.tsx | 18 ++++++--
2 files changed, 64 insertions(+), 5 deletions(-)
diff --git a/src/ui/components/MonthPieShowcase.test.tsx b/src/ui/components/MonthPieShowcase.test.tsx
index 792c27f..650cf50 100644
--- a/src/ui/components/MonthPieShowcase.test.tsx
+++ b/src/ui/components/MonthPieShowcase.test.tsx
@@ -10,6 +10,57 @@ import { monthView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { MonthPieShowcase } from "./MonthPieShowcase.tsx";
+test("pie colors survive filtering, clearing, purchase focus, and allocation sorting", () => {
+ localStorage.removeItem("budget2:show-month-breakdown");
+ const data = createSeedDataset("2026-09");
+ const posts = data.posts.slice(0, 3);
+ data.purchases = posts.map((post, index) => ({
+ id: `purchase-${index}`, date: "2026-09", description: `Purchase ${index}`,
+ total: { amount: 300 - index * 100, currency: "DKK" }, splitMode: "percent" as const,
+ splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], schedule: null,
+ }));
+ data.posts.forEach(post => { post.rules = []; });
+ posts.forEach((post, index) => {
+ post.rules = [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: (index + 1) * 100, currency: "DKK" } } }];
+ });
+ const original = { get: store.get, subscribe: store.subscribe };
+ store.get = () => data;
+ store.subscribe = () => () => {};
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const fills = () => new Map([...container.querySelectorAll("svg title")].map(title => [title.textContent!.split(":")[0], title.parentElement!.getAttribute("fill")]));
+ const checkbox = (index: number) => container.querySelector(`input[aria-label="Include ${posts[index]!.name}"]`) as HTMLInputElement;
+ const button = (label: string) => [...container.querySelectorAll("button")].find(item => item.textContent === label)!;
+ try {
+ act(() => root.render());
+ const initial = fills();
+ expect(new Set(initial.values()).size).toBe(3);
+ act(() => checkbox(0).click());
+ expect(fills().get(posts[1]!.name)).toBe(initial.get(posts[1]!.name));
+ expect(fills().get(posts[2]!.name)).toBe(initial.get(posts[2]!.name));
+ for (const index of [1, 2]) {
+ const dot = checkbox(index).parentElement!.querySelector('span[aria-hidden="true"]') as HTMLElement;
+ expect(dot.style.backgroundColor).toBe(initial.get(posts[index]!.name)!);
+ }
+ act(() => checkbox(1).click());
+ const purchaseColor = fills().get("Purchase 2");
+ expect(purchaseColor).toBeTruthy();
+ const purchaseDot = container.querySelector('ol span[aria-hidden="true"]') as HTMLElement;
+ expect(purchaseDot.style.backgroundColor).toBe(purchaseColor!);
+ act(() => button("Clear").click());
+ act(() => checkbox(2).click());
+ expect(fills().get("Purchase 2")).toBe(purchaseColor);
+ act(() => button("Select all").click());
+ expect(fills()).toEqual(initial);
+ act(() => button("Allocated").click());
+ expect(fills()).toEqual(initial);
+ } finally {
+ act(() => root.unmount());
+ Object.assign(store, original);
+ localStorage.removeItem("budget2:show-month-breakdown");
+ }
+});
+
test("hiding the breakdown stays remembered after remount and leaves its control reachable", () => {
localStorage.removeItem("budget2:show-month-breakdown");
const data = createSeedDataset("2026-09");
diff --git a/src/ui/components/MonthPieShowcase.tsx b/src/ui/components/MonthPieShowcase.tsx
index ca81780..e79083c 100644
--- a/src/ui/components/MonthPieShowcase.tsx
+++ b/src/ui/components/MonthPieShowcase.tsx
@@ -34,6 +34,14 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
const fmt = useMoneyFormat();
const spending = spendingPieData(dataset, view.monthId, excluded);
const chart = mode === "spending" ? spending.chart : monthPieData(view, mode);
+ // Reserve colors before filtering or sorting, so selection never recolors a post.
+ const postColors = new Map(dataset.posts.map((post, index) => [post.id, COLORS[index % COLORS.length]!]));
+ const purchaseColors = new Map(dataset.purchases.map((purchase, index) => [purchase.id, COLORS[index % COLORS.length]!]));
+ const postColor = (id: string) => id === "unassigned"
+ ? COLORS[dataset.posts.length % COLORS.length]!
+ : postColors.get(id)!;
+ const sliceColor = (id: string) => mode === "allocation" ? postColor(id)
+ : spending.focusLabel ? purchaseColors.get(id)! : postColor(id === "unassigned" ? id : id.slice("post:".length));
let angle = 0;
const label = mode === "spending" ? "spending" : "allocation";
@@ -53,7 +61,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
{chart.positiveTotal === 0 ? (
) : chart.positive.length === 1 ? (
-
+
{`${chart.positive[0]!.label}: ${fmt.money(chart.positive[0]!.amount, baseCurrency)}`}
) : chart.positive.map((item, index) => {
@@ -63,7 +71,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
@@ -97,7 +105,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
return next;
});
}} />
- {!spending.focusLabel && !excluded.has(post.id) && post.amount > 0 && item.id === post.id) % COLORS.length] }} aria-hidden="true" />}
+ {!spending.focusLabel && !excluded.has(post.id) && post.amount > 0 && }
{post.label}
{fmt.money(post.amount, baseCurrency)}
{!spending.focusLabel && !excluded.has(post.id) && post.amount > 0 && {Math.round(post.amount / chart.positiveTotal * 100)}%}
@@ -112,9 +120,9 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
) : mode === "allocation" || spending.focusLabel ? (
- {chart.positive.map((item, index) => (
+ {chart.positive.map((item) => (
-
-
+
{item.label}
{fmt.money(item.amount, baseCurrency)} {Math.round((item.amount / chart.positiveTotal) * 100)}%