From d6000e191f97c6b5e0575580578f159046a67c3d Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Tue, 10 Feb 2026 01:47:18 +0000 Subject: [PATCH] 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 --- src/components/reports/CategoryBarChart.tsx | 65 ++++++++ .../reports/CategoryOverTimeChart.tsx | 77 +++++++++ src/components/reports/MonthlyTrendsChart.tsx | 93 +++++++++++ src/hooks/useReports.ts | 147 ++++++++++++++++++ src/i18n/locales/en.json | 6 +- src/i18n/locales/fr.json | 6 +- src/pages/ReportsPage.tsx | 43 ++++- src/services/reportService.ts | 146 +++++++++++++++++ src/shared/types/index.ts | 21 +++ 9 files changed, 594 insertions(+), 10 deletions(-) create mode 100644 src/components/reports/CategoryBarChart.tsx create mode 100644 src/components/reports/CategoryOverTimeChart.tsx create mode 100644 src/components/reports/MonthlyTrendsChart.tsx create mode 100644 src/hooks/useReports.ts create mode 100644 src/services/reportService.ts 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")}

+
+
+

{t("reports.title")}

+
+ +
+ {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"