fix(reports): render category combobox in hierarchical DFS order (#126) #134

Merged
maximus merged 1 commit from issue-126-category-combobox-presentation into main 2026-04-22 01:09:36 +00:00
Owner

Fixes #126

Root cause

The by-category report (/reports/category) pulls categories via getAllCategoriesWithCounts() (in src/services/categoryService.ts), whose SQL ends with ORDER BY c.sort_order, c.name. That is a global sort — two distinct roots that share sort_order=1 would be followed by their respective sub-trees, all bucketed by depth-agnostic sort_order. The combobox was then applying an indent proportional to each row's depth, producing exactly what the user reported: indentation that doesn't follow the visual order, and parents / children from different sub-trees sitting side by side.

Fix

  • New pure helper sortHierarchical(categories, resolveName) exported from src/components/shared/CategoryCombobox.tsx. It re-orders the flat list into a DFS walk: for each group of siblings (same parent_id), sort by sort_order ascending then localized display name, then emit the parent immediately followed by its whole sub-tree.
  • Orphans (category whose parent is missing or filtered out) are appended at the end as pseudo-roots so nothing silently disappears.
  • The combobox now calls the helper inside a useMemo keyed on (categories, displayName) and uses the ordered list both for rendering and for the accent-insensitive filter.
  • Filtering behaviour, keyboard navigation and the depth-based indent are all unchanged.

Coverage

  • src/components/shared/CategoryCombobox.test.ts — 7 unit tests on sortHierarchical:
    • empty list,
    • single root + children,
    • the exact bug-reproducing case (feeds the helper a list pre-sorted the SQL way and asserts the DFS output),
    • sort_order + name tiebreak on siblings,
    • 3-level hierarchy (Dépenses → Assurances → Assurance-auto),
    • orphans (parent not in set) pushed to the end,
    • idempotence (sort of a sorted list is the same list).

Verification

  • npx tsc --noEmit — clean
  • npx vitest run — 23 files / 338 tests pass
  • npm run build — green

Changelog

Entry added under ## [Unreleased] / ### Fixed in both CHANGELOG.md and CHANGELOG.fr.md.

Scope note

The fix is isolated to CategoryCombobox (consumer-side sort) rather than changing the SQL query. That keeps other callers of getAllCategoriesWithCounts unaffected and avoids coupling the SQL to a specific presentation order.

🤖 Generated with Claude Code

Fixes #126 ## Root cause The by-category report (`/reports/category`) pulls categories via `getAllCategoriesWithCounts()` (in `src/services/categoryService.ts`), whose SQL ends with `ORDER BY c.sort_order, c.name`. That is a *global* sort — two distinct roots that share `sort_order=1` would be followed by their respective sub-trees, all bucketed by depth-agnostic `sort_order`. The combobox was then applying an indent proportional to each row's `depth`, producing exactly what the user reported: indentation that doesn't follow the visual order, and parents / children from different sub-trees sitting side by side. ## Fix - New pure helper `sortHierarchical(categories, resolveName)` exported from `src/components/shared/CategoryCombobox.tsx`. It re-orders the flat list into a DFS walk: for each group of siblings (same `parent_id`), sort by `sort_order` ascending then localized display name, then emit the parent immediately followed by its whole sub-tree. - Orphans (category whose parent is missing or filtered out) are appended at the end as pseudo-roots so nothing silently disappears. - The combobox now calls the helper inside a `useMemo` keyed on `(categories, displayName)` and uses the ordered list both for rendering and for the accent-insensitive filter. - Filtering behaviour, keyboard navigation and the depth-based indent are all unchanged. ## Coverage - `src/components/shared/CategoryCombobox.test.ts` — 7 unit tests on `sortHierarchical`: - empty list, - single root + children, - **the exact bug-reproducing case** (feeds the helper a list pre-sorted the SQL way and asserts the DFS output), - `sort_order` + name tiebreak on siblings, - 3-level hierarchy (Dépenses → Assurances → Assurance-auto), - orphans (parent not in set) pushed to the end, - idempotence (sort of a sorted list is the same list). ## Verification - `npx tsc --noEmit` — clean - `npx vitest run` — 23 files / 338 tests pass - `npm run build` — green ## Changelog Entry added under `## [Unreleased] / ### Fixed` in both `CHANGELOG.md` and `CHANGELOG.fr.md`. ## Scope note The fix is isolated to `CategoryCombobox` (consumer-side sort) rather than changing the SQL query. That keeps other callers of `getAllCategoriesWithCounts` unaffected and avoids coupling the SQL to a specific presentation order. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 1 commit 2026-04-22 00:59:15 +00:00
fix(reports): render category combobox in hierarchical DFS order (#126)
All checks were successful
PR Check / rust (push) Successful in 22m7s
PR Check / frontend (push) Successful in 2m19s
PR Check / rust (pull_request) Successful in 21m37s
PR Check / frontend (pull_request) Successful in 2m14s
871768593d
The by-category report combobox (`/reports/category`) was showing its full
category list with scrambled indentation — parents from one sub-tree
interleaved with children from another. Root cause: `getAllCategoriesWithCounts`
returns rows via `ORDER BY sort_order, name`, which is a *global* sort; two
different roots with sort_order=1 would be followed by their respective
children in the same bucket, mixing depths together.

Add a pure `sortHierarchical(categories, resolveName)` helper in
`CategoryCombobox.tsx` that rebuilds the display order as a DFS walk of the
tree: each parent is emitted immediately followed by its descendants, with
siblings within a group sorted by `sort_order` then localized display name.
Orphans (parent filtered out or missing) are appended at the end so nothing
disappears. The helper runs client-side inside the combobox's `useMemo`, so
the fix is scoped to this component and doesn't affect other consumers of
`getAllCategoriesWithCounts`. Filtering on the input query remains unchanged.

Covered by 7 unit tests on the helper (empty list, single root, the exact
bug-reproducing scrambled case, sort_order + name tiebreak, 3-level
hierarchy, orphans, idempotence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Owner

Self-review — APPROVE

Verdict: APPROVE.

Correctness

  • sortHierarchical groups by parent_id, sorts each sibling bucket by (sort_order, localized name), then emits a DFS walk from roots. Parent always precedes its descendants, each sub-tree stays contiguous.
  • Cycle-safe via a visited set.
  • Orphans (parent filtered out / missing) are appended at the end as pseudo-roots so nothing silently disappears from the dropdown.
  • Idempotent: sorting an already-sorted list yields the same order (covered by test).

No regression

  • Filter predicate unchanged (accent-insensitive substring match), only its input changes from categories to orderedCategories — already-filtered items keep the correct parent-first order.
  • computeDepths and indent rendering are untouched; depths are keyed on cat.id so reordering is transparent to the indent logic.
  • Keyboard nav (totalItems, highlightIndex) uses the reordered filtered list seamlessly.
  • Other consumers of CategoryCombobox (TransactionFilterBar, TransactionTable, SplitAdjustmentModal) get the same DFS re-order for free; if their input was already ordered the idempotence guarantee applies.

i18n

  • No new user-visible strings. The helper reuses the existing displayName (which already resolves i18n_key via t(...) with name fallback).

Changelog

  • CHANGELOG.md## [Unreleased] / ### Fixed entry with (#126). ✓
  • CHANGELOG.fr.md## [Non publié] / ### Corrigé entry with (#126). ✓

CI gates

  • npx tsc --noEmit — clean
  • npx vitest run — 23 files / 338 tests pass (including the 7 new helper tests)
  • npm run build — green

Scope

  • Pure presentation-layer fix in the consumer component. SQL query in categoryService.getAllCategoriesWithCounts is untouched, avoiding coupling the service to any specific ordering.

Ready to merge.

## Self-review — APPROVE **Verdict:** APPROVE. ### Correctness - `sortHierarchical` groups by `parent_id`, sorts each sibling bucket by `(sort_order, localized name)`, then emits a DFS walk from roots. Parent always precedes its descendants, each sub-tree stays contiguous. - Cycle-safe via a `visited` set. - Orphans (parent filtered out / missing) are appended at the end as pseudo-roots so nothing silently disappears from the dropdown. - Idempotent: sorting an already-sorted list yields the same order (covered by test). ### No regression - Filter predicate unchanged (accent-insensitive substring match), only its input changes from `categories` to `orderedCategories` — already-filtered items keep the correct parent-first order. - `computeDepths` and indent rendering are untouched; depths are keyed on `cat.id` so reordering is transparent to the indent logic. - Keyboard nav (`totalItems`, `highlightIndex`) uses the reordered `filtered` list seamlessly. - Other consumers of `CategoryCombobox` (`TransactionFilterBar`, `TransactionTable`, `SplitAdjustmentModal`) get the same DFS re-order for free; if their input was already ordered the idempotence guarantee applies. ### i18n - No new user-visible strings. The helper reuses the existing `displayName` (which already resolves `i18n_key` via `t(...)` with `name` fallback). ### Changelog - `CHANGELOG.md` — `## [Unreleased] / ### Fixed` entry with (#126). ✓ - `CHANGELOG.fr.md` — `## [Non publié] / ### Corrigé` entry with (#126). ✓ ### CI gates - `npx tsc --noEmit` — clean - `npx vitest run` — 23 files / 338 tests pass (including the 7 new helper tests) - `npm run build` — green ### Scope - Pure presentation-layer fix in the consumer component. SQL query in `categoryService.getAllCategoriesWithCounts` is untouched, avoiding coupling the service to any specific ordering. **Ready to merge.**
maximus merged commit 1f506fb171 into main 2026-04-22 01:09:36 +00:00
maximus deleted branch issue-126-category-combobox-presentation 2026-04-22 01:09:36 +00:00
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#134
No description provided.