feat(categories): 3-step migration page + categoryMigrationService (#121) #131

Merged
maximus merged 1 commit from issue-121-categories-migration-page into main 2026-04-21 01:33:56 +00:00
15 changed files with 2720 additions and 27 deletions
Showing only changes of commit 0646875327 - Show all commits

View file

@ -3,6 +3,7 @@
## [Non publié]
### Ajouté
- **Page de migration des catégories en 3 étapes** (route `/settings/categories/migrate`, Paramètres → *Migrer vers la structure standard*) : les profils v2 peuvent désormais choisir de migrer vers la nouvelle taxonomie v1 IPC via un parcours guidé — *Découvrir* (arbre en lecture seule, réutilisé de la page Guide), *Simuler* (table 3 colonnes en dry-run avec badges de confiance haute / moyenne / basse / à réviser, panneau latéral cliquable montrant les 50 premières transactions impactées par ligne, sélecteur de cible en ligne pour les lignes non résolues, bouton *Suivant* bloqué tant qu'une ligne n'est pas résolue) et *Consentir* (case à cocher + champ NIP pour les profils protégés + loader 4 étapes). Au clic de confirmation, la page crée une sauvegarde SREF vérifiée via `categoryBackupService` (obligatoire, abort sur échec sans écriture BD) puis lance une transaction SQL atomique via le nouveau service `categoryMigrationService.applyMigration(plan, backup)` — BEGIN → INSERT de la taxonomie v1 → UPDATE des transactions / budgets / budget_templates / keywords / suppliers vers les nouveaux id v1 → replacement des catégories personnalisées sous un nouveau parent *Catégories personnalisées (migration)* → désactivation des catégories v2 → pose de `categories_schema_version='v1'` et journalisation dans `user_preferences.last_categories_migration` → COMMIT. Toute erreur déclenche un ROLLBACK, laissant le profil dans son état pré-migration. Les écrans de succès et d'échec affichent le chemin de la sauvegarde et, pour le succès, le nombre de lignes insérées / transactions, mots-clés et budgets migrés (#121)
- **Bannière du tableau de bord invitant les profils v2 à découvrir la nouvelle taxonomie v1 IPC** : les profils existants (marqués `categories_schema_version='v2'`) voient désormais une bannière fermable en haut du tableau de bord qui pointe vers la nouvelle page Guide des catégories standard. La bannière est non destructive (CTA en lecture seule, aucun changement de catégories), ne s'affiche qu'aux profils v2 (les nouveaux profils semés en v1 ne la voient jamais), et sa fermeture est persistée dans `user_preferences` sous la clé `categories_v1_banner_dismissed` pour ne plus réapparaître (#118)
- **Page Guide des catégories standard** (Paramètres → *Structure standard des catégories*, route `/settings/categories/standard`) : nouvelle page en lecture seule qui expose la taxonomie v1 IPC complète sous forme d'arbre navigable avec repli/expansion par racine, un compteur global en direct (racines · sous-catégories · feuilles · total), une recherche plein texte insensible aux accents sur les noms traduits, des info-bulles au survol affichant la clé `i18n_key`, le type et l'identifiant de chaque nœud, et un bouton *Exporter en PDF* qui ouvre la boîte d'impression du navigateur. Une règle `@media print` dédiée force l'affichage complet de toutes les branches à l'impression, peu importe l'état de repli à l'écran. Tous les libellés passent par `categoriesSeed.*` avec `name` en repli pour les futures lignes personnalisées. Aucune écriture en base, aucune action destructive (#117)
- **Seed de catégories IPC pour les nouveaux profils** : les nouveaux profils sont désormais créés avec la taxonomie v1 IPC (Indice des prix à la consommation) — une hiérarchie alignée sur les catégories de Statistique Canada. Les noms des catégories du seed sont traduits dynamiquement depuis la clé i18n `categoriesSeed.*` (FR/EN), donc affichés dans la langue de l'utilisateur. Les profils existants gardent l'ancien seed v2, marqués via une nouvelle préférence `categories_schema_version` (une page de migration ultérieure offrira le passage v2→v1). Côté interne : colonne `categories.i18n_key` (nullable) ajoutée par la migration v8 (strictement additive), `src/data/categoryTaxonomyV1.json` livré comme source de vérité côté TS, les renderers `CategoryTree` et `CategoryCombobox` utilisent `name` en repli quand aucune clé de traduction n'est présente (catégories créées par l'utilisateur) (#115)

View file

@ -3,6 +3,7 @@
## [Unreleased]
### Added
- **3-step category migration page** (route `/settings/categories/migrate`, Settings → *Migrate to the standard structure*): legacy v2 profiles can now opt in to migrate to the new v1 IPC taxonomy through a guided flow — *Discover* (read-only tree reused from the guide page), *Simulate* (3-column dry-run table with high / medium / low / needs-review confidence badges, a clickable side panel showing the first 50 affected transactions per row, inline target picker for unresolved rows, next button blocked until every row is resolved), and *Consent* (checklist + optional PIN field for protected profiles + 4-step loader). On confirm, the page creates a verified SREF backup via `categoryBackupService` (mandatory, abort on failure with no DB write) and then runs an atomic SQL transaction via the new `categoryMigrationService.applyMigration(plan, backup)` — BEGIN → INSERT v1 taxonomy → UPDATE transactions / budgets / budget_templates / keywords / suppliers to the new v1 category ids → reparent custom categories under a new *Custom categories (migration)* parent → soft-deactivate the v2 seed categories → bump `categories_schema_version='v1'` and journal the run in `user_preferences.last_categories_migration` → COMMIT. Any thrown error triggers ROLLBACK so the profile stays in its pre-migration state. Success and error screens surface the backup path and (for success) the counts of rows inserted / transactions, keywords and budgets migrated (#121)
- **Dashboard banner inviting v2 profiles to discover the new v1 IPC category taxonomy**: legacy profiles (tagged `categories_schema_version='v2'`) now see a dismissable banner at the top of the Dashboard pointing to the new standard categories guide page. The banner is non-destructive (read-only CTA, no category changes), only shown to v2 profiles (new v1-seeded profiles never see it), and its dismissal is persisted in `user_preferences` under `categories_v1_banner_dismissed` so it never reappears once closed (#118)
- **Standard categories guide page** (Settings → *Standard category structure*, route `/settings/categories/standard`): new read-only page that exposes the full v1 IPC taxonomy as a navigable tree with expand/collapse per root, a live category counter (roots · subcategories · leaves · total), accent-insensitive full-text search over translated names, hover tooltips showing the `i18n_key` / type / ID of each node, and a *Export as PDF* button that triggers the browser print dialog. A dedicated `@media print` rule forces every branch to render fully expanded regardless of the on-screen collapse state. All labels resolve via `categoriesSeed.*` with `name` as fallback for future custom rows. No database writes, no destructive actions (#117)
- **IPC-aligned categories seed for new profiles**: brand-new profiles are now seeded with the v1 IPC (Indice des prix à la consommation) taxonomy — a structured hierarchy aligned with Statistics Canada consumer price index categories. Category labels are now translated dynamically from the `categoriesSeed.*` i18n namespace (FR/EN), so seed categories display in the user's current language. Existing profiles remain on the legacy v2 seed, marked via a new `categories_schema_version` user preference (a later migration wizard will offer the v2→v1 transition). Internally: nullable `categories.i18n_key` column added in migration v8 (additive only), `src/data/categoryTaxonomyV1.json` bundled as the TS-side source of truth, `CategoryTree` and `CategoryCombobox` renderers fall back to the raw `name` when no translation key is present (user-created rows) (#115)

View file

@ -17,6 +17,7 @@ import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsPage from "./pages/SettingsPage";
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
import DocsPage from "./pages/DocsPage";
import ChangelogPage from "./pages/ChangelogPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
@ -117,6 +118,10 @@ export default function App() {
path="/settings/categories/standard"
element={<CategoriesStandardGuidePage />}
/>
<Route
path="/settings/categories/migrate"
element={<CategoriesMigrationPage />}
/>
<Route path="/docs" element={<DocsPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
</Route>

View file

@ -0,0 +1,151 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
import type {
MappingRow as MappingRowType,
ConfidenceBadge,
} from "../../services/categoryMappingService";
interface MappingRowProps {
row: MappingRowType;
/** When true, the row is highlighted (its preview panel is open). */
isSelected: boolean;
/** Callback fired when the row is clicked — opens the preview panel. */
onSelect: (v2CategoryId: number) => void;
/**
* Called with the new v1 target id + name when the user resolves the row
* via the inline dropdown. The dropdown is only rendered for unresolved
* ("🟠 needs review") rows resolved rows just show the target name.
*/
onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
/** Number of transactions currently attached to this v2 category. */
transactionCount: number;
}
function badgeClass(confidence: ConfidenceBadge): string {
switch (confidence) {
case "high":
return "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300";
case "medium":
return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300";
case "low":
return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300";
case "none":
return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300";
}
}
export default function MappingRow({
row,
isSelected,
onSelect,
onResolve,
transactionCount,
}: MappingRowProps) {
const { t } = useTranslation();
const { getLeaves } = useCategoryTaxonomy();
// For the resolve dropdown: all v1 leaves (terminal categories). We keep the
// list flat because the simulate row is narrow; the search box in step 2
// already helps users find a target by keyword.
const v1Leaves = useMemo(() => getLeaves(), [getLeaves]);
const badgeLabel = t(
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
);
const reasonLabel = t(
`categoriesSeed.migration.simulate.reason.${row.reason}`,
);
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
const handleResolveChange = (ev: React.ChangeEvent<HTMLSelectElement>) => {
const v1TargetId = Number(ev.target.value);
if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return;
const leaf = v1Leaves.find((l) => l.id === v1TargetId);
if (!leaf) return;
const name = t(leaf.i18n_key, { defaultValue: leaf.name });
onResolve(row.v2CategoryId, v1TargetId, name);
};
const rowClass =
"grid grid-cols-12 gap-2 items-center px-3 py-2 rounded-md border text-sm cursor-pointer transition-colors " +
(isSelected
? "bg-[var(--primary)]/10 border-[var(--primary)]/40"
: "bg-[var(--card)] border-[var(--border)] hover:border-[var(--primary)]/30 hover:bg-[var(--muted)]");
const targetDisplayName = isUnresolved
? null
: row.v1TargetName;
return (
<div
className={rowClass}
onClick={() => onSelect(row.v2CategoryId)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(row.v2CategoryId);
}
}}
aria-label={`${row.v2CategoryName}${targetDisplayName ?? t("categoriesSeed.migration.simulate.needsReview")}`}
>
{/* v2 category name + tx count */}
<div className="col-span-4 flex items-center gap-2 min-w-0">
<span className="truncate font-medium text-[var(--foreground)]">
{row.v2CategoryName}
</span>
<span className="shrink-0 text-xs text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.txCount", {
count: transactionCount,
})}
</span>
</div>
{/* Confidence badge + reason */}
<div className="col-span-3 flex items-center gap-2">
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badgeClass(
row.confidence,
)}`}
title={row.notes ?? undefined}
>
{badgeLabel}
</span>
<span className="text-xs text-[var(--muted-foreground)] truncate">
{reasonLabel}
</span>
</div>
{/* v1 target (or picker) */}
<div
className="col-span-5 flex items-center justify-end gap-2 min-w-0"
onClick={(e) => e.stopPropagation()}
>
{isUnresolved ? (
<select
value=""
onChange={handleResolveChange}
aria-label={t("categoriesSeed.migration.simulate.chooseTarget")}
className="max-w-full truncate rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
>
<option value="" disabled>
{t("categoriesSeed.migration.simulate.chooseTarget")}
</option>
{v1Leaves.map((leaf) => (
<option key={leaf.id} value={leaf.id}>
{t(leaf.i18n_key, { defaultValue: leaf.name })}
</option>
))}
</select>
) : (
<span className="truncate text-[var(--foreground)]">
{targetDisplayName}
</span>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,245 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
ShieldCheck,
FolderLock,
Loader2,
CheckCircle2,
Circle,
} from "lucide-react";
interface StepConsentProps {
/** PIN/password for PIN-protected profiles. Empty string if no PIN. */
password: string;
onPasswordChange: (value: string) => void;
/** True when the current profile is PIN-protected — hides the field otherwise. */
requiresPassword: boolean;
/** Transition indicator: the running loader reuses this file via a flag. */
isRunning: boolean;
/** Progress stage for the loader (0 = backup, 1 = verified, 2 = sql, 3 = done). */
runningStage: 0 | 1 | 2 | 3;
onBack: () => void;
onConfirm: () => void;
}
/**
* Step 3 Consent: an explicit checklist + confirm button, plus a loader
* that takes over once the user clicks confirm. The loader shows the 4 sub-
* steps (backup created, backup verified, SQL running, commit) per the mockup.
*/
export default function StepConsent({
password,
onPasswordChange,
requiresPassword,
isRunning,
runningStage,
onBack,
onConfirm,
}: StepConsentProps) {
const { t } = useTranslation();
const [ack1, setAck1] = useState(false);
const [ack2, setAck2] = useState(false);
const [ack3, setAck3] = useState(false);
const allAck = ack1 && ack2 && ack3;
const canConfirm =
!isRunning && allAck && (!requiresPassword || password.trim().length > 0);
if (isRunning) {
return <RunningLoader stage={runningStage} />;
}
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.consent.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.consent.subtitle")}
</p>
</header>
{/* Backup info card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
<div className="flex items-start gap-3">
<ShieldCheck
size={18}
className="mt-0.5 shrink-0 text-[var(--primary)]"
/>
<div>
<h3 className="font-semibold">
{t("categoriesSeed.migration.consent.backup.title")}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.consent.backup.body")}
</p>
</div>
</div>
<p className="text-xs text-[var(--muted-foreground)] pl-8">
<FolderLock size={12} className="inline mr-1" />
{t("categoriesSeed.migration.consent.backup.location")}
</p>
</div>
{/* Password field (only for PIN-protected profiles) */}
{requiresPassword && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-2">
<label
htmlFor="consent-password"
className="text-sm font-medium text-[var(--foreground)]"
>
{t("categoriesSeed.migration.consent.password.label")}
</label>
<p className="text-xs text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.consent.password.help")}
</p>
<input
id="consent-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
/>
</div>
)}
{/* Checklist */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
<p className="text-sm font-medium text-[var(--foreground)]">
{t("categoriesSeed.migration.consent.checklist.title")}
</p>
<ul className="space-y-2 text-sm text-[var(--foreground)]">
<li>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={ack1}
onChange={(e) => setAck1(e.target.checked)}
className="mt-0.5"
/>
<span>{t("categoriesSeed.migration.consent.checklist.item1")}</span>
</label>
</li>
<li>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={ack2}
onChange={(e) => setAck2(e.target.checked)}
className="mt-0.5"
/>
<span>{t("categoriesSeed.migration.consent.checklist.item2")}</span>
</label>
</li>
<li>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={ack3}
onChange={(e) => setAck3(e.target.checked)}
className="mt-0.5"
/>
<span>{t("categoriesSeed.migration.consent.checklist.item3")}</span>
</label>
</li>
</ul>
</div>
{/* Nav */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] text-sm"
>
<ArrowLeft size={16} />
{t("categoriesSeed.migration.consent.back")}
</button>
<button
type="button"
onClick={onConfirm}
disabled={!canConfirm}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50 transition-opacity"
>
<ShieldCheck size={16} />
{t("categoriesSeed.migration.consent.confirm")}
</button>
</div>
</section>
);
}
interface StageLineProps {
done: boolean;
active: boolean;
label: string;
}
function StageLine({ done, active, label }: StageLineProps) {
const icon = done ? (
<CheckCircle2 size={16} className="text-green-600 dark:text-green-400" />
) : active ? (
<Loader2 size={16} className="animate-spin text-[var(--primary)]" />
) : (
<Circle size={16} className="text-[var(--muted-foreground)]" />
);
return (
<li className="flex items-center gap-3">
{icon}
<span
className={
done
? "text-sm text-[var(--foreground)]"
: active
? "text-sm font-medium text-[var(--foreground)]"
: "text-sm text-[var(--muted-foreground)]"
}
>
{label}
</span>
</li>
);
}
function RunningLoader({ stage }: { stage: 0 | 1 | 2 | 3 }) {
const { t } = useTranslation();
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.running.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.running.subtitle")}
</p>
</header>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5">
<ul className="space-y-3" aria-live="polite">
<StageLine
done={stage > 0}
active={stage === 0}
label={t("categoriesSeed.migration.running.step1")}
/>
<StageLine
done={stage > 1}
active={stage === 1}
label={t("categoriesSeed.migration.running.step2")}
/>
<StageLine
done={stage > 2}
active={stage === 2}
label={t("categoriesSeed.migration.running.step3")}
/>
<StageLine
done={stage > 3}
active={stage === 3}
label={t("categoriesSeed.migration.running.step4")}
/>
</ul>
</div>
</section>
);
}

View file

@ -0,0 +1,167 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowRight, ChevronsDownUp, ChevronsUpDown, Search } from "lucide-react";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
import CategoryTaxonomyTree from "../categories/CategoryTaxonomyTree";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
interface StepDiscoverProps {
onNext: () => void;
}
function collectAllIds(nodes: TaxonomyNode[]): number[] {
const ids: number[] = [];
const walk = (n: TaxonomyNode) => {
ids.push(n.id);
n.children.forEach(walk);
};
nodes.forEach(walk);
return ids;
}
function countNodes(nodes: TaxonomyNode[]): {
roots: number;
subcategories: number;
leaves: number;
} {
let roots = 0;
let subcategories = 0;
let leaves = 0;
for (const root of nodes) {
roots += 1;
for (const child of root.children) {
if (child.children.length === 0) leaves += 1;
else {
subcategories += 1;
for (const leaf of child.children) {
if (leaf.children.length === 0) leaves += 1;
else subcategories += 1;
}
}
}
}
return { roots, subcategories, leaves };
}
/**
* Step 1 Discover: read-only navigation of the v1 taxonomy. Reuses the same
* CategoryTaxonomyTree component as the standalone guide page (#117) so the
* two surfaces stay visually consistent.
*/
export default function StepDiscover({ onNext }: StepDiscoverProps) {
const { t } = useTranslation();
const { taxonomy } = useCategoryTaxonomy();
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<number>>(() => new Set());
const counts = countNodes(taxonomy.roots);
const total = counts.roots + counts.subcategories + counts.leaves;
const toggleNode = (id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleExpandAll = () => {
setExpanded(new Set(collectAllIds(taxonomy.roots)));
};
const handleCollapseAll = () => setExpanded(new Set());
const allExpanded = expanded.size > 0;
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.discover.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.discover.subtitle")}
</p>
</header>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-2">
<h3 className="font-semibold">
{t("categoriesSeed.migration.discover.intro.title")}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.discover.intro.body")}
</p>
</div>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-4">
<p
className="text-sm text-[var(--muted-foreground)]"
aria-live="polite"
>
{t("categoriesSeed.guidePage.counter", {
roots: counts.roots,
subcategories: counts.subcategories,
leaves: counts.leaves,
total,
})}
</p>
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)] pointer-events-none"
aria-hidden="true"
/>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("categoriesSeed.guidePage.searchPlaceholder")}
aria-label={t("categoriesSeed.guidePage.searchPlaceholder")}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={allExpanded ? handleCollapseAll : handleExpandAll}
className="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? (
<>
<ChevronsDownUp size={16} />
{t("categoriesSeed.guidePage.collapseAll")}
</>
) : (
<>
<ChevronsUpDown size={16} />
{t("categoriesSeed.guidePage.expandAll")}
</>
)}
</button>
</div>
</div>
</div>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3">
<CategoryTaxonomyTree
nodes={taxonomy.roots}
expanded={expanded}
onToggle={toggleNode}
searchQuery={search}
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={onNext}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
>
{t("categoriesSeed.migration.discover.next")}
<ArrowRight size={16} />
</button>
</div>
</section>
);
}

View file

@ -0,0 +1,225 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
import MappingRow from "./MappingRow";
import TransactionPreviewPanel from "./TransactionPreviewPanel";
import type {
MigrationPlan,
MappingRow as MappingRowType,
} from "../../services/categoryMappingService";
interface StepSimulateProps {
plan: MigrationPlan;
unresolved: number;
selectedRowV2Id: number | null;
transactionCountByV2Id: Map<number, number>;
onResolveRow: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
onSelectRow: (v2CategoryId: number | null) => void;
onNext: () => void;
onBack: () => void;
}
/**
* Step 2 Simulate (dry-run): a 3-column table (v2 | confidence | v1 target),
* a preview side panel per selected row, and a blocking guard on the "next"
* button until every row is resolved. No DB writes happen here the plan is
* mutated in memory via the reducer.
*/
export default function StepSimulate({
plan,
unresolved,
selectedRowV2Id,
transactionCountByV2Id,
onResolveRow,
onSelectRow,
onNext,
onBack,
}: StepSimulateProps) {
const { t } = useTranslation();
const selectedRow = useMemo<MappingRowType | null>(() => {
if (selectedRowV2Id === null) return null;
return (
plan.rows.find((r) => r.v2CategoryId === selectedRowV2Id) ??
plan.preserved.find((r) => r.v2CategoryId === selectedRowV2Id) ??
null
);
}, [plan.rows, plan.preserved, selectedRowV2Id]);
const canContinue = unresolved === 0;
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.simulate.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.subtitle")}
</p>
</header>
{/* Stats summary */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5">
<dl className="grid grid-cols-2 sm:grid-cols-5 gap-4 text-sm">
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.stats.total")}
</dt>
<dd className="text-lg font-semibold">{plan.stats.total}</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.high")}
</dt>
<dd className="text-lg font-semibold text-green-600 dark:text-green-400">
{plan.stats.high}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.medium")}
</dt>
<dd className="text-lg font-semibold text-blue-600 dark:text-blue-400">
{plan.stats.medium}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.low")}
</dt>
<dd className="text-lg font-semibold text-orange-600 dark:text-orange-400">
{plan.stats.low}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.none")}
</dt>
<dd className="text-lg font-semibold text-red-600 dark:text-red-400">
{plan.stats.none}
</dd>
</div>
</dl>
</div>
{/* Unresolved warning banner */}
{unresolved > 0 && (
<div
role="status"
className="flex items-start gap-3 rounded-xl border border-orange-300 bg-orange-50 p-4 dark:bg-orange-900/10 dark:border-orange-700"
>
<AlertTriangle
size={18}
className="mt-0.5 shrink-0 text-orange-600 dark:text-orange-400"
/>
<p className="text-sm text-orange-900 dark:text-orange-200">
{t("categoriesSeed.migration.simulate.unresolvedWarning", {
count: unresolved,
})}
</p>
</div>
)}
{/* Header row */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3">
<div
className="grid grid-cols-12 gap-2 px-3 py-2 text-xs font-semibold uppercase text-[var(--muted-foreground)] border-b border-[var(--border)]"
aria-hidden="true"
>
<div className="col-span-4">
{t("categoriesSeed.migration.simulate.header.current")}
</div>
<div className="col-span-3">
{t("categoriesSeed.migration.simulate.header.match")}
</div>
<div className="col-span-5 text-right">
{t("categoriesSeed.migration.simulate.header.target")}
</div>
</div>
<ul className="mt-2 space-y-1">
{plan.rows.map((row) => (
<li key={row.v2CategoryId}>
<MappingRow
row={row}
isSelected={selectedRowV2Id === row.v2CategoryId}
onSelect={onSelectRow}
onResolve={onResolveRow}
transactionCount={
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
}
/>
</li>
))}
</ul>
</div>
{/* Preserved categories block */}
{plan.preserved.length > 0 && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
<div className="flex items-start gap-3">
<FolderHeart
size={18}
className="mt-0.5 shrink-0 text-[var(--primary)]"
/>
<div>
<h3 className="font-semibold">
{t("categoriesSeed.migration.simulate.preserved.title", {
count: plan.preserved.length,
})}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.preserved.body")}
</p>
</div>
</div>
<ul className="text-sm 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>
))}
</ul>
</div>
)}
{/* Nav */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] text-sm"
>
<ArrowLeft size={16} />
{t("categoriesSeed.migration.simulate.back")}
</button>
<button
type="button"
onClick={onNext}
disabled={!canContinue}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50 transition-opacity"
>
{t("categoriesSeed.migration.simulate.next")}
<ArrowRight size={16} />
</button>
</div>
{/* Side panel with tx preview */}
{selectedRow !== null && (
<TransactionPreviewPanel
row={selectedRow}
onClose={() => onSelectRow(null)}
/>
)}
</section>
);
}

View file

@ -0,0 +1,180 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { getDb } from "../../services/db";
import type { MappingRow } from "../../services/categoryMappingService";
interface TransactionRow {
id: number;
date: string;
description: string;
amount: number;
}
interface TransactionPreviewPanelProps {
row: MappingRow | null;
onClose: () => void;
}
const MAX_TRANSACTIONS = 50;
/**
* Side panel that shows transactions currently attached to a v2 category
* selected in the simulate table. Read-only exists purely to give the user
* enough context to decide whether the proposed v1 target is correct.
*
* We fetch lazily (once per selected row id) and cap at 50 rows. If the user
* has more than 50 tx attached, a small "... and N more" line is rendered.
*/
export default function TransactionPreviewPanel({
row,
onClose,
}: TransactionPreviewPanelProps) {
const { t, i18n } = useTranslation();
const [loading, setLoading] = useState(false);
const [transactions, setTransactions] = useState<TransactionRow[]>([]);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
let cancelled = false;
if (row === null) {
setTransactions([]);
setTotalCount(0);
return;
}
const load = async () => {
setLoading(true);
try {
const db = await getDb();
const txs = await db.select<TransactionRow[]>(
`SELECT id, date, description, amount
FROM transactions
WHERE category_id = $1
ORDER BY date DESC, id DESC
LIMIT $2`,
[row.v2CategoryId, MAX_TRANSACTIONS],
);
const count = await db.select<Array<{ cnt: number }>>(
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`,
[row.v2CategoryId],
);
if (!cancelled) {
setTransactions(txs);
setTotalCount(count[0]?.cnt ?? 0);
}
} catch {
if (!cancelled) {
setTransactions([]);
setTotalCount(0);
}
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => {
cancelled = true;
};
}, [row]);
if (row === null) return null;
const locale = i18n.language?.startsWith("en") ? "en-CA" : "fr-CA";
const formatAmount = (value: number): string =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: "CAD",
minimumFractionDigits: 2,
}).format(value);
const formatDate = (iso: string): string => {
try {
return new Date(iso).toLocaleDateString(locale);
} catch {
return iso;
}
};
const overflow = Math.max(0, totalCount - transactions.length);
return (
<aside
className="fixed inset-y-0 right-0 w-full max-w-md bg-[var(--card)] border-l border-[var(--border)] shadow-xl z-40 flex flex-col"
aria-label={t("categoriesSeed.migration.simulate.panel.title")}
>
<header className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<div className="min-w-0">
<h3 className="font-semibold truncate text-[var(--foreground)]">
{row.v2CategoryName}
</h3>
<p className="text-xs text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.panel.subtitle", {
count: totalCount,
})}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("categoriesSeed.migration.simulate.panel.close")}
className="rounded-md p-1.5 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<X size={18} />
</button>
</header>
<div className="flex-1 overflow-y-auto p-2">
{loading ? (
<p className="p-4 text-sm text-[var(--muted-foreground)]">
{t("common.loading")}
</p>
) : transactions.length === 0 ? (
<p className="p-4 text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.panel.noTransactions")}
</p>
) : (
<ul className="space-y-1">
{transactions.map((tx) => (
<li
key={tx.id}
className="flex items-center justify-between gap-3 px-2 py-1.5 rounded-md hover:bg-[var(--muted)] text-sm"
>
<div className="min-w-0">
<p className="truncate text-[var(--foreground)]">
{tx.description}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{formatDate(tx.date)}
</p>
</div>
<span
className={
tx.amount < 0
? "shrink-0 font-mono text-red-600 dark:text-red-400"
: "shrink-0 font-mono text-green-600 dark:text-green-400"
}
>
{formatAmount(tx.amount)}
</span>
</li>
))}
{overflow > 0 && (
<li className="px-2 py-2 text-xs text-[var(--muted-foreground)] text-center">
{t("categoriesSeed.migration.simulate.panel.overflow", {
count: overflow,
})}
</li>
)}
</ul>
)}
</div>
<footer className="px-4 py-3 border-t border-[var(--border)] text-xs text-[var(--muted-foreground)]">
{row.v1TargetName
? t("categoriesSeed.migration.simulate.panel.willMapTo", {
target: row.v1TargetName,
})
: t("categoriesSeed.migration.simulate.panel.noTarget")}
</footer>
</aside>
);
}

View file

@ -1,15 +1,40 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FolderTree, ChevronRight } from "lucide-react";
import { FolderTree, ChevronRight, MoveRight } from "lucide-react";
import { getPreference } from "../../services/userPreferenceService";
/**
* Card that surfaces category-related entries in the Settings page.
* For now: a single link to the read-only "standard categories guide"
* (Livraison 1 of the IPC category refactor).
*
* Two entries, depending on the profile's categories_schema_version:
* - "Standard categories guide" (always visible) read-only tree (#117).
* - "Migrate to the standard structure" (v2 only) 3-step migration (#121).
*
* Profiles already on v1 never see the migrate entry (they're done).
*/
export default function CategoriesCard() {
const { t } = useTranslation();
const [showMigrate, setShowMigrate] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const version = await getPreference("categories_schema_version");
if (cancelled) return;
setShowMigrate(version === "v2");
} catch {
if (!cancelled) setShowMigrate(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div className="space-y-3">
<Link
to="/settings/categories/standard"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
@ -34,5 +59,33 @@ export default function CategoriesCard() {
/>
</div>
</Link>
{showMigrate && (
<Link
to="/settings/categories/migrate"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<MoveRight size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">
{t("settings.categoriesCard.migrateTitle")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.categoriesCard.migrateDescription")}
</p>
</div>
</div>
<ChevronRight
size={18}
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors"
/>
</div>
</Link>
)}
</div>
);
}

View file

@ -0,0 +1,191 @@
import { describe, it, expect } from "vitest";
import { migrationReducer, INITIAL_STATE } from "./useCategoryMigration";
import type {
MigrationPlan,
MappingRow,
} from "../services/categoryMappingService";
import type { BackupResult } from "../services/categoryBackupService";
import type { MigrationOutcome } from "../services/categoryMigrationService";
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
function makeRow(v2: number, v1: number | null): MappingRow {
return {
v2CategoryId: v2,
v2CategoryName: `v2-${v2}`,
v1TargetId: v1,
v1TargetName: v1 === null ? null : `v1-${v1}`,
confidence: v1 === null ? "none" : "high",
reason: v1 === null ? "review" : "keyword",
};
}
function makePlan(rows: MappingRow[], preserved: MappingRow[] = []): MigrationPlan {
const unresolved = rows.filter((r) => r.v1TargetId === null);
return {
rows,
preserved,
unresolved,
stats: {
total: rows.length,
high: rows.filter((r) => r.confidence === "high").length,
medium: rows.filter((r) => r.confidence === "medium").length,
low: rows.filter((r) => r.confidence === "low").length,
none: unresolved.length,
},
};
}
const FAKE_BACKUP: BackupResult = {
path: "/tmp/backup.sref",
size: 1024,
checksum: "abc",
verifiedAt: new Date().toISOString(),
encrypted: false,
};
const FAKE_OUTCOME: MigrationOutcome = {
succeeded: true,
insertedV1Count: 139,
updatedTransactionsCount: 100,
updatedBudgetsCount: 10,
updatedKeywordsCount: 20,
deletedV2Count: 40,
customPreservedCount: 2,
backupPath: "/tmp/backup.sref",
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("migrationReducer", () => {
it("starts on discover with no plan / backup / outcome", () => {
expect(INITIAL_STATE.step).toBe("discover");
expect(INITIAL_STATE.plan).toBeNull();
expect(INITIAL_STATE.backup).toBeNull();
expect(INITIAL_STATE.outcome).toBeNull();
expect(INITIAL_STATE.unresolved).toBe(0);
});
it("LOAD_PLAN stores plan and counts unresolved rows", () => {
const plan = makePlan([makeRow(10, 1011), makeRow(11, null)]);
const next = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
expect(next.plan).toBe(plan);
expect(next.unresolved).toBe(1);
expect(next.errors).toEqual([]);
});
it("RESOLVE_ROW sets target, bumps confidence from none to medium, decreases unresolved", () => {
const plan = makePlan([makeRow(10, null), makeRow(11, null)]);
const s1 = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
expect(s1.unresolved).toBe(2);
const s2 = migrationReducer(s1, {
type: "RESOLVE_ROW",
v2CategoryId: 10,
v1TargetId: 1011,
v1TargetName: "Paie régulière",
});
expect(s2.unresolved).toBe(1);
const resolved = s2.plan!.rows.find((r) => r.v2CategoryId === 10)!;
expect(resolved.v1TargetId).toBe(1011);
expect(resolved.v1TargetName).toBe("Paie régulière");
expect(resolved.confidence).toBe("medium");
});
it("GO_NEXT blocks simulate -> consent when unresolved > 0", () => {
const plan = makePlan([makeRow(10, null)]);
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
s = migrationReducer(s, { type: "GO_NEXT" }); // discover -> simulate
expect(s.step).toBe("simulate");
const sBlocked = migrationReducer(s, { type: "GO_NEXT" });
expect(sBlocked.step).toBe("simulate"); // blocked because unresolved=1
});
it("GO_NEXT advances simulate -> consent once all rows are resolved", () => {
const plan = makePlan([makeRow(10, null)]);
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
s = migrationReducer(s, { type: "GO_NEXT" }); // simulate
s = migrationReducer(s, {
type: "RESOLVE_ROW",
v2CategoryId: 10,
v1TargetId: 1011,
v1TargetName: "Paie régulière",
});
s = migrationReducer(s, { type: "GO_NEXT" }); // consent
expect(s.step).toBe("consent");
});
it("GO_BACK goes simulate -> discover and consent -> simulate", () => {
const plan = makePlan([makeRow(10, 1011)]);
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
s = migrationReducer(s, { type: "GO_NEXT" }); // simulate
s = migrationReducer(s, { type: "GO_NEXT" }); // consent
expect(s.step).toBe("consent");
s = migrationReducer(s, { type: "GO_BACK" });
expect(s.step).toBe("simulate");
s = migrationReducer(s, { type: "GO_BACK" });
expect(s.step).toBe("discover");
});
it("START_RUN moves the step to running and clears errors", () => {
const base = { ...INITIAL_STATE, step: "consent" as const, errors: ["old"] };
const s = migrationReducer(base, { type: "START_RUN" });
expect(s.step).toBe("running");
expect(s.errors).toEqual([]);
});
it("SET_BACKUP stores the backup and keeps the current step", () => {
const base = { ...INITIAL_STATE, step: "running" as const };
const s = migrationReducer(base, { type: "SET_BACKUP", backup: FAKE_BACKUP });
expect(s.backup).toBe(FAKE_BACKUP);
expect(s.step).toBe("running");
});
it("SET_OUTCOME moves to success on succeeded=true", () => {
const base = { ...INITIAL_STATE, step: "running" as const };
const s = migrationReducer(base, {
type: "SET_OUTCOME",
outcome: FAKE_OUTCOME,
});
expect(s.step).toBe("success");
expect(s.outcome).toBe(FAKE_OUTCOME);
expect(s.errors).toEqual([]);
});
it("SET_OUTCOME moves to error when outcome.succeeded=false and records the error", () => {
const failed: MigrationOutcome = { ...FAKE_OUTCOME, succeeded: false, error: "sql" };
const base = { ...INITIAL_STATE, step: "running" as const };
const s = migrationReducer(base, { type: "SET_OUTCOME", outcome: failed });
expect(s.step).toBe("error");
expect(s.outcome).toBe(failed);
expect(s.errors).toContain("sql");
});
it("FAIL appends to errors and moves to error step", () => {
const base = { ...INITIAL_STATE, step: "running" as const };
const s = migrationReducer(base, { type: "FAIL", error: "backup failed" });
expect(s.step).toBe("error");
expect(s.errors).toEqual(["backup failed"]);
});
it("SELECT_ROW stores the selected row id", () => {
const s1 = migrationReducer(INITIAL_STATE, { type: "SELECT_ROW", v2CategoryId: 42 });
expect(s1.selectedRowV2Id).toBe(42);
const s2 = migrationReducer(s1, { type: "SELECT_ROW", v2CategoryId: null });
expect(s2.selectedRowV2Id).toBeNull();
});
it("RESET returns the initial state", () => {
const middle = {
...INITIAL_STATE,
step: "success" as const,
errors: ["x"],
outcome: FAKE_OUTCOME,
};
const s = migrationReducer(middle, { type: "RESET" });
expect(s).toEqual(INITIAL_STATE);
});
});

View file

@ -0,0 +1,257 @@
import { useCallback, useReducer } from "react";
import type {
MigrationPlan,
MappingRow,
} from "../services/categoryMappingService";
import type { BackupResult } from "../services/categoryBackupService";
import type { MigrationOutcome } from "../services/categoryMigrationService";
// -----------------------------------------------------------------------------
// useCategoryMigration — useReducer-backed state machine for the 3-step page.
//
// State model:
// - step : which screen is rendered (discover → simulate → consent → running → success | error)
// - plan : MigrationPlan produced by categoryMappingService (memory only)
// - backup : BackupResult once the pre-migration SREF is written (null otherwise)
// - outcome : MigrationOutcome produced by applyMigration (null otherwise)
// - errors : list of error messages collected during the flow
// - unresolved : number of "🟠 needs review" rows still blocking the next step
//
// The hook exposes pure action dispatchers — it does NOT call services. The
// page component is responsible for calling computeMigrationPlan,
// createPreMigrationBackup and applyMigration, then dispatching the
// corresponding actions here. Keeping the reducer service-free lets us test
// the state transitions without mocking Tauri.
// -----------------------------------------------------------------------------
export type MigrationStep =
| "discover"
| "simulate"
| "consent"
| "running"
| "success"
| "error";
export interface MigrationState {
step: MigrationStep;
plan: MigrationPlan | null;
backup: BackupResult | null;
outcome: MigrationOutcome | null;
/** Number of unresolved rows (confidence === 'none' && still missing v1TargetId). */
unresolved: number;
errors: string[];
/** Selected mapping row id to show in the TransactionPreviewPanel. Null = closed. */
selectedRowV2Id: number | null;
}
export type MigrationAction =
| { type: "LOAD_PLAN"; plan: MigrationPlan }
| { type: "RESOLVE_ROW"; v2CategoryId: number; v1TargetId: number; v1TargetName: string }
| { type: "SELECT_ROW"; v2CategoryId: number | null }
| { type: "GO_NEXT" }
| { type: "GO_BACK" }
| { type: "START_RUN" }
| { type: "SET_BACKUP"; backup: BackupResult }
| { type: "SET_OUTCOME"; outcome: MigrationOutcome }
| { type: "FAIL"; error: string }
| { type: "RESET" };
export const INITIAL_STATE: MigrationState = {
step: "discover",
plan: null,
backup: null,
outcome: null,
unresolved: 0,
errors: [],
selectedRowV2Id: null,
};
function countUnresolved(rows: MappingRow[]): number {
let n = 0;
for (const r of rows) {
if (r.v1TargetId === null || r.v1TargetId === undefined) n++;
}
return n;
}
function nextStep(current: MigrationStep): MigrationStep {
switch (current) {
case "discover":
return "simulate";
case "simulate":
return "consent";
case "consent":
return "running";
case "running":
return "success";
default:
return current;
}
}
function previousStep(current: MigrationStep): MigrationStep {
switch (current) {
case "simulate":
return "discover";
case "consent":
return "simulate";
case "error":
return "consent";
default:
return current;
}
}
export function migrationReducer(
state: MigrationState,
action: MigrationAction,
): MigrationState {
switch (action.type) {
case "LOAD_PLAN": {
return {
...state,
plan: action.plan,
unresolved: countUnresolved(action.plan.rows),
errors: [],
};
}
case "RESOLVE_ROW": {
if (state.plan === null) return state;
const rows = state.plan.rows.map((r) =>
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.
confidence: r.confidence === "none" ? "medium" : r.confidence,
}
: r,
);
const plan: MigrationPlan = {
...state.plan,
rows,
unresolved: rows.filter((r) => r.v1TargetId === null),
};
return {
...state,
plan,
unresolved: countUnresolved(rows),
};
}
case "SELECT_ROW":
return { ...state, selectedRowV2Id: action.v2CategoryId };
case "GO_NEXT": {
// Guard: simulate -> consent requires unresolved === 0.
if (state.step === "simulate" && state.unresolved > 0) return state;
return { ...state, step: nextStep(state.step) };
}
case "GO_BACK":
return { ...state, step: previousStep(state.step) };
case "START_RUN":
return { ...state, step: "running", errors: [] };
case "SET_BACKUP":
return { ...state, backup: action.backup };
case "SET_OUTCOME": {
const nextStepName: MigrationStep = action.outcome.succeeded
? "success"
: "error";
return {
...state,
outcome: action.outcome,
step: nextStepName,
errors: action.outcome.error
? [...state.errors, action.outcome.error]
: state.errors,
};
}
case "FAIL":
return {
...state,
step: "error",
errors: [...state.errors, action.error],
};
case "RESET":
return INITIAL_STATE;
default:
return state;
}
}
export interface UseCategoryMigrationResult {
state: MigrationState;
loadPlan: (plan: MigrationPlan) => void;
resolveRow: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
selectRow: (v2CategoryId: number | null) => void;
goNext: () => void;
goBack: () => void;
startRun: () => void;
setBackup: (backup: BackupResult) => void;
setOutcome: (outcome: MigrationOutcome) => void;
fail: (error: string) => void;
reset: () => void;
}
export function useCategoryMigration(): UseCategoryMigrationResult {
const [state, dispatch] = useReducer(migrationReducer, INITIAL_STATE);
const loadPlan = useCallback((plan: MigrationPlan) => {
dispatch({ type: "LOAD_PLAN", plan });
}, []);
const resolveRow = useCallback(
(v2CategoryId: number, v1TargetId: number, v1TargetName: string) => {
dispatch({ type: "RESOLVE_ROW", v2CategoryId, v1TargetId, v1TargetName });
},
[],
);
const selectRow = useCallback((v2CategoryId: number | null) => {
dispatch({ type: "SELECT_ROW", v2CategoryId });
}, []);
const goNext = useCallback(() => dispatch({ type: "GO_NEXT" }), []);
const goBack = useCallback(() => dispatch({ type: "GO_BACK" }), []);
const startRun = useCallback(() => dispatch({ type: "START_RUN" }), []);
const setBackup = useCallback((backup: BackupResult) => {
dispatch({ type: "SET_BACKUP", backup });
}, []);
const setOutcome = useCallback((outcome: MigrationOutcome) => {
dispatch({ type: "SET_OUTCOME", outcome });
}, []);
const fail = useCallback((error: string) => {
dispatch({ type: "FAIL", error });
}, []);
const reset = useCallback(() => dispatch({ type: "RESET" }), []);
return {
state,
loadPlan,
resolveRow,
selectRow,
goNext,
goBack,
startRun,
setBackup,
setOutcome,
fail,
reset,
};
}

View file

@ -574,7 +574,9 @@
"title": "Category management",
"description": "Organize your expenses and income the way you want.",
"standardGuideTitle": "Standard category structure",
"standardGuideDescription": "Browse the CPI taxonomy (read-only)"
"standardGuideDescription": "Browse the CPI taxonomy (read-only)",
"migrateTitle": "Migrate to the standard structure",
"migrateDescription": "Preview, back up and migrate in 3 steps"
},
"logs": {
"title": "Logs",
@ -1273,6 +1275,143 @@
"subcategory": "Subcategory",
"leaf": "Leaf (final category)"
}
},
"migration": {
"customParent": "Custom categories (migration)",
"pageTitle": "Migrate to the standard structure",
"pageSubtitle": "Three steps: discover, simulate, consent. No data is changed until the final step.",
"backToSettings": "Back to settings",
"stepper": {
"ariaLabel": "Migration progress",
"discover": "Discover",
"simulate": "Simulate",
"consent": "Consent"
},
"alreadyMigrated": {
"title": "Your profile already uses the standard structure",
"body": "No migration needed. If you recently migrated, you can review the backup from Settings."
},
"discover": {
"title": "Step 1 · Discover the new structure",
"subtitle": "Take your time to explore the tree. You can come back later without risk.",
"intro": {
"title": "Why this structure?",
"body": "Statistics Canada's official household expenditure classification (CPI basket) identifies the main budget components of a Canadian household. It covers 100% of the financial flows of a typical Quebec household and produces clearer reports."
},
"next": "Continue to migration preview"
},
"simulate": {
"title": "Step 2 · Migration preview",
"subtitle": "Dry-run — no writes. Click a row to see affected transactions and, if needed, pick a different target.",
"loadError": "Failed to load profile data: {{error}}",
"needsReview": "Needs review",
"chooseTarget": "Choose a target...",
"txCount_one": "{{count}} transaction",
"txCount_other": "{{count}} transactions",
"unresolvedWarning_one": "You have {{count}} decision to make before you can continue.",
"unresolvedWarning_other": "You have {{count}} decisions to make before you can continue.",
"header": {
"current": "Current category",
"match": "Match",
"target": "Proposed v1 target"
},
"confidence": {
"high": "High",
"medium": "Medium",
"low": "Low",
"none": "Needs review"
},
"reason": {
"keyword": "Keyword-based",
"supplier": "Supplier-based",
"default": "Default mapping",
"review": "Manual review required",
"preserved": "Preserved"
},
"stats": {
"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"
},
"panel": {
"title": "Affected transactions",
"subtitle_one": "{{count}} transaction attached to this category",
"subtitle_other": "{{count}} transactions attached to this category",
"close": "Close panel",
"noTransactions": "No transaction attached to this category.",
"overflow_one": "... and {{count}} more transaction",
"overflow_other": "... and {{count}} more transactions",
"willMapTo": "Will be reassigned to: {{target}}",
"noTarget": "No target chosen — a decision is required."
},
"back": "Back to discovery",
"next": "All decisions made · Continue"
},
"consent": {
"title": "Step 3 · Confirmation and backup",
"subtitle": "An encrypted copy of your profile will be created BEFORE any change. You can restore it at any time.",
"backup": {
"title": "Automatic backup before migration",
"body": "Simpl'Résultat creates a verified copy of your profile inside your Documents folder. The filename includes the date and time — nothing leaves your device.",
"location": "Location: ~/Documents/Simpl-Resultat/backups/"
},
"password": {
"label": "Profile PIN",
"help": "Your PIN is used to encrypt the backup (AES-256-GCM). It never leaves your device."
},
"checklist": {
"title": "Confirm that:",
"item1": "I understand this operation permanently modifies my categories.",
"item2": "I confirm a verified backup will be created before any change.",
"item3": "I can restore the backup at any time during 90 days."
},
"back": "Back",
"confirm": "Create backup and migrate"
},
"running": {
"title": "Migration in progress...",
"subtitle": "Do not close the application until the operation finishes.",
"step1": "Step 1 · Creating the backup",
"step2": "Step 2 · Verifying the backup (SHA-256 checksum)",
"step3": "Step 3 · Applying the migration (SQL transaction)",
"step4": "Step 4 · Committing and refreshing"
},
"success": {
"title": "Migration successful",
"subtitle": "Your profile now uses the standard structure (Statistics Canada CPI).",
"backupLabel": "Your backup is here:",
"restoreHint": "You can restore this backup at any time from Settings > Categories for 90 days.",
"stats": {
"inserted": "Categories added",
"transactions": "Transactions migrated",
"keywords": "Keywords migrated",
"budgets": "Budgets migrated"
},
"backToDashboard": "Go to dashboard",
"viewCategories": "View my categories"
},
"error": {
"title": "The migration could not be applied",
"subtitle": "No change was saved. Your profile is in the exact same state as before the operation.",
"rollbackNote": "If a partial backup was created, it stays available in your Documents folder — you can delete it manually if it is not useful.",
"retry": "Back to consent step",
"backToSettings": "Back to settings",
"backup": {
"missing_password": "Missing PIN — backing up a protected profile requires the PIN.",
"documents_dir_unavailable": "Unable to locate the Documents folder.",
"permission_denied": "Insufficient permissions to write in the backup folder.",
"disk_space": "Not enough disk space to create the backup.",
"create_dir_failed": "Unable to create the backup folder.",
"write_failed": "Failed to write the backup file.",
"read_back_failed": "Unable to read the backup file back for verification.",
"verification_mismatch": "Backup verification failed (invalid checksum or corrupted file)."
}
}
}
}
}

