From 21da7a3827e7fd21a6a5cc426d8594800604a334 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 18 Oct 2026 19:16:25 -0400 Subject: [PATCH] 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 --- src/hooks/useBudget.test.ts | 39 ++++++++++++++++++++++++++ src/hooks/useBudget.ts | 8 ++++-- src/services/budgetService.test.ts | 44 ++++++++++++++++++++++++++++++ src/services/budgetService.ts | 6 +++- 4 files changed, 94 insertions(+), 3 deletions(-) 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) || [];