Skip to content

feat(commitments): Engagements page — CRUD, remaining balance, progress, ticks (épic PR-2)#234

Merged
thierryvm merged 3 commits into
mainfrom
feat/commitments-page
Jul 20, 2026
Merged

feat(commitments): Engagements page — CRUD, remaining balance, progress, ticks (épic PR-2)#234
thierryvm merged 3 commits into
mainfrom
feat/commitments-page

Conversation

@thierryvm

@thierryvm thierryvm commented Jul 20, 2026

Copy link
Copy Markdown
Owner

PR-2 — La page « Engagements » (épic Dettes & échéanciers)

C'est ici que le modèle de PR-1 devient concret : /app/commitments, accessible depuis le menu (Charges · Engagements · Dépenses).

Ce que tu vois pour chaque engagement

  • Ce qu'il te reste à payer, en gros, qui descend à chaque échéance cochée jusqu'à ✓ 0 €.
  • Une barre de progression + « 4/17 échéances de 250 € · dernière en avril 2027 ».
  • Une facture ponctuelle s'affiche simplement « 340 € en octobre 2026 » (pas de jargon d'échéancier).
  • Une coche 44px qui n'apparaît que si une échéance tombe le mois affiché — impossible de cocher un mois où tu ne dois rien.
  • En tête : le total restant dû de tous tes engagements.

Sous le capot

4 Server Actions calquées sur le contrat des charges : garde uuid, authz workspace (RLS + filtre explicite), rate-limit, audit, revalidate. Le toggle lit le montant de l'échéance depuis l'engagement, jamais depuis le client, et reste idempotent (INSERT payé / DELETE non payé derrière l'index unique). La page lit en select('*') pour qu'une fenêtre de déploiement ne puisse jamais vider l'écran (leçon de l'incident du 18/07).

Tests

22 nouveaux : 11 UI (arithmétique du solde, barre à 0/12/100 %, coche optimiste, one-off sans jargon, création avec ancre sur le mois affiché) + 11 action (dont refus d'un engagement d'un autre workspace, montant lu côté serveur, toggle idempotent). Suite complète 1554 verte, typecheck/lint/build clean.

Smoke @Thierry

  1. Menu → Engagements → « + Ajouter un engagement ».
  2. Crée ton Crédit voiture : type Crédit, montant restant 4 200 €, par échéance 250 €, 17 échéances → la ligne affiche « reste 4 200 € », barre à 0 %.
  3. Coche l'échéance du mois → reste 3 950 €, barre qui avance. Décoche → ça remonte.
  4. Teste une facture ponctuelle (type Facture ponctuelle, 340 €) : pas de champs d'échéances.

Note : le bouton « convertir une charge en engagement » (décision D4) arrive en PR-3 avec l'intégration cockpit — ici tu crées tes engagements à la main pour valider le modèle.

🤖 Generated with Claude Code

Summary by Sourcery

Introduire une page Engagements/Commitments dans l’application avec un CRUD complet et le « ticking » des paiements, intégrée à l’authentification de l’espace de travail, à l’audit logging et à la revalidation du tableau de bord.

Nouvelles fonctionnalités :

  • Ajouter une nouvelle page /app/commitments qui liste les engagements actifs avec le solde restant, une barre de progression et des contrôles de validation mensuelle des paiements.
  • Permettre la création, la suppression et l’activation/désactivation des paiements des engagements via des actions serveur qui ancrent les échéanciers à la période actuellement affichée.
  • Exposer la nouvelle section Commitments dans la navigation d’en-tête pour les utilisateurs authentifiés.

Améliorations :

  • Étendre l’audit logging avec les événements de cycle de vie des engagements et de bascule des paiements pour assurer la traçabilité.
  • Réutiliser les modèles d’autorisation d’espace de travail, de limitation de débit (rate limiting) et de revalidation déjà utilisés pour les charges, pour les données des engagements.
  • Mapper les enregistrements d’engagement et de paiement depuis Supabase vers une structure adaptée au client, qui prend en charge les mises à jour optimistes de l’interface (optimistic UI).

Tests :

  • Ajouter des tests d’actions serveur couvrant la création, la suppression, le comportement de bascule des paiements, l’autorisation et la limitation de débit pour les engagements.
  • Ajouter des tests UI pour la page des engagements couvrant l’arithmétique des soldes, les mises à jour de progression, le rendu ponctuel (one-off), la visibilité des ticks, la bascule optimiste et les parcours de création.
Original summary in English

Summary by Sourcery

Introduce an Engagements/Commitments page in the app with full CRUD and payment ticking, wired into workspace auth, audit logging, and dashboard revalidation.

New Features:

  • Add a new /app/commitments page that lists active commitments with remaining balance, progress bar, and monthly payment tick controls.
  • Allow creating, deleting, and payment-toggling commitments via server actions that anchor schedules to the currently viewed period.
  • Expose the new Commitments section in the authenticated header navigation.

Enhancements:

  • Extend audit logging with commitment lifecycle and payment toggle events for traceability.
  • Reuse workspace authorization, rate limiting, and revalidation patterns from charges for commitments data.
  • Map commitment and payment records from Supabase into a client-friendly shape that supports optimistic UI updates.

Tests:

  • Add server action tests covering commitment creation, deletion, payment toggle behavior, authorization, and rate limiting.
  • Add UI tests for the commitments page covering balance arithmetic, progress updates, one-off rendering, tick visibility, optimistic toggling, and creation workflows.

…rogress, instalment ticks (épic PR-2)

The page that makes the model concrete: for every commitment you see WHAT IS
LEFT to pay and how far along you are, and ticking an instalment moves both.

- /app/commitments (nav: Charges · Engagements · Dépenses). Server page reads
  commitments + their payment ledger (RLS-scoped, select('*') so a schema/deploy
  window can never blank the page), maps to the pure domain client-side.
- Each row: remaining balance counting down to a ✓ 0 €, a progress bar
  (role=progressbar + aria-valuenow), 'x/y échéances de Z · dernière en {mois}'
  for schedules or a single dated amount for a one-off, and a 44px tick shown
  ONLY when an instalment falls due in the viewed period. Header carries the
  total still owed across every commitment.
- 4 Server Actions cloned from the charges contract: uuid guard, workspace
  authz (RLS + explicit filter), rate-limit, audit, revalidate. The toggle
  reads the instalment amount FROM THE COMMITMENT — never from the client —
  and is idempotent (INSERT paid / DELETE unpaid) behind the unique index.
- Add form collapsed by default, instalment fields hidden for a one-off.

i18n x5: app.commitments.* + errors.commitments.* + nav label.
22 new tests (11 UI incl. optimistic tick + balance arithmetic, 11 action incl.
cross-workspace authz). Full suite 1554 green, typecheck/lint/build clean.
@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 20, 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 20, 2026 9:40pm

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

sourcery-ai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Guide du/de la relecteur·rice

Implémente la nouvelle page Engagements (/app/commitments) avec des actions serveur CRUD complètes, une UI optimiste pour les coches de paiement, les calculs de solde restant/progression, ainsi que les tests associés, le tout connecté à la navigation et à la journalisation d’audit, en miroir du contrat existant pour les charges.

Diagramme de séquence pour l’action serveur de bascule du paiement d’un engagement

sequenceDiagram
  actor User
  participant CommitmentsClient
  participant toggleCommitmentPaymentAction
  participant authorizedWorkspace
  participant Supabase as Supabase_DB
  participant logAuditEvent
  participant revalidateCommitments

  User->>CommitmentsClient: click paid tick
  CommitmentsClient->>toggleCommitmentPaymentAction: toggleCommitmentPaymentAction(commitmentId, periodYear, periodMonth)
  toggleCommitmentPaymentAction->>authorizedWorkspace: authorizedWorkspace()
  authorizedWorkspace-->>toggleCommitmentPaymentAction: { ok: true, userId, workspaceId }
  toggleCommitmentPaymentAction->>Supabase: select commitments by id, workspace_id
  Supabase-->>toggleCommitmentPaymentAction: commitment(total_amount, installment_amount, installments_total)
  toggleCommitmentPaymentAction->>Supabase: select commitment_payments by commitment_id, period_year, period_month
  alt existing payment
    Supabase-->>toggleCommitmentPaymentAction: existing payment
    toggleCommitmentPaymentAction->>Supabase: delete from commitment_payments by id, workspace_id
    Supabase-->>toggleCommitmentPaymentAction: delete ok
    toggleCommitmentPaymentAction->>logAuditEvent: logAuditEvent(COMMITMENT_PAYMENT_TOGGLED)
    toggleCommitmentPaymentAction->>revalidateCommitments: revalidateCommitments()
    toggleCommitmentPaymentAction-->>CommitmentsClient: { ok: true, data: { paid: false } }
  else no existing payment
    Supabase-->>toggleCommitmentPaymentAction: no existing payment
    toggleCommitmentPaymentAction->>Supabase: insert into commitment_payments(paid_amount, workspace_id, period_year, period_month)
    Supabase-->>toggleCommitmentPaymentAction: insert ok
    toggleCommitmentPaymentAction->>logAuditEvent: logAuditEvent(COMMITMENT_PAYMENT_TOGGLED)
    toggleCommitmentPaymentAction->>revalidateCommitments: revalidateCommitments()
    toggleCommitmentPaymentAction-->>CommitmentsClient: { ok: true, data: { paid: true } }
  end
  CommitmentsClient-->>User: update optimistic tick and remaining balance
Loading

Diagramme de flux pour le chargement et le rendu de la page des engagements

