fix: keep pie chart colors stable across selections
This commit is contained in:
@@ -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(<MonthPieShowcase view={monthView(data, "2026-09")} baseCurrency="DKK" />));
|
||||
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");
|
||||
|
||||
@@ -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 ? (
|
||||
<circle cx="100" cy="100" r="88" fill="var(--budget-tint)" />
|
||||
) : chart.positive.length === 1 ? (
|
||||
<circle cx="100" cy="100" r="88" fill={COLORS[0]}>
|
||||
<circle cx="100" cy="100" r="88" fill={sliceColor(chart.positive[0]!.id)}>
|
||||
<title>{`${chart.positive[0]!.label}: ${fmt.money(chart.positive[0]!.amount, baseCurrency)}`}</title>
|
||||
</circle>
|
||||
) : chart.positive.map((item, index) => {
|
||||
@@ -63,7 +71,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
|
||||
<path
|
||||
key={item.id}
|
||||
d={wedge(start, index === chart.positive.length - 1 ? 360 : angle)}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
fill={sliceColor(item.id)}
|
||||
stroke="var(--budget-raised)"
|
||||
strokeWidth="2"
|
||||
>
|
||||
@@ -97,7 +105,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
|
||||
return next;
|
||||
});
|
||||
}} />
|
||||
{!spending.focusLabel && !excluded.has(post.id) && post.amount > 0 && <span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: COLORS[chart.positive.findIndex(item => item.id === post.id) % COLORS.length] }} aria-hidden="true" />}
|
||||
{!spending.focusLabel && !excluded.has(post.id) && post.amount > 0 && <span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: sliceColor(post.id) }} aria-hidden="true" />}
|
||||
<span className="min-w-0 flex-1 break-words">{post.label}</span>
|
||||
<span className="font-money shrink-0 tabular-nums">{fmt.money(post.amount, baseCurrency)}
|
||||
{!spending.focusLabel && !excluded.has(post.id) && post.amount > 0 && <span className="ml-1 text-xs text-budget-ink-muted">{Math.round(post.amount / chart.positiveTotal * 100)}%</span>}
|
||||
@@ -112,9 +120,9 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
|
||||
</p>
|
||||
) : mode === "allocation" || spending.focusLabel ? (
|
||||
<ol className="space-y-2.5">
|
||||
{chart.positive.map((item, index) => (
|
||||
{chart.positive.map((item) => (
|
||||
<li key={item.id} className="grid grid-cols-[1rem_minmax(0,1fr)_auto] items-baseline gap-x-2.5 text-sm">
|
||||
<span className="inline-block size-2.5 self-center rounded-full" style={{ backgroundColor: COLORS[index % COLORS.length] }} aria-hidden="true" />
|
||||
<span className="inline-block size-2.5 self-center rounded-full" style={{ backgroundColor: sliceColor(item.id) }} aria-hidden="true" />
|
||||
<span className="min-w-0 truncate" title={item.label}>{item.label}</span>
|
||||
<span className="font-money text-right tabular-nums whitespace-nowrap">{fmt.money(item.amount, baseCurrency)} <span className="ml-1 text-xs text-budget-ink-muted">{Math.round((item.amount / chart.positiveTotal) * 100)}%</span></span>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user