Merge pull request 'fix: remove expense filter from Category Over Time report (#41)' (#42) from fix/simpl-resultat-41-category-time-report-filter into main

This commit is contained in:
maximus 2026-03-30 01:14:11 +00:00
commit b9bdab8b88
10 changed files with 219 additions and 11 deletions

View file

@ -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é

View file

@ -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

View file

@ -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

View file

@ -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({
</div>
)}
{/* Type filter */}
{onCategoryTypeChange && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="px-3 py-2.5 text-sm font-medium text-[var(--foreground)] flex items-center gap-2">
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("categories.type")}
</div>
<div className="border-t border-[var(--border)] px-2 py-2">
<select
value={categoryType ?? ""}
onChange={(e) => {
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)]"
>
<option value="">{t("reports.filters.allTypes")}</option>
<option value="expense">{t("categories.expense")}</option>
<option value="income">{t("categories.income")}</option>
<option value="transfer">{t("categories.transfer")}</option>
</select>
</div>
</div>
)}
{/* Category filter */}
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<button

View file

@ -14,12 +14,15 @@ import { getExpensesByCategory } from "../services/dashboardService";
import { getBudgetVsActualData } from "../services/budgetService";
import { computeDateRange } from "../utils/dateRange";
export type CategoryTypeFilter = "expense" | "income" | "transfer" | null;
interface ReportsState {
tab: ReportTab;
period: DashboardPeriod;
customDateFrom: string;
customDateTo: string;
sourceId: number | null;
categoryType: CategoryTypeFilter;
monthlyTrends: MonthlyTrendItem[];
categorySpending: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
@ -45,7 +48,8 @@ type ReportsAction =
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
| { type: "SET_SOURCE_ID"; payload: number | null };
| { type: "SET_SOURCE_ID"; payload: number | null }
| { type: "SET_CATEGORY_TYPE"; payload: CategoryTypeFilter };
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
@ -57,6 +61,7 @@ const initialState: ReportsState = {
customDateFrom: monthStartStr,
customDateTo: todayStr,
sourceId: null,
categoryType: "expense",
monthlyTrends: [],
categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
@ -97,6 +102,8 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
case "SET_SOURCE_ID":
return { ...state, sourceId: action.payload };
case "SET_CATEGORY_TYPE":
return { ...state, categoryType: action.payload };
default:
return state;
}
@ -115,6 +122,7 @@ export function useReports() {
customTo?: string,
pivotCfg?: PivotConfig,
srcId?: number | null,
catType?: CategoryTypeFilter,
) => {
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 };
}

View file

@ -370,7 +370,8 @@
"title": "Categories",
"search": "Search...",
"all": "All",
"none": "None"
"none": "None",
"allTypes": "All types"
},
"bva": {
"monthly": "Monthly",

View file

@ -370,7 +370,8 @@
"title": "Catégories",
"search": "Rechercher...",
"all": "Toutes",
"none": "Aucune"
"none": "Aucune",
"allTypes": "Tous les types"
},
"bva": {
"monthly": "Mensuel",

View file

@ -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<ImportSource[]>([]);
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}
/>
)}
</div>

View file

@ -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 });
});
});

View file

@ -58,13 +58,20 @@ export async function getCategoryOverTime(
dateTo?: string,
topN: number = 50,
sourceId?: number,
typeFilter?: "expense" | "income" | "transfer",
): Promise<CategoryOverTimeData> {
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<CategoryBreakdownItem[]>(