Compare commits

..

2 commits

Author SHA1 Message Date
334f975deb Merge pull request 'feat: category zoom + secure AddKeywordDialog (#74)' (#93) from issue-74-zoom-add-keyword into main 2026-04-14 19:11:54 +00:00
le king fu
62430c63dc feat: category zoom + secure AddKeywordDialog with context menu (#74)
Some checks failed
PR Check / rust (push) Has been cancelled
PR Check / frontend (push) Has been cancelled
PR Check / rust (pull_request) Has been cancelled
PR Check / frontend (pull_request) Has been cancelled
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) <noreply@anthropic.com>
2026-04-14 15:09:17 -04:00
14 changed files with 1359 additions and 12 deletions

View file

@ -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<number | null>(initialCategoryId);
const [categories, setCategories] = useState<CategoryOption[]>([]);
const [checked, setChecked] = useState<Set<number>>(new Set());
const [applyToHidden, setApplyToHidden] = useState(false);
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [replacePrompt, setReplacePrompt] = useState<string | null>(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 (
<div
onClick={onClose}
className="fixed inset-0 z-[200] bg-black/40 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
<div
onClick={preventBackdropClose}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl max-w-xl w-full max-h-[90vh] flex flex-col"
>
<header className="px-5 py-3 border-b border-[var(--border)]">
<h2 className="text-base font-semibold">{t("reports.keyword.dialogTitle")}</h2>
</header>
<div className="px-5 py-4 flex flex-col gap-4 overflow-y-auto">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.keyword.dialogTitle")}
</span>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
maxLength={KEYWORD_MAX_LENGTH * 2 /* allow user to paste longer then see error */}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
/>
{!validation.ok && (
<span className="text-xs text-[var(--negative)]">
{validation.reason === "tooShort"
? t("reports.keyword.tooShort", { min: KEYWORD_MIN_LENGTH })
: t("reports.keyword.tooLong", { max: KEYWORD_MAX_LENGTH })}
</span>
)}
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.category")}
</span>
<select
value={categoryId ?? ""}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : null)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<section>
<h3 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
{t("reports.keyword.willMatch")}
</h3>
{preview.kind === "idle" && (
<p className="text-sm text-[var(--muted-foreground)] italic"></p>
)}
{preview.kind === "loading" && (
<p className="text-sm text-[var(--muted-foreground)] italic">{t("common.loading")}</p>
)}
{preview.kind === "error" && (
<p className="text-sm text-[var(--negative)]">{preview.message}</p>
)}
{preview.kind === "ready" && (
<>
<p className="text-sm mb-2">
{t("reports.keyword.nMatches", { count: preview.totalMatches })}
</p>
<ul className="divide-y divide-[var(--border)] max-h-[220px] overflow-y-auto border border-[var(--border)] rounded-lg">
{preview.visible.map((tx) => (
<li key={tx.id} className="flex items-center gap-2 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={checked.has(tx.id)}
onChange={() => toggleRow(tx.id)}
className="accent-[var(--primary)]"
/>
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
{tx.date}
</span>
<span className="flex-1 min-w-0 truncate">{tx.description}</span>
<span className="tabular-nums font-medium flex-shrink-0">
{new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
}).format(tx.amount)}
</span>
</li>
))}
</ul>
{preview.totalMatches > preview.visible.length && (
<label className="flex items-center gap-2 text-xs mt-2 text-[var(--muted-foreground)]">
<input
type="checkbox"
checked={applyToHidden}
onChange={(e) => setApplyToHidden(e.target.checked)}
className="accent-[var(--primary)]"
/>
{t("reports.keyword.applyToHidden", {
count: preview.totalMatches - preview.visible.length,
})}
</label>
)}
</>
)}
</section>
{replacePrompt && (
<div className="bg-[var(--muted)]/50 border border-[var(--border)] rounded-lg p-3 text-sm flex flex-col gap-2">
<p>{replacePrompt}</p>
<button
type="button"
onClick={() => {
setAllowReplaceExisting(true);
setReplacePrompt(null);
void handleApply();
}}
className="self-start px-3 py-1.5 rounded-lg bg-[var(--primary)] text-white text-sm"
>
{t("common.confirm")}
</button>
</div>
)}
{applyError && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-lg p-3 text-sm">
{applyError}
</div>
)}
</div>
<footer className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-2 rounded-lg text-sm bg-[var(--card)] border border-[var(--border)] hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleApply}
disabled={!canApply}
className="px-3 py-2 rounded-lg text-sm bg-[var(--primary)] text-white font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isApplying ? t("common.loading") : t("reports.keyword.applyAndRecategorize")}
</button>
</footer>
</div>
</div>
);
}

