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>
572 lines
18 KiB
TypeScript
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,
|
|
};
|
|
}
|