diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md
index a3d909f..df06301 100644
--- a/CHANGELOG.fr.md
+++ b/CHANGELOG.fr.md
@@ -2,6 +2,10 @@
## [Non publié]
+### Modifié
+- Rapport Catégorie dans le temps : suppression du filtre codé en dur sur les dépenses, ajout d'un sélecteur de type avec dépense par défaut (#41)
+- Rapport Catégorie dans le temps : ajout d'un filtre par type (dépense/revenu/transfert) dans le panneau de filtre à droite (#41)
+
## [0.6.6]
### Modifié
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c15ed6..e6ba290 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+### Changed
+- Category Over Time report: removed hard-coded expense-only filter, added type selector defaulting to expense (#41)
+- Category Over Time report: added type filter (expense/income/transfer) in the right filter panel (#41)
+
## [0.6.6]
### Changed
diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md
index 23463d0..4053fee 100644
--- a/docs/guide-utilisateur.md
+++ b/docs/guide-utilisateur.md
@@ -252,7 +252,7 @@ Visualisez vos données financières avec des graphiques interactifs et comparez
- Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)
- Dépenses par catégorie : répartition des dépenses (graphique circulaire)
-- Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)
+- Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en barres empilées), avec filtre par type (dépense/revenu/transfert)
- Budget vs Réel : tableau comparatif mensuel et cumul annuel
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
diff --git a/src/components/reports/ReportFilterPanel.tsx b/src/components/reports/ReportFilterPanel.tsx
index ec0aea0..3421185 100644
--- a/src/components/reports/ReportFilterPanel.tsx
+++ b/src/components/reports/ReportFilterPanel.tsx
@@ -2,6 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Filter, Search } from "lucide-react";
import type { ImportSource } from "../../shared/types";
+import type { CategoryTypeFilter } from "../../hooks/useReports";
interface ReportFilterPanelProps {
categories: { name: string; color: string }[];
@@ -11,6 +12,8 @@ interface ReportFilterPanelProps {
sources: ImportSource[];
selectedSourceId: number | null;
onSourceChange: (id: number | null) => void;
+ categoryType?: CategoryTypeFilter;
+ onCategoryTypeChange?: (type: CategoryTypeFilter) => void;
}
export default function ReportFilterPanel({
@@ -21,6 +24,8 @@ export default function ReportFilterPanel({
sources,
selectedSourceId,
onSourceChange,
+ categoryType,
+ onCategoryTypeChange,
}: ReportFilterPanelProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
@@ -57,6 +62,32 @@ export default function ReportFilterPanel({
)}
+ {/* Type filter */}
+ {onCategoryTypeChange && (
+
+
+
+ {t("categories.type")}
+
+
+ {
+ const v = e.target.value;
+ const valid: CategoryTypeFilter[] = ["expense", "income", "transfer"];
+ onCategoryTypeChange(valid.includes(v as CategoryTypeFilter) ? (v as CategoryTypeFilter) : null);
+ }}
+ className="w-full px-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
+ >
+ {t("reports.filters.allTypes")}
+ {t("categories.expense")}
+ {t("categories.income")}
+ {t("categories.transfer")}
+
+
+
+ )}
+
{/* Category filter */}
{categories.length > 0 &&
{
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
@@ -138,7 +146,7 @@ export function useReports() {
}
case "overTime": {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
- const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined);
+ const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined, catType ?? undefined);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
break;
@@ -170,8 +178,8 @@ export function useReports() {
}, []);
useEffect(() => {
- fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId);
- }, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, fetchData]);
+ fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType);
+ }, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType, fetchData]);
const setTab = useCallback((tab: ReportTab) => {
dispatch({ type: "SET_TAB", payload: tab });
@@ -197,5 +205,9 @@ export function useReports() {
dispatch({ type: "SET_SOURCE_ID", payload: id });
}, []);
- return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId };
+ const setCategoryType = useCallback((catType: CategoryTypeFilter) => {
+ dispatch({ type: "SET_CATEGORY_TYPE", payload: catType });
+ }, []);
+
+ return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType };
}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 7de1e70..e7b4011 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -370,7 +370,8 @@
"title": "Categories",
"search": "Search...",
"all": "All",
- "none": "None"
+ "none": "None",
+ "allTypes": "All types"
},
"bva": {
"monthly": "Monthly",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 0f32f2c..af3cba4 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -370,7 +370,8 @@
"title": "Catégories",
"search": "Rechercher...",
"all": "Toutes",
- "none": "Aucune"
+ "none": "Aucune",
+ "allTypes": "Tous les types"
},
"bva": {
"monthly": "Mensuel",
diff --git a/src/pages/ReportsPage.tsx b/src/pages/ReportsPage.tsx
index da975bd..da4dc90 100644
--- a/src/pages/ReportsPage.tsx
+++ b/src/pages/ReportsPage.tsx
@@ -22,7 +22,7 @@ const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual",
export default function ReportsPage() {
const { t, i18n } = useTranslation();
- const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId } = useReports();
+ const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType } = useReports();
const [sources, setSources] = useState([]);
useEffect(() => {
@@ -236,6 +236,8 @@ export default function ReportsPage() {
sources={sources}
selectedSourceId={state.sourceId}
onSourceChange={setSourceId}
+ categoryType={state.tab === "overTime" ? state.categoryType : undefined}
+ onCategoryTypeChange={state.tab === "overTime" ? setCategoryType : undefined}
/>
)}
diff --git a/src/services/reportService.test.ts b/src/services/reportService.test.ts
new file mode 100644
index 0000000..5b5fa23
--- /dev/null
+++ b/src/services/reportService.test.ts
@@ -0,0 +1,146 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { getCategoryOverTime } from "./reportService";
+
+// Mock the db module
+vi.mock("./db", () => ({
+ getDb: vi.fn(),
+}));
+
+import { getDb } from "./db";
+
+const mockSelect = vi.fn();
+const mockDb = { select: mockSelect };
+
+beforeEach(() => {
+ vi.mocked(getDb).mockResolvedValue(mockDb as never);
+ mockSelect.mockReset();
+});
+
+describe("getCategoryOverTime", () => {
+ it("builds query without WHERE clause when no filters are provided", async () => {
+ // First call: top categories, second call: monthly breakdown
+ mockSelect
+ .mockResolvedValueOnce([]) // topCategories
+ .mockResolvedValueOnce([]); // monthlyRows
+
+ await getCategoryOverTime();
+
+ const topCatSQL = mockSelect.mock.calls[0][0] as string;
+ // No WHERE clause should appear (no filters at all)
+ expect(topCatSQL).not.toContain("WHERE");
+ });
+
+ it("applies typeFilter via category type when provided", async () => {
+ mockSelect
+ .mockResolvedValueOnce([]) // topCategories
+ .mockResolvedValueOnce([]); // monthlyRows
+
+ await getCategoryOverTime(undefined, undefined, 50, undefined, "expense");
+
+ const topCatSQL = mockSelect.mock.calls[0][0] as string;
+ const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
+ expect(topCatSQL).toContain("COALESCE(c.type, 'expense')");
+ expect(topCatSQL).toContain("WHERE");
+ expect(topCatParams[0]).toBe("expense");
+ });
+
+ it("applies income typeFilter correctly", async () => {
+ mockSelect
+ .mockResolvedValueOnce([]) // topCategories
+ .mockResolvedValueOnce([]); // monthlyRows
+
+ await getCategoryOverTime("2025-01-01", "2025-06-30", 50, undefined, "income");
+
+ const topCatSQL = mockSelect.mock.calls[0][0] as string;
+ const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
+ expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
+ expect(topCatParams[0]).toBe("income");
+ // Date params follow
+ expect(topCatParams[1]).toBe("2025-01-01");
+ expect(topCatParams[2]).toBe("2025-06-30");
+ });
+
+ it("applies date range filters", async () => {
+ mockSelect
+ .mockResolvedValueOnce([]) // topCategories
+ .mockResolvedValueOnce([]); // monthlyRows
+
+ await getCategoryOverTime("2025-01-01", "2025-12-31");
+
+ const topCatSQL = mockSelect.mock.calls[0][0] as string;
+ const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
+ expect(topCatSQL).toContain("t.date >= $1");
+ expect(topCatSQL).toContain("t.date <= $2");
+ expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
+ });
+
+ it("applies sourceId filter", async () => {
+ mockSelect
+ .mockResolvedValueOnce([]) // topCategories
+ .mockResolvedValueOnce([]); // monthlyRows
+
+ await getCategoryOverTime(undefined, undefined, 50, 3);
+
+ const topCatSQL = mockSelect.mock.calls[0][0] as string;
+ const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
+ expect(topCatSQL).toContain("t.source_id");
+ expect(topCatParams[0]).toBe(3);
+ });
+
+ it("combines typeFilter, date range, and sourceId", async () => {
+ mockSelect
+ .mockResolvedValueOnce([]) // topCategories
+ .mockResolvedValueOnce([]); // monthlyRows
+
+ await getCategoryOverTime("2025-01-01", "2025-06-30", 10, 2, "expense");
+
+ const topCatSQL = mockSelect.mock.calls[0][0] as string;
+ const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
+ expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
+ expect(topCatSQL).toContain("t.date >= $2");
+ expect(topCatSQL).toContain("t.date <= $3");
+ expect(topCatSQL).toContain("t.source_id = $4");
+ expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
+ });
+
+ it("returns correct structure with categories, data, colors, and categoryIds", async () => {
+ mockSelect
+ .mockResolvedValueOnce([
+ { category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
+ { category_id: 2, category_name: "Transport", category_color: "#00ff00", total: 200 },
+ ])
+ .mockResolvedValueOnce([
+ { month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
+ { month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
+ { month: "2025-02", category_id: 1, category_name: "Food", total: 200 },
+ { month: "2025-02", category_id: 2, category_name: "Transport", total: 100 },
+ ]);
+
+ const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense");
+
+ expect(result.categories).toEqual(["Food", "Transport"]);
+ expect(result.colors).toEqual({ Food: "#ff0000", Transport: "#00ff00" });
+ expect(result.categoryIds).toEqual({ Food: 1, Transport: 2 });
+ expect(result.data).toHaveLength(2);
+ expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Transport: 100 });
+ expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Transport: 100 });
+ });
+
+ it("groups non-top-N categories into Other", async () => {
+ mockSelect
+ .mockResolvedValueOnce([
+ { category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
+ ])
+ .mockResolvedValueOnce([
+ { month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
+ { month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
+ { month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 },
+ ]);
+
+ const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense");
+
+ expect(result.categories).toEqual(["Food", "Other"]);
+ expect(result.colors["Other"]).toBe("#9ca3af");
+ expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
+ });
+});
diff --git a/src/services/reportService.ts b/src/services/reportService.ts
index c455f82..20affe8 100644
--- a/src/services/reportService.ts
+++ b/src/services/reportService.ts
@@ -58,13 +58,20 @@ export async function getCategoryOverTime(
dateTo?: string,
topN: number = 50,
sourceId?: number,
+ typeFilter?: "expense" | "income" | "transfer",
): Promise {
const db = await getDb();
- const whereClauses: string[] = ["t.amount < 0"];
+ const whereClauses: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
+ if (typeFilter) {
+ whereClauses.push(`COALESCE(c.type, 'expense') = $${paramIndex}`);
+ params.push(typeFilter);
+ paramIndex++;
+ }
+
if (dateFrom) {
whereClauses.push(`t.date >= $${paramIndex}`);
params.push(dateFrom);
@@ -81,7 +88,7 @@ export async function getCategoryOverTime(
paramIndex++;
}
- const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
+ const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
// Get top N categories by total spend
const topCategories = await db.select(