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;