From a3d94de36c160308b6bbd7f81f52570eaa2d5a83 Mon Sep 17 00:00:00 2001 From: thierryvm Date: Sun, 19 Jul 2026 10:12:28 +0200 Subject: [PATCH 1/2] fix(charges): name the forgotten bills in the alert + compact add-form (@thierry feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. The forgotten-bills alert was blind ('1 facture de juin' — which one??). It now NAMES them: 'Jamais cochée en juin : Taxe voiture — vérifie qu'elle a bien été payée.' New generic unpaidChargesForPeriod returns the charges themselves (count derives from it); the card takes labels. 2. The add-charge form fields were oversized full-width stacked rows: label + amount now share one row on ≥md, and the CadenceField controls (frequency / day / anchor) collapse onto a single 3-column row on ≥md. Mobile keeps the vertical stack. i18n forgottenAlert v2 x5 ({labels} + singular/plural agreement). 76 tests green across the four affected suites. --- messages/de-DE.json | 2 +- messages/en.json | 2 +- messages/es-ES.json | 2 +- messages/fr-BE.json | 2 +- messages/nl-BE.json | 2 +- src/app/[locale]/app/charges/CadenceField.tsx | 50 +++++++++---------- .../[locale]/app/charges/ChargesClient.tsx | 4 +- src/app/[locale]/app/page.tsx | 6 +-- .../dashboard/ProchainesFacturesCard.tsx | 21 +++++--- .../__tests__/ProchainesFacturesCard.test.tsx | 14 +++--- .../charges/__tests__/unpaid-count.test.ts | 15 +++++- src/lib/domain/charges/index.ts | 6 ++- src/lib/domain/charges/unpaid-count.ts | 28 +++++++---- 13 files changed, 93 insertions(+), 61 deletions(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 932a9c8..1bd9e8c 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1134,7 +1134,7 @@ "watched": "Im Blick", "watchedHint": "Deine markierten Rechnungen, um sie kommen zu sehen.", "watchedEmptyHint": "Tipp: Markiere eine Rechnung auf der Fixkosten-Seite als „im Blick“, um sie hier zu verfolgen.", - "forgottenAlert": "{count, plural, =1 {1 Rechnung von {month} wurde nie abgehakt} other {# Rechnungen von {month} wurden nie abgehakt}} — prüfe, ob sie wirklich bezahlt wurden.", + "forgottenAlert": "In {month} nie abgehakt: {labels} — prüfe, ob {count, plural, =1 {sie wirklich bezahlt wurde} other {sie wirklich bezahlt wurden}}.", "buckets": { "overdue": "Überfällig" } diff --git a/messages/en.json b/messages/en.json index 44997f1..61bc308 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1134,7 +1134,7 @@ "watched": "Watching", "watchedHint": "Your flagged bills, to see them coming.", "watchedEmptyHint": "Tip: flag a bill as “watching” on the Charges page to track it here.", - "forgottenAlert": "{count, plural, =1 {1 bill from {month} was never ticked} other {# bills from {month} were never ticked}} — check they were actually paid.", + "forgottenAlert": "Never ticked in {month}: {labels} — check {count, plural, =1 {it was} other {they were}} actually paid.", "buckets": { "overdue": "Overdue" } diff --git a/messages/es-ES.json b/messages/es-ES.json index 3a9c19a..0730baa 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1134,7 +1134,7 @@ "watched": "En seguimiento", "watchedHint": "Tus facturas marcadas, para verlas venir.", "watchedEmptyHint": "Consejo: marca una factura «en seguimiento» en la página de Gastos fijos para seguirla aquí.", - "forgottenAlert": "{count, plural, =1 {1 factura de {month} nunca se marcó} other {# facturas de {month} nunca se marcaron}} — comprueba que realmente se pagaron.", + "forgottenAlert": "Nunca {count, plural, =1 {marcada} other {marcadas}} en {month}: {labels} — comprueba que realmente se {count, plural, =1 {pagó} other {pagaron}}.", "buckets": { "overdue": "Atrasadas" } diff --git a/messages/fr-BE.json b/messages/fr-BE.json index c4166db..651475e 100644 --- a/messages/fr-BE.json +++ b/messages/fr-BE.json @@ -1134,7 +1134,7 @@ "watched": "À surveiller", "watchedHint": "Tes factures marquées, pour les voir venir.", "watchedEmptyHint": "Astuce : marque une facture « à surveiller » depuis la page Charges pour la suivre ici.", - "forgottenAlert": "{count, plural, =1 {1 facture de {month} n'a jamais été cochée} other {# factures de {month} n'ont jamais été cochées}} — vérifie qu'elles ont bien été payées.", + "forgottenAlert": "Jamais {count, plural, =1 {cochée} other {cochées}} en {month} : {labels} — vérifie {count, plural, =1 {qu'elle a bien été payée} other {qu'elles ont bien été payées}}.", "buckets": { "overdue": "En retard" } diff --git a/messages/nl-BE.json b/messages/nl-BE.json index c9926d1..fb12062 100644 --- a/messages/nl-BE.json +++ b/messages/nl-BE.json @@ -1134,7 +1134,7 @@ "watched": "In het oog houden", "watchedHint": "Je gemarkeerde facturen, om ze te zien aankomen.", "watchedEmptyHint": "Tip: markeer een factuur als 'in het oog houden' op de pagina Vaste kosten om ze hier te volgen.", - "forgottenAlert": "{count, plural, =1 {1 factuur van {month} werd nooit afgevinkt} other {# facturen van {month} werden nooit afgevinkt}} — controleer of ze echt betaald zijn.", + "forgottenAlert": "Nooit afgevinkt in {month}: {labels} — controleer of {count, plural, =1 {die echt betaald is} other {die echt betaald zijn}}.", "buckets": { "overdue": "Te laat" } diff --git a/src/app/[locale]/app/charges/CadenceField.tsx b/src/app/[locale]/app/charges/CadenceField.tsx index 9d3f289..1aa811a 100644 --- a/src/app/[locale]/app/charges/CadenceField.tsx +++ b/src/app/[locale]/app/charges/CadenceField.tsx @@ -65,28 +65,31 @@ export function CadenceField({ idPrefix, value, onChange, disabled }: CadenceFie return (
-
- - -
+ {/* Compact control row (@thierry 2026-07-19: full-width stacked fields + were oversized). Mobile keeps the vertical stack; ≥md the three + selects share one line. */} +
+
+ + +
-
-
+
@@ -108,10 +111,7 @@ export function CadenceField({ idPrefix, value, onChange, disabled }: CadenceFie
{!isMonthly && ( -
+
diff --git a/src/app/[locale]/app/charges/ChargesClient.tsx b/src/app/[locale]/app/charges/ChargesClient.tsx index 18000d9..c5522cc 100644 --- a/src/app/[locale]/app/charges/ChargesClient.tsx +++ b/src/app/[locale]/app/charges/ChargesClient.tsx @@ -515,7 +515,9 @@ export function ChargesClient({
-
+ {/* Compact layout (@thierry 2026-07-19): label + amount share one + row on ≥md — no more oversized full-width stacked fields. */} +
c.label)} forgottenMonthLabel={formatMonth(snapshot.previousPeriod.month, locale)} /> diff --git a/src/components/dashboard/ProchainesFacturesCard.tsx b/src/components/dashboard/ProchainesFacturesCard.tsx index 911f304..9df2838 100644 --- a/src/components/dashboard/ProchainesFacturesCard.tsx +++ b/src/components/dashboard/ProchainesFacturesCard.tsx @@ -17,12 +17,13 @@ type Props = { todayIso: string; locale: Locale; /** - * Bills due LAST month that were never ticked (the per-period ledger resets - * naturally on month rollover, hiding them). > 0 renders the "forgotten - * bills" alert so the user checks they were actually paid. Computed - * upstream via `countUnpaidForPeriod` on the previous period's ledger. + * Labels of the bills due LAST month that were never ticked (the per-period + * ledger resets naturally on month rollover, hiding them). Non-empty renders + * the "forgotten bills" alert NAMING the bills so the user checks they were + * actually paid. Computed upstream via `unpaidChargesForPeriod` on the + * previous period's ledger. */ - forgottenCount?: number; + forgottenLabels?: readonly string[]; /** Localized label of the previous month (e.g. « juin »), for the alert copy. */ forgottenMonthLabel?: string; }; @@ -58,7 +59,7 @@ export async function ProchainesFacturesCard({ payments, todayIso, locale, - forgottenCount = 0, + forgottenLabels = [], forgottenMonthLabel = '', }: Props) { const t = await getTranslations('dashboard.upcomingBills'); @@ -148,7 +149,7 @@ export async function ProchainesFacturesCard({ {/* Forgotten-bills alert — factual, calm, FSMA-safe. The copy text stays `text-foreground` (AA both themes); the warning tint is decorative only (same dark-safety rule as the overdue badge). */} - {forgottenCount > 0 && ( + {forgottenLabels.length > 0 && (

- {t('forgottenAlert', { count: forgottenCount, month: forgottenMonthLabel })} + {t('forgottenAlert', { + count: forgottenLabels.length, + month: forgottenMonthLabel, + labels: forgottenLabels.join(', '), + })}

)} {/* ── Ce mois-ci ─────────────────────────────────────────── */} diff --git a/src/components/dashboard/__tests__/ProchainesFacturesCard.test.tsx b/src/components/dashboard/__tests__/ProchainesFacturesCard.test.tsx index 73d4d36..8557ae2 100644 --- a/src/components/dashboard/__tests__/ProchainesFacturesCard.test.tsx +++ b/src/components/dashboard/__tests__/ProchainesFacturesCard.test.tsx @@ -70,7 +70,7 @@ async function renderCard(input: { charges: Charge[]; payments?: PaymentLedger; todayIso?: string; - forgottenCount?: number; + forgottenLabels?: readonly string[]; forgottenMonthLabel?: string; }) { return render( @@ -79,7 +79,7 @@ async function renderCard(input: { payments: input.payments ?? NO_PAYMENTS, todayIso: input.todayIso ?? TODAY, locale: 'fr-BE', - forgottenCount: input.forgottenCount ?? 0, + forgottenLabels: input.forgottenLabels ?? [], forgottenMonthLabel: input.forgottenMonthLabel ?? '', }), ); @@ -179,25 +179,25 @@ describe(' — THI-329 PR-C', () => { expect(screen.getByTestId('prochaines-factures-watched-hint')).toBeInTheDocument(); }); - it('renders the forgotten-bills alert with the dynamic count and month label', async () => { + it('names the forgotten bills in the alert (plural form)', async () => { await renderCard({ charges: [makeCharge()], - forgottenCount: 2, + forgottenLabels: ['Taxe voiture', 'S.W.D.E'], forgottenMonthLabel: 'juin', }); expect(screen.getByTestId('prochaines-factures-forgotten')).toHaveTextContent( - /2 factures de juin n'ont jamais été cochées/, + /Jamais cochées en juin : Taxe voiture, S\.W\.D\.E/, ); }); it('uses the singular form for a single forgotten bill', async () => { await renderCard({ charges: [makeCharge()], - forgottenCount: 1, + forgottenLabels: ['Taxe voiture'], forgottenMonthLabel: 'juin', }); expect(screen.getByTestId('prochaines-factures-forgotten')).toHaveTextContent( - /1 facture de juin n'a jamais été cochée/, + /Jamais cochée en juin : Taxe voiture — vérifie qu'elle a bien été payée/, ); }); diff --git a/src/lib/domain/charges/__tests__/unpaid-count.test.ts b/src/lib/domain/charges/__tests__/unpaid-count.test.ts index fd9d7f0..6edfda7 100644 --- a/src/lib/domain/charges/__tests__/unpaid-count.test.ts +++ b/src/lib/domain/charges/__tests__/unpaid-count.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { countUnpaidForPeriod, type UnpaidCountCharge } from '../unpaid-count'; +import { + countUnpaidForPeriod, + unpaidChargesForPeriod, + type UnpaidCountCharge, +} from '../unpaid-count'; const charge = (over: Partial = {}): UnpaidCountCharge => ({ id: 'c1', @@ -37,4 +41,13 @@ describe('countUnpaidForPeriod', () => { ]; expect(countUnpaidForPeriod(charges, new Set(['paid']), { month: 6 })).toBe(1); }); + + it('returns the charges themselves (generic), preserving caller-side fields', () => { + const charges = [ + { ...charge({ id: 'paid' }), label: 'Loyer' }, + { ...charge({ id: 'unpaid' }), label: 'Taxe voiture' }, + ]; + const result = unpaidChargesForPeriod(charges, new Set(['paid']), { month: 6 }); + expect(result.map((c) => c.label)).toEqual(['Taxe voiture']); + }); }); diff --git a/src/lib/domain/charges/index.ts b/src/lib/domain/charges/index.ts index 58afa71..349c7ff 100644 --- a/src/lib/domain/charges/index.ts +++ b/src/lib/domain/charges/index.ts @@ -12,7 +12,11 @@ export { type ChargePeriodStatus, type CurrentPeriodDueResult, } from './current-period-due-date'; -export { countUnpaidForPeriod, type UnpaidCountCharge } from './unpaid-count'; +export { + countUnpaidForPeriod, + unpaidChargesForPeriod, + type UnpaidCountCharge, +} from './unpaid-count'; export { chargeMatchesMonth } from './match-month'; export { paymentMonthsFromFrequency } from './payment-months-from-frequency'; export { diff --git a/src/lib/domain/charges/unpaid-count.ts b/src/lib/domain/charges/unpaid-count.ts index e76f89b..c2e30d4 100644 --- a/src/lib/domain/charges/unpaid-count.ts +++ b/src/lib/domain/charges/unpaid-count.ts @@ -1,12 +1,11 @@ /** - * Count the active charges DUE in `period` that carry no paid tick. + * Active charges DUE in `period` that carry no paid tick. * - * Feeds the "forgotten bills" alert (epic Factures cohérentes, final brick): - * the payment ledger is per-period, so when the month rolls over every - * checkbox naturally resets — which also makes last month's unticked bills - * invisible. This pure helper, fed with the PREVIOUS period's paid charge - * ids, tells the dashboard how many bills were never ticked so the user can - * check they were actually paid. + * Feeds the "forgotten bills" alert (epic Factures cohérentes): the payment + * ledger is per-period, so when the month rolls over every checkbox naturally + * resets — which also makes last month's unticked bills invisible. Fed with + * the PREVIOUS period's paid charge ids, this tells the dashboard WHICH bills + * were never ticked so the user can check they were actually paid. * * Pure calendar/set logic — no dates are resolved, no money involved. */ @@ -16,12 +15,21 @@ export type UnpaidCountCharge = Readonly<{ isActive: boolean; }>; +/** The charges themselves (generic: callers keep their richer charge type). */ +export function unpaidChargesForPeriod( + charges: readonly T[], + paidChargeIds: ReadonlySet, + period: Readonly<{ month: number }>, +): T[] { + return charges.filter( + (c) => c.isActive && c.paymentMonths.includes(period.month) && !paidChargeIds.has(c.id), + ); +} + export function countUnpaidForPeriod( charges: readonly UnpaidCountCharge[], paidChargeIds: ReadonlySet, period: Readonly<{ month: number }>, ): number { - return charges.filter( - (c) => c.isActive && c.paymentMonths.includes(period.month) && !paidChargeIds.has(c.id), - ).length; + return unpaidChargesForPeriod(charges, paidChargeIds, period).length; } From c69dad435d1097b577625ee33b3e3b72a083f788 Mon Sep 17 00:00:00 2001 From: thierryvm Date: Sun, 19 Jul 2026 10:26:02 +0200 Subject: [PATCH 2/2] =?UTF-8?q?refactor(dashboard):=20single=20forgotten?= =?UTF-8?q?=20prop=20object=20=E2=80=94=20labels=20can=20never=20appear=20?= =?UTF-8?q?without=20their=20month=20(Sourcery=20#230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[locale]/app/page.tsx | 14 +++++----- .../dashboard/ProchainesFacturesCard.tsx | 27 +++++++++---------- .../__tests__/ProchainesFacturesCard.test.tsx | 12 +++------ 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/app/[locale]/app/page.tsx b/src/app/[locale]/app/page.tsx index 9c5dbe0..124966b 100644 --- a/src/app/[locale]/app/page.tsx +++ b/src/app/[locale]/app/page.tsx @@ -202,12 +202,14 @@ export default async function DashboardPage() { payments={paymentsLedger} todayIso={todayIso} locale={locale} - forgottenLabels={unpaidChargesForPeriod( - snapshot.charges, - new Set(snapshot.previousMonthPaidChargeIds), - snapshot.previousPeriod, - ).map((c) => c.label)} - forgottenMonthLabel={formatMonth(snapshot.previousPeriod.month, locale)} + forgotten={{ + labels: unpaidChargesForPeriod( + snapshot.charges, + new Set(snapshot.previousMonthPaidChargeIds), + snapshot.previousPeriod, + ).map((c) => c.label), + monthLabel: formatMonth(snapshot.previousPeriod.month, locale), + }} /> diff --git a/src/components/dashboard/ProchainesFacturesCard.tsx b/src/components/dashboard/ProchainesFacturesCard.tsx index 9df2838..96e8a8e 100644 --- a/src/components/dashboard/ProchainesFacturesCard.tsx +++ b/src/components/dashboard/ProchainesFacturesCard.tsx @@ -17,15 +17,15 @@ type Props = { todayIso: string; locale: Locale; /** - * Labels of the bills due LAST month that were never ticked (the per-period - * ledger resets naturally on month rollover, hiding them). Non-empty renders - * the "forgotten bills" alert NAMING the bills so the user checks they were - * actually paid. Computed upstream via `unpaidChargesForPeriod` on the - * previous period's ledger. + * Bills due LAST month that were never ticked (the per-period ledger resets + * naturally on month rollover, hiding them). Non-empty `labels` renders the + * "forgotten bills" alert NAMING the bills so the user checks they were + * actually paid. Single object so labels can never appear without their + * month (Sourcery #230). Computed upstream via `unpaidChargesForPeriod` + * on the previous period's ledger; `monthLabel` is the localized previous + * month (e.g. « juin »). */ - forgottenLabels?: readonly string[]; - /** Localized label of the previous month (e.g. « juin »), for the alert copy. */ - forgottenMonthLabel?: string; + forgotten?: { labels: readonly string[]; monthLabel: string }; }; type Row = Readonly<{ @@ -59,8 +59,7 @@ export async function ProchainesFacturesCard({ payments, todayIso, locale, - forgottenLabels = [], - forgottenMonthLabel = '', + forgotten, }: Props) { const t = await getTranslations('dashboard.upcomingBills'); @@ -149,7 +148,7 @@ export async function ProchainesFacturesCard({ {/* Forgotten-bills alert — factual, calm, FSMA-safe. The copy text stays `text-foreground` (AA both themes); the warning tint is decorative only (same dark-safety rule as the overdue badge). */} - {forgottenLabels.length > 0 && ( + {forgotten && forgotten.labels.length > 0 && (

{t('forgottenAlert', { - count: forgottenLabels.length, - month: forgottenMonthLabel, - labels: forgottenLabels.join(', '), + count: forgotten.labels.length, + month: forgotten.monthLabel, + labels: forgotten.labels.join(', '), })}

)} diff --git a/src/components/dashboard/__tests__/ProchainesFacturesCard.test.tsx b/src/components/dashboard/__tests__/ProchainesFacturesCard.test.tsx index 8557ae2..f53d844 100644 --- a/src/components/dashboard/__tests__/ProchainesFacturesCard.test.tsx +++ b/src/components/dashboard/__tests__/ProchainesFacturesCard.test.tsx @@ -70,8 +70,7 @@ async function renderCard(input: { charges: Charge[]; payments?: PaymentLedger; todayIso?: string; - forgottenLabels?: readonly string[]; - forgottenMonthLabel?: string; + forgotten?: { labels: readonly string[]; monthLabel: string }; }) { return render( await ProchainesFacturesCard({ @@ -79,8 +78,7 @@ async function renderCard(input: { payments: input.payments ?? NO_PAYMENTS, todayIso: input.todayIso ?? TODAY, locale: 'fr-BE', - forgottenLabels: input.forgottenLabels ?? [], - forgottenMonthLabel: input.forgottenMonthLabel ?? '', + forgotten: input.forgotten, }), ); } @@ -182,8 +180,7 @@ describe(' — THI-329 PR-C', () => { it('names the forgotten bills in the alert (plural form)', async () => { await renderCard({ charges: [makeCharge()], - forgottenLabels: ['Taxe voiture', 'S.W.D.E'], - forgottenMonthLabel: 'juin', + forgotten: { labels: ['Taxe voiture', 'S.W.D.E'], monthLabel: 'juin' }, }); expect(screen.getByTestId('prochaines-factures-forgotten')).toHaveTextContent( /Jamais cochées en juin : Taxe voiture, S\.W\.D\.E/, @@ -193,8 +190,7 @@ describe(' — THI-329 PR-C', () => { it('uses the singular form for a single forgotten bill', async () => { await renderCard({ charges: [makeCharge()], - forgottenLabels: ['Taxe voiture'], - forgottenMonthLabel: 'juin', + forgotten: { labels: ['Taxe voiture'], monthLabel: 'juin' }, }); expect(screen.getByTestId('prochaines-factures-forgotten')).toHaveTextContent( /Jamais cochée en juin : Taxe voiture — vérifie qu'elle a bien été payée/,