Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion messages/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion messages/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion messages/fr-BE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion messages/nl-BE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
50 changes: 25 additions & 25 deletions src/app/[locale]/app/charges/CadenceField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,28 +65,31 @@ export function CadenceField({ idPrefix, value, onChange, disabled }: CadenceFie

return (
<div className="flex flex-col gap-3" data-testid={`${idPrefix}-field`}>
<div className="flex flex-col gap-2">
<label htmlFor={freqId} className="text-sm font-medium">
{t('frequencyLabel')}
</label>
<select
id={freqId}
data-testid={`${idPrefix}-frequency`}
className={selectClass}
value={value.frequency}
disabled={disabled}
onChange={(e) => onChange({ ...value, frequency: e.target.value as ChargeFrequency })}
>
{CHARGE_FREQUENCIES.map((f) => (
<option key={f} value={f}>
{tFreq(f)}
</option>
))}
</select>
</div>
{/* 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. */}
<div className="grid gap-3 md:grid-cols-3 md:items-end">
<div className="flex flex-col gap-2">
<label htmlFor={freqId} className="text-sm font-medium">
{t('frequencyLabel')}
</label>
<select
id={freqId}
data-testid={`${idPrefix}-frequency`}
className={selectClass}
value={value.frequency}
disabled={disabled}
onChange={(e) => onChange({ ...value, frequency: e.target.value as ChargeFrequency })}
>
{CHARGE_FREQUENCIES.map((f) => (
<option key={f} value={f}>
{tFreq(f)}
</option>
))}
</select>
</div>

<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-32 flex-1 flex-col gap-2">
<div className="flex flex-col gap-2">
<label htmlFor={dayId} className="text-sm font-medium">
{t('dayLabel')}
</label>
Expand All @@ -108,10 +111,7 @@ export function CadenceField({ idPrefix, value, onChange, disabled }: CadenceFie
</div>

{!isMonthly && (
<div
className="flex min-w-32 flex-1 flex-col gap-2"
data-testid={`${idPrefix}-month-wrap`}
>
<div className="flex flex-col gap-2" data-testid={`${idPrefix}-month-wrap`}>
<label htmlFor={monthId} className="text-sm font-medium">
{t('anchorMonthLabel')}
</label>
Expand Down
4 changes: 3 additions & 1 deletion src/app/[locale]/app/charges/ChargesClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,9 @@ export function ChargesClient({
</CardHeader>
<CardContent>
<form onSubmit={onCreate} className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-2 md:col-span-2">
{/* Compact layout (@thierry 2026-07-19): label + amount share one
row on ≥md — no more oversized full-width stacked fields. */}
<div className="flex flex-col gap-2">
<Label htmlFor="label">{t('labelLabel')}</Label>
<Input
id="label"
Expand Down
16 changes: 9 additions & 7 deletions src/app/[locale]/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ProchainesFacturesCard } from '@/components/dashboard/ProchainesFacture
import { SimulatorDrawer } from '@/components/dashboard/SimulatorDrawer';
import { Expenses, Transfer, money } from '@/lib/domain';
import { calculerSituationDuMois, paymentKey, type PaymentLedger } from '@/lib/domain/cockpit';
import { countUnpaidForPeriod } from '@/lib/domain/charges';
import { unpaidChargesForPeriod } from '@/lib/domain/charges';
import { getWorkspaceSnapshot, toCockpitCharges } from '@/lib/data/workspace-snapshot';
import type { AccountType } from '@/lib/schemas/account';
import type { Locale } from '@/i18n/routing';
Expand Down Expand Up @@ -202,12 +202,14 @@ export default async function DashboardPage() {
payments={paymentsLedger}
todayIso={todayIso}
locale={locale}
forgottenCount={countUnpaidForPeriod(
snapshot.charges,
new Set(snapshot.previousMonthPaidChargeIds),
snapshot.previousPeriod,
)}
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),
}}
/>
</section>

Expand Down
24 changes: 14 additions & 10 deletions src/components/dashboard/ProchainesFacturesCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ type Props = {
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.
* 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 »).
*/
forgottenCount?: number;
/** Localized label of the previous month (e.g. « juin »), for the alert copy. */
forgottenMonthLabel?: string;
forgotten?: { labels: readonly string[]; monthLabel: string };
};

type Row = Readonly<{
Expand Down Expand Up @@ -58,8 +59,7 @@ export async function ProchainesFacturesCard({
payments,
todayIso,
locale,
forgottenCount = 0,
forgottenMonthLabel = '',
forgotten,
}: Props) {
const t = await getTranslations('dashboard.upcomingBills');

Expand Down Expand Up @@ -148,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). */}
{forgottenCount > 0 && (
{forgotten && forgotten.labels.length > 0 && (
<p
className="border-warning/40 bg-warning/10 text-foreground flex items-start gap-2 rounded-lg border px-3 py-2.5 text-sm"
data-testid="prochaines-factures-forgotten"
Expand All @@ -158,7 +158,11 @@ export async function ProchainesFacturesCard({
className="text-warning mt-0.5 h-4 w-4 shrink-0"
strokeWidth={2}
/>
{t('forgottenAlert', { count: forgottenCount, month: forgottenMonthLabel })}
{t('forgottenAlert', {
count: forgotten.labels.length,
month: forgotten.monthLabel,
labels: forgotten.labels.join(', '),
})}
</p>
)}
{/* ── Ce mois-ci ─────────────────────────────────────────── */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,15 @@ async function renderCard(input: {
charges: Charge[];
payments?: PaymentLedger;
todayIso?: string;
forgottenCount?: number;
forgottenMonthLabel?: string;
forgotten?: { labels: readonly string[]; monthLabel: string };
}) {
return render(
await ProchainesFacturesCard({
charges: input.charges,
payments: input.payments ?? NO_PAYMENTS,
todayIso: input.todayIso ?? TODAY,
locale: 'fr-BE',
forgottenCount: input.forgottenCount ?? 0,
forgottenMonthLabel: input.forgottenMonthLabel ?? '',
forgotten: input.forgotten,
}),
);
}
Expand Down Expand Up @@ -179,25 +177,23 @@ describe('<ProchainesFacturesCard /> — 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,
forgottenMonthLabel: 'juin',
forgotten: { labels: ['Taxe voiture', 'S.W.D.E'], monthLabel: '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,
forgottenMonthLabel: 'juin',
forgotten: { labels: ['Taxe voiture'], monthLabel: '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/,
);
});

Expand Down
15 changes: 14 additions & 1 deletion src/lib/domain/charges/__tests__/unpaid-count.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): UnpaidCountCharge => ({
id: 'c1',
Expand Down Expand Up @@ -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']);
});
});
6 changes: 5 additions & 1 deletion src/lib/domain/charges/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 18 additions & 10 deletions src/lib/domain/charges/unpaid-count.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -16,12 +15,21 @@ export type UnpaidCountCharge = Readonly<{
isActive: boolean;
}>;

/** The charges themselves (generic: callers keep their richer charge type). */
export function unpaidChargesForPeriod<T extends UnpaidCountCharge>(
charges: readonly T[],
paidChargeIds: ReadonlySet<string>,
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<string>,
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;
}
Loading