View file

@ -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 (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 relative">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<ChartPatternDefs
prefix="cat-donut"
categories={byChild.map((c, index) => ({ color: c.categoryColor, index }))}
/>
<Pie
data={byChild}
dataKey="total"
nameKey="categoryName"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={95}
paddingAngle={2}
>
{byChild.map((entry, index) => (
<Cell
key={entry.categoryId}
fill={getPatternFill("cat-donut", index, entry.categoryColor)}
/>
))}
</Pie>
<Tooltip
formatter={(value) =>
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",
}}
/>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-xs text-[var(--muted-foreground)]">{centerLabel}</span>
<span className="text-lg font-bold">{centerValue}</span>
</div>
</div>
);
}

View file

@ -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 (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<h3 className="text-sm font-semibold mb-2">{t("reports.category.evolution")}</h3>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data} margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tickFormatter={formatMonth}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) =>
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(v)
}
/>
<Tooltip
formatter={(value) =>
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",
}}
/>
<Area type="monotone" dataKey="total" stroke={color} fill={color} fillOpacity={0.2} />
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -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<SortKey>("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") => (
<th
onClick={() => 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 && <span className="ml-1">{sortDir === "asc" ? "▲" : "▼"}</span>}
</th>
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-[var(--card)]">
<tr className="border-b border-[var(--border)]">
{header("date", t("transactions.date"), "left")}
{header("description", t("transactions.description"), "left")}
{header("amount", t("transactions.amount"), "right")}
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td colSpan={3} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</td>
</tr>
) : (
sorted.map((tx) => (
<tr
key={tx.id}
onContextMenu={(e) => handleContextMenu(e, tx)}
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2 tabular-nums text-[var(--muted-foreground)]">{tx.date}</td>
<td className="px-3 py-2 truncate max-w-[280px]">{tx.description}</td>
<td
className="px-3 py-2 text-right tabular-nums font-medium"
style={{
color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)",
}}
>
{formatAmount(tx.amount, i18n.language)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{menu && onAddKeyword && (
<ContextMenu
x={menu.x}
y={menu.y}
header={menu.tx.description}
onClose={() => setMenu(null)}
items={[
{
icon: <Tag size={14} />,
label: t("reports.keyword.addFromTransaction"),
onClick: () => {
onAddKeyword(menu.tx);
},
},
]}
/>
)}
</div>
);
}

View file

@ -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<CategoryOption[]>([]);
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 (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<label className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.category.selectCategory")}
</span>
<select
value={categoryId ?? ""}
onChange={(e) => onCategoryChange(e.target.value ? Number(e.target.value) : null)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
>
<option value=""></option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeSubcategories}
onChange={(e) => onIncludeSubcategoriesChange(e.target.checked)}
className="accent-[var(--primary)]"
/>
<span>
{includeSubcategories
? t("reports.category.includeSubcategories")
: t("reports.category.directOnly")}
</span>
</label>
</div>
);
}

View file

