Compare commits
No commits in common. "49a4ef21716c0e58ccac9a11293c02b40acccda2" and "4416457c221d4db0888880cef636123c2f948d92" have entirely different histories.
49a4ef2171
...
4416457c22
10 changed files with 51 additions and 55 deletions
|
|
@ -2,13 +2,6 @@
|
||||||
|
|
||||||
## [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,13 +2,6 @@
|
||||||
|
|
||||||
## [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,5 +1,4 @@
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -10,8 +9,6 @@ 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 {
|
||||||
|
|
@ -105,7 +102,6 @@ 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;
|
||||||
|
|
@ -115,20 +111,9 @@ 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)] flex items-center gap-1">
|
<div className="text-sm text-[var(--muted-foreground)]">{title}</div>
|
||||||
<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)]">
|
||||||
{kpi.current === null ? "—" : formatValue(kpi.current, format, language)}
|
{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,6 +1,7 @@
|
||||||
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;
|
||||||
|
|
@ -54,6 +55,7 @@ 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);
|
||||||
|
|
||||||
|
|
@ -74,6 +76,17 @@ 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 } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -81,5 +94,10 @@ export function useCartes() {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
setReferencePeriod,
|
setReferencePeriod,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
period,
|
||||||
|
setPeriod,
|
||||||
|
setCustomDates,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,6 @@
|
||||||
"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,7 +418,6 @@
|
||||||
"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,9 +1,7 @@
|
||||||
// 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";
|
||||||
|
|
@ -14,7 +12,19 @@ import { useCartes } from "../hooks/useCartes";
|
||||||
|
|
||||||
export default function ReportsCartesPage() {
|
export default function ReportsCartesPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { year, month, snapshot, isLoading, error, setReferencePeriod } = useCartes();
|
const {
|
||||||
|
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 : "";
|
||||||
|
|
||||||
|
|
@ -31,7 +41,14 @@ 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-end gap-3 mb-6 flex-wrap">
|
<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}
|
||||||
|
/>
|
||||||
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
|
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -78,7 +95,6 @@ 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,8 +59,7 @@ 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);
|
||||||
// Savings rate is null (renders as "—") when income is zero.
|
expect(snapshot.kpis.savingsRate.current).toBe(0);
|
||||||
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);
|
||||||
|
|
@ -119,7 +118,7 @@ describe("getCartesSnapshot", () => {
|
||||||
expect(snapshot.kpis.income.deltaYoYAbs).toBeNull();
|
expect(snapshot.kpis.income.deltaYoYAbs).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("savings rate is null when income is zero (no division by zero, renders as — in UI)", async () => {
|
it("savings rate stays at 0 when income is zero (no division by zero)", async () => {
|
||||||
routeSelect([
|
routeSelect([
|
||||||
{
|
{
|
||||||
match: "strftime('%Y-%m', date)",
|
match: "strftime('%Y-%m', date)",
|
||||||
|
|
@ -130,7 +129,7 @@ describe("getCartesSnapshot", () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const snapshot = await getCartesSnapshot(2026, 3);
|
const snapshot = await getCartesSnapshot(2026, 3);
|
||||||
expect(snapshot.kpis.savingsRate.current).toBeNull();
|
expect(snapshot.kpis.savingsRate.current).toBe(0);
|
||||||
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 | null,
|
current: number,
|
||||||
previous: number | null,
|
previous: number | null,
|
||||||
): { abs: number | null; pct: number | null } {
|
): { abs: number | null; pct: number | null } {
|
||||||
if (current === null || previous === null) return { abs: null, pct: null };
|
if (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 | null,
|
current: number,
|
||||||
previousMonth: number | null,
|
previousMonth: number | null,
|
||||||
previousYear: number | null,
|
previousYear: number | null,
|
||||||
): CartesKpi {
|
): CartesKpi {
|
||||||
|
|
@ -770,9 +770,7 @@ 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;
|
||||||
// Savings rate is undefined when income is zero — expose as null rather than
|
const refSavings = refIncome > 0 ? (refNet / refIncome) * 100 : 0;
|
||||||
// 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;
|
||||||
|
|
@ -854,9 +852,7 @@ 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;
|
||||||
// `refExpenses` is always a concrete number (never null) — unlike
|
const referenceAmount = expensesKpi.current;
|
||||||
// `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,9 +368,7 @@ export interface CartesSparklinePoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CartesKpi {
|
export interface CartesKpi {
|
||||||
// `current` is nullable for ratio-style KPIs (e.g. savings rate) when the
|
current: number;
|
||||||
// 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