Merge pull request 'fix(reports/cartes): remove broken period selector + add savings-rate tooltip' (#107) from issue-101-cartes-period-savings-tooltip into main

This commit is contained in:
maximus 2026-04-19 01:04:38 +00:00
commit 49a4ef2171
10 changed files with 55 additions and 51 deletions

View file

@ -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é

View file

@ -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

View file

@ -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"
>
<div className="text-sm text-[var(--muted-foreground)]">{title}</div>
<div className="text-sm text-[var(--muted-foreground)] flex items-center gap-1">
<span>{title}</span>
{tooltip && (
<span
title={tooltip}
aria-label={tooltip}
className="inline-flex items-center text-[var(--muted-foreground)] cursor-help"
>
<HelpCircle size={12} aria-hidden="true" />
</span>
)}
</div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{formatValue(kpi.current, format, language)}
{kpi.current === null ? "—" : formatValue(kpi.current, format, language)}
</div>
<KpiSparkline data={kpi.sparkline} />
<div className="flex items-start justify-between gap-2 pt-1 border-t border-[var(--border)]">

View file

@ -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,
};
}

View file

@ -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",

View file

@ -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",

View file

@ -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() {
<h1 className="text-2xl font-bold">{t("reports.hub.cartes")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<div className="flex flex-col sm:flex-row sm:items-center justify-end gap-3 mb-6 flex-wrap">
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
</div>
@ -95,6 +78,7 @@ export default function ReportsCartesPage() {
kpi={snapshot.kpis.savingsRate}
format="percent"
deltaIsBadWhenUp={false}
tooltip={t("reports.cartes.savingsRateTooltip")}
/>
</section>

View file

@ -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);

View file

@ -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

View file

@ -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;