View file

@ -574,7 +574,9 @@
"title": "Gestion des catégories",
"description": "Organisez vos dépenses et revenus selon vos besoins.",
"standardGuideTitle": "Structure standard des catégories",
"standardGuideDescription": "Explorer la taxonomie IPC (lecture seule)"
"standardGuideDescription": "Explorer la taxonomie IPC (lecture seule)",
"migrateTitle": "Migrer vers la structure standard",
"migrateDescription": "Aperçu, sauvegarde et migration en 3 étapes"
},
"logs": {
"title": "Journaux",
@ -1273,6 +1275,143 @@
"subcategory": "Sous-catégorie",
"leaf": "Feuille (catégorie finale)"
}
},
"migration": {
"customParent": "Catégories personnalisées (migration)",
"pageTitle": "Migrer vers la structure standard",
"pageSubtitle": "Trois étapes : découvrir, simuler, consentir. Aucune donnée n'est modifiée avant l'étape finale.",
"backToSettings": "Retour aux paramètres",
"stepper": {
"ariaLabel": "Progression de la migration",
"discover": "Découvrir",
"simulate": "Simuler",
"consent": "Consentir"
},
"alreadyMigrated": {
"title": "Votre profil utilise déjà la structure standard",
"body": "Aucune migration à effectuer. Si vous avez récemment migré, vous pouvez consulter la sauvegarde depuis les paramètres."
},
"discover": {
"title": "Étape 1 · Découvrir la nouvelle structure",
"subtitle": "Prenez le temps d'explorer l'arborescence. Vous pouvez revenir plus tard sans risque.",
"intro": {
"title": "Pourquoi cette structure ?",
"body": "La classification officielle des dépenses des ménages au Canada (Statistique Canada, panier IPC) identifie les grandes composantes du budget d'un ménage. Elle couvre 100 % des flux financiers d'un ménage québécois typique et permet des rapports plus lisibles."
},
"next": "Continuer vers l'aperçu de migration"
},
"simulate": {
"title": "Étape 2 · Aperçu de la migration",
"subtitle": "Simulation sans écriture. Cliquez sur une ligne pour voir les transactions impactées et, si besoin, choisir une autre cible.",
"loadError": "Impossible de charger les données du profil : {{error}}",
"needsReview": "À réviser",
"chooseTarget": "Choisir une cible...",
"txCount_one": "{{count}} transaction",
"txCount_other": "{{count}} transactions",
"unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",
"unresolvedWarning_other": "Vous avez {{count}} décisions à prendre avant de pouvoir continuer.",
"header": {
"current": "Catégorie actuelle",
"match": "Correspondance",
"target": "Cible v1 proposée"
},
"confidence": {
"high": "Haute",
"medium": "Moyenne",
"low": "Basse",
"none": "À réviser"
},
"reason": {
"keyword": "Basée sur un mot-clé",
"supplier": "Basée sur un fournisseur",
"default": "Mapping par défaut",
"review": "Révision manuelle requise",
"preserved": "Préservée"
},
"stats": {
"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"
},
"panel": {
"title": "Transactions impactées",
"subtitle_one": "{{count}} transaction attachée à cette catégorie",
"subtitle_other": "{{count}} transactions attachées à cette catégorie",
"close": "Fermer le panneau",
"noTransactions": "Aucune transaction attachée à cette catégorie.",
"overflow_one": "... et {{count}} autre transaction",
"overflow_other": "... et {{count}} autres transactions",
"willMapTo": "Sera réassignée à : {{target}}",
"noTarget": "Aucune cible choisie — une décision est requise."
},
"back": "Revenir à la découverte",
"next": "Toutes décisions prises · Continuer"
},
"consent": {
"title": "Étape 3 · Confirmation et sauvegarde",
"subtitle": "Une copie chiffrée de votre profil sera créée AVANT tout changement. Vous pourrez la rétablir à tout moment.",
"backup": {
"title": "Sauvegarde automatique avant migration",
"body": "Simpl'Résultat crée une copie vérifiée de votre profil dans le dossier Documents. Le nom du fichier contient la date et l'heure — aucune donnée ne quitte votre appareil.",
"location": "Emplacement : ~/Documents/Simpl-Resultat/backups/"
},
"password": {
"label": "NIP du profil",
"help": "Votre NIP est utilisé pour chiffrer la sauvegarde (AES-256-GCM). Il ne quitte jamais votre appareil."
},
"checklist": {
"title": "Confirmez que :",
"item1": "J'ai compris que cette opération modifie définitivement mes catégories.",
"item2": "Je confirme qu'une sauvegarde vérifiée sera créée avant tout changement.",
"item3": "Je pourrai rétablir la sauvegarde à tout moment pendant 90 jours."
},
"back": "Revenir",
"confirm": "Créer la sauvegarde et migrer"
},
"running": {
"title": "Migration en cours...",
"subtitle": "Ne fermez pas l'application avant la fin de l'opération.",
"step1": "Étape 1 · Création de la sauvegarde",
"step2": "Étape 2 · Vérification de la sauvegarde (checksum SHA-256)",
"step3": "Étape 3 · Application de la migration (transaction SQL)",
"step4": "Étape 4 · Validation et rafraîchissement"
},
"success": {
"title": "Migration réussie",
"subtitle": "Votre profil utilise maintenant la structure standard (IPC Statistique Canada).",
"backupLabel": "Votre sauvegarde est ici :",
"restoreHint": "Vous pourrez rétablir cette sauvegarde à tout moment depuis Paramètres > Catégories pendant 90 jours.",
"stats": {
"inserted": "Catégories ajoutées",
"transactions": "Transactions migrées",
"keywords": "Mots-clés migrés",
"budgets": "Budgets migrés"
},
"backToDashboard": "Aller au tableau de bord",
"viewCategories": "Voir mes catégories"
},
"error": {
"title": "La migration n'a pas pu être appliquée",
"subtitle": "Aucun changement n'a été enregistré. Votre profil est dans l'état exact où il se trouvait avant l'opération.",
"rollbackNote": "Si une sauvegarde partielle a été créée, elle reste disponible dans votre dossier Documents — vous pouvez la supprimer manuellement si elle ne vous sert pas.",
"retry": "Revenir à l'étape de consentement",
"backToSettings": "Retour aux paramètres",
"backup": {
"missing_password": "NIP manquant — la sauvegarde d'un profil protégé nécessite le NIP.",
"documents_dir_unavailable": "Impossible de localiser le dossier Documents.",
"permission_denied": "Permissions insuffisantes pour écrire dans le dossier de sauvegarde.",
"disk_space": "Espace disque insuffisant pour créer la sauvegarde.",
"create_dir_failed": "Impossible de créer le dossier de sauvegarde.",
"write_failed": "Échec d'écriture du fichier de sauvegarde.",
"read_back_failed": "Impossible de relire le fichier de sauvegarde pour vérification.",
"verification_mismatch": "La vérification de la sauvegarde a échoué (checksum invalide ou fichier corrompu)."
}
}
}
}
}

View file

@ -0,0 +1,538 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
ArrowLeft,
CheckCircle2,
AlertOctagon,
FolderOpen,
RotateCcw,
Home as HomeIcon,
} from "lucide-react";
import { useProfile } from "../contexts/ProfileContext";
import { useCategoryMigration } from "../hooks/useCategoryMigration";
import {
computeMigrationPlan,
type ProfileData,
} from "../services/categoryMappingService";
import {
createPreMigrationBackup,
BackupError,
} from "../services/categoryBackupService";
import { applyMigration } from "../services/categoryMigrationService";
import { getDb } from "../services/db";
import { getPreference } from "../services/userPreferenceService";
import StepDiscover from "../components/categories-migration/StepDiscover";
import StepSimulate from "../components/categories-migration/StepSimulate";
import StepConsent from "../components/categories-migration/StepConsent";
/**
* 3-step category migration page route `/settings/categories/migrate`.
*
* Wraps:
* - StepDiscover (read-only taxonomy preview, reused from #117)
* - StepSimulate (dry-run table + transaction preview side panel)
* - StepConsent (checklist + loader + success/error screens)
*
* The page owns the data-fetching pipeline:
* 1. Load profile data (categories, keywords, transactions, suppliers) from
* SQLite as soon as we enter step 2 the first time, then compute the
* MigrationPlan via the pure function from categoryMappingService.
* 2. On consent-confirm: createPreMigrationBackup applyMigration outcome.
*
* State transitions are delegated to useCategoryMigration (useReducer).
*/
const CATEGORIES_SCHEMA_VERSION_KEY = "categories_schema_version";
type AlreadyMigratedState = "loading" | "needs_migration" | "already_v1";
export default function CategoriesMigrationPage() {
const { t } = useTranslation();
const { activeProfile } = useProfile();
const {
state,
loadPlan,
resolveRow,
selectRow,
goNext,
goBack,
startRun,
setBackup,
setOutcome,
fail,
} = useCategoryMigration();
// Gate the page: profiles already on v1 should see a friendly "already done"
// screen instead of re-migrating (re-running the writer would be idempotent
// thanks to OR IGNORE, but it would surprise the user).
const [schemaCheck, setSchemaCheck] = useState<AlreadyMigratedState>(
"loading",
);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const version = await getPreference(CATEGORIES_SCHEMA_VERSION_KEY);
if (cancelled) return;
setSchemaCheck(version === "v1" ? "already_v1" : "needs_migration");
} catch {
if (!cancelled) setSchemaCheck("needs_migration");
}
})();
return () => {
cancelled = true;
};
}, []);
// Lazy-load the profile data + plan the first time we enter the simulate step.
const [txCountByV2Id, setTxCountByV2Id] = useState<Map<number, number>>(
() => new Map(),
);
const [loadError, setLoadError] = useState<string | null>(null);
const planLoadedRef = useRef(false);
useEffect(() => {
if (state.step !== "simulate" || planLoadedRef.current) return;
planLoadedRef.current = true;
(async () => {
try {
const db = await getDb();
const [v2Cats, kws, txs, sups, txCounts] = await Promise.all([
db.select<Array<{ id: number; name: string; parent_id: number | null }>>(
"SELECT id, name, parent_id FROM categories WHERE is_active = 1",
),
db.select<Array<{ id: number; keyword: string; category_id: number }>>(
"SELECT id, keyword, category_id FROM keywords WHERE is_active = 1",
),
db.select<
Array<{
id: number;
description: string;
category_id: number | null;
supplier_id: number | null;
}>
>(
"SELECT id, description, category_id, supplier_id FROM transactions",
),
db.select<Array<{ id: number; name: string }>>(
"SELECT id, name FROM suppliers WHERE is_active = 1",
),
db.select<Array<{ category_id: number; cnt: number }>>(
"SELECT category_id, COUNT(*) AS cnt FROM transactions WHERE category_id IS NOT NULL GROUP BY category_id",
),
]);
const profileData: ProfileData = {
v2Categories: v2Cats,
keywords: kws,
transactions: txs,
suppliers: sups,
};
const plan = computeMigrationPlan(profileData);
loadPlan(plan);
const counts = new Map<number, number>();
for (const row of txCounts) {
counts.set(row.category_id, Number(row.cnt));
}
setTxCountByV2Id(counts);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setLoadError(msg);
planLoadedRef.current = false;
}
})();
}, [state.step, loadPlan]);
// Password for PIN-protected backups.
const [password, setPassword] = useState("");
const requiresPassword =
!!activeProfile?.pin_hash && activeProfile.pin_hash.length > 0;
// Loader stage for the 4 sub-steps (see StepConsent RunningLoader).
const [runningStage, setRunningStage] = useState<0 | 1 | 2 | 3>(0);
const handleConfirm = async () => {
if (!activeProfile || !state.plan) return;
startRun();
setRunningStage(0);
try {
// 1. Create + verify the backup.
const backup = await createPreMigrationBackup({
profile: activeProfile,
password: requiresPassword ? password : undefined,
});
setBackup(backup);
setRunningStage(1);
// 2. Tiny pause for the UI to breathe (checksum already verified inside
// createPreMigrationBackup, but we bump the stage for the loader).
await new Promise((r) => setTimeout(r, 200));
setRunningStage(2);
// 3. Run the migration.
const outcome = await applyMigration(state.plan, backup);
setRunningStage(3);
await new Promise((r) => setTimeout(r, 200));
setOutcome(outcome);
} catch (e) {
if (e instanceof BackupError) {
const label = t(
`categoriesSeed.migration.error.backup.${e.code}`,
{ defaultValue: e.message },
);
fail(label);
} else {
const msg = e instanceof Error ? e.message : String(e);
fail(msg);
}
}
};
// --------------------------------------------------------------------------
// Renders
// --------------------------------------------------------------------------
if (schemaCheck === "loading") {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
</div>
);
}
if (schemaCheck === "already_v1") {
return (
<div className="p-6 max-w-2xl mx-auto space-y-4">
<Link
to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft size={16} />
{t("categoriesSeed.migration.backToSettings")}
</Link>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3">
<h1 className="text-xl font-semibold">
{t("categoriesSeed.migration.alreadyMigrated.title")}
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.alreadyMigrated.body")}
</p>
</div>
</div>
);
}
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div>
<Link
to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft size={16} />
{t("categoriesSeed.migration.backToSettings")}
</Link>
</div>
<header className="space-y-1">
<h1 className="text-2xl font-bold">
{t("categoriesSeed.migration.pageTitle")}
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.pageSubtitle")}
</p>
</header>
<Stepper step={state.step} />
{loadError !== null && state.step === "simulate" && (
<div className="rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-900 dark:bg-red-900/10 dark:border-red-700 dark:text-red-200">
{t("categoriesSeed.migration.simulate.loadError", {
error: loadError,
})}
</div>
)}
{state.step === "discover" && <StepDiscover onNext={goNext} />}
{state.step === "simulate" && state.plan !== null && (
<StepSimulate
plan={state.plan}
unresolved={state.unresolved}
selectedRowV2Id={state.selectedRowV2Id}
transactionCountByV2Id={txCountByV2Id}
onResolveRow={resolveRow}
onSelectRow={selectRow}
onNext={goNext}
onBack={goBack}
/>
)}
{state.step === "simulate" && state.plan === null && loadError === null && (
<div className="flex items-center justify-center p-10">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
</div>
)}
{(state.step === "consent" || state.step === "running") && (
<StepConsent
password={password}
onPasswordChange={setPassword}
requiresPassword={requiresPassword}
isRunning={state.step === "running"}
runningStage={runningStage}
onBack={goBack}
onConfirm={handleConfirm}
/>
)}
{state.step === "success" && state.outcome && (
<SuccessScreen
backupPath={state.outcome.backupPath}
insertedV1={state.outcome.insertedV1Count}
updatedTx={state.outcome.updatedTransactionsCount}
updatedKw={state.outcome.updatedKeywordsCount}
updatedBg={state.outcome.updatedBudgetsCount}
/>
)}
{state.step === "error" && (
<ErrorScreen
errors={state.errors}
onRetry={goBack}
/>
)}
</div>
);
}
interface StepperProps {
step: string;
}
function Stepper({ step }: StepperProps) {
const { t } = useTranslation();
const steps: Array<{
key: string;
label: string;
active: boolean;
done: boolean;
}> = useMemo(
() => [
{
key: "discover",
label: t("categoriesSeed.migration.stepper.discover"),
active: step === "discover",
done: ["simulate", "consent", "running", "success"].includes(step),
},
{
key: "simulate",
label: t("categoriesSeed.migration.stepper.simulate"),
active: step === "simulate",
done: ["consent", "running", "success"].includes(step),
},
{
key: "consent",
label: t("categoriesSeed.migration.stepper.consent"),
active: step === "consent" || step === "running",
done: step === "success",
},
],
[step, t],
);
return (
<ol
className="flex items-center gap-3 text-sm"
aria-label={t("categoriesSeed.migration.stepper.ariaLabel")}
>
{steps.map((s, i) => (
<li key={s.key} className="flex items-center gap-3">
<span
className={
"inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-semibold border " +
(s.done
? "bg-[var(--primary)] text-white border-[var(--primary)]"
: s.active
? "bg-[var(--primary)]/10 text-[var(--primary)] border-[var(--primary)]"
: "bg-[var(--muted)] text-[var(--muted-foreground)] border-[var(--border)]")
}
>
{i + 1}
</span>
<span
className={
s.active
? "font-medium text-[var(--foreground)]"
: s.done
? "text-[var(--foreground)]"
: "text-[var(--muted-foreground)]"
}
>
{s.label}
</span>
{i < steps.length - 1 && (
<span
aria-hidden="true"
className="h-px w-8 bg-[var(--border)]"
/>
)}
</li>
))}
</ol>
);
}
interface SuccessScreenProps {
backupPath: string;
insertedV1: number;
updatedTx: number;
updatedKw: number;
updatedBg: number;
}
function SuccessScreen({
backupPath,
insertedV1,
updatedTx,
updatedKw,
updatedBg,
}: SuccessScreenProps) {
const { t } = useTranslation();
return (
<section className="space-y-6">
<div className="bg-[var(--card)] border border-green-300 dark:border-green-700 rounded-xl p-6 space-y-4">
<div className="flex items-start gap-3">
<CheckCircle2
size={28}
className="shrink-0 text-green-600 dark:text-green-400"
/>
<div>
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.success.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.success.subtitle")}
</p>
</div>
</div>
<div className="bg-[var(--muted)] rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-[var(--foreground)]">
<FolderOpen size={16} className="inline mr-1" />
{t("categoriesSeed.migration.success.backupLabel")}
</p>
<code className="block text-xs break-all p-2 bg-[var(--background)] rounded border border-[var(--border)]">
{backupPath}
</code>
<p className="text-xs text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.success.restoreHint")}
</p>
</div>
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.success.stats.inserted")}
</dt>
<dd className="text-lg font-semibold">{insertedV1}</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.success.stats.transactions")}
</dt>
<dd className="text-lg font-semibold">{updatedTx}</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.success.stats.keywords")}
</dt>
<dd className="text-lg font-semibold">{updatedKw}</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.success.stats.budgets")}
</dt>
<dd className="text-lg font-semibold">{updatedBg}</dd>
</div>
</dl>
</div>
<div className="flex items-center justify-center gap-3">
<Link
to="/"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90"
>
<HomeIcon size={16} />
{t("categoriesSeed.migration.success.backToDashboard")}
</Link>
<Link
to="/categories"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)]"
>
{t("categoriesSeed.migration.success.viewCategories")}
</Link>
</div>
</section>
);
}
interface ErrorScreenProps {
errors: string[];
onRetry: () => void;
}
function ErrorScreen({ errors, onRetry }: ErrorScreenProps) {
const { t } = useTranslation();
return (
<section className="space-y-6">
<div className="bg-[var(--card)] border border-red-300 dark:border-red-700 rounded-xl p-6 space-y-4">
<div className="flex items-start gap-3">
<AlertOctagon
size={28}
className="shrink-0 text-red-600 dark:text-red-400"
/>
<div>
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.error.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.error.subtitle")}
</p>
</div>
</div>
{errors.length > 0 && (
<ul className="space-y-1 text-sm text-red-700 dark:text-red-400">
{errors.map((e, i) => (
<li
key={i}
className="p-2 rounded bg-red-50 dark:bg-red-900/10 break-words"
>
{e}
</li>
))}
</ul>
)}
<p className="text-xs text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.error.rollbackNote")}
</p>
</div>
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={onRetry}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90"
>
<RotateCcw size={16} />
{t("categoriesSeed.migration.error.retry")}
</button>
<Link
to="/settings"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)]"
>
{t("categoriesSeed.migration.error.backToSettings")}
</Link>
</div>
</section>
);
}

