diff --git a/src/components/reports/CategoryBarChart.tsx b/src/components/reports/CategoryBarChart.tsx
new file mode 100644
index 0000000..d6195db
--- /dev/null
+++ b/src/components/reports/CategoryBarChart.tsx
@@ -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 (
+
+
{t("dashboard.noData")}
+
+ );
+ }
+
+ return (
+
+
+
+ eurFormatter(v)}
+ tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
+ stroke="var(--border)"
+ />
+
+ eurFormatter(value ?? 0)}
+ contentStyle={{
+ backgroundColor: "var(--card)",
+ border: "1px solid var(--border)",
+ borderRadius: "8px",
+ }}
+ />
+
+ {data.map((item, index) => (
+ |
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/reports/CategoryOverTimeChart.tsx b/src/components/reports/CategoryOverTimeChart.tsx
new file mode 100644
index 0000000..bcf7993
--- /dev/null
+++ b/src/components/reports/CategoryOverTimeChart.tsx
@@ -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 (
+
+
{t("dashboard.noData")}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ eurFormatter(v)}
+ tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
+ stroke="var(--border)"
+ width={80}
+ />
+ eurFormatter(value ?? 0)}
+ labelFormatter={(label) => formatMonth(String(label))}
+ contentStyle={{
+ backgroundColor: "var(--card)",
+ border: "1px solid var(--border)",
+ borderRadius: "8px",
+ }}
+ />
+
+ {data.categories.map((name) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/reports/MonthlyTrendsChart.tsx b/src/components/reports/MonthlyTrendsChart.tsx
new file mode 100644
index 0000000..3d7cd44
--- /dev/null
+++ b/src/components/reports/MonthlyTrendsChart.tsx
@@ -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 (
+
+
{t("dashboard.noData")}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ eurFormatter(v)}
+ tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
+ stroke="var(--border)"
+ width={80}
+ />
+ eurFormatter(value ?? 0)}
+ labelFormatter={(label) => formatMonth(String(label))}
+ contentStyle={{
+ backgroundColor: "var(--card)",
+ border: "1px solid var(--border)",
+ borderRadius: "8px",
+ }}
+ />
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/useReports.ts b/src/hooks/useReports.ts
new file mode 100644
index 0000000..06073c1
--- /dev/null
+++ b/src/hooks/useReports.ts
@@ -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 };
+}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 0687271..af78788 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -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": {
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 855f020..94de41d 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -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": {
diff --git a/src/pages/ReportsPage.tsx b/src/pages/ReportsPage.tsx
index 98f0324..5e9a7c5 100644
--- a/src/pages/ReportsPage.tsx
+++ b/src/pages/ReportsPage.tsx
@@ -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 (
-
-
{t("reports.title")}
-
-
{t("common.noResults")}
+
+
+
+
+ {TABS.map((tab) => (
+
+ ))}
+
+
+ {state.error && (
+
+ {state.error}
+
+ )}
+
+ {state.tab === "trends" &&
}
+ {state.tab === "byCategory" &&
}
+ {state.tab === "overTime" &&
}
);
}
diff --git a/src/services/reportService.ts b/src/services/reportService.ts
new file mode 100644
index 0000000..1248d03
--- /dev/null
+++ b/src/services/reportService.ts
@@ -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
{
+ 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(
+ `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 {
+ 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(
+ `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 = {};
+ 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();
+ 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,
+ };
+}
diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts
index da13e77..aa08639 100644
--- a/src/shared/types/index.ts
+++ b/src/shared/types/index.ts
@@ -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;
+}
+
export type ImportWizardStep =
| "source-list"
| "source-config"