fix: show a category whose parent is deleted or gone in the budget (#393)

The budget grid and the Budget vs Actual report read active categories
only and started from the categories without a parent, so a category whose
parent was deactivated or no longer exists vanished from both, budget
amounts included. It now stands at the top level, as in the tree and the
category pickers (displaysAsRoot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-18 19:16:25 -04:00
parent db5ee65720
commit 21da7a3827
4 changed files with 94 additions and 3 deletions

View file

@ -322,6 +322,45 @@ describe("buildBudgetYearRows — depth-first emission (issue #424)", () => {
}); });
}); });
describe("buildBudgetYearRows — a category whose parent is deleted or gone (#393)", () => {
// The budget reads active categories only: a deleted (inactive) parent is
// simply absent from the list, like a parent that no longer exists.
it("shows a leaf whose parent is absent as a root, with its budget amounts", () => {
const rows = buildBudgetYearRows(
[...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 99, sort_order: 5 })],
[entry(7, 40, 1), entry(7, 60, 2)],
[{ category_id: 7, actual: -80 }],
);
const gifts = rows.find((r) => r.category_id === 7);
expect(gifts).toMatchObject({ parent_id: null, depth: 0, is_parent: false, annual: 100, previousYearTotal: -80 });
expectDepthFirst(rows);
});
it("shows a parent whose own parent is absent as a root group, its children under it", () => {
const rows = buildBudgetYearRows(
[
...CATEGORIES,
cat(7, "Gifts", "expense", { parent_id: 99, is_inputable: false, sort_order: 5 }),
cat(8, "Flowers", "expense", { parent_id: 7, sort_order: 1 }),
],
[entry(8, 25)],
[],
);
expect(rows.find((r) => r.category_id === 7)).toMatchObject({ parent_id: null, depth: 0, is_parent: true, annual: 25 });
expect(rows.find((r) => r.category_id === 8)).toMatchObject({ parent_id: 7, depth: 1, annual: 25 });
expectDepthFirst(rows);
});
it("still hides the members of a parent_id cycle, which « Vérifier la hiérarchie » repairs", () => {
const rows = buildBudgetYearRows(
[...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 8 }), cat(8, "Flowers", "expense", { parent_id: 7 })],
[],
[],
);
expect(rows.some((r) => r.category_id === 7 || r.category_id === 8)).toBe(false);
});
});
describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => { describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => {
const rows = buildBudgetYearRows(CATEGORIES, [], []); const rows = buildBudgetYearRows(CATEGORIES, [], []);

View file

@ -12,6 +12,7 @@ import {
deleteTemplate as deleteTemplateSvc, deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService"; } from "../services/budgetService";
import { getAllImportSources, getTransactionCount } from "../services/transactionService"; import { getAllImportSources, getTransactionCount } from "../services/transactionService";
import { displaysAsRoot } from "../utils/categoryHierarchy";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
export interface BudgetState { export interface BudgetState {
@ -241,8 +242,11 @@ export function buildBudgetYearRows(
return [subtotal, ...gcRows]; return [subtotal, ...gcRows];
} }
// Identify top-level parents and standalone leaves // Identify top-level parents and standalone leaves. A category whose parent
const topLevel = allCategories.filter((c) => !c.parent_id); // is deactivated or gone (not in the active list) stands at the top level,
// as in the tree and the pickers, instead of vanishing from the grid (#393).
const activeIds = new Set(allCategories.map((c) => c.id));
const topLevel = allCategories.filter((c) => displaysAsRoot(c, activeIds));
for (const cat of topLevel) { for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || []; const children = childrenByParent.get(cat.id) || [];

View file

@ -94,6 +94,50 @@ describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => {
}); });
}); });
// The categories query keeps active rows only: a deleted (inactive) parent is
// absent from the list, like a parent that no longer exists (#393).
describe("getBudgetVsActualData — a category whose parent is deleted or gone (#393)", () => {
const answer = (categories: unknown[]) =>
mockSelect.mockImplementation((sql: string) => {
if (sql.includes("FROM categories")) return Promise.resolve(categories);
if (sql.includes("FROM budget_entries")) {
return Promise.resolve([{ id: 1, category_id: 7, year: 2026, month: 3, amount: 50 }]);
}
if (sql.includes("FROM transactions")) return Promise.resolve([{ category_id: 7, actual: -30 }]);
return Promise.resolve([]);
});
const category = (id: number, name: string, parent_id: number | null, is_inputable = 1) => ({
id,
name,
type: "expense",
parent_id,
is_active: 1,
is_inputable,
sort_order: id,
});
it("shows it as a root row, with its budget and its actuals", async () => {
answer([category(1, "Logement", null, 0), category(2, "Loyer", 1), category(7, "Cadeaux", 99)]);
const rows = await getBudgetVsActualData(2026, 3);
expect(rows.find((r) => r.category_id === 7)).toMatchObject({
parent_id: null,
depth: 0,
is_parent: false,
monthBudget: -50,
monthActual: -30,
});
});
it("shows a parent whose own parent is absent as a root group", async () => {
answer([category(6, "Fêtes", 99, 0), category(7, "Cadeaux", 6)]);
const rows = await getBudgetVsActualData(2026, 3);
expect(rows.map((r) => [r.category_id, r.parent_id, r.depth, r.is_parent])).toEqual([
[6, null, 0, true],
[7, 6, 1, false],
]);
});
});
// getActualTotalsForYear is the previous-year actuals reference column // getActualTotalsForYear is the previous-year actuals reference column
// consumed by useBudget (Issue #276) — a thin wrapper over the same // consumed by useBudget (Issue #276) — a thin wrapper over the same
// getActualsByCategoryRange helper, so it gets the same accountIds pass-through. // getActualsByCategoryRange helper, so it gets the same accountIds pass-through.

View file

@ -2,6 +2,7 @@ import type Database from "@tauri-apps/plugin-sql";
import { getDb } from "./db"; import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents"; import { notifyDataChanged } from "./dataEvents";
import { inPlaceholders } from "../utils/sqlFilters"; import { inPlaceholders } from "../utils/sqlFilters";
import { displaysAsRoot } from "../utils/categoryHierarchy";
import type { import type {
Category, Category,
BudgetEntry, BudgetEntry,
@ -409,7 +410,10 @@ export async function getBudgetVsActualData(
} }
const rows: BudgetVsActualRow[] = []; const rows: BudgetVsActualRow[] = [];
const topLevel = allCategories.filter((c) => !c.parent_id); // A category whose parent is deactivated or gone (not in the active list)
// stands at the top level, as in the tree and the pickers (#393).
const activeIds = new Set(allCategories.map((c) => c.id));
const topLevel = allCategories.filter((c) => displaysAsRoot(c, activeIds));
for (const cat of topLevel) { for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || []; const children = childrenByParent.get(cat.id) || [];