feat(onboarding): empty states on the Transactions and Budget pages (#353) #379

Merged
maximus merged 1 commit from issue-353-transactions-budget-empty into main 2026-10-04 12:51:28 +00:00
Owner

Fixes #353

Stacked on PR #378 (issue-352-dashboard-empty-state), which brings the shared EmptyState component and getTransactionCount(). Merge #378 first; this PR's base must be rebased to main afterwards.

What

  • src/hooks/useTransactions.ts: transactionsReducer + initialTransactionsState exported; new totalInDb: number | null loaded once at mount alongside categories and sources (Promise.all + getTransactionCount()); pure selector showsEmptyState(state) = totalInDb === 0 && !isLoading.
  • src/hooks/useBudget.ts: budgetReducer + initialBudgetState() exported (kept lazy, year is read at mount); totalInDb loaded in the existing once-on-mount effect with the import sources; showsEmptyState(state) = totalInDb === 0.
  • TransactionsPage: when the selector is true, renders EmptyState (icon, title, description, primary action to /import) instead of the filter bar, summary bar, table and pagination. Otherwise unchanged; the error banner stays above both branches.
  • BudgetPage: renders EmptyState instead of FilterPanel + BudgetTable, keeping the title, PageHelp and TemplateActions. RequireFeature feature="budget" in App.tsx untouched.
  • i18n FR + EN: transactions.empty.{title,description,cta}, budget.empty.{title,description,cta}.
  • No CHANGELOG entry (centralised in #357), no DB migration.

Why

The default Transactions filter is "since January 1st" (useTransactions.ts), so rows.length === 0 cannot distinguish an empty profile from a year without transactions; a profile-wide count is the only reliable signal. null (count not loaded yet) keeps the regular layout so nothing flashes.

How tested

  • New src/hooks/useTransactions.test.ts (6 tests): totalInDb starts null; SET_TOTAL_IN_DB keeps the same filters / sort / page references; 0 shows the empty state then N > 0 renders the table; loading at 0 keeps the regular layout; an empty page on a populated profile is not the empty state; other actions preserve the count.
  • src/hooks/useBudget.test.ts extended (4 tests): same invariants on year / rows / templates / accounts.
  • Regression: TransactionTable ("Aucune transaction trouvée") and BudgetTable ("Aucune catégorie trouvée") are untouched and still rendered on a populated profile whose filter returns nothing.
  • npm test: 75 files, 1411 tests green. npm run build (tsc + vite) green. No hard-coded UI strings in the touched components.

Generated autonomously by /autopilot run of 2026-10-03

🤖 Generated with Claude Code

Fixes #353 Stacked on PR #378 (`issue-352-dashboard-empty-state`), which brings the shared `EmptyState` component and `getTransactionCount()`. Merge #378 first; this PR's base must be rebased to `main` afterwards. ## What - `src/hooks/useTransactions.ts`: `transactionsReducer` + `initialTransactionsState` exported; new `totalInDb: number | null` loaded once at mount alongside categories and sources (`Promise.all` + `getTransactionCount()`); pure selector `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`. - `src/hooks/useBudget.ts`: `budgetReducer` + `initialBudgetState()` exported (kept lazy, `year` is read at mount); `totalInDb` loaded in the existing once-on-mount effect with the import sources; `showsEmptyState(state)` = `totalInDb === 0`. - `TransactionsPage`: when the selector is true, renders `EmptyState` (icon, title, description, primary action to `/import`) instead of the filter bar, summary bar, table and pagination. Otherwise unchanged; the error banner stays above both branches. - `BudgetPage`: renders `EmptyState` instead of `FilterPanel` + `BudgetTable`, keeping the title, `PageHelp` and `TemplateActions`. `RequireFeature feature="budget"` in `App.tsx` untouched. - i18n FR + EN: `transactions.empty.{title,description,cta}`, `budget.empty.{title,description,cta}`. - No CHANGELOG entry (centralised in #357), no DB migration. ## Why The default Transactions filter is "since January 1st" (`useTransactions.ts`), so `rows.length === 0` cannot distinguish an empty profile from a year without transactions; a profile-wide count is the only reliable signal. `null` (count not loaded yet) keeps the regular layout so nothing flashes. ## How tested - New `src/hooks/useTransactions.test.ts` (6 tests): `totalInDb` starts `null`; `SET_TOTAL_IN_DB` keeps the same `filters` / `sort` / `page` references; 0 shows the empty state then N > 0 renders the table; loading at 0 keeps the regular layout; an empty page on a populated profile is not the empty state; other actions preserve the count. - `src/hooks/useBudget.test.ts` extended (4 tests): same invariants on `year` / `rows` / `templates` / `accounts`. - Regression: `TransactionTable` ("Aucune transaction trouvée") and `BudgetTable` ("Aucune catégorie trouvée") are untouched and still rendered on a populated profile whose filter returns nothing. - `npm test`: 75 files, 1411 tests green. `npm run build` (tsc + vite) green. No hard-coded UI strings in the touched components. Generated autonomously by /autopilot run of 2026-10-03 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 1 commit 2026-10-03 23:41:05 +00:00
feat(onboarding): empty states on the Transactions and Budget pages (#353)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m51s
8eac47f69b
A profile without any transaction now gets an "import first" card with a
button to the import page on both pages, in place of the filter bar, the
table and the pagination (Transactions) and of the filter panel and the
grid (Budget). Title, page help and template actions stay.

The signal is a profile-wide `totalInDb` count loaded once at mount via
`getTransactionCount()`, not `rows.length`: the default Transactions
filter is "since January 1st", so an empty page cannot tell an empty
profile from a year without transactions. `null` (not loaded yet) keeps
the regular layout, and so does a loading Transactions page.

`transactionsReducer` / `initialTransactionsState` and `budgetReducer` /
`initialBudgetState` are exported with a pure `showsEmptyState` selector
per hook, so the reducer tests pin that the count never touches filters,
sort or page and that 0 then N > 0 brings the table back. The existing
"no transactions found" / "no categories found" rows are unchanged.

Resolves #353

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
maximus added the
autopilot:pending-human
label 2026-10-03 23:41:09 +00:00
Author
Owner

Self-review (autopilot 2026-10-03) — Verdict: APPROVE

Summary. The diff is confined to the two hooks, the two pages, the two locale files and two test files. Both reducers are exported with their initial state, totalInDb is a new orthogonal field (null → count), and a pure showsEmptyState(state) selector per hook is what each page branches on, so the 0 → N transition is covered by the reducer tests rather than by a renderer the repo does not have. CHANGELOG*, App.tsx (RequireFeature), TransactionTable, BudgetTable and the DB are untouched.

Security. No new input surface: one SELECT COUNT(*) FROM transactions on the local SQLite (existing getTransactionCount() from #378), and a <Link to="/import"> to an internal route through EmptyState. Nothing leaves the machine.

Acceptance criterion Status Evidence
transactionsReducer + initialTransactionsState exported (private reducer at :80) ✅ useTransactions.ts — useReducer(transactionsReducer, initialTransactionsState)
budgetReducer + initial state exported (:47) ✅ initialBudgetState() kept lazy (year read at mount), useReducer(budgetReducer, undefined, initialBudgetState)
totalInDb: number | null, loaded once at mount with categories/sources via getTransactionCount() ✅ both once-on-mount effects use Promise.all with the count; never re-fetched
Tests: setting totalInDb does not touch filters / sort / page ✅ useTransactions.test.ts asserts same references, plus full-state equality modulo totalInDb; useBudget.test.ts does the same for year / rows / templates / accounts
Tests: 0 then N > 0 → the table is rendered ✅ showsEmptyState true at 0, false at 42 / 12, in both files
Transactions: EmptyState when totalInDb === 0 && !isLoading, replacing filter bar + table + pagination; otherwise unchanged ✅ single ternary in TransactionsPage; summary bar is also hidden in the empty branch (it only shows 0 $ otherwise); the error banner stays above both branches
Budget: EmptyState replacing FilterPanel + BudgetTable when totalInDb === 0, keeping title, PageHelp, TemplateActions ✅ header block untouched, only the two components are swapped
rows.length === 0 is not the signal (default filter since Jan 1st) ✅ pinned by the test "an empty page on a populated profile is NOT the empty state"
Regression: "Aucune transaction trouvée" / "Aucune catégorie trouvée" still rendered on a populated profile ✅ TransactionTable / BudgetTable unchanged and rendered whenever the count is null or > 0
i18n FR + EN, same keys, valid JSON ✅ transactions.empty.{title,description,cta}, budget.empty.{title,description,cta} in both files; build + full suite parse them
No hard-coded UI strings ✅ grep on both pages: every visible string goes through t()
No CHANGELOG, no DB migration ✅ not in the diff
npm test, npm run build green ✅ 75 files / 1411 tests; tsc + vite OK

Concerns (non-blocking).

  • On a profile whose count is still unknown, the regular layout (filter bar + loading placeholder) shows for the first few milliseconds before the count arrives; the empty card then replaces it. That is the behaviour the issue asks for (null keeps the layout) and avoids flashing the card on populated profiles.
  • BudgetPage keeps its opacity-50 pointer-events-none wrapper while refreshData runs, so the empty card's button is briefly non-clickable during the initial load, exactly like the grid was. Not a regression.
  • Both hooks export a selector named showsEmptyState; each page imports it from its own hook, so there is no ambiguity, but a future shared import would need aliasing.

Generated autonomously by /autopilot run of 2026-10-03

## Self-review (autopilot 2026-10-03) — Verdict: **APPROVE** **Summary.** The diff is confined to the two hooks, the two pages, the two locale files and two test files. Both reducers are exported with their initial state, `totalInDb` is a new orthogonal field (`null` → count), and a pure `showsEmptyState(state)` selector per hook is what each page branches on, so the 0 → N transition is covered by the reducer tests rather than by a renderer the repo does not have. `CHANGELOG*`, `App.tsx` (`RequireFeature`), `TransactionTable`, `BudgetTable` and the DB are untouched. **Security.** No new input surface: one `SELECT COUNT(*) FROM transactions` on the local SQLite (existing `getTransactionCount()` from #378), and a `<Link to="/import">` to an internal route through `EmptyState`. Nothing leaves the machine. | Acceptance criterion | Status | Evidence | |---|---|---| | `transactionsReducer` + `initialTransactionsState` exported (private reducer at `:80`) | ✅ | `useTransactions.ts` — `useReducer(transactionsReducer, initialTransactionsState)` | | `budgetReducer` + initial state exported (`:47`) | ✅ | `initialBudgetState()` kept lazy (`year` read at mount), `useReducer(budgetReducer, undefined, initialBudgetState)` | | `totalInDb: number \| null`, loaded once at mount with categories/sources via `getTransactionCount()` | ✅ | both once-on-mount effects use `Promise.all` with the count; never re-fetched | | Tests: setting `totalInDb` does not touch `filters` / `sort` / `page` | ✅ | `useTransactions.test.ts` asserts same references, plus full-state equality modulo `totalInDb`; `useBudget.test.ts` does the same for `year` / `rows` / `templates` / `accounts` | | Tests: 0 then N > 0 → the table is rendered | ✅ | `showsEmptyState` true at 0, false at 42 / 12, in both files | | Transactions: `EmptyState` when `totalInDb === 0 && !isLoading`, replacing filter bar + table + pagination; otherwise unchanged | ✅ | single ternary in `TransactionsPage`; summary bar is also hidden in the empty branch (it only shows 0 $ otherwise); the error banner stays above both branches | | Budget: `EmptyState` replacing `FilterPanel` + `BudgetTable` when `totalInDb === 0`, keeping title, `PageHelp`, `TemplateActions` | ✅ | header block untouched, only the two components are swapped | | `rows.length === 0` is not the signal (default filter since Jan 1st) | ✅ | pinned by the test "an empty page on a populated profile is NOT the empty state" | | Regression: "Aucune transaction trouvée" / "Aucune catégorie trouvée" still rendered on a populated profile | ✅ | `TransactionTable` / `BudgetTable` unchanged and rendered whenever the count is `null` or > 0 | | i18n FR + EN, same keys, valid JSON | ✅ | `transactions.empty.{title,description,cta}`, `budget.empty.{title,description,cta}` in both files; build + full suite parse them | | No hard-coded UI strings | ✅ | grep on both pages: every visible string goes through `t()` | | No CHANGELOG, no DB migration | ✅ | not in the diff | | `npm test`, `npm run build` green | ✅ | 75 files / 1411 tests; tsc + vite OK | **Concerns (non-blocking).** - On a profile whose count is still unknown, the regular layout (filter bar + loading placeholder) shows for the first few milliseconds before the count arrives; the empty card then replaces it. That is the behaviour the issue asks for (`null` keeps the layout) and avoids flashing the card on populated profiles. - `BudgetPage` keeps its `opacity-50 pointer-events-none` wrapper while `refreshData` runs, so the empty card's button is briefly non-clickable during the initial load, exactly like the grid was. Not a regression. - Both hooks export a selector named `showsEmptyState`; each page imports it from its own hook, so there is no ambiguity, but a future shared import would need aliasing. _Generated autonomously by /autopilot run of 2026-10-03_
Author
Owner

Revue adversariale — Verdict : APPROVE

Résumé. La PR fait ce qu'elle annonce : un compteur totalInDb chargé une fois au montage (SELECT COUNT(*) FROM transactions, hérité de #378), un sélecteur pur showsEmptyState par hook, et un seul ternaire par page qui remplace filtres + table/grille par EmptyState. Diff lu en entier contre sa base issue-352-dashboard-empty-state (1 commit, 8 fichiers), pas contre main. Aucune migration, aucune chaîne en dur, pas de skip/only, clés i18n FR/EN présentes sans doublon (vérifié par un parseur JSON à détection de clés dupliquées), Resolves #353 dans le message de commit et dans le corps → la timeline de #353 porte bien closes depuis cette PR, et un merge local ff-only fermera aussi l'issue.

Exécuté par le relecteur (copie hors dépôt via git archive 8eac47f, supprimée en fin de revue) :

  • tsc --noEmit : exit 0. vitest run : 75 fichiers / 1411 tests verts — même chiffre que la PR.
  • CI du head : PR Check — Frontend success (1m51s) — la PR stackée a bien eu sa CI (aucun filtre branches:).
  • Mutations (chacune appliquée, mesurée, annulée ; fichiers redevenus identiques au head, vérifié par cmp) :
Mutation Tests rouges
M1 — showsEmptyState (transactions) sans le garde !isLoading 1 / 15
M2 — SET_TOTAL_IN_DB (transactions) remet filters et page à zéro 1 / 15
M3 — showsEmptyState (transactions) confond null et 0 2 / 15
M4 — showsEmptyState (budget) confond null et 0 2 / 15
M5 — SET_TOTAL_IN_DB (budget) vide rows 1 / 15

Les tests tiennent sur le point qui compte : le sélecteur distingue « pas encore chargé » de « zéro », et le compteur ne touche pas l'état de pagination (références identiques, pas seulement égalité profonde).

Autres vérifications. BrowserRouter key={refreshKey} (App.tsx:109) remonte les pages au changement de profil, donc le compteur chargé « une fois au montage » est bien rechargé par profil — pas de compteur périmé entre deux profils. EmptyState accepte {label, to} et rend un <Link>, pas de useNavigate : conforme à son contrat. Le RequireFeature feature="budget" d'App.tsx est intact.

Suggestions (non bloquantes)

  1. Le câblage des pages n'est couvert par aucun test. Les reducers et sélecteurs le sont, mais inverser le ternaire isEmptyProfile ? dans l'une ou l'autre page laisserait les 1411 tests verts — seul tsc les voit. C'est la convention du dépôt (pas de renderer), donc pas un blocage ; c'est le trou à connaître avant de toucher à ces pages.
  2. Couplage Promise.all (useTransactions.ts:171-180, useBudget.ts:435-441) : un échec de getTransactionCount() fait désormais échouer aussi le chargement des catégories/sources (Transactions) ou des comptes (Budget), ce qui n'était pas le cas avant. Impact réel faible (COUNT(*) qui échoue = base cassée), mais Promise.allSettled ou un try séparé garderait l'ancien comportement.
  3. Le garde !isLoading sur Transactions allonge le flash au lieu de l'éviter. Son commentaire parle d'« un profil dont le compte est déjà connu », mais le compte n'est jamais connu avant le montage (chargé dans le même effet). Sur un profil vide, la mise en page régulière (barre de filtres + barre de synthèse + « Chargement… ») s'affiche donc jusqu'à la fin de la première page, puis bascule sur la carte. Le « nothing flashes » du corps de PR dit l'inverse de ce qui se passe ; sans le garde, la bascule aurait lieu dès l'arrivée du compte. Sans conséquence sur SQLite local (quelques ms), et incohérent avec le sélecteur Budget qui n'a pas ce garde — à aligner un jour, dans un sens ou l'autre.
  4. Sur un profil vide, le bouton « Auto-catégoriser » reste dans l'en-tête de Transactions et ne peut produire que autoCategorizeNone. Cosmétique.
  5. BudgetPage garde son wrapper opacity-50 pointer-events-none pendant refreshData, donc la carte est brièvement grisée et non cliquable au premier chargement — déjà noté par la self-review, non-régression.

Actions faites. Copie hors dépôt scratchpad/pr379 supprimée, aucun fichier /tmp/pr379-* laissé. Label de #353 : déjà status:approved (posé par la self-review autopilot), inchangé.

Revue /pr-review du 2026-10-03 — mutations et suite rejouées par le relecteur, pas relayées.

## Revue adversariale — Verdict : **APPROVE** **Résumé.** La PR fait ce qu'elle annonce : un compteur `totalInDb` chargé une fois au montage (`SELECT COUNT(*) FROM transactions`, hérité de #378), un sélecteur pur `showsEmptyState` par hook, et un seul ternaire par page qui remplace filtres + table/grille par `EmptyState`. Diff lu en entier contre sa base `issue-352-dashboard-empty-state` (1 commit, 8 fichiers), pas contre `main`. Aucune migration, aucune chaîne en dur, pas de `skip`/`only`, clés i18n FR/EN présentes sans doublon (vérifié par un parseur JSON à détection de clés dupliquées), `Resolves #353` dans le **message de commit** et dans le corps → la timeline de #353 porte bien `closes` depuis cette PR, et un merge local `ff-only` fermera aussi l'issue. **Exécuté par le relecteur** (copie hors dépôt via `git archive 8eac47f`, supprimée en fin de revue) : - `tsc --noEmit` : exit 0. `vitest run` : **75 fichiers / 1411 tests verts** — même chiffre que la PR. - CI du head : `PR Check — Frontend` **success** (1m51s) — la PR stackée a bien eu sa CI (aucun filtre `branches:`). - **Mutations** (chacune appliquée, mesurée, annulée ; fichiers redevenus identiques au head, vérifié par `cmp`) : | Mutation | Tests rouges | |---|---| | M1 — `showsEmptyState` (transactions) sans le garde `!isLoading` | 1 / 15 | | M2 — `SET_TOTAL_IN_DB` (transactions) remet `filters` et `page` à zéro | 1 / 15 | | M3 — `showsEmptyState` (transactions) confond `null` et `0` | 2 / 15 | | M4 — `showsEmptyState` (budget) confond `null` et `0` | 2 / 15 | | M5 — `SET_TOTAL_IN_DB` (budget) vide `rows` | 1 / 15 | Les tests tiennent sur le point qui compte : le sélecteur distingue « pas encore chargé » de « zéro », et le compteur ne touche pas l'état de pagination (références identiques, pas seulement égalité profonde). **Autres vérifications.** `BrowserRouter key={refreshKey}` (`App.tsx:109`) remonte les pages au changement de profil, donc le compteur chargé « une fois au montage » est bien rechargé par profil — pas de compteur périmé entre deux profils. `EmptyState` accepte `{label, to}` et rend un `<Link>`, pas de `useNavigate` : conforme à son contrat. Le `RequireFeature feature="budget"` d'`App.tsx` est intact. ### Suggestions (non bloquantes) 1. **Le câblage des pages n'est couvert par aucun test.** Les reducers et sélecteurs le sont, mais inverser le ternaire `isEmptyProfile ?` dans l'une ou l'autre page laisserait les 1411 tests verts — seul `tsc` les voit. C'est la convention du dépôt (pas de renderer), donc pas un blocage ; c'est le trou à connaître avant de toucher à ces pages. 2. **Couplage `Promise.all`** (`useTransactions.ts:171-180`, `useBudget.ts:435-441`) : un échec de `getTransactionCount()` fait désormais échouer aussi le chargement des catégories/sources (Transactions) ou des comptes (Budget), ce qui n'était pas le cas avant. Impact réel faible (`COUNT(*)` qui échoue = base cassée), mais `Promise.allSettled` ou un `try` séparé garderait l'ancien comportement. 3. **Le garde `!isLoading` sur Transactions allonge le flash au lieu de l'éviter.** Son commentaire parle d'« un profil dont le compte est déjà connu », mais le compte n'est jamais connu avant le montage (chargé dans le même effet). Sur un profil vide, la mise en page régulière (barre de filtres + barre de synthèse + « Chargement… ») s'affiche donc jusqu'à la fin de la **première page**, puis bascule sur la carte. Le « nothing flashes » du corps de PR dit l'inverse de ce qui se passe ; sans le garde, la bascule aurait lieu dès l'arrivée du compte. Sans conséquence sur SQLite local (quelques ms), et incohérent avec le sélecteur Budget qui n'a pas ce garde — à aligner un jour, dans un sens ou l'autre. 4. Sur un profil vide, le bouton « Auto-catégoriser » reste dans l'en-tête de Transactions et ne peut produire que `autoCategorizeNone`. Cosmétique. 5. `BudgetPage` garde son wrapper `opacity-50 pointer-events-none` pendant `refreshData`, donc la carte est brièvement grisée et non cliquable au premier chargement — déjà noté par la self-review, non-régression. **Actions faites.** Copie hors dépôt `scratchpad/pr379` supprimée, aucun fichier `/tmp/pr379-*` laissé. Label de #353 : déjà `status:approved` (posé par la self-review autopilot), inchangé. _Revue /pr-review du 2026-10-03 — mutations et suite rejouées par le relecteur, pas relayées._
maximus changed target branch from issue-352-dashboard-empty-state to main 2026-10-04 12:51:22 +00:00
maximus merged commit 8eac47f69b into main 2026-10-04 12:51:28 +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#379
No description provided.