feat: implement aggregated and per-account balance visualization in statistics page with interactive chart options

This commit is contained in:
Julien Froidefond
2025-11-28 14:01:26 +01:00
parent fc483d0573
commit b2db902e5c
2 changed files with 189 additions and 26 deletions

View File

@@ -119,27 +119,84 @@ export default function StatisticsPage() {
const avgMonthlyExpenses = const avgMonthlyExpenses =
monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0; monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0;
// Balance evolution // Balance evolution - Aggregated
const sortedTransactions = [...transactions].sort( 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() (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
); );
let runningBalance = 0; let runningBalance = 0;
const balanceByDate = new Map<string, number>(); const aggregatedBalanceByDate = new Map<string, number>();
sortedTransactions.forEach((t) => { sortedAllTransactions.forEach((t) => {
runningBalance += t.amount; runningBalance += t.amount;
balanceByDate.set(t.date, runningBalance); aggregatedBalanceByDate.set(t.date, runningBalance);
}); });
const balanceChartData = Array.from(balanceByDate.entries()).map( const aggregatedBalanceData = Array.from(
([date, balance]) => ({ 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<string, Map<string, number>>();
data.accounts.forEach((account) => {
accountBalances.set(account.id, new Map());
});
// Calculate running balance per account
const accountRunningBalances = new Map<string, number>();
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<string>();
accountBalances.forEach((dates) => {
dates.forEach((_, date) => allDates.add(date));
});
const sortedDates = Array.from(allDates).sort();
const lastBalances = new Map<string, number>();
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", { date: new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit", day: "2-digit",
month: "short", 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 { return {
monthlyChartData, monthlyChartData,
@@ -148,7 +205,8 @@ export default function StatisticsPage() {
totalIncome, totalIncome,
totalExpenses, totalExpenses,
avgMonthlyExpenses, avgMonthlyExpenses,
balanceChartData, aggregatedBalanceData,
perAccountBalanceData,
transactionCount: transactions.length, transactionCount: transactions.length,
}; };
}, [data, period, selectedAccount]); }, [data, period, selectedAccount]);
@@ -219,7 +277,9 @@ export default function StatisticsPage() {
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
/> />
<BalanceLineChart <BalanceLineChart
data={stats.balanceChartData} aggregatedData={stats.aggregatedBalanceData}
perAccountData={stats.perAccountBalanceData}
accounts={data.accounts}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
/> />
<TopExpensesList <TopExpensesList

View File

@@ -1,6 +1,9 @@
"use client"; "use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Layers, SplitSquareHorizontal } from "lucide-react";
import { import {
LineChart, LineChart,
Line, Line,
@@ -9,29 +12,75 @@ import {
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
Legend,
} from "recharts"; } from "recharts";
import type { Account } from "@/lib/types";
interface BalanceChartData { interface BalanceChartDataPoint {
date: string; date: string;
solde: number; [key: string]: string | number;
} }
interface BalanceLineChartProps { interface BalanceLineChartProps {
data: BalanceChartData[]; aggregatedData: BalanceChartDataPoint[];
perAccountData: BalanceChartDataPoint[];
accounts: Account[];
formatCurrency: (amount: number) => string; formatCurrency: (amount: number) => string;
} }
const ACCOUNT_COLORS = [
"#6366f1", // indigo
"#22c55e", // green
"#f59e0b", // amber
"#ef4444", // red
"#8b5cf6", // violet
"#06b6d4", // cyan
"#ec4899", // pink
"#84cc16", // lime
];
export function BalanceLineChart({ export function BalanceLineChart({
data, aggregatedData,
perAccountData,
accounts,
formatCurrency, formatCurrency,
}: BalanceLineChartProps) { }: BalanceLineChartProps) {
const [mode, setMode] = useState<"aggregated" | "split">("aggregated");
const [hoveredAccount, setHoveredAccount] = useState<string | null>(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 ( return (
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Évolution du solde</CardTitle> <CardTitle>Évolution du solde</CardTitle>
<div className="flex gap-1">
<Button
variant={mode === "aggregated" ? "default" : "ghost"}
size="sm"
onClick={() => setMode("aggregated")}
title="Tous les comptes agrégés"
>
<Layers className="w-4 h-4" />
</Button>
<Button
variant={mode === "split" ? "default" : "ghost"}
size="sm"
onClick={() => setMode("split")}
title="Par compte"
>
<SplitSquareHorizontal className="w-4 h-4" />
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{data.length > 0 ? ( {hasData ? (
<div className="h-[300px]"> <div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={data}> <LineChart data={data}>
@@ -50,13 +99,68 @@ export function BalanceLineChart({
borderRadius: "8px", borderRadius: "8px",
}} }}
/> />
<Line {mode === "aggregated" ? (
type="monotone" <Line
dataKey="solde" type="monotone"
stroke="#6366f1" dataKey="solde"
strokeWidth={2} name="Solde total"
dot={false} stroke="#6366f1"
/> strokeWidth={2}
dot={false}
/>
) : (
accounts.map((account, index) => (
<Line
key={account.id}
type="monotone"
dataKey={account.id}
name={account.name}
stroke={ACCOUNT_COLORS[index % ACCOUNT_COLORS.length]}
strokeWidth={hoveredAccount === account.id ? 3 : 2}
strokeOpacity={getLineOpacity(account.id)}
dot={false}
/>
))
)}
{mode === "split" && (
<Legend
content={({ payload }) => (
<div
className="flex flex-wrap justify-center gap-x-4 gap-y-1 mt-2"
onMouseLeave={() => setHoveredAccount(null)}
>
{payload?.map((entry, index) => {
const accountId = entry.dataKey as string;
const isHovered = hoveredAccount === accountId;
const isDimmed =
hoveredAccount && hoveredAccount !== accountId;
return (
<div
key={`legend-${index}`}
className="flex items-center gap-1.5 text-sm cursor-pointer transition-opacity"
style={{
opacity: isDimmed ? 0.3 : 1,
}}
onMouseEnter={() => setHoveredAccount(accountId)}
>
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor: entry.color,
transform: isHovered ? "scale(1.2)" : "scale(1)",
transition: "transform 0.15s",
}}
/>
<span className="text-foreground">
{entry.value}
</span>
</div>
);
})}
</div>
)}
/>
)}
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -69,4 +173,3 @@ export function BalanceLineChart({
</Card> </Card>
); );
} }