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:
commit
49a4ef2171
10 changed files with 55 additions and 51 deletions
|
|
@ -2,6 +2,13 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [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
|
## [0.8.2] - 2026-04-17
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,13 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.8.2] - 2026-04-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
import KpiSparkline from "./KpiSparkline";
|
import KpiSparkline from "./KpiSparkline";
|
||||||
import type { CartesKpi, CartesKpiId } from "../../../shared/types";
|
import type { CartesKpi, CartesKpiId } from "../../../shared/types";
|
||||||
|
|
||||||
|
|
@ -9,6 +10,8 @@ export interface KpiCardProps {
|
||||||
format: "currency" | "percent";
|
format: "currency" | "percent";
|
||||||
/** When true, positive deltas are rendered in red (e.g. rising expenses). */
|
/** When true, positive deltas are rendered in red (e.g. rising expenses). */
|
||||||
deltaIsBadWhenUp?: boolean;
|
deltaIsBadWhenUp?: boolean;
|
||||||
|
/** Optional help text shown on hover of a (?) icon next to the title. */
|
||||||
|
tooltip?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(amount: number, language: string): string {
|
function formatCurrency(amount: number, language: string): string {
|
||||||
|
|
@ -102,6 +105,7 @@ export default function KpiCard({
|
||||||
kpi,
|
kpi,
|
||||||
format,
|
format,
|
||||||
deltaIsBadWhenUp = false,
|
deltaIsBadWhenUp = false,
|
||||||
|
tooltip,
|
||||||
}: KpiCardProps) {
|
}: KpiCardProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const language = i18n.language;
|
const language = i18n.language;
|
||||||
|
|
@ -111,9 +115,20 @@ export default function KpiCard({
|
||||||
data-kpi={id}
|
data-kpi={id}
|
||||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3"
|
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)]">
|
<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>
|
</div>
|
||||||
<KpiSparkline data={kpi.sparkline} />
|
<KpiSparkline data={kpi.sparkline} />
|
||||||
<div className="flex items-start justify-between gap-2 pt-1 border-t border-[var(--border)]">
|
<div className="flex items-start justify-between gap-2 pt-1 border-t border-[var(--border)]">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
import type { CartesSnapshot } from "../shared/types";
|
import type { CartesSnapshot } from "../shared/types";
|
||||||
import { getCartesSnapshot } from "../services/reportService";
|
import { getCartesSnapshot } from "../services/reportService";
|
||||||
import { useReportsPeriod } from "./useReportsPeriod";
|
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
year: number;
|
year: number;
|
||||||
|
|
@ -55,7 +54,6 @@ function reducer(state: State, action: Action): State {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCartes() {
|
export function useCartes() {
|
||||||
const { from, to, period, setPeriod, setCustomDates } = useReportsPeriod();
|
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
|
|
@ -76,17 +74,6 @@ export function useCartes() {
|
||||||
fetch(state.year, state.month);
|
fetch(state.year, state.month);
|
||||||
}, [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) => {
|
const setReferencePeriod = useCallback((year: number, month: number) => {
|
||||||
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
|
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -94,10 +81,5 @@ export function useCartes() {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
setReferencePeriod,
|
setReferencePeriod,
|
||||||
from,
|
|
||||||
to,
|
|
||||||
period,
|
|
||||||
setPeriod,
|
|
||||||
setCustomDates,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,7 @@
|
||||||
"expenses": "Expenses",
|
"expenses": "Expenses",
|
||||||
"net": "Net balance",
|
"net": "Net balance",
|
||||||
"savingsRate": "Savings rate",
|
"savingsRate": "Savings rate",
|
||||||
|
"savingsRateTooltip": "Formula: (income − expenses) ÷ income × 100, computed on the reference month.",
|
||||||
"deltaMoMLabel": "vs last month",
|
"deltaMoMLabel": "vs last month",
|
||||||
"deltaYoYLabel": "vs last year",
|
"deltaYoYLabel": "vs last year",
|
||||||
"flowChartTitle": "Income vs expenses — last 12 months",
|
"flowChartTitle": "Income vs expenses — last 12 months",
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,7 @@
|
||||||
"expenses": "Dépenses",
|
"expenses": "Dépenses",
|
||||||
"net": "Solde net",
|
"net": "Solde net",
|
||||||
"savingsRate": "Taux d'épargne",
|
"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",
|
"deltaMoMLabel": "vs mois précédent",
|
||||||
"deltaYoYLabel": "vs l'an dernier",
|
"deltaYoYLabel": "vs l'an dernier",
|
||||||
"flowChartTitle": "Revenus vs dépenses — 12 derniers mois",
|
"flowChartTitle": "Revenus vs dépenses — 12 derniers mois",
|
||||||
|
|
|
||||||
|
|
@ -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 { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
|
||||||
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
||||||
import KpiCard from "../components/reports/cards/KpiCard";
|
import KpiCard from "../components/reports/cards/KpiCard";
|
||||||
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
|
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
|
||||||
|
|
@ -12,19 +14,7 @@ import { useCartes } from "../hooks/useCartes";
|
||||||
|
|
||||||
export default function ReportsCartesPage() {
|
export default function ReportsCartesPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const { year, month, snapshot, isLoading, error, setReferencePeriod } = useCartes();
|
||||||
year,
|
|
||||||
month,
|
|
||||||
snapshot,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
setReferencePeriod,
|
|
||||||
period,
|
|
||||||
setPeriod,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
setCustomDates,
|
|
||||||
} = useCartes();
|
|
||||||
|
|
||||||
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
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>
|
<h1 className="text-2xl font-bold">{t("reports.hub.cartes")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-end gap-3 mb-6 flex-wrap">
|
||||||
<PeriodSelector
|
|
||||||
value={period}
|
|
||||||
onChange={setPeriod}
|
|
||||||
customDateFrom={from}
|
|
||||||
customDateTo={to}
|
|
||||||
onCustomDateChange={setCustomDates}
|
|
||||||
/>
|
|
||||||
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
|
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -95,6 +78,7 @@ export default function ReportsCartesPage() {
|
||||||
kpi={snapshot.kpis.savingsRate}
|
kpi={snapshot.kpis.savingsRate}
|
||||||
format="percent"
|
format="percent"
|
||||||
deltaIsBadWhenUp={false}
|
deltaIsBadWhenUp={false}
|
||||||
|
tooltip={t("reports.cartes.savingsRateTooltip")}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ describe("getCartesSnapshot", () => {
|
||||||
expect(snapshot.kpis.income.current).toBe(0);
|
expect(snapshot.kpis.income.current).toBe(0);
|
||||||
expect(snapshot.kpis.expenses.current).toBe(0);
|
expect(snapshot.kpis.expenses.current).toBe(0);
|
||||||
expect(snapshot.kpis.net.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.kpis.income.sparkline).toHaveLength(13);
|
||||||
expect(snapshot.flow12Months).toHaveLength(12);
|
expect(snapshot.flow12Months).toHaveLength(12);
|
||||||
expect(snapshot.topMoversUp).toHaveLength(0);
|
expect(snapshot.topMoversUp).toHaveLength(0);
|
||||||
|
|
@ -118,7 +119,7 @@ describe("getCartesSnapshot", () => {
|
||||||
expect(snapshot.kpis.income.deltaYoYAbs).toBeNull();
|
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([
|
routeSelect([
|
||||||
{
|
{
|
||||||
match: "strftime('%Y-%m', date)",
|
match: "strftime('%Y-%m', date)",
|
||||||
|
|
@ -129,7 +130,7 @@ describe("getCartesSnapshot", () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const snapshot = await getCartesSnapshot(2026, 3);
|
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.income.current).toBe(0);
|
||||||
expect(snapshot.kpis.expenses.current).toBe(500);
|
expect(snapshot.kpis.expenses.current).toBe(500);
|
||||||
expect(snapshot.kpis.net.current).toBe(-500);
|
expect(snapshot.kpis.net.current).toBe(-500);
|
||||||
|
|
|
||||||
|
|
@ -605,10 +605,10 @@ function monthKey(year: number, month: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractDelta(
|
function extractDelta(
|
||||||
current: number,
|
current: number | null,
|
||||||
previous: number | null,
|
previous: number | null,
|
||||||
): { abs: number | null; pct: 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 abs = current - previous;
|
||||||
const pct = previous === 0 ? null : (abs / previous) * 100;
|
const pct = previous === 0 ? null : (abs / previous) * 100;
|
||||||
return { abs, pct };
|
return { abs, pct };
|
||||||
|
|
@ -616,7 +616,7 @@ function extractDelta(
|
||||||
|
|
||||||
function buildKpi(
|
function buildKpi(
|
||||||
sparkline: CartesSparklinePoint[],
|
sparkline: CartesSparklinePoint[],
|
||||||
current: number,
|
current: number | null,
|
||||||
previousMonth: number | null,
|
previousMonth: number | null,
|
||||||
previousYear: number | null,
|
previousYear: number | null,
|
||||||
): CartesKpi {
|
): CartesKpi {
|
||||||
|
|
@ -770,7 +770,9 @@ export async function getCartesSnapshot(
|
||||||
const refIncome = refRow?.income ?? 0;
|
const refIncome = refRow?.income ?? 0;
|
||||||
const refExpenses = refRow?.expenses ?? 0;
|
const refExpenses = refRow?.expenses ?? 0;
|
||||||
const refNet = refIncome - refExpenses;
|
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 momRow = flowByMonth.get(momKey);
|
||||||
const momIncome = momRow ? momRow.income : null;
|
const momIncome = momRow ? momRow.income : null;
|
||||||
|
|
@ -852,7 +854,9 @@ export async function getCartesSnapshot(
|
||||||
const historicalAverage = historicalYears.length
|
const historicalAverage = historicalYears.length
|
||||||
? historicalYears.reduce((sum, r) => sum + r.amount, 0) / historicalYears.length
|
? historicalYears.reduce((sum, r) => sum + r.amount, 0) / historicalYears.length
|
||||||
: null;
|
: 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 =
|
const deviationPct =
|
||||||
historicalAverage !== null && historicalAverage > 0
|
historicalAverage !== null && historicalAverage > 0
|
||||||
? ((referenceAmount - historicalAverage) / historicalAverage) * 100
|
? ((referenceAmount - historicalAverage) / historicalAverage) * 100
|
||||||
|
|
|
||||||
|
|
@ -368,7 +368,9 @@ export interface CartesSparklinePoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CartesKpi {
|
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;
|
previousMonth: number | null;
|
||||||
previousYear: number | null;
|
previousYear: number | null;
|
||||||
deltaMoMAbs: number | null;
|
deltaMoMAbs: number | null;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue