feat(categories): merge custom categories into the standard taxonomy (#259)
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:
parent
e4fe703578
commit
60d7f8ca49
9 changed files with 227 additions and 46 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [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
|
||||
|
||||
### Modifié
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -199,18 +199,20 @@ export default function StepSimulate({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="text-sm space-y-1">
|
||||
<ul className="space-y-1">
|
||||
{plan.preserved.map((row) => (
|
||||
<li
|
||||
key={row.v2CategoryId}
|
||||
className="px-3 py-1.5 rounded-md bg-[var(--muted)] text-[var(--foreground)]"
|
||||
>
|
||||
<span className="font-medium">{row.v2CategoryName}</span>
|
||||
<span className="text-xs text-[var(--muted-foreground)] ml-2">
|
||||
{t("categoriesSeed.migration.simulate.preserved.txCount", {
|
||||
count: transactionCountByV2Id.get(row.v2CategoryId) ?? 0,
|
||||
})}
|
||||
</span>
|
||||
<li key={row.v2CategoryId}>
|
||||
<MappingRow
|
||||
row={row}
|
||||
isSelected={selectedRowV2Id === row.v2CategoryId}
|
||||
onSelect={onSelectRow}
|
||||
onResolve={onResolveRow}
|
||||
transactionCount={
|
||||
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
||||
}
|
||||
targetCategories={targetCategories}
|
||||
resolveTarget={resolveTarget}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,46 @@ describe("migrationReducer", () => {
|
|||
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", () => {
|
||||
const plan = makePlan([makeRow(10, null)]);
|
||||
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
|
||||
|
|
|
|||
|
|
@ -118,28 +118,36 @@ export function migrationReducer(
|
|||
|
||||
case "RESOLVE_ROW": {
|
||||
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,
|
||||
v1TargetId: action.v1TargetId,
|
||||
v1TargetName: action.v1TargetName,
|
||||
// Once a user resolves a row manually, bump the confidence badge
|
||||
// to "medium" so the simulate table reflects their decision.
|
||||
// We keep the reason as-is so that the tooltip still explains
|
||||
// what the algorithm thought.
|
||||
// Resolving a row manually bumps the confidence badge to "medium"
|
||||
// so the table reflects the decision. Reason is left as-is so the
|
||||
// tooltip still explains what the algorithm thought.
|
||||
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 = {
|
||||
...state.plan,
|
||||
rows,
|
||||
preserved,
|
||||
unresolved: rows.filter((r) => r.v1TargetId === null),
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1483,11 +1483,9 @@
|
|||
"total": "Total"
|
||||
},
|
||||
"preserved": {
|
||||
"title_one": "{{count}} custom category preserved",
|
||||
"title_other": "{{count}} custom categories preserved",
|
||||
"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.",
|
||||
"txCount_one": "{{count}} transaction",
|
||||
"txCount_other": "{{count}} transactions"
|
||||
"title_one": "{{count}} custom category",
|
||||
"title_other": "{{count}} custom categories",
|
||||
"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)\"."
|
||||
},
|
||||
"panel": {
|
||||
"title": "Affected transactions",
|
||||
|
|
|
|||
|
|
@ -1483,11 +1483,9 @@
|
|||
"total": "Total"
|
||||
},
|
||||
"preserved": {
|
||||
"title_one": "{{count}} catégorie personnalisée préservée",
|
||||
"title_other": "{{count}} catégories personnalisées préservé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.",
|
||||
"txCount_one": "{{count}} transaction",
|
||||
"txCount_other": "{{count}} transactions"
|
||||
"title_one": "{{count}} catégorie personnalisée",
|
||||
"title_other": "{{count}} catégories personnalisées",
|
||||
"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) »."
|
||||
},
|
||||
"panel": {
|
||||
"title": "Transactions impactées",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
const map = new Map<number, number>();
|
||||
for (const row of rows) {
|
||||
if (row.v1TargetId !== null && row.v1TargetId !== undefined) {
|
||||
map.set(row.v2CategoryId, row.v1TargetId);
|
||||
if (isResolvedTarget(row)) {
|
||||
map.set(row.v2CategoryId, row.v1TargetId as number);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
|
|
@ -190,13 +195,21 @@ async function applyMigrationInTransaction(
|
|||
backup: BackupResult,
|
||||
outcome: 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");
|
||||
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;
|
||||
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.
|
||||
await db.execute(
|
||||
`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.
|
||||
if (customParentId !== null) {
|
||||
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(
|
||||
`UPDATE categories SET parent_id = $1 WHERE id = $2`,
|
||||
[customParentId, preservedRow.v2CategoryId],
|
||||
|
|
@ -330,18 +346,16 @@ async function applyMigrationInTransaction(
|
|||
}
|
||||
}
|
||||
|
||||
// 9. Soft-delete v2 seeded categories that are now unreferenced.
|
||||
// We deactivate instead of hard-deleting so that any historical
|
||||
// reference we might have missed stays intact (is_active=0 hides them
|
||||
// from the UI lists). We explicitly only target the v2 seed id range
|
||||
// (< 1000) AND ids that map in our plan — this avoids touching user
|
||||
// custom categories that may also have parent_id < 1000 structural.
|
||||
for (const row of plan.rows) {
|
||||
// Only deactivate rows that were part of the v2 seed AND we successfully
|
||||
// mapped to a v1 target. Rows with no v1 target (unresolved review) are
|
||||
// left alone — in the UX, the consent step is blocked until all rows
|
||||
// are resolved, so this should be dead code, but it is a safety net.
|
||||
if (row.v1TargetId === null) continue;
|
||||
// 9. Soft-delete every category whose data we just rewrote: mapped v2 seed
|
||||
// rows AND merged custom categories. We deactivate (is_active=0) instead
|
||||
// of hard-deleting so any historical reference we might have missed stays
|
||||
// intact and hidden from the UI lists.
|
||||
for (const row of allMappableRows) {
|
||||
// Skip rows with no target. Seed rows can't reach here (consent is
|
||||
// blocked until they're all resolved), but a custom left unmerged
|
||||
// legitimately does — it must stay alive under the bucket, not be
|
||||
// deactivated.
|
||||
if (!isResolvedTarget(row)) continue;
|
||||
const r = await db.execute(
|
||||
`UPDATE categories SET is_active = 0 WHERE id = $1`,
|
||||
[row.v2CategoryId],
|
||||
|
|
|
|||
Loading…
Reference in a new issue