feat(reports/cartes): Mensuel/YTD toggle on KPI cards + user guide section (#102) #114

Merged
maximus merged 1 commit from issue-102-cartes-ytd-toggle-docs into main 2026-04-19 13:55:36 +00:00
12 changed files with 479 additions and 33 deletions

View file

@ -3,6 +3,8 @@
## [Non publié] ## [Non publié]
### Ajouté ### Ajouté
- **Rapport Cartes — Toggle Mensuel / Cumul annuel (YTD)** (`/reports/cartes`) : nouveau toggle segmenté à côté du sélecteur de mois de référence bascule les quatre cartes KPI (revenus, dépenses, solde net, taux d'épargne) entre la valeur du mois de référence (défaut inchangé) et une vue cumul annuel. En mode YTD, la valeur courante somme janvier → mois de référence, le delta MoM la compare à la fenêtre Jan → (mois 1) de la même année (null en janvier), le delta YoY la compare à Jan → mois de référence de l'année précédente, et le taux d'épargne utilise les revenus/dépenses YTD. La sparkline 13 mois, les top mouvements, la saisonnalité et l'adhésion budgétaire restent mensuels peu importe le toggle. L'info-bulle du taux d'épargne reflète maintenant le mode actif. Choix persisté dans `localStorage` (`reports-cartes-period-mode`) (#102)
- **Guide utilisateur — Section Cartes** : nouvelle section dédiée documentant les formules des quatre KPI, le toggle Mensuel/YTD, la sparkline, les top mouvements, les règles de saisonnalité et d'adhésion budgétaire, ainsi que le cas limite du taux d'épargne (« — » quand les revenus sont à zéro) (#102)
- **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) - **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)
- **Rapport Tendances — Par catégorie** (`/reports/trends`) : nouveau toggle segmenté pour basculer le graphique d'évolution par catégorie entre les barres empilées (par défaut, inchangé) et une vue surface empilée Recharts (`<AreaChart stackId="1">`) qui montre la composition totale dans le temps. Les deux modes partagent la même palette de catégories et les mêmes patterns SVG en niveaux de gris. Le type choisi est mémorisé dans `localStorage` (`reports-trends-category-charttype`) (#105) - **Rapport Tendances — Par catégorie** (`/reports/trends`) : nouveau toggle segmenté pour basculer le graphique d'évolution par catégorie entre les barres empilées (par défaut, inchangé) et une vue surface empilée Recharts (`<AreaChart stackId="1">`) qui montre la composition totale dans le temps. Les deux modes partagent la même palette de catégories et les mêmes patterns SVG en niveaux de gris. Le type choisi est mémorisé dans `localStorage` (`reports-trends-category-charttype`) (#105)

View file

@ -3,6 +3,8 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Cartes report — Monthly / YTD toggle** (`/reports/cartes`): new segmented toggle next to the reference-month picker flips the four KPI cards (income, expenses, net balance, savings rate) between the reference-month value (unchanged default) and a Year-to-Date cumulative view. In YTD mode, the "current" value sums January → reference month, MoM delta compares it to the same-year Jan → (refMonth 1) window (null for January), YoY delta compares it to Jan → refMonth of the previous year, and the savings rate uses the YTD income/expenses. The 13-month sparkline, top movers, seasonality and budget adherence cards remain monthly regardless of the toggle. The savings-rate tooltip now reflects the active mode. Choice persisted in `localStorage` (`reports-cartes-period-mode`) (#102)
- **User guide — Cartes section**: new dedicated section documenting the four KPI formulas, the Monthly/YTD toggle, the sparkline, top movers, seasonality and budget adherence rules, along with the savings-rate edge case ("—" when income is zero) (#102)
- **Cartes report**: help tooltip on the savings-rate KPI explaining the formula — `(income expenses) ÷ income × 100`, computed on the reference month (#101) - **Cartes report**: help tooltip on the savings-rate KPI explaining the formula — `(income expenses) ÷ income × 100`, computed on the reference month (#101)
- **Trends report — by category** (`/reports/trends`): new segmented toggle to switch the category-evolution chart between stacked bars (default, unchanged) and a Recharts stacked-area view (`<AreaChart stackId="1">`) that shows total composition over time. Both modes share the same category palette and SVG grayscale patterns. The chosen type is persisted in `localStorage` (`reports-trends-category-charttype`) (#105) - **Trends report — by category** (`/reports/trends`): new segmented toggle to switch the category-evolution chart between stacked bars (default, unchanged) and a Recharts stacked-area view (`<AreaChart stackId="1">`) that shows total composition over time. Both modes share the same category palette and SVG grayscale patterns. The chosen type is persisted in `localStorage` (`reports-trends-category-charttype`) (#105)

View file

@ -260,6 +260,62 @@ Le sélecteur de période en haut à droite est **partagé** entre toutes les pa
- Tableau triable des **top mouvements** (catégories avec la plus forte variation vs mois précédent), ou graphique en barres divergentes centré sur zéro (toggle graphique/tableau) - Tableau triable des **top mouvements** (catégories avec la plus forte variation vs mois précédent), ou graphique en barres divergentes centré sur zéro (toggle graphique/tableau)
- Liste des **plus grosses transactions récentes** avec fenêtre configurable 30 / 60 / 90 jours - Liste des **plus grosses transactions récentes** avec fenêtre configurable 30 / 60 / 90 jours
### Rapport Cartes (`/reports/cartes`)
Un tableau de bord condensé qui résume un mois de référence en quatre KPIs, une sparkline 13 mois, les plus gros mouvements de catégories, l'adhésion budgétaire et la saisonnalité. Deux contrôles en en-tête : le **sélecteur de mois de référence** et le **toggle Mensuel / Cumul annuel (YTD)**.
#### Les 4 cartes KPI et leurs formules
- **Revenus** = somme des transactions positives
- **Dépenses** = valeur absolue de la somme des transactions négatives
- **Solde net** = `revenus dépenses`
- **Taux d'épargne** = `(revenus dépenses) ÷ revenus × 100`. Affiché comme « — » quand les revenus sont à zéro (évite la division par zéro et le « 0 % » trompeur)
Chaque carte affiche la valeur courante, deux deltas (vs mois précédent, vs l'an dernier) et une sparkline 13 mois. Le delta est vert si la variation est favorable au KPI (hausse pour Revenus, baisse pour Dépenses), rouge dans le cas contraire.
#### Toggle Mensuel / Cumul annuel (YTD)
Placé à côté du sélecteur de mois, il bascule les 4 cartes entre deux vues :
- **Mensuel** (par défaut) — la valeur courante est celle du mois de référence. Les deltas comparent ce mois à son précédent (MoM) et au même mois de l'an dernier (YoY)
- **Cumul annuel (YTD)** — la valeur courante est la somme depuis le 1er janvier de l'année de référence jusqu'à la fin du mois de référence inclus. Les deltas deviennent :
- **MoM YTD** = cumul actuel (Jan→mois de réf) vs cumul précédent (Jan→mois de réf1) de la même année. **Null en janvier** (pas de fenêtre antérieure dans la même année) — affiché comme « — »
- **YoY YTD** = cumul actuel vs le même cumul (Jan→mois de réf) de l'année précédente
- **Taux d'épargne YTD** = `(revenus YTD dépenses YTD) ÷ revenus YTD × 100`, null si les revenus YTD sont à zéro
Le choix du mode est **persisté** (clé locale `reports-cartes-period-mode`) et restauré au redémarrage. La sparkline 13 mois, elle, reste toujours mensuelle — elle donne le contexte temporel indépendamment du toggle.
#### Sparkline 13 mois
Chaque KPI inclut une mini-courbe des 13 derniers mois (mois de référence + 12 précédents). Les mois sans données comptent comme zéro pour que la courbe reste continue. Non affectée par le toggle Mensuel / YTD.
#### Top mouvements (MoM)
Deux listes : les 5 catégories avec la plus forte **hausse** de dépenses vs mois précédent et les 5 avec la plus forte **baisse**. Triées par variation absolue en dollars, avec la variation en pourcentage à droite. Toujours mensuelles, indépendamment du toggle.
#### Saisonnalité
Compare les dépenses du mois de référence à la **moyenne du même mois calendaire sur les 2 années précédentes**.
- Écart en pourcentage : `(dépenses du mois moyenne historique) ÷ moyenne historique × 100`
- Affiché comme « pas assez d'historique pour ce mois » s'il n'y a aucune donnée historique ou si la moyenne est à zéro
Toujours basée sur le mois de référence, indépendamment du toggle Mensuel / YTD.
#### Adhésion budgétaire
Score `N/M` des catégories dont les dépenses restent sous le budget mensuel (comparaison en valeur absolue pour gérer correctement les budgets de dépenses stockés signés négatifs).
- Seules les catégories de type **dépense** avec un budget non nul sont comptées, feuilles uniquement (les catégories parentes sont ignorées pour éviter le double comptage)
- Suivi des **3 pires dépassements** avec le montant et le pourcentage de dépassement
Toujours mensuelle, indépendamment du toggle.
#### À savoir
- Le sélecteur de période générique (utilisé par les autres rapports) est volontairement absent ici : la Cartes pivote autour d'un mois unique avec comparaisons automatiques, donc seul le sélecteur de mois de référence est exposé
- Le taux d'épargne affiche « — » (pas « 0 % ») quand les revenus sont à zéro, pour distinguer « pas de revenus » de « revenus = dépenses »
### Rapport Tendances (`/reports/trends`) ### Rapport Tendances (`/reports/trends`)
- **Flux global** : revenus vs dépenses vs solde net sur la période, en graphique d'aires ou tableau - **Flux global** : revenus vs dépenses vs solde net sur la période, en graphique d'aires ou tableau

View file

@ -0,0 +1,60 @@
import { useTranslation } from "react-i18next";
import { Calendar as MonthIcon, CalendarRange as YtdIcon } from "lucide-react";
import type { CartesKpiPeriodMode } from "../../../shared/types";
export interface CartesPeriodModeToggleProps {
value: CartesKpiPeriodMode;
onChange: (value: CartesKpiPeriodMode) => void;
}
/**
* Segmented toggle that flips the four Cartes KPI cards between the reference
* month (current default) and a Year-to-Date view. The 13-month sparkline and
* the Seasonality / Top Movers / Budget Adherence widgets are unaffected they
* always remain monthly by design. Persistence is owned by the parent hook
* (`useCartes`); this component is a controlled input only.
*/
export default function CartesPeriodModeToggle({
value,
onChange,
}: CartesPeriodModeToggleProps) {
const { t } = useTranslation();
const options: { type: CartesKpiPeriodMode; icon: React.ReactNode; label: string }[] = [
{
type: "month",
icon: <MonthIcon size={14} />,
label: t("reports.cartes.periodMode.month"),
},
{
type: "ytd",
icon: <YtdIcon size={14} />,
label: t("reports.cartes.periodMode.ytd"),
},
];
return (
<div
className="inline-flex gap-1"
role="group"
aria-label={t("reports.cartes.periodMode.aria")}
>
{options.map(({ type, icon, label }) => (
<button
key={type}
type="button"
onClick={() => onChange(type)}
aria-pressed={value === type}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === type
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
</div>
);
}

View file

@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { defaultCartesReferencePeriod } from "./useCartes"; import {
defaultCartesReferencePeriod,
readCartesPeriodMode,
CARTES_PERIOD_MODE_STORAGE_KEY,
} from "./useCartes";
describe("defaultCartesReferencePeriod", () => { describe("defaultCartesReferencePeriod", () => {
it("returns the month before the given date", () => { it("returns the month before the given date", () => {
@ -23,3 +27,51 @@ describe("defaultCartesReferencePeriod", () => {
}); });
}); });
}); });
describe("readCartesPeriodMode (localStorage round-trip)", () => {
const store = new Map<string, string>();
const mockLocalStorage = {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
removeItem: vi.fn((key: string) => {
store.delete(key);
}),
clear: vi.fn(() => store.clear()),
key: vi.fn(),
length: 0,
};
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", mockLocalStorage);
});
it("defaults to 'month' when nothing is persisted", () => {
expect(readCartesPeriodMode()).toBe("month");
});
it("reads back a previously persisted 'month' value (round-trip)", () => {
store.set(CARTES_PERIOD_MODE_STORAGE_KEY, "month");
expect(readCartesPeriodMode()).toBe("month");
});
it("reads back a previously persisted 'ytd' value (round-trip)", () => {
store.set(CARTES_PERIOD_MODE_STORAGE_KEY, "ytd");
expect(readCartesPeriodMode()).toBe("ytd");
});
it("falls back to default for unknown/corrupted values", () => {
store.set(CARTES_PERIOD_MODE_STORAGE_KEY, "bogus");
expect(readCartesPeriodMode()).toBe("month");
});
it("supports a custom fallback", () => {
expect(readCartesPeriodMode(CARTES_PERIOD_MODE_STORAGE_KEY, "ytd")).toBe("ytd");
});
it("uses the expected storage key", () => {
expect(CARTES_PERIOD_MODE_STORAGE_KEY).toBe("reports-cartes-period-mode");
});
});

View file

@ -1,11 +1,14 @@
import { useReducer, useCallback, useEffect, useRef } from "react"; import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CartesSnapshot } from "../shared/types"; import type { CartesSnapshot, CartesKpiPeriodMode } from "../shared/types";
import { getCartesSnapshot } from "../services/reportService"; import { getCartesSnapshot } from "../services/reportService";
import { defaultReferencePeriod } from "../utils/referencePeriod"; import { defaultReferencePeriod } from "../utils/referencePeriod";
export const CARTES_PERIOD_MODE_STORAGE_KEY = "reports-cartes-period-mode";
interface State { interface State {
year: number; year: number;
month: number; month: number;
mode: CartesKpiPeriodMode;
snapshot: CartesSnapshot | null; snapshot: CartesSnapshot | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
@ -13,6 +16,7 @@ interface State {
type Action = type Action =
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } } | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_MODE"; payload: CartesKpiPeriodMode }
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SNAPSHOT"; payload: CartesSnapshot } | { type: "SET_SNAPSHOT"; payload: CartesSnapshot }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
@ -23,10 +27,24 @@ type Action =
*/ */
export const defaultCartesReferencePeriod = defaultReferencePeriod; export const defaultCartesReferencePeriod = defaultReferencePeriod;
/**
* Read the persisted period mode from localStorage. Unrecognised values fall
* back to "month" so a corrupted/legacy key never breaks the page.
*/
export function readCartesPeriodMode(
storageKey: string = CARTES_PERIOD_MODE_STORAGE_KEY,
fallback: CartesKpiPeriodMode = "month",
): CartesKpiPeriodMode {
if (typeof localStorage === "undefined") return fallback;
const saved = localStorage.getItem(storageKey);
return saved === "month" || saved === "ytd" ? saved : fallback;
}
const defaultRef = defaultReferencePeriod(); const defaultRef = defaultReferencePeriod();
const initialState: State = { const initialState: State = {
year: defaultRef.year, year: defaultRef.year,
month: defaultRef.month, month: defaultRef.month,
mode: "month",
snapshot: null, snapshot: null,
isLoading: false, isLoading: false,
error: null, error: null,
@ -36,6 +54,8 @@ function reducer(state: State, action: Action): State {
switch (action.type) { switch (action.type) {
case "SET_REFERENCE_PERIOD": case "SET_REFERENCE_PERIOD":
return { ...state, year: action.payload.year, month: action.payload.month }; return { ...state, year: action.payload.year, month: action.payload.month };
case "SET_MODE":
return { ...state, mode: action.payload };
case "SET_LOADING": case "SET_LOADING":
return { ...state, isLoading: action.payload }; return { ...state, isLoading: action.payload };
case "SET_SNAPSHOT": case "SET_SNAPSHOT":
@ -48,14 +68,17 @@ function reducer(state: State, action: Action): State {
} }
export function useCartes() { export function useCartes() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState, (init) => ({
...init,
mode: readCartesPeriodMode(),
}));
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetch = useCallback(async (year: number, month: number) => { const fetch = useCallback(async (year: number, month: number, mode: CartesKpiPeriodMode) => {
const id = ++fetchIdRef.current; const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
try { try {
const snapshot = await getCartesSnapshot(year, month); const snapshot = await getCartesSnapshot(year, month, mode);
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_SNAPSHOT", payload: snapshot }); dispatch({ type: "SET_SNAPSHOT", payload: snapshot });
} catch (e) { } catch (e) {
@ -65,15 +88,23 @@ export function useCartes() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetch(state.year, state.month); fetch(state.year, state.month, state.mode);
}, [fetch, state.year, state.month]); }, [fetch, state.year, state.month, state.mode]);
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 } });
}, []); }, []);
const setMode = useCallback((mode: CartesKpiPeriodMode) => {
if (typeof localStorage !== "undefined") {
localStorage.setItem(CARTES_PERIOD_MODE_STORAGE_KEY, mode);
}
dispatch({ type: "SET_MODE", payload: mode });
}, []);
return { return {
...state, ...state,
setReferencePeriod, setReferencePeriod,
setMode,
}; };
} }

View file

@ -424,7 +424,15 @@
"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.", "savingsRateTooltip": {
"month": "Formula: (income expenses) ÷ income × 100, computed on the reference month.",
"ytd": "Formula: (YTD income YTD expenses) ÷ YTD income × 100, cumulative from January 1st."
},
"periodMode": {
"month": "Monthly",
"ytd": "YTD",
"aria": "Period"
},
"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",
@ -813,6 +821,7 @@
"features": [ "features": [
"Hub: compact highlights panel + 4 navigation cards", "Hub: compact highlights panel + 4 navigation cards",
"Highlights: current month and YTD balances with sparklines, top movers vs. last month, top recent transactions (30/60/90 day window)", "Highlights: current month and YTD balances with sparklines, top movers vs. last month, top recent transactions (30/60/90 day window)",
"Cartes: single-month KPI dashboard (income, expenses, net, savings rate) with a Monthly/YTD toggle, 13-month sparklines, top movers, budget adherence, and seasonality",
"Trends: global flow (income vs. expenses) and by-category evolution with a chart/table toggle", "Trends: global flow (income vs. expenses) and by-category evolution with a chart/table toggle",
"Compare: Month vs. Previous Month, Year vs. Previous Year, and Actual vs. Budget", "Compare: Month vs. Previous Month, Year vs. Previous Year, and Actual vs. Budget",
"Category Zoom: single-category drill-down with donut, monthly evolution, and filterable transaction table; auto-rollup of subcategories", "Category Zoom: single-category drill-down with donut, monthly evolution, and filterable transaction table; auto-rollup of subcategories",
@ -824,6 +833,7 @@
"Open /reports to see the highlights panel and four navigation cards", "Open /reports to see the highlights panel and four navigation cards",
"Adjust the period with the period selector — it is mirrored in the URL and shared with every sub-report", "Adjust the period with the period selector — it is mirrored in the URL and shared with every sub-report",
"Click a card or a sub-route link to open the corresponding report", "Click a card or a sub-route link to open the corresponding report",
"On /reports/cartes, pick a reference month then toggle Monthly vs. YTD to flip the 4 KPI cards between the month and the year-to-date cumulative view — the choice is persisted",
"Toggle chart vs. table on any sub-report — your choice is remembered", "Toggle chart vs. table on any sub-report — your choice is remembered",
"Right-click any transaction row in the category zoom, highlights list, or transactions page to add a keyword", "Right-click any transaction row in the category zoom, highlights list, or transactions page to add a keyword",
"In the keyword dialog, review the preview of matching transactions and confirm to apply" "In the keyword dialog, review the preview of matching transactions and confirm to apply"
@ -831,7 +841,9 @@
"tips": [ "tips": [
"Copy the URL to share a specific period + report state", "Copy the URL to share a specific period + report state",
"Keywords must be 264 characters long", "Keywords must be 264 characters long",
"The Category Zoom is protected against malformed category trees: a parent_id cycle cannot freeze the app" "The Category Zoom is protected against malformed category trees: a parent_id cycle cannot freeze the app",
"On /reports/cartes in YTD mode, the MoM delta for January is always \"—\" (no prior YTD window inside the same year), and the savings rate stays \"—\" when YTD income is zero",
"Seasonality, top movers, and budget adherence stay monthly even when the toggle is set to YTD — only the 4 KPI numbers change"
] ]
}, },
"settings": { "settings": {

View file

@ -424,7 +424,15 @@
"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.", "savingsRateTooltip": {
"month": "Formule : (revenus dépenses) ÷ revenus × 100, calculée sur le mois de référence.",
"ytd": "Formule : (revenus YTD dépenses YTD) ÷ revenus YTD × 100, cumul depuis le 1er janvier."
},
"periodMode": {
"month": "Mensuel",
"ytd": "Cumul annuel",
"aria": "Choix de la période"
},
"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",
@ -813,6 +821,7 @@
"features": [ "features": [
"Hub : panneau de faits saillants condensé + 4 cartes de navigation", "Hub : panneau de faits saillants condensé + 4 cartes de navigation",
"Faits saillants : soldes mois courant et cumul annuel avec sparklines, top mouvements vs mois précédent, plus grosses transactions récentes (fenêtre 30/60/90 jours)", "Faits saillants : soldes mois courant et cumul annuel avec sparklines, top mouvements vs mois précédent, plus grosses transactions récentes (fenêtre 30/60/90 jours)",
"Cartes : tableau de bord KPI d'un mois (revenus, dépenses, solde net, taux d'épargne) avec toggle Mensuel/YTD, sparklines 13 mois, top mouvements, adhésion budgétaire et saisonnalité",
"Tendances : flux global (revenus vs dépenses) et évolution par catégorie avec toggle graphique/tableau", "Tendances : flux global (revenus vs dépenses) et évolution par catégorie avec toggle graphique/tableau",
"Comparables : Mois vs Mois précédent, Année vs Année précédente, et Réel vs Budget", "Comparables : Mois vs Mois précédent, Année vs Année précédente, et Réel vs Budget",
"Zoom catégorie : analyse d'une seule catégorie avec donut, évolution mensuelle et tableau de transactions filtrable ; rollup automatique des sous-catégories", "Zoom catégorie : analyse d'une seule catégorie avec donut, évolution mensuelle et tableau de transactions filtrable ; rollup automatique des sous-catégories",
@ -824,6 +833,7 @@
"Ouvrez /reports pour voir le panneau de faits saillants et les quatre cartes de navigation", "Ouvrez /reports pour voir le panneau de faits saillants et les quatre cartes de navigation",
"Ajustez la période avec le sélecteur — elle est reflétée dans l'URL et partagée avec tous les sous-rapports", "Ajustez la période avec le sélecteur — elle est reflétée dans l'URL et partagée avec tous les sous-rapports",
"Cliquez sur une carte ou un lien pour ouvrir le sous-rapport correspondant", "Cliquez sur une carte ou un lien pour ouvrir le sous-rapport correspondant",
"Sur /reports/cartes, choisissez un mois de référence puis basculez entre Mensuel et Cumul annuel (YTD) pour flipper les 4 cartes KPI — le choix est persisté",
"Basculez graphique/tableau sur n'importe quel sous-rapport — votre choix est mémorisé", "Basculez graphique/tableau sur n'importe quel sous-rapport — votre choix est mémorisé",
"Cliquez droit sur une ligne de transaction dans le zoom catégorie, la liste des faits saillants, ou la page transactions pour ajouter un mot-clé", "Cliquez droit sur une ligne de transaction dans le zoom catégorie, la liste des faits saillants, ou la page transactions pour ajouter un mot-clé",
"Dans le dialog de mot-clé, passez en revue la prévisualisation des transactions qui matchent et confirmez pour appliquer" "Dans le dialog de mot-clé, passez en revue la prévisualisation des transactions qui matchent et confirmez pour appliquer"
@ -831,7 +841,9 @@
"tips": [ "tips": [
"Copiez l'URL pour partager une période et un rapport spécifiques", "Copiez l'URL pour partager une période et un rapport spécifiques",
"Les mots-clés doivent faire entre 2 et 64 caractères", "Les mots-clés doivent faire entre 2 et 64 caractères",
"Le Zoom catégorie est protégé contre les arborescences malformées : un cycle parent_id ne peut pas figer l'app" "Le Zoom catégorie est protégé contre les arborescences malformées : un cycle parent_id ne peut pas figer l'app",
"Sur /reports/cartes en mode YTD, le delta MoM du mois de janvier est toujours « — » (pas de fenêtre YTD antérieure dans la même année), et le taux d'épargne reste « — » quand les revenus YTD sont à zéro",
"La saisonnalité, les top mouvements et l'adhésion budgétaire restent mensuels même quand le toggle est sur YTD — seuls les 4 chiffres KPI changent"
] ]
}, },
"settings": { "settings": {

View file

@ -5,6 +5,7 @@ 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 CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker"; import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
import CartesPeriodModeToggle from "../components/reports/cards/CartesPeriodModeToggle";
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";
import TopMoversList from "../components/reports/cards/TopMoversList"; import TopMoversList from "../components/reports/cards/TopMoversList";
@ -14,10 +15,21 @@ 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, mode, snapshot, isLoading, error, setReferencePeriod, setMode } =
useCartes();
const preserveSearch = typeof window !== "undefined" ? window.location.search : ""; const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
// The savings-rate tooltip copy depends on the active period mode so users
// always see the formula that matches the number currently on screen. The
// i18n key is a nested object (month / ytd) — suffixing keeps the two
// variants side by side in the locale files.
const savingsRateTooltip = t(
mode === "ytd"
? "reports.cartes.savingsRateTooltip.ytd"
: "reports.cartes.savingsRateTooltip.month",
);
return ( return (
<div className={isLoading ? "opacity-60" : ""}> <div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
@ -32,6 +44,7 @@ export default function ReportsCartesPage() {
</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-end gap-3 mb-6 flex-wrap">
<CartesPeriodModeToggle value={mode} onChange={setMode} />
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} /> <CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
</div> </div>
@ -78,7 +91,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")} tooltip={savingsRateTooltip}
/> />
</section> </section>

View file

@ -231,6 +231,133 @@ describe("getCartesSnapshot", () => {
expect(snapshot.topMoversDown[0].categoryName).toBe("D3"); expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
}); });
it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => {
// Reference = 2026-03, YTD = Jan + Feb + Mar of 2026.
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
// Previous year YTD: Jan + Feb + Mar 2025 = income 3000, expenses 1500.
{ month: "2025-01", income: 1000, expenses: 500 },
{ month: "2025-02", income: 1000, expenses: 500 },
{ month: "2025-03", income: 1000, expenses: 500 },
// Current YTD: Jan + Feb + Mar 2026.
{ month: "2026-01", income: 2000, expenses: 800 },
{ month: "2026-02", income: 2500, expenses: 1000 },
{ month: "2026-03", income: 3000, expenses: 1200 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3, "ytd");
// Current = sum Jan+Feb+Mar 2026
expect(snapshot.kpis.income.current).toBe(7500);
expect(snapshot.kpis.expenses.current).toBe(3000);
expect(snapshot.kpis.net.current).toBe(4500);
// Savings rate YTD = 4500 / 7500 = 60%
expect(snapshot.kpis.savingsRate.current).toBe(60);
// MoM in YTD = current YTD vs Jan→Feb 2026 = income 4500, expenses 1800.
expect(snapshot.kpis.income.previousMonth).toBe(4500);
expect(snapshot.kpis.income.deltaMoMAbs).toBe(3000);
expect(snapshot.kpis.expenses.previousMonth).toBe(1800);
// YoY in YTD = Jan→Mar 2026 vs Jan→Mar 2025.
expect(snapshot.kpis.income.previousYear).toBe(3000);
expect(snapshot.kpis.income.deltaYoYAbs).toBe(4500);
expect(snapshot.kpis.expenses.previousYear).toBe(1500);
});
it("YTD savings rate is null when YTD income is zero", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2026-01", income: 0, expenses: 200 },
{ month: "2026-02", income: 0, expenses: 150 },
{ month: "2026-03", income: 0, expenses: 300 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3, "ytd");
expect(snapshot.kpis.income.current).toBe(0);
expect(snapshot.kpis.expenses.current).toBe(650);
expect(snapshot.kpis.savingsRate.current).toBeNull();
});
it("YTD MoM delta is null when reference month is January (no prior YTD window in same year)", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2025-01", income: 500, expenses: 200 },
{ month: "2026-01", income: 1000, expenses: 400 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 1, "ytd");
expect(snapshot.kpis.income.current).toBe(1000);
// MoM previous YTD for January is empty (no prior month in same year).
expect(snapshot.kpis.income.previousMonth).toBeNull();
expect(snapshot.kpis.income.deltaMoMAbs).toBeNull();
expect(snapshot.kpis.income.deltaMoMPct).toBeNull();
// YoY still works — Jan 2025.
expect(snapshot.kpis.income.previousYear).toBe(500);
expect(snapshot.kpis.income.deltaYoYAbs).toBe(500);
});
it("YTD YoY delta uses Jan→refMonth of the previous year", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
// Prev year YTD Jan→Apr 2025
{ month: "2025-01", income: 100, expenses: 50 },
{ month: "2025-02", income: 100, expenses: 50 },
{ month: "2025-03", income: 100, expenses: 50 },
{ month: "2025-04", income: 100, expenses: 50 },
// Prev year outside window — must be ignored.
{ month: "2025-05", income: 999, expenses: 999 },
// Current YTD Jan→Apr 2026
{ month: "2026-01", income: 300, expenses: 100 },
{ month: "2026-02", income: 300, expenses: 100 },
{ month: "2026-03", income: 300, expenses: 100 },
{ month: "2026-04", income: 300, expenses: 100 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 4, "ytd");
expect(snapshot.kpis.income.current).toBe(1200);
expect(snapshot.kpis.income.previousYear).toBe(400); // 4 * 100
expect(snapshot.kpis.expenses.previousYear).toBe(200); // 4 * 50
expect(snapshot.kpis.income.deltaYoYAbs).toBe(800);
});
it("defaults to mode=month and produces monthly KPIs (back-compat)", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2026-01", income: 1000, expenses: 400 },
{ month: "2026-02", income: 1000, expenses: 400 },
{ month: "2026-03", income: 5000, expenses: 2500 },
],
},
]);
// No mode argument — default "month".
const snapshot = await getCartesSnapshot(2026, 3);
// Monthly ref only, not YTD sum.
expect(snapshot.kpis.income.current).toBe(5000);
expect(snapshot.kpis.expenses.current).toBe(2500);
});
it("counts budgeted expense categories even though monthBudget is stored signed-negative (#112)", async () => { it("counts budgeted expense categories even though monthBudget is stored signed-negative (#112)", async () => {
// Regression: before the fix, `r.monthBudget > 0` rejected every expense // Regression: before the fix, `r.monthBudget > 0` rejected every expense
// row because budgetService signs expense budgets as negative. The card // row because budgetService signs expense budgets as negative. The card

View file

@ -22,6 +22,7 @@ import type {
CartesBudgetWorstOverrun, CartesBudgetWorstOverrun,
CartesSeasonality, CartesSeasonality,
CartesSeasonalityYear, CartesSeasonalityYear,
CartesKpiPeriodMode,
} from "../shared/types"; } from "../shared/types";
export async function getMonthlyTrends( export async function getMonthlyTrends(
@ -782,6 +783,7 @@ async function fetchSeasonality(
export async function getCartesSnapshot( export async function getCartesSnapshot(
referenceYear: number, referenceYear: number,
referenceMonth: number, referenceMonth: number,
mode: CartesKpiPeriodMode = "month",
): Promise<CartesSnapshot> { ): Promise<CartesSnapshot> {
// Date window: 25 months back from the reference to cover YoY + a 13-month // Date window: 25 months back from the reference to cover YoY + a 13-month
// sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24. // sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24.
@ -848,32 +850,99 @@ export async function getCartesSnapshot(
const yoyMeta = { year: referenceYear - 1, month: referenceMonth }; const yoyMeta = { year: referenceYear - 1, month: referenceMonth };
const yoyKey = monthKey(yoyMeta.year, yoyMeta.month); const yoyKey = monthKey(yoyMeta.year, yoyMeta.month);
// Monthly aggregates — the "month" mode default behavior.
const refRow = flowByMonth.get(refKey); const refRow = flowByMonth.get(refKey);
const refIncome = refRow?.income ?? 0; const monthRefIncome = refRow?.income ?? 0;
const refExpenses = refRow?.expenses ?? 0; const monthRefExpenses = refRow?.expenses ?? 0;
const refNet = refIncome - refExpenses; const monthRefNet = monthRefIncome - monthRefExpenses;
// Savings rate is undefined when income is zero — expose as null rather than // Savings rate is undefined when income is zero — expose as null rather than
// rendering a misleading "0 %" in the UI. // rendering a misleading "0 %" in the UI.
const refSavings = refIncome > 0 ? (refNet / refIncome) * 100 : null; const monthRefSavings = monthRefIncome > 0 ? (monthRefNet / monthRefIncome) * 100 : null;
const momRow = flowByMonth.get(momKey); const momRow = flowByMonth.get(momKey);
const momIncome = momRow ? momRow.income : null; const monthMomIncome = momRow ? momRow.income : null;
const momExpenses = momRow ? momRow.expenses : null; const monthMomExpenses = momRow ? momRow.expenses : null;
const momNet = momRow ? momRow.income - momRow.expenses : null; const monthMomNet = momRow ? momRow.income - momRow.expenses : null;
const momSavings = const monthMomSavings =
momRow && momRow.income > 0 ? ((momRow.income - momRow.expenses) / momRow.income) * 100 : null; momRow && momRow.income > 0 ? ((momRow.income - momRow.expenses) / momRow.income) * 100 : null;
const yoyRow = flowByMonth.get(yoyKey); const yoyRow = flowByMonth.get(yoyKey);
const yoyIncome = yoyRow ? yoyRow.income : null; const monthYoyIncome = yoyRow ? yoyRow.income : null;
const yoyExpenses = yoyRow ? yoyRow.expenses : null; const monthYoyExpenses = yoyRow ? yoyRow.expenses : null;
const yoyNet = yoyRow ? yoyRow.income - yoyRow.expenses : null; const monthYoyNet = yoyRow ? yoyRow.income - yoyRow.expenses : null;
const yoySavings = const monthYoySavings =
yoyRow && yoyRow.income > 0 ? ((yoyRow.income - yoyRow.expenses) / yoyRow.income) * 100 : null; yoyRow && yoyRow.income > 0 ? ((yoyRow.income - yoyRow.expenses) / yoyRow.income) * 100 : null;
const incomeKpi = buildKpi(incomeSpark, refIncome, momIncome, yoyIncome); // YTD aggregates — sum monthly flows from January of a given year up to (and
const expensesKpi = buildKpi(expensesSpark, refExpenses, momExpenses, yoyExpenses); // including) `upToMonth`. Reuses the already-fetched `flowByMonth` map, so
const netKpi = buildKpi(netSpark, refNet, momNet, yoyNet); // no additional SQL round trip is required. Missing months contribute zero
const savingsKpi = buildKpi(savingsSpark, refSavings, momSavings, yoySavings); // to the sum (unlike monthly deltas which preserve the null distinction).
const sumYtd = (year: number, upToMonth: number): { income: number; expenses: number } => {
let income = 0;
let expenses = 0;
for (let m = 1; m <= upToMonth; m++) {
const row = flowByMonth.get(monthKey(year, m));
if (row) {
income += row.income;
expenses += row.expenses;
}
}
return { income, expenses };
};
// Current YTD: Jan→refMonth of refYear.
const ytdCurrent = sumYtd(referenceYear, referenceMonth);
const ytdRefIncome = ytdCurrent.income;
const ytdRefExpenses = ytdCurrent.expenses;
const ytdRefNet = ytdRefIncome - ytdRefExpenses;
const ytdRefSavings = ytdRefIncome > 0 ? (ytdRefNet / ytdRefIncome) * 100 : null;
// YTD MoM: previous YTD window is Jan→(refMonth-1) of refYear. When the
// reference month is January, there is no prior window inside the same
// year, so MoM deltas must be null (not zero).
let ytdMomIncome: number | null = null;
let ytdMomExpenses: number | null = null;
let ytdMomNet: number | null = null;
let ytdMomSavings: number | null = null;
if (referenceMonth > 1) {
const prev = sumYtd(referenceYear, referenceMonth - 1);
ytdMomIncome = prev.income;
ytdMomExpenses = prev.expenses;
ytdMomNet = prev.income - prev.expenses;
ytdMomSavings = prev.income > 0 ? ((prev.income - prev.expenses) / prev.income) * 100 : null;
}
// YTD YoY: same window (Jan→refMonth) of the previous calendar year.
const ytdPrevYear = sumYtd(referenceYear - 1, referenceMonth);
const ytdYoyIncome = ytdPrevYear.income;
const ytdYoyExpenses = ytdPrevYear.expenses;
const ytdYoyNet = ytdPrevYear.income - ytdPrevYear.expenses;
const ytdYoySavings =
ytdPrevYear.income > 0 ? (ytdYoyNet / ytdPrevYear.income) * 100 : null;
// Select the KPI aggregate set based on the requested mode. Sparklines are
// always the 13-month monthly series regardless of mode — the toggle only
// affects the "current" value and its MoM/YoY comparisons.
const isYtd = mode === "ytd";
const refIncome = isYtd ? ytdRefIncome : monthRefIncome;
const refExpenses = isYtd ? ytdRefExpenses : monthRefExpenses;
const refNet = isYtd ? ytdRefNet : monthRefNet;
const refSavings = isYtd ? ytdRefSavings : monthRefSavings;
const kpiMomIncome = isYtd ? ytdMomIncome : monthMomIncome;
const kpiMomExpenses = isYtd ? ytdMomExpenses : monthMomExpenses;
const kpiMomNet = isYtd ? ytdMomNet : monthMomNet;
const kpiMomSavings = isYtd ? ytdMomSavings : monthMomSavings;
const kpiYoyIncome = isYtd ? ytdYoyIncome : monthYoyIncome;
const kpiYoyExpenses = isYtd ? ytdYoyExpenses : monthYoyExpenses;
const kpiYoyNet = isYtd ? ytdYoyNet : monthYoyNet;
const kpiYoySavings = isYtd ? ytdYoySavings : monthYoySavings;
const incomeKpi = buildKpi(incomeSpark, refIncome, kpiMomIncome, kpiYoyIncome);
const expensesKpi = buildKpi(expensesSpark, refExpenses, kpiMomExpenses, kpiYoyExpenses);
const netKpi = buildKpi(netSpark, refNet, kpiMomNet, kpiYoyNet);
const savingsKpi = buildKpi(savingsSpark, refSavings, kpiMomSavings, kpiYoySavings);
// 12-month income vs expenses series for the overlay chart. // 12-month income vs expenses series for the overlay chart.
const flow12Months = buildSeries(12); const flow12Months = buildSeries(12);
@ -954,9 +1023,10 @@ 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 // Seasonality compares the reference month's expenses to the same calendar
// `savingsKpi.current` which is nullable when income is zero. // month in previous years — it is intentionally insensitive to the KPI
const referenceAmount = refExpenses; // period toggle, so always use the monthly expenses (not the YTD figure).
const referenceAmount = monthRefExpenses;
const deviationPct = const deviationPct =
historicalAverage !== null && historicalAverage > 0 historicalAverage !== null && historicalAverage > 0
? ((referenceAmount - historicalAverage) / historicalAverage) * 100 ? ((referenceAmount - historicalAverage) / historicalAverage) * 100

View file

@ -397,6 +397,15 @@ export interface CartesKpi {
export type CartesKpiId = "income" | "expenses" | "net" | "savingsRate"; export type CartesKpiId = "income" | "expenses" | "net" | "savingsRate";
/**
* Period mode for the Cartes KPI cards.
* - "month": KPI value = reference month only.
* - "ytd": KPI value = cumulative from Jan 1st of the reference year up to
* the end of the reference month. The sparkline is not affected,
* only the rendered "current" value and its MoM/YoY deltas.
*/
export type CartesKpiPeriodMode = "month" | "ytd";
export interface CartesTopMover { export interface CartesTopMover {
categoryId: number | null; categoryId: number | null;
categoryName: string; categoryName: string;