diff --git a/src/hooks/useBudget.test.ts b/src/hooks/useBudget.test.ts index 822eefc..77cad06 100644 --- a/src/hooks/useBudget.test.ts +++ b/src/hooks/useBudget.test.ts @@ -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)", () => { const rows = buildBudgetYearRows(CATEGORIES, [], []); diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index 45a37b8..80499b5 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -12,6 +12,7 @@ import { deleteTemplate as deleteTemplateSvc, } from "../services/budgetService"; import { getAllImportSources, getTransactionCount } from "../services/transactionService"; +import { displaysAsRoot } from "../utils/categoryHierarchy"; import { useReportsPeriod } from "./useReportsPeriod"; export interface BudgetState { @@ -241,8 +242,11 @@ export function buildBudgetYearRows( return [subtotal, ...gcRows]; } - // Identify top-level parents and standalone leaves - const topLevel = allCategories.filter((c) => !c.parent_id); + // Identify top-level parents and standalone leaves. 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, 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) { const children = childrenByParent.get(cat.id) || []; diff --git a/src/services/budgetService.test.ts b/src/services/budgetService.test.ts index d22e0f1..8e8ebc3 100644 --- a/src/services/budgetService.test.ts +++ b/src/services/budgetService.test.ts @@ -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 // consumed by useBudget (Issue #276) — a thin wrapper over the same // getActualsByCategoryRange helper, so it gets the same accountIds pass-through. diff --git a/src/services/budgetService.ts b/src/services/budgetService.ts index 9bc2586..de5d684 100644 --- a/src/services/budgetService.ts +++ b/src/services/budgetService.ts @@ -2,6 +2,7 @@ import type Database from "@tauri-apps/plugin-sql"; import { getDb } from "./db"; import { notifyDataChanged } from "./dataEvents"; import { inPlaceholders } from "../utils/sqlFilters"; +import { displaysAsRoot } from "../utils/categoryHierarchy"; import type { Category, BudgetEntry, @@ -409,7 +410,10 @@ export async function getBudgetVsActualData( } 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) { const children = childrenByParent.get(cat.id) || [];