feat: category zoom + secure AddKeywordDialog (#74) #93
14 changed files with 1359 additions and 12 deletions
278
src/components/categories/AddKeywordDialog.tsx
Normal file
278
src/components/categories/AddKeywordDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/reports/CategoryDonutChart.tsx
Normal file
72
src/components/reports/CategoryDonutChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/reports/CategoryEvolutionChart.tsx
Normal file
82
src/components/reports/CategoryEvolutionChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/components/reports/CategoryTransactionsTable.tsx
Normal file
135
src/components/reports/CategoryTransactionsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/reports/CategoryZoomHeader.tsx
Normal file
72
src/components/reports/CategoryZoomHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<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 (
|
||||
<div className="p-8 text-center text-[var(--muted-foreground)]">
|
||||
<h1 className="text-2xl font-bold mb-4">{t("reports.hub.categoryZoom")}</h1>
|
||||
<p>{t("common.underConstruction")}</p>
|
||||
<div className={isLoading ? "opacity-60" : ""}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
174
src/services/categorizationService.test.ts
Normal file
174
src/services/categorizationService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<CategoryDelt
|
|||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue