Compare commits
No commits in common. "b9bdab8b8853fa8c5fcfa2e0d4b1b021852d5dd4" and "99fdf4f9ea9284795cfdb569f9a5d216d0047b6c" have entirely different histories.
b9bdab8b88
...
99fdf4f9ea
10 changed files with 11 additions and 219 deletions
|
|
@ -2,10 +2,6 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [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]
|
## [0.6.6]
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,6 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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]
|
## [0.6.6]
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -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)
|
- Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)
|
||||||
- Dépenses par catégorie : répartition des dépenses (graphique circulaire)
|
- 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 barres empilées), avec filtre par type (dépense/revenu/transfert)
|
- Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)
|
||||||
- Budget vs Réel : tableau comparatif mensuel et cumul annuel
|
- Budget vs Réel : tableau comparatif mensuel et cumul annuel
|
||||||
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
|
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
|
||||||
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
|
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Filter, Search } from "lucide-react";
|
import { Filter, Search } from "lucide-react";
|
||||||
import type { ImportSource } from "../../shared/types";
|
import type { ImportSource } from "../../shared/types";
|
||||||
import type { CategoryTypeFilter } from "../../hooks/useReports";
|
|
||||||
|
|
||||||
interface ReportFilterPanelProps {
|
interface ReportFilterPanelProps {
|
||||||
categories: { name: string; color: string }[];
|
categories: { name: string; color: string }[];
|
||||||
|
|
@ -12,8 +11,6 @@ interface ReportFilterPanelProps {
|
||||||
sources: ImportSource[];
|
sources: ImportSource[];
|
||||||
selectedSourceId: number | null;
|
selectedSourceId: number | null;
|
||||||
onSourceChange: (id: number | null) => void;
|
onSourceChange: (id: number | null) => void;
|
||||||
categoryType?: CategoryTypeFilter;
|
|
||||||
onCategoryTypeChange?: (type: CategoryTypeFilter) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportFilterPanel({
|
export default function ReportFilterPanel({
|
||||||
|
|
@ -24,8 +21,6 @@ export default function ReportFilterPanel({
|
||||||
sources,
|
sources,
|
||||||
selectedSourceId,
|
selectedSourceId,
|
||||||
onSourceChange,
|
onSourceChange,
|
||||||
categoryType,
|
|
||||||
onCategoryTypeChange,
|
|
||||||
}: ReportFilterPanelProps) {
|
}: ReportFilterPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
@ -62,32 +57,6 @@ export default function ReportFilterPanel({
|
||||||
</div>
|
</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 */}
|
{/* Category filter */}
|
||||||
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,12 @@ import { getExpensesByCategory } from "../services/dashboardService";
|
||||||
import { getBudgetVsActualData } from "../services/budgetService";
|
import { getBudgetVsActualData } from "../services/budgetService";
|
||||||
import { computeDateRange } from "../utils/dateRange";
|
import { computeDateRange } from "../utils/dateRange";
|
||||||
|
|
||||||
export type CategoryTypeFilter = "expense" | "income" | "transfer" | null;
|
|
||||||
|
|
||||||
interface ReportsState {
|
interface ReportsState {
|
||||||
tab: ReportTab;
|
tab: ReportTab;
|
||||||
period: DashboardPeriod;
|
period: DashboardPeriod;
|
||||||
customDateFrom: string;
|
customDateFrom: string;
|
||||||
customDateTo: string;
|
customDateTo: string;
|
||||||
sourceId: number | null;
|
sourceId: number | null;
|
||||||
categoryType: CategoryTypeFilter;
|
|
||||||
monthlyTrends: MonthlyTrendItem[];
|
monthlyTrends: MonthlyTrendItem[];
|
||||||
categorySpending: CategoryBreakdownItem[];
|
categorySpending: CategoryBreakdownItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
categoryOverTime: CategoryOverTimeData;
|
||||||
|
|
@ -48,8 +45,7 @@ type ReportsAction =
|
||||||
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
|
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
|
||||||
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
|
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
|
||||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
|
| { 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 now = new Date();
|
||||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
|
@ -61,7 +57,6 @@ const initialState: ReportsState = {
|
||||||
customDateFrom: monthStartStr,
|
customDateFrom: monthStartStr,
|
||||||
customDateTo: todayStr,
|
customDateTo: todayStr,
|
||||||
sourceId: null,
|
sourceId: null,
|
||||||
categoryType: "expense",
|
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categorySpending: [],
|
categorySpending: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
|
|
@ -102,8 +97,6 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||||
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||||
case "SET_SOURCE_ID":
|
case "SET_SOURCE_ID":
|
||||||
return { ...state, sourceId: action.payload };
|
return { ...state, sourceId: action.payload };
|
||||||
case "SET_CATEGORY_TYPE":
|
|
||||||
return { ...state, categoryType: action.payload };
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +115,6 @@ export function useReports() {
|
||||||
customTo?: string,
|
customTo?: string,
|
||||||
pivotCfg?: PivotConfig,
|
pivotCfg?: PivotConfig,
|
||||||
srcId?: number | null,
|
srcId?: number | null,
|
||||||
catType?: CategoryTypeFilter,
|
|
||||||
) => {
|
) => {
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
|
@ -146,7 +138,7 @@ export function useReports() {
|
||||||
}
|
}
|
||||||
case "overTime": {
|
case "overTime": {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined, catType ?? undefined);
|
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||||
break;
|
break;
|
||||||
|
|
@ -178,8 +170,8 @@ export function useReports() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType);
|
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, state.categoryType, fetchData]);
|
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, fetchData]);
|
||||||
|
|
||||||
const setTab = useCallback((tab: ReportTab) => {
|
const setTab = useCallback((tab: ReportTab) => {
|
||||||
dispatch({ type: "SET_TAB", payload: tab });
|
dispatch({ type: "SET_TAB", payload: tab });
|
||||||
|
|
@ -205,9 +197,5 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setCategoryType = useCallback((catType: CategoryTypeFilter) => {
|
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId };
|
||||||
dispatch({ type: "SET_CATEGORY_TYPE", payload: catType });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -370,8 +370,7 @@
|
||||||
"title": "Categories",
|
"title": "Categories",
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"none": "None",
|
"none": "None"
|
||||||
"allTypes": "All types"
|
|
||||||
},
|
},
|
||||||
"bva": {
|
"bva": {
|
||||||
"monthly": "Monthly",
|
"monthly": "Monthly",
|
||||||
|
|
|
||||||
|
|
@ -370,8 +370,7 @@
|
||||||
"title": "Catégories",
|
"title": "Catégories",
|
||||||
"search": "Rechercher...",
|
"search": "Rechercher...",
|
||||||
"all": "Toutes",
|
"all": "Toutes",
|
||||||
"none": "Aucune",
|
"none": "Aucune"
|
||||||
"allTypes": "Tous les types"
|
|
||||||
},
|
},
|
||||||
"bva": {
|
"bva": {
|
||||||
"monthly": "Mensuel",
|
"monthly": "Mensuel",
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual",
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType } = useReports();
|
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId } = useReports();
|
||||||
const [sources, setSources] = useState<ImportSource[]>([]);
|
const [sources, setSources] = useState<ImportSource[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -236,8 +236,6 @@ export default function ReportsPage() {
|
||||||
sources={sources}
|
sources={sources}
|
||||||
selectedSourceId={state.sourceId}
|
selectedSourceId={state.sourceId}
|
||||||
onSourceChange={setSourceId}
|
onSourceChange={setSourceId}
|
||||||
categoryType={state.tab === "overTime" ? state.categoryType : undefined}
|
|
||||||
onCategoryTypeChange={state.tab === "overTime" ? setCategoryType : undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -58,20 +58,13 @@ export async function getCategoryOverTime(
|
||||||
dateTo?: string,
|
dateTo?: string,
|
||||||
topN: number = 50,
|
topN: number = 50,
|
||||||
sourceId?: number,
|
sourceId?: number,
|
||||||
typeFilter?: "expense" | "income" | "transfer",
|
|
||||||
): Promise<CategoryOverTimeData> {
|
): Promise<CategoryOverTimeData> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
const whereClauses: string[] = [];
|
const whereClauses: string[] = ["t.amount < 0"];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (typeFilter) {
|
|
||||||
whereClauses.push(`COALESCE(c.type, 'expense') = $${paramIndex}`);
|
|
||||||
params.push(typeFilter);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
whereClauses.push(`t.date >= $${paramIndex}`);
|
whereClauses.push(`t.date >= $${paramIndex}`);
|
||||||
params.push(dateFrom);
|
params.push(dateFrom);
|
||||||
|
|
@ -88,7 +81,7 @@ export async function getCategoryOverTime(
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||||
|
|
||||||
// Get top N categories by total spend
|
// Get top N categories by total spend
|
||||||
const topCategories = await db.select<CategoryBreakdownItem[]>(
|
const topCategories = await db.select<CategoryBreakdownItem[]>(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue