fix(export): preserve import sources and templates across data export/import #341

Closed
maximus wants to merge 1 commit from issue-331-sref-import-sources into issue-330-bank-signatures-drift
Owner

Exporting then re-importing data destroyed every import configuration. dataExportService serialised only categories, suppliers, keywords and transactions, then ran DELETE FROM import_sources on restore and replaced them with a synthetic "Data Import" source. After restoring a backup, every source had to be reconfigured by hand.

This was the third path by which the import format was lost, independent of the root bug (#324) and of format drift (#330), and the most radical of the three.

What changed

  • import_sources and import_config_templates are serialised into the envelope, behind an explicit format_version. A file without one is the earlier format: its missing arrays are treated as empty and the import behaves as before.
  • Wipe + restore of both import paths are wrapped in withTransaction — the service had none. A constraint violation mid-restore used to abandon the operation and destroy the user's financial history with no rollback.
  • Templates are restored before sources (template_id is a foreign key), upserted by name, and import_sources.template_id is remapped through the resolved ids. Restoring into a profile that already holds templates no longer hits UNIQUE constraint failed: import_config_templates.name — the normal path, not a theoretical one.
  • amount_mode and sign_convention are whitelisted at the import boundary with a readable message; the v17 CHECK already refuses bad values at the DB level, but an SQLite constraint error is not a user-facing message.

Verification

  • 1176 vitest passed (60 files) — baseline 1140 after link 8, +36
  • npm run build clean (tsc + vite), cargo check clean (no Rust touched)

Recovery note

The worker implementing this link was killed by a session limit during its final validation pass, after writing the code but before committing. The work was recovered from its worktree and verified from scratch (full suite, build, cargo) before this commit. Its decisions log covers only the branch setup; the rationale above was reconstructed from the diff and the issue body, so this PR deserves a closer read than its siblings.

Resolves #331

Generated autonomously by /autopilot run of 2026-08-13

Exporting then re-importing data destroyed every import configuration. `dataExportService` serialised only categories, suppliers, keywords and transactions, then ran `DELETE FROM import_sources` on restore and replaced them with a synthetic "Data Import" source. After restoring a backup, every source had to be reconfigured by hand. This was the third path by which the import format was lost, independent of the root bug (#324) and of format drift (#330), and the most radical of the three. ## What changed - `import_sources` and `import_config_templates` are serialised into the envelope, behind an explicit `format_version`. A file without one is the earlier format: its missing arrays are treated as empty and the import behaves as before. - Wipe + restore of both import paths are wrapped in `withTransaction` — the service had none. A constraint violation mid-restore used to abandon the operation and destroy the user's financial history with no rollback. - Templates are restored **before** sources (`template_id` is a foreign key), upserted by name, and `import_sources.template_id` is remapped through the resolved ids. Restoring into a profile that already holds templates no longer hits `UNIQUE constraint failed: import_config_templates.name` — the normal path, not a theoretical one. - `amount_mode` and `sign_convention` are whitelisted at the import boundary with a readable message; the v17 `CHECK` already refuses bad values at the DB level, but an SQLite constraint error is not a user-facing message. ## Verification - 1176 vitest passed (60 files) — baseline 1140 after link 8, +36 - `npm run build` clean (tsc + vite), `cargo check` clean (no Rust touched) ## Recovery note The worker implementing this link was killed by a session limit during its final validation pass, after writing the code but before committing. The work was recovered from its worktree and verified from scratch (full suite, build, cargo) before this commit. Its decisions log covers only the branch setup; the rationale above was reconstructed from the diff and the issue body, so this PR deserves a closer read than its siblings. Resolves #331 Generated autonomously by /autopilot run of 2026-08-13
maximus added 1 commit 2026-08-14 14:59:16 +00:00
fix(export): preserve import sources and templates across data export/import
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
f377d760af
Exporting then re-importing data destroyed every import configuration:
dataExportService serialised only categories, suppliers, keywords and
transactions, then ran DELETE FROM import_sources on restore and replaced
them with a synthetic 'Data Import' source. After restoring a backup, every
source had to be reconfigured by hand.

- Serialise import_sources and import_config_templates into the envelope,
  with an explicit format_version; a file without one is the earlier format
  and its missing arrays are treated as empty.
- Wrap wipe + restore in withTransaction, which the service had nowhere:
  a constraint violation mid-restore used to destroy financial history with
  no rollback.
- Restore templates BEFORE sources (template_id is a foreign key), upserting
  by name and remapping template_id through the resolved ids, so restoring
  into a profile that already has templates no longer hits UNIQUE(name).
- Whitelist amount_mode and sign_convention at the import boundary with a
  readable message rather than an SQLite constraint error.

Resolves #331

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
maximus added the
autopilot:pending-human
label 2026-08-14 14:59:17 +00:00
Author
Owner

Revue adversariale — PR #341

Verdict : APPROVE — aucun blocage.

Note de contexte : cette PR a été récupérée après un run interrompu. Le worker a été tué par une limite de session pendant sa passe de validation finale, après avoir écrit le code mais avant de committer ; son journal de décisions ne couvre que la préparation de la branche, donc le corps de la PR est une reconstruction à partir du diff, pas un témoignage. Chaque affirmation du corps a donc été revérifiée dans le code plutôt que crue sur parole. C'est aussi pourquoi cette revue est plus longue que celles des autres maillons de la pile.


Résumé

L'export SREF transporte maintenant import_sources et import_config_templates, la restauration les remet en place, et les deux chemins destructifs passent enfin par une transaction. Les 6 affirmations du corps ont été vérifiées une à une dans le code, plus les 6 pièges listés au cahier de revue. Elles tiennent toutes. Détail de la vérification ci-dessous, puis 7 suggestions non bloquantes.

Ce qui a été vérifié (et tient)

1. Contrat withTransaction — respecté à la lettre.
dataExportService.ts:417-436 (runRestore) suit exactement le contrat documenté dans db.ts:80-104 : withTransaction ne garantit que l'exclusivité du lock, l'appelant émet BEGIN/COMMIT/ROLLBACK. C'est l'idiome des sites existants (balance.service.ts:1608-1611, categoryMigrationService.ts:205).
Le BEGIN est hors du try : un échec de BEGIN ne déclenche pas de ROLLBACK fantôme. Le drapeau open reste true pendant le COMMIT, donc un COMMIT qui échoue déclenche bien un ROLLBACK. Un ROLLBACK qui échoue est avalé et l'erreur d'origine préservée — correct, c'est elle qui explique la panne.

2. Aucun withTransaction imbriqué. Les 3 fonctions d'import ont exactement 2 appelants hors tests — useDataImport.ts:185-191 et categoryRestoreService.ts:255 — aucun des deux n'est dans une transaction. categoryMigrationService crée sa sauvegarde avant d'ouvrir la sienne (:180-190). Aucune fonction du corps (restoreCategories, restoreSuppliers, restoreKeywords, restoreImportTemplates, restoreImportSources, attachTransactions) n'appelle getDb() : toutes reçoivent le handle en paramètre. Pas de seconde connexion ouverte depuis l'intérieur.

3. Échec à mi-restauration → profil intact. Les 6 DELETE FROM sont bien à l'intérieur du runRestore dans les deux chemins (:568-573 et :614-616), pas avant. La liste blanche, elle, est appelée avant runRestore (:559 et :607) : un fichier refusé n'ouvre même pas de transaction — le test :685 l'assert littéralement (expect(db.log).not.toContain("BEGIN")).

4. Rétrocompatibilité — le chemin sans version est bien l'ancien. parseImportedJson garde son contrôle !envelope.data avant de toucher aux nouveaux tableaux, donc pas de TypeError sur un fichier tronqué. validateImportedFormatRows(undefined, undefined) est un no-op (?? []). Fichier v1 → tableaux absents → rien restauré → source hôte créée. Un seul écart de comportement assumé : la source « Data Import » et sa ligne imported_files ne sont plus créées quand le fichier ne porte aucune transaction (attachTransactions:483 sort tôt). C'est un mieux, mais c'est bien un écart vs. l'ancien code.

5. Remap template_id — pas de liaison silencieusement fausse. Un template_name sans correspondance retombe sur NULL (:460-462), jamais sur un modèle local homonyme : la Map ne contient que les noms du fichier. Le sens dangereux (accrocher un modèle local différent) est donc fermé. Reste le sens inverse, cf. suggestion 2.

6. La source « Data Import » n'atteint plus le codec avec '{}'. HOST_SOURCE_MAPPING = {date:0,description:1,amount:2}, et c'est exact : serializeTransactionsToCsv:228-238 émet les colonnes dans cet ordre, Papa.unparse sépare par virgule, t.date est en ISO, les dépenses sont négatives — le delimiter: "," / has_header: 1 / skip_lines: 0 / %Y-%m-%d / negative_expense insérés décrivent réellement ce fichier. formatFromRow l'accepte (les deux index sont numériques).

7. Chiffrement — transparent. write_export_file / read_import_file (export_import_commands.rs:110-137) traitent le contenu comme une chaîne UTF-8 opaque : chiffrement AES-256-GCM des octets, aucun parsing de schéma côté Rust. Ajouter des tableaux à l'enveloppe n'interagit pas avec ce chemin. Le round-trip de createPreMigrationBackup (sha256 avant/après) reste valide.

8. Pas de valeur NULL capable de faire refuser une sauvegarde légitime. Point qui aurait pu être un blocage : l'ancien restore insérait ses sources sans amount_mode ni sign_convention. Vérifié en base — les deux colonnes sont NOT NULL DEFAULT 'single' / 'negative_expense' sur import_sources (v17) et sur import_config_templates depuis leur création en v5. Les sources écrites par l'ancien code retombent donc dans la liste blanche. Seul absolute_indicator (admis par le CHECK v17, refusé par AMOUNT_MODES) peut échouer, et aucune UI ne l'écrit — cf. suggestion 4.

9. Reste. ImportSummary gagne 3 champs requis mais n'est construit qu'aux 2 sites mis à jour (parseImportedJson, parseImportedCsv) — aucun autre littéral à corriger. i18n : les 5 clés existent dans fr.json et en.json, les deux fichiers restent du JSON valide, et il n'y a pas de collision avec les clés préexistantes de l'assistant (elles vivent sous import.errors racine, pas sous settings.dataManagement.import.errors). Aucune migration touchée, aucun secret, logique dans services/, commentaires en anglais, aucun .skip/.only. CI verte sur f377d76. CHANGELOG et docs/architecture.md absents par construction : #332 est le maillon docs+changelog de la milestone.


Suggestions (aucune bloquante)

  1. format_version est écrit mais ne sert à rien en lecture. Il est stampé, remonté dans le résumé, testé — et jamais comparé. Un fichier format_version: 3 produit par une version future serait accepté en silence par ce build, avec la sémantique v2. Refuser format_version > SREF_FORMAT_VERSION avec un message dédié ferait faire au champ le travail pour lequel il a été ajouté. (dataExportService.ts:331)

  2. L'upsert de modèle écrase un modèle local homonyme au contenu différent. import_config_templates n'est dans aucune liste de purge, donc restaurer dans un profil existant remplace le format d'un « Desjardins EOP » local par celui du fichier, sans que rien ne le dise : la modale compte désormais les modèles entrants mais ne signale pas que les homonymes existants sont remplacés. Le commentaire de restoreImportTemplates:1380-1387 argumente contre la purge de la table — l'argument est bon, mais l'écrasement par nom produit la même perte sur le sous-ensemble homonyme. Soit le nommer dans la modale, soit renommer en cas de collision.

  3. createPreMigrationBackup peut maintenant échouer là où il réussissait. Il vérifie sa sauvegarde en la relisant par parseImportedJson (categoryBackupService.ts:309), qui valide désormais. Un profil portant amount_mode = 'absolute_indicator' — accepté par le CHECK v17, refusé par AMOUNT_MODES — ne pourrait plus produire de sauvegarde pré-migration (verification_mismatch). Injoignable aujourd'hui par l'UI, mais le CHECK admet la valeur exprès pour que le 3e mode arrive sans migration : le jour où il arrive, la porte s'ouvre. L'export sort volontairement sans valider (pickFormatRow), mais cette relecture-là annule la propriété sur ce chemin précis.

  4. La liste blanche ne couvre que les 2 champs énumérés. Un .sref édité à la main avec skip_lines: null ou has_header: "oui" traverse la validation et se casse sur la contrainte SQLite — rollback propre, aucune perte, mais message brut. Cohérent avec le choix documenté de ne pas décoder column_mapping ; à considérer si le fichier doit être traité comme franchement hostile.

  5. La propriété « tout ou rien » est prouvée contre un FakeDb, pas contre SQLite. Le fake snapshote les tables au BEGIN et les restaure au ROLLBACK : ça prouve que runRestore émet le ROLLBACK, pas que SQLite défait les 6 DELETE. C'est le bon niveau pour un test unitaire et le fake est honnête (il modélise UNIQUE(name) et la branche ON CONFLICT), mais la garantie centrale de cette PR mériterait une vérification au niveau intégration.

  6. useDataExport.performExport interroge sources et modèles même en format === "csv", où serializeTransactionsToCsv les jette. Deux allers-retours DB pour rien (useDataExport.ts:65-70).

  7. Note préexistante, pas un défaut de cette PR : serialize() dans db.ts:56-66 contourne le lock FIFO tant que inTransaction est vrai. Tout appel DB émis depuis l'extérieur pendant la restauration s'exécuterait donc directement sur le pool et pourrait forcer sqlx à ouvrir une 2e connexion, séparant les statements suivants de la restauration de sa transaction ouverte. Inoffensif aux sites existants (transactions courtes) ; ici la transaction couvre la réécriture du profil entier. Vérifié : aucun poller de fond ne touche la DB aujourd'hui, donc rien ne le déclenche — à garder en tête si l'un apparaît.


Récapitulatif : les 6 tâches et les 2 critères d'acceptation de #331 sont couverts, y compris les 3 corrections de /review-spec (transaction, ordre modèles→sources + upsert, liste blanche avec message lisible). Les 7 points ci-dessus sont du durcissement et du suivi, pas des conditions de merge.

## Revue adversariale — PR #341 **Verdict : APPROVE** — aucun blocage. > **Note de contexte** : cette PR a été récupérée après un run interrompu. Le worker a été tué par une limite de session pendant sa passe de validation finale, après avoir écrit le code mais avant de committer ; son journal de décisions ne couvre que la préparation de la branche, donc le corps de la PR est une **reconstruction à partir du diff**, pas un témoignage. Chaque affirmation du corps a donc été revérifiée dans le code plutôt que crue sur parole. C'est aussi pourquoi cette revue est plus longue que celles des autres maillons de la pile. --- ## Résumé L'export SREF transporte maintenant `import_sources` et `import_config_templates`, la restauration les remet en place, et les deux chemins destructifs passent enfin par une transaction. Les 6 affirmations du corps ont été vérifiées une à une dans le code, plus les 6 pièges listés au cahier de revue. **Elles tiennent toutes.** Détail de la vérification ci-dessous, puis 7 suggestions non bloquantes. ### Ce qui a été vérifié (et tient) **1. Contrat `withTransaction` — respecté à la lettre.** `dataExportService.ts:417-436` (`runRestore`) suit exactement le contrat documenté dans `db.ts:80-104` : `withTransaction` ne garantit que l'exclusivité du lock, l'appelant émet `BEGIN`/`COMMIT`/`ROLLBACK`. C'est l'idiome des sites existants (`balance.service.ts:1608-1611`, `categoryMigrationService.ts:205`). Le `BEGIN` est **hors** du `try` : un échec de `BEGIN` ne déclenche pas de `ROLLBACK` fantôme. Le drapeau `open` reste `true` pendant le `COMMIT`, donc un `COMMIT` qui échoue déclenche bien un `ROLLBACK`. Un `ROLLBACK` qui échoue est avalé et l'erreur d'origine préservée — correct, c'est elle qui explique la panne. **2. Aucun `withTransaction` imbriqué.** Les 3 fonctions d'import ont exactement 2 appelants hors tests — `useDataImport.ts:185-191` et `categoryRestoreService.ts:255` — aucun des deux n'est dans une transaction. `categoryMigrationService` crée sa sauvegarde **avant** d'ouvrir la sienne (`:180-190`). Aucune fonction du corps (`restoreCategories`, `restoreSuppliers`, `restoreKeywords`, `restoreImportTemplates`, `restoreImportSources`, `attachTransactions`) n'appelle `getDb()` : toutes reçoivent le handle en paramètre. Pas de seconde connexion ouverte depuis l'intérieur. **3. Échec à mi-restauration → profil intact.** Les 6 `DELETE FROM` sont bien **à l'intérieur** du `runRestore` dans les deux chemins (`:568-573` et `:614-616`), pas avant. La liste blanche, elle, est appelée **avant** `runRestore` (`:559` et `:607`) : un fichier refusé n'ouvre même pas de transaction — le test `:685` l'assert littéralement (`expect(db.log).not.toContain("BEGIN")`). **4. Rétrocompatibilité — le chemin sans version est bien l'ancien.** `parseImportedJson` garde son contrôle `!envelope.data` **avant** de toucher aux nouveaux tableaux, donc pas de `TypeError` sur un fichier tronqué. `validateImportedFormatRows(undefined, undefined)` est un no-op (`?? []`). Fichier v1 → tableaux absents → rien restauré → source hôte créée. Un seul **écart de comportement assumé** : la source « Data Import » et sa ligne `imported_files` ne sont plus créées quand le fichier ne porte **aucune** transaction (`attachTransactions:483` sort tôt). C'est un mieux, mais c'est bien un écart vs. l'ancien code. **5. Remap `template_id` — pas de liaison silencieusement fausse.** Un `template_name` sans correspondance retombe sur `NULL` (`:460-462`), jamais sur un modèle local homonyme : la Map ne contient que les noms **du fichier**. Le sens dangereux (accrocher un modèle local différent) est donc fermé. Reste le sens inverse, cf. suggestion 2. **6. La source « Data Import » n'atteint plus le codec avec `'{}'`.** `HOST_SOURCE_MAPPING` = `{date:0,description:1,amount:2}`, et c'est **exact** : `serializeTransactionsToCsv:228-238` émet les colonnes dans cet ordre, `Papa.unparse` sépare par virgule, `t.date` est en ISO, les dépenses sont négatives — le `delimiter: ","` / `has_header: 1` / `skip_lines: 0` / `%Y-%m-%d` / `negative_expense` insérés décrivent réellement ce fichier. `formatFromRow` l'accepte (les deux index sont numériques). **7. Chiffrement — transparent.** `write_export_file` / `read_import_file` (`export_import_commands.rs:110-137`) traitent le contenu comme une chaîne UTF-8 opaque : chiffrement AES-256-GCM des octets, aucun parsing de schéma côté Rust. Ajouter des tableaux à l'enveloppe n'interagit pas avec ce chemin. Le round-trip de `createPreMigrationBackup` (sha256 avant/après) reste valide. **8. Pas de valeur `NULL` capable de faire refuser une sauvegarde légitime.** Point qui aurait pu être un blocage : l'ancien restore insérait ses sources **sans** `amount_mode` ni `sign_convention`. Vérifié en base — les deux colonnes sont `NOT NULL DEFAULT 'single'` / `'negative_expense'` sur `import_sources` (v17) **et** sur `import_config_templates` depuis leur création en v5. Les sources écrites par l'ancien code retombent donc dans la liste blanche. Seul `absolute_indicator` (admis par le `CHECK` v17, refusé par `AMOUNT_MODES`) peut échouer, et aucune UI ne l'écrit — cf. suggestion 4. **9. Reste.** `ImportSummary` gagne 3 champs requis mais n'est construit qu'aux 2 sites mis à jour (`parseImportedJson`, `parseImportedCsv`) — aucun autre littéral à corriger. i18n : les 5 clés existent dans `fr.json` **et** `en.json`, les deux fichiers restent du JSON valide, et il n'y a pas de collision avec les clés préexistantes de l'assistant (elles vivent sous `import.errors` racine, pas sous `settings.dataManagement.import.errors`). Aucune migration touchée, aucun secret, logique dans `services/`, commentaires en anglais, aucun `.skip`/`.only`. CI verte sur `f377d76`. CHANGELOG et `docs/architecture.md` absents **par construction** : #332 est le maillon docs+changelog de la milestone. --- ## Suggestions (aucune bloquante) 1. **`format_version` est écrit mais ne sert à rien en lecture.** Il est stampé, remonté dans le résumé, testé — et jamais comparé. Un fichier `format_version: 3` produit par une version future serait accepté en silence par ce build, avec la sémantique v2. Refuser `format_version > SREF_FORMAT_VERSION` avec un message dédié ferait faire au champ le travail pour lequel il a été ajouté. (`dataExportService.ts:331`) 2. **L'upsert de modèle écrase un modèle local homonyme au contenu différent.** `import_config_templates` n'est dans aucune liste de purge, donc restaurer dans un profil existant remplace le format d'un « Desjardins EOP » local par celui du fichier, sans que rien ne le dise : la modale compte désormais les modèles **entrants** mais ne signale pas que les homonymes existants sont remplacés. Le commentaire de `restoreImportTemplates:1380-1387` argumente contre la purge de la table — l'argument est bon, mais l'écrasement par nom produit la même perte sur le sous-ensemble homonyme. Soit le nommer dans la modale, soit renommer en cas de collision. 3. **`createPreMigrationBackup` peut maintenant échouer là où il réussissait.** Il vérifie sa sauvegarde en la relisant par `parseImportedJson` (`categoryBackupService.ts:309`), qui valide désormais. Un profil portant `amount_mode = 'absolute_indicator'` — accepté par le `CHECK` v17, refusé par `AMOUNT_MODES` — ne pourrait plus produire de sauvegarde pré-migration (`verification_mismatch`). Injoignable aujourd'hui par l'UI, mais le `CHECK` admet la valeur **exprès** pour que le 3e mode arrive sans migration : le jour où il arrive, la porte s'ouvre. L'export sort volontairement sans valider (`pickFormatRow`), mais cette relecture-là annule la propriété sur ce chemin précis. 4. **La liste blanche ne couvre que les 2 champs énumérés.** Un `.sref` édité à la main avec `skip_lines: null` ou `has_header: "oui"` traverse la validation et se casse sur la contrainte SQLite — rollback propre, aucune perte, mais message brut. Cohérent avec le choix documenté de ne pas décoder `column_mapping` ; à considérer si le fichier doit être traité comme franchement hostile. 5. **La propriété « tout ou rien » est prouvée contre un FakeDb, pas contre SQLite.** Le fake snapshote les tables au `BEGIN` et les restaure au `ROLLBACK` : ça prouve que `runRestore` **émet** le `ROLLBACK`, pas que SQLite défait les 6 `DELETE`. C'est le bon niveau pour un test unitaire et le fake est honnête (il modélise `UNIQUE(name)` et la branche `ON CONFLICT`), mais la garantie centrale de cette PR mériterait une vérification au niveau intégration. 6. **`useDataExport.performExport` interroge sources et modèles même en `format === "csv"`**, où `serializeTransactionsToCsv` les jette. Deux allers-retours DB pour rien (`useDataExport.ts:65-70`). 7. **Note préexistante, pas un défaut de cette PR** : `serialize()` dans `db.ts:56-66` contourne le lock FIFO tant que `inTransaction` est vrai. Tout appel DB émis **depuis l'extérieur** pendant la restauration s'exécuterait donc directement sur le pool et pourrait forcer sqlx à ouvrir une 2e connexion, séparant les statements suivants de la restauration de sa transaction ouverte. Inoffensif aux sites existants (transactions courtes) ; ici la transaction couvre la réécriture du profil entier. Vérifié : aucun poller de fond ne touche la DB aujourd'hui, donc rien ne le déclenche — à garder en tête si l'un apparaît. --- **Récapitulatif** : les 6 tâches et les 2 critères d'acceptation de #331 sont couverts, y compris les 3 corrections de `/review-spec` (transaction, ordre modèles→sources + upsert, liste blanche avec message lisible). Les 7 points ci-dessus sont du durcissement et du suivi, pas des conditions de merge.
Author
Owner

Mergée dans main en fast-forward avec le reste de la pile (tip 37b832e).

Forgejo ne détecte pas un merge local comme merged — la PR est donc fermée à la main, et l'issue liée s'est fermée automatiquement via son Resolves #N.

Tip cumulé validé avant push : 1181 vitest, 111 tests Rust, build tsc + vite. La CI ne tourne pas sur push main, cette validation locale était donc le seul filet.

Mergée dans `main` en fast-forward avec le reste de la pile (tip `37b832e`). Forgejo ne détecte pas un merge local comme *merged* — la PR est donc fermée à la main, et l'issue liée s'est fermée automatiquement via son `Resolves #N`. Tip cumulé validé avant push : **1181 vitest**, **111 tests Rust**, build tsc + vite. La CI ne tourne pas sur push `main`, cette validation locale était donc le seul filet.
maximus closed this pull request 2026-08-14 16:14:02 +00:00
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#341
No description provided.