Simpl-Resultat/src/services/reportService.ts
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

572 lines
18 KiB
TypeScript

import { getDb } from "./db";
import type {
MonthlyTrendItem,
CategoryBreakdownItem,
CategoryOverTimeData,
CategoryOverTimeItem,
HighlightsData,
HighlightMover,
CategoryDelta,
CategoryZoomData,
CategoryZoomChild,
CategoryZoomEvolutionPoint,
MonthBalance,
RecentTransaction,
} from "../shared/types";
export async function getMonthlyTrends(
dateFrom?: string,
dateTo?: string,
sourceId?: number,
): Promise<MonthlyTrendItem[]> {
const db = await getDb();
const whereClauses: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (dateFrom) {
whereClauses.push(`date >= $${paramIndex}`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
whereClauses.push(`date <= $${paramIndex}`);
params.push(dateTo);
paramIndex++;
}
if (sourceId != null) {
whereClauses.push(`source_id = $${paramIndex}`);
params.push(sourceId);
paramIndex++;
}
const whereSQL =
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
return db.select<MonthlyTrendItem[]>(
`SELECT
strftime('%Y-%m', date) AS month,
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
FROM transactions
${whereSQL}
GROUP BY month
ORDER BY month ASC`,
params
);
}
export async function getCategoryOverTime(
dateFrom?: string,
dateTo?: string,
topN: number = 50,
sourceId?: number,
typeFilter?: "expense" | "income" | "transfer",
): Promise<CategoryOverTimeData> {
const db = await getDb();
const whereClauses: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (typeFilter) {
whereClauses.push(`COALESCE(c.type, 'expense') = $${paramIndex}`);
params.push(typeFilter);
paramIndex++;
}
if (dateFrom) {
whereClauses.push(`t.date >= $${paramIndex}`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
whereClauses.push(`t.date <= $${paramIndex}`);
params.push(dateTo);
paramIndex++;
}
if (sourceId != null) {
whereClauses.push(`t.source_id = $${paramIndex}`);
params.push(sourceId);
paramIndex++;
}
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
// Get top N categories by total spend
const topCategories = await db.select<CategoryBreakdownItem[]>(
`SELECT
t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color,
ABS(SUM(t.amount)) AS total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${whereSQL}
GROUP BY t.category_id
ORDER BY total DESC
LIMIT $${paramIndex}`,
[...params, topN]
);
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
const colors: Record<string, string> = {};
const categoryIds: Record<string, number | null> = {};
for (const cat of topCategories) {
colors[cat.category_name] = cat.category_color;
categoryIds[cat.category_name] = cat.category_id;
}
// Get monthly breakdown for all categories
const monthlyRows = await db.select<
Array<{
month: string;
category_id: number | null;
category_name: string;
total: number;
}>
>(
`SELECT
strftime('%Y-%m', t.date) AS month,
t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name,
ABS(SUM(t.amount)) AS total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${whereSQL}
GROUP BY month, t.category_id
ORDER BY month ASC`,
params
);
// Build pivot data
const monthMap = new Map<string, CategoryOverTimeItem>();
let hasOther = false;
for (const row of monthlyRows) {
if (!monthMap.has(row.month)) {
monthMap.set(row.month, { month: row.month });
}
const item = monthMap.get(row.month)!;
if (topCategoryIds.has(row.category_id)) {
item[row.category_name] = ((item[row.category_name] as number) || 0) + row.total;
} else {
item["Other"] = ((item["Other"] as number) || 0) + row.total;
hasOther = true;
}
}
if (hasOther) {
colors["Other"] = "#9ca3af";
}
const categories = topCategories.map((c) => c.category_name);
if (hasOther) {
categories.push("Other");
}
return {
categories,
data: Array.from(monthMap.values()),
colors,
categoryIds,
};
}
// --- Highlights (Issue #71) ---
/**
* Shifts a YYYY-MM-DD date string by `months` months and returns the first day
* of the resulting month as YYYY-MM-01. Used to compute the start of the
* 12-month sparkline window relative to the reference date.
*/
function shiftMonthStart(refIso: string, months: number): string {
const [y, m] = refIso.split("-").map(Number);
const d = new Date(Date.UTC(y, m - 1 + months, 1));
const yy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
return `${yy}-${mm}-01`;
}
function shiftDate(refIso: string, days: number): string {
const [y, m, d] = refIso.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d + days));
const yy = dt.getUTCFullYear();
const mm = String(dt.getUTCMonth() + 1).padStart(2, "0");
const dd = String(dt.getUTCDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
}
function monthEnd(yyyyMm: string): string {
const [y, m] = yyyyMm.split("-").map(Number);
const d = new Date(Date.UTC(y, m, 0)); // day 0 of next month = last day of this month
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yyyyMm}-${dd}`;
}
/**
* Returns the dashboard "highlights" snapshot for the reports hub:
* - net balance for the reference month
* - YTD net balance
* - last 12 months of net balances (for sparkline)
* - top movers (biggest change in spending vs previous month)
* - top transactions (biggest absolute amounts in last `windowDays` days)
*
* All SQL is parameterised. `referenceDate` defaults to today and is overridable
* from tests for deterministic fixtures.
*/
export async function getHighlights(
windowDays: number = 30,
referenceDate?: string,
topMoversLimit: number = 5,
topTransactionsLimit: number = 10,
): Promise<HighlightsData> {
const db = await getDb();
const refIso = referenceDate ?? (() => {
const t = new Date();
return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, "0")}-${String(t.getDate()).padStart(2, "0")}`;
})();
const currentMonth = refIso.slice(0, 7); // YYYY-MM
const currentYear = refIso.slice(0, 4);
const yearStart = `${currentYear}-01-01`;
const currentMonthStart = `${currentMonth}-01`;
const currentMonthEnd = monthEnd(currentMonth);
const previousMonthStart = shiftMonthStart(refIso, -1);
const previousMonth = previousMonthStart.slice(0, 7);
const previousMonthEnd = monthEnd(previousMonth);
const sparklineStart = shiftMonthStart(refIso, -11); // 11 months back + current = 12
const recentWindowStart = shiftDate(refIso, -(windowDays - 1));
// 1. Net balance for current month
const currentBalanceRows = await db.select<Array<{ net: number | null }>>(
`SELECT COALESCE(SUM(amount), 0) AS net
FROM transactions
WHERE date >= $1 AND date <= $2`,
[currentMonthStart, currentMonthEnd],
);
const netBalanceCurrent = Number(currentBalanceRows[0]?.net ?? 0);
// 2. YTD balance
const ytdRows = await db.select<Array<{ net: number | null }>>(
`SELECT COALESCE(SUM(amount), 0) AS net
FROM transactions
WHERE date >= $1 AND date <= $2`,
[yearStart, refIso],
);
const netBalanceYtd = Number(ytdRows[0]?.net ?? 0);
// 3. 12-month sparkline series
const seriesRows = await db.select<Array<{ month: string; net: number | null }>>(
`SELECT strftime('%Y-%m', date) AS month, COALESCE(SUM(amount), 0) AS net
FROM transactions
WHERE date >= $1 AND date <= $2
GROUP BY month
ORDER BY month ASC`,
[sparklineStart, currentMonthEnd],
);
const seriesMap = new Map(seriesRows.map((r) => [r.month, Number(r.net ?? 0)]));
const monthlyBalanceSeries: MonthBalance[] = [];
for (let i = 11; i >= 0; i--) {
const monthKey = shiftMonthStart(refIso, -i).slice(0, 7);
monthlyBalanceSeries.push({ month: monthKey, netBalance: seriesMap.get(monthKey) ?? 0 });
}
// 4. Top movers — expense-side only (amount < 0), compare current vs previous month
const moversRows = await db.select<
Array<{
category_id: number | null;
category_name: string;
category_color: string;
current_total: number | null;
previous_total: number | null;
}>
>(
`SELECT
t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color,
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.amount < 0
AND (
(t.date >= $1 AND t.date <= $2)
OR (t.date >= $3 AND t.date <= $4)
)
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(current_total - previous_total) DESC
LIMIT $5`,
[currentMonthStart, currentMonthEnd, previousMonthStart, previousMonthEnd, topMoversLimit],
);
const topMovers: HighlightMover[] = moversRows.map((r) => {
const current = Number(r.current_total ?? 0);
const previous = Number(r.previous_total ?? 0);
const deltaAbs = current - previous;
const deltaPct = previous === 0 ? null : (deltaAbs / previous) * 100;
return {
categoryId: r.category_id,
categoryName: r.category_name,
categoryColor: r.category_color,
previousAmount: previous,
currentAmount: current,
deltaAbs,
deltaPct,
};
});
// 5. Top transactions within the recent window
const recentRows = 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 t.date >= $1 AND t.date <= $2
ORDER BY ABS(t.amount) DESC
LIMIT $3`,
[recentWindowStart, refIso, topTransactionsLimit],
);
return {
currentMonth,
netBalanceCurrent,
netBalanceYtd,
monthlyBalanceSeries,
topMovers,
topTransactions: recentRows,
};
}
// --- Compare (Issue #73) ---
interface RawDeltaRow {
category_id: number | null;
category_name: string;
category_color: string;
current_total: number | null;
previous_total: number | null;
}
function rowsToDeltas(rows: RawDeltaRow[]): CategoryDelta[] {
return rows.map((r) => {
const current = Number(r.current_total ?? 0);
const previous = Number(r.previous_total ?? 0);
const deltaAbs = current - previous;
const deltaPct = previous === 0 ? null : (deltaAbs / previous) * 100;
return {
categoryId: r.category_id,
categoryName: r.category_name,
categoryColor: r.category_color,
previousAmount: previous,
currentAmount: current,
deltaAbs,
deltaPct,
};
});
}
function monthBoundaries(year: number, month: number): { start: string; end: string } {
const mm = String(month).padStart(2, "0");
const endDate = new Date(Date.UTC(year, month, 0));
const dd = String(endDate.getUTCDate()).padStart(2, "0");
return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` };
}
function previousMonth(year: number, month: number): { year: number; month: number } {
if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 };
}
/**
* Month-over-month expense delta by category. All SQL parameterised.
*/
export async function getCompareMonthOverMonth(
year: number,
month: number,
): Promise<CategoryDelta[]> {
const db = await getDb();
const { start: curStart, end: curEnd } = monthBoundaries(year, month);
const prev = previousMonth(year, month);
const { start: prevStart, end: prevEnd } = monthBoundaries(prev.year, prev.month);
const rows = await db.select<RawDeltaRow[]>(
`SELECT
t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color,
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.amount < 0
AND (
(t.date >= $1 AND t.date <= $2)
OR (t.date >= $3 AND t.date <= $4)
)
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(current_total - previous_total) DESC`,
[curStart, curEnd, prevStart, prevEnd],
);
return rowsToDeltas(rows);
}
/**
* Year-over-year expense delta by category. All SQL parameterised.
*/
export async function getCompareYearOverYear(year: number): Promise<CategoryDelta[]> {
const db = await getDb();
const curStart = `${year}-01-01`;
const curEnd = `${year}-12-31`;
const prevStart = `${year - 1}-01-01`;
const prevEnd = `${year - 1}-12-31`;
const rows = await db.select<RawDeltaRow[]>(
`SELECT
t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color,
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.amount < 0
AND (
(t.date >= $1 AND t.date <= $2)
OR (t.date >= $3 AND t.date <= $4)
)
GROUP BY t.category_id, category_name, category_color
ORDER BY ABS(current_total - previous_total) DESC`,
[curStart, curEnd, prevStart, prevEnd],
);
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,
};
}