flowchart LR
  U[User
/app/commitments] --> P[CommitmentsPage]
  P --> WS[getWorkspaceSnapshot]
  P --> L[getLocale]
  P --> S[Supabase
createClient]
  S --> CQ[select * from commitments
where workspace_id]
  S --> PQ[select commitment_id,
period_year, period_month
from commitment_payments]
  CQ --> PM[map rows to RawCommitment]
  PQ --> PK[build paidKeysByCommitment]
  PM --> CC[CommitmentsClient]
  PK --> CC
  WS --> CC
  L --> CC
  CC --> UI[Commitments list,
remaining balance,
progress bars,
payment ticks]
Loading

Changements au niveau des fichiers

Changement Détails Fichiers
Ajouter l’UI côté client pour la page Commitments avec coche optimiste, calcul du solde restant, barre de progression, et flux de création/suppression ancrés à la période affichée.
  • Définir la forme RawCommitment et la mapper vers les helpers de domaine Commitment pour le solde restant, les mensualités payées, les vérifications d’échéance dans la période et le calcul de la période de fin.
  • Implémenter l’état de paiement optimiste en utilisant useOptimistic sur un ensemble de clés engagement-période, alimentant le bouton de coche et les libellés de progression/solde restant.
  • Construire un formulaire d’ajout d’engagement qui affiche conditionnellement les champs de mensualités selon le type (dette/plan de paiement vs ponctuel), valide les entrées numériques basiques et appelle l’action de création ancrée à la période actuelle.
  • Rendre la liste des engagements avec un en-tête de total restant, un montant restant par ligne ou une coche à 0 €, une barre de progression, un texte de résumé planifié/ponctuel, un bouton de coche conditionnel seulement lorsque l’échéance tombe dans la période actuelle, et un bouton de suppression relié à l’action de suppression.
src/app/[locale]/app/commitments/CommitmentsClient.tsx
Introduire des actions serveur pour les engagements (création, mise à jour, suppression, bascule de paiement) avec autorisation de l’espace de travail, limitation de débit (rate limiting), journalisation d’audit et revalidation du cache en miroir des charges.
  • Implémenter le helper authorizedWorkspace pour résoudre la première adhésion propriétaire/éditeur pour l’utilisateur courant ou retourner un code d’erreur, réutilisé par toutes les actions.
  • Ajouter createCommitmentAction et updateCommitmentAction en utilisant des schémas zod pour valider les entrées, insérer/mettre à jour des lignes en snake_case dans la table commitments et émettre des événements d’audit COMMITMENT_CREATED/UPDATED.
  • Ajouter deleteCommitmentAction avec validation UUID, périmètre d’espace de travail via RLS + filtre explicite, journalisation d’audit et revalidation en cas de succès.
  • Implémenter toggleCommitmentPaymentAction comme un insert/delete idempotent sur commitment_payments, lisant le montant payé depuis l’engagement (installment_amount ou total_amount pour un paiement ponctuel), en appliquant le périmètre d’espace de travail, et en retournant si la période est désormais payée ou non.
src/lib/actions/commitments.ts
Connecter la page des engagements au routage, au chargement de données, à la navigation et à la journalisation grâce à des requêtes select('*') sûres et au calcul des clés de grand livre (ledger).
  • Définir CommitmentsPage qui récupère l’instantané d’espace de travail et la locale, puis charge commitments et commitment_payments depuis Supabase avec des filtres explicites sur workspace_id.
  • Mapper les lignes de commitments depuis les champs DB en snake_case vers des objets RawCommitment, y compris les conversions numériques et les cast d’énumérations.
  • Construire paidKeysByCommitment à partir des lignes de commitment_payments en utilisant des clés de grand livre ${year}-${month} consommées par le composant client.
  • Ajouter generateMetadata pour le titre de la page des engagements et intégrer la route dans l’en-tête de navigation de l’application via un nouveau lien /app/commitments.
src/app/[locale]/app/commitments/page.tsx
src/components/layout/Header.tsx
Étendre la journalisation d’audit et les tests pour couvrir les actions sur les engagements, y compris l’autorisation d’espace de travail, la limitation de débit et le comportement de bascule de paiement.
  • Ajouter les constantes d’événements COMMITMENT_CREATED/UPDATED/DELETED/PAYMENT_TOGGLED à l’énum d’audit log pour être utilisées par les actions sur les engagements.
  • Créer une suite vitest pour les actions sur les engagements qui mock Supabase, la limitation de débit et la journalisation d’audit, en vérifiant les payloads corrects, les appels d’audit, les codes d’erreur et le comportement idempotent de la bascule.
  • Ajouter une suite de tests UI CommitmentsClient couvrant l’état vide, le calcul du solde restant, les pourcentages de progression, le rendu des paiements ponctuels sans jargon de planification, la coche optimiste, la création ancrée, les champs de mensualités conditionnels et le câblage de la suppression.
src/lib/security/audit-log.ts
src/lib/actions/__tests__/commitments.test.ts
src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx
Mettre à jour les bundles de messages i18n pour prendre en charge les libellés, résumés et toasts de la page des engagements dans plusieurs locales.
  • Ajouter les chaînes de l’espace de noms app.commitments (titre, sous-titre, libellés, aides textuelles, résumés, textes ARIA, toasts) en français et dans les autres locales prises en charge.
  • S’assurer que les libellés de type (dette, plan de paiement, ponctuel) et les clés de validation/erreur référencées par CommitmentsClient et les actions existent dans chaque bundle de locale.
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 une issue GitHub à partir d’un commentaire de revue : Demandez à Sourcery de créer une issue à partir d’un commentaire de revue en y répondant. Vous pouvez également répondre à un commentaire de revue avec @sourcery-ai issue pour créer une issue à 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 le guide du/de la relecteur·rice : Commentez @sourcery-ai guide sur la pull request pour (re)générer le guide du/de la relecteur·rice à 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 souhaitez 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 fonctionnalités de revue comme le résumé de pull request généré par Sourcery, le guide du/de la relecteur·rice, et d’autres.
  • Changer la langue de revue.
  • Ajouter, supprimer ou modifier des instructions de revue personnalisées.
  • Ajuster d’autres paramètres de revue.

Obtenir de l’aide

Original review guide in English

Reviewer's Guide

Implements the new Engagements (/app/commitments) page with full CRUD server actions, optimistic UI for payment ticks, remaining balance/progress calculations, and associated tests, wired into navigation and audit logging, mirroring the existing charges contract.

Sequence diagram for commitment payment toggle server action

sequenceDiagram
  actor User
  participant CommitmentsClient
  participant toggleCommitmentPaymentAction
  participant authorizedWorkspace
  participant Supabase as Supabase_DB
  participant logAuditEvent
  participant revalidateCommitments

  User->>CommitmentsClient: click paid tick
  CommitmentsClient->>toggleCommitmentPaymentAction: toggleCommitmentPaymentAction(commitmentId, periodYear, periodMonth)
  toggleCommitmentPaymentAction->>authorizedWorkspace: authorizedWorkspace()
  authorizedWorkspace-->>toggleCommitmentPaymentAction: { ok: true, userId, workspaceId }
  toggleCommitmentPaymentAction->>Supabase: select commitments by id, workspace_id
  Supabase-->>toggleCommitmentPaymentAction: commitment(total_amount, installment_amount, installments_total)
  toggleCommitmentPaymentAction->>Supabase: select commitment_payments by commitment_id, period_year, period_month
  alt existing payment
    Supabase-->>toggleCommitmentPaymentAction: existing payment
    toggleCommitmentPaymentAction->>Supabase: delete from commitment_payments by id, workspace_id
    Supabase-->>toggleCommitmentPaymentAction: delete ok
    toggleCommitmentPaymentAction->>logAuditEvent: logAuditEvent(COMMITMENT_PAYMENT_TOGGLED)
    toggleCommitmentPaymentAction->>revalidateCommitments: revalidateCommitments()
    toggleCommitmentPaymentAction-->>CommitmentsClient: { ok: true, data: { paid: false } }
  else no existing payment
    Supabase-->>toggleCommitmentPaymentAction: no existing payment
    toggleCommitmentPaymentAction->>Supabase: insert into commitment_payments(paid_amount, workspace_id, period_year, period_month)
    Supabase-->>toggleCommitmentPaymentAction: insert ok
    toggleCommitmentPaymentAction->>logAuditEvent: logAuditEvent(COMMITMENT_PAYMENT_TOGGLED)
    toggleCommitmentPaymentAction->>revalidateCommitments: revalidateCommitments()
    toggleCommitmentPaymentAction-->>CommitmentsClient: { ok: true, data: { paid: true } }
  end
  CommitmentsClient-->>User: update optimistic tick and remaining balance
Loading

Flow diagram for loading and rendering the commitments page

flowchart LR
  U[User
/app/commitments] --> P[CommitmentsPage]
  P --> WS[getWorkspaceSnapshot]
  P --> L[getLocale]
  P --> S[Supabase
createClient]
  S --> CQ[select * from commitments
where workspace_id]
  S --> PQ[select commitment_id,
period_year, period_month
from commitment_payments]
  CQ --> PM[map rows to RawCommitment]
  PQ --> PK[build paidKeysByCommitment]
  PM --> CC[CommitmentsClient]
  PK --> CC
  WS --> CC
  L --> CC
  CC --> UI[Commitments list,
remaining balance,
progress bars,
payment ticks]
Loading

File-Level Changes

Change Details Files
Add client-side Commitments page UI with optimistic ticking, remaining balance computation, progress bar, and creation/deletion flows anchored to the viewed period.
  • Define RawCommitment shape and map it to domain Commitment helpers for remaining balance, installments paid, due-in-period checks, and end period computation.
  • Implement optimistic payment state using useOptimistic over a set of commitment-period keys, driving the tick button UI and progress/remaining labels.
  • Build an add-commitment form that conditionally shows installment fields based on kind (debt/installment plan vs one-off), validates basic numeric input, and calls the create action anchored to the current period.
  • Render the commitments list with total remaining header, per-row remaining amount or 0€ checkmark, progress bar, schedule/one-off summary text, conditional tick button only when due in current period, and delete button wired to the delete action.
