From 1a36d4e61865de269b21f537a041feb9bb3b3da9 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 19 Oct 2026 20:51:24 -0400 Subject: [PATCH 01/25] feat(categories): refuse a parent that holds budget amounts or keywords, on create too (#393) Decision of Max of 2026-10-19 (review 25437, point 3): a category that holds direct transactions, a non-zero budget amount (budget_entries or budget_template_entries) or active keywords cannot become a parent. The check (`checkParentContent`) runs through the transaction's handle, before any write, in `updateCategory` (changed parent), `reattachCategory` and now `createCategory`, which writes its row and the parent's flag in one transaction. Order: transactions, budget, keywords; the first found is the refusal (`parent_has_budget`, `parent_has_keywords` added to `CategoryHierarchyError`, translated in FR and EN). The service tests run the content queries as written on node:sqlite, tables created from consolidated_schema.sql. Co-Authored-By: Claude Opus 5.5 --- src/hooks/useCategories.test.ts | 26 ++++ src/i18n/locales/en.json | 6 +- src/i18n/locales/fr.json | 6 +- src/services/categoryService.test.ts | 220 +++++++++++++++++++++++++-- src/services/categoryService.ts | 105 +++++++++---- 5 files changed, 313 insertions(+), 50 deletions(-) diff --git a/src/hooks/useCategories.test.ts b/src/hooks/useCategories.test.ts index 9791cd4..5e8adf4 100644 --- a/src/hooks/useCategories.test.ts +++ b/src/hooks/useCategories.test.ts @@ -42,6 +42,7 @@ import { } from "./useCategories"; import { CategoryHierarchyError, + createCategory, findOrphanCategories, getAllCategoriesWithCounts, reattachCategory, @@ -80,6 +81,7 @@ beforeEach(() => { vi.mocked(getAllCategoriesWithCounts).mockReset(); vi.mocked(getAllCategoriesWithCounts).mockResolvedValue(ROWS as never); vi.mocked(updateCategory).mockReset(); + vi.mocked(createCategory).mockReset(); vi.mocked(findOrphanCategories).mockReset(); vi.mocked(reattachCategory).mockReset(); vi.mocked(logInfo).mockReset(); @@ -92,6 +94,15 @@ describe("categoriesErrorOf / categoriesErrorText (#393)", () => { expect(categoriesErrorText(error, markedT)).toBe("t(categories.hierarchy.errors.cycle)"); }); + it("names the refusals of a parent's budget amounts and keywords by their keys (decision of 2026-10-19)", () => { + expect(categoriesErrorOf(new CategoryHierarchyError("parent_has_budget"))).toEqual({ + key: "categories.hierarchy.errors.parent_has_budget", + }); + expect(categoriesErrorOf(new CategoryHierarchyError("parent_has_keywords"))).toEqual({ + key: "categories.hierarchy.errors.parent_has_keywords", + }); + }); + it("keeps the message of any other failure", () => { const error = categoriesErrorOf(new Error("database is locked")); expect(error).toEqual({ message: "database is locked" }); @@ -116,6 +127,21 @@ describe("useCategories — saving the form (#393)", () => { expect(state.editingCategory).not.toBeNull(); }); + it("shows a refused sub-category by its translated code, and keeps the creation form open", async () => { + vi.mocked(createCategory).mockRejectedValue(new CategoryHierarchyError("parent_has_keywords")); + let api = await mount(); + api.startCreating(); + api = harness.render(useCategories); + const child = { ...FORM, name: "Électricité", parent_id: 10 }; + await api.saveCategory(child); + const { state } = harness.render(useCategories); + expect(createCategory).toHaveBeenCalledWith(expect.objectContaining({ name: "Électricité", parent_id: 10 })); + expect(state.error).toEqual({ key: "categories.hierarchy.errors.parent_has_keywords" }); + expect(state.isSaving).toBe(false); + expect(state.isCreating).toBe(true); + expect(state.editingCategory).not.toBeNull(); + }); + it("shows any other failure by its message", async () => { vi.mocked(updateCategory).mockRejectedValue(new Error("database is locked")); let api = await mount(); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f594a56..2e35478 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -466,7 +466,9 @@ "self_parent": "A category cannot be its own parent.", "cycle": "This parent would create a parent loop: it sits under this category, or inside a loop. Choose another parent.", "too_deep": "With this parent, the category or its subcategories would go beyond three levels. Choose a parent higher in the hierarchy.", - "parent_has_transactions": "This parent has transactions of its own: once a parent, it would no longer take input. Choose another parent, or recategorize its transactions first." + "parent_has_transactions": "This parent has transactions of its own: once a parent, it would no longer take input. Choose another parent, or recategorize its transactions first.", + "parent_has_budget": "This parent has budget amounts of its own, in the budget or in a budget template: once a parent, it would no longer take input and those amounts would drop out of the grid. Choose another parent, or first set those amounts to zero and delete, on the Budget page, the templates that hold any.", + "parent_has_keywords": "This parent has keywords of its own: once a parent, it would no longer take input, yet its keywords would keep sorting transactions into it. Choose another parent, or first delete its keywords (add them to another category if needed)." } }, "help": { @@ -1201,7 +1203,7 @@ "Use the All Keywords view to get a global overview of your categorization rules", "Use Re-initialize to reset categories to defaults — this will unlink all transaction categories", "A category missing from the tree, the menus and the budget is often caught in a parent loop: Check hierarchy finds it, and its transactions, keywords and budget amounts come back with it", - "A category with transactions of its own cannot become the parent of an existing category, since it would no longer take input: recategorize its transactions first, or choose another parent" + "A category with transactions, budget amounts (in the budget or in a template) or keywords of its own cannot become a parent, of an existing category or of a new subcategory, since it would no longer take input: first recategorize its transactions, set its amounts to zero or delete its keywords, or choose another parent" ] }, "adjustments": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 16a165a..9a89e68 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -466,7 +466,9 @@ "self_parent": "Une catégorie ne peut pas être son propre parent.", "cycle": "Ce parent créerait une boucle de parenté : il se trouve sous cette catégorie, ou dans une boucle. Choisissez un autre parent.", "too_deep": "Avec ce parent, la catégorie ou ses sous-catégories dépasseraient trois niveaux. Choisissez un parent plus haut dans la hiérarchie.", - "parent_has_transactions": "Ce parent a des transactions à son nom : devenu parent, il ne serait plus saisissable. Choisissez un autre parent, ou reclassez d'abord ses transactions." + "parent_has_transactions": "Ce parent a des transactions à son nom : devenu parent, il ne serait plus saisissable. Choisissez un autre parent, ou reclassez d'abord ses transactions.", + "parent_has_budget": "Ce parent a des montants de budget à son nom, dans le budget ou dans un modèle de budget : devenu parent, il ne serait plus saisissable et ces montants sortiraient de la grille. Choisissez un autre parent, ou remettez d'abord ces montants à zéro et supprimez, dans la page Budget, les modèles qui en contiennent.", + "parent_has_keywords": "Ce parent a des mots-clés à son nom : devenu parent, il ne serait plus saisissable, mais ses mots-clés continueraient d'y classer des transactions. Choisissez un autre parent, ou supprimez d'abord ses mots-clés (ajoutez-les au besoin à une autre catégorie)." } }, "help": { @@ -1201,7 +1203,7 @@ "Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation", "Utilisez Réinitialiser pour revenir aux catégories par défaut — cela dissociera toutes les catégories des transactions", "Une catégorie disparue de l'arbre, des menus et du budget est souvent prise dans une boucle de parenté : Vérifier la hiérarchie la retrouve, et ses transactions, ses mots-clés et ses montants de budget reviennent avec elle", - "Une catégorie qui a des transactions à son nom ne peut pas devenir le parent d'une catégorie existante, puisqu'elle ne serait plus saisissable : reclassez d'abord ses transactions, ou choisissez un autre parent" + "Une catégorie qui a des transactions, des montants de budget (dans le budget ou dans un modèle) ou des mots-clés à son nom ne peut pas devenir un parent, ni d'une catégorie existante ni d'une nouvelle sous-catégorie, puisqu'elle ne serait plus saisissable : reclassez d'abord ses transactions, remettez ses montants à zéro ou supprimez ses mots-clés, ou choisissez un autre parent" ] }, "adjustments": { diff --git a/src/services/categoryService.test.ts b/src/services/categoryService.test.ts index f74c42e..6c5a0a3 100644 --- a/src/services/categoryService.test.ts +++ b/src/services/categoryService.test.ts @@ -1,14 +1,24 @@ // categoryService — the hierarchy rules at the write (#393): a new parent is -// checked by `updateCategory` and `reattachCategory`, the reads and the writes -// run in one transaction through its handle, the orphan diagnostic reads every -// category, and `getCategoryDepth` ends on a cycle. +// checked by `updateCategory` and `reattachCategory`, a parent's content by +// `createCategory` too, the reads and the writes run in one transaction +// through its handle, the orphan diagnostic reads every category, and +// `getCategoryDepth` ends on a cycle. // -// The base is a fake: a `categories` table in memory, the direct transaction -// count of each category, BEGIN/COMMIT/ROLLBACK. `withTransaction` hands the -// callback its own handle; the global connection of `getDb()` throws while a -// transaction runs, as the real lock would deadlock (docs/architecture.md, -// « Accès DB et transactions »). +// The base is a fake: a `categories` table in memory and BEGIN/COMMIT/ROLLBACK. +// What a parent holds (its transactions, budget amounts and keywords) lives in +// a real SQLite database (`node:sqlite`, built into Node, no package), whose +// tables are created by their CREATE TABLE of the schema of a new profile +// (`consolidated_schema.sql`): the checks' SQL runs as written, so a condition +// dropped from a query (`amount <> 0`, `is_active = 1`) turns a test red. +// `withTransaction` hands the callback its own handle; the global connection +// of `getDb()` throws while a transaction runs, as the real lock would +// deadlock (docs/architecture.md, « Accès DB et transactions »). import { describe, it, expect, vi, beforeEach } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { DatabaseSync } from "node:sqlite"; +import fr from "../i18n/locales/fr.json"; +import en from "../i18n/locales/en.json"; const fake = vi.hoisted(() => ({ inTransaction: false, @@ -30,6 +40,7 @@ vi.mock("./db", () => ({ import { withTransaction } from "./db"; import { + CATEGORY_HIERARCHY_ERROR_CODES, CategoryHierarchyError, categoryHierarchyErrorKey, createCategory, @@ -60,21 +71,70 @@ const row = (id: number, parent_id: number | null, extra: Partial = {}): Ro const HIERARCHY_SQL = "SELECT id, parent_id, is_active FROM categories"; +/** What categories hold in their own name, for the checks of a parent's content. */ +interface Content { + /** Budget amounts of a year, as [category, amount]. */ + budget?: Array<[number, number]>; + /** Amounts of budget templates, as [category, amount]. */ + templates?: Array<[number, number]>; + /** Keywords, as [category, is_active]. */ + keywords?: Array<[number, 0 | 1]>; +} + +/** The tables the content checks read. */ +const CONTENT_TABLES = ["transactions", "budget_entries", "budget_template_entries", "keywords"]; +const CONTENT_SQL = /\bFROM (transactions|budget_entries|budget_template_entries|keywords)\b/; + +const SCHEMA = readFileSync( + resolve(import.meta.dirname, "../../src-tauri/src/database/consolidated_schema.sql"), + "utf-8", +); + +/** The CREATE TABLE of `table` in the schema of a new profile. */ +function createTableSql(table: string): string { + const found = SCHEMA.match(new RegExp(`CREATE TABLE IF NOT EXISTS ${table} \\([\\s\\S]*?\\n\\);`)); + if (!found) throw new Error(`no CREATE TABLE for ${table} in consolidated_schema.sql`); + return found[0]; +} + /** - * The categories table and the direct transaction counts. Every statement is - * logged with the connection it went through ("handle" or "global"). + * A SQLite database with the real content tables (no categories table: the + * foreign keys are off) holding `transactions` direct transactions per + * category and `content`. */ -function useFakeDb(initial: Row[], transactions: Record = {}) { +function contentDb(transactions: Record, content: Content): DatabaseSync { + const db = new DatabaseSync(":memory:", { enableForeignKeyConstraints: false }); + for (const table of CONTENT_TABLES) db.exec(createTableSql(table)); + const tx = db.prepare("INSERT INTO transactions (date, description, amount, category_id) VALUES ('2026-09-01', 'tx', -10, ?)"); + for (const [category, count] of Object.entries(transactions)) { + for (let i = 0; i < count; i++) tx.run(Number(category)); + } + const budget = db.prepare("INSERT INTO budget_entries (category_id, year, month, amount) VALUES (?, 2026, ?, ?)"); + (content.budget ?? []).forEach(([category, amount], i) => budget.run(category, (i % 12) + 1, amount)); + const template = db.prepare("INSERT INTO budget_template_entries (template_id, category_id, amount) VALUES (?, ?, ?)"); + (content.templates ?? []).forEach(([category, amount], i) => template.run(i + 1, category, amount)); + const keyword = db.prepare("INSERT INTO keywords (keyword, category_id, priority, is_active) VALUES (?, ?, 0, ?)"); + (content.keywords ?? []).forEach(([category, active], i) => keyword.run(`KW${i}`, category, active)); + return db; +} + +/** + * The categories table, and what categories hold. Every statement is logged + * with the connection it went through ("handle" or "global"). + */ +function useFakeDb(initial: Row[], transactions: Record = {}, content: Content = {}) { let rows = initial.map((r) => ({ ...r })); let beforeTransaction: Row[] | null = null; const log: Array<{ via: "handle" | "global"; sql: string; params: unknown[] }> = []; + const held = contentDb(transactions, content); const select = async (sql: string, params: unknown[] = []) => { if (sql === HIERARCHY_SQL) { return rows.map(({ id, parent_id, is_active }) => ({ id, parent_id, is_active })); } - if (sql.includes("COUNT(*) AS cnt FROM transactions WHERE category_id = $1")) { - return [{ cnt: transactions[params[0] as number] ?? 0 }]; + if (CONTENT_SQL.test(sql)) { + // The query as written, its `$1` bound by name. + return held.prepare(sql).all(Object.fromEntries(params.map((p, i) => [`$${i + 1}`, p as number]))); } if (sql.includes("SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1")) { const found = rows.find((r) => r.id === params[0] && r.is_active === 1); @@ -261,16 +321,31 @@ describe("updateCategory — regression of #393: A -> B -> A", () => { }); it("does not check an unchanged parent: a rename under a parent with transactions or at a fourth level is saved", async () => { - // Stored before the rules: 2 holds transactions and has a child; 1000 sits at level 3. - const db = useFakeDb([...TREE, row(1000, 100)], { 2: 5 }); + // Stored before the rules: 2 holds transactions, a budget amount and a + // keyword, and has a child; 1000 sits at level 3. + const db = useFakeDb([...TREE, row(1000, 100)], { 2: 5 }, { budget: [[2, 40]], keywords: [[2, 1]] }); await updateCategory(20, form(2, { name: "Rent" })); await updateCategory(1000, form(100, { name: "Deep" })); expect(db.rows().find((r) => r.id === 20)?.name).toBe("Rent"); expect(db.rows().find((r) => r.id === 1000)?.name).toBe("Deep"); - expect(db.log.some((l) => l.sql.includes("FROM transactions"))).toBe(false); + expect(db.log.some((l) => CONTENT_SQL.test(l.sql))).toBe(false); expect(db.writes().filter((w) => w === "UPDATE categories SET is_inputable = 0")).toEqual([]); }); + it("counts the depth on the active categories only: a category under a deleted parent is a root", async () => { + // 3 hangs under 2, deactivated: the tree shows 3 at the root, and so do the + // pickers. Root 1 carries 10: under 3 (level 0), 10 lands at level 2. + const db = useFakeDb([row(1, null), row(10, 1), row(2, null, { is_active: 0 }), row(3, 2)]); + await updateCategory(1, form(3)); + expect(db.rows().find((r) => r.id === 1)?.parent_id).toBe(3); + expect(db.writes()).toEqual([ + "BEGIN", + "UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6", + "UPDATE categories SET is_inputable = 0", + "COMMIT", + ]); + }); + it("refuses a deactivated parent with a plain error, out of reach of the pickers", async () => { const db = useFakeDb([...TREE, row(3, null, { is_active: 0 })]); const error = await updateCategory(20, form(3)).then( @@ -283,7 +358,120 @@ describe("updateCategory — regression of #393: A -> B -> A", () => { }); }); +describe("a parent's content: transactions, budget amounts, keywords (#393, decision of 2026-10-19)", () => { + /** A sub-category of `parent` created from the form. */ + const newChild = (parent: number) => ({ + name: "New", + type: "expense", + color: "#000", + parent_id: parent, + is_inputable: true, + sort_order: 1, + }); + + // The three ways a category becomes a parent. In TREE, 11 is a leaf under + // root 1 and 20 a leaf under root 2: each puts 20, or a new category, under 11. + const ENTRY_POINTS: Array<[string, () => Promise]> = [ + ["updateCategory (parent changed)", () => updateCategory(20, form(11))], + ["reattachCategory", () => reattachCategory(20, 11)], + ["createCategory (sub-category)", () => createCategory(newChild(11))], + ]; + + const HOLDINGS: Array<[string, Record, Content]> = [ + ["parent_has_transactions", { 11: 2 }, {}], + ["parent_has_budget", {}, { budget: [[11, 25]] }], + ["parent_has_keywords", {}, { keywords: [[11, 1]] }], + ]; + + describe.each(ENTRY_POINTS)("%s", (_name, act) => { + it.each(HOLDINGS)("refuses %s, writing nothing", async (code, transactions, content) => { + const db = useFakeDb(TREE, transactions, content); + expect((await refusal(act())).code).toBe(code); + expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]); + expect(db.rows()).toHaveLength(TREE.length); + expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(2); + expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(1); + // Every check went through the transaction's handle. + expect(db.log.filter((l) => CONTENT_SQL.test(l.sql)).every((l) => l.via === "handle")).toBe(true); + }); + + it("accepts a parent whose amounts are all zero and whose keywords are all deleted", async () => { + const db = useFakeDb(TREE, {}, { + budget: [[11, 0], [11, -0]], + templates: [[11, 0]], + keywords: [[11, 0], [11, 0]], + }); + await act(); + expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0); + const writes = db.writes(); + expect([writes[0], writes[writes.length - 1]]).toEqual(["BEGIN", "COMMIT"]); + }); + }); + + it("refuses a budget amount of a template, even with no amount in the budget itself", async () => { + useFakeDb(TREE, {}, { templates: [[11, 120]] }); + expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_budget"); + }); + + it("reads the budget and the keywords of the parent only, never of the moved category", async () => { + // 20 itself holds an amount, a keyword and transactions: moving it changes nothing for them. + const db = useFakeDb(TREE, { 20: 4 }, { budget: [[20, 50]], templates: [[20, 50]], keywords: [[20, 1]] }); + await updateCategory(20, form(11)); + expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(11); + }); + + it("checks transactions, then budget amounts, then keywords: the first found is the refusal", async () => { + useFakeDb(TREE, { 11: 1 }, { budget: [[11, 10]], keywords: [[11, 1]] }); + expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_transactions"); + useFakeDb(TREE, {}, { budget: [[11, 10]], keywords: [[11, 1]] }); + expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_budget"); + }); + + it("checks the content after the hierarchy rules: a parent that loops is refused as a loop", async () => { + const db = useFakeDb(TREE, { 10: 3 }, { keywords: [[10, 1]] }); + expect((await refusal(updateCategory(1, form(10)))).code).toBe("cycle"); + expect(db.log.some((l) => CONTENT_SQL.test(l.sql))).toBe(false); + }); + + it("creates a sub-category in one transaction through its handle: the parent's content, the row, the parent's flag", async () => { + const db = useFakeDb(TREE); + const id = await createCategory(newChild(11)); + const created = db.rows().find((r) => r.id === id); + expect(created).toMatchObject({ name: "New", parent_id: 11, is_inputable: 1 }); + expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0); + expect(db.writes()).toEqual(["BEGIN", "INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)", "UPDATE categories SET is_inputable = 0", "COMMIT"]); + // The depth is read before the transaction; everything after BEGIN goes through the handle. + const begin = db.log.findIndex((l) => l.sql === "BEGIN"); + expect(db.log.slice(0, begin).every((l) => l.via === "global" && l.sql.startsWith("SELECT parent_id FROM categories"))).toBe(true); + expect(db.log.slice(begin).every((l) => l.via === "handle")).toBe(true); + expect(db.log.slice(begin).filter((l) => CONTENT_SQL.test(l.sql))).toHaveLength(3); + }); + + it("creates a root category without any content check", async () => { + const db = useFakeDb(TREE, { 1: 3 }, { keywords: [[1, 1]] }); + await createCategory({ ...newChild(11), parent_id: null }); + expect(db.log.some((l) => CONTENT_SQL.test(l.sql))).toBe(false); + expect(db.writes()).toEqual(["BEGIN", "INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)", "COMMIT"]); + }); + + it("checks a parent that is already a parent the same way", async () => { + // 1 already has children (10, 11), and a keyword stored before these rules. + useFakeDb(TREE, {}, { keywords: [[1, 1]] }); + expect((await refusal(createCategory(newChild(1)))).code).toBe("parent_has_keywords"); + }); +}); + describe("CategoryHierarchyError", () => { + it("has a message for each of its codes, in French and in English", () => { + const errorsOf = (locale: unknown) => + (locale as { categories: { hierarchy: { errors: Record } } }).categories.hierarchy.errors; + for (const code of CATEGORY_HIERARCHY_ERROR_CODES) { + expect(errorsOf(fr)[code], `fr ${code}`).toMatch(/\S/); + expect(errorsOf(en)[code], `en ${code}`).toMatch(/\S/); + } + expect(Object.keys(errorsOf(fr)).sort()).toEqual([...CATEGORY_HIERARCHY_ERROR_CODES].sort()); + }); + it("carries its code and names the i18n key of its message", () => { const error = new CategoryHierarchyError("too_deep"); expect(error).toBeInstanceOf(Error); diff --git a/src/services/categoryService.ts b/src/services/categoryService.ts index a73bfaa..0ba748e 100644 --- a/src/services/categoryService.ts +++ b/src/services/categoryService.ts @@ -12,15 +12,20 @@ import { } from "../utils/categoryHierarchy"; /** Why a new parent is refused (#393); each code has its message under `categories.hierarchy.errors`. */ -export type CategoryHierarchyErrorCode = - | "self_parent" - | "cycle" - | "too_deep" - | "parent_has_transactions"; +export const CATEGORY_HIERARCHY_ERROR_CODES = [ + "self_parent", + "cycle", + "too_deep", + "parent_has_transactions", + "parent_has_budget", + "parent_has_keywords", +] as const; + +export type CategoryHierarchyErrorCode = (typeof CATEGORY_HIERARCHY_ERROR_CODES)[number]; /** - * Thrown when `updateCategory` or `reattachCategory` refuses a new parent - * (#393), before any write. The UI shows the translation of its code + * Thrown when `createCategory`, `updateCategory` or `reattachCategory` refuses + * a parent (#393), before any write. The UI shows the translation of its code * (`categoryHierarchyErrorKey`), never `message`, which is for the tests and * the developer console. */ @@ -63,12 +68,48 @@ async function markAsParent(db: Database, parentId: number): Promise { ); } +/** + * What a parent may not hold in its own name (#393, decision of 2026-10-19): + * once a parent, it stops taking input, yet its direct transactions would stay + * on it, a non-zero budget amount (in a year of the budget or in a template) + * would drop out of the budget grid, and its active keywords would keep sorting + * transactions into it. A deleted keyword (`is_active = 0`) sorts nothing and + * no screen shows it, so it does not count. Checked in this order, and the + * first one found is the refusal: transactions first, as before these two. + */ +const PARENT_CONTENT_CHECKS: ReadonlyArray<{ code: CategoryHierarchyErrorCode; sql: string }> = [ + { + code: "parent_has_transactions", + sql: `SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`, + }, + { + code: "parent_has_budget", + sql: `SELECT (SELECT COUNT(*) FROM budget_entries WHERE category_id = $1 AND amount <> 0) + + (SELECT COUNT(*) FROM budget_template_entries WHERE category_id = $1 AND amount <> 0) AS cnt`, + }, + { + code: "parent_has_keywords", + sql: `SELECT COUNT(*) AS cnt FROM keywords WHERE category_id = $1 AND is_active = 1`, + }, +]; + +/** + * Refuses `parentId` as a parent when it holds what `PARENT_CONTENT_CHECKS` + * lists, whatever it is today (a leaf, or already a parent), through the + * handle it is given. Throws before any write. + */ +async function checkParentContent(db: Database, parentId: number): Promise { + for (const { code, sql } of PARENT_CONTENT_CHECKS) { + const found = await db.select>(sql, [parentId]); + if ((found[0]?.cnt ?? 0) > 0) throw new CategoryHierarchyError(code); + } +} + /** * Checks moving category `id` under `parentId` (#393), on rows read in the * caller's transaction and through its handle: the hierarchy rules of - * `parentChangeRefusal`, then a parent that holds transactions of its own is - * refused (once a parent, it would stop taking input). The root is always - * accepted. Throws before any write. + * `parentChangeRefusal`, then what the parent holds (`checkParentContent`). + * The root is always accepted. Throws before any write. */ async function checkNewParent( db: Database, @@ -84,11 +125,7 @@ async function checkNewParent( } const refusal = parentChangeRefusal(active, id, parentId); if (refusal !== null) throw new CategoryHierarchyError(refusal); - const used = await db.select>( - `SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`, - [parentId] - ); - if ((used[0]?.cnt ?? 0) > 0) throw new CategoryHierarchyError("parent_has_transactions"); + await checkParentContent(db, parentId); } /** Runs `body` between BEGIN and COMMIT on the transaction handle, ROLLBACK on failure. */ @@ -159,6 +196,13 @@ export async function getCategoryDepth(categoryId: number): Promise { return depth; } +/** + * Creates a category. Under a parent: the depth is checked first, outside any + * transaction (`getCategoryDepth`); then, in one transaction through its + * handle, what the parent holds (#393, `checkParentContent`: + * `CategoryHierarchyError` on a refusal, nothing written), the new row, and + * the parent's `is_inputable`. + */ export async function createCategory(data: { name: string; type: string; @@ -167,27 +211,28 @@ export async function createCategory(data: { is_inputable: boolean; sort_order: number; }): Promise { - const db = await getDb(); + const parentId = data.parent_id ?? null; // Validate max depth: parent at depth 2 would create a 4th level - if (data.parent_id !== null) { - const parentDepth = await getCategoryDepth(data.parent_id); + if (parentId !== null) { + const parentDepth = await getCategoryDepth(parentId); if (parentDepth >= 2) { throw new Error("Cannot create category: maximum depth of 3 levels reached"); } } - const result = await db.execute( - `INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)`, - [data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order] + return withTransaction((db) => + inTransaction(db, async () => { + if (parentId !== null) await checkParentContent(db, parentId); + const result = await db.execute( + `INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)`, + [data.name, data.type, data.color, parentId, data.is_inputable ? 1 : 0, data.sort_order] + ); + // Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0 + if (parentId !== null) await markAsParent(db, parentId); + return result.lastInsertId as number; + }) ); - - // Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0 - if (data.parent_id !== null) { - await markAsParent(db, data.parent_id); - } - - return result.lastInsertId as number; } /** @@ -197,8 +242,8 @@ export async function createCategory(data: { * `is_inputable`) run in one transaction, through its handle. * * An unchanged parent is not checked: a name or a colour saved on a category - * stored before these rules (a fourth level, a parent with transactions) must - * not be refused. + * stored before these rules (a fourth level, a parent that holds transactions, + * budget amounts or keywords) must not be refused. */ export async function updateCategory( id: number, -- 2.45.2 From c8c8c068fcbca706b23dbd7884142ba29d5fc3b7 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 19 Oct 2026 20:51:29 -0400 Subject: [PATCH 02/25] docs: a parent's budget amounts and keywords, the create path, node:sqlite in tests (#393) The unreleased #393 entries now say the refusal covers budget amounts and keywords and applies when a subcategory is created; guide tip and architecture.md (validation, createCategory, out-of-scope drag-and-drop, the node:sqlite test note) follow. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.fr.md | 4 ++-- CHANGELOG.md | 4 ++-- docs/architecture.md | 10 +++++++--- docs/guide-utilisateur.md | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index dd04bf6..4ef2b6f 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -4,7 +4,7 @@ ### Ajouté -- Catégories : un bouton « Vérifier la hiérarchie », entre « Tous les mots-clés » et « Réinitialiser », liste les catégories orphelines : celles prises dans une boucle de parenté, qui les cache avec leurs sous-catégories de l'arbre, des menus de catégories, du budget et des rapports, et celles dont le parent a été supprimé ou est introuvable. Chacune se rattache à la racine, choisie par défaut, ou à un autre parent ; elle revient alors à sa place avec ses transactions, ses mots-clés et ses montants de budget. Le Journal note chaque rattachement, sans nom de catégorie (#393). +- Catégories : un bouton « Vérifier la hiérarchie », entre « Tous les mots-clés » et « Réinitialiser », liste les catégories orphelines : celles prises dans une boucle de parenté, qui les cache avec leurs sous-catégories de l'arbre, des menus de catégories, du budget et des rapports, et celles dont le parent a été supprimé ou est introuvable. Chacune se rattache à la racine, choisie par défaut, ou à un autre parent, vérifié comme dans le formulaire de catégorie ; elle revient alors à sa place avec ses transactions, ses mots-clés et ses montants de budget. Le Journal note chaque rattachement, sans nom de catégorie (#393). ### Modifié @@ -15,7 +15,7 @@ ### Corrigé -- Catégories : un nouveau parent est désormais vérifié à l'enregistrement d'une catégorie. L'une de ses propres sous-catégories (la boucle de parenté qui cachait toute une branche), un parent qui créerait un quatrième niveau et un parent qui a des transactions à son nom (devenu parent, il ne serait plus saisissable) sont refusés avec un message, et rien n'est enregistré. Une catégorie enregistrée sans changement de parent n'est jamais refusée par ces vérifications (#393). +- Catégories : un nouveau parent est désormais vérifié à l'enregistrement d'une catégorie. L'une de ses propres sous-catégories (la boucle de parenté qui cachait toute une branche) et un parent qui créerait un quatrième niveau sont refusés avec un message, et rien n'est enregistré. Un parent qui a des transactions, un montant de budget non nul (dans le budget ou dans un modèle de budget) ou des mots-clés à son nom est aussi refusé, puisque devenu parent il ne serait plus saisissable : à l'enregistrement d'une catégorie comme à la création d'une sous-catégorie ; le message dit quoi déplacer d'abord. Une catégorie enregistrée sans changement de parent n'est jamais refusée par ces vérifications (#393). - Budget : une catégorie dont le parent a été supprimé ou est introuvable ne disparaît plus de la grille Budget ni du rapport Budget vs réel : elle s'affiche au premier niveau, comme dans l'arbre des catégories (#393). - Import : les messages de « Créer un dossier d'exemple » suivent désormais la langue de l'application : un dossier d'exemple déjà présent dans le dossier d'import (nommé, avec la marche à suivre : actualiser la liste, ou le renommer s'il n'y apparaît pas), un dossier d'import introuvable, une date du jour illisible. Toute autre erreur reste affichée telle que le système l'a écrite. Une fois les dossiers d'exemple écrits, le message de réussite ne s'affiche que si le dossier d'import a pu être relu ; sinon le message d'erreur s'affiche seul, au lieu des deux à la fois (#384). - Import : après le choix d'un autre dossier d'import, l'avertissement des fichiers posés directement dans le dossier d'import ne nomme plus les fichiers de l'ancien dossier sous le nom du nouveau, pendant la lecture du nouveau dossier ou quand il ne peut pas être lu (#446). diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c3785..ccfa77d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Categories: a “Check hierarchy” button, between “All keywords” and “Re-initialize”, lists the orphan categories: those caught in a parent loop, which hides them and their subcategories from the tree, the category menus, the budget and the reports, and those whose parent was deleted or cannot be found. Each one can be reattached to the root, chosen by default, or to another parent; it then comes back in its place with its transactions, keywords and budget amounts. The Journal notes each reattachment, without any category name (#393). +- Categories: a “Check hierarchy” button, between “All keywords” and “Re-initialize”, lists the orphan categories: those caught in a parent loop, which hides them and their subcategories from the tree, the category menus, the budget and the reports, and those whose parent was deleted or cannot be found. Each one can be reattached to the root, chosen by default, or to another parent, checked as in the category form; it then comes back in its place with its transactions, keywords and budget amounts. The Journal notes each reattachment, without any category name (#393). ### Changed @@ -15,7 +15,7 @@ ### Fixed -- Categories: a new parent is now checked when a category is saved. One of the category's own subcategories (the parent loop that hid a whole branch), a parent that would make a fourth level, and a parent with transactions of its own (once a parent, it would no longer take input) are refused with a message, and nothing is saved. A category saved with its parent unchanged is never refused by these checks (#393). +- Categories: a new parent is now checked when a category is saved. One of the category's own subcategories (the parent loop that hid a whole branch) and a parent that would make a fourth level are refused with a message, and nothing is saved. A parent that holds transactions, a budget amount other than zero (in the budget or in a budget template) or keywords of its own is refused too, since once a parent it would no longer take input: when a category is saved, and when a subcategory is created under it; the message says what to move first. A category saved with its parent unchanged is never refused by these checks (#393). - Budget: a category whose parent was deleted or cannot be found no longer disappears from the Budget grid and the Budget vs Actual report: it now shows at the top level, as in the category tree (#393). - Import: the messages of “Create a sample folder” now follow the app's language: a sample folder already in the import folder (named, with what to do: refresh the list, or rename it if it does not show up), an import folder that cannot be found, today's date unreadable. Any other error still shows as the system wrote it. Once the sample folders are written, the success message only shows when the import folder could be read again; otherwise the error message shows alone, instead of both at once (#384). - Import: after you choose another import folder, the warning about the files placed directly in the import folder no longer names the previous folder's files under the new folder's name, while the new folder is being read or when it cannot be read (#446). diff --git a/docs/architecture.md b/docs/architecture.md index 413fdd9..29f1304 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -201,7 +201,7 @@ Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `prof |---------|---------------| | `db.ts` | Wrapper de connexion (tauri-plugin-sql), verrou FIFO de tous les accès et `withTransaction` (voir « Accès DB et transactions »). `connectToProfile` appelle `repair_migrations` puis `prepare_profile_db` avant `Database.load`, et écrit au Journal ce que la préparation a changé (`describePreparation`, noms de fichiers seulement, #390) ; un échec est écrit au Journal et le profil s'ouvre quand même | | `profileService.ts` | Gestion des profils | -| `categoryService.ts` | CRUD catégories hiérarchiques. `updateCategory` vérifie un nouveau parent et `reattachCategory` rattache une catégorie orpheline, en une transaction par son handle ; `CategoryHierarchyError` typée ; `findOrphanCategories` (diagnostic, requête dédiée sur toutes les catégories) ; `getCategoryDepth` s'arrête sur un cycle — voir « Hiérarchie des catégories » | +| `categoryService.ts` | CRUD catégories hiérarchiques. `updateCategory` vérifie un nouveau parent, `reattachCategory` rattache une catégorie orpheline et `createCategory` vérifie ce que porte le parent d'une sous-catégorie, chacun en une transaction par son handle ; `CategoryHierarchyError` typée ; `findOrphanCategories` (diagnostic, requête dédiée sur toutes les catégories) ; `getCategoryDepth` s'arrête sur un cycle — voir « Hiérarchie des catégories » | | `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) ; `getTransactionCount` (compte brut de la table, sans filtre — signal des états vides de Transactions et Budget, #351/#353) | | `importSourceService.ts` | Configuration des sources d'import — lit et écrit le format complet via le codec `importFormat.ts` ([ADR 0019](adr/0019-format-import-persiste.md)) | | `importedFileService.ts` | Suivi des fichiers importés | @@ -281,11 +281,11 @@ Un `parent_id` en boucle (A sous B, B sous A, ou une catégorie sous elle-même) - **Module pur unique** — `src/utils/categoryHierarchy.ts` : `depthsOf` (une catégorie prise dans une boucle, ou dessous, n'a **pas** de profondeur), `subtreeHeight` et `descendantsOf` (une seule descente, `levelsBelow`), `fitsUnder` (trois niveaux au plus, `MAX_CATEGORY_DEPTH = 2`, sous-arbre compris), `parentChangeRefusal`, `findOrphans`, `displaysAsRoot`. Chaque remontée et chaque descente garde l'ensemble des catégories vues : une boucle termine le parcours. Les parcours ont trois consommateurs, sans autre implémentation : `buildParentOptions` (liste des parents du formulaire, #423, et de la fenêtre), le diagnostic et la validation du service ; le budget lit `displaysAsRoot`. `computeDepths` de `CategoryCombobox` ne sert qu'à l'indentation et reste à part. - **Diagnostic** — `findOrphanCategories` lit **toutes** les catégories, actives ou non (`SELECT id, parent_id, is_active FROM categories`) : les autres requêtes ne gardent que les actives, ce qui ferait d'un parent désactivé un parent absent. Motifs : `cycle` (le membre de plus petit `id`, une entrée par boucle), `inactive_parent`, `missing_parent` ; seule la tête de chaque groupe est listée, sa descendance la suit. -- **Validation** — `updateCategory` (seulement quand le parent change : un renommage d'une catégorie enregistrée avant ces règles n'est jamais refusé) et `reattachCategory` (toujours) passent par `checkNewParent` : `parentChangeRefusal` sur les catégories actives (`self_parent` ; `cycle` pour un parent pris dans la descendance ou sans profondeur ; `too_deep`), puis `parent_has_transactions` pour un parent qui porte des transactions directes (devenu parent, il ne serait plus saisissable). La racine est toujours acceptée : c'est la réparation de dernier recours d'une boucle. Un refus lève `CategoryHierarchyError` (`code`), que `useCategories` reconnaît par `instanceof` et affiche par la clé `categories.hierarchy.errors.`. Lectures et écritures (la ligne, puis `is_inputable = 0` sur le nouveau parent, comme `createCategory`) se font dans **un** `withTransaction`, par son handle. Un parent inactif ou inexistant lève une erreur simple, hors d'atteinte de l'interface. +- **Validation** — `updateCategory` (seulement quand le parent change : un renommage d'une catégorie enregistrée avant ces règles n'est jamais refusé) et `reattachCategory` (toujours) passent par `checkNewParent` : `parentChangeRefusal` sur les catégories actives (`self_parent` ; `cycle` pour un parent pris dans la descendance ou sans profondeur ; `too_deep`), puis ce que porte le parent (`checkParentContent`, décision de Max du 2026-10-19), dans cet ordre, le premier trouvé faisant le refus : `parent_has_transactions` (des transactions directes), `parent_has_budget` (un montant non nul dans `budget_entries` ou `budget_template_entries`), `parent_has_keywords` (un mot-clé actif ; un mot-clé supprimé, `is_active = 0`, ne classe rien et ne compte pas). Devenu parent, il ne serait plus saisissable : ses transactions resteraient dessus, ses montants sortiraient de la grille du budget, et ses mots-clés continueraient d'y classer des transactions. Le contenu se vérifie quel que soit l'état du parent, feuille ou déjà parent. `createCategory` vérifie de même le parent d'une sous-catégorie, dans un `withTransaction` qui porte aussi l'insertion et le `is_inputable = 0` du parent ; sa vérification de profondeur (`getCategoryDepth`, erreur simple) reste avant la transaction. La racine est toujours acceptée : c'est la réparation de dernier recours d'une boucle. Un refus lève `CategoryHierarchyError` (`code`), que `useCategories` reconnaît par `instanceof` et affiche par la clé `categories.hierarchy.errors.`. Lectures et écritures (la ligne, puis `is_inputable = 0` sur le nouveau parent, comme `createCategory`) se font dans **un** `withTransaction`, par son handle. Un parent inactif ou inexistant lève une erreur simple, hors d'atteinte de l'interface. - **`getCategoryDepth`** garde les `id` vus et rend `Infinity` sur une boucle : `createCategory` refuse alors ce parent. - **Fenêtre** — `HierarchyCheckModal` (présentationnelle) : nombre d'orphelines ou « aucune », une ligne par orpheline (nom, motif traduit, `CategoryCombobox` du nouveau parent alimenté par `buildParentOptions` avec l'orpheline en catégorie éditée, « Racine » présélectionnée en `extraOptions`, bouton « Rattacher »), bouton « Fermer ». La liste du combobox sort de la fenêtre par son portail (#429). Après un rattachement, une ligne `logInfo` donne le motif et le nombre de catégories remises en place, sans nom de catégorie, puis la liste et l'arbre se rechargent. - **Budget** — `buildBudgetYearRows` (`useBudget.ts`) et `getBudgetVsActualData` (`budgetService.ts`) placent au premier niveau une catégorie dont le parent n'est pas dans la liste active (`displaysAsRoot`), comme l'arbre et les sélecteurs ; une boucle y reste cachée, la fenêtre la répare. -- **Hors portée** — le glisser-déposer : `updateCategorySortOrders` écrit `parent_id` sans cette validation, gardé par l'interface (`moveCategory` ne peut pas déposer une catégorie dans son propre sous-arbre) ; il ne refuse pas non plus un parent avec transactions et ne touche pas `is_inputable`. La restauration d'une sauvegarde (`restoreCategories`) réinsère les `parent_id` tels quels, boucles comprises : la fenêtre répare après coup. +- **Hors portée** — le glisser-déposer : `updateCategorySortOrders` écrit `parent_id` sans cette validation, gardé par l'interface (`moveCategory` ne peut pas déposer une catégorie dans son propre sous-arbre) ; il ne refuse pas non plus un parent qui porte des transactions, des montants de budget ou des mots-clés, et ne touche pas `is_inputable`. La restauration d'une sauvegarde (`restoreCategories`) réinsère les `parent_id` tels quels, boucles comprises : la fenêtre répare après coup. ## Import CSV — format persisté et détection @@ -683,6 +683,10 @@ Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x) `CategoryCombobox` rend sa liste ouverte dans un portail sur `document.body`, en position fixe au-dessus de toutes les fenêtres (`z-[300]`) : aucun conteneur défilant ne la coupe. Fermé, il ne rend que `
`, sans portail, et son rendu statique n'en demande rien. La règle de placement (sous le champ, au-dessus quand la place manque en dessous et suffit au-dessus) et les écouteurs qui gardent la liste sur son champ pendant qu'elle est ouverte (`scroll` en capture, `resize`, retirés avec elle) sont des fonctions pures de `src/components/shared/placeListbox.ts`, testées sans DOM. Le composant ouvert, lui, ne se rend pas sans DOM : `renderToStaticMarkup` le rend fermé et le harnais refuse ses `useId` et `useLayoutEffect` ; le reste (portail, clic dans la liste, clavier) se vérifie dans l'application. La liste n'étant plus dans le DOM de son conteneur, un écouteur natif de clic extérieur posé par un conteneur la verrait dehors : celui du combobox la compte comme intérieure. +### SQL réel — `node:sqlite` (#393) + +`categoryService.test.ts` exécute les requêtes qui vérifient ce que porte un parent (transactions, montants de budget, mots-clés) telles qu'elles sont écrites, sur une base SQLite en mémoire de `node:sqlite`, module intégré à Node (aucun paquet ; expérimental sous Node 22, qui l'annonce par un avertissement à l'exécution). Ses tables y sont créées par leur `CREATE TABLE` de `consolidated_schema.sql`, clés étrangères coupées : la table `categories` reste dans le faux du test. Une condition retirée d'une requête (`amount <> 0`, `is_active = 1`) rougit donc un test, ce qu'un faux qui réimplémente la règle ne ferait pas. Les autres tests de services gardent leurs faux. + ### Limites connues (#437) - **Emplacements préréglés du harnais.** Le harnais donne un emplacement à chaque appel de hook, tous types confondus (`useState`, `useReducer`, `useRef`, `useMemo`, `useCallback`, `useEffect`), dans l'ordre des appels : c'est le helper `slot` de `fakeReact`. Un test qui prérègle un emplacement par sa position (`harness.slots[0] = …`) dépend donc de l'ordre des hooks du composant : un hook ajouté avant, de n'importe quel type, décale les emplacements sans rien dire, et la valeur préréglée tombe sur un autre hook. Préréglages actuels, chacun commenté dans son fichier : `PriceFetchConsentToggle.test.tsx` (emplacements 0 à 2), `PriceFetchControl.test.tsx` (la fenêtre de consentement ouverte), `useOnboardingChecklist.wiring.test.ts` (`mountWith` et deux écritures directes) et `SystemsSettingsPage.test.tsx` ; `OnboardingStepCard.test.tsx` lit le `useRef` de l'emplacement 0. Qui ajoute un hook avant ces emplacements met le test à jour. Un test qui ouvre un état par un clic dans l'arbre rendu (`TransactionTable.wiring.test.ts`) n'a pas cette dépendance. diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md index b90df0c..ac0a9d5 100644 --- a/docs/guide-utilisateur.md +++ b/docs/guide-utilisateur.md @@ -295,7 +295,7 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots - Utilisez la vue Tous les mots-clés pour avoir un aperçu global de vos règles de catégorisation - Utilisez Réinitialiser pour revenir aux catégories par défaut — cela dissociera toutes les catégories des transactions - Une catégorie disparue de l'arbre, des menus et du budget est souvent prise dans une boucle de parenté : Vérifier la hiérarchie la retrouve, et ses transactions, ses mots-clés et ses montants de budget reviennent avec elle -- Une catégorie qui a des transactions à son nom ne peut pas devenir le parent d'une catégorie existante, puisqu'elle ne serait plus saisissable : reclassez d'abord ses transactions, ou choisissez un autre parent +- Une catégorie qui a des transactions, des montants de budget (dans le budget ou dans un modèle) ou des mots-clés à son nom ne peut pas devenir un parent, ni d'une catégorie existante ni d'une nouvelle sous-catégorie, puisqu'elle ne serait plus saisissable : reclassez d'abord ses transactions, remettez ses montants à zéro ou supprimez ses mots-clés, ou choisissez un autre parent --- -- 2.45.2 From be4b92345115fa3e15183a7e3ebc10bb94c3c47e Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 19 Oct 2026 20:59:02 -0400 Subject: [PATCH 03/25] fix(categories): reload the categories before the hierarchy check (#393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a drag-and-drop, SET_TREE leaves state.categories with what the tree shows, without the members of a loop: the window then showed "—" for the orphan and offered a parent the service refused (review 25437, point 1). checkHierarchy now reads the categories again before the diagnostic, without the page's loading state, so a form open behind the window stays as it is; a load started meanwhile wins. Pinned by a drag-and-drop-then-check hook test, plus the refusal-then-success case of the window's message (RM2). Co-Authored-By: Claude Opus 5.5 --- src/hooks/useCategories.test.ts | 52 +++++++++++++++++++++++++++++++-- src/hooks/useCategories.ts | 15 +++++++++- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/hooks/useCategories.test.ts b/src/hooks/useCategories.test.ts index 5e8adf4..6b4bfea 100644 --- a/src/hooks/useCategories.test.ts +++ b/src/hooks/useCategories.test.ts @@ -181,7 +181,8 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => { let api = await mount(); await api.checkHierarchy(); api = harness.render(useCategories); - expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(1); + // The mount's read, then the check's. + expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2); await api.reattachOrphan(CYCLE, 1); @@ -192,7 +193,7 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => { expect(line).toContain("3 categories"); for (const row of ROWS) expect(line).not.toContain(row.name); expect(findOrphanCategories).toHaveBeenCalledTimes(2); - expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2); + expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(3); const { state } = harness.render(useCategories); expect(state.hierarchyCheck).toEqual({ orphans: [], error: null }); expect(state.isSaving).toBe(false); @@ -209,7 +210,8 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => { await api.reattachOrphan(CYCLE, null); expect(reattachCategory).toHaveBeenCalledWith(3, null); expect(logInfo).toHaveBeenCalledTimes(1); - expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(2); + // The mount's read, the check's, and the reload after the committed reattachment. + expect(getAllCategoriesWithCounts).toHaveBeenCalledTimes(3); const { state } = harness.render(useCategories); expect(state.hierarchyCheck?.error).toEqual({ message: "database is locked" }); expect(state.isSaving).toBe(false); @@ -232,6 +234,50 @@ describe("useCategories — « Vérifier la hiérarchie » (#393)", () => { expect(logInfo).not.toHaveBeenCalled(); }); + it("a refusal, then a reattachment that goes through: the window's message goes", async () => { + vi.mocked(findOrphanCategories).mockResolvedValueOnce([CYCLE]).mockResolvedValueOnce([]); + vi.mocked(reattachCategory) + .mockRejectedValueOnce(new CategoryHierarchyError("parent_has_transactions")) + .mockResolvedValueOnce(3); + let api = await mount(); + await api.checkHierarchy(); + api = harness.render(useCategories); + await api.reattachOrphan(CYCLE, 1); + api = harness.render(useCategories); + expect(api.state.hierarchyCheck?.error).toEqual({ key: "categories.hierarchy.errors.parent_has_transactions" }); + await api.reattachOrphan(CYCLE, null); + expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: [], error: null }); + }); + + it("after a drag-and-drop, reads the categories again: the window gets the members of a loop back", async () => { + // 1 with 10 under it, 2, and 3 <-> 5: no root reaches 3 nor 5. + const row = (id: number, parent_id: number | null) => ({ ...ROWS[1], id, name: `c${id}`, parent_id, sort_order: id }); + vi.mocked(getAllCategoriesWithCounts).mockResolvedValue([ + row(1, null), + row(10, 1), + row(2, null), + row(3, 5), + row(5, 3), + ] as never); + vi.mocked(findOrphanCategories).mockResolvedValue([CYCLE]); + let api = await mount(); + const ids = () => harness.render(useCategories).state.categories.map((c) => c.id).sort((a, b) => a - b); + expect(ids()).toEqual([1, 2, 3, 5, 10]); + + await api.moveCategory(2, null, 0); + // The drag-and-drop keeps what the tree shows: the loop is gone from the list. + expect(ids()).toEqual([1, 2, 10]); + + api = harness.render(useCategories); + harness.dispatch.mockClear(); + await api.checkHierarchy(); + expect(ids()).toEqual([1, 2, 3, 5, 10]); + expect(harness.render(useCategories).state.hierarchyCheck).toEqual({ orphans: [CYCLE], error: null }); + // Read without the page's loading state: the tree and an open form stay on screen. + const types = harness.dispatch.mock.calls.map(([action]) => (action as { type: string }).type); + expect(types).not.toContain("SET_LOADING"); + }); + it("closes the window; a check that ends after it closed changes nothing", async () => { let resolve: (orphans: OrphanCategory[]) => void = () => undefined; vi.mocked(findOrphanCategories).mockReturnValue(new Promise((r) => (resolve = r))); diff --git a/src/hooks/useCategories.ts b/src/hooks/useCategories.ts index 711269e..0826f3c 100644 --- a/src/hooks/useCategories.ts +++ b/src/hooks/useCategories.ts @@ -403,10 +403,23 @@ export function useCategories() { [state.tree, loadCategories] ); - /** Opens the « Vérifier la hiérarchie » window and runs the diagnostic (#393). */ + /** + * Opens the « Vérifier la hiérarchie » window and runs the diagnostic (#393). + * The window names the orphans and offers their parents from + * `state.categories`, which a drag-and-drop leaves with what the tree shows + * only (`SET_TREE`), without the categories no root reaches: the categories + * are read again first. Without the page's loading state, so a form open + * behind the window stays as it is; a load started meanwhile wins. + */ const checkHierarchy = useCallback(async () => { dispatch({ type: "OPEN_HIERARCHY_CHECK" }); try { + const fetchId = fetchIdRef.current; + const rows = await getAllCategoriesWithCounts(); + if (fetchId === fetchIdRef.current) { + const flat = rows.map((r) => ({ ...r, children: [] as CategoryTreeNode[] })); + dispatch({ type: "SET_CATEGORIES", payload: { flat, tree: buildTree(flat) } }); + } const orphans = await findOrphanCategories(); dispatch({ type: "SET_ORPHANS", payload: orphans }); } catch (e) { -- 2.45.2 From 7f252936694c82c54c700feb7927f0d2a33dd1fb Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 19 Oct 2026 20:59:02 -0400 Subject: [PATCH 04/25] test(categories): pin the rules the review of #393 left green (#393) Review 25437, point 2: the loop named by its own smallest member, not by a smaller id under it (RM1); the page hands the window every category, not the tree (RM11); a category that is its own parent stays out of the budget, and displaysAsRoot says so (RM15). RM7 (depth counted on active categories at the service) is pinned in categoryService.test.ts by 1a36d4e, RM2 by be4b923. Co-Authored-By: Claude Opus 5.5 --- src/hooks/useBudget.test.ts | 5 ++++ src/pages/CategoriesPage.render.test.tsx | 29 ++++++++++++++++++++++++ src/utils/categoryHierarchy.test.ts | 9 ++++++++ 3 files changed, 43 insertions(+) diff --git a/src/hooks/useBudget.test.ts b/src/hooks/useBudget.test.ts index 77cad06..6043aae 100644 --- a/src/hooks/useBudget.test.ts +++ b/src/hooks/useBudget.test.ts @@ -359,6 +359,11 @@ describe("buildBudgetYearRows — a category whose parent is deleted or gone (#3 ); expect(rows.some((r) => r.category_id === 7 || r.category_id === 8)).toBe(false); }); + + it("hides a category that is its own parent too, a loop of one member", () => { + const rows = buildBudgetYearRows([...CATEGORIES, cat(7, "Gifts", "expense", { parent_id: 7 })], [entry(7, 40)], []); + expect(rows.some((r) => r.category_id === 7)).toBe(false); + }); }); describe("buildBudgetYearRows + multi-level collapse (issue #289 end-to-end)", () => { diff --git a/src/pages/CategoriesPage.render.test.tsx b/src/pages/CategoriesPage.render.test.tsx index 80df5bd..55fb941 100644 --- a/src/pages/CategoriesPage.render.test.tsx +++ b/src/pages/CategoriesPage.render.test.tsx @@ -202,6 +202,35 @@ describe("CategoriesPage — « Vérifier la hiérarchie » (#393)", () => { expect(render()).toContain("t(categories.hierarchy.errors.too_deep)"); }); + it("hands the window every category, not the tree: an orphan in a loop is named", () => { + const node = (id: number, name: string, parent_id: number | null) => ({ + id, + name, + parent_id, + color: null, + icon: null, + type: "expense", + is_active: true, + is_inputable: true, + sort_order: id, + keyword_count: 0, + i18n_key: null, + children: [], + }); + // 3 <-> 5: no root reaches them, so the tree holds root 1 (and 10 under it) only. + const categories = [node(1, "Logement", null), node(10, "Loyer", 1), node(3, "Cadeaux", 5), node(5, "Fêtes", 3)]; + harness.state = { + isLoading: false, + categories, + tree: [{ ...categories[0], children: [categories[1]] }], + hierarchyCheck: { orphans: [{ id: 3, reason: "cycle" }], error: null }, + }; + const html = render(); + expect(html).toContain(">Cadeaux

"); + expect(html).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Cadeaux])"'); + expect(html).not.toContain(">—

"); + }); + it("translates a refused save of the form by its key, and shows any other failure as is", () => { harness.state = { error: { key: "categories.hierarchy.errors.parent_has_transactions" } }; expect(render()).toContain(">t(categories.hierarchy.errors.parent_has_transactions)"); diff --git a/src/utils/categoryHierarchy.test.ts b/src/utils/categoryHierarchy.test.ts index de853a6..11b537a 100644 --- a/src/utils/categoryHierarchy.test.ts +++ b/src/utils/categoryHierarchy.test.ts @@ -113,6 +113,10 @@ describe("displaysAsRoot", () => { expect(displaysAsRoot({ id: 10, parent_id: 1 }, ids)).toBe(false); expect(displaysAsRoot({ id: 1, parent_id: 2 }, ids)).toBe(false); }); + + it("is not a root as its own parent: that one-member loop stays hidden, like the tree hides it", () => { + expect(displaysAsRoot({ id: 10, parent_id: 10 }, ids)).toBe(false); + }); }); describe("isActiveRow", () => { @@ -139,6 +143,11 @@ describe("findOrphans (#393)", () => { expect(findOrphans(rows)).toEqual([{ id: 3, reason: "cycle" }]); }); + it("names the loop by its own smallest member, never by a smaller id that hangs under it", () => { + // 5 <-> 8, 2 under 5: reattaching 2 would leave 5 and 8 hidden. + expect(findOrphans([r(2, 5), r(5, 8), r(8, 5), r(1, null)])).toEqual([{ id: 5, reason: "cycle" }]); + }); + it("lists a three-member cycle once, by its smallest id, whatever the order of the rows", () => { // 6 -> 4 -> 9 -> 6, 12 under 9. const rows = [r(6, 4), r(4, 9), r(9, 6), r(12, 9), r(1, null)]; -- 2.45.2 From a5b92b975eb67e470ae6c00f0a3e2b79d3515c78 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 19 Oct 2026 20:59:27 -0400 Subject: [PATCH 05/25] docs: the hierarchy window reads the categories again on opening (#393) Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 29f1304..7425952 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -283,7 +283,7 @@ Un `parent_id` en boucle (A sous B, B sous A, ou une catégorie sous elle-même) - **Diagnostic** — `findOrphanCategories` lit **toutes** les catégories, actives ou non (`SELECT id, parent_id, is_active FROM categories`) : les autres requêtes ne gardent que les actives, ce qui ferait d'un parent désactivé un parent absent. Motifs : `cycle` (le membre de plus petit `id`, une entrée par boucle), `inactive_parent`, `missing_parent` ; seule la tête de chaque groupe est listée, sa descendance la suit. - **Validation** — `updateCategory` (seulement quand le parent change : un renommage d'une catégorie enregistrée avant ces règles n'est jamais refusé) et `reattachCategory` (toujours) passent par `checkNewParent` : `parentChangeRefusal` sur les catégories actives (`self_parent` ; `cycle` pour un parent pris dans la descendance ou sans profondeur ; `too_deep`), puis ce que porte le parent (`checkParentContent`, décision de Max du 2026-10-19), dans cet ordre, le premier trouvé faisant le refus : `parent_has_transactions` (des transactions directes), `parent_has_budget` (un montant non nul dans `budget_entries` ou `budget_template_entries`), `parent_has_keywords` (un mot-clé actif ; un mot-clé supprimé, `is_active = 0`, ne classe rien et ne compte pas). Devenu parent, il ne serait plus saisissable : ses transactions resteraient dessus, ses montants sortiraient de la grille du budget, et ses mots-clés continueraient d'y classer des transactions. Le contenu se vérifie quel que soit l'état du parent, feuille ou déjà parent. `createCategory` vérifie de même le parent d'une sous-catégorie, dans un `withTransaction` qui porte aussi l'insertion et le `is_inputable = 0` du parent ; sa vérification de profondeur (`getCategoryDepth`, erreur simple) reste avant la transaction. La racine est toujours acceptée : c'est la réparation de dernier recours d'une boucle. Un refus lève `CategoryHierarchyError` (`code`), que `useCategories` reconnaît par `instanceof` et affiche par la clé `categories.hierarchy.errors.`. Lectures et écritures (la ligne, puis `is_inputable = 0` sur le nouveau parent, comme `createCategory`) se font dans **un** `withTransaction`, par son handle. Un parent inactif ou inexistant lève une erreur simple, hors d'atteinte de l'interface. - **`getCategoryDepth`** garde les `id` vus et rend `Infinity` sur une boucle : `createCategory` refuse alors ce parent. -- **Fenêtre** — `HierarchyCheckModal` (présentationnelle) : nombre d'orphelines ou « aucune », une ligne par orpheline (nom, motif traduit, `CategoryCombobox` du nouveau parent alimenté par `buildParentOptions` avec l'orpheline en catégorie éditée, « Racine » présélectionnée en `extraOptions`, bouton « Rattacher »), bouton « Fermer ». La liste du combobox sort de la fenêtre par son portail (#429). Après un rattachement, une ligne `logInfo` donne le motif et le nombre de catégories remises en place, sans nom de catégorie, puis la liste et l'arbre se rechargent. +- **Fenêtre** — `HierarchyCheckModal` (présentationnelle) : nombre d'orphelines ou « aucune », une ligne par orpheline (nom, motif traduit, `CategoryCombobox` du nouveau parent alimenté par `buildParentOptions` avec l'orpheline en catégorie éditée, « Racine » présélectionnée en `extraOptions`, bouton « Rattacher »), bouton « Fermer ». La liste du combobox sort de la fenêtre par son portail (#429). À l'ouverture, `checkHierarchy` relit les catégories avant le diagnostic, sans l'état de chargement de la page (un formulaire ouvert derrière la fenêtre reste tel quel) : un glisser-déposer laisse dans `state.categories` ce que montre l'arbre (`SET_TREE`), sans les catégories qu'aucune racine n'atteint, que la fenêtre doit nommer et dont elle calcule les parents proposés. Après un rattachement, une ligne `logInfo` donne le motif et le nombre de catégories remises en place, sans nom de catégorie, puis la liste et l'arbre se rechargent. - **Budget** — `buildBudgetYearRows` (`useBudget.ts`) et `getBudgetVsActualData` (`budgetService.ts`) placent au premier niveau une catégorie dont le parent n'est pas dans la liste active (`displaysAsRoot`), comme l'arbre et les sélecteurs ; une boucle y reste cachée, la fenêtre la répare. - **Hors portée** — le glisser-déposer : `updateCategorySortOrders` écrit `parent_id` sans cette validation, gardé par l'interface (`moveCategory` ne peut pas déposer une catégorie dans son propre sous-arbre) ; il ne refuse pas non plus un parent qui porte des transactions, des montants de budget ou des mots-clés, et ne touche pas `is_inputable`. La restauration d'une sauvegarde (`restoreCategories`) réinsère les `parent_id` tels quels, boucles comprises : la fenêtre répare après coup. -- 2.45.2 From 813ee07559344cb8e723543df4299de49f3d14fb Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 19 Oct 2026 21:03:59 -0400 Subject: [PATCH 06/25] fix(i18n): name the eight getting-started steps in the dashboard help (#446) Decision of Max of 2026-10-19 (review 25487, point 4): docs.dashboard.steps[0] (FR and EN) and step 1 of the guide's Dashboard section listed six of the eight steps. They now name the eight, by the labels of onboarding.checklist.items, in the list's order, and say the two visit steps are checked with the card's "Got it" or, for the reports, a click on the outlined Trends card. A test holds the step to the checklist's labels and order; the #446 changelog entries follow. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.fr.md | 2 +- CHANGELOG.md | 2 +- docs/guide-utilisateur.md | 2 +- .../settings/DocsContent.locales.test.ts | 22 +++++++++++++++++++ src/i18n/locales/en.json | 2 +- src/i18n/locales/fr.json | 2 +- 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 4ef2b6f..7cf7d65 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -11,7 +11,7 @@ - Paramètres → Système : « Journaux et commentaires » vient désormais juste après « Mise à jour », avant l'historique des versions, qui ne repousse plus le Journal en bas de la page. L'historique des versions s'ouvre sur les notes de la version installée, les autres versions repliées : cliquez sur un numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (#372). - Ajustements : la catégorie d'une ligne d'ajustement accepte désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie et les noms des catégories standard dans votre langue. Elle propose les mêmes catégories que la page Transactions, actives et autorisées à la saisie, comme la fenêtre de répartition ouverte depuis la page Ajustements ; une ligne déjà enregistrée sur une catégorie masquée de la saisie la garde, son champ vide tant que vous n'en choisissez pas une autre. Le filtre de catégorie de la fenêtre « Lier transferts » du Bilan accepte aussi la saisie, avec « Toutes les catégories » en tête. Une liste de catégories ouverte n'est plus coupée par le bord d'une fenêtre ou du tableau des transactions, dans la fenêtre de répartition comme sur les dernières lignes du tableau, et elle s'ouvre vers le haut quand elle ne tient pas en dessous mais tient au-dessus (#429). - Transactions : sur un profil sans aucune transaction, la carte « Aucune transaction pour l'instant » s'affiche dès que l'application sait que le profil est vide, au lieu des filtres, des totaux et de « Chargement... » jusqu'au retour de la première page de la liste (#384). -- Textes d'aide : l'aide de la page Rapports nomme désormais les rapports par leurs cartes, « Tendances » et « Analyse par catégorie », au lieu de « Tendances mensuelles » et « Catégories dans le temps », des onglets qui n'existent plus. La section Tableau de bord du guide d'utilisation dit désormais comment se cochent les huit étapes de démarrage : selon vos données, ou par « J'ai compris » pour les visites (#446). +- Textes d'aide : l'aide de la page Rapports nomme désormais les rapports par leurs cartes, « Tendances » et « Analyse par catégorie », au lieu de « Tendances mensuelles » et « Catégories dans le temps », des onglets qui n'existent plus. La section Tableau de bord du guide d'utilisation nomme désormais les huit étapes de démarrage et dit comment elles se cochent : selon vos données, ou par « J'ai compris » (ou, pour les rapports, un clic sur la carte Tendances entourée) pour les visites (#446). ### Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index ccfa77d..2d73785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ - Settings → System: “Logs and feedback” now comes right after “Update”, before the version history, which no longer pushes the Journal to the bottom of the page. The version history opens on the notes of the installed version, with every other version folded: click a version number to read its notes, or “Expand all” to open the whole history (#372). - Adjustments: the category of an adjustment line now takes typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy and the names of the standard categories in your language. It offers the same categories as the Transactions page, active and allowed for input, as does the split window opened from the Adjustments page; a line already saved on a category hidden from input keeps it, its field empty until you choose another one. The category filter of the “Link transfers” window of the Balance sheet takes typing too, with “Any category” first. An open category list is no longer cut by the edge of a window or of the Transactions table, in the split window as on the last rows of the table, and it opens upward when it does not fit below but fits above (#429). - Transactions: on a profile without any transaction, the “No transactions yet” card now shows as soon as the app knows the profile is empty, instead of the filters, the totals and “Loading...” until the first page of the list comes back (#384). -- Help texts: the help of the Reports page now names the reports by their cards, “Trends” and “Category Analysis”, instead of “Monthly Trends” and “Category Over Time”, tabs that no longer exist. The Dashboard section of the user guide now says how the eight getting-started steps get checked: from your data, or with “Got it” for the visit steps (#446). +- Help texts: the help of the Reports page now names the reports by their cards, “Trends” and “Category Analysis”, instead of “Monthly Trends” and “Category Over Time”, tabs that no longer exist. The Dashboard section of the user guide now names the eight getting-started steps and says how they get checked: from your data, or with “Got it” (or, for the reports, a click on the outlined Trends card) for the visit steps (#446). ### Fixed diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md index ac0a9d5..dfea96c 100644 --- a/docs/guide-utilisateur.md +++ b/docs/guide-utilisateur.md @@ -100,7 +100,7 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p ### Comment faire -1. Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage : créer un profil (déjà cochée), choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total +1. Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage, en huit étapes : « Créer un profil » (déjà cochée), « Choisir un dossier d'import », « Configurer une source d'import », « Importer un premier fichier », « Parcourir les catégories et les mots-clés », « Classer une transaction à la main », « Saisir un premier budget » et « Parcourir les rapports ». Les étapes se cochent selon vos données, sauf les deux étapes de visite (catégories et rapports), cochées par « J'ai compris » sur leur carte ou, pour les rapports, par un clic sur la carte Tendances entourée. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total 2. Dès que le profil a des transactions, la liste quitte le tableau de bord : le bandeau de démarrage, en haut de chaque page, la remplace jusqu'à la fin du parcours (voir Premiers pas → Le parcours de démarrage) 3. « Masquer » retire la liste de la carte d'accueil, et le bandeau de toutes les pages, pour ce profil seulement ; Paramètres → Utilisateurs les réaffiche 4. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.) diff --git a/src/components/settings/DocsContent.locales.test.ts b/src/components/settings/DocsContent.locales.test.ts index 5c35ca4..c76de14 100644 --- a/src/components/settings/DocsContent.locales.test.ts +++ b/src/components/settings/DocsContent.locales.test.ts @@ -15,6 +15,7 @@ import en from "../../i18n/locales/en.json"; import { SECTIONS } from "./DocsContent"; import { readSubsections } from "./DocsSubsections"; import { GETTING_STARTED_GUIDE } from "../onboarding/OnboardingBanner"; +import { computeChecklist } from "../../utils/onboardingChecklist"; type Docs = Record; @@ -137,6 +138,27 @@ describe("docs.* keys of the user guide", () => { } }); + it("name the eight getting-started steps in the Dashboard section, by their labels, in the list's order", () => { + // docs.dashboard.steps[0] lists the checklist (#446 review): each row by + // the label it shows, in the order computeChecklist gives them, and the + // button that checks the visit steps by its own label. + const order = computeChecklist( + { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 }, + { importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true }, + ).items.map((item) => item.key); + expect(order).toHaveLength(8); + for (const [lng, locale] of [["fr", fr], ["en", en]] as const) { + const onboarding = (locale as { + onboarding: { checklist: { items: Record }; card: { acknowledge: string } }; + }).onboarding; + const step = (section(LOCALES[lng], "dashboard").steps as string[])[0]; + const at = order.map((key) => step.indexOf(onboarding.checklist.items[key])); + for (const [i, key] of order.entries()) expect(at[i], `${lng} ${key}`).toBeGreaterThan(-1); + expect(at, `${lng}: the list's order`).toEqual([...at].sort((a, b) => a - b)); + expect(step, lng).toContain(onboarding.card.acknowledge); + } + }); + it("describe the getting-started path in the section that \"Open the guide\" opens", () => { // The end of the path links to the guide by its hash (#397); the section it // opens carries the path's own sub-section (#401). diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2e35478..2f2f067 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1015,7 +1015,7 @@ "Welcome card for a profile without transactions, with the getting-started checklist: eight steps, checked from your data or with \"Got it\" for the visit steps, and a \"Hide\" button" ], "steps": [ - "In a profile without transactions, the dashboard shows a welcome card and the getting-started checklist: create a profile (already ticked), choose an import folder, set up an import source, import a first file, categorize a transaction by hand, enter a first budget. Each unticked row leads to the page where it is done; the Budget box is marked \"Base and up\" in the Free edition and does not count towards the total", + "In a profile without transactions, the dashboard shows a welcome card and the getting-started checklist, in eight steps: \"Create a profile\" (already ticked), \"Choose an import folder\", \"Set up an import source\", \"Import a first file\", \"Browse categories and keywords\", \"Categorize a transaction by hand\", \"Enter a first budget\" and \"Browse the reports\". The steps are ticked from your data, except the two visit steps (categories and reports), ticked with \"Got it\" on their card or, for the reports, by a click on the outlined Trends card. Each unticked row leads to the page where it is done; the Budget box is marked \"Base and up\" in the Free edition and does not count towards the total", "Once the profile has transactions, the checklist leaves the dashboard: the getting-started banner, at the top of every page, takes over until the end of the path (see Getting Started → The getting-started path)", "\"Hide\" removes the checklist from the welcome card, and the banner from every page, for this profile only; Settings → Users shows them again", "Use the period selector in the top-right to choose a time range (month, 3 months, year, etc.)", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 9a89e68..e156f93 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1015,7 +1015,7 @@ "Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : huit étapes, cochées selon vos données ou par « J'ai compris » pour les visites, et un bouton « Masquer »" ], "steps": [ - "Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage : créer un profil (déjà cochée), choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total", + "Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage, en huit étapes : « Créer un profil » (déjà cochée), « Choisir un dossier d'import », « Configurer une source d'import », « Importer un premier fichier », « Parcourir les catégories et les mots-clés », « Classer une transaction à la main », « Saisir un premier budget » et « Parcourir les rapports ». Les étapes se cochent selon vos données, sauf les deux étapes de visite (catégories et rapports), cochées par « J'ai compris » sur leur carte ou, pour les rapports, par un clic sur la carte Tendances entourée. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total", "Dès que le profil a des transactions, la liste quitte le tableau de bord : le bandeau de démarrage, en haut de chaque page, la remplace jusqu'à la fin du parcours (voir Premiers pas → Le parcours de démarrage)", "« Masquer » retire la liste de la carte d'accueil, et le bandeau de toutes les pages, pour ce profil seulement ; Paramètres → Utilisateurs les réaffiche", "Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)", -- 2.45.2 From 5f2c4dd5bda5c25c5732180ac0ec19e5d374e882 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 19 Oct 2026 21:06:22 -0400 Subject: [PATCH 07/25] fix(combobox): follow the input when a render moves it (#429) Review 25431: a window centered on screen grows when its content arrives, and moves its fields with no scroll nor resize; the open list, placed in a portal, stayed where it was. While open, the combobox now reads its position again after every render (useLayoutEffect without dependencies); `keepPlacement` hands back the same state object for an unchanged position, so React renders nothing more. Pinned by CategoryCombobox.placement.test.ts (harness, useLayoutEffect mapped on its useEffect, fake input) and keepPlacement's own tests. The boundary test of placeListbox now sits at exactly 240 px below with room above (point 1), and AddKeywordDialog's comment no longer claims the list is cut (point 3). Co-Authored-By: Claude Opus 5.5 --- .../categories/AddKeywordDialog.tsx | 7 +- .../shared/CategoryCombobox.placement.test.ts | 105 ++++++++++++++++++ src/components/shared/CategoryCombobox.tsx | 13 ++- src/components/shared/placeListbox.test.ts | 33 +++++- src/components/shared/placeListbox.ts | 13 +++ 5 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 src/components/shared/CategoryCombobox.placement.test.ts diff --git a/src/components/categories/AddKeywordDialog.tsx b/src/components/categories/AddKeywordDialog.tsx index 69c12ee..a67ef6b 100644 --- a/src/components/categories/AddKeywordDialog.tsx +++ b/src/components/categories/AddKeywordDialog.tsx @@ -146,9 +146,10 @@ export default function AddKeywordDialog({

{t("reports.keyword.dialogTitle")}

- {/* The two fields sit outside the scrolling block, so the open category - list is drawn over the preview below instead of being cut by its - scrollport. */} + {/* The two fields sit outside the scrolling block, so they stay in view + while the preview scrolls. The open category list does not depend + on it any more: since #429 it lives in a portal on document.body, + where no scrollport cuts it. */}