From b258e2b80aa8af784c5a23ba7464e02b9b923f17 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 18 Apr 2026 20:50:18 -0400 Subject: [PATCH] fix(reports/cartes): remove broken period selector + add savings-rate tooltip (#101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the non-functional PeriodSelector from /reports/cartes — the Cartes report is by design a "month X vs X-1 vs X-12" snapshot, so the reference-month picker is the only control needed. - Simplify useCartes to drop its useReportsPeriod dependency; the hook now only exposes the reference year/month and its setter. - Add a (?) help bubble (lucide HelpCircle) next to the savings-rate KPI title, wired up via a new `tooltip?: string` prop on KpiCard. - Propagate `number | null` through CartesKpi.current and buildKpi so savings rate is now null (rendered as "—") when reference-month income is 0 instead of a misleading "0 %". Use refExpenses directly for seasonality.referenceAmount since it is always numeric. - Update the cartes snapshot tests to expect null for the zero-income case. - Add FR/EN strings reports.cartes.savingsRateTooltip + CHANGELOG entries in both locales. --- CHANGELOG.fr.md | 7 ++++++ CHANGELOG.md | 7 ++++++ src/components/reports/cards/KpiCard.tsx | 19 +++++++++++++-- src/hooks/useCartes.ts | 18 --------------- src/i18n/locales/en.json | 1 + src/i18n/locales/fr.json | 1 + src/pages/ReportsCartesPage.tsx | 28 +++++------------------ src/services/reportService.cartes.test.ts | 7 +++--- src/services/reportService.ts | 14 ++++++++---- src/shared/types/index.ts | 4 +++- 10 files changed, 55 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 3bd4801..434493b 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,6 +2,13 @@ ## [Non publié] +### Ajouté +- **Rapport Cartes** : info-bulle d'aide sur le KPI taux d'épargne expliquant la formule — `(revenus − dépenses) ÷ revenus × 100`, calculée sur le mois de référence (#101) + +### Corrigé +- **Rapport Cartes** : retrait du sélecteur de période non fonctionnel — le rapport Cartes est un instantané « mois X vs X-1 vs X-12 », seul le sélecteur de mois de référence est nécessaire (#101) +- **Rapport Cartes** : le KPI taux d'épargne affiche maintenant « — » au lieu de « 0 % » lorsque le mois de référence n'a aucun revenu (une division par zéro est indéfinie, pas zéro) (#101) + ## [0.8.2] - 2026-04-17 ### Ajouté diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f696ae..507859d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Added +- **Cartes report**: help tooltip on the savings-rate KPI explaining the formula — `(income − expenses) ÷ income × 100`, computed on the reference month (#101) + +### Fixed +- **Cartes report**: removed the non-functional period selector — the Cartes report is a "month X vs X-1 vs X-12" snapshot, so only the reference-month picker is needed (#101) +- **Cartes report**: savings-rate KPI now shows "—" instead of "0 %" when the reference month has no income (division by zero is undefined, not zero) (#101) + ## [0.8.2] - 2026-04-17 ### Added diff --git a/src/components/reports/cards/KpiCard.tsx b/src/components/reports/cards/KpiCard.tsx index 5de9af5..7f4a19f 100644 --- a/src/components/reports/cards/KpiCard.tsx +++ b/src/components/reports/cards/KpiCard.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { HelpCircle } from "lucide-react"; import KpiSparkline from "./KpiSparkline"; import type { CartesKpi, CartesKpiId } from "../../../shared/types"; @@ -9,6 +10,8 @@ export interface KpiCardProps { format: "currency" | "percent"; /** When true, positive deltas are rendered in red (e.g. rising expenses). */ deltaIsBadWhenUp?: boolean; + /** Optional help text shown on hover of a (?) icon next to the title. */ + tooltip?: string; } function formatCurrency(amount: number, language: string): string { @@ -102,6 +105,7 @@ export default function KpiCard({ kpi, format, deltaIsBadWhenUp = false, + tooltip, }: KpiCardProps) { const { t, i18n } = useTranslation(); const language = i18n.language; @@ -111,9 +115,20 @@ export default function KpiCard({ data-kpi={id} className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3" > -
{title}
+
+ {title} + {tooltip && ( + + + )} +
- {formatValue(kpi.current, format, language)} + {kpi.current === null ? "—" : formatValue(kpi.current, format, language)}
diff --git a/src/hooks/useCartes.ts b/src/hooks/useCartes.ts index d42cc15..7d82eda 100644 --- a/src/hooks/useCartes.ts +++ b/src/hooks/useCartes.ts @@ -1,7 +1,6 @@ import { useReducer, useCallback, useEffect, useRef } from "react"; import type { CartesSnapshot } from "../shared/types"; import { getCartesSnapshot } from "../services/reportService"; -import { useReportsPeriod } from "./useReportsPeriod"; interface State { year: number; @@ -55,7 +54,6 @@ function reducer(state: State, action: Action): State { } export function useCartes() { - const { from, to, period, setPeriod, setCustomDates } = useReportsPeriod(); const [state, dispatch] = useReducer(reducer, initialState); const fetchIdRef = useRef(0); @@ -76,17 +74,6 @@ export function useCartes() { fetch(state.year, state.month); }, [fetch, state.year, state.month]); - // Keep the reference month in sync with the URL `to` date, so navigating - // via PeriodSelector works as expected. - useEffect(() => { - const [y, m] = to.split("-").map(Number); - if (!Number.isFinite(y) || !Number.isFinite(m)) return; - if (y !== state.year || m !== state.month) { - dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [to]); - const setReferencePeriod = useCallback((year: number, month: number) => { dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } }); }, []); @@ -94,10 +81,5 @@ export function useCartes() { return { ...state, setReferencePeriod, - from, - to, - period, - setPeriod, - setCustomDates, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9a81b4a..3905ec6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -418,6 +418,7 @@ "expenses": "Expenses", "net": "Net balance", "savingsRate": "Savings rate", + "savingsRateTooltip": "Formula: (income − expenses) ÷ income × 100, computed on the reference month.", "deltaMoMLabel": "vs last month", "deltaYoYLabel": "vs last year", "flowChartTitle": "Income vs expenses — last 12 months", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index bcf7e30..2bc0c75 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -418,6 +418,7 @@ "expenses": "Dépenses", "net": "Solde net", "savingsRate": "Taux d'épargne", + "savingsRateTooltip": "Formule : (revenus − dépenses) ÷ revenus × 100, calculée sur le mois de référence.", "deltaMoMLabel": "vs mois précédent", "deltaYoYLabel": "vs l'an dernier", "flowChartTitle": "Revenus vs dépenses — 12 derniers mois", diff --git a/src/pages/ReportsCartesPage.tsx b/src/pages/ReportsCartesPage.tsx index d6157ca..5495704 100644 --- a/src/pages/ReportsCartesPage.tsx +++ b/src/pages/ReportsCartesPage.tsx @@ -1,7 +1,9 @@ +// The Cartes report is intentionally a "month X vs X-1 vs X-12" snapshot, so +// only a reference-month picker is surfaced here — a generic date-range +// selector has no meaning on this sub-report (see issue #101). import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; -import PeriodSelector from "../components/dashboard/PeriodSelector"; import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker"; import KpiCard from "../components/reports/cards/KpiCard"; import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart"; @@ -12,19 +14,7 @@ import { useCartes } from "../hooks/useCartes"; export default function ReportsCartesPage() { const { t } = useTranslation(); - const { - year, - month, - snapshot, - isLoading, - error, - setReferencePeriod, - period, - setPeriod, - from, - to, - setCustomDates, - } = useCartes(); + const { year, month, snapshot, isLoading, error, setReferencePeriod } = useCartes(); const preserveSearch = typeof window !== "undefined" ? window.location.search : ""; @@ -41,14 +31,7 @@ export default function ReportsCartesPage() {

{t("reports.hub.cartes")}

-
- +
@@ -95,6 +78,7 @@ export default function ReportsCartesPage() { kpi={snapshot.kpis.savingsRate} format="percent" deltaIsBadWhenUp={false} + tooltip={t("reports.cartes.savingsRateTooltip")} /> diff --git a/src/services/reportService.cartes.test.ts b/src/services/reportService.cartes.test.ts index 4e459fb..3d31032 100644 --- a/src/services/reportService.cartes.test.ts +++ b/src/services/reportService.cartes.test.ts @@ -59,7 +59,8 @@ describe("getCartesSnapshot", () => { expect(snapshot.kpis.income.current).toBe(0); expect(snapshot.kpis.expenses.current).toBe(0); expect(snapshot.kpis.net.current).toBe(0); - expect(snapshot.kpis.savingsRate.current).toBe(0); + // Savings rate is null (renders as "—") when income is zero. + expect(snapshot.kpis.savingsRate.current).toBeNull(); expect(snapshot.kpis.income.sparkline).toHaveLength(13); expect(snapshot.flow12Months).toHaveLength(12); expect(snapshot.topMoversUp).toHaveLength(0); @@ -118,7 +119,7 @@ describe("getCartesSnapshot", () => { expect(snapshot.kpis.income.deltaYoYAbs).toBeNull(); }); - it("savings rate stays at 0 when income is zero (no division by zero)", async () => { + it("savings rate is null when income is zero (no division by zero, renders as — in UI)", async () => { routeSelect([ { match: "strftime('%Y-%m', date)", @@ -129,7 +130,7 @@ describe("getCartesSnapshot", () => { ]); const snapshot = await getCartesSnapshot(2026, 3); - expect(snapshot.kpis.savingsRate.current).toBe(0); + expect(snapshot.kpis.savingsRate.current).toBeNull(); expect(snapshot.kpis.income.current).toBe(0); expect(snapshot.kpis.expenses.current).toBe(500); expect(snapshot.kpis.net.current).toBe(-500); diff --git a/src/services/reportService.ts b/src/services/reportService.ts index efe9632..5ca02a7 100644 --- a/src/services/reportService.ts +++ b/src/services/reportService.ts @@ -605,10 +605,10 @@ function monthKey(year: number, month: number): string { } function extractDelta( - current: number, + current: number | null, previous: number | null, ): { abs: number | null; pct: number | null } { - if (previous === null) return { abs: null, pct: null }; + if (current === null || previous === null) return { abs: null, pct: null }; const abs = current - previous; const pct = previous === 0 ? null : (abs / previous) * 100; return { abs, pct }; @@ -616,7 +616,7 @@ function extractDelta( function buildKpi( sparkline: CartesSparklinePoint[], - current: number, + current: number | null, previousMonth: number | null, previousYear: number | null, ): CartesKpi { @@ -770,7 +770,9 @@ export async function getCartesSnapshot( const refIncome = refRow?.income ?? 0; const refExpenses = refRow?.expenses ?? 0; const refNet = refIncome - refExpenses; - const refSavings = refIncome > 0 ? (refNet / refIncome) * 100 : 0; + // Savings rate is undefined when income is zero — expose as null rather than + // rendering a misleading "0 %" in the UI. + const refSavings = refIncome > 0 ? (refNet / refIncome) * 100 : null; const momRow = flowByMonth.get(momKey); const momIncome = momRow ? momRow.income : null; @@ -852,7 +854,9 @@ export async function getCartesSnapshot( const historicalAverage = historicalYears.length ? historicalYears.reduce((sum, r) => sum + r.amount, 0) / historicalYears.length : null; - const referenceAmount = expensesKpi.current; + // `refExpenses` is always a concrete number (never null) — unlike + // `savingsKpi.current` which is nullable when income is zero. + const referenceAmount = refExpenses; const deviationPct = historicalAverage !== null && historicalAverage > 0 ? ((referenceAmount - historicalAverage) / historicalAverage) * 100 diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index f315f43..4602133 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -368,7 +368,9 @@ export interface CartesSparklinePoint { } export interface CartesKpi { - current: number; + // `current` is nullable for ratio-style KPIs (e.g. savings rate) when the + // denominator is zero and the value is genuinely undefined rather than 0. + current: number | null; previousMonth: number | null; previousYear: number | null; deltaMoMAbs: number | null; -- 2.45.2