Merge pull request 'feat: category zoom + secure AddKeywordDialog (#74)' (#93) from issue-74-zoom-add-keyword into main
This commit is contained in:
commit
334f975deb
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";
|
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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue