feat(reports): réel-vs-réel = analyse de résultat + défaut mois précédent #255
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#255
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-253-analyse-resultat"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Resolves #253.
Le rapport « réel vs réel » était un rapport de dépenses (
WHERE amount < 0) : les revenus n'apparaissaient pas, impossible de juger surplus vs déficit. Il devient une analyse de résultat (le nom du produit).Changement 1 — analyse de résultat
COMPARE_DELTA_SQL:WHERE+CASEpar type élargis àincomeen SUM signé (commetransfer). Dépenses inchangées (ABS des sorties), netting transferts #243 intact.COMPARE_TYPE_ORDER).ComparePeriodTable: le grand total plat (devenu faux dès qu'on mélange revenus + dépenses ABS) est remplacé par les 2 lignes de résultat, extraites dans un module pur testécompareResults.ts. Couleurs type-aware : hausse de revenu/résultat = vert, de dépense = rouge ; les montants de résultat sont colorés par signe (surplus vert / déficit rouge).ComparePeriodChartreste une vue de dépenses : filtré aux feuillesexpensepour ne pas mélanger des barres de revenu.Changement 2 — défaut mois précédent
useCompare: la synchro initiale sur letodeuseReportsPeriod(= 31 déc, année civile) écrasait le défaut mois-précédent deinitialState→ le compare ouvrait sur décembre (mois futur, vide). La 1ʳᵉ passe de l'effet est désormais sautée ; seules les navigations utilisateur synchronisent ensuite.Divers
.gitignore: la règlereports/(scratch autopilot) était non ancrée et ignorait silencieusement les nouveaux fichiers soussrc/components/reports/. Ancrée à/reports/.Tests
compareResults.test.ts: roll-up résultat, transfert déséquilibré à une jambe, déficit, exclusion des sous-totaux.reportService.test.ts: SQL élargi (sous-chaînes), règle bucket revenu, ordre de sections revenus-d'abord.Suivi
Revue /pr-review — REQUEST_CHANGES
Résumé : le Changement 1 (analyse de résultat) est solide — SQL correct, dépenses byte-identiques, netting #243 intact, module pur
compareResults.tsbien testé, i18n FR/EN complet, et le fix.gitignore(/reports/ancré) est nécessaire et juste (sans lui les nouveaux fichiers soussrc/components/reports/étaient silencieusement ignorés). Un point bloquant sur le Changement 2 : le garde « sauter la 1ʳᵉ passe » est défait parReact.StrictModeen dev et réintroduit le bug qu'il corrige.Point bloquant
1.
src/hooks/useCompare.ts:130-142— le gardedidInitPeriodSync(skip-first) est non idempotent sous StrictMode → le compare rouvre sur décembre en devL'app est montée sous
<React.StrictMode>(src/main.tsx:13), qui double-invoque les effets au montage en dev (setup → cleanup → setup). Le ref n'est pas réinitialisé entre les deux :didInitPeriodSync.current= false → passe à true →return(sauté).didInitPeriodSync.current= déjà true → ne saute pas → parseto=2026-12-31→dispatch(SET_REFERENCE_PERIOD {2026, 12}).Résultat : en dev, la référence est écrasée vers décembre (mois futur, vide) — exactement le bug que le Changement 2 vise à corriger. Se manifeste ~11 mois sur 12 (sauf en janvier, où le mois précédent = décembre). La prod (build Tauri, pas de double-invoke StrictMode) est correcte, donc l'utilisateur final n'est pas touché — mais la vérification manuelle en
tauri devle sera, et l'affirmation du corps de PR (« la 1ʳᵉ passe de l'effet est désormais sautée ») est fausse en dev.À noter : les deux autres gardes-ref du code (
duplicateCheckDonedansuseCategories.ts,planLoadedRefdansCategoriesMigrationPage.tsx) sont des gardes run-once — StrictMode-safe car le garde empêche la ré-exécution. Le pattern skip-first est l'inverse et casse sous StrictMode.Fix robuste — garder sur un changement de
toplutôt que sur un booléen :Sous StrictMode : setup #1
to === lastSyncedTo→ return ; setup #2 idem → return. Les navigations utilisateur (changement réel deto) déclenchent la sync ensuite. Le comportement de cet effet n'est couvert par aucun test (useCompare.test.tsne teste que les helpers purs) — un test RTL avecMemoryRouterverrouillerait le défaut mois-précédent.Suggestions (non bloquant)
reports.compare.totalRow(en.json/fr.json:430) n'est plus référencée depuis la suppression de la ligne « grand total ». À retirer.hasTransfersnon consommé côté UI :computeResultsle calcule et le teste, mais la table décide du rendu viatransferSection. Champ mort côté UI (ou brancher la table dessus pour une seule source de vérité).ComparePeriodChartfiltre aux feuillesexpense; une période 100 % revenus/transferts afficherait un graphe sans barres (axes seuls) alors que la table, elle, a des données. Cas rare, purement cosmétique.Revue adversariale automatisée. La logique métier (SQL income-statement, roll-up des résultats, sections) est correcte et bien couverte ; le seul blocage est la robustesse StrictMode de l'effet de sync.
Point bloquant corrigé en
d57b256.StrictMode : le garde booléen « skip-first » n'était pas idempotent sous le double-invoke des effets en dev — le flag flippait au setup #1, donc le setup #2 re-synchronisait la référence sur décembre (le bug même du Changement 2). Remplacé par un garde sur changement de valeur : un ref initialisé à la valeur de montage + une fonction pure
syncReferenceOnPeriodChange()qui ne dispatch que sitochange réellement. Idempotent sous le double-invoke, et testé (5 cas) — la décision étant pure, pas besoin de harness de rendu.Les 3 mineurs aussi :
reports.compare.totalRowretirée (2 locales) ;results.hasTransfers(auparavant calculé/testé mais non consommé) ;Build tsc+vite + 690 vitest verts.
Revue — PR #255 ·
feat(reports): réel-vs-réel = analyse de résultat + défaut mois précédentVerdict : REQUEST_CHANGES
Résumé
La refonte du rapport Comparaison en analyse de résultat est propre, bien découpée (module pur
compareResults.ts) et bien testée. Mais élargirCOMPARE_DELTA_SQLpour faire remonter les revenus a un effet de bord non suivi :getCartesSnapshotpartage cette requête, donc les catégories de revenu s'infiltrent désormais dans la carte « top mouvements » du tableau de bord Cartes — où le code couleur pensé pour des dépenses les représente à contresens.Problème bloquant
1.
src/services/reportService.ts:1177— les revenus polluent la carte « top mouvements » des CartesgetCartesSnapshot(ligne 1019) appellegetCompareMonthOverMonth(...)pour bâtirmomRows, puis en dérivetopMoversUp/topMoversDown(lignes 1177-1201) sans filtre de type :Avant cette PR, le
WHERE (t.amount < 0 OR type = 'transfer')excluait entièrement les catégories de revenu deCOMPARE_DELTA_SQL(transactions positives, typeincome) : la carte était de fait dépenses-seulement (les transferts nettent à ~0 → filtrés pardeltaAbs !== 0). Après la PR, les revenus reviennent en SUM signé positif, passent le filtresignificantMovers, et atterrissent dans les listes.Or
TopMoversList(src/components/reports/cards/TopMoversList.tsx:44) code la couleur pour des dépenses :Conséquence : une hausse de revenu (ex. « Salaire +2 000 $ +25 % ») s'affiche en rouge sous « Biggest increases / Catégories en hausse » comme une mauvaise nouvelle, et une baisse de revenu passe en vert dans « en baisse ». Le sens financier est inversé pour toute ligne de revenu, sur un écran hors du périmètre annoncé de la PR.
Ni testé, ni mentionné dans la PR : le test
reportService.cartes.test.ts« splits top movers by sign » mocke les réponses SQL avec desmomRowssans type de catégorie (la requêteCOMPARE_CATEGORIES_SQLn'est pas routée → tout devientexpensepar défaut), donc il reste vert et ne détecte pas la fuite. La régression est silencieuse.Correctif : filtrer les mouvements aux feuilles
expense, en miroir de ce que fait déjàComparePeriodChartdans cette même PR ((r.category_type ?? "expense") === "expense") :Ajouter un cas de test cartes qui route
COMPARE_CATEGORIES_SQLavec une catégorieincomeet vérifie qu'elle n'apparaît pas danstopMoversUp/Down.Points positifs
compareResults.tsextrait en module pur, arithmétique de roll-up cohérente (deltaAbs = current − previous→combineinterne consistant), bien couvert (transfert déséquilibré, déficit, exclusion des sous-totaux, YTD).syncReferenceOnPeriodChange: garde sur changement detoplutôt que flag fire-once → idempotent sous StrictMode, avec tests explicites (mount no-op, double-invoke, navigation,toillisible).resultBeforeTransfers/resultNetajoutées FR+EN,totalRowretirée des deux et plus référencée), CHANGELOG dans les deux langues..gitignore: ancragereports/→/reports/correct et nécessaire (le motif non ancré aurait silencieusement ignorécompareResults.tssoussrc/components/reports/; le fichier est bien tracké).Suggestions non bloquantes
renderResultRow/pct: quand un résultat traverse zéro (déficit → surplus),pct(delta, previous)divise parMath.abs(previous)et peut afficher un pourcentage sémantiquement bizarre (ex. −500 → +300 = +160 %). Pré-existant sur les sous-totaux, non bloquant — envisager un tiret—quandpreviousetcurrentsont de signes opposés.Régression Cartes corrigée en
c7914c2.En élargissant
COMPARE_DELTA_SQLpour faire remonter les revenus,getCartesSnapshot(carte « top mouvements ») les recevait aussi — et cette carte est une vue de dépenses (up = rouge), donc un salaire en hausse serait passé en rouge sous « biggest increases ». FiltresignificantMoversrestreint aux feuillesexpense((r.category_type ?? "expense") === "expense"), en miroir deComparePeriodChart. Sortie dépenses byte-identique à avant #253.Test de régression ajouté (
reportService.cartes.test.ts) : une catégorieincomeavec le plus gros delta ne doit PAS coiffer le top-movers — l'ancien test mockait le SQL sans type, d'où le trou silencieux.Vérif : seuls 2 consommateurs des fonctions compare (
useCompare— table montre les revenus, chart filtre ;getCartesSnapshot— maintenant filtré). Build + tests verts.Revue — PR #255 : réel-vs-réel = analyse de résultat
Verdict : APPROVE
Refonte propre et bien testée. Le rapport « réel vs réel » passe d'un rapport de dépenses à une véritable analyse de résultat (revenus + dépenses + deux lignes de résultat), et le défaut d'ouverture bascule sur le mois précédent. Aucun changement Rust/DB (v1→v16 inchangées).
tscvert, 691 vitest verts en local, aucune régression détectée chez les autres consommateurs du service compare.Vérifications
COMPARE_DELTA_SQLélargit leCASE/WHEREàIN ('transfer', 'income')— littéral, paramétrage$1..$8intact, aucune injection. Dépenses inchangées (ABS des sorties), netting transferts #243 préservé.COMPARE_DELTA_SQLn'est consommé que pargetCompareMonthOverMonth/YoY. Les deux vues qui reçoivent ces lignes filtrent maintenantcategory_type === 'expense'— Cartes top movers (significantMovers, régression corrigée + test dédié) etComparePeriodChart.getHighlightsa sa propre requête, non touchée.computeResultsrepose surdeltaAbs = current − previous(cohérent leaf / subtotal / factory de test) ;combinecomposante-à-composante gardemonthDelta = monthCurrent − monthPrevious.sumLeavesexclutis_parent→ pas de double comptage. Lepctà dénominateurabs(previous)est en fait le bon choix pour les lignes de résultat (un résultat précédent négatif ne fausse pas le signe du %).syncReferenceOnPeriodChangegate sur un changement deto(ref seedé autoinitial) → no-op au montage, idempotent sous StrictMode.toest dérivé synchroniquement des query params (useReportsPeriod,useMemo), donc pas de changement post-montage parasite : le défaut mois-précédent survit.resultBeforeTransfers/resultNetajoutés FR+EN ;totalRowretiré des deux (plus aucune référence — vérifié). Aucune chaîne en dur.reports/→/reports/— correction réelle et nécessaire : la règle non ancrée aurait ignoré silencieusement les nouveaux fichiers soussrc/components/reports/.Resolves #253présent.Observations (non bloquantes)
Rien de bloquant.
Mergée localement dans
main(merge commit1360940). Fermée manuellement — un merge local ne marque pas la PR « merged » côté Forgejo.Pull request closed