refactor(categories): migration target picker lists leaves only
All checks were successful
PR Check / rust (pull_request) Successful in 22m29s
PR Check / frontend (pull_request) Successful in 2m30s

The v1 target combobox in the migration preview now offers only leaf
categories (the inputable end-categories), not intermediate parents, so a
transaction is never mapped to a grouping bucket. Leaves render as a flat,
un-indented list in taxonomy order.

A low-confidence default can still point a v2 category at a non-leaf parent
(e.g. 'Divertissement' #1710); MappingRow re-injects that current target for
its single row via findTaxonomyCategory so the input stays populated, while
new picks remain leaves-only. Adds a findTaxonomyCategory pure helper + tests
(adapter now returns 112 leaves, not the 150-node full tree).
This commit is contained in:
le king fu 2026-07-04 20:40:29 -04:00
parent e45736bbde
commit ce2793fc2c
4 changed files with 185 additions and 66 deletions

View file

@ -1,3 +1,4 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import CategoryCombobox from "../shared/CategoryCombobox"; import CategoryCombobox from "../shared/CategoryCombobox";
import type { Category } from "../../shared/types"; import type { Category } from "../../shared/types";
@ -22,10 +23,18 @@ interface MappingRowProps {
/** Number of transactions currently attached to this v2 category. */ /** Number of transactions currently attached to this v2 category. */
transactionCount: number; transactionCount: number;
/** /**
* v1 leaf catalogue adapted to the `Category` shape the combobox expects. * v1 LEAF catalogue adapted to the `Category` shape the combobox expects.
* Computed ONCE by StepSimulate and shared across rows (perf). * Computed ONCE by StepSimulate and shared across rows (perf). Leaves only
* a transaction is never mapped to a grouping bucket.
*/ */
targetCategories: Category[]; targetCategories: Category[];
/**
* Resolves a target id (possibly a non-leaf parent absent from the leaves-only
* `targetCategories`) to a display `Category`. Used to keep a low-confidence
* parent default like "Divertissement" (#1710) visible on its row without
* offering parents as new picks.
*/
resolveTarget: (id: number) => Category | null;
} }
function badgeClass(confidence: ConfidenceBadge): string { function badgeClass(confidence: ConfidenceBadge): string {
@ -48,9 +57,23 @@ export default function MappingRow({
onResolve, onResolve,
transactionCount, transactionCount,
targetCategories, targetCategories,
resolveTarget,
}: MappingRowProps) { }: MappingRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// The picker lists leaves only. If this row's current target is a non-leaf
// parent (a low-confidence default like "Divertissement" #1710), it is absent
// from that list and would blank the combobox input, so re-inject it for this
// row only — shown as the current value, never offered as a new pick.
const comboboxCategories = useMemo(() => {
const id = row.v1TargetId;
if (id == null || targetCategories.some((c) => c.id === id)) {
return targetCategories;
}
const current = resolveTarget(id);
return current ? [...targetCategories, current] : targetCategories;
}, [targetCategories, row.v1TargetId, resolveTarget]);
const badgeLabel = t( const badgeLabel = t(
`categoriesSeed.migration.simulate.confidence.${row.confidence}`, `categoriesSeed.migration.simulate.confidence.${row.confidence}`,
); );
@ -136,7 +159,7 @@ export default function MappingRow({
> >
<div className="w-full max-w-[16rem]"> <div className="w-full max-w-[16rem]">
<CategoryCombobox <CategoryCombobox
categories={targetCategories} categories={comboboxCategories}
value={row.v1TargetId ?? null} value={row.v1TargetId ?? null}
onChange={handleTargetChange} onChange={handleTargetChange}
compact compact

View file

@ -1,9 +1,12 @@
import { useMemo } from "react"; import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react"; import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
import MappingRow from "./MappingRow"; import MappingRow from "./MappingRow";
import TransactionPreviewPanel from "./TransactionPreviewPanel"; import TransactionPreviewPanel from "./TransactionPreviewPanel";
import { taxonomyToComboboxCategories } from "./migrationTargets"; import {
taxonomyToComboboxCategories,
findTaxonomyCategory,
} from "./migrationTargets";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy"; import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
import type { import type {
MigrationPlan, MigrationPlan,
@ -40,15 +43,23 @@ export default function StepSimulate({
const { t } = useTranslation(); const { t } = useTranslation();
const { taxonomy } = useCategoryTaxonomy(); const { taxonomy } = useCategoryTaxonomy();
// Full v1 taxonomy adapted to the combobox `Category` shape (hierarchical). // v1 LEAF catalogue adapted to the combobox `Category` shape. Computed once
// Computed once here (not per row) and shared across every MappingRow's // here (not per row) and shared across every MappingRow's target picker —
// target picker. Includes non-leaf parents so mid-tree default targets like // only leaves are offered, so a transaction is never mapped to a grouping
// "Divertissement" (id 1710) display and can be re-picked. // bucket.
const targetCategories = useMemo( const targetCategories = useMemo(
() => taxonomyToComboboxCategories(taxonomy.roots), () => taxonomyToComboboxCategories(taxonomy.roots),
[taxonomy], [taxonomy],
); );
// Resolve a target id (possibly a non-leaf parent absent from the leaves-only
// list) to a display Category, so a row keeps showing a low-confidence parent
// default like "Divertissement" (#1710) without offering parents as picks.
const resolveTarget = useCallback(
(id: number) => findTaxonomyCategory(taxonomy.roots, id),
[taxonomy],
);
const selectedRow = useMemo<MappingRowType | null>(() => { const selectedRow = useMemo<MappingRowType | null>(() => {
if (selectedRowV2Id === null) return null; if (selectedRowV2Id === null) return null;
return ( return (
@ -162,6 +173,7 @@ export default function StepSimulate({
transactionCountByV2Id.get(row.v2CategoryId) ?? 0 transactionCountByV2Id.get(row.v2CategoryId) ?? 0
} }
targetCategories={targetCategories} targetCategories={targetCategories}
resolveTarget={resolveTarget}
/> />
</li> </li>
))} ))}

View file

@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { taxonomyToComboboxCategories } from "./migrationTargets"; import {
taxonomyToComboboxCategories,
findTaxonomyCategory,
} from "./migrationTargets";
import { import {
getTaxonomyV1, getTaxonomyV1,
type TaxonomyNode, type TaxonomyNode,
@ -22,38 +25,50 @@ function node(
}; };
} }
describe("taxonomyToComboboxCategories", () => { describe("taxonomyToComboboxCategories (leaves only)", () => {
it("returns [] for empty input", () => { it("returns [] for empty input", () => {
expect(taxonomyToComboboxCategories([])).toEqual([]); expect(taxonomyToComboboxCategories([])).toEqual([]);
}); });
it("flattens the tree in parent-before-children (DFS) order", () => { it("keeps only leaves, dropping every intermediate parent, in DFS order", () => {
const roots = [ const roots = [
node(1000, "Revenus", [node(1010, "Emploi", [node(1011, "Paie")])]), node(1000, "Revenus", [
node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]),
]),
node(1100, "Alimentation", [node(1111, "Épicerie")]), node(1100, "Alimentation", [node(1111, "Épicerie")]),
]; ];
const out = taxonomyToComboboxCategories(roots); const out = taxonomyToComboboxCategories(roots);
expect(out.map((c) => c.id)).toEqual([1000, 1010, 1011, 1100, 1111]); // parents 1000/1010/1100 dropped; leaves kept in depth-first reading order
expect(out.map((c) => c.id)).toEqual([1011, 1012, 1111]);
}); });
it("wires real parent_id (undefined for roots) so the combobox can nest", () => { it("flattens leaves: parent_id undefined + running sort_order (flat list)", () => {
const roots = [node(1000, "Revenus", [node(1010, "Emploi", [node(1011, "Paie")])])]; const roots = [
const byId = new Map(taxonomyToComboboxCategories(roots).map((c) => [c.id, c])); node(1000, "Revenus", [
expect(byId.get(1000)!.parent_id).toBeUndefined(); node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]),
expect(byId.get(1010)!.parent_id).toBe(1000); ]),
expect(byId.get(1011)!.parent_id).toBe(1010); node(1100, "Alimentation", [node(1111, "Épicerie")]),
];
const out = taxonomyToComboboxCategories(roots);
expect(out.every((c) => c.parent_id === undefined)).toBe(true);
expect(out.map((c) => c.sort_order)).toEqual([0, 1, 2]);
}); });
it("marks only leaves inputable; parents are selectable but non-inputable", () => { it("marks every returned row inputable (all leaves)", () => {
const roots = [node(1700, "Loisirs", [node(1710, "Divertissement", [node(1711, "Cinéma")])])]; const roots = [
const byId = new Map(taxonomyToComboboxCategories(roots).map((c) => [c.id, c])); node(1700, "Loisirs", [
expect(byId.get(1700)!.is_inputable).toBe(false); node(1710, "Divertissement", [node(1711, "Cinéma")]),
expect(byId.get(1710)!.is_inputable).toBe(false); // intermediate parent ]),
expect(byId.get(1711)!.is_inputable).toBe(true); // leaf ];
const out = taxonomyToComboboxCategories(roots);
expect(out.map((c) => c.id)).toEqual([1711]); // 1700 + 1710 parents dropped
expect(out.every((c) => c.is_inputable)).toBe(true);
}); });
it("copies id/name/type/color/i18n_key and defaults is_active + created_at", () => { it("copies id/name/type/color/i18n_key and defaults is_active + created_at", () => {
const [c] = taxonomyToComboboxCategories([node(1111, "Épicerie", [], "expense")]); const [c] = taxonomyToComboboxCategories([
node(1111, "Épicerie", [], "expense"),
]);
expect(c).toMatchObject({ expect(c).toMatchObject({
id: 1111, id: 1111,
name: "Épicerie", name: "Épicerie",
@ -62,22 +77,53 @@ describe("taxonomyToComboboxCategories", () => {
i18n_key: "categoriesSeed.test.1111", i18n_key: "categoriesSeed.test.1111",
is_active: true, is_active: true,
is_inputable: true, is_inputable: true,
sort_order: 1,
}); });
expect(typeof c.created_at).toBe("string"); expect(typeof c.created_at).toBe("string");
}); });
it("adapts the real bundled v1 taxonomy: every node present, ids unique, non-leaf target 1710 offered", () => { it("adapts the real v1 taxonomy: 112 leaves, ids unique, NO non-leaf parent offered", () => {
const out = taxonomyToComboboxCategories(getTaxonomyV1().roots); const out = taxonomyToComboboxCategories(getTaxonomyV1().roots);
expect(out.length).toBe(150); // 11 roots + 27 intermediates + 112 leaves expect(out.length).toBe(112); // leaves only (was 150 incl. 38 parents)
const ids = new Set(out.map((c) => c.id)); const ids = new Set(out.map((c) => c.id));
expect(ids.size).toBe(out.length); expect(ids.size).toBe(out.length);
// The migration default maps v2 cat 26 → v1 1710 (a non-leaf parent), so it // The non-leaf parent 1710 "Divertissement" must NOT be a selectable option.
// must be selectable/displayable in the picker. expect(out.find((c) => c.id === 1710)).toBeUndefined();
const divertissement = out.find((c) => c.id === 1710); // A known leaf is present and inputable.
expect(divertissement).toBeDefined();
expect(divertissement!.is_inputable).toBe(false);
// Leaves stay inputable — a random known leaf.
expect(out.find((c) => c.id === 1111)!.is_inputable).toBe(true); expect(out.find((c) => c.id === 1111)!.is_inputable).toBe(true);
expect(out.every((c) => c.is_inputable)).toBe(true);
});
});
describe("findTaxonomyCategory", () => {
const roots = [
node(1700, "Loisirs", [
node(1710, "Divertissement", [node(1711, "Cinéma")]),
]),
];
it("resolves a non-leaf parent by id (to display a current target)", () => {
const c = findTaxonomyCategory(roots, 1710);
expect(c).toMatchObject({
id: 1710,
name: "Divertissement",
is_inputable: false,
});
expect(c!.parent_id).toBeUndefined();
});
it("resolves a leaf too", () => {
expect(findTaxonomyCategory(roots, 1711)).toMatchObject({
id: 1711,
is_inputable: true,
});
});
it("returns null for an unknown id", () => {
expect(findTaxonomyCategory(roots, 9999)).toBeNull();
});
it("finds the real low-confidence default target 1710 in the bundled taxonomy", () => {
const c = findTaxonomyCategory(getTaxonomyV1().roots, 1710);
expect(c).toMatchObject({ id: 1710, is_inputable: false });
}); });
}); });

View file

@ -2,42 +2,80 @@ import type { Category } from "../../shared/types";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService"; import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
/** /**
* Flatten the v1 taxonomy tree into `Category`-shaped rows that * Flatten the v1 taxonomy into `Category`-shaped rows for the migration target
* `CategoryCombobox` can render (hierarchical, accent-insensitive type-ahead). * picker, keeping ONLY the leaves the inputable end-categories a transaction
* can actually be filed under. Intermediate parents (grouping buckets) are
* dropped so the type-ahead offers real targets only, never a bucket.
* *
* We map EVERY node roots, intermediate parents AND leaves not only the * Leaves are emitted in depth-first reading order and carry a running
* leaves: the migration's default mapping table can point a v2 category at a * `sort_order` with `parent_id` undefined, so `CategoryCombobox` renders them as
* non-leaf parent (e.g. v2 "Jeux, Films & Livres" v1 "Divertissement", * one flat, un-indented list in taxonomy order (leaves stay grouped under their
* id 1710), so the picker must be able to both display and offer those nodes. * original parent) instead of re-scrambling them by the per-parent sort_order.
* Feeding the full tree also lets the combobox reproduce the taxonomy hierarchy
* (parents before children, siblings by sort order) the same shape the
* by-category report picker already uses.
* *
* Each row carries its real `parent_id` and `sort_order`; `is_inputable` * Every leaf id is `is_inputable` in the DB after the migration, so any pick is
* mirrors the DB (leaves only), matching what the migration writer inserts via * FK-safe.
* `listAllV1Rows()`. Any id the user can pick here therefore exists in the DB
* after the migration, so the resulting mapping is always FK-safe.
* *
* Pure helper no i18n, no DB, no React. The display name is resolved by the * Edge case: a low-confidence default can point a v2 category at a NON-leaf
* combobox at render time via `i18n_key` (falling back to `name`). * parent (e.g. v2 "Jeux, Films & Livres" -> v1 "Divertissement" #1710). Such a
* value is absent from this leaves-only list; `MappingRow` re-injects it for that
* single row (via `findTaxonomyCategory`) so the input still shows the current
* suggestion, while new picks stay leaves-only.
*
* Pure helper no i18n, no DB, no React.
*/ */
export function taxonomyToComboboxCategories(roots: TaxonomyNode[]): Category[] { export function taxonomyToComboboxCategories(roots: TaxonomyNode[]): Category[] {
const out: Category[] = []; const out: Category[] = [];
const walk = (node: TaxonomyNode, parentId: number | null): void => { let order = 0;
out.push({ const walk = (node: TaxonomyNode): void => {
id: node.id, if (node.children.length === 0) {
name: node.name, out.push({
parent_id: parentId ?? undefined, id: node.id,
color: node.color, name: node.name,
type: node.type, parent_id: undefined,
is_active: true, color: node.color,
is_inputable: node.children.length === 0, type: node.type,
sort_order: node.sort_order, is_active: true,
i18n_key: node.i18n_key, is_inputable: true,
created_at: "", sort_order: order++,
}); i18n_key: node.i18n_key,
for (const child of node.children) walk(child, node.id); created_at: "",
});
return;
}
for (const child of node.children) walk(child);
}; };
for (const root of roots) walk(root, null); for (const root of roots) walk(root);
return out; return out;
} }
/**
* Resolve any taxonomy node (leaf OR parent) by id into the `Category` shape,
* for DISPLAY of a current non-leaf target that the leaves-only picker omits.
* `sort_order` is pushed to the end so, when injected, the node sorts last.
* Returns null when the id is absent from the taxonomy. Pure helper.
*/
export function findTaxonomyCategory(
roots: TaxonomyNode[],
id: number,
): Category | null {
const stack: TaxonomyNode[] = [...roots];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.id === id) {
return {
id: node.id,
name: node.name,
parent_id: undefined,
color: node.color,
type: node.type,
is_active: true,
is_inputable: node.children.length === 0,
sort_order: Number.MAX_SAFE_INTEGER,
i18n_key: node.i18n_key,
created_at: "",
};
}
for (const child of node.children) stack.push(child);
}
return null;
}