@ -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"; import { useReportsPeriod } from "./useReportsPeriod";
interface State { interface State {
zoomedCategoryId: number | null; zoomedCategoryId: number | null;
rollupChildren: boolean; rollupChildren: boolean;
data: CategoryZoomData | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -12,11 +15,13 @@ type Action =
| { type: "SET_CATEGORY"; payload: number | null } | { type: "SET_CATEGORY"; payload: number | null }
| { type: "TOGGLE_ROLLUP"; payload: boolean } | { type: "TOGGLE_ROLLUP"; payload: boolean }
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_DATA"; payload: CategoryZoomData }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
const initialState: State = { const initialState: State = {
zoomedCategoryId: null, zoomedCategoryId: null,
rollupChildren: true, rollupChildren: true,
data: null,
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -24,11 +29,13 @@ const initialState: State = {
function reducer(state: State, action: Action): State { function reducer(state: State, action: Action): State {
switch (action.type) { switch (action.type) {
case "SET_CATEGORY": case "SET_CATEGORY":
return { ...state, zoomedCategoryId: action.payload }; return { ...state, zoomedCategoryId: action.payload, data: null };
case "TOGGLE_ROLLUP": case "TOGGLE_ROLLUP":
return { ...state, rollupChildren: action.payload }; return { ...state, rollupChildren: action.payload };
case "SET_LOADING": case "SET_LOADING":
return { ...state, isLoading: action.payload }; return { ...state, isLoading: action.payload };
case "SET_DATA":
return { ...state, data: action.payload, isLoading: false, error: null };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
default: default:
@ -39,6 +46,28 @@ function reducer(state: State, action: Action): State {
export function useCategoryZoom() { export function useCategoryZoom() {
const { from, to } = useReportsPeriod(); const { from, to } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState); 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) => { const setCategory = useCallback((id: number | null) => {
dispatch({ type: "SET_CATEGORY", payload: id }); dispatch({ type: "SET_CATEGORY", payload: id });
@ -48,6 +77,9 @@ export function useCategoryZoom() {
dispatch({ type: "TOGGLE_ROLLUP", payload: flag }); dispatch({ type: "TOGGLE_ROLLUP", payload: flag });
}, []); }, []);
// Real fetch lives in Issue #74 (getCategoryZoom with recursive CTE). const refetch = useCallback(() => {
return { ...state, setCategory, setRollupChildren, from, to }; fetch(state.zoomedCategoryId, state.rollupChildren, from, to);
}, [fetch, state.zoomedCategoryId, state.rollupChildren, from, to]);
return { ...state, setCategory, setRollupChildren, refetch, from, to };
} }

View file

@ -408,6 +408,26 @@
"modeYoY": "Year vs previous year", "modeYoY": "Year vs previous year",
"modeBudget": "Actual vs budget" "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": { "highlights": {
"balances": "Balances", "balances": "Balances",
"netBalanceCurrent": "This month", "netBalanceCurrent": "This month",

View file

@ -408,6 +408,26 @@
"modeYoY": "Année vs année précédente", "modeYoY": "Année vs année précédente",
"modeBudget": "Réel vs budget" "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": { "highlights": {
"balances": "Soldes", "balances": "Soldes",
"netBalanceCurrent": "Ce mois-ci", "netBalanceCurrent": "Ce mois-ci",

View file

@ -1,11 +1,114 @@
import { useState } from "react";
import { useTranslation } from "react-i18next"; 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() { 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<RecentTransaction | null>(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 ( return (
<div className="p-8 text-center text-[var(--muted-foreground)]"> <div className={isLoading ? "opacity-60" : ""}>
<h1 className="text-2xl font-bold mb-4">{t("reports.hub.categoryZoom")}</h1> <div className="flex items-center gap-3 mb-4">
<p>{t("common.underConstruction")}</p> <Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.categoryZoom")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
</div>
<div className="mb-4">
<CategoryZoomHeader
categoryId={zoomedCategoryId}
includeSubcategories={rollupChildren}
onCategoryChange={setCategory}
onIncludeSubcategoriesChange={setRollupChildren}
/>
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{zoomedCategoryId === null ? (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("reports.category.selectCategory")}
</div>
) : data ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<CategoryDonutChart
byChild={data.byChild}
centerLabel={centerLabel}
centerValue={centerValue}
/>
<CategoryEvolutionChart data={data.monthlyEvolution} />
<div className="lg:col-span-2">
<h3 className="text-sm font-semibold mb-2">{t("reports.category.transactions")}</h3>
<CategoryTransactionsTable
transactions={data.transactions}
onAddKeyword={(tx) => setPending(tx)}
/>
</div>
</div>
) : null}
{pending && (
<AddKeywordDialog
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
initialCategoryId={zoomedCategoryId}
onClose={() => setPending(null)}
onApplied={() => {
setPending(null);
refetch();
}}
/>
)}
</div> </div>
); );
} }

View file

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

View file

@ -1,5 +1,5 @@
import { getDb } from "./db"; import { getDb } from "./db";
import type { Keyword } from "../shared/types"; import type { Keyword, RecentTransaction } from "../shared/types";
/** /**
* Normalize a description for keyword matching: * Normalize a description for keyword matching:
@ -7,7 +7,7 @@ import type { Keyword } from "../shared/types";
* - strip accents via NFD decomposition * - strip accents via NFD decomposition
* - collapse whitespace * - collapse whitespace
*/ */
function normalizeDescription(desc: string): string { export function normalizeDescription(desc: string): string {
return desc return desc
.normalize("NFD") .normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") .replace(/[\u0300-\u036f]/g, "")
@ -25,7 +25,7 @@ const WORD_CHAR = /\w/;
* (e.g., brackets, parentheses, dashes). This ensures keywords like * (e.g., brackets, parentheses, dashes). This ensures keywords like
* "[VIREMENT]" or "(INTERAC)" can match correctly. * "[VIREMENT]" or "(INTERAC)" can match correctly.
*/ */
function buildKeywordRegex(normalizedKeyword: string): RegExp { export function buildKeywordRegex(normalizedKeyword: string): RegExp {
const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const left = WORD_CHAR.test(normalizedKeyword[0]) const left = WORD_CHAR.test(normalizedKeyword[0])
? "\\b" ? "\\b"
@ -50,7 +50,7 @@ interface CompiledKeyword {
/** /**
* Compile keywords into regex patterns once for reuse across multiple matches. * 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) => ({ return keywords.map((kw) => ({
regex: buildKeywordRegex(normalizeDescription(kw.keyword)), regex: buildKeywordRegex(normalizeDescription(kw.keyword)),
category_id: kw.category_id, category_id: kw.category_id,
@ -112,3 +112,162 @@ export async function categorizeBatch(
return matchDescription(normalized, compiled); 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<RecentTransaction[]>(
`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<ApplyKeywordResult> {
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<Array<{ id: number; category_id: number }>>(
`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;
}
}

View file

@ -4,6 +4,7 @@ import {
getHighlights, getHighlights,
getCompareMonthOverMonth, getCompareMonthOverMonth,
getCompareYearOverYear, getCompareYearOverYear,
getCategoryZoom,
} from "./reportService"; } from "./reportService";
// Mock the db module // 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"]); 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");
});
});

View file

@ -7,6 +7,9 @@ import type {
HighlightsData, HighlightsData,
HighlightMover, HighlightMover,
CategoryDelta, CategoryDelta,
CategoryZoomData,
CategoryZoomChild,
CategoryZoomEvolutionPoint,
MonthBalance, MonthBalance,
RecentTransaction, RecentTransaction,
} from "../shared/types"; } from "../shared/types";
@ -445,3 +448,125 @@ export async function getCompareYearOverYear(year: number): Promise<CategoryDelt
); );
return rowsToDeltas(rows); return rowsToDeltas(rows);
} }
// --- Category zoom (Issue #74) ---
/**
* Recursive CTE fragment bounded to `depth < 5` so a corrupted `parent_id`
* loop (A B A) can never spin forever. The depth budget is intentionally
* low: real category trees rarely exceed 3 levels.
*/
const CATEGORY_TREE_CTE = `
WITH RECURSIVE cat_tree(id, depth) AS (
SELECT id, 0 FROM categories WHERE id = $1
UNION ALL
SELECT c.id, ct.depth + 1
FROM categories c
JOIN cat_tree ct ON c.parent_id = ct.id
WHERE ct.depth < 5
)
`;
/**
* Returns the zoom-on-category report for the given category id.
*
* When `includeSubcategories` is true the recursive CTE walks down the
* category tree (capped at 5 levels) and aggregates matching rows. When
* false, only direct transactions on `categoryId` are considered. Every
* query is parameterised; the category id is never interpolated.
*/
export async function getCategoryZoom(
categoryId: number,
dateFrom: string,
dateTo: string,
includeSubcategories: boolean = true,
): Promise<CategoryZoomData> {
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<RecentTransaction[]>(
`${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<Array<{ rollup: number | null }>>(
`${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<Array<{ month: string; total: number | null }>>(
`${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,
};
}

View file

@ -291,6 +291,25 @@ export interface CategoryDelta {
// Historical alias — used by the highlights hub. Shape identical to CategoryDelta. // Historical alias — used by the highlights hub. Shape identical to CategoryDelta.
export type HighlightMover = 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 { export interface MonthBalance {
month: string; // "YYYY-MM" month: string; // "YYYY-MM"
netBalance: number; netBalance: number;