feat(charges): watched marker + dashboard bills card rework (THI-329 PR-C) - #227
Conversation
…PR-C) The dashboard finally answers @Thierry's P5 verbatim. ProchainesFacturesCard drops the fragmented J-7/J-14/J-30 buckets for two sections: - « Ce mois-ci »: the month's UNPAID bills (anchored via currentPeriodDueDate, overdue surfaced + sorted first), capped at 5 by date, headed by the live 'reste à payer' (same definition as the charges-page banner). All paid → success state. - « À surveiller »: ONLY manually flagged bills (is_watched) not already due this month, with their real next unpaid occurrence (nextUnpaidDueDate). Replaces the rejected automatic 'Mois prochain' bucket. Educational hint when nothing is flagged yet. DATA: migration 20260718000001 adds charges.is_watched (boolean default false, table-level RLS untouched). New Server Action toggleWatchAction (uuid guard, workspace authz read+write, rate-limit, CHARGE_WATCH_TOGGLED audit, revalidate). Snapshot + domain Charge + supabase types carry isWatched. UI charges page: inline Bookmark toggle per row (44px mobile, aria-pressed, useOptimistic, brand fill when flagged) between frequency and edit. a11y (THI-348 SOLDÉ): the rewritten card drops text-danger-on-tint chips and text-brand-700 — overdue is white on solid danger (4.84:1 both themes), day chips are neutral surface tokens, links use dark-safe text-brand-text. i18n x5: dashboard.upcomingBills.{thisMonth,thisMonthHint,thisMonthEmpty, remainingLabel,watched,watchedHint,watchedEmptyHint}, buckets j7/j14/j30 removed; app.charges.{watchAria,unwatchAria,toastWatched,toastUnwatched}; errors.charges.watchFailed. Tests: 9 card (new contract) + 5 action + 3 client watch + i18n parity. Full suite 1474 green, typecheck + lint + build clean. Review: plan-reviewer hit the session limit mid-review; @Thierry explicitly mandated a Fable 5 self-review pass instead (2026-07-18) — plan committed in docs/plans/pr-c-watched-marker-dashboard.md.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Guide du·de la relecteur·riceRemanie la carte des prochaines factures sur le tableau de bord en sections « Ce mois-ci » et « À surveiller » basées sur les charges de la période en cours et les charges marquées à surveiller. Introduit en parallèle une chaîne complète pour le marqueur « à surveiller » (colonne en base de données, snapshot, action serveur, bouton de favoris côté client, tests et i18n), tout en corrigeant les problèmes de contraste des couleurs du tableau de bord. Diagramme de séquence pour le nouveau flux de bascule du marqueur « à surveiller »sequenceDiagram
actor User
participant ChargesClient
participant toggleWatchAction
participant Supabase
participant AuditLog
participant Dashboard as ProchainesFacturesCard
User->>ChargesClient: click Bookmark (onToggleWatch)
ChargesClient->>ChargesClient: applyOptimisticWatched
ChargesClient->>toggleWatchAction: toggleWatchAction(chargeId)
toggleWatchAction->>toggleWatchAction: uuidSchema.safeParse(id)
toggleWatchAction->>toggleWatchAction: authorizedWorkspace()
toggleWatchAction->>toggleWatchAction: rateLimit("mutation", userKey)
toggleWatchAction->>Supabase: createClient()
toggleWatchAction->>Supabase: from("charges").select("is_watched").eq("id").eq("workspace_id").maybeSingle()
Supabase-->>toggleWatchAction: charge.is_watched
toggleWatchAction->>Supabase: from("charges").update({ is_watched: !is_watched }).eq("id").eq("workspace_id")
Supabase-->>toggleWatchAction: update ok
toggleWatchAction->>AuditLog: logAuditEvent(CHARGE_WATCH_TOGGLED)
toggleWatchAction->>Dashboard: revalidateDashboard()
toggleWatchAction->>Dashboard: revalidateAppPath("charges")
toggleWatchAction-->>ChargesClient: { ok: true, data: { watched } }
ChargesClient->>ChargesClient: toast.success(toastWatched/toastUnwatched)
User-->>Dashboard: sees updated "À surveiller" section
Modifications par fichier
Conseils et commandesInteragir avec Sourcery
Personnaliser votre expérienceAccédez à votre tableau de bord pour :
Obtenir de l’aide
Original review guide in EnglishReviewer's GuideReworks the dashboard upcoming bills card into "Ce mois-ci" and "À surveiller" sections driven by current-period and watched charges, and introduces a full watched-marker pipeline (DB column, snapshot, server action, client bookmark button, tests, and i18n) while fixing dashboard color contrast issues. Sequence diagram for the new watched marker toggle flowsequenceDiagram
actor User
participant ChargesClient
participant toggleWatchAction
participant Supabase
participant AuditLog
participant Dashboard as ProchainesFacturesCard
User->>ChargesClient: click Bookmark (onToggleWatch)
ChargesClient->>ChargesClient: applyOptimisticWatched
ChargesClient->>toggleWatchAction: toggleWatchAction(chargeId)
toggleWatchAction->>toggleWatchAction: uuidSchema.safeParse(id)
toggleWatchAction->>toggleWatchAction: authorizedWorkspace()
toggleWatchAction->>toggleWatchAction: rateLimit("mutation", userKey)
toggleWatchAction->>Supabase: createClient()
toggleWatchAction->>Supabase: from("charges").select("is_watched").eq("id").eq("workspace_id").maybeSingle()
Supabase-->>toggleWatchAction: charge.is_watched
toggleWatchAction->>Supabase: from("charges").update({ is_watched: !is_watched }).eq("id").eq("workspace_id")
Supabase-->>toggleWatchAction: update ok
toggleWatchAction->>AuditLog: logAuditEvent(CHARGE_WATCH_TOGGLED)
toggleWatchAction->>Dashboard: revalidateDashboard()
toggleWatchAction->>Dashboard: revalidateAppPath("charges")
toggleWatchAction-->>ChargesClient: { ok: true, data: { watched } }
ChargesClient->>ChargesClient: toast.success(toastWatched/toastUnwatched)
User-->>Dashboard: sees updated "À surveiller" section
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - j’ai trouvé 1 problème et laissé quelques retours de haut niveau :
- Dans
ChargesClientet sa méthodeonToggleWatch, l’appel optimiste àapplyOptimisticWatchedn’est jamais annulé quandtoggleWatchActionéchoue, donc l’UI peut rester bloquée dans un état différent de celui du serveur ; envisage de revenir en arrière sur le basculement optimiste (ou d’éviter l’optimisme) en cas d’erreur afin de garder l’ensemble synchronisé avec le backend. - Dans
ProchainesFacturesCard,hasAnyDueest calculé à partir de toutes les factures dues ce mois-ci alors que le compteur affiché utilise uniquement les lignes impayées ; si toutes les factures sont payées, on obtient un chip itemCount à « 0 » à côté de l’état de succès « Tout est payé », ce qui peut être déroutant — alignerhasAnyDuesur les lignes impayées (ou cacher le chip lorsque l’état de succès est affiché) simplifierait l’UX.
Prompt pour les agents IA
Please address the comments from this code review:
## Overall Comments
- In `ChargesClient`’s `onToggleWatch`, the optimistic `applyOptimisticWatched` call is never reverted when `toggleWatchAction` fails, so the UI can get stuck in a different state from the server; consider reverting the optimistic flip (or avoiding optimism) on error to keep the set in sync with the backend.
- In `ProchainesFacturesCard`, `hasAnyDue` is computed from all due-this-month charges while the displayed count uses only unpaid rows; if all bills are paid, this yields a "0" itemCount chip next to the "Tout est payé" success state, which might be confusing—aligning `hasAnyDue` with unpaid rows (or hiding the chip when the success state shows) would simplify the UX.
## Individual Comments
### Comment 1
<location path="src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx" line_range="552-561" />
<code_context>
+ it('seeds the watch button state from isWatched and calls toggleWatchAction on tap', async () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Consider asserting optimistic watch state and the thrown-error path for `toggleWatchAction`.
Your current tests cover initial `aria-pressed` seeding, action wiring, and success/error toasts, but two contract behaviors are still unverified:
1) Because `useOptimistic` is used, the button/icon should toggle immediately on click, before the server responds. You can assert that `aria-pressed` flips right after `fireEvent.click`, independent of the mocked result.
2) `onToggleWatch` has a `catch` branch that handles thrown errors differently from `{ ok: false }`. Adding a test where `toggleWatchMock` rejects would exercise this path and confirm we still show the `watchFailed` toast instead of silently swallowing exceptions.
Suggested implementation:
```typescript
it('seeds the watch button state from isWatched, optimistically toggles, and calls toggleWatchAction on tap', async () => {
toggleWatchMock.mockResolvedValue({ ok: true, data: { watched: true } });
renderCharges([monthlyCharge], { currentPeriod: { year: 2026, month: 1 } });
const watchBtn = screen.getByTestId(`charges-row-watch-${monthlyCharge.id}`);
// initial state is seeded from isWatched
expect(watchBtn).toHaveAttribute('aria-pressed', 'false');
await act(async () => {
fireEvent.click(watchBtn);
// useOptimistic should flip the pressed state immediately on click
expect(watchBtn).toHaveAttribute('aria-pressed', 'true');
});
await waitFor(() =>
expect(toggleWatchMock).toHaveBeenCalledWith(monthlyCharge.id),
);
await waitFor(() => expect(toastSuccessMock).toHaveBeenCalled());
});
it('optimistically toggles watch state and shows watchFailed toast when toggleWatchAction rejects', async () => {
const error = new Error('network error');
toggleWatchMock.mockRejectedValueOnce(error);
renderCharges([monthlyCharge], { currentPeriod: { year: 2026, month: 1 } });
const watchBtn = screen.getByTestId(`charges-row-watch-${monthlyCharge.id}`);
expect(watchBtn).toHaveAttribute('aria-pressed', 'false');
await act(async () => {
fireEvent.click(watchBtn);
// even when the action rejects, we still optimistically toggle
expect(watchBtn).toHaveAttribute('aria-pressed', 'true');
});
await waitFor(() =>
expect(toggleWatchMock).toHaveBeenCalledWith(monthlyCharge.id),
);
await waitFor(() =>
expect(toastErrorMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'watchFailed' }),
),
);
});
```
If your `toastErrorMock` signature or message shape differs (for example, it receives just a string key or a full message object), adjust the final `expect(toastErrorMock)...` accordingly to match how the `watchFailed` toast is actually invoked in your production code.
Also ensure `toastErrorMock` is defined in this test file and wired to whatever toast system the component uses for error paths; if not, add a corresponding jest mock similar to `toastSuccessMock`.
</issue_to_address>Sourcery est gratuit pour l’open source – si nos revues vous plaisent, pensez à les partager ✨
Original comment in English
Hey - I've found 1 issue, and left some high level feedback:
- In
ChargesClient’sonToggleWatch, the optimisticapplyOptimisticWatchedcall is never reverted whentoggleWatchActionfails, so the UI can get stuck in a different state from the server; consider reverting the optimistic flip (or avoiding optimism) on error to keep the set in sync with the backend. - In
ProchainesFacturesCard,hasAnyDueis computed from all due-this-month charges while the displayed count uses only unpaid rows; if all bills are paid, this yields a "0" itemCount chip next to the "Tout est payé" success state, which might be confusing—aligninghasAnyDuewith unpaid rows (or hiding the chip when the success state shows) would simplify the UX.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `ChargesClient`’s `onToggleWatch`, the optimistic `applyOptimisticWatched` call is never reverted when `toggleWatchAction` fails, so the UI can get stuck in a different state from the server; consider reverting the optimistic flip (or avoiding optimism) on error to keep the set in sync with the backend.
- In `ProchainesFacturesCard`, `hasAnyDue` is computed from all due-this-month charges while the displayed count uses only unpaid rows; if all bills are paid, this yields a "0" itemCount chip next to the "Tout est payé" success state, which might be confusing—aligning `hasAnyDue` with unpaid rows (or hiding the chip when the success state shows) would simplify the UX.
## Individual Comments
### Comment 1
<location path="src/app/[locale]/app/charges/__tests__/ChargesClient.test.tsx" line_range="552-561" />
<code_context>
+ it('seeds the watch button state from isWatched and calls toggleWatchAction on tap', async () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Consider asserting optimistic watch state and the thrown-error path for `toggleWatchAction`.
Your current tests cover initial `aria-pressed` seeding, action wiring, and success/error toasts, but two contract behaviors are still unverified:
1) Because `useOptimistic` is used, the button/icon should toggle immediately on click, before the server responds. You can assert that `aria-pressed` flips right after `fireEvent.click`, independent of the mocked result.
2) `onToggleWatch` has a `catch` branch that handles thrown errors differently from `{ ok: false }`. Adding a test where `toggleWatchMock` rejects would exercise this path and confirm we still show the `watchFailed` toast instead of silently swallowing exceptions.
Suggested implementation:
```typescript
it('seeds the watch button state from isWatched, optimistically toggles, and calls toggleWatchAction on tap', async () => {
toggleWatchMock.mockResolvedValue({ ok: true, data: { watched: true } });
renderCharges([monthlyCharge], { currentPeriod: { year: 2026, month: 1 } });
const watchBtn = screen.getByTestId(`charges-row-watch-${monthlyCharge.id}`);
// initial state is seeded from isWatched
expect(watchBtn).toHaveAttribute('aria-pressed', 'false');
await act(async () => {
fireEvent.click(watchBtn);
// useOptimistic should flip the pressed state immediately on click
expect(watchBtn).toHaveAttribute('aria-pressed', 'true');
});
await waitFor(() =>
expect(toggleWatchMock).toHaveBeenCalledWith(monthlyCharge.id),
);
await waitFor(() => expect(toastSuccessMock).toHaveBeenCalled());
});
it('optimistically toggles watch state and shows watchFailed toast when toggleWatchAction rejects', async () => {
const error = new Error('network error');
toggleWatchMock.mockRejectedValueOnce(error);
renderCharges([monthlyCharge], { currentPeriod: { year: 2026, month: 1 } });
const watchBtn = screen.getByTestId(`charges-row-watch-${monthlyCharge.id}`);
expect(watchBtn).toHaveAttribute('aria-pressed', 'false');
await act(async () => {
fireEvent.click(watchBtn);
// even when the action rejects, we still optimistically toggle
expect(watchBtn).toHaveAttribute('aria-pressed', 'true');
});
await waitFor(() =>
expect(toggleWatchMock).toHaveBeenCalledWith(monthlyCharge.id),
);
await waitFor(() =>
expect(toastErrorMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'watchFailed' }),
),
);
});
```
If your `toastErrorMock` signature or message shape differs (for example, it receives just a string key or a full message object), adjust the final `expect(toastErrorMock)...` accordingly to match how the `watchFailed` toast is actually invoked in your production code.
Also ensure `toastErrorMock` is defined in this test file and wired to whatever toast system the component uses for error paths; if not, add a corresponding jest mock similar to `toastSuccessMock`.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
React 19 flushes the useOptimistic update asynchronously and reconciles to the base once the transition settles — the flip is only observable while the action promise is PENDING. Sourcery's inline-assert form was red; this holds the promise, asserts the optimistic state, then resolves.
…ailures Incident 2026-07-18: the #227 preview (code SELECTing is_watched) hit the prod schema (column not migrated yet) — the whole charges query failed, the '?? []' fallback swallowed it, and the dashboard + charges page rendered empty, looking exactly like data loss (all 23 rows were intact, verified read-only). Two defenses: - charges SELECT switches to '*': whatever columns exist are returned, the mapping defaults missing ones (isWatched ?? false) — a deploy/migration ordering window can never blank the page again. - a failing charges read is now log.error'ed instead of silently coerced to an empty list.
… domain (épic PR-1) (#233) ## PR-1 — Fondations « Engagements » (dettes, échéanciers, factures futures) Première brique de l'épic issu de tes retours sur ton Coda : *« pas les factures futures, les dettes liées à des crédits, ou un remboursement différé des impôts suite à un arrangement avec le SPF »*. **Cette PR ne change RIEN de visible** — schéma + domaine + contrats uniquement. L'UI arrive en PR-2. ### Le modèle : un seul objet pour tes 3 besoins | | `debt` (crédit voiture) | `installment_plan` (SPF) | `one_off` (facture future) | |---|---|---|---| | Ce que tu saisis | reste 4 200 € · 17 × 250 € | reste 1 600 € · 8 × 200 € | 340 € en octobre | | Ce qu'Ankora affiche (PR-2) | barre de progression + solde | « 3/8 payées · reste 1 000 € » | « 340 € le 12 oct. » | **Tout est dérivé** (date de fin, solde restant, progression) — jamais stocké, donc jamais faux. Les paiements réutilisent exactement le mécanisme des factures : cocher une échéance = le geste que tu connais déjà. ### Contenu - **Migration** `commitments` + `commitment_payments` : RLS, policies, index et trigger clonés 1:1 sur `charges`/`charge_payments`. Deux CHECKs encodent les invariants. **Additive**, aucune table existante touchée. - **Domaine pur** : `installmentPeriods`, `endPeriod`, `isDueInPeriod`, `remainingBalance`, `installmentsPaid`, `isFinished`. Le solde est borné à [0, total] : la dernière échéance absorbe l'arrondi (3 × 33,33 € sur 100 € tombe pile sur 0) et une coche parasite hors échéancier ne peut pas fausser la progression. - **Contrats Zod** miroirs des CHECKs SQL (la base ne peut pas refuser ce que le schéma a accepté). - **`supabase/types.ts` régénéré depuis la prod** via le CLI — remplace l'édition manuelle de #227. ### Décisions verrouillées avec toi (spec : `docs/plans/epic-dettes-echeanciers-spec.md`) Nom « **Engagements** » · saisie « **solde restant + échéances restantes** » · **bouton « convertir »** depuis une charge (PR-2) · **facture ponctuelle incluse**. ### Migration prod **Déjà appliquée** (`supabase db push`, ledger vérifié). Comme aucune UI ne la consomme, la prod est inchangée dans les deux sens. ### Tests 22 nouveaux (16 domaine + 6 schéma), suite complète **1513 verte**, typecheck/lint/build clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by Sourcery Introduire un modèle de données fondamental et une logique métier pour des engagements financiers finis (dettes, plans de paiement échelonné et factures ponctuelles futures) sans aucun changement d’interface utilisateur. Nouvelles fonctionnalités : - Ajouter les nouvelles tables `commitments` et `commitment_payments`, ainsi que les types Supabase associés et les politiques RLS, afin de modéliser les engagements financiers finis et leurs paiements. - Définir des schémas Zod pour valider les engagements en conformité avec les contraintes de la base de données. - Ajouter des fonctions de domaine pures pour calculer les calendriers d’engagement, les soldes restants et le statut de complétion. Améliorations : - Régénérer les types TypeScript de Supabase à partir du schéma de production et supprimer les typings GraphQL manuels obsolètes. - Documenter l’epic « Dettes & échéanciers », y compris l’énoncé du problème, le modèle de données et le plan de déploiement. Tests : - Ajouter des tests unitaires pour la nouvelle logique de domaine des calendriers d’engagements et les schémas Zod afin de garantir la justesse des calculs et des règles de validation. <details> <summary>Original summary in English</summary> ## Summary by Sourcery Introduce a foundational data model and domain logic for finite financial commitments (debts, installment plans, and one-off future bills) without any UI changes. New Features: - Add new `commitments` and `commitment_payments` tables, plus related Supabase types and RLS policies, to model finite financial engagements and their payments. - Define Zod schemas for validating commitments in alignment with database constraints. - Add pure domain functions for computing commitment schedules, remaining balances, and completion status. Enhancements: - Regenerate Supabase TypeScript types from the production schema and remove obsolete manual GraphQL typings. - Document the "Dettes & échéanciers" epic, including problem statement, data model, and rollout plan. Tests: - Add unit tests for the new commitments schedule domain logic and Zod schemas to ensure correctness of calculations and validation rules. </details>
PR-C — Marqueur « à surveiller » + card factures du dashboard remaniée (THI-329)
Dernier gros morceau de l'epic « Factures cohérentes ». Le dashboard répond enfin au besoin exprimé : « Ce mois-ci » = les factures du mois non payées + le reste à payer, et « À surveiller » = uniquement les factures que TU as marquées (fini le bucket « Mois prochain » automatique et bruyant).
La card
ProchainesFacturesCard, réécritecurrentPeriodDueDate— une facture passée non payée = badge « En retard », triée en tête), max 5 par date, montant « Reste à payer » live en tête (même définition que le bandeau de la page charges → cohérence totale). Tout payé → état succès « Tout est payé ce mois ».is_watched) non dues ce mois, avec leur vraie prochaine échéance non payée. Hint éducatif quand rien n'est marqué.text-dangersur teinte nitext-brand-700(fails AA dark) — badge blanc sur danger plein (4.84:1 les 2 thèmes), chips neutres, lienstext-brand-text.Le marqueur
20260718000001:charges.is_watched boolean not null default false(RLS table intacte).toggleWatchAction: garde uuid → authz workspace (lecture + écriture filtrées) → rate-limit → auditCHARGE_WATCH_TOGGLED→ revalidate.aria-pressed, optimiste, rempli brand quand marqué).Gouvernance
docs/plans/pr-c-watched-marker-dashboard.md. Analyse A→Z faite sur la chaîne complète (domaine → action → snapshot → UI).Tests
9 card (nouveau contrat, incl. cap 5, non-duplication watched/ce-mois, badge dark-safe) + 5 action (authz cross-workspace, audit, uuid, erreurs DB) + 3 client + parité i18n ×5. Suite complète 1474 verte, typecheck + lint + build clean.
Additive (
default false), zéro perte, RLS intacte — mais c'est ton merge (garde-fou).Smoke @Thierry (desktop + mobile + dark)
Dashboard : « Ce mois-ci » liste tes non-payées avec le reste à payer (tout payé aujourd'hui → état succès) ; marque une facture 🔖 sur
/app/charges→ elle apparaît dans « À surveiller » (si pas due ce mois) ; démarque → disparaît.🤖 Generated with Claude Code
Summary by Sourcery
Refonte de la carte des factures à venir du tableau de bord pour l’aligner avec l’épopée « Factures cohérentes » en affichant les factures impayées du mois en cours et une liste de surveillance contrôlée par l’utilisateur, le tout reposant sur un nouveau marqueur de surveillance persistant sur les charges.
Nouvelles fonctionnalités :
Corrections de bugs :
Améliorations :
Original summary in English
Summary by Sourcery
Rework the dashboard upcoming bills card to align with the "Factures cohérentes" epic by showing unpaid current-month bills and a user-controlled watchlist, backed by a new persisted watch marker on charges.
New Features:
Bug Fixes:
Enhancements: