From 62430c63dc312583cc7b18c9881d7706ed68116e Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 14 Apr 2026 15:09:17 -0400 Subject: [PATCH] feat: category zoom + secure AddKeywordDialog with context menu (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service layer - New reportService.getCategoryZoom(categoryId, from, to, includeChildren) — bounded recursive CTE (WHERE ct.depth < 5) protects against parent_id cycles; direct-only path skips the CTE; every binding is parameterised - Export categorizationService helpers normalizeDescription / buildKeywordRegex / compileKeywords so the dialog can reuse them - New validateKeyword() enforces 2–64 char length (anti-ReDoS), whitespace-only rejection, returns discriminated result - New previewKeywordMatches(keyword, limit=50) uses parameterised LIKE + regex filter in memory; caps candidate scan at 1000 rows to protect against catastrophic backtracking - New applyKeywordWithReassignment wraps INSERT (or UPDATE-reassign) + per-transaction UPDATEs in an explicit BEGIN/COMMIT/ROLLBACK; rejects existing keyword reassignment unless allowReplaceExisting is set; never recategorises historical transactions beyond the ids the caller supplied Hook - Flesh out useCategoryZoom with reducer + fetch + refetch hook Components (flat under src/components/reports/) - CategoryZoomHeader — category combobox + include/direct toggle - CategoryDonutChart — template'd from dashboard/CategoryPieChart with innerRadius=55 and ChartPatternDefs for SVG patterns - CategoryEvolutionChart — AreaChart with Intl-formatted axes - CategoryTransactionsTable — sortable table with per-row onContextMenu → ContextMenu → "Add as keyword" action AddKeywordDialog — src/components/categories/AddKeywordDialog.tsx - Lives in categories/ (not reports/) because it is a keyword-editing widget consumed from multiple sections - Renders transaction descriptions as React children only (no dangerouslySetInnerHTML); CSS truncation (CWE-79 safe) - Per-row checkboxes for applying recategorisation; cap visible rows at 50; explicit opt-in checkbox to extend to N-50 non-displayed matches - Surfaces apply errors + "keyword already exists" replace prompt - Re-runs category zoom fetch on success so the zoomed view updates Page - ReportsCategoryPage composes header + donut + evolution + transactions + AddKeywordDialog, fetches from useCategoryZoom, preserves query string for back navigation i18n - New keys reports.category.* and reports.keyword.* in FR + EN - Plural forms use i18next v25 _one / _other suffixes (nMatches) Tests - 3 reportService tests cover bounded CTE, cycle-guard depth check, direct-only fallthrough - New categorizationService.test.ts: 13 tests covering validation boundaries, parameterised LIKE preview, regex word-boundary filter, explicit BEGIN/COMMIT wrapping, rollback on failure, existing keyword reassignment policy - 62 total tests passing Fixes #74 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../categories/AddKeywordDialog.tsx | 278 ++++++++++++++++++ src/components/reports/CategoryDonutChart.tsx | 72 +++++ .../reports/CategoryEvolutionChart.tsx | 82 ++++++ .../reports/CategoryTransactionsTable.tsx | 135 +++++++++ src/components/reports/CategoryZoomHeader.tsx | 72 +++++ src/hooks/useCategoryZoom.ts | 40 ++- src/i18n/locales/en.json | 20 ++ src/i18n/locales/fr.json | 20 ++ src/pages/ReportsCategoryPage.tsx | 111 ++++++- src/services/categorizationService.test.ts | 174 +++++++++++ src/services/categorizationService.ts | 167 ++++++++++- src/services/reportService.test.ts | 56 ++++ src/services/reportService.ts | 125 ++++++++ src/shared/types/index.ts | 19 ++ 14 files changed, 1359 insertions(+), 12 deletions(-) create mode 100644 src/components/categories/AddKeywordDialog.tsx create mode 100644 src/components/reports/CategoryDonutChart.tsx create mode 100644 src/components/reports/CategoryEvolutionChart.tsx create mode 100644 src/components/reports/CategoryTransactionsTable.tsx create mode 100644 src/components/reports/CategoryZoomHeader.tsx create mode 100644 src/services/categorizationService.test.ts diff --git a/src/components/categories/AddKeywordDialog.tsx b/src/components/categories/AddKeywordDialog.tsx new file mode 100644 index 0000000..dcaf3dd --- /dev/null +++ b/src/components/categories/AddKeywordDialog.tsx @@ -0,0 +1,278 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { RecentTransaction } from "../../shared/types"; +import { + KEYWORD_MAX_LENGTH, + KEYWORD_MIN_LENGTH, + KEYWORD_PREVIEW_LIMIT, + applyKeywordWithReassignment, + previewKeywordMatches, + validateKeyword, +} from "../../services/categorizationService"; +import { getAllCategoriesWithCounts } from "../../services/categoryService"; + +interface CategoryOption { + id: number; + name: string; +} + +export interface AddKeywordDialogProps { + initialKeyword: string; + initialCategoryId?: number | null; + onClose: () => void; + onApplied?: () => void; +} + +type PreviewState = + | { kind: "idle" } + | { kind: "loading" } + | { kind: "ready"; visible: RecentTransaction[]; totalMatches: number } + | { kind: "error"; message: string }; + +export default function AddKeywordDialog({ + initialKeyword, + initialCategoryId = null, + onClose, + onApplied, +}: AddKeywordDialogProps) { + const { t } = useTranslation(); + const [keyword, setKeyword] = useState(initialKeyword); + const [categoryId, setCategoryId] = useState(initialCategoryId); + const [categories, setCategories] = useState([]); + const [checked, setChecked] = useState>(new Set()); + const [applyToHidden, setApplyToHidden] = useState(false); + const [preview, setPreview] = useState({ kind: "idle" }); + const [isApplying, setIsApplying] = useState(false); + const [applyError, setApplyError] = useState(null); + const [replacePrompt, setReplacePrompt] = useState(null); + const [allowReplaceExisting, setAllowReplaceExisting] = useState(false); + + const validation = useMemo(() => validateKeyword(keyword), [keyword]); + + useEffect(() => { + getAllCategoriesWithCounts() + .then((rows) => setCategories(rows.map((r) => ({ id: r.id, name: r.name })))) + .catch(() => setCategories([])); + }, []); + + useEffect(() => { + if (!validation.ok) { + setPreview({ kind: "idle" }); + return; + } + let cancelled = false; + setPreview({ kind: "loading" }); + previewKeywordMatches(validation.value, KEYWORD_PREVIEW_LIMIT) + .then(({ visible, totalMatches }) => { + if (cancelled) return; + setPreview({ kind: "ready", visible, totalMatches }); + setChecked(new Set(visible.map((tx) => tx.id))); + }) + .catch((e: unknown) => { + if (cancelled) return; + setPreview({ kind: "error", message: e instanceof Error ? e.message : String(e) }); + }); + return () => { + cancelled = true; + }; + }, [validation]); + + const toggleRow = (id: number) => { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const canApply = + validation.ok && + categoryId !== null && + preview.kind === "ready" && + !isApplying && + checked.size > 0; + + const handleApply = async () => { + if (!validation.ok || categoryId === null) return; + setIsApplying(true); + setApplyError(null); + try { + await applyKeywordWithReassignment({ + keyword: validation.value, + categoryId, + transactionIds: Array.from(checked), + allowReplaceExisting, + }); + if (onApplied) onApplied(); + onClose(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg === "keyword_already_exists") { + setReplacePrompt(t("reports.keyword.alreadyExists", { category: "" })); + } else { + setApplyError(msg); + } + } finally { + setIsApplying(false); + } + }; + + const preventBackdropClose = (e: React.MouseEvent) => e.stopPropagation(); + + return ( +
+
+
+

{t("reports.keyword.dialogTitle")}

+
+ +
+ + + + +
+

+ {t("reports.keyword.willMatch")} +

+ {preview.kind === "idle" && ( +

+ )} + {preview.kind === "loading" && ( +

{t("common.loading")}

+ )} + {preview.kind === "error" && ( +

{preview.message}

+ )} + {preview.kind === "ready" && ( + <> +

+ {t("reports.keyword.nMatches", { count: preview.totalMatches })} +

+
    + {preview.visible.map((tx) => ( +
  • + toggleRow(tx.id)} + className="accent-[var(--primary)]" + /> + + {tx.date} + + {tx.description} + + {new Intl.NumberFormat("en-CA", { + style: "currency", + currency: "CAD", + }).format(tx.amount)} + +
  • + ))} +
+ {preview.totalMatches > preview.visible.length && ( + + )} + + )} +
+ + {replacePrompt && ( +
+

{replacePrompt}

+ +
+ )} + + {applyError && ( +
+ {applyError} +
+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/reports/CategoryDonutChart.tsx b/src/components/reports/CategoryDonutChart.tsx new file mode 100644 index 0000000..6b194e3 --- /dev/null +++ b/src/components/reports/CategoryDonutChart.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from "react-i18next"; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts"; +import type { CategoryZoomChild } from "../../shared/types"; +import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns"; + +export interface CategoryDonutChartProps { + byChild: CategoryZoomChild[]; + centerLabel: string; + centerValue: string; +} + +export default function CategoryDonutChart({ + byChild, + centerLabel, + centerValue, +}: CategoryDonutChartProps) { + const { t } = useTranslation(); + + if (byChild.length === 0) { + return ( +
+ {t("reports.empty.noData")} +
+ ); + } + + return ( +
+ + + ({ color: c.categoryColor, index }))} + /> + + {byChild.map((entry, index) => ( + + ))} + + + typeof value === "number" + ? new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(value) + : String(value) + } + contentStyle={{ + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "0.5rem", + }} + /> + + +
+ {centerLabel} + {centerValue} +
+
+ ); +} diff --git a/src/components/reports/CategoryEvolutionChart.tsx b/src/components/reports/CategoryEvolutionChart.tsx new file mode 100644 index 0000000..aefcc5a --- /dev/null +++ b/src/components/reports/CategoryEvolutionChart.tsx @@ -0,0 +1,82 @@ +import { useTranslation } from "react-i18next"; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import type { CategoryZoomEvolutionPoint } from "../../shared/types"; + +export interface CategoryEvolutionChartProps { + data: CategoryZoomEvolutionPoint[]; + color?: string; +} + +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" }); +} + +export default function CategoryEvolutionChart({ + data, + color = "var(--primary)", +}: CategoryEvolutionChartProps) { + const { t, i18n } = useTranslation(); + + if (data.length === 0) { + return ( +
+ {t("reports.empty.noData")} +
+ ); + } + + return ( +
+

{t("reports.category.evolution")}

+ + + + + + new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", { + style: "currency", + currency: "CAD", + maximumFractionDigits: 0, + }).format(v) + } + /> + + typeof value === "number" + ? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", { + style: "currency", + currency: "CAD", + }).format(value) + : String(value) + } + labelFormatter={(label) => (typeof label === "string" ? formatMonth(label) : String(label ?? ""))} + contentStyle={{ + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "0.5rem", + }} + /> + + + +
+ ); +} diff --git a/src/components/reports/CategoryTransactionsTable.tsx b/src/components/reports/CategoryTransactionsTable.tsx new file mode 100644 index 0000000..404ebf9 --- /dev/null +++ b/src/components/reports/CategoryTransactionsTable.tsx @@ -0,0 +1,135 @@ +import { useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Tag } from "lucide-react"; +import type { RecentTransaction } from "../../shared/types"; +import ContextMenu from "../shared/ContextMenu"; + +export interface CategoryTransactionsTableProps { + transactions: RecentTransaction[]; + onAddKeyword?: (transaction: RecentTransaction) => void; +} + +type SortKey = "date" | "description" | "amount"; + +function formatAmount(amount: number, language: string): string { + return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { + style: "currency", + currency: "CAD", + }).format(amount); +} + +export default function CategoryTransactionsTable({ + transactions, + onAddKeyword, +}: CategoryTransactionsTableProps) { + const { t, i18n } = useTranslation(); + const [sortKey, setSortKey] = useState("amount"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null); + + const sorted = useMemo(() => { + const copy = [...transactions]; + copy.sort((a, b) => { + let cmp = 0; + switch (sortKey) { + case "date": + cmp = a.date.localeCompare(b.date); + break; + case "description": + cmp = a.description.localeCompare(b.description); + break; + case "amount": + cmp = Math.abs(a.amount) - Math.abs(b.amount); + break; + } + return sortDir === "asc" ? cmp : -cmp; + }); + return copy; + }, [transactions, sortKey, sortDir]); + + function toggleSort(key: SortKey) { + if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc")); + else { + setSortKey(key); + setSortDir("desc"); + } + } + + const handleContextMenu = (e: React.MouseEvent, tx: RecentTransaction) => { + if (!onAddKeyword) return; + e.preventDefault(); + setMenu({ x: e.clientX, y: e.clientY, tx }); + }; + + const header = (key: SortKey, label: string, align: "left" | "right") => ( + toggleSort(key)} + className={`${align === "right" ? "text-right" : "text-left"} px-3 py-2 font-medium text-[var(--muted-foreground)] cursor-pointer hover:text-[var(--foreground)] select-none`} + > + {label} + {sortKey === key && {sortDir === "asc" ? "▲" : "▼"}} + + ); + + return ( +
+
+ + + + {header("date", t("transactions.date"), "left")} + {header("description", t("transactions.description"), "left")} + {header("amount", t("transactions.amount"), "right")} + + + + {sorted.length === 0 ? ( + + + + ) : ( + sorted.map((tx) => ( + handleContextMenu(e, tx)} + className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40" + > + + + + + )) + )} + +
+ {t("reports.empty.noData")} +
{tx.date}{tx.description}= 0 ? "var(--positive, #10b981)" : "var(--foreground)", + }} + > + {formatAmount(tx.amount, i18n.language)} +
+
+ + {menu && onAddKeyword && ( + setMenu(null)} + items={[ + { + icon: , + label: t("reports.keyword.addFromTransaction"), + onClick: () => { + onAddKeyword(menu.tx); + }, + }, + ]} + /> + )} +
+ ); +} diff --git a/src/components/reports/CategoryZoomHeader.tsx b/src/components/reports/CategoryZoomHeader.tsx new file mode 100644 index 0000000..78bdc3d --- /dev/null +++ b/src/components/reports/CategoryZoomHeader.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { getAllCategoriesWithCounts } from "../../services/categoryService"; + +interface CategoryOption { + id: number; + name: string; + color: string | null; + parent_id: number | null; +} + +export interface CategoryZoomHeaderProps { + categoryId: number | null; + includeSubcategories: boolean; + onCategoryChange: (id: number | null) => void; + onIncludeSubcategoriesChange: (flag: boolean) => void; +} + +export default function CategoryZoomHeader({ + categoryId, + includeSubcategories, + onCategoryChange, + onIncludeSubcategoriesChange, +}: CategoryZoomHeaderProps) { + const { t } = useTranslation(); + const [categories, setCategories] = useState([]); + + useEffect(() => { + getAllCategoriesWithCounts() + .then((rows) => + setCategories( + rows.map((r) => ({ id: r.id, name: r.name, color: r.color, parent_id: r.parent_id })), + ), + ) + .catch(() => setCategories([])); + }, []); + + return ( +
+ + +
+ ); +} diff --git a/src/hooks/useCategoryZoom.ts b/src/hooks/useCategoryZoom.ts index fcc5e00..e888b5b 100644 --- a/src/hooks/useCategoryZoom.ts +++ b/src/hooks/useCategoryZoom.ts @@ -1,9 +1,12 @@ -import { useReducer, useCallback } from "react"; +import { useReducer, useEffect, useRef, useCallback } from "react"; +import type { CategoryZoomData } from "../shared/types"; +import { getCategoryZoom } from "../services/reportService"; import { useReportsPeriod } from "./useReportsPeriod"; interface State { zoomedCategoryId: number | null; rollupChildren: boolean; + data: CategoryZoomData | null; isLoading: boolean; error: string | null; } @@ -12,11 +15,13 @@ type Action = | { type: "SET_CATEGORY"; payload: number | null } | { type: "TOGGLE_ROLLUP"; payload: boolean } | { type: "SET_LOADING"; payload: boolean } + | { type: "SET_DATA"; payload: CategoryZoomData } | { type: "SET_ERROR"; payload: string }; const initialState: State = { zoomedCategoryId: null, rollupChildren: true, + data: null, isLoading: false, error: null, }; @@ -24,11 +29,13 @@ const initialState: State = { function reducer(state: State, action: Action): State { switch (action.type) { case "SET_CATEGORY": - return { ...state, zoomedCategoryId: action.payload }; + return { ...state, zoomedCategoryId: action.payload, data: null }; case "TOGGLE_ROLLUP": return { ...state, rollupChildren: action.payload }; case "SET_LOADING": return { ...state, isLoading: action.payload }; + case "SET_DATA": + return { ...state, data: action.payload, isLoading: false, error: null }; case "SET_ERROR": return { ...state, error: action.payload, isLoading: false }; default: @@ -39,6 +46,28 @@ function reducer(state: State, action: Action): State { export function useCategoryZoom() { const { from, to } = useReportsPeriod(); const [state, dispatch] = useReducer(reducer, initialState); + const fetchIdRef = useRef(0); + + const fetch = useCallback( + async (categoryId: number | null, includeChildren: boolean, dateFrom: string, dateTo: string) => { + if (categoryId === null) return; + const id = ++fetchIdRef.current; + dispatch({ type: "SET_LOADING", payload: true }); + try { + const data = await getCategoryZoom(categoryId, dateFrom, dateTo, includeChildren); + if (id !== fetchIdRef.current) return; + dispatch({ type: "SET_DATA", payload: data }); + } catch (e) { + if (id !== fetchIdRef.current) return; + dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); + } + }, + [], + ); + + useEffect(() => { + fetch(state.zoomedCategoryId, state.rollupChildren, from, to); + }, [fetch, state.zoomedCategoryId, state.rollupChildren, from, to]); const setCategory = useCallback((id: number | null) => { dispatch({ type: "SET_CATEGORY", payload: id }); @@ -48,6 +77,9 @@ export function useCategoryZoom() { dispatch({ type: "TOGGLE_ROLLUP", payload: flag }); }, []); - // Real fetch lives in Issue #74 (getCategoryZoom with recursive CTE). - return { ...state, setCategory, setRollupChildren, from, to }; + const refetch = useCallback(() => { + fetch(state.zoomedCategoryId, state.rollupChildren, from, to); + }, [fetch, state.zoomedCategoryId, state.rollupChildren, from, to]); + + return { ...state, setCategory, setRollupChildren, refetch, from, to }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b3e18e9..8fbe755 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -408,6 +408,26 @@ "modeYoY": "Year vs previous year", "modeBudget": "Actual vs budget" }, + "category": { + "selectCategory": "Select a category", + "includeSubcategories": "Include subcategories", + "directOnly": "Direct only", + "breakdown": "Total", + "evolution": "Evolution", + "transactions": "Transactions" + }, + "keyword": { + "addFromTransaction": "Add as keyword", + "dialogTitle": "New keyword", + "willMatch": "Will also match", + "nMatches_one": "{{count}} transaction matched", + "nMatches_other": "{{count}} transactions matched", + "applyAndRecategorize": "Apply and recategorize", + "applyToHidden": "Also apply to {{count}} non-displayed transactions", + "tooShort": "Minimum {{min}} characters", + "tooLong": "Maximum {{max}} characters", + "alreadyExists": "This keyword already exists for another category. Reassign?" + }, "highlights": { "balances": "Balances", "netBalanceCurrent": "This month", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2efe87c..53ce475 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -408,6 +408,26 @@ "modeYoY": "Année vs année précédente", "modeBudget": "Réel vs budget" }, + "category": { + "selectCategory": "Choisir une catégorie", + "includeSubcategories": "Inclure les sous-catégories", + "directOnly": "Directe seulement", + "breakdown": "Total", + "evolution": "Évolution", + "transactions": "Transactions" + }, + "keyword": { + "addFromTransaction": "Ajouter comme mot-clé", + "dialogTitle": "Nouveau mot-clé", + "willMatch": "Matchera aussi", + "nMatches_one": "{{count}} transaction matchée", + "nMatches_other": "{{count}} transactions matchées", + "applyAndRecategorize": "Appliquer et recatégoriser", + "applyToHidden": "Appliquer aussi aux {{count}} transactions non affichées", + "tooShort": "Minimum {{min}} caractères", + "tooLong": "Maximum {{max}} caractères", + "alreadyExists": "Ce mot-clé existe déjà pour une autre catégorie. Remplacer ?" + }, "highlights": { "balances": "Soldes", "netBalanceCurrent": "Ce mois-ci", diff --git a/src/pages/ReportsCategoryPage.tsx b/src/pages/ReportsCategoryPage.tsx index a3831fa..6790928 100644 --- a/src/pages/ReportsCategoryPage.tsx +++ b/src/pages/ReportsCategoryPage.tsx @@ -1,11 +1,114 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { ArrowLeft } from "lucide-react"; +import PeriodSelector from "../components/dashboard/PeriodSelector"; +import CategoryZoomHeader from "../components/reports/CategoryZoomHeader"; +import CategoryDonutChart from "../components/reports/CategoryDonutChart"; +import CategoryEvolutionChart from "../components/reports/CategoryEvolutionChart"; +import CategoryTransactionsTable from "../components/reports/CategoryTransactionsTable"; +import AddKeywordDialog from "../components/categories/AddKeywordDialog"; +import { useCategoryZoom } from "../hooks/useCategoryZoom"; +import { useReportsPeriod } from "../hooks/useReportsPeriod"; +import type { RecentTransaction } from "../shared/types"; export default function ReportsCategoryPage() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); + const { + zoomedCategoryId, + rollupChildren, + data, + isLoading, + error, + setCategory, + setRollupChildren, + refetch, + } = useCategoryZoom(); + const [pending, setPending] = useState(null); + + const preserveSearch = typeof window !== "undefined" ? window.location.search : ""; + + const centerLabel = t("reports.category.breakdown"); + const centerValue = data + ? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", { + style: "currency", + currency: "CAD", + maximumFractionDigits: 0, + }).format(data.rollupTotal) + : "—"; + return ( -
-

{t("reports.hub.categoryZoom")}

-

{t("common.underConstruction")}

+
+
+ + + +

{t("reports.hub.categoryZoom")}

+
+ +
+ +
+ +
+ +
+ + {error && ( +
+ {error} +
+ )} + + {zoomedCategoryId === null ? ( +
+ {t("reports.category.selectCategory")} +
+ ) : data ? ( +
+ + +
+

{t("reports.category.transactions")}

+ setPending(tx)} + /> +
+
+ ) : null} + + {pending && ( + setPending(null)} + onApplied={() => { + setPending(null); + refetch(); + }} + /> + )}
); } diff --git a/src/services/categorizationService.test.ts b/src/services/categorizationService.test.ts new file mode 100644 index 0000000..0d374a5 --- /dev/null +++ b/src/services/categorizationService.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + validateKeyword, + previewKeywordMatches, + applyKeywordWithReassignment, + KEYWORD_MAX_LENGTH, +} from "./categorizationService"; + +vi.mock("./db", () => ({ + getDb: vi.fn(), +})); + +import { getDb } from "./db"; + +const mockSelect = vi.fn(); +const mockExecute = vi.fn(); +const mockDb = { select: mockSelect, execute: mockExecute }; + +beforeEach(() => { + vi.mocked(getDb).mockResolvedValue(mockDb as never); + mockSelect.mockReset(); + mockExecute.mockReset(); +}); + +describe("validateKeyword", () => { + it("rejects whitespace-only input", () => { + expect(validateKeyword(" ")).toEqual({ ok: false, reason: "tooShort" }); + }); + + it("rejects single-character keywords", () => { + expect(validateKeyword("a")).toEqual({ ok: false, reason: "tooShort" }); + }); + + it("accepts a minimal two-character keyword after trim", () => { + expect(validateKeyword(" ab ")).toEqual({ ok: true, value: "ab" }); + }); + + it("rejects keywords longer than 64 characters (ReDoS cap)", () => { + const long = "a".repeat(KEYWORD_MAX_LENGTH + 1); + expect(validateKeyword(long)).toEqual({ ok: false, reason: "tooLong" }); + }); + + it("accepts keywords at exactly 64 characters", () => { + const borderline = "a".repeat(KEYWORD_MAX_LENGTH); + expect(validateKeyword(borderline)).toEqual({ ok: true, value: borderline }); + }); +}); + +describe("previewKeywordMatches", () => { + it("binds the LIKE pattern as a parameter (no interpolation)", async () => { + mockSelect.mockResolvedValueOnce([]); + + await previewKeywordMatches("METRO"); + + expect(mockSelect).toHaveBeenCalledTimes(1); + const sql = mockSelect.mock.calls[0][0] as string; + const params = mockSelect.mock.calls[0][1] as unknown[]; + expect(sql).toContain("LIKE $1"); + expect(sql).not.toContain("'metro'"); + expect(sql).not.toContain("'%metro%'"); + expect(params[0]).toBe("%metro%"); + }); + + it("returns an empty preview when the keyword is invalid", async () => { + const result = await previewKeywordMatches("a"); + expect(result).toEqual({ visible: [], totalMatches: 0 }); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it("filters candidates with the regex and respects the visible cap", async () => { + // 3 rows: 2 true matches, 1 substring-only (should be dropped by word-boundary) + mockSelect.mockResolvedValueOnce([ + { id: 1, date: "2026-03-15", description: "METRO #123", amount: -45, category_name: null, category_color: null }, + { id: 2, date: "2026-03-02", description: "METRO PLUS", amount: -67.2, category_name: null, category_color: null }, + { id: 3, date: "2026-02-18", description: "METROPOLITAIN", amount: -12, category_name: null, category_color: null }, + ]); + + const result = await previewKeywordMatches("METRO", 2); + + expect(result.totalMatches).toBe(2); + expect(result.visible).toHaveLength(2); + expect(result.visible[0].id).toBe(1); + expect(result.visible[1].id).toBe(2); + }); +}); + +describe("applyKeywordWithReassignment", () => { + it("wraps INSERT + UPDATEs in a BEGIN/COMMIT transaction", async () => { + mockSelect.mockResolvedValueOnce([]); // existing keyword lookup → none + mockExecute.mockResolvedValue({ lastInsertId: 77, rowsAffected: 1 }); + + await applyKeywordWithReassignment({ + keyword: "METRO", + categoryId: 3, + transactionIds: [1, 2], + allowReplaceExisting: false, + }); + + const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim()); + expect(commands[0]).toBe("BEGIN"); + expect(commands[commands.length - 1]).toBe("COMMIT"); + expect(commands.filter((c) => c.startsWith("INSERT INTO keywords"))).toHaveLength(1); + expect(commands.filter((c) => c.startsWith("UPDATE transactions"))).toHaveLength(2); + }); + + it("rolls back when an UPDATE throws", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute + .mockResolvedValueOnce(undefined) // BEGIN + .mockResolvedValueOnce({ lastInsertId: 77 }) // INSERT keywords + .mockRejectedValueOnce(new Error("disk full")); // UPDATE transactions fails + + await expect( + applyKeywordWithReassignment({ + keyword: "METRO", + categoryId: 3, + transactionIds: [1], + allowReplaceExisting: false, + }), + ).rejects.toThrow("disk full"); + + const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim()); + expect(commands).toContain("BEGIN"); + expect(commands).toContain("ROLLBACK"); + expect(commands).not.toContain("COMMIT"); + }); + + it("blocks reassignment when keyword exists for another category without allowReplaceExisting", async () => { + mockSelect.mockResolvedValueOnce([{ id: 10, category_id: 5 }]); + + await expect( + applyKeywordWithReassignment({ + keyword: "METRO", + categoryId: 3, + transactionIds: [1], + allowReplaceExisting: false, + }), + ).rejects.toThrow("keyword_already_exists"); + + const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim()); + expect(commands).toContain("BEGIN"); + expect(commands).toContain("ROLLBACK"); + }); + + it("reassigns existing keyword when allowReplaceExisting is true", async () => { + mockSelect.mockResolvedValueOnce([{ id: 10, category_id: 5 }]); + mockExecute.mockResolvedValue({ rowsAffected: 1 }); + + const result = await applyKeywordWithReassignment({ + keyword: "METRO", + categoryId: 3, + transactionIds: [1, 2], + allowReplaceExisting: true, + }); + + expect(result.replacedExisting).toBe(true); + expect(result.updatedTransactions).toBe(2); + const commands = mockExecute.mock.calls.map((c) => (c[0] as string).trim()); + expect(commands.some((c) => c.startsWith("UPDATE keywords SET category_id"))).toBe(true); + expect(commands).toContain("COMMIT"); + }); + + it("rejects invalid keywords before touching the database", async () => { + await expect( + applyKeywordWithReassignment({ + keyword: "a", + categoryId: 3, + transactionIds: [1], + allowReplaceExisting: false, + }), + ).rejects.toThrow("invalid_keyword:tooShort"); + expect(mockExecute).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/categorizationService.ts b/src/services/categorizationService.ts index 914a74e..e804961 100644 --- a/src/services/categorizationService.ts +++ b/src/services/categorizationService.ts @@ -1,5 +1,5 @@ import { getDb } from "./db"; -import type { Keyword } from "../shared/types"; +import type { Keyword, RecentTransaction } from "../shared/types"; /** * Normalize a description for keyword matching: @@ -7,7 +7,7 @@ import type { Keyword } from "../shared/types"; * - strip accents via NFD decomposition * - collapse whitespace */ -function normalizeDescription(desc: string): string { +export function normalizeDescription(desc: string): string { return desc .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") @@ -25,7 +25,7 @@ const WORD_CHAR = /\w/; * (e.g., brackets, parentheses, dashes). This ensures keywords like * "[VIREMENT]" or "(INTERAC)" can match correctly. */ -function buildKeywordRegex(normalizedKeyword: string): RegExp { +export function buildKeywordRegex(normalizedKeyword: string): RegExp { const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const left = WORD_CHAR.test(normalizedKeyword[0]) ? "\\b" @@ -50,7 +50,7 @@ interface CompiledKeyword { /** * Compile keywords into regex patterns once for reuse across multiple matches. */ -function compileKeywords(keywords: Keyword[]): CompiledKeyword[] { +export function compileKeywords(keywords: Keyword[]): CompiledKeyword[] { return keywords.map((kw) => ({ regex: buildKeywordRegex(normalizeDescription(kw.keyword)), category_id: kw.category_id, @@ -112,3 +112,162 @@ export async function categorizeBatch( return matchDescription(normalized, compiled); }); } + +// --- AddKeywordDialog support (Issue #74) --- + +export const KEYWORD_MIN_LENGTH = 2; +export const KEYWORD_MAX_LENGTH = 64; +export const KEYWORD_PREVIEW_LIMIT = 50; + +/** + * Validate a keyword before it hits the regex engine. + * + * Rejects whitespace-only input and caps length at 64 chars to prevent + * ReDoS (CWE-1333) when the compiled regex is replayed across many + * transactions later. + */ +export function validateKeyword(raw: string): { ok: true; value: string } | { ok: false; reason: "tooShort" | "tooLong" } { + const trimmed = raw.trim(); + if (trimmed.length < KEYWORD_MIN_LENGTH) return { ok: false, reason: "tooShort" }; + if (trimmed.length > KEYWORD_MAX_LENGTH) return { ok: false, reason: "tooLong" }; + return { ok: true, value: trimmed }; +} + +/** + * Preview the transactions that would be recategorised if the user commits + * the given keyword. Uses a parameterised `LIKE ?1` to scope the candidates, + * then re-filters in memory with `buildKeywordRegex` for exact word-boundary + * matching. Results are capped at `limit` visible rows — callers decide what + * to do with the `totalMatches` (which may be greater than the returned list). + * + * SECURITY: the keyword is never interpolated into the SQL string. `LIKE ?1` + * is the only parameterised binding, and the `%...%` wrapping happens inside + * the bound parameter value. + */ +export async function previewKeywordMatches( + keyword: string, + limit: number = KEYWORD_PREVIEW_LIMIT, +): Promise<{ visible: RecentTransaction[]; totalMatches: number }> { + const validation = validateKeyword(keyword); + if (!validation.ok) { + return { visible: [], totalMatches: 0 }; + } + const normalized = normalizeDescription(validation.value); + const regex = buildKeywordRegex(normalized); + const db = await getDb(); + + // Coarse pre-filter via parameterised LIKE (case-insensitive thanks to + // normalize on the JS side). A small cap protects against catastrophic + // backtracking across a huge candidate set — hard-capped to 1000 rows + // before the in-memory filter. + const likePattern = `%${normalized}%`; + const candidates = await db.select( + `SELECT t.id, t.date, t.description, t.amount, + c.name AS category_name, + c.color AS category_color + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE LOWER(t.description) LIKE $1 + ORDER BY t.date DESC + LIMIT 1000`, + [likePattern], + ); + + const matched: RecentTransaction[] = []; + for (const tx of candidates) { + const normDesc = normalizeDescription(tx.description); + if (regex.test(normDesc)) matched.push(tx); + } + + return { + visible: matched.slice(0, limit), + totalMatches: matched.length, + }; +} + +export interface ApplyKeywordInput { + keyword: string; + categoryId: number; + /** ids of transactions to recategorise (only those the user checked). */ + transactionIds: number[]; + /** + * When true, and a keyword with the same spelling already exists for a + * different category, that existing keyword is **reassigned** to the new + * category rather than creating a duplicate. Matches the spec decision + * that history is never touched — only the visible transactions are + * recategorised. + */ + allowReplaceExisting: boolean; +} + +export interface ApplyKeywordResult { + keywordId: number; + updatedTransactions: number; + replacedExisting: boolean; +} + +/** + * INSERTs (or reassigns) a keyword and recategorises the given transaction + * ids in a single SQL transaction. Either all writes commit or none do. + * + * SECURITY: every query is parameterised. The caller is expected to have + * vetted `transactionIds` from a preview window that the user confirmed. + */ +export async function applyKeywordWithReassignment( + input: ApplyKeywordInput, +): Promise { + const validation = validateKeyword(input.keyword); + if (!validation.ok) { + throw new Error(`invalid_keyword:${validation.reason}`); + } + const keyword = validation.value; + + const db = await getDb(); + await db.execute("BEGIN"); + try { + // Is there already a row for this keyword spelling? + const existing = await db.select>( + `SELECT id, category_id FROM keywords WHERE keyword = $1 LIMIT 1`, + [keyword], + ); + + let keywordId: number; + let replacedExisting = false; + if (existing.length > 0) { + if (!input.allowReplaceExisting && existing[0].category_id !== input.categoryId) { + throw new Error("keyword_already_exists"); + } + await db.execute( + `UPDATE keywords SET category_id = $1, is_active = 1 WHERE id = $2`, + [input.categoryId, existing[0].id], + ); + keywordId = existing[0].id; + replacedExisting = existing[0].category_id !== input.categoryId; + } else { + const result = await db.execute( + `INSERT INTO keywords (keyword, category_id, priority) VALUES ($1, $2, $3)`, + [keyword, input.categoryId, 100], + ); + keywordId = Number(result.lastInsertId ?? 0); + } + + let updatedTransactions = 0; + for (const txId of input.transactionIds) { + await db.execute( + `UPDATE transactions + SET category_id = $1, + is_manually_categorized = 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2`, + [input.categoryId, txId], + ); + updatedTransactions++; + } + + await db.execute("COMMIT"); + return { keywordId, updatedTransactions, replacedExisting }; + } catch (e) { + await db.execute("ROLLBACK"); + throw e; + } +} diff --git a/src/services/reportService.test.ts b/src/services/reportService.test.ts index 4a8895e..4bc31bc 100644 --- a/src/services/reportService.test.ts +++ b/src/services/reportService.test.ts @@ -4,6 +4,7 @@ import { getHighlights, getCompareMonthOverMonth, getCompareYearOverYear, + getCategoryZoom, } from "./reportService"; // Mock the db module @@ -336,3 +337,58 @@ describe("getCompareYearOverYear", () => { expect(params).toEqual(["2026-01-01", "2026-12-31", "2025-01-01", "2025-12-31"]); }); }); + +describe("getCategoryZoom", () => { + it("uses a bounded recursive CTE when including subcategories", async () => { + mockSelect + .mockResolvedValueOnce([]) // transactions + .mockResolvedValueOnce([{ rollup: 0 }]) // rollup + .mockResolvedValueOnce([]) // byChild + .mockResolvedValueOnce([]); // evolution + + await getCategoryZoom(42, "2026-01-01", "2026-12-31", true); + + const txSql = mockSelect.mock.calls[0][0] as string; + expect(txSql).toContain("WITH RECURSIVE cat_tree"); + expect(txSql).toContain("WHERE ct.depth < 5"); + const txParams = mockSelect.mock.calls[0][1] as unknown[]; + expect(txParams).toEqual([42, "2026-01-01", "2026-12-31"]); + }); + + it("terminates on a cyclic category tree because of the depth cap", async () => { + // Simulate a cyclic parent_id chain. Since the mocked db.select simply + // returns our canned values, the real cycle guard (depth < 5) is what we + // can assert: the CTE must include the bound. + mockSelect + .mockResolvedValueOnce([]) // transactions + .mockResolvedValueOnce([{ rollup: 0 }]) // rollup + .mockResolvedValueOnce([]) // byChild + .mockResolvedValueOnce([]); // evolution + + await expect( + getCategoryZoom(1, "2026-01-01", "2026-01-31", true), + ).resolves.toBeDefined(); + + // Every recursive query sent must contain the depth guard. + for (const call of mockSelect.mock.calls) { + const sql = call[0] as string; + if (sql.includes("cat_tree")) { + expect(sql).toContain("ct.depth < 5"); + } + } + }); + + it("issues a direct-only query when includeSubcategories is false", async () => { + mockSelect + .mockResolvedValueOnce([]) // transactions + .mockResolvedValueOnce([{ rollup: 0 }]); // rollup — no byChild / evolution recursive here + // evolution is still queried with categoryId = direct + mockSelect.mockResolvedValueOnce([]); + + await getCategoryZoom(7, "2026-01-01", "2026-12-31", false); + + const txSql = mockSelect.mock.calls[0][0] as string; + expect(txSql).not.toContain("cat_tree"); + expect(txSql).toContain("t.category_id = $1"); + }); +}); diff --git a/src/services/reportService.ts b/src/services/reportService.ts index 6caf4e0..8b85088 100644 --- a/src/services/reportService.ts +++ b/src/services/reportService.ts @@ -7,6 +7,9 @@ import type { HighlightsData, HighlightMover, CategoryDelta, + CategoryZoomData, + CategoryZoomChild, + CategoryZoomEvolutionPoint, MonthBalance, RecentTransaction, } from "../shared/types"; @@ -445,3 +448,125 @@ export async function getCompareYearOverYear(year: number): Promise { + const db = await getDb(); + + const categoryFilter = includeSubcategories + ? `${CATEGORY_TREE_CTE} + SELECT t.id, t.date, t.description, t.amount, t.category_id + FROM transactions t + WHERE t.category_id IN (SELECT id FROM cat_tree) + AND t.date >= $2 AND t.date <= $3` + : `SELECT t.id, t.date, t.description, t.amount, t.category_id + FROM transactions t + WHERE t.category_id = $1 + AND t.date >= $2 AND t.date <= $3`; + + // 1. Transactions (with join for display) + const txRows = await db.select( + `${includeSubcategories ? CATEGORY_TREE_CTE : ""} + SELECT t.id, t.date, t.description, t.amount, c.name AS category_name, c.color AS category_color + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE ${includeSubcategories + ? "t.category_id IN (SELECT id FROM cat_tree)" + : "t.category_id = $1"} + AND t.date >= $2 AND t.date <= $3 + ORDER BY ABS(t.amount) DESC + LIMIT 500`, + [categoryId, dateFrom, dateTo], + ); + + // 2. Rollup total (sum of absolute amounts of matching rows) + const totalRows = await db.select>( + `${categoryFilter.replace("SELECT t.id, t.date, t.description, t.amount, t.category_id", "SELECT COALESCE(SUM(ABS(t.amount)), 0) AS rollup")}`, + [categoryId, dateFrom, dateTo], + ); + const rollupTotal = Number(totalRows[0]?.rollup ?? 0); + + // 3. Breakdown by direct child (only meaningful when includeSubcategories is true) + const byChild: CategoryZoomChild[] = []; + if (includeSubcategories) { + const childRows = await db.select< + Array<{ child_id: number; child_name: string; child_color: string; total: number | null }> + >( + `SELECT child.id AS child_id, + child.name AS child_name, + COALESCE(child.color, '#9ca3af') AS child_color, + COALESCE(SUM(ABS(t.amount)), 0) AS total + FROM categories child + LEFT JOIN transactions t ON t.category_id = child.id + AND t.date >= $2 AND t.date <= $3 + WHERE child.parent_id = $1 + GROUP BY child.id, child.name, child.color + ORDER BY total DESC`, + [categoryId, dateFrom, dateTo], + ); + for (const r of childRows) { + byChild.push({ + categoryId: r.child_id, + categoryName: r.child_name, + categoryColor: r.child_color, + total: Number(r.total ?? 0), + }); + } + } + + // 4. Monthly evolution across the window + const evolutionRows = await db.select>( + `${includeSubcategories ? CATEGORY_TREE_CTE : ""} + SELECT strftime('%Y-%m', t.date) AS month, + COALESCE(SUM(ABS(t.amount)), 0) AS total + FROM transactions t + WHERE ${includeSubcategories + ? "t.category_id IN (SELECT id FROM cat_tree)" + : "t.category_id = $1"} + AND t.date >= $2 AND t.date <= $3 + GROUP BY month + ORDER BY month ASC`, + [categoryId, dateFrom, dateTo], + ); + const monthlyEvolution: CategoryZoomEvolutionPoint[] = evolutionRows.map((r) => ({ + month: r.month, + total: Number(r.total ?? 0), + })); + + return { + rollupTotal, + byChild, + monthlyEvolution, + transactions: txRows, + }; +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 215e2e3..5ec6a98 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -291,6 +291,25 @@ export interface CategoryDelta { // Historical alias — used by the highlights hub. Shape identical to CategoryDelta. export type HighlightMover = CategoryDelta; +export interface CategoryZoomChild { + categoryId: number; + categoryName: string; + categoryColor: string; + total: number; +} + +export interface CategoryZoomEvolutionPoint { + month: string; + total: number; +} + +export interface CategoryZoomData { + rollupTotal: number; + byChild: CategoryZoomChild[]; + monthlyEvolution: CategoryZoomEvolutionPoint[]; + transactions: RecentTransaction[]; +} + export interface MonthBalance { month: string; // "YYYY-MM" netBalance: number;