feat: implement reports page with trends, category, and over-time charts

Add three chart tabs sharing a period selector: monthly income/expenses
area chart, horizontal category bar chart, and stacked category-over-time
bar chart with top 8 + Other grouping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-10 01:47:18 +00:00
parent 3506c2c87e
commit d6000e191f
9 changed files with 594 additions and 10 deletions

View file

@ -0,0 +1,65 @@
import { useTranslation } from "react-i18next";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import type { CategoryBreakdownItem } from "../../shared/types";
const eurFormatter = (value: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value);
interface CategoryBarChartProps {
data: CategoryBreakdownItem[];
}
export default function CategoryBarChart({ data }: CategoryBarChartProps) {
const { t } = useTranslation();
if (data.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
</div>
);
}
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<ResponsiveContainer width="100%" height={Math.max(400, data.length * 40)}>
<BarChart data={data} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<XAxis
type="number"
tickFormatter={(v) => eurFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
type="category"
dataKey="category_name"
width={120}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<Tooltip
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
/>
<Bar dataKey="total" name={t("dashboard.expenses")} radius={[0, 4, 4, 0]}>
{data.map((item, index) => (
<Cell key={index} fill={item.category_color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,77 @@
import { useTranslation } from "react-i18next";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Legend,
CartesianGrid,
} from "recharts";
import type { CategoryOverTimeData } from "../../shared/types";
const eurFormatter = (value: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value);
function formatMonth(month: string): string {
const [year, m] = month.split("-");
const date = new Date(Number(year), Number(m) - 1);
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
}
interface CategoryOverTimeChartProps {
data: CategoryOverTimeData;
}
export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartProps) {
const { t } = useTranslation();
if (data.data.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
</div>
);
}
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tickFormatter={formatMonth}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
tickFormatter={(v) => eurFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
/>
<Legend />
{data.categories.map((name) => (
<Bar
key={name}
dataKey={name}
stackId="stack"
fill={data.colors[name]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,93 @@
import { useTranslation } from "react-i18next";
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} from "recharts";
import type { MonthlyTrendItem } from "../../shared/types";
const eurFormatter = (value: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value);
function formatMonth(month: string): string {
const [year, m] = month.split("-");
const date = new Date(Number(year), Number(m) - 1);
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
}
interface MonthlyTrendsChartProps {
data: MonthlyTrendItem[];
}
export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
const { t } = useTranslation();
if (data.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
</div>
);
}
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<ResponsiveContainer width="100%" height={400}>
<AreaChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="gradientIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--positive)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--positive)" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradientExpenses" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--negative)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--negative)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tickFormatter={formatMonth}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
tickFormatter={(v) => eurFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
/>
<Area
type="monotone"
dataKey="income"
name={t("dashboard.income")}
stroke="var(--positive)"
fill="url(#gradientIncome)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="expenses"
name={t("dashboard.expenses")}
stroke="var(--negative)"
fill="url(#gradientExpenses)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

147
src/hooks/useReports.ts Normal file
View file

@ -0,0 +1,147 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type {
ReportTab,
DashboardPeriod,
MonthlyTrendItem,
CategoryBreakdownItem,
CategoryOverTimeData,
} from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
import { getExpensesByCategory } from "../services/dashboardService";
interface ReportsState {
tab: ReportTab;
period: DashboardPeriod;
monthlyTrends: MonthlyTrendItem[];
categorySpending: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
isLoading: boolean;
error: string | null;
}
type ReportsAction =
| { type: "SET_TAB"; payload: ReportTab }
| { type: "SET_PERIOD"; payload: DashboardPeriod }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData };
const initialState: ReportsState = {
tab: "trends",
period: "6months",
monthlyTrends: [],
categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {} },
isLoading: false,
error: null,
};
function reducer(state: ReportsState, action: ReportsAction): ReportsState {
switch (action.type) {
case "SET_TAB":
return { ...state, tab: action.payload };
case "SET_PERIOD":
return { ...state, period: action.payload };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
case "SET_MONTHLY_TRENDS":
return { ...state, monthlyTrends: action.payload, isLoading: false };
case "SET_CATEGORY_SPENDING":
return { ...state, categorySpending: action.payload, isLoading: false };
case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false };
default:
return state;
}
}
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month":
from = new Date(year, month, 1);
break;
case "3months":
from = new Date(year, month - 2, 1);
break;
case "6months":
from = new Date(year, month - 5, 1);
break;
case "12months":
from = new Date(year, month - 11, 1);
break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export function useReports() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetchData = useCallback(async (tab: ReportTab, period: DashboardPeriod) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const { dateFrom, dateTo } = computeDateRange(period);
switch (tab) {
case "trends": {
const data = await getMonthlyTrends(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
break;
}
case "byCategory": {
const data = await getExpensesByCategory(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
break;
}
case "overTime": {
const data = await getCategoryOverTime(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
break;
}
}
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, []);
useEffect(() => {
fetchData(state.tab, state.period);
}, [state.tab, state.period, fetchData]);
const setTab = useCallback((tab: ReportTab) => {
dispatch({ type: "SET_TAB", payload: tab });
}, []);
const setPeriod = useCallback((period: DashboardPeriod) => {
dispatch({ type: "SET_PERIOD", payload: period });
}, []);
return { state, setTab, setPeriod };
}

View file

@ -225,9 +225,9 @@
"reports": {
"title": "Reports",
"period": "Period",
"byCategory": "By Category",
"byMonth": "By Month",
"trends": "Trends",
"byCategory": "Expenses by Category",
"overTime": "Category Over Time",
"trends": "Monthly Trends",
"export": "Export"
},
"common": {

View file

@ -225,9 +225,9 @@
"reports": {
"title": "Rapports",
"period": "Période",
"byCategory": "Par catégorie",
"byMonth": "Par mois",
"trends": "Tendances",
"byCategory": "Dépenses par catégorie",
"overTime": "Catégories dans le temps",
"trends": "Tendances mensuelles",
"export": "Exporter"
},
"common": {

View file

@ -1,14 +1,49 @@
import { useTranslation } from "react-i18next";
import { useReports } from "../hooks/useReports";
import type { ReportTab } from "../shared/types";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
const TABS: ReportTab[] = ["trends", "byCategory", "overTime"];
export default function ReportsPage() {
const { t } = useTranslation();
const { state, setTab, setPeriod } = useReports();
return (
<div>
<h1 className="text-2xl font-bold mb-6">{t("reports.title")}</h1>
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
<p>{t("common.noResults")}</p>
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
<PeriodSelector value={state.period} onChange={setPeriod} />
</div>
<div className="flex gap-2 mb-6">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => setTab(tab)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === state.tab
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t(`reports.${tab}`)}
</button>
))}
</div>
{state.error && (
<div className="bg-red-100 text-red-700 rounded-xl p-4 mb-6">
{state.error}
</div>
)}
{state.tab === "trends" && <MonthlyTrendsChart data={state.monthlyTrends} />}
{state.tab === "byCategory" && <CategoryBarChart data={state.categorySpending} />}
{state.tab === "overTime" && <CategoryOverTimeChart data={state.categoryOverTime} />}
</div>
);
}

View file

@ -0,0 +1,146 @@
import { getDb } from "./db";
import type {
MonthlyTrendItem,
CategoryBreakdownItem,
CategoryOverTimeData,
CategoryOverTimeItem,
} from "../shared/types";
export async function getMonthlyTrends(
dateFrom?: string,
dateTo?: string
): Promise<MonthlyTrendItem[]> {
const db = await getDb();
const whereClauses: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (dateFrom) {
whereClauses.push(`date >= $${paramIndex}`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
whereClauses.push(`date <= $${paramIndex}`);
params.push(dateTo);
paramIndex++;
}
const whereSQL =
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
return db.select<MonthlyTrendItem[]>(
`SELECT
strftime('%Y-%m', date) AS month,
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
FROM transactions
${whereSQL}
GROUP BY month
ORDER BY month ASC`,
params
);
}
export async function getCategoryOverTime(
dateFrom?: string,
dateTo?: string,
topN: number = 8
): Promise<CategoryOverTimeData> {
const db = await getDb();
const whereClauses: string[] = ["t.amount < 0"];
const params: unknown[] = [];
let paramIndex = 1;
if (dateFrom) {
whereClauses.push(`t.date >= $${paramIndex}`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
whereClauses.push(`t.date <= $${paramIndex}`);
params.push(dateTo);
paramIndex++;
}
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
// Get top N categories by total spend
const topCategories = await db.select<CategoryBreakdownItem[]>(
`SELECT
t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color,
ABS(SUM(t.amount)) AS total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${whereSQL}
GROUP BY t.category_id
ORDER BY total DESC
LIMIT $${paramIndex}`,
[...params, topN]
);
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
const colors: Record<string, string> = {};
for (const cat of topCategories) {
colors[cat.category_name] = cat.category_color;
}
// Get monthly breakdown for all categories
const monthlyRows = await db.select<
Array<{
month: string;
category_id: number | null;
category_name: string;
total: number;
}>
>(
`SELECT
strftime('%Y-%m', t.date) AS month,
t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name,
ABS(SUM(t.amount)) AS total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${whereSQL}
GROUP BY month, t.category_id
ORDER BY month ASC`,
params
);
// Build pivot data
const monthMap = new Map<string, CategoryOverTimeItem>();
let hasOther = false;
for (const row of monthlyRows) {
if (!monthMap.has(row.month)) {
monthMap.set(row.month, { month: row.month });
}
const item = monthMap.get(row.month)!;
if (topCategoryIds.has(row.category_id)) {
item[row.category_name] = ((item[row.category_name] as number) || 0) + row.total;
} else {
item["Other"] = ((item["Other"] as number) || 0) + row.total;
hasOther = true;
}
}
if (hasOther) {
colors["Other"] = "#9ca3af";
}
const categories = topCategories.map((c) => c.category_name);
if (hasOther) {
categories.push("Other");
}
return {
categories,
data: Array.from(monthMap.values()),
colors,
};
}

View file

@ -233,6 +233,27 @@ export interface RecentTransaction {
category_color: string | null;
}
// --- Report Types ---
export type ReportTab = "trends" | "byCategory" | "overTime";
export interface MonthlyTrendItem {
month: string; // "2025-01"
income: number;
expenses: number;
}
export interface CategoryOverTimeItem {
month: string;
[categoryName: string]: number | string;
}
export interface CategoryOverTimeData {
categories: string[];
data: CategoryOverTimeItem[];
colors: Record<string, string>;
}
export type ImportWizardStep =
| "source-list"
| "source-config"