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")} +
+
+ +
+
+ )} + {/* Category filter */} {categories.length > 0 &&
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(