feat(reports): getCategoryOverTime -> arbre tendances (sidecar id-keyed) + resultats (#264) #268

Closed
maximus wants to merge 1 commit from issue-264-overtime-tree into issue-263-generic-tree-builder
Owner

Resolves #264

Generated autonomously by /autopilot run of 2026-07-07

Base: #263 (generic leaf-driven tree builder). Stacked PR — review/merge #263 first.

What

Sidecar approach (resolves the 3 review caveats). getCategoryOverTime gains an id-keyed hierarchical tree: OverTimeRow[], built via the generic buildLeafDrivenTree (#263) as a new buildOverTimeTree specialisation — leaf-driven, income-first, no top-N / no "Other" on the tree path. The name-keyed pivot is left untouched, so the chart and the dashboard render identically.

computeOverTimeResults now consumes the tree leaves (never is_parent subtotals), grouped by each leaf's category_id-resolved type → section subtotals + Result-before/net lines are exact.

Pivot unchanged (proof)

  • getCategoryOverTime keeps the exact same top-N + "Other" pivot loop; data.data / categories / colors / categoryIds / types are byte-identical (only a sidecar tree field is added to the return).
  • CategoryOverTimeChart.tsx diff = comment only (no logic) → still reads the top-N-capped pivot, series stay bounded.
  • DashboardPage.tsx = not touched; useDashboard.ts / useTrends.ts diffs = only tree: [] added to the initial-state literal (the chart never reads tree).
  • Test getCategoryOverTime asserts the pivot still yields ["Food","Other"] while the tree carries every category.

OverTimeRow shape (for #265)

interface OverTimeRow {
  categoryId: number | null;
  categoryName: string;
  categoryColor: string;
  monthly: number[]; // positive magnitude per month, index-aligned to data.data
  total: number;
  parent_id: number | null;   // snake_case hierarchy block — mirrors CategoryDelta
  is_parent: boolean;
  depth: number;
  category_type: "expense" | "income" | "transfer";
}

Composes with collapsibleRows / useCollapsibleGroups (same snake_case fields as CategoryDelta) for the #265 hierarchy + collapse work.

Also

  • CategoryOverTimeTable renders the tree leaves (every category, id-safe) instead of the top-N pivot — delivers the "all categories in the table" decision. Hierarchy nesting + collapse + interleaved Result stay for #265.
  • SQL parameterised; the one added query (SELECT ... FROM categories) has no interpolation.

Decisions

  • [HIGH] Extracted buildOverTimeTree (exported) rather than inlining, so it is unit-tested directly like buildCompareTree and avoids a forward const reference.
  • [MEDIUM] computeOverTimeResults dropped its visibleCategories (name) param and the table dropped hiddenCategories: the table now shows all categories (per the #264 decision), and the chart keeps its own name-based hide. No visible regression (they are never shown together; toggled by view mode).
  • [MEDIUM] A->B->A parent_id cycle is root-less under the builder → returns empty (terminates) rather than throwing; the test asserts .not.toThrow().

Verification

  • npm run build green (tsc + vite), tsc --noEmit clean.
  • npm test720 passed (713 baseline + 7). New: homonym-no-collision, leaves-only-not-parents, buildOverTimeTree suite (nesting / income-first / grand-total invariance / orphan / cycle), pivot-stays-top-N + tree-has-all-categories.
  • No DB migration (v1->v16 unchanged).
Resolves #264 Generated autonomously by /autopilot run of 2026-07-07 Base: #263 (generic leaf-driven tree builder). Stacked PR — review/merge #263 first. ## What Sidecar approach (resolves the 3 review caveats). `getCategoryOverTime` gains an id-keyed hierarchical `tree: OverTimeRow[]`, built via the generic `buildLeafDrivenTree` (#263) as a new `buildOverTimeTree` specialisation — leaf-driven, income-first, **no top-N / no "Other"** on the tree path. The name-keyed pivot is left untouched, so the chart and the dashboard render identically. `computeOverTimeResults` now consumes the **tree leaves** (never `is_parent` subtotals), grouped by each leaf's `category_id`-resolved type → section subtotals + Result-before/net lines are exact. ## Pivot unchanged (proof) - `getCategoryOverTime` keeps the exact same top-N + "Other" pivot loop; `data.data / categories / colors / categoryIds / types` are byte-identical (only a sidecar `tree` field is added to the return). - `CategoryOverTimeChart.tsx` diff = **comment only** (no logic) → still reads the top-N-capped pivot, series stay bounded. - `DashboardPage.tsx` = **not touched**; `useDashboard.ts` / `useTrends.ts` diffs = only `tree: []` added to the initial-state literal (the chart never reads `tree`). - Test `getCategoryOverTime` asserts the pivot still yields `["Food","Other"]` while the tree carries every category. ## OverTimeRow shape (for #265) ```ts interface OverTimeRow { categoryId: number | null; categoryName: string; categoryColor: string; monthly: number[]; // positive magnitude per month, index-aligned to data.data total: number; parent_id: number | null; // snake_case hierarchy block — mirrors CategoryDelta is_parent: boolean; depth: number; category_type: "expense" | "income" | "transfer"; } ``` Composes with `collapsibleRows` / `useCollapsibleGroups` (same snake_case fields as `CategoryDelta`) for the #265 hierarchy + collapse work. ## Also - `CategoryOverTimeTable` renders the tree leaves (**every** category, id-safe) instead of the top-N pivot — delivers the "all categories in the table" decision. Hierarchy nesting + collapse + interleaved Result stay for #265. - SQL parameterised; the one added query (`SELECT ... FROM categories`) has no interpolation. ## Decisions - [HIGH] Extracted `buildOverTimeTree` (exported) rather than inlining, so it is unit-tested directly like `buildCompareTree` and avoids a forward `const` reference. - [MEDIUM] `computeOverTimeResults` dropped its `visibleCategories` (name) param and the table dropped `hiddenCategories`: the table now shows **all** categories (per the #264 decision), and the chart keeps its own name-based hide. No visible regression (they are never shown together; toggled by view mode). - [MEDIUM] A->B->A parent_id cycle is root-less under the builder → returns empty (terminates) rather than throwing; the test asserts `.not.toThrow()`. ## Verification - `npm run build` green (tsc + vite), `tsc --noEmit` clean. - `npm test` → **720 passed** (713 baseline + 7). New: homonym-no-collision, leaves-only-not-parents, `buildOverTimeTree` suite (nesting / income-first / grand-total invariance / orphan / cycle), pivot-stays-top-N + tree-has-all-categories. - No DB migration (v1->v16 unchanged).
maximus added 1 commit 2026-07-07 23:31:50 +00:00
Adds an id-keyed hierarchical `tree: OverTimeRow[]` sidecar to
getCategoryOverTime, built via the generic buildLeafDrivenTree (#263) as
buildOverTimeTree — leaf-driven, income-first, no top-N / no "Other" bucket.
The name-keyed pivot (data/categories/colors/types/categoryIds) is left
BYTE-IDENTICAL, so the Trends chart and the dashboard render unchanged.

computeOverTimeResults now consumes the id-keyed tree LEAVES (never the
`is_parent` subtotals) grouped by each leaf's category_id-resolved type, so
the section subtotals and the Result-before/net lines are exact: two homonym
categories of different types no longer collide, and a non-top-N income or
transfer category is no longer lumped into "Other" as an expense.

CategoryOverTimeTable renders the tree leaves (every category, id-safe)
instead of the top-N pivot; the chart keeps reading the top-N-capped pivot so
its stacked series stay bounded. OverTimeRow mirrors CategoryDelta's snake_case
hierarchy block (parent_id/is_parent/depth/category_type) so it composes with
collapsibleRows / useCollapsibleGroups for the #265 hierarchy work.

Tests: rewrote overTimeResults.test.ts onto the tree (homonym regression +
leaves-only-not-parents); added buildOverTimeTree suite (nesting, income-first,
grand-total invariance, orphan, A->B->A cycle depth guard) and a
getCategoryOverTime tree-wiring test proving the pivot stays top-N+Other while
the tree carries every category. 720 vitest green, build + tsc green.

Resolves #264

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
maximus added the
autopilot:pending-human
type:feature
labels 2026-07-07 23:31:58 +00:00
Author
Owner

/pr-review — PR #268 : arbre tendances id-keyed (sidecar) + résultats (#264)

Verdict : APPROVE

Résumé

Le sidecar tree: OverTimeRow[] est ajouté à getCategoryOverTime sans toucher au pivot name-keyed (chart + dashboard inchangés, byte-identiques), et computeOverTimeResults consomme désormais les feuilles de l'arbre (id-keyed, toutes catégories) → sous-totaux et lignes de Résultat exacts, homonymes séparés. Implémentation propre, bien testée, aucune migration DB.

Points vérifiés

  • Pivot inchangé : la boucle de pivot est identique ; data/categories/colors/categoryIds/types restent byte-identiques, seul tree s'ajoute au retour. CategoryOverTimeChart = diff commentaire seul. Prouvé par le test « pivot stays top-N + tree has all categories » (categories === ["Food","Other"], data[0] === {Food:300, Other:150}).
  • Flux de données sûr : tree est un champ requis de CategoryOverTimeData ; les deux seuls littéraux de construction (useTrends/useDashboard initial state) reçoivent tree: [], et les reducers propagent le action.payload complet → jamais data.tree === undefined au rendu (sinon for (const row of data.tree) planterait). tsc couvre tout site oublié.
  • Arithmétique exacte : computeOverTimeResults bucketise les feuilles par category_type résolu par id (jamais par nom) et exclut les is_parent → pas de double comptage parent/enfant, homonymes de types différents séparés, et un « petit » revenu/transfert n'est plus compté en dépense. Invariance grand-total (feuilles vs plat) testée.
  • buildOverTimeTree : spécialisation fidèle de buildLeafDrivenTree (#263), miroir de buildCompareTreemonthly[] index-aligné sur monthCount, garde de cycle MAX_TREE_DEPTH, tri income→expense→transfer via COMPARE_TYPE_ORDER. Cycle A→B→A → root-less → retourne [] (termine, ne throw pas), testé .not.toThrow().
  • Sécurité : la nouvelle requête SELECT id,name,color,type,parent_id FROM categories n'a aucune interpolation ; le pivot reste paramétré $n. Aucun secret.
  • Tests / conventions : 6 nouveaux tests pertinents (homonymes, feuilles-seulement, invariance, orphelin null-id, cycle, pivot-préservé). Les régressions homonyme/double-count échouent sans le fix (net = 0 au lieu de 700) → vrais tests de régression. CHANGELOG EN + FR mis à jour sous [Unreleased]/[Non publié]. Commit unique, format conventional.

Suggestions (non bloquantes)

  1. Le dashboard paie l'arbre qu'il n'utilise pas : useDashboard appelle getCategoryOverTime dans un Promise.all, donc chaque chargement du tableau de bord exécute maintenant le SELECT ... FROM categories + buildOverTimeTree alors que le chart du dashboard ne lit jamais tree. Coût négligeable (petite table catégories), mais c'est du travail inutile ; un flag opt-in (includeTree) l'éviterait si ça devenait sensible.
  2. hiddenCategories n'affecte plus le tableau : masquer des catégories en vue chart puis basculer en vue tableau les réaffiche toutes. C'est la décision assumée de #264 (le tableau montre toutes les catégories), donc OK — simplement à garder en tête côté UX (l'état de masquage est partagé entre les deux vues).
  3. Hiérarchie/collapse différés à #265 : le tableau affiche pour l'instant les feuilles à plat par section (+ sous-totaux + lignes de Résultat). Intentionnel — la PR #269 empilée par-dessus ajoute l'imbrication + collapse + Résultat interleavé.

Note de merge (pile)

check.yml ne se déclenche que sur les PR vers main : ce maillon (base issue-263) n'est pas CI-validé. Valider le tip cumulé en local avant merge (tsc + vite build, vitest, cargo check/test) — le corps de PR annonce build vert + 720 vitest ; à confirmer sur le tip empilé (#267#268, voire #269).


Review adversariale — analyse statique du diff head-vs-base (#268) + lecture de buildLeafDrivenTree (#263) et des reducers/consommateurs. Suite non exécutée localement (contexte review).

## /pr-review — PR #268 : arbre tendances id-keyed (sidecar) + résultats (#264) **Verdict : APPROVE** ### Résumé Le sidecar `tree: OverTimeRow[]` est ajouté à `getCategoryOverTime` sans toucher au pivot name-keyed (chart + dashboard inchangés, byte-identiques), et `computeOverTimeResults` consomme désormais les feuilles de l'arbre (id-keyed, toutes catégories) → sous-totaux et lignes de Résultat exacts, homonymes séparés. Implémentation propre, bien testée, aucune migration DB. ### Points vérifiés - **Pivot inchangé** : la boucle de pivot est identique ; `data`/`categories`/`colors`/`categoryIds`/`types` restent byte-identiques, seul `tree` s'ajoute au retour. `CategoryOverTimeChart` = diff commentaire seul. Prouvé par le test « pivot stays top-N + tree has all categories » (`categories === ["Food","Other"]`, `data[0] === {Food:300, Other:150}`). - **Flux de données sûr** : `tree` est un champ requis de `CategoryOverTimeData` ; les deux seuls littéraux de construction (`useTrends`/`useDashboard` initial state) reçoivent `tree: []`, et les reducers propagent le `action.payload` complet → jamais `data.tree === undefined` au rendu (sinon `for (const row of data.tree)` planterait). `tsc` couvre tout site oublié. - **Arithmétique exacte** : `computeOverTimeResults` bucketise les feuilles par `category_type` résolu **par id** (jamais par nom) et exclut les `is_parent` → pas de double comptage parent/enfant, homonymes de types différents séparés, et un « petit » revenu/transfert n'est plus compté en dépense. Invariance grand-total (feuilles vs plat) testée. - **`buildOverTimeTree`** : spécialisation fidèle de `buildLeafDrivenTree` (#263), miroir de `buildCompareTree` — `monthly[]` index-aligné sur `monthCount`, garde de cycle `MAX_TREE_DEPTH`, tri income→expense→transfer via `COMPARE_TYPE_ORDER`. Cycle A→B→A → root-less → retourne `[]` (termine, ne throw pas), testé `.not.toThrow()`. - **Sécurité** : la nouvelle requête `SELECT id,name,color,type,parent_id FROM categories` n'a aucune interpolation ; le pivot reste paramétré `$n`. Aucun secret. - **Tests / conventions** : 6 nouveaux tests pertinents (homonymes, feuilles-seulement, invariance, orphelin null-id, cycle, pivot-préservé). Les régressions homonyme/double-count échouent sans le fix (net = 0 au lieu de 700) → vrais tests de régression. CHANGELOG EN + FR mis à jour sous [Unreleased]/[Non publié]. Commit unique, format conventional. ### Suggestions (non bloquantes) 1. **Le dashboard paie l'arbre qu'il n'utilise pas** : `useDashboard` appelle `getCategoryOverTime` dans un `Promise.all`, donc chaque chargement du tableau de bord exécute maintenant le `SELECT ... FROM categories` + `buildOverTimeTree` alors que le chart du dashboard ne lit jamais `tree`. Coût négligeable (petite table catégories), mais c'est du travail inutile ; un flag opt-in (`includeTree`) l'éviterait si ça devenait sensible. 2. **`hiddenCategories` n'affecte plus le tableau** : masquer des catégories en vue chart puis basculer en vue tableau les réaffiche toutes. C'est la décision assumée de #264 (le tableau montre **toutes** les catégories), donc OK — simplement à garder en tête côté UX (l'état de masquage est partagé entre les deux vues). 3. **Hiérarchie/collapse différés à #265** : le tableau affiche pour l'instant les feuilles **à plat** par section (+ sous-totaux + lignes de Résultat). Intentionnel — la PR #269 empilée par-dessus ajoute l'imbrication + collapse + Résultat interleavé. ### Note de merge (pile) `check.yml` ne se déclenche que sur les PR vers `main` : ce maillon (base `issue-263`) n'est pas CI-validé. Valider le tip cumulé en local avant merge (tsc + vite build, vitest, cargo check/test) — le corps de PR annonce build vert + **720 vitest** ; à confirmer sur le tip empilé (#267 → #268, voire #269). --- *Review adversariale — analyse statique du diff head-vs-base (#268) + lecture de `buildLeafDrivenTree` (#263) et des reducers/consommateurs. Suite non exécutée localement (contexte review).*
Author
Owner

Mergée localement dans main (pile #266/#267/#268/#269, tip fe87313). Fermée manuellement — merge local d'une pile stackée (l'API merge Forgejo échoue sur les bases non-main), tip cumulé validé build + 728 vitest verts.

Mergée localement dans `main` (pile #266/#267/#268/#269, tip `fe87313`). Fermée manuellement — merge local d'une pile stackée (l'API merge Forgejo échoue sur les bases non-`main`), tip cumulé validé build + 728 vitest verts.
maximus closed this pull request 2026-07-08 00:34:04 +00:00

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#268
No description provided.