View file

@ -0,0 +1,401 @@
import { getDb } from "./db";
import { setPreference } from "./userPreferenceService";
import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService";
import type { BackupResult } from "./categoryBackupService";
import type { MigrationPlan, MappingRow } from "./categoryMappingService";
// -----------------------------------------------------------------------------
// Category migration service — orchestrates the atomic v2 → v1 SQL writeover
// using a MigrationPlan computed upstream by categoryMappingService.
//
// The service is intentionally destructive: it is only meant to be called
// *after* a verified pre-migration SREF backup (BackupResult) has been written
// to disk and confirmed by the user. The caller (CategoriesMigrationPage /
// useCategoryMigration hook) is responsible for sequencing backup → migrate →
// surface success/error — this service only runs the DB part.
//
// Ordering (all inside a single BEGIN / COMMIT transaction):
// 1. Guard: backup looks valid (path + checksum present).
// 2. BEGIN.
// 3. If plan.preserved.length > 0: INSERT a new parent category
// "Catégories personnalisées (migration)" (with i18n_key so it renders
// in both languages), keep its new id.
// 4. INSERT all v1 taxonomy nodes with explicit ids from the bundled
// `categoryTaxonomyV1.json`. We skip ids that already exist in the DB
// (OR IGNORE) so a re-run is safe.
// 5. UPDATE transactions.category_id per plan.rows mapping (v2 → v1).
// 6. UPDATE budget_entries.category_id and budget_template_entries.category_id
// per plan.rows mapping (v2 → v1). We DELETE conflicting rows first
// because budget_entries has UNIQUE(category_id, year, month) and
// budget_template_entries has UNIQUE(template_id, category_id).
// 7. UPDATE keywords.category_id per plan.rows mapping (v2 → v1). We
// DELETE conflicting rows first because of UNIQUE(keyword, category_id).
// 8. UPDATE suppliers.category_id per plan.rows mapping (v2 → v1).
// 9. Re-parent preserved v2 custom categories under the new "Catégories
// personnalisées (migration)" parent.
// 10. DELETE v2 seeded categories that are now empty (no transactions /
// keywords / budgets / suppliers / child categories referencing them).
// Deletion is soft (is_active=0) to preserve ON DELETE CASCADE from
// historical budget_entries etc. in edge cases.
// 11. Set `categories_schema_version='v1'` and record
// `last_categories_migration` JSON in user_preferences.
// 12. COMMIT. On any thrown error: ROLLBACK and report in MigrationOutcome.
// -----------------------------------------------------------------------------
export interface MigrationOutcome {
/** True when the transaction committed; false if we rolled back or aborted. */
succeeded: boolean;
/** Human-readable error message on failure. Undefined on success. */
error?: string;
/** Number of v1 taxonomy rows we inserted (may be 0 on re-run). */
insertedV1Count: number;
/** Number of transactions whose category_id was rewritten. */
updatedTransactionsCount: number;
/** Number of budget_entries + budget_template_entries rows rewritten. */
updatedBudgetsCount: number;
/** Number of keywords rows rewritten. */
updatedKeywordsCount: number;
/** Number of v2 categories we deactivated (soft-delete). */
deletedV2Count: number;
/** Number of custom categories re-parented under the new parent. */
customPreservedCount: number;
/** Path to the SREF backup that was created before this run. */
backupPath: string;
}
/** JSON journalled in user_preferences.last_categories_migration. */
export interface LastMigrationJournal {
timestamp: string;
backupPath: string;
outcome: Pick<
MigrationOutcome,
| "insertedV1Count"
| "updatedTransactionsCount"
| "updatedBudgetsCount"
| "updatedKeywordsCount"
| "deletedV2Count"
| "customPreservedCount"
>;
}
// Preference keys we write at the end of a successful migration.
const SCHEMA_VERSION_KEY = "categories_schema_version";
const LAST_MIGRATION_KEY = "last_categories_migration";
// Id reserved for the "Catégories personnalisées (migration)" parent we
// create when the profile has custom v2 categories. We deliberately pick a
// number that is outside the v1 taxonomy range (1000 1999) and outside the
// v2 seed range (< 1000) to avoid collisions even on re-runs.
const CUSTOM_PARENT_NEW_ID = 2000;
const CUSTOM_PARENT_NAME = "Catégories personnalisées (migration)";
const CUSTOM_PARENT_I18N_KEY = "categoriesSeed.migration.customParent";
const CUSTOM_PARENT_COLOR = "#64748b";
const CUSTOM_PARENT_TYPE = "expense";
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
function flattenTaxonomy(root: TaxonomyNode, parentId: number | null, out: Array<TaxonomyFlat>): void {
out.push({
id: root.id,
name: root.name,
i18n_key: root.i18n_key,
parent_id: parentId,
type: root.type,
color: root.color,
sort_order: root.sort_order,
is_inputable: root.children.length === 0,
});
for (const child of root.children) {
flattenTaxonomy(child, root.id, out);
}
}
interface TaxonomyFlat {
id: number;
name: string;
i18n_key: string;
parent_id: number | null;
type: string;
color: string;
sort_order: number;
is_inputable: boolean;
}
function listAllV1Rows(): TaxonomyFlat[] {
const flat: TaxonomyFlat[] = [];
for (const root of getTaxonomyV1().roots) {
flattenTaxonomy(root, null, flat);
}
return flat;
}
/** Validate the backup looks usable — path + checksum present. */
function validateBackup(backup: BackupResult): void {
if (!backup || typeof backup.path !== "string" || backup.path.length === 0) {
throw new Error("invalid_backup: missing path");
}
if (typeof backup.checksum !== "string" || backup.checksum.length === 0) {
throw new Error("invalid_backup: missing checksum");
}
}
/** Build the v2Id → v1Id map from plan.rows (only resolved targets are 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);
}
}
return map;
}
// -----------------------------------------------------------------------------
// Public entry point
// -----------------------------------------------------------------------------
export async function applyMigration(
plan: MigrationPlan,
backup: BackupResult,
): Promise<MigrationOutcome> {
const outcome: MigrationOutcome = {
succeeded: false,
insertedV1Count: 0,
updatedTransactionsCount: 0,
updatedBudgetsCount: 0,
updatedKeywordsCount: 0,
deletedV2Count: 0,
customPreservedCount: 0,
backupPath: backup?.path ?? "",
};
try {
validateBackup(backup);
} catch (e) {
outcome.error = e instanceof Error ? e.message : String(e);
return outcome;
}
const db = await getDb();
const mapping = buildMappingFromRows(plan.rows);
await db.execute("BEGIN");
try {
// 1. Optionally create the "custom categories (migration)" parent.
let customParentId: number | null = null;
if (plan.preserved.length > 0) {
// Use INSERT OR IGNORE so a re-run never throws on the PK.
await db.execute(
`INSERT OR IGNORE INTO categories
(id, name, parent_id, color, type, is_active, is_inputable, sort_order, i18n_key)
VALUES ($1, $2, NULL, $3, $4, 1, 0, 99, $5)`,
[
CUSTOM_PARENT_NEW_ID,
CUSTOM_PARENT_NAME,
CUSTOM_PARENT_COLOR,
CUSTOM_PARENT_TYPE,
CUSTOM_PARENT_I18N_KEY,
],
);
customParentId = CUSTOM_PARENT_NEW_ID;
}
// 2. INSERT v1 taxonomy. Roots first, then subcategories, then leaves,
// thanks to flattenTaxonomy's depth-first walk. Use OR IGNORE so a
// partial earlier run is recoverable.
const v1Rows = listAllV1Rows();
for (const row of v1Rows) {
const result = await db.execute(
`INSERT OR IGNORE INTO categories
(id, name, parent_id, color, type, is_active, is_inputable, sort_order, i18n_key)
VALUES ($1, $2, $3, $4, $5, 1, $6, $7, $8)`,
[
row.id,
row.name,
row.parent_id,
row.color,
row.type,
row.is_inputable ? 1 : 0,
row.sort_order,
row.i18n_key,
],
);
// tauri-plugin-sql returns rowsAffected; on OR IGNORE conflicts it's 0.
const affected = Number(result.rowsAffected ?? 0);
outcome.insertedV1Count += affected;
}
// 3. Rewrite transactions.category_id v2 → v1.
for (const [v2Id, v1Id] of mapping.entries()) {
const r = await db.execute(
`UPDATE transactions SET category_id = $1, updated_at = CURRENT_TIMESTAMP
WHERE category_id = $2`,
[v1Id, v2Id],
);
outcome.updatedTransactionsCount += Number(r.rowsAffected ?? 0);
}
// 4. Rewrite budget_entries (handle UNIQUE(category_id, year, month) by
// deleting rows we'd collide with first — in a preview-and-consent
// flow, the collision means the user already has a budget on the v1
// target for the same period, so dropping the v2 duplicate is the
// least-surprising choice).
for (const [v2Id, v1Id] of mapping.entries()) {
await db.execute(
`DELETE FROM budget_entries
WHERE category_id = $1
AND (year, month) IN (
SELECT year, month FROM budget_entries WHERE category_id = $2
)`,
[v2Id, v1Id],
);
const r = await db.execute(
`UPDATE budget_entries SET category_id = $1, updated_at = CURRENT_TIMESTAMP
WHERE category_id = $2`,
[v1Id, v2Id],
);
outcome.updatedBudgetsCount += Number(r.rowsAffected ?? 0);
}
// 5. Rewrite budget_template_entries (same collision rule via
// UNIQUE(template_id, category_id)).
for (const [v2Id, v1Id] of mapping.entries()) {
await db.execute(
`DELETE FROM budget_template_entries
WHERE category_id = $1
AND template_id IN (
SELECT template_id FROM budget_template_entries WHERE category_id = $2
)`,
[v2Id, v1Id],
);
const r = await db.execute(
`UPDATE budget_template_entries SET category_id = $1
WHERE category_id = $2`,
[v1Id, v2Id],
);
outcome.updatedBudgetsCount += Number(r.rowsAffected ?? 0);
}
// 6. Rewrite keywords (UNIQUE(keyword, category_id)). Drop v2 keywords
// whose normalized spelling already points at the v1 target before the
// UPDATE, to avoid constraint violations.
for (const [v2Id, v1Id] of mapping.entries()) {
await db.execute(
`DELETE FROM keywords
WHERE category_id = $1
AND keyword IN (
SELECT keyword FROM keywords WHERE category_id = $2
)`,
[v2Id, v1Id],
);
const r = await db.execute(
`UPDATE keywords SET category_id = $1 WHERE category_id = $2`,
[v1Id, v2Id],
);
outcome.updatedKeywordsCount += Number(r.rowsAffected ?? 0);
}
// 7. Rewrite suppliers.category_id — no unique constraint, straightforward.
for (const [v2Id, v1Id] of mapping.entries()) {
await db.execute(
`UPDATE suppliers SET category_id = $1, updated_at = CURRENT_TIMESTAMP
WHERE category_id = $2`,
[v1Id, v2Id],
);
}
// 8. Re-parent preserved custom categories under the new parent. We touch
// only the top level of the custom tree (parent_id IS NULL or pointing
// at a v2 structural parent in the 1..6 range): children follow naturally.
if (customParentId !== null) {
for (const preservedRow of plan.preserved) {
const r = await db.execute(
`UPDATE categories SET parent_id = $1 WHERE id = $2`,
[customParentId, preservedRow.v2CategoryId],
);
outcome.customPreservedCount += Number(r.rowsAffected ?? 0);
}
}
// 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;
const r = await db.execute(
`UPDATE categories SET is_active = 0 WHERE id = $1`,
[row.v2CategoryId],
);
outcome.deletedV2Count += Number(r.rowsAffected ?? 0);
}
// 10. Also deactivate the v2 structural parents (1..6) — they have no v1
// equivalent and become obsolete after the migration.
{
const r = await db.execute(
`UPDATE categories SET is_active = 0
WHERE id IN (1, 2, 3, 4, 5, 6)`,
);
outcome.deletedV2Count += Number(r.rowsAffected ?? 0);
}
// 11. Bump the schema version and journal the run.
await db.execute(
`INSERT INTO user_preferences (key, value, updated_at)
VALUES ($1, 'v1', CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = 'v1', updated_at = CURRENT_TIMESTAMP`,
[SCHEMA_VERSION_KEY],
);
const journal: LastMigrationJournal = {
timestamp: new Date().toISOString(),
backupPath: backup.path,
outcome: {
insertedV1Count: outcome.insertedV1Count,
updatedTransactionsCount: outcome.updatedTransactionsCount,
updatedBudgetsCount: outcome.updatedBudgetsCount,
updatedKeywordsCount: outcome.updatedKeywordsCount,
deletedV2Count: outcome.deletedV2Count,
customPreservedCount: outcome.customPreservedCount,
},
};
await db.execute(
`INSERT INTO user_preferences (key, value, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`,
[LAST_MIGRATION_KEY, JSON.stringify(journal)],
);
await db.execute("COMMIT");
outcome.succeeded = true;
return outcome;
} catch (e) {
try {
await db.execute("ROLLBACK");
} catch {
// Swallow: if the rollback itself fails there is nothing we can do here
// besides returning the original error to the caller.
}
outcome.error = e instanceof Error ? e.message : String(e);
outcome.succeeded = false;
return outcome;
}
}
/**
* Convenience: mark the schema as v1 without running the full migration.
* Exported for tests and tooling the happy-path user flow goes through
* `applyMigration` which handles this transactionally.
*/
export async function markSchemaVersionV1(): Promise<void> {
await setPreference(SCHEMA_VERSION_KEY, "v1");
}