src/app/[locale]/app/commitments/CommitmentsClient.tsx
Introduce commitments server actions (create, update, delete, toggle payment) with workspace authz, rate limiting, audit logging, and cache revalidation mirroring charges.
  • Implement authorizedWorkspace helper to resolve first owner/editor membership for the current user or return an error code, reused by all actions.
  • Add createCommitmentAction and updateCommitmentAction using zod schemas to validate input, insert/update snake_case rows in the commitments table, and emit COMMITMENT_CREATED/UPDATED audit events.
  • Add deleteCommitmentAction with UUID validation, workspace scoping via RLS + explicit filter, audit logging, and revalidation on success.
  • Implement toggleCommitmentPaymentAction as an idempotent insert/delete on commitment_payments, reading the paid amount from the commitment (installment_amount or total_amount for one-off), enforcing workspace scoping, and returning whether the period is now paid.
src/lib/actions/commitments.ts
Wire the commitments page into routing, data loading, navigation, and logging with safe select('*') queries and ledger key computation.
  • Define CommitmentsPage that fetches workspace snapshot and locale, then loads commitments and commitment_payments from Supabase with explicit workspace_id filters.
  • Map commitments rows from snake_case DB fields into RawCommitment objects, including numeric conversions and enum casts.
  • Build paidKeysByCommitment from commitment_payments rows using ${year}-${month} ledger keys consumed by the client component.
  • Add generateMetadata for the commitments page title and integrate the route into the app nav header via a new /app/commitments link.
src/app/[locale]/app/commitments/page.tsx
src/components/layout/Header.tsx
Extend audit logging and tests to cover commitments actions, including workspace authz, rate limiting, and payment toggling behavior.
  • Add COMMITMENT_CREATED/UPDATED/DELETED/PAYMENT_TOGGLED event constants to the audit log enum for use by commitments actions.
  • Create a vitest suite for commitments actions that mocks Supabase, rate limiting, and audit logging, asserting correct payloads, audit calls, error codes, and idempotent toggle behavior.
  • Add a CommitmentsClient UI test suite exercising empty state, remaining balance math, progress percentages, one-off rendering without schedule jargon, optimistic ticking, anchored creation, conditional installment fields, and deletion wiring.
src/lib/security/audit-log.ts
src/lib/actions/__tests__/commitments.test.ts
src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx
Update i18n message bundles to support the commitments page labels, summaries, and toasts in multiple locales.
  • Add app.commitments namespace strings (title, subtitle, labels, hints, summaries, ARIA texts, toasts) in French and other supported locales.
  • Ensure kind labels (debt, installment plan, one-off) and validation/error keys referenced by CommitmentsClient and actions exist in each locale bundle.
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.

Salut – j’ai trouvé 7 problèmes et laissé quelques retours de haut niveau :

  • La logique de coche optimiste dans CommitmentsClient ne rétablit jamais l’état de l’UI quand toggleCommitmentPaymentAction échoue, ce qui fait que les utilisateurs peuvent voir un mois marqué comme payé alors qu’il ne l’est pas réellement ; envisage de revenir sur le Set optimiste en cas d’erreur pour garder l’affichage cohérent avec le backend.
  • Dans le handler onCreate de CommitmentsClient, seul totalAmount est validé avant l’envoi, alors que installmentAmount et installmentsTotal peuvent être NaN pour les engagements qui ne sont pas en one‑shot ; ajouter une validation basique côté client pour ces champs éviterait des allers‑retours inutiles et des toasts d’erreur déroutants.
Prompt pour les agents IA
Please address the comments from this code review:

## Overall Comments
- The optimistic tick logic in `CommitmentsClient` never rolls back the UI state when `toggleCommitmentPaymentAction` fails, so users can end up seeing a paid month that is actually unpaid; consider reverting the optimistic Set on error to keep the display consistent with the backend.
- In the `onCreate` handler for `CommitmentsClient`, only `totalAmount` is validated before submitting, while `installmentAmount` and `installmentsTotal` can be NaN for non-one-off commitments; adding basic client-side validation for these fields would avoid avoidable round-trips and confusing error toasts.

## Individual Comments

