From b2db902e5c8a7192bc6baa1f15b474e9d1b3ab91 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 28 Nov 2025 14:01:26 +0100 Subject: [PATCH] feat: implement aggregated and per-account balance visualization in statistics page with interactive chart options --- app/statistics/page.tsx | 84 ++++++++++-- components/statistics/balance-line-chart.tsx | 131 +++++++++++++++++-- 2 files changed, 189 insertions(+), 26 deletions(-) diff --git a/app/statistics/page.tsx b/app/statistics/page.tsx index 4017a2c..3dbc0e7 100644 --- a/app/statistics/page.tsx +++ b/app/statistics/page.tsx @@ -119,27 +119,84 @@ export default function StatisticsPage() { const avgMonthlyExpenses = monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0; - // Balance evolution - const sortedTransactions = [...transactions].sort( + // Balance evolution - Aggregated + const allTransactionsForBalance = data.transactions.filter( + (t) => new Date(t.date) >= startDate + ); + const sortedAllTransactions = [...allTransactionsForBalance].sort( (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() ); let runningBalance = 0; - const balanceByDate = new Map(); - sortedTransactions.forEach((t) => { + const aggregatedBalanceByDate = new Map(); + sortedAllTransactions.forEach((t) => { runningBalance += t.amount; - balanceByDate.set(t.date, runningBalance); + aggregatedBalanceByDate.set(t.date, runningBalance); }); - const balanceChartData = Array.from(balanceByDate.entries()).map( - ([date, balance]) => ({ + const aggregatedBalanceData = Array.from( + aggregatedBalanceByDate.entries() + ).map(([date, balance]) => ({ + date: new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "short", + }), + solde: Math.round(balance), + })); + + // Balance evolution - Per account + const accountBalances = new Map>(); + data.accounts.forEach((account) => { + accountBalances.set(account.id, new Map()); + }); + + // Calculate running balance per account + const accountRunningBalances = new Map(); + data.accounts.forEach((account) => { + accountRunningBalances.set(account.id, 0); + }); + + sortedAllTransactions.forEach((t) => { + const currentBalance = accountRunningBalances.get(t.accountId) || 0; + const newBalance = currentBalance + t.amount; + accountRunningBalances.set(t.accountId, newBalance); + + const accountDates = accountBalances.get(t.accountId); + if (accountDates) { + accountDates.set(t.date, newBalance); + } + }); + + // Merge all dates and create data points + const allDates = new Set(); + accountBalances.forEach((dates) => { + dates.forEach((_, date) => allDates.add(date)); + }); + + const sortedDates = Array.from(allDates).sort(); + const lastBalances = new Map(); + data.accounts.forEach((account) => { + lastBalances.set(account.id, 0); + }); + + const perAccountBalanceData = sortedDates.map((date) => { + const point: { date: string; [key: string]: string | number } = { date: new Date(date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", }), - solde: Math.round(balance), - }) - ); + }; + + data.accounts.forEach((account) => { + const accountDates = accountBalances.get(account.id); + if (accountDates?.has(date)) { + lastBalances.set(account.id, accountDates.get(date)!); + } + point[account.id] = Math.round(lastBalances.get(account.id) || 0); + }); + + return point; + }); return { monthlyChartData, @@ -148,7 +205,8 @@ export default function StatisticsPage() { totalIncome, totalExpenses, avgMonthlyExpenses, - balanceChartData, + aggregatedBalanceData, + perAccountBalanceData, transactionCount: transactions.length, }; }, [data, period, selectedAccount]); @@ -219,7 +277,9 @@ export default function StatisticsPage() { formatCurrency={formatCurrency} /> string; } +const ACCOUNT_COLORS = [ + "#6366f1", // indigo + "#22c55e", // green + "#f59e0b", // amber + "#ef4444", // red + "#8b5cf6", // violet + "#06b6d4", // cyan + "#ec4899", // pink + "#84cc16", // lime +]; + export function BalanceLineChart({ - data, + aggregatedData, + perAccountData, + accounts, formatCurrency, }: BalanceLineChartProps) { + const [mode, setMode] = useState<"aggregated" | "split">("aggregated"); + const [hoveredAccount, setHoveredAccount] = useState(null); + + const data = mode === "aggregated" ? aggregatedData : perAccountData; + const hasData = data.length > 0; + + const getLineOpacity = (accountId: string) => { + if (!hoveredAccount) return 1; + return hoveredAccount === accountId ? 1 : 0.15; + }; + return ( - + Évolution du solde +
+ + +
- {data.length > 0 ? ( + {hasData ? (
@@ -50,13 +99,68 @@ export function BalanceLineChart({ borderRadius: "8px", }} /> - + {mode === "aggregated" ? ( + + ) : ( + accounts.map((account, index) => ( + + )) + )} + {mode === "split" && ( + ( +
setHoveredAccount(null)} + > + {payload?.map((entry, index) => { + const accountId = entry.dataKey as string; + const isHovered = hoveredAccount === accountId; + const isDimmed = + hoveredAccount && hoveredAccount !== accountId; + return ( +
setHoveredAccount(accountId)} + > +
+ + {entry.value} + +
+ ); + })} +
+ )} + /> + )}
@@ -69,4 +173,3 @@ export function BalanceLineChart({ ); } -