From 00dd8fc3358761fc13933d7b7a42deffc470a82e Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 30 Nov 2025 17:05:03 +0100 Subject: [PATCH] feat: enhance statistics page with new charts and data visualizations including savings trend, category trends, and year-over-year comparisons --- app/statistics/page.tsx | 237 ++++++++++++++-- components/statistics/category-bar-chart.tsx | 118 ++++++++ .../statistics/category-trend-chart.tsx | 268 ++++++++++++++++++ .../statistics/income-expense-trend-chart.tsx | 94 ++++++ components/statistics/index.ts | 5 + components/statistics/savings-trend-chart.tsx | 116 ++++++++ .../statistics/year-over-year-chart.tsx | 91 ++++++ 7 files changed, 902 insertions(+), 27 deletions(-) create mode 100644 components/statistics/category-bar-chart.tsx create mode 100644 components/statistics/category-trend-chart.tsx create mode 100644 components/statistics/income-expense-trend-chart.tsx create mode 100644 components/statistics/savings-trend-chart.tsx create mode 100644 components/statistics/year-over-year-chart.tsx diff --git a/app/statistics/page.tsx b/app/statistics/page.tsx index 9e25a51..c8e8e69 100644 --- a/app/statistics/page.tsx +++ b/app/statistics/page.tsx @@ -8,6 +8,11 @@ import { CategoryPieChart, BalanceLineChart, TopExpensesList, + CategoryBarChart, + CategoryTrendChart, + SavingsTrendChart, + IncomeExpenseTrendChart, + YearOverYearChart, } from "@/components/statistics"; import { useBankingData } from "@/lib/hooks"; import { getAccountBalance } from "@/lib/account-utils"; @@ -409,6 +414,126 @@ export default function StatisticsPage() { return point; }); + // Savings trend data + const savingsTrendData = monthlyChartData.map((month) => ({ + month: month.month, + savings: month.solde, + cumulative: 0, // Will be calculated if needed + })); + + // Category trend data (monthly breakdown by category) + const categoryTrendByMonth = new Map>(); + transactions + .filter((t) => t.amount < 0) + .forEach((t) => { + const monthKey = t.date.substring(0, 7); + const catId = t.categoryId || "uncategorized"; + + if (!categoryTrendByMonth.has(monthKey)) { + categoryTrendByMonth.set(monthKey, new Map()); + } + const monthCategories = categoryTrendByMonth.get(monthKey)!; + const current = monthCategories.get(catId) || 0; + monthCategories.set(catId, current + Math.abs(t.amount)); + }); + + const allCategoryMonths = Array.from(categoryTrendByMonth.keys()).sort(); + const categoryTrendData = allCategoryMonths.map((monthKey) => { + const point: { month: string; [key: string]: string | number } = { + month: new Date(monthKey + "-01").toLocaleDateString("fr-FR", { + month: "short", + year: "numeric", + }), + }; + const monthCategories = categoryTrendByMonth.get(monthKey)!; + monthCategories.forEach((total, catId) => { + point[catId] = Math.round(total); + }); + return point; + }); + + // Category trend data grouped by parent category + const categoryTrendByMonthByParent = new Map>(); + transactions + .filter((t) => t.amount < 0) + .forEach((t) => { + const monthKey = t.date.substring(0, 7); + const category = data.categories.find((c) => c.id === t.categoryId); + // Use parent category ID if category has a parent, otherwise use the category itself + // If category is null (uncategorized), use "uncategorized" + let groupId: string; + if (!category) { + groupId = "uncategorized"; + } else if (category.parentId) { + groupId = category.parentId; + } else { + // Category is a parent itself + groupId = category.id; + } + + if (!categoryTrendByMonthByParent.has(monthKey)) { + categoryTrendByMonthByParent.set(monthKey, new Map()); + } + const monthCategories = categoryTrendByMonthByParent.get(monthKey)!; + const current = monthCategories.get(groupId) || 0; + monthCategories.set(groupId, current + Math.abs(t.amount)); + }); + + // Use the same months as categoryTrendData + const categoryTrendDataByParent = allCategoryMonths.map((monthKey) => { + const point: { month: string; [key: string]: string | number } = { + month: new Date(monthKey + "-01").toLocaleDateString("fr-FR", { + month: "short", + year: "numeric", + }), + }; + const monthCategories = categoryTrendByMonthByParent.get(monthKey); + if (monthCategories) { + monthCategories.forEach((total, groupId) => { + point[groupId] = Math.round(total); + }); + } + return point; + }); + + // Year over year comparison (if we have at least 12 months) + const currentYear = new Date().getFullYear(); + const previousYear = currentYear - 1; + const yearOverYearData: Array<{ + month: string; + current: number; + previous: number; + label: string; + }> = []; + + if (allCategoryMonths.length >= 12) { + const currentYearData = new Map(); + const previousYearData = new Map(); + + monthlyData.forEach((values, monthKey) => { + const date = new Date(monthKey + "-01"); + const monthName = date.toLocaleDateString("fr-FR", { month: "short" }); + const year = date.getFullYear(); + + if (year === currentYear) { + currentYearData.set(monthName, values.expenses); + } else if (year === previousYear) { + previousYearData.set(monthName, values.expenses); + } + }); + + // Get all months from current year + const months = Array.from(currentYearData.keys()); + months.forEach((month) => { + yearOverYearData.push({ + month, + current: currentYearData.get(month) || 0, + previous: previousYearData.get(month) || 0, + label: month, + }); + }); + } + return { monthlyChartData, categoryChartData, @@ -419,6 +544,10 @@ export default function StatisticsPage() { aggregatedBalanceData, perAccountBalanceData, transactionCount: transactions.length, + savingsTrendData, + categoryTrendData, + categoryTrendDataByParent, + yearOverYearData, }; }, [data, startDate, endDate, selectedAccounts, selectedCategories, excludeInternalTransfers, internalTransferCategory]); @@ -609,34 +738,88 @@ export default function StatisticsPage() { - + {/* Vue d'ensemble */} +
+

Vue d'ensemble

+ +
+ +
+
+ +
+
+ + {/* Revenus et Dépenses */} +
+

Revenus et Dépenses

+
+ + ({ + month: m.month, + revenus: m.revenus, + depenses: m.depenses, + }))} + formatCurrency={formatCurrency} + /> +
+ {stats.yearOverYearData.length > 0 && ( +
+ +
+ )} +
+ + {/* Analyse par Catégorie */} +
+

Analyse par Catégorie

+
+ + +
+
+ +
+
+ +
+
-
- - - - -
); } diff --git a/components/statistics/category-bar-chart.tsx b/components/statistics/category-bar-chart.tsx new file mode 100644 index 0000000..8b3ef38 --- /dev/null +++ b/components/statistics/category-bar-chart.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { CategoryIcon } from "@/components/ui/category-icon"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, +} from "recharts"; +import type { CategoryChartData } from "./category-pie-chart"; + +interface CategoryBarChartProps { + data: CategoryChartData[]; + formatCurrency: (amount: number) => string; + title?: string; + maxItems?: number; +} + +export function CategoryBarChart({ + data, + formatCurrency, + title = "Top catégories de dépenses", + maxItems = 10, +}: CategoryBarChartProps) { + const displayData = data.slice(0, maxItems).reverse(); // Reverse pour avoir le plus grand en haut + + return ( + + + {title} + + + {displayData.length > 0 ? ( +
+ + + + { + if (Math.abs(v) >= 1000) { + return `${(v / 1000).toFixed(1)}k€`; + } + return `${Math.round(v)}€`; + }} + tick={{ fill: "var(--muted-foreground)" }} + /> + { + const item = displayData.find((d) => d.name === value); + return item ? value : ""; + }} + /> + { + if (!active || !payload?.length) return null; + const item = payload[0].payload as CategoryChartData; + return ( +
+
+ + + {item.name} + +
+
+ {formatCurrency(item.value)} +
+
+ ); + }} + /> + + {displayData.map((entry, index) => ( + + ))} + +
+
+
+ ) : ( +
+ Pas de données pour cette période +
+ )} +
+
+ ); +} + diff --git a/components/statistics/category-trend-chart.tsx b/components/statistics/category-trend-chart.tsx new file mode 100644 index 0000000..3defa48 --- /dev/null +++ b/components/statistics/category-trend-chart.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { CategoryIcon } from "@/components/ui/category-icon"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { ChevronDown, ChevronUp, Layers, List } from "lucide-react"; +import type { Category } from "@/lib/types"; + +interface CategoryTrendDataPoint { + month: string; + [categoryId: string]: string | number; +} + +interface CategoryTrendChartProps { + data: CategoryTrendDataPoint[]; + dataByParent: CategoryTrendDataPoint[]; + categories: Category[]; + formatCurrency: (amount: number) => string; + maxCategories?: number; +} + +const CATEGORY_COLORS = [ + "#ef4444", // red + "#f59e0b", // amber + "#eab308", // yellow + "#22c55e", // green + "#06b6d4", // cyan + "#3b82f6", // blue + "#6366f1", // indigo + "#8b5cf6", // violet + "#ec4899", // pink + "#f97316", // orange +]; + +export function CategoryTrendChart({ + data, + dataByParent, + categories, + formatCurrency, + maxCategories = 5, +}: CategoryTrendChartProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [selectedCategories, setSelectedCategories] = useState([]); + const [groupByParent, setGroupByParent] = useState(true); + + // Use the appropriate dataset based on groupByParent + const currentData = groupByParent ? dataByParent : data; + + // Get top categories by total amount + const categoryTotals = new Map(); + currentData.forEach((point) => { + Object.keys(point).forEach((key) => { + if (key !== "month" && typeof point[key] === "number") { + const current = categoryTotals.get(key) || 0; + categoryTotals.set(key, current + (point[key] as number)); + } + }); + }); + + const topCategories = Array.from(categoryTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, maxCategories) + .map(([id]) => id); + + const displayCategories = isExpanded + ? Array.from(categoryTotals.keys()) + : topCategories; + + const categoriesToShow = + selectedCategories.length > 0 ? selectedCategories : displayCategories; + + // Helper function to get category name + // When groupByParent is true, the categoryId in dataByParent is already the parent ID + const getCategoryName = (categoryId: string): string => { + if (categoryId === "uncategorized") return "Non catégorisé"; + const category = categories.find((c) => c.id === categoryId); + return category?.name || "Inconnu"; + }; + + // Helper function to get category info (for color, icon) + // When groupByParent is true, the categoryId in dataByParent is already the parent ID + const getCategoryInfo = (categoryId: string): Category | null => { + if (categoryId === "uncategorized") return null; + return categories.find((c) => c.id === categoryId) || null; + }; + + return ( + + + Évolution des dépenses par catégorie +
+ + {categoryTotals.size > maxCategories && ( + + )} +
+
+ + {currentData.length > 0 && categoriesToShow.length > 0 ? ( +
+ + + + + { + if (Math.abs(v) >= 1000) { + return `${(v / 1000).toFixed(1)}k€`; + } + return `${Math.round(v)}€`; + }} + tick={{ fill: "var(--muted-foreground)" }} + /> + formatCurrency(value)} + contentStyle={{ + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "8px", + }} + /> + { + // Get all category IDs from data + const allCategoryIds = Array.from(categoryTotals.keys()); + + return ( +
+ {allCategoryIds.map((categoryId) => { + const categoryInfo = getCategoryInfo(categoryId); + const categoryName = getCategoryName(categoryId); + if (!categoryInfo && categoryId !== "uncategorized") return null; + + const isInDisplayCategories = displayCategories.includes(categoryId); + const isSelected = + selectedCategories.length === 0 + ? isInDisplayCategories + : selectedCategories.includes(categoryId); + return ( + + ); + })} +
+ ); + }} + /> + {categoriesToShow.map((categoryId, index) => { + const categoryInfo = getCategoryInfo(categoryId); + const categoryName = getCategoryName(categoryId); + if (!categoryInfo && categoryId !== "uncategorized") return null; + + const isSelected = + selectedCategories.length === 0 || + selectedCategories.includes(categoryId); + return ( + 0} + /> + ); + })} +
+
+
+ ) : ( +
+ Pas de données pour cette période +
+ )} +
+
+ ); +} + diff --git a/components/statistics/income-expense-trend-chart.tsx b/components/statistics/income-expense-trend-chart.tsx new file mode 100644 index 0000000..e12f5a3 --- /dev/null +++ b/components/statistics/income-expense-trend-chart.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; + +interface IncomeExpenseTrendDataPoint { + month: string; + revenus: number; + depenses: number; +} + +interface IncomeExpenseTrendChartProps { + data: IncomeExpenseTrendDataPoint[]; + formatCurrency: (amount: number) => string; +} + +export function IncomeExpenseTrendChart({ + data, + formatCurrency, +}: IncomeExpenseTrendChartProps) { + return ( + + + Tendances revenus et dépenses + + + {data.length > 0 ? ( +
+ + + + + { + if (Math.abs(v) >= 1000) { + return `${(v / 1000).toFixed(1)}k€`; + } + return `${Math.round(v)}€`; + }} + tick={{ fill: "var(--muted-foreground)" }} + /> + formatCurrency(value)} + contentStyle={{ + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "8px", + }} + /> + + + + + +
+ ) : ( +
+ Pas de données pour cette période +
+ )} +
+
+ ); +} + diff --git a/components/statistics/index.ts b/components/statistics/index.ts index 0f4e42e..421b189 100644 --- a/components/statistics/index.ts +++ b/components/statistics/index.ts @@ -3,4 +3,9 @@ export { MonthlyChart } from "./monthly-chart"; export { CategoryPieChart } from "./category-pie-chart"; export { BalanceLineChart } from "./balance-line-chart"; export { TopExpensesList } from "./top-expenses-list"; +export { CategoryBarChart } from "./category-bar-chart"; +export { CategoryTrendChart } from "./category-trend-chart"; +export { SavingsTrendChart } from "./savings-trend-chart"; +export { IncomeExpenseTrendChart } from "./income-expense-trend-chart"; +export { YearOverYearChart } from "./year-over-year-chart"; diff --git a/components/statistics/savings-trend-chart.tsx b/components/statistics/savings-trend-chart.tsx new file mode 100644 index 0000000..41bb850 --- /dev/null +++ b/components/statistics/savings-trend-chart.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { TrendingUp, TrendingDown } from "lucide-react"; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; + +interface SavingsTrendDataPoint { + month: string; + savings: number; + cumulative: number; +} + +interface SavingsTrendChartProps { + data: SavingsTrendDataPoint[]; + formatCurrency: (amount: number) => string; +} + +export function SavingsTrendChart({ + data, + formatCurrency, +}: SavingsTrendChartProps) { + const latestSavings = data.length > 0 ? data[data.length - 1].savings : 0; + const isPositive = latestSavings >= 0; + + return ( + + + Évolution des économies +
+ {isPositive ? ( + + ) : ( + + )} + + {formatCurrency(latestSavings)} + +
+
+ + {data.length > 0 ? ( +
+ + + + + + + + + + + { + if (Math.abs(v) >= 1000) { + return `${(v / 1000).toFixed(1)}k€`; + } + return `${Math.round(v)}€`; + }} + tick={{ fill: "var(--muted-foreground)" }} + /> + formatCurrency(value)} + contentStyle={{ + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "8px", + }} + /> + + + +
+ ) : ( +
+ Pas de données pour cette période +
+ )} +
+
+ ); +} + diff --git a/components/statistics/year-over-year-chart.tsx b/components/statistics/year-over-year-chart.tsx new file mode 100644 index 0000000..2298614 --- /dev/null +++ b/components/statistics/year-over-year-chart.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; + +interface YearOverYearDataPoint { + month: string; + current: number; + previous: number; + label: string; +} + +interface YearOverYearChartProps { + data: YearOverYearDataPoint[]; + formatCurrency: (amount: number) => string; + currentYearLabel?: string; + previousYearLabel?: string; +} + +export function YearOverYearChart({ + data, + formatCurrency, + currentYearLabel = "Cette année", + previousYearLabel = "Année précédente", +}: YearOverYearChartProps) { + return ( + + + Comparaison année sur année + + + {data.length > 0 ? ( +
+ + + + + { + if (Math.abs(v) >= 1000) { + return `${(v / 1000).toFixed(1)}k€`; + } + return `${Math.round(v)}€`; + }} + tick={{ fill: "var(--muted-foreground)" }} + /> + formatCurrency(value)} + contentStyle={{ + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "8px", + }} + /> + + + + + +
+ ) : ( +
+ Pas de données pour cette période +
+ )} +
+
+ ); +} +