Skip to content

feat(charges): coherence pass — persistent paid state, cadence units, list-first (THI-329) - #226

Merged
thierryvm merged 3 commits into
mainfrom
feat/charges-page-coherence
Jul 18, 2026
Merged

feat(charges): coherence pass — persistent paid state, cadence units, list-first (THI-329)#226
thierryvm merged 3 commits into
mainfrom
feat/charges-page-coherence

Conversation

@thierryvm

@thierryvm thierryvm commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Passe de cohérence /app/charges (THI-329, analyse validée @Thierry 2026-07-18)

Suite du retour « le Sous-total affiche 1 804,21 € alors que tout est payé ». Analyse complète de la logique de la page → 6 fixes, scope validé via AskUserQuestion (« Cohérence + structure »).

Les 6 changements

  1. Chip de groupe persistant (3 états) : « reste X € » décompte, puis bascule en « ✓ tout payé » au lieu de disparaître — le sous-total figé ne peut plus se faire passer pour un montant encore dû.
  2. Unités de cadence sur les sous-totaux : « 1 804,21 €/mois », « 45 €/trimestre », « 528 €/an » — trois unités de temps différentes portaient la même étiquette.
  3. Bandeau succès quand tout est payé : teinte brand + check + « Tout est payé ce mois » — la micro-récompense du mois.
  4. Hint masqué une fois tout coché (bruit).
  5. Titre : « 19 charges · 16 dues ce mois » — explique l'écart 19 vs 16/16.
  6. Liste d'abord : le formulaire d'ajout (action rare) est replié derrière un bouton dans le header (aria-expanded), s'ouvre au-dessus de la liste, se referme après un ajout réussi.
  • Tri par date d'échéance croissante dans chaque groupe (stable : cocher ne réordonne jamais).

Risque / QA