### Comment 1
<location path="src/app/[locale]/app/commitments/CommitmentsClient.tsx" line_range="125-127" />
<code_context>
+  function onTogglePaid(c: RawCommitment) {
+    const entry = `${c.id}|${periodKey(currentPeriod.year, currentPeriod.month)}`;
+    startTransition(async () => {
+      applyOptimisticPaid(entry);
+      try {
+        const result = await toggleCommitmentPaymentAction({
+          commitmentId: c.id,
+          periodYear: currentPeriod.year,
</code_context>
<issue_to_address>
**issue (bug_risk):** Optimistic paid state is never reverted when the toggle action fails, which can desync UI from the backend.

In `onTogglePaid`, the optimistic state is updated before the server call and never reverted if `toggleCommitmentPaymentAction` fails or throws. This leaves the UI showing "paid" when the backend rejected the change (e.g. rate limiting, auth failures). Please revert the optimistic update in both the non-`ok` path and the `catch` block so the UI always reflects the persisted state.
</issue_to_address>

### Comment 2
<location path="src/app/[locale]/app/commitments/CommitmentsClient.tsx" line_range="353-356" />
<code_context>
+                const tickedThisPeriod = paidKeys.has(
+                  periodKey(currentPeriod.year, currentPeriod.month),
+                );
+                const progress = Math.round((paid / c.installmentsTotal) * 100);
+                const finished = paid >= c.installmentsTotal;
+
</code_context>
<issue_to_address>
**suggestion:** Progress percentage is not clamped, and assumes installmentsTotal is always > 0.

This relies on `Math.round((paid / c.installmentsTotal) * 100)` without validation. If `installmentsTotal <= 0` (e.g. bad data) you can get NaN, and if `paid > installmentsTotal` you can exceed 100% for `aria-valuenow` and the width style. Consider guarding against non‑positive `installmentsTotal` and clamping the computed percentage to `[0, 100]` before rendering.

```suggestion
                const dueThisPeriod = isDueInPeriod(domain, currentPeriod);
                const tickedThisPeriod = paidKeys.has(
                  periodKey(currentPeriod.year, currentPeriod.month),
                );
                const rawProgress =
                  c.installmentsTotal > 0 ? (paid / c.installmentsTotal) * 100 : 0;
                const progress = Math.min(
                  100,
                  Math.max(0, Math.round(rawProgress)),
                );
                const finished =
                  c.installmentsTotal > 0 ? paid >= c.installmentsTotal : false;
```
</issue_to_address>

### Comment 3
<location path="src/lib/actions/__tests__/commitments.test.ts" line_range="254" />
<code_context>
+  });
+});
+
+describe('toggleCommitmentPaymentAction', () => {
+  const period = { commitmentId: COMMITMENT_ID, periodYear: 2026, periodMonth: 8 };
+
</code_context>
<issue_to_address>
**suggestion (testing):** Toggle payment tests miss several important error and edge-case branches

The existing tests cover the main happy paths, but several failure branches are untested:
- Rate limit failure (`rateLimit` returning `success: false`) should yield `errors.session.rateLimited` (currently only covered for create).
- DB failures for both deleting an existing tick and inserting a new payment should return `errors.commitments.payments.toggleFailed`.
- `logAuditEvent` isn’t error-handled, but tests should still verify it’s called for both insert and delete to prevent regressions.
Adding 2–3 tests for these cases will validate behaviour under failures and ensure error codes stay aligned with the UI contract.

Suggested implementation:

```typescript
describe('toggleCommitmentPaymentAction', () => {
  const period = { commitmentId: COMMITMENT_ID, periodYear: 2026, periodMonth: 8 };

  it('returns a rateLimited error when the rate limiter fails', async () => {
    // Arrange: rate limit fails before any DB access
    (rateLimit as jest.Mock).mockResolvedValue({ success: false });

    const result = await toggleCommitmentPaymentAction(period);

    expect(result).toEqual({
      ok: false,
      error: errors.session.rateLimited,
    });

    // No audit events or DB operations should have happened
    expect(auditSpy).not.toHaveBeenCalled();
  });

  it('returns toggleFailed when deleting an existing payment fails', async () => {
    // Arrange: rate limiter allows the operation
    (rateLimit as jest.Mock).mockResolvedValue({ success: true });

    // First select finds an existing payment for the period
    supa.program({
      table: 'commitments_payments',
      op: 'select',
      result: {
        data: [
          {
            commitment_id: COMMITMENT_ID,
            period_year: period.periodYear,
            period_month: period.periodMonth,
          },
        ],
        error: null,
      },
    });

    // Delete of existing payment fails
    supa.program({
      table: 'commitments_payments',
      op: 'delete',
      result: {
        data: null,
        error: { message: 'delete failed' },
      },
    });

    const result = await toggleCommitmentPaymentAction(period);

    expect(result).toEqual({
      ok: false,
      error: errors.commitments.payments.toggleFailed,
    });

    // Audit event should not be written on failure
    expect(auditSpy).not.toHaveBeenCalled();
  });

  it('returns toggleFailed when inserting a new payment fails', async () => {
    // Arrange: rate limiter allows the operation
    (rateLimit as jest.Mock).mockResolvedValue({ success: true });

    // First select finds no existing payment for the period
    supa.program({
      table: 'commitments_payments',
      op: 'select',
      result: {
        data: [],
        error: null,
      },
    });

    // Insert of new payment fails
    supa.program({
      table: 'commitments_payments',
      op: 'insert',
      result: {
        data: null,
        error: { message: 'insert failed' },
      },
    });

    const result = await toggleCommitmentPaymentAction(period);

    expect(result).toEqual({
      ok: false,
      error: errors.commitments.payments.toggleFailed,
    });

    // Audit event should not be written on failure
    expect(auditSpy).not.toHaveBeenCalled();
  });

  it('logs an audit event when inserting a new payment succeeds', async () => {
    (rateLimit as jest.Mock).mockResolvedValue({ success: true });

    // No existing payment
    supa.program({
      table: 'commitments_payments',
      op: 'select',
      result: {
        data: [],
        error: null,
      },
    });

    // Successful insert
    supa.program({
      table: 'commitments_payments',
      op: 'insert',
      result: {
        data: [
          {
            commitment_id: COMMITMENT_ID,
            period_year: period.periodYear,
            period_month: period.periodMonth,
          },
        ],
        error: null,
      },
    });

    const result = await toggleCommitmentPaymentAction(period);

    expect(result.ok).toBe(true);
    expect(auditSpy).toHaveBeenCalledWith('commitment.payment.toggled', {
      userId: 'user-1',
      workspaceId: 'ws-1',
      commitmentId: COMMITMENT_ID,
      periodYear: period.periodYear,
      periodMonth: period.periodMonth,
      action: 'insert',
    });
  });

  it('logs an audit event when deleting an existing payment succeeds', async () => {
    (rateLimit as jest.Mock).mockResolvedValue({ success: true });

    // Existing payment present
    supa.program({
      table: 'commitments_payments',
      op: 'select',
      result: {
        data: [
          {
            commitment_id: COMMITMENT_ID,
            period_year: period.periodYear,
            period_month: period.periodMonth,
          },
        ],
        error: null,
      },
    });

    // Successful delete
    supa.program({
      table: 'commitments_payments',
      op: 'delete',
      result: {
        data: null,
        error: null,
      },
    });

    const result = await toggleCommitmentPaymentAction(period);

    expect(result.ok).toBe(true);
    expect(auditSpy).toHaveBeenCalledWith('commitment.payment.toggled', {
      userId: 'user-1',
      workspaceId: 'ws-1',
      commitmentId: COMMITMENT_ID,
      periodYear: period.periodYear,
      periodMonth: period.periodMonth,
      action: 'delete',
    });
  });

```

1. The tests above assume a mocked `rateLimit` function is already imported and jest-mocked in this file (similar to how the create-commitment-payment tests stub rate limiting). If your test suite uses a different helper (e.g. `rateLimitOrThrow`, `rateLimiter`, `limit`), replace `(rateLimit as jest.Mock)` with the correct mock variable used elsewhere in this file.
2. The error objects `errors.session.rateLimited` and `errors.commitments.payments.toggleFailed` are used as suggested in your review comment. If the existing tests assert on `error.code`/`error.message` instead of using the exported constants directly, align these expectations with the existing convention.
3. The audit event name `'commitment.payment.toggled'` and payload shape in the `toHaveBeenCalledWith` assertions are inferred. Update the event name and payload keys to match whatever `toggleCommitmentPaymentAction` actually passes to `logAuditEvent` (you can mirror this from the existing “happy path” toggle tests).
4. The `supa.program` scripting for `commitments_payments` should match the other tests in this file; if you use a different table name or column names for commitment payments, adjust the scripted `data` objects accordingly so the action detects “existing vs. non-existing” payments in the same way as the current tests.
</issue_to_address>

### Comment 4
<location path="src/lib/actions/__tests__/commitments.test.ts" line_range="221-226" />
<code_context>
+    expect(supa.lastInsertPayload()).toMatchObject({ installment_amount: null });
+  });
+
+  it('rejects invalid input before touching the DB', async () => {
+    programMembership();
+    const r = await createCommitmentAction({ ...VALID_INPUT, totalAmount: -1 });
+    expect(r.ok).toBe(false);
+    expect(auditSpy).not.toHaveBeenCalled();
+  });
+
</code_context>
<issue_to_address>
**suggestion (testing):** Validation failure branch should assert `fieldErrors` shape returned by `createCommitmentAction`

This only asserts `ok` is false and no audit event is emitted, but doesn’t verify the `fieldErrors` contract from `commitmentInputSchema`. Since the action returns `fieldErrors: parsed.error.flatten().fieldErrors` and the UI likely relies on this shape, please add an assertion along the lines of:
```ts
expect(r).toMatchObject({
  ok: false,
  errorCode: 'errors.validation.generic',
  fieldErrors: expect.objectContaining({ totalAmount: expect.any(Array) }),
});
```
to lock in the expected validation error shape and prevent regressions in error handling.

```suggestion
  it('rejects invalid input before touching the DB', async () => {
    programMembership();
    const r = await createCommitmentAction({ ...VALID_INPUT, totalAmount: -1 });
    expect(r.ok).toBe(false);
    expect(r).toMatchObject({
      ok: false,
      errorCode: 'errors.validation.generic',
      fieldErrors: expect.objectContaining({
        totalAmount: expect.any(Array),
      }),
    });
    expect(auditSpy).not.toHaveBeenCalled();
  });
```
</issue_to_address>

### Comment 5
<location path="src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx" line_range="140-149" />
<code_context>
+  it('ticks an instalment optimistically and calls the action with the viewed period', async () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Optimistic tick test does not assert that remaining balance and progress bar update immediately

The header comment says ticking an instalment should immediately update both remaining balance and progress. This test only checks the optimistic aria state and action call. To fully cover the behaviour, also assert the numeric UI before and after the click, e.g. capture `remaining` and `progress`, assert their initial values, trigger the click, then `waitFor` the updated remaining amount and `aria-valuenow` to reflect the optimistic change.

Suggested implementation:

```typescript
  it('ticks an instalment optimistically and calls the action with the viewed period', async () => {
    let resolveAction!: (v: { ok: true; data: { paid: boolean } }) => void;
    toggleMock.mockImplementation(
      () =>
        new Promise((res) => {
          resolveAction = res;
        }),
    );
    renderPage([carLoan], { currentPeriod: { year: 2026, month: 3 } });
    const tick = screen.getByTestId('commitment-paid-car');
    expect(tick).toHaveAttribute('aria-pressed', 'false');

    // Capture initial remaining balance and progress for the car commitment
    const remaining = screen.getByTestId('commitment-remaining-car');
    const progress = screen.getByTestId('commitment-progress-car');
    const initialRemaining = remaining.textContent;
    const initialProgress = progress.getAttribute('aria-valuenow');

    // Tick the instalment optimistically
    await userEvent.click(tick);

    // Remaining balance and progress bar should update immediately (optimistic UI),
    // before the toggle action promise resolves.
    await waitFor(() => {
      expect(remaining.textContent).not.toBe(initialRemaining);
      expect(progress.getAttribute('aria-valuenow')).not.toBe(initialProgress);
    });

```

1. Ensure there are elements in the rendered UI with `data-testid="commitment-remaining-car"` and `data-testid="commitment-progress-car"`, and that the progress element has an `aria-valuenow` attribute. If your existing tests use different test IDs (e.g. `commitment-remaining-car-loan` or a generic pattern), adjust the test IDs in this change to match.
2. Confirm that `userEvent` and `waitFor` are already imported in this test file (typically from `@testing-library/user-event` and `@testing-library/react`). If not, add their imports at the top of the file in line with existing conventions.
3. If your progress UI is accessed differently (for example via `getByRole('progressbar', { name: /car/i })` rather than a `data-testid`), adapt the `progress` query to the pattern already used elsewhere in the test suite.
</issue_to_address>

### Comment 6
<location path="src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx" line_range="178-187" />
<code_context>
+  it('creates a commitment with the anchor set to the viewed period', async () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Creation tests should also cover failure scenarios and input validation feedback

This test covers the happy path well. To better exercise the client behaviour, please also add tests for:
- A failed creation (`createCommitmentAction` returns `{ ok: false, errorCode: ... }`): assert `toastErrorMock` is called and form values are preserved.
- The local validation branch in `onCreate` (e.g. `totalAmount < 0` or non-numeric input): assert an error toast is shown and `createCommitmentAction` is **not** called.
These will verify the UI for both server rejection and immediate numeric validation failures.

Suggested implementation:

```typescript
  it('creates a commitment with the anchor set to the viewed period', async () => {
    createMock.mockResolvedValue({ ok: true });
    renderPage([], { currentPeriod: { year: 2026, month: 5 } });
    fireEvent.click(screen.getByTestId('commitments-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Arrangement SPF' } });
    fireEvent.change(screen.getByLabelText(/Montant restant dû/), { target: { value: '1600' } });
    fireEvent.change(screen.getByLabelText(/Montant par échéance/), { target: { value: '200' } });
    fireEvent.change(screen.getByLabelText(/Nombre d'échéances/), { target: { value: '8' } });
    await act(async () => {
      fireEvent.submit(screen.getByRole('button', { name: /^ajouter$/i }).closest('form')!);
    });

```

` section so they can be placed after the full test definition in your file.

Here are the code changes:

<file_operations>
<file_operation operation="edit" file_path="src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx">
<<<<<<< SEARCH
  it('creates a commitment with the anchor set to the viewed period', async () => {
    createMock.mockResolvedValue({ ok: true });
    renderPage([], { currentPeriod: { year: 2026, month: 5 } });
    fireEvent.click(screen.getByTestId('commitments-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Arrangement SPF' } });
    fireEvent.change(screen.getByLabelText(/Montant restant dû/), { target: { value: '1600' } });
    fireEvent.change(screen.getByLabelText(/Montant par échéance/), { target: { value: '200' } });
    fireEvent.change(screen.getByLabelText(/Nombre d'échéances/), { target: { value: '8' } });
    await act(async () => {
      fireEvent.submit(screen.getByRole('button', { name: /^ajouter$/i }).closest('form')!);
    });
=======
  it('creates a commitment with the anchor set to the viewed period', async () => {
    createMock.mockResolvedValue({ ok: true });
    renderPage([], { currentPeriod: { year: 2026, month: 5 } });
    fireEvent.click(screen.getByTestId('commitments-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Arrangement SPF' } });
    fireEvent.change(screen.getByLabelText(/Montant restant dû/), { target: { value: '1600' } });
    fireEvent.change(screen.getByLabelText(/Montant par échéance/), { target: { value: '200' } });
    fireEvent.change(screen.getByLabelText(/Nombre d'échéances/), { target: { value: '8' } });
    await act(async () => {
      fireEvent.submit(screen.getByRole('button', { name: /^ajouter$/i }).closest('form')!);
    });
>>>>>>> REPLACE
</file_operation>
</file_operations>

<additional_changes>
Please add the following two tests **after** the full body of the existing `"creates a commitment with the anchor set to the viewed period"` test, alongside the other `it(...)` blocks in the same `describe`:

```ts
  it('shows an error toast and preserves form values when creation fails on the server', async () => {
    createMock.mockResolvedValue({
      ok: false,
      errorCode: 'errors.commitments.createFailed',
    });

    renderPage([], { currentPeriod: { year: 2026, month: 5 } });

    fireEvent.click(screen.getByTestId('commitments-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Arrangement SPF' } });
    fireEvent.change(screen.getByLabelText(/Montant restant dû/), { target: { value: '1600' } });
    fireEvent.change(screen.getByLabelText(/Montant par échéance/), { target: { value: '200' } });
    fireEvent.change(screen.getByLabelText(/Nombre d'échéances/), { target: { value: '8' } });

    await act(async () => {
      fireEvent.submit(screen.getByRole('button', { name: /^ajouter$/i }).closest('form')!);
    });

    await waitFor(() => {
      expect(toastErrorMock).toHaveBeenCalled();
    });

    // Form values should be preserved after a failed creation
    expect(screen.getByLabelText('Libellé')).toHaveValue('Arrangement SPF');
    expect(screen.getByLabelText(/Montant restant dû/)).toHaveValue('1600');
    expect(screen.getByLabelText(/Montant par échéance/)).toHaveValue('200');
    expect(screen.getByLabelText(/Nombre d'échéances/)).toHaveValue('8');
  });

  it('shows a validation error and does not call create when the total amount is invalid', async () => {
    createMock.mockResolvedValue({ ok: true }); // should not be reached

    renderPage([], { currentPeriod: { year: 2026, month: 5 } });

    fireEvent.click(screen.getByTestId('commitments-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Crédit auto' } });

    // Trigger local validation: invalid / negative total amount
    fireEvent.change(screen.getByLabelText(/Montant restant dû/), { target: { value: '-10' } });
    fireEvent.change(screen.getByLabelText(/Montant par échéance/), { target: { value: '200' } });
    fireEvent.change(screen.getByLabelText(/Nombre d'échéances/), { target: { value: '8' } });

    await act(async () => {
      fireEvent.submit(screen.getByRole('button', { name: /^ajouter$/i }).closest('form')!);
    });

    await waitFor(() => {
      expect(toastErrorMock).toHaveBeenCalled();
    });

    expect(createMock).not.toHaveBeenCalled();
  });
```

You may want to adjust:
- The `errorCode` string (`'errors.commitments.createFailed'`) to match the actual error code used by `createCommitmentAction`.
- The specific label texts / regexes if they differ slightly elsewhere in the file.
These tests will cover both the server-side failure branch and the local numeric validation branch in `onCreate`, verifying that the UI shows an error toast, preserves values on server failure, and avoids calling `createCommitmentAction` for immediately invalid input.
</issue_to_address>

### Comment 7
<location path="src/app/[locale]/app/commitments/CommitmentsClient.tsx" line_range="62" />
<code_context>
+  one_off: 'kinds.oneOff',
+} as const;
+
+export function CommitmentsClient({
+  commitments,
+  paidKeysByCommitment,
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the optimistic paid ledger logic into a custom hook and the per-item rendering into a `CommitmentRow` component to break this large component into smaller, focused units.

You can reduce complexity without changing behaviour by extracting a couple of focused building blocks.

### 1. Encapsulate the optimistic paid ledger

The `${id}|${key}` encoding + `Set` logic is sprinkled in multiple places (`paidBase`, `applyOptimisticPaid`, `paidKeysOf`, `onTogglePaid`, `tickedThisPeriod`). Moving that into a hook will shrink the main component and centralise the representation.

For example:

```ts
// commitments/hooks/useOptimisticPaidLedger.ts
type Period = { year: number; month: number };

export function useOptimisticPaidLedger(
  paidKeysByCommitment: Record<string, string[]>,
) {
  const base = useMemo(() => {
    const set = new Set<string>();
    for (const [id, keys] of Object.entries(paidKeysByCommitment)) {
      for (const k of keys) set.add(`${id}|${k}`);
    }
    return set;
  }, [paidKeysByCommitment]);

  const [optimisticPaid, applyOptimisticPaid] = useOptimistic(
    base,
    (current: ReadonlySet<string>, entry: string) => {
      const next = new Set(current);
      next.has(entry) ? next.delete(entry) : next.add(entry);
      return next;
    },
  );

  const periodKeyOf = (p: Period) => `${p.year}-${p.month}`;

  const paidKeysOf = (id: string): ReadonlySet<string> => {
    const prefix = `${id}|`;
    const keys = new Set<string>();
    for (const entry of optimisticPaid) {
      if (entry.startsWith(prefix)) keys.add(entry.slice(prefix.length));
    }
    return keys;
  };

  const togglePaidOptimistic = (id: string, period: Period) => {
    applyOptimisticPaid(`${id}|${periodKeyOf(period)}`);
  };

  return { paidKeysOf, togglePaidOptimistic };
}
```

Then in `CommitmentsClient`:

```ts
const { paidKeysOf, togglePaidOptimistic } = useOptimisticPaidLedger(
  paidKeysByCommitment,
);

function onTogglePaid(c: RawCommitment) {
  startTransition(async () => {
    togglePaidOptimistic(c.id, currentPeriod);
    // keep the rest of the action logic unchanged
  });
}
```

This removes the string concatenation details from the main component and makes the payment-toggling code easier to scan.

### 2. Extract a `CommitmentRow` component

The `map` body currently computes a view model + renders a dense `<li>`. Extracting that into a row component reduces the size of `CommitmentsClient` and isolates per-row logic.

First, compute the minimal view model in the parent:

```ts
const rows = active.map((c) => {
  const domain = toDomain(c);
  const paidKeys = paidKeysOf(c.id);
  const paid = installmentsPaid(domain, paidKeys);
  const remaining = remainingBalance(domain, paidKeys);
  const end = endPeriod(domain);
  const dueThisPeriod = isDueInPeriod(domain, currentPeriod);
  const tickedThisPeriod = paidKeys.has(periodKey(currentPeriod.year, currentPeriod.month));
  const progress = Math.round((paid / c.installmentsTotal) * 100);
  const finished = paid >= c.installmentsTotal;

  return {
    raw: c,
    paid,
    remaining,
    end,
    dueThisPeriod,
    tickedThisPeriod,
    progress,
    finished,
  };
});
```

Then render via a dedicated component:

```tsx
<ul role="list" className="divide-border/60 divide-y" data-testid="commitments-list">
  {rows.map((row) => (
    <CommitmentRow
      key={row.raw.id}
      row={row}
      locale={locale}
      onTogglePaid={() => onTogglePaid(row.raw)}
      onDelete={() => onDelete(row.raw.id)}
      isPending={isPending}
      t={t}
    />
  ))}
</ul>
```

`CommitmentRow` keeps only row-level concerns:

```tsx
type CommitmentRowProps = {
  row: {
    raw: RawCommitment;
    paid: number;
    remaining: number;
    end: { year: number; month: number };
    dueThisPeriod: boolean;
    tickedThisPeriod: boolean;
    progress: number;
    finished: boolean;
  };
  locale: Locale;
  isPending: boolean;
  onTogglePaid(): void;
  onDelete(): void;
  t: ReturnType<typeof useTranslations<'app.commitments'>>;
};

function CommitmentRow({
  row: { raw: c, paid, remaining, end, dueThisPeriod, tickedThisPeriod, progress, finished },
  locale,
  isPending,
  onTogglePaid,
  onDelete,
  t,
}: CommitmentRowProps) {
  return (
    <li data-testid={`commitment-row-${c.id}`} className="relative py-4 pr-24">
      {/* existing JSX for header, progress bar, summary, buttons … */}
    </li>
  );
}
```

This keeps all behaviour intact but breaks the 470-line monolith into smaller, single-responsibility units, making the main component mostly orchestration of hooks + child components.
</issue_to_address>

Sourcery est gratuit pour l’open source – si nos revues te sont utiles, merci d’en parler ✨
Aide-moi à être plus utile ! Clique sur 👍 ou 👎 sur chaque commentaire et j’utiliserai ces retours pour améliorer mes revues.
Original comment in English

Hey - I've found 7 issues, and left some high level feedback:

  • The optimistic tick logic in CommitmentsClient never rolls back the UI state when toggleCommitmentPaymentAction fails, so users can end up seeing a paid month that is actually unpaid; consider reverting the optimistic Set on error to keep the display consistent with the backend.
  • In the onCreate handler for CommitmentsClient, only totalAmount is validated before submitting, while installmentAmount and installmentsTotal can be NaN for non-one-off commitments; adding basic client-side validation for these fields would avoid avoidable round-trips and confusing error toasts.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The optimistic tick logic in `CommitmentsClient` never rolls back the UI state when `toggleCommitmentPaymentAction` fails, so users can end up seeing a paid month that is actually unpaid; consider reverting the optimistic Set on error to keep the display consistent with the backend.
- In the `onCreate` handler for `CommitmentsClient`, only `totalAmount` is validated before submitting, while `installmentAmount` and `installmentsTotal` can be NaN for non-one-off commitments; adding basic client-side validation for these fields would avoid avoidable round-trips and confusing error toasts.

## Individual Comments

### Comment 1
<location path="src/app/[locale]/app/commitments/CommitmentsClient.tsx" line_range="125-127" />
<code_context>
+  function onTogglePaid(c: RawCommitment) {
+    const entry = `${c.id}|${periodKey(currentPeriod.year, currentPeriod.month)}`;
+    startTransition(async () => {
+      applyOptimisticPaid(entry);
+      try {
+        const result = await toggleCommitmentPaymentAction({
+          commitmentId: c.id,
+          periodYear: currentPeriod.year,
</code_context>
<issue_to_address>
**issue (bug_risk):** Optimistic paid state is never reverted when the toggle action fails, which can desync UI from the backend.

In `onTogglePaid`, the optimistic state is updated before the server call and never reverted if `toggleCommitmentPaymentAction` fails or throws. This leaves the UI showing "paid" when the backend rejected the change (e.g. rate limiting, auth failures). Please revert the optimistic update in both the non-`ok` path and the `catch` block so the UI always reflects the persisted state.
</issue_to_address>

### Comment 2
<location path="src/app/[locale]/app/commitments/CommitmentsClient.tsx" line_range="353-356" />
<code_context>
+                const tickedThisPeriod = paidKeys.has(
+                  periodKey(currentPeriod.year, currentPeriod.month),
+                );
+                const progress = Math.round((paid / c.installmentsTotal) * 100);
+                const finished = paid >= c.installmentsTotal;
+
</code_context>
<issue_to_address>
**suggestion:** Progress percentage is not clamped, and assumes installmentsTotal is always > 0.

This relies on `Math.round((paid / c.installmentsTotal) * 100)` without validation. If `installmentsTotal <= 0` (e.g. bad data) you can get NaN, and if `paid > installmentsTotal` you can exceed 100% for `aria-valuenow` and the width style. Consider guarding against non‑positive `installmentsTotal` and clamping the computed percentage to `[0, 100]` before rendering.

```suggestion
                const dueThisPeriod = isDueInPeriod(domain, currentPeriod);
                const tickedThisPeriod = paidKeys.has(
                  periodKey(currentPeriod.year, currentPeriod.month),
                );
                const rawProgress =
                  c.installmentsTotal > 0 ? (paid / c.installmentsTotal) * 100 : 0;
                const progress = Math.min(
                  100,
                  Math.max(0, Math.round(rawProgress)),
                );
                const finished =
                  c.installmentsTotal > 0 ? paid >= c.installmentsTotal : false;
```
</issue_to_address>

### Comment 3
<location path="src/lib/actions/__tests__/commitments.test.ts" line_range="254" />
<code_context>
+  });
+});
+
+describe('toggleCommitmentPaymentAction', () => {
+  const period = { commitmentId: COMMITMENT_ID, periodYear: 2026, periodMonth: 8 };
+
</code_context>
<issue_to_address>
**suggestion (testing):** Toggle payment tests miss several important error and edge-case branches

The existing tests cover the main happy paths, but several failure branches are untested:
- Rate limit failure (`rateLimit` returning `success: false`) should yield `errors.session.rateLimited` (currently only covered for create).
- DB failures for both deleting an existing tick and inserting a new payment should return `errors.commitments.payments.toggleFailed`.
- `logAuditEvent` isn’t error-handled, but tests should still verify it’s called for both insert and delete to prevent regressions.
Adding 2–3 tests for these cases will validate behaviour under failures and ensure error codes stay aligned with the UI contract.

Suggested implementation:

```typescript
describe('toggleCommitmentPaymentAction', () => {
  const period = { commitmentId: COMMITMENT_ID, periodYear: 2026, periodMonth: 8 };

  it('returns a rateLimited error when the rate limiter fails', async () => {
    // Arrange: rate limit fails before any DB access
    (rateLimit as jest.Mock).mockResolvedValue({ success: false });

    const result = await toggleCommitmentPaymentAction(period);

    expect(result).toEqual({
      ok: false,
      error: errors.session.rateLimited,
    });

    // No audit events or DB operations should have happened
    expect(auditSpy).not.toHaveBeenCalled();
  });

  it('returns toggleFailed when deleting an existing payment fails', async () => {
    // Arrange: rate limiter allows the operation
    (rateLimit as jest.Mock).mockResolvedValue({ success: true });

    // First select finds an existing payment for the period
    supa.program({
      table: 'commitments_payments',
      op: 'select',
      result: {
        data: [
          {
            commitment_id: COMMITMENT_ID,
            period_year: period.periodYear,
            period_month: period.periodMonth,
          },
        ],
        error: null,
      },
    });

    // Delete of existing payment fails
    supa.program({
      table: 'commitments_payments',
      op: 'delete',
      result: {
        data: null,
        error: { message: 'delete failed' },
      },
    });

    const result = await toggleCommitmentPaymentAction(period);

    expect(result).toEqual({
      ok: false,
      error: errors.commitments.payments.toggleFailed,
    });

    // Audit event should not be written on failure
    expect(auditSpy).not.toHaveBeenCalled();
  });

  it('returns toggleFailed when inserting a new payment fails', async () => {
    // Arrange: rate limiter allows the operation
    (rateLimit as jest.Mock).mockResolvedValue({ success: true });

    // First select finds no existing payment for the period
    supa.program({
      table: 'commitments_payments',
      op: 'select',
      result: {
        data: [],
        error: null,
      },
    });

    // Insert of new payment fails
    supa.program({
      table: 'commitments_payments',
      op: 'insert',
      result: {
        data: null,
        error: { message: 'insert failed' },
      },
    });

    const result = await toggleCommitmentPaymentAction(period);

    expect(result).toEqual({
      ok: false,
      error: errors.commitments.payments.toggleFailed,
    });

    // Audit event should not be written on failure
    expect(auditSpy).not.toHaveBeenCalled();
  });

  it('logs an audit event when inserting a new payment succeeds', async () => {
    (rateLimit as jest.Mock).mockResolvedValue({ success: true });

    // No existing payment
    supa.program({
      table: 'commitments_payments',
      op: 'select',
      result: {
        data: [],
        error: null,
      },
    });

    // Successful insert
    supa.program({
      table: 'commitments_payments',
      op: 'insert',
      result: {
        data: [
          {
            commitment_id: COMMITMENT_ID,
            period_year: period.periodYear,
            period_month: period.periodMonth,
          },
        ],
        error: null,
      },
    });

    const result = await toggleCommitmentPaymentAction(period);

    expect(result.ok).toBe(true);
    expect(auditSpy).toHaveBeenCalledWith('commitment.payment.toggled', {
      userId: 'user-1',
      workspaceId: 'ws-1',
      commitmentId: COMMITMENT_ID,
      periodYear: period.periodYear,
      periodMonth: period.periodMonth,
      action: 'insert',
    });
  });

  it('logs an audit event when deleting an existing payment succeeds', async () => {
    (rateLimit as jest.Mock).mockResolvedValue({ success: true });

    // Existing payment present
    supa.program({
      table: 'commitments_payments',
      op: 'select',
      result: {
        data: [
          {
            commitment_id: COMMITMENT_ID,
            period_year: period.periodYear,
            period_month: period.periodMonth,
          },
        ],
        error: null,
      },
    });

    // Successful delete
    supa.program({
      table: 'commitments_payments',
      op: 'delete',
      result: {
        data: null,
        error: null,
      },
    });

    const result = await toggleCommitmentPaymentAction(period);

    expect(result.ok).toBe(true);
    expect(auditSpy).toHaveBeenCalledWith('commitment.payment.toggled', {
      userId: 'user-1',
      workspaceId: 'ws-1',
      commitmentId: COMMITMENT_ID,
      periodYear: period.periodYear,
      periodMonth: period.periodMonth,
      action: 'delete',
    });
  });

```

1. The tests above assume a mocked `rateLimit` function is already imported and jest-mocked in this file (similar to how the create-commitment-payment tests stub rate limiting). If your test suite uses a different helper (e.g. `rateLimitOrThrow`, `rateLimiter`, `limit`), replace `(rateLimit as jest.Mock)` with the correct mock variable used elsewhere in this file.
2. The error objects `errors.session.rateLimited` and `errors.commitments.payments.toggleFailed` are used as suggested in your review comment. If the existing tests assert on `error.code`/`error.message` instead of using the exported constants directly, align these expectations with the existing convention.
3. The audit event name `'commitment.payment.toggled'` and payload shape in the `toHaveBeenCalledWith` assertions are inferred. Update the event name and payload keys to match whatever `toggleCommitmentPaymentAction` actually passes to `logAuditEvent` (you can mirror this from the existing “happy path” toggle tests).
4. The `supa.program` scripting for `commitments_payments` should match the other tests in this file; if you use a different table name or column names for commitment payments, adjust the scripted `data` objects accordingly so the action detects “existing vs. non-existing” payments in the same way as the current tests.
</issue_to_address>

### Comment 4
<location path="src/lib/actions/__tests__/commitments.test.ts" line_range="221-226" />
<code_context>
+    expect(supa.lastInsertPayload()).toMatchObject({ installment_amount: null });
+  });
+
+  it('rejects invalid input before touching the DB', async () => {
+    programMembership();
+    const r = await createCommitmentAction({ ...VALID_INPUT, totalAmount: -1 });
+    expect(r.ok).toBe(false);
+    expect(auditSpy).not.toHaveBeenCalled();
+  });
+
</code_context>
<issue_to_address>
**suggestion (testing):** Validation failure branch should assert `fieldErrors` shape returned by `createCommitmentAction`

This only asserts `ok` is false and no audit event is emitted, but doesn’t verify the `fieldErrors` contract from `commitmentInputSchema`. Since the action returns `fieldErrors: parsed.error.flatten().fieldErrors` and the UI likely relies on this shape, please add an assertion along the lines of:
```ts
expect(r).toMatchObject({
  ok: false,
  errorCode: 'errors.validation.generic',
  fieldErrors: expect.objectContaining({ totalAmount: expect.any(Array) }),
});
```
to lock in the expected validation error shape and prevent regressions in error handling.

```suggestion
  it('rejects invalid input before touching the DB', async () => {
    programMembership();
    const r = await createCommitmentAction({ ...VALID_INPUT, totalAmount: -1 });
    expect(r.ok).toBe(false);
    expect(r).toMatchObject({
      ok: false,
      errorCode: 'errors.validation.generic',
      fieldErrors: expect.objectContaining({
        totalAmount: expect.any(Array),
      }),
    });
    expect(auditSpy).not.toHaveBeenCalled();
  });
```
</issue_to_address>

### Comment 5
<location path="src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx" line_range="140-149" />
<code_context>
+  it('ticks an instalment optimistically and calls the action with the viewed period', async () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Optimistic tick test does not assert that remaining balance and progress bar update immediately

The header comment says ticking an instalment should immediately update both remaining balance and progress. This test only checks the optimistic aria state and action call. To fully cover the behaviour, also assert the numeric UI before and after the click, e.g. capture `remaining` and `progress`, assert their initial values, trigger the click, then `waitFor` the updated remaining amount and `aria-valuenow` to reflect the optimistic change.

Suggested implementation:

```typescript
  it('ticks an instalment optimistically and calls the action with the viewed period', async () => {
    let resolveAction!: (v: { ok: true; data: { paid: boolean } }) => void;
    toggleMock.mockImplementation(
      () =>
        new Promise((res) => {
          resolveAction = res;
        }),
    );
    renderPage([carLoan], { currentPeriod: { year: 2026, month: 3 } });
    const tick = screen.getByTestId('commitment-paid-car');
    expect(tick).toHaveAttribute('aria-pressed', 'false');

    // Capture initial remaining balance and progress for the car commitment
    const remaining = screen.getByTestId('commitment-remaining-car');
    const progress = screen.getByTestId('commitment-progress-car');
    const initialRemaining = remaining.textContent;
    const initialProgress = progress.getAttribute('aria-valuenow');

    // Tick the instalment optimistically
    await userEvent.click(tick);

    // Remaining balance and progress bar should update immediately (optimistic UI),
    // before the toggle action promise resolves.
    await waitFor(() => {
      expect(remaining.textContent).not.toBe(initialRemaining);
      expect(progress.getAttribute('aria-valuenow')).not.toBe(initialProgress);
    });

```

1. Ensure there are elements in the rendered UI with `data-testid="commitment-remaining-car"` and `data-testid="commitment-progress-car"`, and that the progress element has an `aria-valuenow` attribute. If your existing tests use different test IDs (e.g. `commitment-remaining-car-loan` or a generic pattern), adjust the test IDs in this change to match.
2. Confirm that `userEvent` and `waitFor` are already imported in this test file (typically from `@testing-library/user-event` and `@testing-library/react`). If not, add their imports at the top of the file in line with existing conventions.
3. If your progress UI is accessed differently (for example via `getByRole('progressbar', { name: /car/i })` rather than a `data-testid`), adapt the `progress` query to the pattern already used elsewhere in the test suite.
</issue_to_address>

### Comment 6
<location path="src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx" line_range="178-187" />
<code_context>
+  it('creates a commitment with the anchor set to the viewed period', async () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Creation tests should also cover failure scenarios and input validation feedback

This test covers the happy path well. To better exercise the client behaviour, please also add tests for:
- A failed creation (`createCommitmentAction` returns `{ ok: false, errorCode: ... }`): assert `toastErrorMock` is called and form values are preserved.
- The local validation branch in `onCreate` (e.g. `totalAmount < 0` or non-numeric input): assert an error toast is shown and `createCommitmentAction` is **not** called.
These will verify the UI for both server rejection and immediate numeric validation failures.

Suggested implementation:

```typescript
  it('creates a commitment with the anchor set to the viewed period', async () => {
    createMock.mockResolvedValue({ ok: true });
    renderPage([], { currentPeriod: { year: 2026, month: 5 } });
    fireEvent.click(screen.getByTestId('commitments-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Arrangement SPF' } });
    fireEvent.change(screen.getByLabelText(/Montant restant dû/), { target: { value: '1600' } });
    fireEvent.change(screen.getByLabelText(/Montant par échéance/), { target: { value: '200' } });
    fireEvent.change(screen.getByLabelText(/Nombre d'échéances/), { target: { value: '8' } });
    await act(async () => {
      fireEvent.submit(screen.getByRole('button', { name: /^ajouter$/i }).closest('form')!);
    });

```

` section so they can be placed after the full test definition in your file.

Here are the code changes:

<file_operations>
<file_operation operation="edit" file_path="src/app/[locale]/app/commitments/__tests__/CommitmentsClient.test.tsx">
<<<<<<< SEARCH
  it('creates a commitment with the anchor set to the viewed period', async () => {
    createMock.mockResolvedValue({ ok: true });
    renderPage([], { currentPeriod: { year: 2026, month: 5 } });
    fireEvent.click(screen.getByTestId('commitments-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Arrangement SPF' } });
    fireEvent.change(screen.getByLabelText(/Montant restant dû/), { target: { value: '1600' } });
    fireEvent.change(screen.getByLabelText(/Montant par échéance/), { target: { value: '200' } });
    fireEvent.change(screen.getByLabelText(/Nombre d'échéances/), { target: { value: '8' } });
    await act(async () => {
      fireEvent.submit(screen.getByRole('button', { name: /^ajouter$/i }).closest('form')!);
    });
=======
  it('creates a commitment with the anchor set to the viewed period', async () => {
    createMock.mockResolvedValue({ ok: true });
    renderPage([], { currentPeriod: { year: 2026, month: 5 } });
    fireEvent.click(screen.getByTestId('commitments-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Arrangement SPF' } });
    fireEvent.change(screen.getByLabelText(/Montant restant dû/), { target: { value: '1600' } });
    fireEvent.change(screen.getByLabelText(/Montant par échéance/), { target: { value: '200' } });
    fireEvent.change(screen.getByLabelText(/Nombre d'échéances/), { target: { value: '8' } });
    await act(async () => {
      fireEvent.submit(screen.getByRole('button', { name: /^ajouter$/i }).closest('form')!);
    });
>>>>>>> REPLACE
</file_operation>
</file_operations>

<additional_changes>
Please add the following two tests **after** the full body of the existing `"creates a commitment with the anchor set to the viewed period"` test, alongside the other `it(...)` blocks in the same `describe`:

```ts
  it('shows an error toast and preserves form values when creation fails on the server', async () => {
    createMock.mockResolvedValue({
      ok: false,
      errorCode: 'errors.commitments.createFailed',
    });

    renderPage([], { currentPeriod: { year: 2026, month: 5 } });

    fireEvent.click(screen.getByTestId('commitments-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Arrangement SPF' } });
    fireEvent.change(screen.getByLabelText(/Montant restant dû/), { target: { value: '1600' } });
    fireEvent.change(screen.getByLabelText(/Montant par échéance/), { target: { value: '200' } });
    fireEvent.change(screen.getByLabelText(/Nombre d'échéances/), { target: { value: '8' } });

    await act(async () => {
      fireEvent.submit(screen.getByRole('button', { name: /^ajouter$/i }).closest('form')!);
    });

    await waitFor(() => {
      expect(toastErrorMock).toHaveBeenCalled();
    });

    // Form values should be preserved after a failed creation
    expect(screen.getByLabelText('Libellé')).toHaveValue('Arrangement SPF');
    expect(screen.getByLabelText(/Montant restant dû/)).toHaveValue('1600');
    expect(screen.getByLabelText(/Montant par échéance/)).toHaveValue('200');
    expect(screen.getByLabelText(/Nombre d'échéances/)).toHaveValue('8');
  });

  it('shows a validation error and does not call create when the total amount is invalid', async () => {
    createMock.mockResolvedValue({ ok: true }); // should not be reached

    renderPage([], { currentPeriod: { year: 2026, month: 5 } });

    fireEvent.click(screen.getByTestId('commitments-add-toggle'));
    fireEvent.change(screen.getByLabelText('Libellé'), { target: { value: 'Crédit auto' } });

    // Trigger local validation: invalid / negative total amount
    fireEvent.change(screen.getByLabelText(/Montant restant dû/), { target: { value: '-10' } });
    fireEvent.change(screen.getByLabelText(/Montant par échéance/), { target: { value: '200' } });
    fireEvent.change(screen.getByLabelText(/Nombre d'échéances/), { target: { value: '8' } });

    await act(async () => {
      fireEvent.submit(screen.getByRole('button', { name: /^ajouter$/i }).closest('form')!);
    });

    await waitFor(() => {
      expect(toastErrorMock).toHaveBeenCalled();
    });

    expect(createMock).not.toHaveBeenCalled();
  });
```

You may want to adjust:
- The `errorCode` string (`'errors.commitments.createFailed'`) to match the actual error code used by `createCommitmentAction`.
- The specific label texts / regexes if they differ slightly elsewhere in the file.
These tests will cover both the server-side failure branch and the local numeric validation branch in `onCreate`, verifying that the UI shows an error toast, preserves values on server failure, and avoids calling `createCommitmentAction` for immediately invalid input.
</issue_to_address>

### Comment 7
<location path="src/app/[locale]/app/commitments/CommitmentsClient.tsx" line_range="62" />
<code_context>
+  one_off: 'kinds.oneOff',
+} as const;
+
+export function CommitmentsClient({
+  commitments,
+  paidKeysByCommitment,
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the optimistic paid ledger logic into a custom hook and the per-item rendering into a `CommitmentRow` component to break this large component into smaller, focused units.

You can reduce complexity without changing behaviour by extracting a couple of focused building blocks.

### 1. Encapsulate the optimistic paid ledger

The `${id}|${key}` encoding + `Set` logic is sprinkled in multiple places (`paidBase`, `applyOptimisticPaid`, `paidKeysOf`, `onTogglePaid`, `tickedThisPeriod`). Moving that into a hook will shrink the main component and centralise the representation.

For example:

```ts
// commitments/hooks/useOptimisticPaidLedger.ts
type Period = { year: number; month: number };

export function useOptimisticPaidLedger(
  paidKeysByCommitment: Record<string, string[]>,
) {
  const base = useMemo(() => {
    const set = new Set<string>();
    for (const [id, keys] of Object.entries(paidKeysByCommitment)) {
      for (const k of keys) set.add(`${id}|${k}`);
    }
    return set;
  }, [paidKeysByCommitment]);

  const [optimisticPaid, applyOptimisticPaid] = useOptimistic(
    base,
    (current: ReadonlySet<string>, entry: string) => {
      const next = new Set(current);
      next.has(entry) ? next.delete(entry) : next.add(entry);
      return next;
    },
  );

  const periodKeyOf = (p: Period) => `${p.year}-${p.month}`;

  const paidKeysOf = (id: string): ReadonlySet<string> => {
    const prefix = `${id}|`;
    const keys = new Set<string>();
    for (const entry of optimisticPaid) {
      if (entry.startsWith(prefix)) keys.add(entry.slice(prefix.length));
    }
    return keys;
  };

  const togglePaidOptimistic = (id: string, period: Period) => {
    applyOptimisticPaid(`${id}|${periodKeyOf(period)}`);
  };

  return { paidKeysOf, togglePaidOptimistic };
}
```

Then in `CommitmentsClient`:

```ts
const { paidKeysOf, togglePaidOptimistic } = useOptimisticPaidLedger(
  paidKeysByCommitment,
);

function onTogglePaid(c: RawCommitment) {
  startTransition(async () => {
    togglePaidOptimistic(c.id, currentPeriod);
    // keep the rest of the action logic unchanged
  });
}
```

This removes the string concatenation details from the main component and makes the payment-toggling code easier to scan.

### 2. Extract a `CommitmentRow` component

The `map` body currently computes a view model + renders a dense `<li>`. Extracting that into a row component reduces the size of `CommitmentsClient` and isolates per-row logic.

First, compute the minimal view model in the parent:

```ts
const rows = active.map((c) => {
  const domain = toDomain(c);
  const paidKeys = paidKeysOf(c.id);
  const paid = installmentsPaid(domain, paidKeys);
  const remaining = remainingBalance(domain, paidKeys);
  const end = endPeriod(domain);
  const dueThisPeriod = isDueInPeriod(domain, currentPeriod);
  const tickedThisPeriod = paidKeys.has(periodKey(currentPeriod.year, currentPeriod.month));
  const progress = Math.round((paid / c.installmentsTotal) * 100);
  const finished = paid >= c.installmentsTotal;

  return {
    raw: c,
    paid,
    remaining,
    end,
    dueThisPeriod,
    tickedThisPeriod,
    progress,
    finished,
  };
});
```

Then render via a dedicated component:

```tsx
<ul role="list" className="divide-border/60 divide-y" data-testid="commitments-list">
  {rows.map((row) => (
    <CommitmentRow
      key={row.raw.id}
      row={row}
      locale={locale}
      onTogglePaid={() => onTogglePaid(row.raw)}
      onDelete={() => onDelete(row.raw.id)}
      isPending={isPending}
      t={t}
    />
  ))}
</ul>
```

`CommitmentRow` keeps only row-level concerns:

```tsx
type CommitmentRowProps = {
  row: {
    raw: RawCommitment;
    paid: number;
    remaining: number;
    end: { year: number; month: number };
    dueThisPeriod: boolean;
    tickedThisPeriod: boolean;
    progress: number;
    finished: boolean;
  };
  locale: Locale;
  isPending: boolean;
  onTogglePaid(): void;
  onDelete(): void;
  t: ReturnType<typeof useTranslations<'app.commitments'>>;
};

function CommitmentRow({
  row: { raw: c, paid, remaining, end, dueThisPeriod, tickedThisPeriod, progress, finished },
  locale,
  isPending,
  onTogglePaid,
  onDelete,
  t,
}: CommitmentRowProps) {
  return (
    <li data-testid={`commitment-row-${c.id}`} className="relative py-4 pr-24">
      {/* existing JSX for header, progress bar, summary, buttons … */}
    </li>
  );
}
```

This keeps all behaviour intact but breaks the 470-line monolith into smaller, single-responsibility units, making the main component mostly orchestration of hooks + child components.
</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/commitments/CommitmentsClient.tsx
Comment thread src/app/[locale]/app/commitments/CommitmentsClient.tsx
Comment thread src/lib/actions/__tests__/commitments.test.ts
Comment thread src/lib/actions/__tests__/commitments.test.ts Outdated
Comment thread src/app/[locale]/app/commitments/CommitmentsClient.tsx
…er error branches

Sourcery #234 review:
- The 'missing manual rollback' finding is answered by PROOF, not by code:
  useOptimistic discards its value when the transition settles and re-derives
  from the server base — which a rejected toggle left untouched. Two tests now
  lock it (rejected result AND thrown error): aria-pressed returns to false and
  the balance goes back to 4 200 €. A hand-rolled revert would double-toggle.
- Progress percentage clamped to [0,100] with a zero-guard (unreachable today
  given the DB CHECK + the schedule-bounded count, but corrupted data must not
  produce an invalid aria-valuenow).
- Coverage added: optimistic tick moves BOTH the balance and the bar mid-flight,
  creation failure keeps the form open, negative amount blocked client-side,
  fieldErrors shape on validation failure, DB error on create, non-uuid id,
  rate-limit, and both INSERT/DELETE failure branches of the toggle.

73 commitment tests. Full suite 1564 green.
Upstream advisory published after the PR opened; the CI 'npm audit
--audit-level=high' gate went red on a transitive dev dependency, not on
anything in this diff. Lockfile-only patch bump, no new dependency.
@thierryvm
thierryvm merged commit 3fb4f3f into main Jul 20, 2026
9 checks passed
@thierryvm
thierryvm deleted the feat/commitments-page branch July 20, 2026 21:50
thierryvm added a commit that referenced this pull request Jul 24, 2026
@Thierry: an échéancier (e.g. 11 × 220 €) could only tick the CURRENT month and
had no way to edit. Now:
- InstallmentStepper « X / N payées » with − / + : + marks the earliest unpaid
  scheduled instalment, − un-marks the latest paid one, reusing the existing
  single-period toggle action. Buttons disabled at bounds + while pending, so a
  double-click can't cancel a payment. useOptimistic reducer switched to explicit
  {key, paid} intent (idempotent under stale reads) — keeps the #234 rollback.
- Edit via the pencil: reuses the form (single formMode source + resetForm),
  wired to the existing updateCommitmentAction. Guard blocks reducing
  installmentsTotal below what's already ticked (no silent ledger orphaning).
- Row controls moved from cramped absolute corners into a flow flex row.
- i18n: +10 keys × 5 locales, −2 dead. plan-reviewer APPROVED. 1616 tests.
thierryvm added a commit that referenced this pull request Jul 24, 2026
## Motivation (@Thierry)

Sur `/app/commitments`, un échéancier (ex. **11 × 220 €**) ne pouvait
valider que le **mois courant** (bouton ✓ gated `dueThisPeriod`) et
**aucune édition** n'était possible. On doit pouvoir valider les 11
versements + corriger un engagement.

## Ce que fait la PR

- **Compteur −/+ « X / N payées »** (`InstallmentStepper`, extrait) :
`+` coche la plus ancienne échéance non payée, `−` décoche la plus
récente payée — réutilise l'action toggle mono-période existante.
Boutons désactivés aux bornes **et pendant une mutation** → un
double-clic ne peut pas annuler un paiement (reducer `useOptimistic`
passé en **intent explicite** `{key, paid}`, idempotent sur lecture
périmée ; contrat rollback #234 préservé).
- **Édition** via le crayon : réutilise le formulaire (source unique
`formMode` + `resetForm`), branché sur l'`updateCommitmentAction` **déjà
existant**. **Garde** : impossible de réduire `installmentsTotal` sous
le nombre déjà coché (pas d'orphelin ledger silencieux).
- **Layout ligne** : contrôles (stepper + crayon + poubelle) déplacés
des coins `absolute` cramés vers une rangée `flex` en flux,
mobile-first, cibles 44px (`size-11 md:size-9`).
- **i18n** : +10 clés × 5 locales (traductions réelles), −2 obsolètes.

## Process

`plan-reviewer` : ✅ APPROVED (6 renforcements intégrés : race
double-clic, garde reduction, migration des 6 tests toggle, discipline
reset form bi-mode, DoD, hygiène branche). Backend (update/toggle
actions) **non modifié** — réutilisé.

## Evidence

- `typecheck` ✅ · `lint` 0 erreur · **1616 tests** (+6 stepper, +4 net
commitments, 6 tests toggle réécrits sur le stepper dont le contrat
rollback)
- QA : `dashboard-ux-auditor` + `i18n-auditor` lancés.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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