Simpl-Resultat/src/components/reports/CategoryEvolutionChart.tsx
le king fu 62430c63dc
Some checks failed
PR Check / rust (push) Has been cancelled
PR Check / frontend (push) Has been cancelled
PR Check / rust (pull_request) Has been cancelled
PR Check / frontend (pull_request) Has been cancelled
feat: category zoom + secure AddKeywordDialog with context menu (#74)
Service layer
- New reportService.getCategoryZoom(categoryId, from, to, includeChildren) —
  bounded recursive CTE (WHERE ct.depth < 5) protects against parent_id cycles;
  direct-only path skips the CTE; every binding is parameterised
- Export categorizationService helpers normalizeDescription / buildKeywordRegex /
  compileKeywords so the dialog can reuse them
- New validateKeyword() enforces 2–64 char length (anti-ReDoS), whitespace-only
  rejection, returns discriminated result
- New previewKeywordMatches(keyword, limit=50) uses parameterised LIKE + regex
  filter in memory; caps candidate scan at 1000 rows to protect against
  catastrophic backtracking
- New applyKeywordWithReassignment wraps INSERT (or UPDATE-reassign) +
  per-transaction UPDATEs in an explicit BEGIN/COMMIT/ROLLBACK; rejects
  existing keyword reassignment unless allowReplaceExisting is set; never
  recategorises historical transactions beyond the ids the caller supplied

Hook
- Flesh out useCategoryZoom with reducer + fetch + refetch hook

Components (flat under src/components/reports/)
- CategoryZoomHeader — category combobox + include/direct toggle
- CategoryDonutChart — template'd from dashboard/CategoryPieChart with
  innerRadius=55 and ChartPatternDefs for SVG patterns
- CategoryEvolutionChart — AreaChart with Intl-formatted axes
- CategoryTransactionsTable — sortable table with per-row onContextMenu
  → ContextMenu → "Add as keyword" action

AddKeywordDialog — src/components/categories/AddKeywordDialog.tsx
- Lives in categories/ (not reports/) because it is a keyword-editing widget
  consumed from multiple sections
- Renders transaction descriptions as React children only (no
  dangerouslySetInnerHTML); CSS truncation (CWE-79 safe)
- Per-row checkboxes for applying recategorisation; cap visible rows at 50;
  explicit opt-in checkbox to extend to N-50 non-displayed matches
- Surfaces apply errors + "keyword already exists" replace prompt
- Re-runs category zoom fetch on success so the zoomed view updates

Page
- ReportsCategoryPage composes header + donut + evolution + transactions
  + AddKeywordDialog, fetches from useCategoryZoom, preserves query string
  for back navigation

i18n
- New keys reports.category.* and reports.keyword.* in FR + EN
- Plural forms use i18next v25 _one / _other suffixes (nMatches)

Tests
- 3 reportService tests cover bounded CTE, cycle-guard depth check, direct-only fallthrough
- New categorizationService.test.ts: 13 tests covering validation boundaries,
  parameterised LIKE preview, regex word-boundary filter, explicit BEGIN/COMMIT
  wrapping, rollback on failure, existing keyword reassignment policy
- 62 total tests passing

Fixes #74

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:09:17 -04:00

82 lines
2.6 KiB
TypeScript

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