UI-only, voie LÉGÈRE (risk tiering acté) : zéro domaine/action/migration. Testids E2E tous conservés (vérifié : les specs e2e/charges/ n'interagissent pas avec le formulaire). i18n ×5 locales. 39 tests ChargesClient verts + typecheck.

Smoke @Thierry (desktop + mobile + dark)

  • Tout payé → bandeau brand « Tout est payé ce mois » + « ✓ tout payé » sous chaque groupe.
  • Décocher une facture → bandeau redevient « Reste à payer ce mois X € », chip « reste X € », hint réapparaît.
  • Bouton « + Ajouter une charge » dans le header → formulaire s'ouvre/se referme ; ajout OK.
  • Lignes triées par date dans chaque groupe.

🤖 Generated with Claude Code

Summary by Sourcery

Affiner l’UX du tableau de bord des charges pour mettre l’accent sur le flux de paiement, améliorer la clarté des sous-totaux et des unités de cadence, et rendre les états payés persistants et sans ambiguïté.

Améliorations :

  • Replier le formulaire d’ajout de charge derrière un en-tête à bascule afin que la liste des charges soit affichée en premier, avec un repli automatique après une création réussie.
  • Trier les charges au sein de chaque groupe de fréquence par date d’échéance résolue tout en conservant l’ordre stable lors du basculement de l’état payé.
  • Afficher des libellés explicites d’unité de cadence sur chaque sous-total de groupe afin de distinguer les montants mensuels, trimestriels, semestriels et annuels.
  • Améliorer le “chip” de paiement par groupe afin qu’il affiche un montant restant en temps réel puis un état persistant « tout payé » au lieu de disparaître une fois entièrement payé.
  • Mettre à jour la bannière globale du montant restant pour qu’elle passe à un état de succès lorsque toutes les charges dues sont payées et masquer l’indication de bascule de paiement lorsqu’il n’y a plus rien à cocher.
  • Étendre les messages i18n pour les nouveaux libellés et états dans toutes les locales prises en charge.

Tests :

  • Ajouter et mettre à jour des tests unitaires pour couvrir le formulaire d’ajout replié, l’état « tout payé » par groupe, le comportement de la bannière « tout payé », le tri basé sur la date d’échéance, et les unités de cadence sur les sous-totaux.
Original summary in English

Summary by Sourcery

Refine the charges dashboard UX to emphasize the payment workflow, improve clarity of subtotals and cadence units, and make paid states persistent and unambiguous.

Enhancements:

  • Collapse the add-charge form behind a header toggle so the list of charges is shown first, auto-collapsing again after a successful creation.
  • Sort charges within each frequency group by resolved due date while keeping the order stable when toggling paid status.
  • Display explicit cadence unit labels on each group subtotal to distinguish between monthly, quarterly, semiannual, and annual amounts.
  • Enhance the per-group payment chip so it shows a live remaining amount and then a persistent “all paid” state instead of disappearing once fully paid.
  • Update the global remaining banner to switch to a success state when all due charges are paid and hide the paid-toggle hint once there is nothing left to tick.
  • Extend i18n messages for the new labels and states across all supported locales.

Tests:

  • Add and update unit tests to cover the collapsed add form, per-group all-paid state, all-paid banner behavior, due-date-based sorting, and cadence unit labels on subtotals.

… list-first layout (THI-329)

Full-page coherence review with @Thierry (scope validated 2026-07-18):

1. Group payment chip is now PERSISTENT tri-state: 'reste X €' counts down,
   then flips to a 'tout payé' check instead of silently disappearing —
   the static subtotal could be misread as an amount still owed.
2. Group subtotals get their cadence unit (/mois, /trimestre, /an): the
   monthly subtotal is per month but the annual one per YEAR — same label,
   different time units, unlabeled until now.
3. All-paid banner success state (brand tint + check + 'Tout est payé ce
   mois') — the month's micro-reward; hint hidden once everything is ticked.
4. Card title says '19 charges · 16 dues ce mois' — explains the 19 vs 16/16 gap.
5. Rows sorted by resolved due date ascending (stable: ticking never reorders).
6. List-first layout: the add form (rare action) is collapsed behind a header
   toggle (aria-expanded); it opens above the list and closes on successful add.

UI-only (light lane): no domain/action/migration change. E2E testids untouched.
i18n: app.charges.{groupAllPaid,allPaidTitle,dueCount,subtotalUnit.*} x5 locales.
39 ChargesClient tests green.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ankora Ready Ready Preview, Comment Jul 18, 2026 10:21am

@github-actions github-actions Bot added area:charges Charges management status:review-needed Ready for review type:feat New user-facing feature labels Jul 18, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Guide du relecteur

Refactorise le tableau de bord des prélèvements pour rendre l’état « payé » persistant et plus clair, ajoute des unités de cadence aux sous-totaux de groupe, réorganise la mise en page pour que la liste soit affichée en premier avec un formulaire d’ajout repliable, et fiabilise le comportement avec de nouveaux tests et des chaînes i18n pour toutes les locales.

Diagramme de séquence pour le comportement du formulaire d’ajout de prélèvement avec bascule

sequenceDiagram
  actor User
  participant ChargesClient

  User->>ChargesClient: click charges-add-toggle
  ChargesClient->>ChargesClient: setShowAddForm(true)

  User->>ChargesClient: submit onCreate
  ChargesClient->>ChargesClient: setLabel("")
  ChargesClient->>ChargesClient: setAmount("")
  ChargesClient->>ChargesClient: setShowAddForm(false)
Loading

Modifications par fichier

Changement Détails Fichiers
Rendre le formulaire d’ajout de prélèvement repliable et secondaire par rapport à la liste (UX centrée sur la liste).
  • Introduire l’état showAddForm, qui par défaut est replié, afin que la liste occupe l’écran principal.
  • Déplacer le formulaire d’ajout dans une Card conditionnelle contrôlée par un bouton d’en-tête agissant comme un toggle, avec aria-expanded/aria-controls et un testid.
  • Replier automatiquement le formulaire après une création réussie pour ramener le focus sur la liste.
  • Mettre à jour les tests existants pour ouvrir le formulaire via le nouveau toggle d’en-tête avant d’interagir avec les champs.
src/app/[locale]/app/charges/ChargesClient.tsx
src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
Trier les prélèvements au sein de chaque groupe de fréquence par date d’échéance résolue de manière stable.
  • Remplacer le simple groupement par fréquence par un useMemo qui calcule une chaîne dueIso par prélèvement à l’aide de currentPeriodDueDate, indépendamment de l’état payé.
  • Trier les lignes de chaque groupe par dueIso ascendant avant le rendu, pour que l’activation du toggle « payé » ne réordonne pas les lignes.
  • Ajouter un test qui vérifie que l’ordre des listitem dans un groupe correspond aux dates d’échéance attendues (les plus tôt puis les plus tard).
src/app/[locale]/app/charges/ChargesClient.tsx
src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
Clarifier le récapitulatif des paiements mensuels avec un état de succès « tout payé » et une aide contextuelle.
  • Calculer allPaidThisMonth à partir de dueThisMonth et optimisticPaid pour représenter la complétion basée sur le nombre de factures, y compris les factures à 0 €.
  • Appliquer un style différent à la bannière paid-summary lorsque allPaidThisMonth est vrai (fond aux couleurs de la marque, icône de validation, texte de succès) par rapport au cas où un montant reste dû.
  • Masquer l’aide sur le « toggle payé » une fois que allPaidThisMonth est à true pour réduire le bruit.
  • Ajouter des tests pour couvrir le passage de la bannière à l’état de succès et la disparition de l’aide lorsque tout est payé.
src/app/[locale]/app/charges/ChargesClient.tsx
src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
Rendre les puces de paiement par groupe persistantes et distinguer le compte à rebours actif de l’état totalement payé.
  • Dériver groupDue et groupRemaining par groupe en fonction des prélèvements actifs dus ce mois-ci et de optimisticPaid.
  • Introduire le drapeau groupAllPaid et afficher une puce persistante « tout payé » avec une icône de validation lorsque tous les prélèvements dus dans le groupe sont payés.
  • Conserver la puce « reste X € » uniquement tant que groupRemaining > 0 afin que le sous-total statique ne soit jamais interprété comme encore dû une fois tout payé.
  • Étendre les tests pour vérifier la transition, pour chaque groupe, de la puce de montant restant à la puce persistante « tout payé ».
src/app/[locale]/app/charges/ChargesClient.tsx
src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
Étiqueter les sous-totaux de groupe avec des unités de cadence et clarifier les nombres « total » vs « dus ce mois-ci » dans l’en-tête.
  • Ajouter une map SUBTOTAL_UNIT_KEY pour piloter les clés i18n par fréquence pour les suffixes d’unité de sous-total.
  • Ajouter une étiquette d’unité (par ex. « /mois », « /an ») à côté de chaque sous-total de groupe en utilisant les nouvelles clés i18n et des testids.
  • Augmenter le titre principal de la Card de liste avec un suffixe « · X dus ce mois-ci » lorsque c’est pertinent, afin d’expliquer la différence entre le nombre total de prélèvements et les prélèvements dus ce mois-ci.
  • Ajouter des tests qui confirment les étiquettes d’unité de cadence pour les sous-totaux mensuels et annuels.
src/app/[locale]/app/charges/ChargesClient.tsx
src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
messages/de-DE.json
messages/en.json
messages/es-ES.json
messages/fr-BE.json
messages/nl-BE.json

Conseils et commandes

Interagir avec Sourcery

  • Déclencher une nouvelle revue : Commentez @sourcery-ai review sur la pull request.
  • Poursuivre les discussions : Répondez directement aux commentaires de revue de Sourcery.
  • Générer un ticket GitHub à partir d’un commentaire de revue : Demandez à Sourcery de créer un ticket à partir d’un commentaire de revue en y répondant. Vous pouvez aussi répondre à un commentaire de revue avec @sourcery-ai issue pour créer un ticket à partir de celui-ci.
  • Générer un titre de pull request : Écrivez @sourcery-ai n’importe où dans le titre de la pull request pour générer un titre à tout moment. Vous pouvez aussi commenter @sourcery-ai title sur la pull request pour (re)générer le titre à tout moment.
  • Générer un résumé de pull request : Écrivez @sourcery-ai summary n’importe où dans le corps de la pull request pour générer un résumé de PR à tout moment exactement à l’endroit souhaité. Vous pouvez aussi commenter @sourcery-ai summary sur la pull request pour (re)générer le résumé à tout moment.
  • Générer un guide du relecteur : Commentez @sourcery-ai guide sur la pull request pour (re)générer le guide du relecteur à tout moment.
  • Résoudre tous les commentaires Sourcery : Commentez @sourcery-ai resolve sur la pull request pour résoudre tous les commentaires Sourcery. Utile si vous avez déjà traité tous les commentaires et ne voulez plus les voir.
  • Ignorer toutes les revues Sourcery : Commentez @sourcery-ai dismiss sur la pull request pour ignorer toutes les revues Sourcery existantes. Particulièrement utile si vous voulez repartir de zéro avec une nouvelle revue – n’oubliez pas de commenter @sourcery-ai review pour déclencher une nouvelle revue !

Personnaliser votre expérience

Accédez à votre dashboard pour :

  • Activer ou désactiver des fonctions de revue comme le résumé de pull request généré par Sourcery, le guide du relecteur, et d’autres.
  • Changer la langue de la revue.
  • Ajouter, supprimer ou modifier des consignes de revue personnalisées.
  • Ajuster d’autres paramètres de revue.

Obtenir de l’aide

Original review guide in English

Reviewer's Guide

Refactors the charges dashboard to make the “paid” state persistent and clearer, adds cadence units to group subtotals, reorders the layout to be list-first with a toggled add form, and hardens behavior with new tests and i18n strings across locales.

Sequence diagram for the toggled add-charge form behavior

sequenceDiagram
  actor User
  participant ChargesClient

  User->>ChargesClient: click charges-add-toggle
  ChargesClient->>ChargesClient: setShowAddForm(true)

  User->>ChargesClient: submit onCreate
  ChargesClient->>ChargesClient: setLabel("")
  ChargesClient->>ChargesClient: setAmount("")
  ChargesClient->>ChargesClient: setShowAddForm(false)
Loading

File-Level Changes

Change Details Files
Make the add-charge form collapsible and secondary to the list (list-first UX).
  • Introduce showAddForm state defaulting to collapsed so the list owns the first screen.
  • Move the add form into a conditional Card controlled by a header Button toggle with aria-expanded/aria-controls and a testid.
  • Automatically collapse the form after a successful create action to return focus to the list.
  • Update existing tests to open the form via the new header toggle before interacting with fields.
src/app/[locale]/app/charges/ChargesClient.tsx
src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
Sort charges within each frequency group by resolved due date in a stable way.
  • Replace simple frequency-based grouping with a useMemo that computes a dueIso string per charge using currentPeriodDueDate independent of paid state.
  • Sort each group’s rows by dueIso ascending before rendering, ensuring ticking the paid toggle does not reorder rows.
  • Add a test that asserts listitem order within a group matches the expected early/late due dates.
src/app/[locale]/app/charges/ChargesClient.tsx
src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
Clarify monthly payment summary with an all-paid success state and context-aware hint.
  • Compute allPaidThisMonth from dueThisMonth and optimisticPaid to represent count-based completion, including 0€ bills.
  • Style the paid-summary banner differently when allPaidThisMonth (brand background, check icon, success copy) versus when there is remaining amount.
  • Hide the “paid toggle” hint once allPaidThisMonth is true to reduce noise.
  • Add tests to cover the banner flipping to the success state and the hint disappearing when everything is paid.
src/app/[locale]/app/charges/ChargesClient.tsx
src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
Make per-group payment chips persistent and distinguish between live countdown and fully-paid state.
  • Derive groupDue and groupRemaining per group based on active charges due this month and optimisticPaid.
  • Introduce groupAllPaid flag and render a persistent “tout payé” chip with check icon when all due charges in the group are paid.
  • Keep the “reste X €” chip only while groupRemaining > 0 so the static subtotal is never misinterpreted as still owed once fully paid.
  • Extend tests to verify the transition from remaining amount chip to persistent all-paid chip per group.
src/app/[locale]/app/charges/ChargesClient.tsx
src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
Label group subtotals with cadence units and clarify count vs due-this-month numbers in the header.
  • Add SUBTOTAL_UNIT_KEY map to drive per-frequency i18n keys for subtotal unit suffixes.
  • Append a unit label (e.g., “/mois”, “/an”) next to each group subtotal using the new i18n keys and testids.
  • Augment the main list Card title with a “· X dues this month” suffix when applicable to explain the difference between total charges and charges due this month.
  • Add tests that confirm cadence unit labels for monthly and annual subtotals.
src/app/[locale]/app/charges/ChargesClient.tsx
src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
messages/de-DE.json
messages/en.json
messages/es-ES.json
messages/fr-BE.json
messages/nl-BE.json

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - j'ai trouvé 5 problèmes

Prompt pour les agents IA
Veuillez traiter les commentaires de cette revue de code :

## Commentaires individuels

### Commentaire 1
<location path="src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx" line_range="187-190" />
<code_context>

   it('preserves the add-form CRUD scaffolding (out-of-scope guard against accidental refactor)', () => {
     renderCharges([]);
+    // Coherence pass 2026-07: the form is collapsed by default (the list owns
+    // the first screen) and opens through the header toggle.
+    expect(screen.queryByLabelText('Libellé')).toBeNull();
+    fireEvent.click(screen.getByTestId('charges-add-toggle'));
     // The 5 form fields + submit button stay reachable by their labels and roles.
     expect(screen.getByLabelText('Libellé')).toBeInTheDocument();
</code_context>
<issue_to_address>
**suggestion (testing):** Ajoutez un test vérifiant que le formulaire d’ajout se replie automatiquement après une création réussie

Étant donné que l’implémentation replie également automatiquement le formulaire après un `createChargeAction` réussi, merci d’ajouter un test qui :
- ouvre le formulaire via `charges-add-toggle`,
- remplit et soumet le formulaire, attend le succès optimiste,
- puis vérifie que le formulaire est retiré du DOM et que `aria-expanded` sur le toggle est à `false`.

Cela permettra de se protéger contre les régressions où le formulaire reste ouvert après un ajout réussi.

Implémentation suggérée :

```typescript
  it('preserves the add-form CRUD scaffolding (out-of-scope guard against accidental refactor)', () => {
    renderCharges([]);
    // Coherence pass 2026-07: the form is collapsed by default (the list owns
    // the first screen) and opens through the header toggle.
    expect(screen.queryByLabelText('Libellé')).toBeNull();
    fireEvent.click(screen.getByTestId('charges-add-toggle'));
    // The 5 form fields + submit button stay reachable by their labels and roles.
    expect(screen.getByLabelText('Libellé')).toBeInTheDocument();
    expect(screen.getByLabelText(/Montant/)).toBeInTheDocument();
  });

  it('passes paymentDay + computed paymentMonths to createChargeAction', async () => {
    createChargeMock.mockResolvedValue({ ok: true });
    renderCharges([]);
    fireEvent.click(screen.getByTestId('charges-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Assurance' } });
    fireEvent.change(screen.getByLabelText(/Montant/), { target: { value: '120' } });
    fireEvent.change(screen.getByLabelText(/jour du mois/i), { target: { value: '15' } });

    // Submit the form (kept minimal here; other fields may be populated elsewhere in the suite).
    fireEvent.click(screen.getByRole('button', { name: /ajouter/i }));

    // The optimistic result updates the summary.
    await waitFor(() => {
      expect(screen.getByTestId('charges-paid-summary')).toHaveTextContent('1/1');
    });
  });

  it('auto-collapses the add form after a successful createChargeAction', async () => {
    createChargeMock.mockResolvedValue({ ok: true });
    renderCharges([]);

    // Initially collapsed.
    const toggle = screen.getByTestId('charges-add-toggle');
    expect(toggle).toHaveAttribute('aria-expanded', 'false');
    expect(screen.queryByLabelText('Libellé')).toBeNull();

    // Open the form.
    fireEvent.click(toggle);
    expect(toggle).toHaveAttribute('aria-expanded', 'true');
    expect(screen.getByLabelText('Libellé')).toBeInTheDocument();

    // Fill in the minimal required fields.
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Assurance' } });
    fireEvent.change(screen.getByLabelText(/Montant/), { target: { value: '120' } });
    fireEvent.change(screen.getByLabelText(/jour du mois/i), { target: { value: '15' } });

    // Submit the form.
    fireEvent.click(screen.getByRole('button', { name: /ajouter/i }));

    // Wait for the optimistic success to be reflected in the UI and for the form to auto-collapse.
    await waitFor(() => {
      // The newly added charge is reflected in the summary.
      expect(screen.getByTestId('charges-paid-summary')).toHaveTextContent('1/1');
      // The form is removed from the DOM.
      expect(screen.queryByLabelText('Libellé')).toBeNull();
      // The toggle reflects the collapsed state.
      expect(toggle).toHaveAttribute('aria-expanded', 'false');
    });
  });

```

- Assurez-vous que `waitFor` est importé depuis vos utilitaires de test (par exemple `import { render, screen, waitFor, fireEvent } from '@testing-library/react';`) s’il n’est pas déjà utilisé ailleurs dans le fichier.
- Si le bouton de soumission a un autre nom accessible que `/ajouter/i`, adaptez le sélecteur `getByRole('button', { name: /ajouter/i })` pour correspondre au libellé réel utilisé dans le composant.
- Si des champs supplémentaires sont requis pour une soumission réussie (par ex. fréquence, sélection des mois), reflétez exactement l’ensemble des `fireEvent.change` ou clics déjà utilisés dans le test existant « passes paymentDay + computed paymentMonths » ou dans d’autres tests de création, afin que le chemin de succès optimiste soit déclenché de manière fiable.
</issue_to_address>

### Commentaire 2
<location path="src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx" line_range="229-234" />
<code_context>
   it('passes paymentDay + computed paymentMonths to createChargeAction', async () => {
     createChargeMock.mockResolvedValue({ ok: true });
     renderCharges([]);
+    fireEvent.click(screen.getByTestId('charges-add-toggle'));
     fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Assurance' } });
     fireEvent.change(screen.getByLabelText(/Montant/), { target: { value: '120' } });
     fireEvent.change(screen.getByLabelText(/jour du mois/i), { target: { value: '15' } });
</code_context>
<issue_to_address>
**suggestion (testing):** Vérifier également que `aria-expanded` sur le toggle du formulaire d’ajout reflète l’état replié/ouvert

Comme ce test valide déjà le flux CRUD, ajoutez également une vérification du comportement d’accessibilité du toggle : avant le clic, `getByTestId('charges-add-toggle')` devrait avoir `aria-expanded="false"` et le formulaire ne devrait pas être dans le DOM ; après le clic, `aria-expanded="true"` et le formulaire devrait être visible. Cela permettra de détecter les régressions sur `aria-expanded`/`aria-controls` via le test.

Implémentation suggérée :

```typescript
  it('preserves the add-form CRUD scaffolding (out-of-scope guard against accidental refactor)', () => {
    renderCharges([]);
    // Coherence pass 2026-07: the form is collapsed by default (the list owns
    // the first screen) and opens through the header toggle.
    const toggle = screen.getByTestId('charges-add-toggle');
    expect(toggle).toHaveAttribute('aria-expanded', 'false');
    expect(screen.queryByLabelText('Libellé')).toBeNull();

    fireEvent.click(toggle);

    // The toggle now reflects the open state and the form appears in the DOM.
    expect(toggle).toHaveAttribute('aria-expanded', 'true');
    // The 5 form fields + submit button stay reachable by their labels and roles.
    expect(screen.getByLabelText('Libellé')).toBeInTheDocument();
    expect(screen.getByLabelText(/Montant/)).toBeInTheDocument();
  });

  it('passes paymentDay + computed paymentMonths to createChargeAction', async () => {
    createChargeMock.mockResolvedValue({ ok: true });
    renderCharges([]);

    const toggle = screen.getByTestId('charges-add-toggle');
    expect(toggle).toHaveAttribute('aria-expanded', 'false');
    expect(screen.queryByLabelText('Libellé')).toBeNull();

    fireEvent.click(toggle);

    expect(toggle).toHaveAttribute('aria-expanded', 'true');
    expect(screen.getByLabelText('Libellé')).toBeInTheDocument();

    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Assurance' } });
    fireEvent.change(screen.getByLabelText(/Montant/), { target: { value: '120' } });
    fireEvent.change(screen.getByLabelText(/jour du mois/i), { target: { value: '15' } });
    expect(screen.getByTestId('charges-paid-summary')).toHaveTextContent('1/1');
  });

```

Si le formulaire d’ajout est rendu de manière conditionnelle via `aria-controls` pointant vers le conteneur du formulaire, vous pouvez éventuellement renforcer encore les assertions en vérifiant que l’élément dont l’`id` est référencé par `aria-controls` est absent/présent avant/après le clic :
1. Lire `const controlsId = toggle.getAttribute('aria-controls');`
2. Vérifier `expect(screen.queryByTestId(controlsId!)).toBeNull()` (ou `queryByRole`/`queryByLabelText`) avant le clic.
3. Vérifier `expect(screen.getByTestId(controlsId!)).toBeInTheDocument()` après le clic.
Adaptez le sélecteur (`getByTestId`, `getByRole`, etc.) pour qu’il corresponde à la façon dont le conteneur du formulaire est identifié dans l’implémentation.
</issue_to_address>

### Commentaire 3
<location path="src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx" line_range="491-500" />
<code_context>
+  it('sorts rows by resolved due date ascending within a group (stable under ticking)', () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Renforcer le test de tri pour prouver que l’ordre reste stable lors du pointage d’une facture

Ce test ne vérifie que l’ordre ascendant initial, alors que sa description promet aussi la stabilité lors du pointage. Pour couvrir cela, veuillez également (dans ce test ou un nouveau) capturer les IDs des éléments de liste, pointer l’un des éléments via son testid, puis relire la liste et vérifier que l’ordre des IDs reste inchangé. Cela validera le comportement « aucun réordonnancement sous le doigt de l’utilisateur ».

Implémentation suggérée :

```typescript
  it('sorts rows by resolved due date ascending within a group (stable under ticking)', async () => {
    const late = { ...monthlyCharge, id: 'late', label: 'Late bill', paymentDay: 20 };
    const early = { ...monthlyCharge, id: 'early', label: 'Early bill', paymentDay: 3 };

    renderCharges([late, early], { currentPeriod: { year: 2026, month: 1 } });

    const group = within(screen.getByTestId('charges-group-monthly'));

    const listItemsBefore = group.getAllByRole('listitem');
    const idsBefore = listItemsBefore.map((el) => el.getAttribute('data-testid'));
    expect(idsBefore).toEqual(['charges-row-early', 'charges-row-late']);

    // Tick one of the items and ensure the order stays the same
    // (no reordering under the user's finger)
    const firstRowCheckbox = within(listItemsBefore[0]).getByRole('checkbox');
    await user.click(firstRowCheckbox);

    const listItemsAfter = group.getAllByRole('listitem');
    const idsAfter = listItemsAfter.map((el) => el.getAttribute('data-testid'));
    expect(idsAfter).toEqual(idsBefore);
  });

```

Ce changement suppose :
1. Qu’une instance `user` provenant de `@testing-library/user-event` est déjà disponible dans ce fichier de test (par ex. via `const user = userEvent.setup()` dans un `beforeEach` ou au niveau supérieur).
2. Que chaque ligne de la liste contient une case à cocher accessible via `getByRole('checkbox')` à partir de l’élément de ligne.

Si l’une de ces hypothèses est fausse, vous devrez :
- Vous assurer que `user` est défini, généralement en important `userEvent` et en créant une instance partagée.
- Adapter `getByRole('checkbox')` pour qu’il corresponde à votre contrôle de pointage réel, par exemple en utilisant un `data-testid` spécifique dans la ligne ou un autre rôle/sélecteur.
</issue_to_address>

### Commentaire 4
<location path="src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx" line_range="501-506" />
<code_context>
+    expect(ids).toEqual(['charges-row-early', 'charges-row-late']);
+  });
+
+  it('labels each group subtotal with its cadence unit', () => {
+    renderCharges(sampleCharges, {
+      subtotals: { monthly: 1200, quarterly: 0, semiannual: 0, annual: 300 },
+    });
+    expect(screen.getByTestId('charges-group-subtotal-monthly')).toHaveTextContent('/mois');
+    expect(screen.getByTestId('charges-group-subtotal-annual')).toHaveTextContent('/an');
   });

</code_context>
<issue_to_address>
**suggestion (testing):** Couvrir toutes les unités de cadence et le nouveau badge de nombre d’échéances dans les tests de header

Le test actuel couvre déjà le mensuel et l’annuel, mais deux petits ajouts réduiraient encore le risque de régression :

1. Étendre ou paramétrer ce test pour vérifier aussi les libellés trimestriel et semestriel, afin que les quatre cadences soient validées et que tout problème de clé de traduction soit détecté.
2. Ajouter un test dédié pour le badge `dueCount` du titre d’en-tête, en vérifiant à la fois le libellé « N au total / M dues ce mois-ci » et le fait que le badge est omis lorsque `dueThisMonth.length === 0`.

Cela reliera directement l’explication numérique et les suffixes d’unité aux tests.

Implémentation suggérée :

```typescript
  it('sorts rows by resolved due date ascending within a group (stable under ticking)', () => {
    const late = { ...monthlyCharge, id: 'late', label: 'Late bill', paymentDay: 20 };
    const early = { ...monthlyCharge, id: 'early', label: 'Early bill', paymentDay: 3 };
    renderCharges([late, early], { currentPeriod: { year: 2026, month: 1 } });
    const ids = within(screen.getByTestId('charges-group-monthly'))
      .getAllByRole('listitem')
      .map((el) => el.getAttribute('data-testid'));
    expect(ids).toEqual(['charges-row-early', 'charges-row-late']);
  });

  it.each([
    ['monthly', '/mois'],
    ['quarterly', '/trimestre'],
    ['semiannual', '/semestre'],
    ['annual', '/an'],
  ] as const)(
    'labels %s group subtotal with its cadence unit',
    (cadence, expectedSuffix) => {
      renderCharges(sampleCharges, {
        subtotals: { monthly: 1200, quarterly: 800, semiannual: 600, annual: 300 },
      });

      expect(
        screen.getByTestId(`charges-group-subtotal-${cadence}`),
      ).toHaveTextContent(expectedSuffix);
    },
  );

  it('shows the header due-count badge with total and current-month counts, and hides it when none are due', () => {
    // Scenario 1: some charges are due this month
    renderCharges(sampleCharges, {
      currentPeriod: { year: 2026, month: 1 },
    });

    const dueCountBadge = screen.getByTestId('charges-header-due-count');
    // Example expected wording: "N au total / M dues ce mois-ci"
    // The exact numbers depend on sampleCharges and the header implementation.
    expect(dueCountBadge.textContent).toMatch(/au total/);
    expect(dueCountBadge.textContent).toMatch(/dues? ce mois[- ]ci/);

    // Scenario 2: no charges due this month should omit the badge entirely
    renderCharges(sampleCharges, {
      currentPeriod: { year: 2026, month: 12 },
      // Ensure the helper treats this as "no dues this month"; see additional_changes.
    });

    expect(screen.queryByTestId('charges-header-due-count')).toBeNull();
  });

  it('preserves the add-form CRUD scaffolding (out-of-scope guard against accidental refactor)', () => {

```

1. Alignez les attentes sur les libellés de cadence avec vos clés i18n réelles :
   - Si trimestriel et semestriel utilisent des suffixes différents de `/trimestre` et `/semestre`, mettez à jour les valeurs `expectedSuffix` en conséquence.
   - Assurez-vous que les nœuds DOM correspondants ont bien `data-testid="charges-group-subtotal-quarterly"` et `data-testid="charges-group-subtotal-semiannual"` comme utilisé dans le test.
2. Le helper `renderCharges` ne montre actuellement qu’une partie de son API dans l’extrait ; assurez-vous qu’il :
   - Accepte l’objet `subtotals` avec les quatre clés de cadence (`monthly`, `quarterly`, `semiannual`, `annual`) et rend les quatre éléments de sous-total avec les `data-testid="charges-group-subtotal-*"`.
3. Pour le test du badge de nombre d’échéances dans le header :
   - Confirmez que le header rend un badge avec `data-testid="charges-header-due-count"` lorsqu’au moins une charge est due dans la période courante, et qu’il inclut à la fois le nombre total et le nombre « dues ce mois-ci » dans son texte. Si votre libellé réel diffère (par ex. anglais vs français), adaptez les deux attentes `toMatch` au texte réel, ou remplacez-les par un seul `toHaveTextContent` comparant la chaîne exacte.
   - Assurez-vous que `renderCharges` rend un arbre frais lors du deuxième appel dans le test (s’il effectue du caching interne, vous devrez peut-être mettre le deuxième scénario dans un test séparé ou appeler `cleanup()` entre les deux).
   - Adaptez le deuxième scénario de sorte que, pour la `currentPeriod` donnée, aucune charge ne soit considérée comme « due ce mois-ci ». Selon votre implémentation, cela peut nécessiter soit :
     * De modifier `currentPeriod` vers un mois où `sampleCharges` n’a aucun élément dû, soit
     * D’ajouter un override explicite (par ex. `dueThisMonth: []`) si `renderCharges` le permet.
</issue_to_address>

### Commentaire 5
<location path="src/app/[locale]/app/charges/ChargesClient.tsx" line_range="125" />
<code_context>
+  // Rows are sorted by resolved due date ascending — a STABLE order (ticking a
+  // bill never reorders rows under the user's finger, unlike unpaid-first).
+  // The resolver is paid-independent for the date, so `isPaid: false` is fine.
+  const groups = useMemo(() => {
+    const dueIsoOf = (c: RawCharge): string =>
+      currentPeriodDueDate(
</code_context>
<issue_to_address>
**issue (complexity):** Envisagez d’extraire la logique de tri et d’état de paiement dans des helpers partagés et de simplifier les `classNames`/configs pour garder le composant ChargesClient plus déclaratif et plus facile à lire.

Vous pouvez conserver tout le nouveau comportement tout en rendant le composant plus lisible en extrayant quelques helpers purs et en aplatissant une partie du JSX.

### 1. Extraire le calcul de `groups`

Le `useMemo` définit actuellement `dueIsoOf`, décore les lignes pour le tri, puis les décore. Déplacez cela dans un helper de haut niveau pour que le code de rendu se lise comme « group + sort » plutôt que « implémenter le tri » :

```ts
function sortChargesByDueDate(
  charges: RawCharge[],
  currentPeriod: CurrentPeriod,
  todayIso: string,
): RawCharge[] {
  const dueIsoOf = (c: RawCharge): string =>
    currentPeriodDueDate(
      { isActive: c.isActive, paymentMonths: c.paymentMonths, paymentDay: c.paymentDay },
      currentPeriod,
      todayIso,
      false,
    )?.dueDateIso ?? '9999-12-31';

  return [...charges]
    .map((c) => ({ c, dueIso: dueIsoOf(c) }))
    .sort((a, b) => (a.dueIso < b.dueIso ? -1 : a.dueIso > b.dueIso ? 1 : 0))
    .map(({ c }) => c);
}
```

Puis le hook devient :

```ts
const groups = useMemo(
  () =>
    FREQUENCIES.map((freq) => ({
      freq,
      rows: sortChargesByDueDate(
        charges.filter((c) => c.frequency === freq),
        currentPeriod,
        todayIso,
      ),
    })).filter((group) => group.rows.length > 0),
  [charges, currentPeriod, todayIso],
);
```

On conserve le même comportement d’ordre stable tout en rendant « qu’est-ce qu’un groupe ? » évident au premier coup d’œil.

### 2. Extraire l’état de paiement par groupe

Vous répétez la logique `isActive`/`paymentMonths.includes` inline. La centraliser réduira la duplication et gardera l’arbre de rendu déclaratif :

```ts
type GroupPaymentState = {
  remaining: number;
  allPaid: boolean;
};

function computeGroupPaymentState(
  rows: RawCharge[],
  currentPeriod: CurrentPeriod,
  optimisticPaid: ReadonlySet<string>,
): GroupPaymentState {
  const groupDue = rows.filter(
    (c) => c.isActive && c.paymentMonths.includes(currentPeriod.month),
  );

  const remaining = groupDue
    .filter((c) => !optimisticPaid.has(c.id))
    .reduce((sum, c) => sum + c.amount, 0);

  const allPaid = groupDue.length > 0 && groupDue.every((c) => optimisticPaid.has(c.id));

  return { remaining, allPaid };
}
```

Utilisation dans le `map` :

```ts
{groups.map(({ freq, rows }) => {
  const headingId = `charges-group-${freq}-heading`;
  const { remaining: groupRemaining, allPaid: groupAllPaid } = computeGroupPaymentState(
    rows,
    currentPeriod,
    optimisticPaid,
  );

  // ... JSX unchanged, using groupRemaining, groupAllPaid ...
})}
```

Même logique, mais le composant n’a plus besoin de connaître les règles exactes de paiement à plusieurs endroits.

### 3. Aplatir les conditions sur les `className` du résumé

Si vous disposez d’un helper `cn`/`clsx`, vous pouvez éviter les longues chaînes interpolées et rendre l’état « succès » vs état par défaut plus clair :

```ts
import { cn } from '@/lib/cn';

<div
  data-testid="charges-paid-summary"
  className={cn(
    'mb-4 flex items-center justify-between gap-3 rounded-lg px-4 py-3',
    allPaidThisMonth ? 'bg-surface-muted' : 'bg-brand-600/10',
  )}
>
  <div className="min-w-0" aria-live="polite" role="status" aria-atomic="true">
    <p
      className={cn(
        'flex items-center gap-1.5 text-xs font-medium',
        allPaidThisMonth ? 'text-brand-text' : 'text-muted-foreground',
      )}
    >
      {allPaidThisMonth && <Check aria-hidden className="h-3.5 w-3.5" strokeWidth={3} />}
      {allPaidThisMonth ? t('allPaidTitle') : t('remainingLabel')}
    </p>
    <p
      data-testid="charges-remaining-amount"
      className={cn(
        'text-xl font-bold tabular-nums',
        allPaidThisMonth ? 'text-brand-text' : 'text-foreground',
      )}
    >
      {formatCurrency(remainingThisMonth, locale)}
    </p>
  </div>
  {/* ... */}
</div>
```

Le comportement reste identique ; le JSX est plus facile à parcourir.

### 4. Déplacer `SUBTOTAL_UNIT_KEY` dans une config partagée

Pour garder ce composant centré sur le comportement, vous pouvez colocaliser la correspondance fréquence → clé de traduction avec les autres configurations de fréquence :

```ts
// chargesConfig.ts
export const FREQUENCIES = ['monthly', 'quarterly', 'semiannual', 'annual'] as const;
export type Frequency = (typeof FREQUENCIES)[number];

export const SUBTOTAL_UNIT_KEY: Record<Frequency, string> = {
  monthly: 'subtotalUnit.monthly',
  quarterly: 'subtotalUnit.quarterly',
  semiannual: 'subtotalUnit.semiannual',
  annual: 'subtotalUnit.annual',
};
```

Puis dans le composant :

```ts
import { FREQUENCIES, SUBTOTAL_UNIT_KEY, type Frequency } from './chargesConfig';

// ...

{t(SUBTOTAL_UNIT_KEY[freq])}
```

Aucun changement de comportement, mais le composant se débarrasse du bruit de configuration et ses responsabilités deviennent plus claires.
</issue_to_address>

Sourcery est gratuit pour l’open source – si vous aimez nos revues, merci d’envisager de les partager ✨
Aidez-moi à être plus utile ! Cliquez sur 👍 ou 👎 sur chaque commentaire et j’utiliserai vos retours pour améliorer vos revues.
Original comment in English

Hey - I've found 5 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx" line_range="187-190" />
<code_context>

   it('preserves the add-form CRUD scaffolding (out-of-scope guard against accidental refactor)', () => {
     renderCharges([]);
+    // Coherence pass 2026-07: the form is collapsed by default (the list owns
+    // the first screen) and opens through the header toggle.
+    expect(screen.queryByLabelText('Libellé')).toBeNull();
+    fireEvent.click(screen.getByTestId('charges-add-toggle'));
     // The 5 form fields + submit button stay reachable by their labels and roles.
     expect(screen.getByLabelText('Libellé')).toBeInTheDocument();
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test that the add form auto-collapses after a successful creation

Since the implementation also auto-collapses the form after a successful `createChargeAction`, please add a test that:
- opens the form via `charges-add-toggle`,
- fills and submits the form, waits for the optimistic success,
- then asserts the form is removed from the DOM and `aria-expanded` on the toggle is `false`.

This will guard against regressions where the form stays open after a successful add.

Suggested implementation:

```typescript
  it('preserves the add-form CRUD scaffolding (out-of-scope guard against accidental refactor)', () => {
    renderCharges([]);
    // Coherence pass 2026-07: the form is collapsed by default (the list owns
    // the first screen) and opens through the header toggle.
    expect(screen.queryByLabelText('Libellé')).toBeNull();
    fireEvent.click(screen.getByTestId('charges-add-toggle'));
    // The 5 form fields + submit button stay reachable by their labels and roles.
    expect(screen.getByLabelText('Libellé')).toBeInTheDocument();
    expect(screen.getByLabelText(/Montant/)).toBeInTheDocument();
  });

  it('passes paymentDay + computed paymentMonths to createChargeAction', async () => {
    createChargeMock.mockResolvedValue({ ok: true });
    renderCharges([]);
    fireEvent.click(screen.getByTestId('charges-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Assurance' } });
    fireEvent.change(screen.getByLabelText(/Montant/), { target: { value: '120' } });
    fireEvent.change(screen.getByLabelText(/jour du mois/i), { target: { value: '15' } });

    // Submit the form (kept minimal here; other fields may be populated elsewhere in the suite).
    fireEvent.click(screen.getByRole('button', { name: /ajouter/i }));

    // The optimistic result updates the summary.
    await waitFor(() => {
      expect(screen.getByTestId('charges-paid-summary')).toHaveTextContent('1/1');
    });
  });

  it('auto-collapses the add form after a successful createChargeAction', async () => {
    createChargeMock.mockResolvedValue({ ok: true });
    renderCharges([]);

    // Initially collapsed.
    const toggle = screen.getByTestId('charges-add-toggle');
    expect(toggle).toHaveAttribute('aria-expanded', 'false');
    expect(screen.queryByLabelText('Libellé')).toBeNull();

    // Open the form.
    fireEvent.click(toggle);
    expect(toggle).toHaveAttribute('aria-expanded', 'true');
    expect(screen.getByLabelText('Libellé')).toBeInTheDocument();

    // Fill in the minimal required fields.
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Assurance' } });
    fireEvent.change(screen.getByLabelText(/Montant/), { target: { value: '120' } });
    fireEvent.change(screen.getByLabelText(/jour du mois/i), { target: { value: '15' } });

    // Submit the form.
    fireEvent.click(screen.getByRole('button', { name: /ajouter/i }));

    // Wait for the optimistic success to be reflected in the UI and for the form to auto-collapse.
    await waitFor(() => {
      // The newly added charge is reflected in the summary.
      expect(screen.getByTestId('charges-paid-summary')).toHaveTextContent('1/1');
      // The form is removed from the DOM.
      expect(screen.queryByLabelText('Libellé')).toBeNull();
      // The toggle reflects the collapsed state.
      expect(toggle).toHaveAttribute('aria-expanded', 'false');
    });
  });

```

- Ensure `waitFor` is imported from your test utils (e.g. `import { render, screen, waitFor, fireEvent } from '@testing-library/react';`) if it is not already used elsewhere in the file.
- If the submit button has a different accessible name than `/ajouter/i`, adjust the `getByRole('button', { name: /ajouter/i })` selector to match the actual label used in the component.
- If some additional fields are required for a successful submission (e.g. frequency, months selection), mirror the exact set of `fireEvent.change` or clicks already used in the existing “passes paymentDay + computed paymentMonths” or other create tests so that the optimistic success path is triggered reliably.
</issue_to_address>

### Comment 2
<location path="src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx" line_range="229-234" />
<code_context>
   it('passes paymentDay + computed paymentMonths to createChargeAction', async () => {
     createChargeMock.mockResolvedValue({ ok: true });
     renderCharges([]);
+    fireEvent.click(screen.getByTestId('charges-add-toggle'));
     fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Assurance' } });
     fireEvent.change(screen.getByLabelText(/Montant/), { target: { value: '120' } });
     fireEvent.change(screen.getByLabelText(/jour du mois/i), { target: { value: '15' } });
</code_context>
<issue_to_address>
**suggestion (testing):** Also assert `aria-expanded` on the add-form toggle reflects the collapsed/open state

Since this test already validates the CRUD flow, also assert the accessibility behavior of the toggle: before the click, `getByTestId('charges-add-toggle')` should have `aria-expanded="false"` and the form should not be in the DOM; after the click, `aria-expanded="true"` and the form should be visible. This will ensure regressions in `aria-expanded`/`aria-controls` are caught by the test.

Suggested implementation:

```typescript
  it('preserves the add-form CRUD scaffolding (out-of-scope guard against accidental refactor)', () => {
    renderCharges([]);
    // Coherence pass 2026-07: the form is collapsed by default (the list owns
    // the first screen) and opens through the header toggle.
    const toggle = screen.getByTestId('charges-add-toggle');
    expect(toggle).toHaveAttribute('aria-expanded', 'false');
    expect(screen.queryByLabelText('Libellé')).toBeNull();

    fireEvent.click(toggle);

    // The toggle now reflects the open state and the form appears in the DOM.
    expect(toggle).toHaveAttribute('aria-expanded', 'true');
    // The 5 form fields + submit button stay reachable by their labels and roles.
    expect(screen.getByLabelText('Libellé')).toBeInTheDocument();
    expect(screen.getByLabelText(/Montant/)).toBeInTheDocument();
  });

  it('passes paymentDay + computed paymentMonths to createChargeAction', async () => {
    createChargeMock.mockResolvedValue({ ok: true });
    renderCharges([]);

    const toggle = screen.getByTestId('charges-add-toggle');
    expect(toggle).toHaveAttribute('aria-expanded', 'false');
    expect(screen.queryByLabelText('Libellé')).toBeNull();

    fireEvent.click(toggle);

    expect(toggle).toHaveAttribute('aria-expanded', 'true');
    expect(screen.getByLabelText('Libellé')).toBeInTheDocument();

    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Assurance' } });
    fireEvent.change(screen.getByLabelText(/Montant/), { target: { value: '120' } });
    fireEvent.change(screen.getByLabelText(/jour du mois/i), { target: { value: '15' } });
    expect(screen.getByTestId('charges-paid-summary')).toHaveTextContent('1/1');
  });

```

If the add-form is conditionally rendered via `aria-controls` pointing to the form container, you may optionally strengthen the assertions further by checking that the element with `id` referenced by `aria-controls` is absent/present before/after the click:
1. Read `const controlsId = toggle.getAttribute('aria-controls');`
2. Assert `expect(screen.queryByTestId(controlsId!)).toBeNull()` (or `queryByRole`/`queryByLabelText`) before click.
3. Assert `expect(screen.getByTestId(controlsId!)).toBeInTheDocument()` after click.
Adjust the selector (`getByTestId`, `getByRole`, etc.) to match how the form container is identified in the implementation.
</issue_to_address>

### Comment 3
<location path="src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx" line_range="491-500" />
<code_context>
+  it('sorts rows by resolved due date ascending within a group (stable under ticking)', () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Strengthen the sort test to prove the order is stable when ticking a bill

This test only checks the initial ascending order, but the description also promises stability when ticking. To cover that, please also (in this test or a new one) capture the list item IDs, tick one of the items via its testid, then re-read the list and assert the ID order is unchanged. That will validate the “no reordering under the user’s finger” behavior.

Suggested implementation:

```typescript
  it('sorts rows by resolved due date ascending within a group (stable under ticking)', async () => {
    const late = { ...monthlyCharge, id: 'late', label: 'Late bill', paymentDay: 20 };
    const early = { ...monthlyCharge, id: 'early', label: 'Early bill', paymentDay: 3 };

    renderCharges([late, early], { currentPeriod: { year: 2026, month: 1 } });

    const group = within(screen.getByTestId('charges-group-monthly'));

    const listItemsBefore = group.getAllByRole('listitem');
    const idsBefore = listItemsBefore.map((el) => el.getAttribute('data-testid'));
    expect(idsBefore).toEqual(['charges-row-early', 'charges-row-late']);

    // Tick one of the items and ensure the order stays the same
    // (no reordering under the user's finger)
    const firstRowCheckbox = within(listItemsBefore[0]).getByRole('checkbox');
    await user.click(firstRowCheckbox);

    const listItemsAfter = group.getAllByRole('listitem');
    const idsAfter = listItemsAfter.map((el) => el.getAttribute('data-testid'));
    expect(idsAfter).toEqual(idsBefore);
  });

```

This change assumes:
1. There is a `user` instance from `@testing-library/user-event` already available in this test file (e.g. via `const user = userEvent.setup()` in a `beforeEach` or at the top level).
2. Each list item row contains a checkbox reachable via `getByRole('checkbox')` from within the row element.

If either assumption is false, you will need to:
- Ensure `user` is defined, typically by importing `userEvent` and creating a shared instance.
- Adjust `getByRole('checkbox')` to match your actual ticking control, e.g. by using a specific `data-testid` inside the row or a different role/selector.
</issue_to_address>

### Comment 4
<location path="src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx" line_range="501-506" />
<code_context>
+    expect(ids).toEqual(['charges-row-early', 'charges-row-late']);
+  });
+
+  it('labels each group subtotal with its cadence unit', () => {
+    renderCharges(sampleCharges, {
+      subtotals: { monthly: 1200, quarterly: 0, semiannual: 0, annual: 300 },
+    });
+    expect(screen.getByTestId('charges-group-subtotal-monthly')).toHaveTextContent('/mois');
+    expect(screen.getByTestId('charges-group-subtotal-annual')).toHaveTextContent('/an');
   });

</code_context>
<issue_to_address>
**suggestion (testing):** Cover all cadence units and the new due-count badge in header tests

The current test already covers monthly and annual, but two small additions would make regressions less likely:

1. Extend or parameterize this test to also assert quarterly and semiannual labels, so all four cadences are verified and any translation-key mismatch is caught.
2. Add a dedicated test for the header title’s `dueCount` badge, verifying both the “N total / M due this month” wording and that the badge is omitted when `dueThisMonth.length === 0`.

This will tie the numeric explanation and unit suffixes directly to tests.

Suggested implementation:

```typescript
  it('sorts rows by resolved due date ascending within a group (stable under ticking)', () => {
    const late = { ...monthlyCharge, id: 'late', label: 'Late bill', paymentDay: 20 };
    const early = { ...monthlyCharge, id: 'early', label: 'Early bill', paymentDay: 3 };
    renderCharges([late, early], { currentPeriod: { year: 2026, month: 1 } });
    const ids = within(screen.getByTestId('charges-group-monthly'))
      .getAllByRole('listitem')
      .map((el) => el.getAttribute('data-testid'));
    expect(ids).toEqual(['charges-row-early', 'charges-row-late']);
  });

  it.each([
    ['monthly', '/mois'],
    ['quarterly', '/trimestre'],
    ['semiannual', '/semestre'],
    ['annual', '/an'],
  ] as const)(
    'labels %s group subtotal with its cadence unit',
    (cadence, expectedSuffix) => {
      renderCharges(sampleCharges, {
        subtotals: { monthly: 1200, quarterly: 800, semiannual: 600, annual: 300 },
      });

      expect(
        screen.getByTestId(`charges-group-subtotal-${cadence}`),
      ).toHaveTextContent(expectedSuffix);
    },
  );

  it('shows the header due-count badge with total and current-month counts, and hides it when none are due', () => {
    // Scenario 1: some charges are due this month
    renderCharges(sampleCharges, {
      currentPeriod: { year: 2026, month: 1 },
    });

    const dueCountBadge = screen.getByTestId('charges-header-due-count');
    // Example expected wording: "N au total / M dues ce mois-ci"
    // The exact numbers depend on sampleCharges and the header implementation.
    expect(dueCountBadge.textContent).toMatch(/au total/);
    expect(dueCountBadge.textContent).toMatch(/dues? ce mois[- ]ci/);

    // Scenario 2: no charges due this month should omit the badge entirely
    renderCharges(sampleCharges, {
      currentPeriod: { year: 2026, month: 12 },
      // Ensure the helper treats this as "no dues this month"; see additional_changes.
    });

    expect(screen.queryByTestId('charges-header-due-count')).toBeNull();
  });

  it('preserves the add-form CRUD scaffolding (out-of-scope guard against accidental refactor)', () => {

```

1. Align the cadence-label expectations with your actual i18n keys:
   - If quarterly and semiannual use different suffixes than `/trimestre` and `/semestre`, update the `expectedSuffix` values accordingly.
   - Ensure the corresponding DOM nodes have `data-testid="charges-group-subtotal-quarterly"` and `data-testid="charges-group-subtotal-semiannual"` as used in the test.
2. The `renderCharges` helper currently only shows part of its API in the snippet; make sure it:
   - Accepts the `subtotals` object with all four cadence keys (`monthly`, `quarterly`, `semiannual`, `annual`) and renders the four subtotal elements with the `charges-group-subtotal-*` test IDs.
3. For the header due-count badge test:
   - Confirm that the header renders a badge with `data-testid="charges-header-due-count"` when at least one charge is due in the current period, and that it includes both the total count and the “due this month” count in its text. If your actual wording differs (e.g. English vs French), adjust the two `toMatch` expectations to the real copy, or replace them with a single `toHaveTextContent` comparing the precise string.
   - Ensure that `renderCharges` re-renders a fresh tree on the second call in the test (if it does internal caching you might need to wrap the second scenario in a separate test or call `cleanup()` between them).
   - Adapt the second scenario so that, under the given `currentPeriod`, no charges qualify as "due this month". Depending on your implementation, this might require either:
     * Changing the `currentPeriod` to a month where `sampleCharges` has no due items, or
     * Adding an explicit override (e.g. `dueThisMonth: []`) if `renderCharges` supports that.
</issue_to_address>

### Comment 5
<location path="src/app/[locale]/app/charges/ChargesClient.tsx" line_range="125" />
<code_context>
+  // Rows are sorted by resolved due date ascending — a STABLE order (ticking a
+  // bill never reorders rows under the user's finger, unlike unpaid-first).
+  // The resolver is paid-independent for the date, so `isPaid: false` is fine.
+  const groups = useMemo(() => {
+    const dueIsoOf = (c: RawCharge): string =>
+      currentPeriodDueDate(
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the sorting and payment-state logic into shared helpers and simplifying classNames/config to keep the ChargesClient component more declarative and easier to scan.

You can keep all the new behavior while making the component easier to follow by extracting a couple of pure helpers and flattening some JSX.

### 1. Extract `groups` computation

The `useMemo` currently defines `dueIsoOf`, decorates rows to sort, then undecorates. Pull that into a top-level helper so the render code reads as “group + sort”, not “implement sorting”:

```ts
function sortChargesByDueDate(
  charges: RawCharge[],
  currentPeriod: CurrentPeriod,
  todayIso: string,
): RawCharge[] {
  const dueIsoOf = (c: RawCharge): string =>
    currentPeriodDueDate(
      { isActive: c.isActive, paymentMonths: c.paymentMonths, paymentDay: c.paymentDay },
      currentPeriod,
      todayIso,
      false,
    )?.dueDateIso ?? '9999-12-31';

  return [...charges]
    .map((c) => ({ c, dueIso: dueIsoOf(c) }))
    .sort((a, b) => (a.dueIso < b.dueIso ? -1 : a.dueIso > b.dueIso ? 1 : 0))
    .map(({ c }) => c);
}
```

Then the hook becomes:

```ts
const groups = useMemo(
  () =>
    FREQUENCIES.map((freq) => ({
      freq,
      rows: sortChargesByDueDate(
        charges.filter((c) => c.frequency === freq),
        currentPeriod,
        todayIso,
      ),
    })).filter((group) => group.rows.length > 0),
  [charges, currentPeriod, todayIso],
);
```

This keeps the same stable ordering behavior but makes “what is a group?” obvious at a glance.

### 2. Extract per‑group payment state

You’re repeating the `isActive`/`paymentMonths.includes` logic inline. Centralizing it will reduce duplication and keep the render tree declarative:

```ts
type GroupPaymentState = {
  remaining: number;
  allPaid: boolean;
};

function computeGroupPaymentState(
  rows: RawCharge[],
  currentPeriod: CurrentPeriod,
  optimisticPaid: ReadonlySet<string>,
): GroupPaymentState {
  const groupDue = rows.filter(
    (c) => c.isActive && c.paymentMonths.includes(currentPeriod.month),
  );

  const remaining = groupDue
    .filter((c) => !optimisticPaid.has(c.id))
    .reduce((sum, c) => sum + c.amount, 0);

  const allPaid = groupDue.length > 0 && groupDue.every((c) => optimisticPaid.has(c.id));

  return { remaining, allPaid };
}
```

Usage in the map:

```ts
{groups.map(({ freq, rows }) => {
  const headingId = `charges-group-${freq}-heading`;
  const { remaining: groupRemaining, allPaid: groupAllPaid } = computeGroupPaymentState(
    rows,
    currentPeriod,
    optimisticPaid,
  );

  // ... JSX unchanged, using groupRemaining, groupAllPaid ...
})}
```

Same logic, but the component no longer needs to know the exact payment rules in multiple places.

### 3. Flatten the summary className conditionals

If you have a `cn`/`clsx` helper, you can avoid long template literals and make the success state vs default state clearer:

```ts
import { cn } from '@/lib/cn';

<div
  data-testid="charges-paid-summary"
  className={cn(
    'mb-4 flex items-center justify-between gap-3 rounded-lg px-4 py-3',
    allPaidThisMonth ? 'bg-surface-muted' : 'bg-brand-600/10',
  )}
>
  <div className="min-w-0" aria-live="polite" role="status" aria-atomic="true">
    <p
      className={cn(
        'flex items-center gap-1.5 text-xs font-medium',
        allPaidThisMonth ? 'text-brand-text' : 'text-muted-foreground',
      )}
    >
      {allPaidThisMonth && <Check aria-hidden className="h-3.5 w-3.5" strokeWidth={3} />}
      {allPaidThisMonth ? t('allPaidTitle') : t('remainingLabel')}
    </p>
    <p
      data-testid="charges-remaining-amount"
      className={cn(
        'text-xl font-bold tabular-nums',
        allPaidThisMonth ? 'text-brand-text' : 'text-foreground',
      )}
    >
      {formatCurrency(remainingThisMonth, locale)}
    </p>
  </div>
  {/* ... */}
</div>
```

Behavior stays identical; JSX is easier to scan.

### 4. Move `SUBTOTAL_UNIT_KEY` to shared config

To keep this component focused on behavior, you can colocate the frequency → translation key mapping with the other frequency configuration:

```ts
// chargesConfig.ts
export const FREQUENCIES = ['monthly', 'quarterly', 'semiannual', 'annual'] as const;
export type Frequency = (typeof FREQUENCIES)[number];

export const SUBTOTAL_UNIT_KEY: Record<Frequency, string> = {
  monthly: 'subtotalUnit.monthly',
  quarterly: 'subtotalUnit.quarterly',
  semiannual: 'subtotalUnit.semiannual',
  annual: 'subtotalUnit.annual',
};
```

Then in the component:

```ts
import { FREQUENCIES, SUBTOTAL_UNIT_KEY, type Frequency } from './chargesConfig';

// ...

{t(SUBTOTAL_UNIT_KEY[freq])}
```

No behavior change, but the component sheds configuration noise and the responsibilities are clearer.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
Comment thread src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
Comment thread src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx
Comment thread src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx Outdated
Comment thread src/app/[locale]/app/charges/ChargesClient.tsx
thierryvm and others added 2 commits July 18, 2026 12:20
…s cohérentes

Mirror the Obsidian vault handoff into the repo per the double-redundancy
rule. Session content is historical (#223/#224 merged, #221 closed); the
durable value is the decision log and the still-active anti-patterns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l cadence units (Sourcery)

Four testing suggestions from the #226 review applied: successful creation
collapses the form; the header toggle reflects state via aria-expanded;
ticking a bill never reorders rows; every cadence unit + the due-this-month
title count are asserted. 42 ChargesClient tests green.
@thierryvm
thierryvm merged commit d2e6df6 into main Jul 18, 2026
9 of 10 checks passed
@thierryvm
thierryvm deleted the feat/charges-page-coherence branch July 18, 2026 10:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:charges Charges management status:review-needed Ready for review type:feat New user-facing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant