fix(reports/cartes): remove broken period selector + add savings-rate tooltip #107
10 changed files with 55 additions and 51 deletions
|
|
@ -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é
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue