feat(categories): merge custom categories into the standard taxonomy (#259)
All checks were successful
PR Check / rust (pull_request) Successful in 22m15s
PR Check / frontend (pull_request) Successful in 2m29s

Custom categories with no standard match were shown as read-only text in
the migration wizard and force-parented under a catch-all bucket. They now
get the same inline target picker as seeded rows: picking a standard leaf
merges the custom category — its transactions, budgets, keywords and
suppliers are reassigned to the leaf — and deactivates it. Leaving a custom
unmapped keeps the previous behaviour and never blocks the wizard.

- Reducer: RESOLVE_ROW resolves rows in both plan.rows and plan.preserved;
  the Next-button guard still counts seeded rows only.
- Writer: the rewrite mapping now includes resolved preserved rows; the
  catch-all parent is created only when a custom is left unmerged; merged
  customs are deactivated instead of re-parented (shared isResolvedTarget
  helper across the three sites).
- UI: the preserved block renders MappingRow instead of plain text.
- i18n (FR/EN) + CHANGELOG (FR/EN).

Tests: reducer (resolve a preserved custom, guard unchanged, GO_NEXT still
proceeds) + writer (reassign to the chosen leaf, soft-delete, no empty
parent when all merged, orphan-free merge of a custom parent with an
unresolved child). 836 vitest green, tsc + vite build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-07-18 18:06:23 -04:00
parent e4fe703578
commit 60d7f8ca49
9 changed files with 227 additions and 46 deletions

View file

@ -2,6 +2,10 @@
## [Non publié] ## [Non publié]
### Ajouté
- Migration des catégories : les catégories personnalisées sans correspondance standard peuvent désormais être **fusionnées** dans une catégorie standard, au lieu d'être seulement mises de côté. Chaque catégorie personnalisée du bloc « Catégories personnalisées » reçoit le même sélecteur de cible que les lignes du seed — choisissez une feuille standard et ses transactions, budgets, mots-clés et fournisseurs y sont réassignés, puis la catégorie personnalisée est retirée. Laisser une catégorie personnalisée non mappée conserve le comportement précédent (regroupée sous « Catégories personnalisées (migration) ») et ne bloque jamais la migration (#259).
## [0.14.0] - 2026-07-18 ## [0.14.0] - 2026-07-18
### Modifié ### Modifié

View file

@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Added
- Category migration: custom categories that have no standard match can now be **merged** into a standard category instead of only being set aside. Each custom category in the "Custom categories" block gets the same target picker as the seeded rows — choose a standard leaf and its transactions, budgets, keywords and suppliers are reassigned to it, then the custom category is removed. Leaving a custom category unmapped keeps the previous behaviour (grouped under "Custom categories (migration)") and never blocks the migration (#259).
## [0.14.0] - 2026-07-18 ## [0.14.0] - 2026-07-18
### Changed ### Changed

View file

@ -199,18 +199,20 @@ export default function StepSimulate({
</p> </p>
</div> </div>
</div> </div>
<ul className="text-sm space-y-1"> <ul className="space-y-1">
{plan.preserved.map((row) => ( {plan.preserved.map((row) => (
<li <li key={row.v2CategoryId}>
key={row.v2CategoryId} <MappingRow
className="px-3 py-1.5 rounded-md bg-[var(--muted)] text-[var(--foreground)]" row={row}
> isSelected={selectedRowV2Id === row.v2CategoryId}
<span className="font-medium">{row.v2CategoryName}</span> onSelect={onSelectRow}
<span className="text-xs text-[var(--muted-foreground)] ml-2"> onResolve={onResolveRow}
{t("categoriesSeed.migration.simulate.preserved.txCount", { transactionCount={
count: transactionCountByV2Id.get(row.v2CategoryId) ?? 0, transactionCountByV2Id.get(row.v2CategoryId) ?? 0
})} }
</span> targetCategories={targetCategories}
resolveTarget={resolveTarget}
/>
</li> </li>
))} ))}
</ul> </ul>

View file

@ -95,6 +95,46 @@ describe("migrationReducer", () => {
expect(resolved.confidence).toBe("medium"); expect(resolved.confidence).toBe("medium");
}); });
it("RESOLVE_ROW resolves a preserved custom category and bumps its confidence (#259)", () => {
const plan = makePlan([makeRow(10, 1011)], [makeRow(9001, null)]);
const s1 = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
const s2 = migrationReducer(s1, {
type: "RESOLVE_ROW",
v2CategoryId: 9001,
v1TargetId: 1111,
v1TargetName: "Épicerie régulière",
});
const merged = s2.plan!.preserved.find((r) => r.v2CategoryId === 9001)!;
expect(merged.v1TargetId).toBe(1111);
expect(merged.v1TargetName).toBe("Épicerie régulière");
expect(merged.confidence).toBe("medium");
});
it("RESOLVE_ROW on a preserved custom does NOT change unresolved (seed rows only) (#259)", () => {
// One unresolved seed row + one custom: the guard counts the seed only.
const plan = makePlan([makeRow(10, null)], [makeRow(9001, null)]);
const s1 = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
expect(s1.unresolved).toBe(1);
const s2 = migrationReducer(s1, {
type: "RESOLVE_ROW",
v2CategoryId: 9001,
v1TargetId: 1111,
v1TargetName: "Épicerie régulière",
});
// Merging the custom must not decrement the seed-row guard.
expect(s2.unresolved).toBe(1);
});
it("GO_NEXT advances simulate -> consent with an unmerged custom still present (#259)", () => {
// All seed rows resolved, one custom left unmerged: the wizard must proceed.
const plan = makePlan([makeRow(10, 1011)], [makeRow(9001, null)]);
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
s = migrationReducer(s, { type: "GO_NEXT" }); // discover -> simulate
expect(s.step).toBe("simulate");
s = migrationReducer(s, { type: "GO_NEXT" }); // simulate -> consent
expect(s.step).toBe("consent");
});
it("GO_NEXT blocks simulate -> consent when unresolved > 0", () => { it("GO_NEXT blocks simulate -> consent when unresolved > 0", () => {
const plan = makePlan([makeRow(10, null)]); const plan = makePlan([makeRow(10, null)]);
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan }); let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });

View file

@ -118,28 +118,36 @@ export function migrationReducer(
case "RESOLVE_ROW": { case "RESOLVE_ROW": {
if (state.plan === null) return state; if (state.plan === null) return state;
const rows = state.plan.rows.map((r) => // A resolved target can land on a seeded row (plan.rows) OR a custom
// category (plan.preserved) — the latter is the "merge a custom into a
// standard leaf" path (#259). Apply the target in whichever bucket holds
// the row; it is a no-op for the other one.
const applyTarget = (r: MappingRow): MappingRow =>
r.v2CategoryId === action.v2CategoryId r.v2CategoryId === action.v2CategoryId
? { ? {
...r, ...r,
v1TargetId: action.v1TargetId, v1TargetId: action.v1TargetId,
v1TargetName: action.v1TargetName, v1TargetName: action.v1TargetName,
// Once a user resolves a row manually, bump the confidence badge // Resolving a row manually bumps the confidence badge to "medium"
// to "medium" so the simulate table reflects their decision. // so the table reflects the decision. Reason is left as-is so the
// We keep the reason as-is so that the tooltip still explains // tooltip still explains what the algorithm thought.
// what the algorithm thought.
confidence: r.confidence === "none" ? "medium" : r.confidence, confidence: r.confidence === "none" ? "medium" : r.confidence,
} }
: r, : r;
); const rows = state.plan.rows.map(applyTarget);
const preserved = state.plan.preserved.map(applyTarget);
const plan: MigrationPlan = { const plan: MigrationPlan = {
...state.plan, ...state.plan,
rows, rows,
preserved,
unresolved: rows.filter((r) => r.v1TargetId === null), unresolved: rows.filter((r) => r.v1TargetId === null),
}; };
return { return {
...state, ...state,
plan, plan,
// `unresolved` gates the Next button and counts seeded rows ONLY. A
// custom left unmerged is a legitimate choice and must never block the
// wizard.
unresolved: countUnresolved(rows), unresolved: countUnresolved(rows),
}; };
} }

View file

@ -1483,11 +1483,9 @@
"total": "Total" "total": "Total"
}, },
"preserved": { "preserved": {
"title_one": "{{count}} custom category preserved", "title_one": "{{count}} custom category",
"title_other": "{{count}} custom categories preserved", "title_other": "{{count}} custom categories",
"body": "Your custom categories will be grouped under the parent \"Custom categories (migration)\". You can move or rename them at your own pace after the migration.", "body": "These categories have no standard equivalent. Pick a target to merge a category: its transactions, budgets, keywords and suppliers are reassigned to it and the category disappears. Leave the field empty to keep it as-is under \"Custom categories (migration)\"."
"txCount_one": "{{count}} transaction",
"txCount_other": "{{count}} transactions"
}, },
"panel": { "panel": {
"title": "Affected transactions", "title": "Affected transactions",

View file

@ -1483,11 +1483,9 @@
"total": "Total" "total": "Total"
}, },
"preserved": { "preserved": {
"title_one": "{{count}} catégorie personnalisée préservée", "title_one": "{{count}} catégorie personnalisée",
"title_other": "{{count}} catégories personnalisées préservées", "title_other": "{{count}} catégories personnalisées",
"body": "Vos catégories personnalisées seront regroupées sous le parent « Catégories personnalisées (migration) ». Vous pourrez les déplacer ou les renommer à votre rythme après la migration.", "body": "Ces catégories n'ont pas d'équivalent standard. Choisissez une cible pour fusionner une catégorie : ses transactions, budgets, mots-clés et fournisseurs y sont réassignés et la catégorie disparaît. Laissez le champ vide pour la conserver telle quelle sous « Catégories personnalisées (migration) »."
"txCount_one": "{{count}} transaction",
"txCount_other": "{{count}} transactions"
}, },
"panel": { "panel": {
"title": "Transactions impactées", "title": "Transactions impactées",

View file

@ -303,6 +303,119 @@ describe("applyMigration — preserved custom categories", () => {
}); });
}); });
// ---------------------------------------------------------------------------
// Merged custom categories (#259)
// ---------------------------------------------------------------------------
describe("applyMigration — merged custom categories (#259)", () => {
it("reassigns transactions/budgets/keywords/suppliers of a merged custom to the chosen leaf", async () => {
// Custom category 9001 merged into leaf 1444.
await applyMigration(
makePlan([makeRow(22, 1111)], [makeRow(9001, 1444)]),
FAKE_BACKUP,
);
const merged = (re: RegExp) =>
fake.calls.filter(
(c) => re.test(c.sql) && (c.params?.[1] as number) === 9001,
);
// Each reassignment must target the chosen leaf (1444), keyed by the custom
// id (9001) — not merely be emitted.
expect(merged(/UPDATE transactions SET category_id/i)[0]?.params).toEqual([
1444, 9001,
]);
expect(merged(/UPDATE budget_entries SET category_id/i)[0]?.params).toEqual([
1444, 9001,
]);
expect(merged(/UPDATE keywords SET category_id/i)[0]?.params).toEqual([
1444, 9001,
]);
expect(merged(/UPDATE suppliers SET category_id/i)[0]?.params).toEqual([
1444, 9001,
]);
});
it("soft-deletes a merged custom (is_active=0) and does NOT re-parent it", async () => {
await applyMigration(
makePlan([makeRow(22, 1111)], [makeRow(9001, 1444)]),
FAKE_BACKUP,
);
const deactivate = fake.calls.filter(
(c) =>
/UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) &&
(c.params?.[0] as number) === 9001,
);
const reparent = fake.calls.filter(
(c) =>
/UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
(c.params?.[1] as number) === 9001,
);
expect(deactivate.length).toBe(1);
expect(reparent.length).toBe(0);
});
it("does NOT create the custom parent when every preserved custom is merged", async () => {
await applyMigration(
makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, 1555)]),
FAKE_BACKUP,
);
const parentInserts = fake.calls.filter(
(c) =>
/INSERT OR IGNORE INTO categories/i.test(c.sql) &&
(c.params?.[0] as number) === 2000,
);
expect(parentInserts.length).toBe(0);
});
it("creates the custom parent when at least one preserved custom is left unmerged", async () => {
await applyMigration(
makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, null)]),
FAKE_BACKUP,
);
const parentInserts = fake.calls.filter(
(c) =>
/INSERT OR IGNORE INTO categories/i.test(c.sql) &&
(c.params?.[0] as number) === 2000,
);
expect(parentInserts.length).toBe(1);
// Only the unmerged custom (9002) is re-parented; the merged one (9001) is not.
const reparent = fake.calls.filter((c) =>
/UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql),
);
expect(reparent.map((c) => c.params?.[1])).toEqual([9002]);
});
it("leaves no orphan when a custom PARENT is merged but its custom CHILD is not (regression)", async () => {
// plan.preserved is a flat list: a merged parent (9001 → 1444) and its
// unresolved child (9002). The child must land under the bucket (2000) and
// the parent be deactivated — the child never dangles under a dead parent.
await applyMigration(
makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, null)]),
FAKE_BACKUP,
);
// Child re-parented under 2000.
const childReparent = fake.calls.filter(
(c) =>
/UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
(c.params?.[1] as number) === 9002,
);
expect(childReparent.length).toBe(1);
expect(childReparent[0].params?.[0]).toBe(2000);
// Parent merged → deactivated, not re-parented.
const parentDeactivate = fake.calls.filter(
(c) =>
/UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) &&
(c.params?.[0] as number) === 9001,
);
const parentReparent = fake.calls.filter(
(c) =>
/UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
(c.params?.[1] as number) === 9001,
);
expect(parentDeactivate.length).toBe(1);
expect(parentReparent.length).toBe(0);
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Rollback on SQL failure // Rollback on SQL failure
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -142,12 +142,17 @@ function validateBackup(backup: BackupResult): void {
} }
} }
/** Build the v2Id → v1Id map from plan.rows (only resolved targets are kept). */ /** True when a mapping row carries a concrete v1 target (resolved / merged). */
function isResolvedTarget(row: MappingRow): boolean {
return row.v1TargetId !== null && row.v1TargetId !== undefined;
}
/** Build the v2Id → v1Id map from mapping rows (only resolved targets kept). */
function buildMappingFromRows(rows: MappingRow[]): Map<number, number> { function buildMappingFromRows(rows: MappingRow[]): Map<number, number> {
const map = new Map<number, number>(); const map = new Map<number, number>();
for (const row of rows) { for (const row of rows) {
if (row.v1TargetId !== null && row.v1TargetId !== undefined) { if (isResolvedTarget(row)) {
map.set(row.v2CategoryId, row.v1TargetId); map.set(row.v2CategoryId, row.v1TargetId as number);
} }
} }
return map; return map;
@ -190,13 +195,21 @@ async function applyMigrationInTransaction(
backup: BackupResult, backup: BackupResult,
outcome: MigrationOutcome, outcome: MigrationOutcome,
): Promise<MigrationOutcome> { ): Promise<MigrationOutcome> {
const mapping = buildMappingFromRows(plan.rows); // Seeded rows AND merged custom categories (preserved rows the user gave a
// target) share the same rewrite: their transactions, budgets, keywords and
// suppliers are reassigned to the chosen v1 leaf. Unresolved preserved rows
// have a null target and are filtered out by buildMappingFromRows.
const allMappableRows = [...plan.rows, ...plan.preserved];
const mapping = buildMappingFromRows(allMappableRows);
await db.execute("BEGIN"); await db.execute("BEGIN");
try { try {
// 1. Optionally create the "custom categories (migration)" parent. // 1. Optionally create the "custom categories (migration)" parent — only
// when at least one custom category is left UNMERGED. If every custom
// was merged into a standard leaf, the bucket would be empty, so skip it.
let customParentId: number | null = null; let customParentId: number | null = null;
if (plan.preserved.length > 0) { const hasUnmergedPreserved = plan.preserved.some((p) => !isResolvedTarget(p));
if (hasUnmergedPreserved) {
// Use INSERT OR IGNORE so a re-run never throws on the PK. // Use INSERT OR IGNORE so a re-run never throws on the PK.
await db.execute( await db.execute(
`INSERT OR IGNORE INTO categories `INSERT OR IGNORE INTO categories
@ -322,6 +335,9 @@ async function applyMigrationInTransaction(
// at a v2 structural parent in the 1..6 range): children follow naturally. // at a v2 structural parent in the 1..6 range): children follow naturally.
if (customParentId !== null) { if (customParentId !== null) {
for (const preservedRow of plan.preserved) { for (const preservedRow of plan.preserved) {
// Merged customs are deactivated in the soft-delete step below, not
// re-parented — only the ones left unmerged move under the bucket.
if (isResolvedTarget(preservedRow)) continue;
const r = await db.execute( const r = await db.execute(
`UPDATE categories SET parent_id = $1 WHERE id = $2`, `UPDATE categories SET parent_id = $1 WHERE id = $2`,
[customParentId, preservedRow.v2CategoryId], [customParentId, preservedRow.v2CategoryId],
@ -330,18 +346,16 @@ async function applyMigrationInTransaction(
} }
} }
// 9. Soft-delete v2 seeded categories that are now unreferenced. // 9. Soft-delete every category whose data we just rewrote: mapped v2 seed
// We deactivate instead of hard-deleting so that any historical // rows AND merged custom categories. We deactivate (is_active=0) instead
// reference we might have missed stays intact (is_active=0 hides them // of hard-deleting so any historical reference we might have missed stays
// from the UI lists). We explicitly only target the v2 seed id range // intact and hidden from the UI lists.
// (< 1000) AND ids that map in our plan — this avoids touching user for (const row of allMappableRows) {
// custom categories that may also have parent_id < 1000 structural. // Skip rows with no target. Seed rows can't reach here (consent is
for (const row of plan.rows) { // blocked until they're all resolved), but a custom left unmerged
// Only deactivate rows that were part of the v2 seed AND we successfully // legitimately does — it must stay alive under the bucket, not be
// mapped to a v1 target. Rows with no v1 target (unresolved review) are // deactivated.
// left alone — in the UX, the consent step is blocked until all rows if (!isResolvedTarget(row)) continue;
// are resolved, so this should be dead code, but it is a safety net.
if (row.v1TargetId === null) continue;
const r = await db.execute( const r = await db.execute(
`UPDATE categories SET is_active = 0 WHERE id = $1`, `UPDATE categories SET is_active = 0 WHERE id = $1`,
[row.v2CategoryId], [row.v2CategoryId],