refactor(reports): extract a generic leaf-driven tree builder
Factor the hand-rolled hierarchy walk out of buildCompareTree into a generic buildLeafDrivenTree<T>(leaves, opts): the shared skeleton (leaf/ orphan split, ancestor "relevant" set, DB-order adjacency, buildNode recursion with the MAX_TREE_DEPTH guard + loop-break, magnitude sibling ordering, orphan append, contiguous income->expense->transfer section sort) now lives in one place, parameterised by injection points (categoryIdOf, makeLeaf, makeSubtotal, decorateDirectLeaf, makeOrphan, sortKey, isSubtotal, sectionOf, sectionOrder, maxDepth). buildCompareTree becomes a thin specialisation supplying only the delta arithmetic; output is byte-identical (compare tests unchanged as the non-regression guard). Exported (with TreeCatMeta / TreeSectionType / LeafDrivenTreeOptions) so the trends tree reuses the exact same skeleton. Behavior-preserving refactor: no user-facing change, no CHANGELOG entry, no DB migration. Resolves #263 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5ade8650cf
commit
5a1ce42034
1 changed files with 216 additions and 124 deletions
|
|
@ -435,6 +435,206 @@ function monthBoundaries(year: number, month: number): { start: string; end: str
|
||||||
return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` };
|
return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Generic leaf-driven tree builder (Issue #263) ---
|
||||||
|
|
||||||
|
/** Section key shared by every hierarchical report (income-statement order). */
|
||||||
|
export type TreeSectionType = "expense" | "income" | "transfer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal category metadata every hierarchical report needs: id, display
|
||||||
|
* name/color, income-statement `type`, and `parent_id` to walk the hierarchy.
|
||||||
|
* Shared by the compare tree (Issue #247) and the trends tree (Issue #264).
|
||||||
|
*/
|
||||||
|
export interface TreeCatMeta {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
type: TreeSectionType | null;
|
||||||
|
parent_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
|
||||||
|
const MAX_TREE_DEPTH = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection points that specialise the generic tree builder for a concrete row
|
||||||
|
* type `T`. `T` is BOTH the flat leaf payload fed in and the enriched row shape
|
||||||
|
* emitted — leaf/subtotal rows just add the hierarchy fields (`parent_id`,
|
||||||
|
* `is_parent`, `depth`, …) the caller writes in `makeLeaf` / `makeSubtotal`.
|
||||||
|
*/
|
||||||
|
export interface LeafDrivenTreeOptions<T> {
|
||||||
|
/** Category hierarchy (fetched without an is_active filter — keeps history). */
|
||||||
|
categories: TreeCatMeta[];
|
||||||
|
/** Category a leaf belongs to; a null/absent id → orphan (top-level leaf). */
|
||||||
|
categoryIdOf: (leaf: T) => number | null;
|
||||||
|
/** Enrich a leaf payload into a leaf row parented at `parentId` / `depth`. */
|
||||||
|
makeLeaf: (cat: TreeCatMeta, leaf: T, parentId: number | null, depth: number) => T;
|
||||||
|
/** Reduce a node's descendant leaves into its subtotal (is_parent) row. */
|
||||||
|
makeSubtotal: (
|
||||||
|
cat: TreeCatMeta,
|
||||||
|
descendantLeaves: T[],
|
||||||
|
parentId: number | null,
|
||||||
|
depth: number,
|
||||||
|
) => T;
|
||||||
|
/** Annotate the "(direct)" leaf of a parent that also has own transactions. */
|
||||||
|
decorateDirectLeaf: (leafRow: T, cat: TreeCatMeta) => T;
|
||||||
|
/** Enrich an orphan (Uncategorized / hard-deleted) payload into a root leaf. */
|
||||||
|
makeOrphan: (leaf: T) => T;
|
||||||
|
/** Magnitude used to order siblings (descending) — read off a block's head. */
|
||||||
|
sortKey: (row: T) => number;
|
||||||
|
/** True when a row is a subtotal, so descendant *leaves* can be summed. */
|
||||||
|
isSubtotal: (row: T) => boolean;
|
||||||
|
/** Section key of a row for the final contiguous type sort. */
|
||||||
|
sectionOf: (row: T) => string;
|
||||||
|
/** Section order, income-first e.g. `{ income: 0, expense: 1, transfer: 2 }`. */
|
||||||
|
sectionOrder: Record<string, number>;
|
||||||
|
/** Rank for a section absent from `sectionOrder` (default 9 — sorts last). */
|
||||||
|
unknownSectionRank?: number;
|
||||||
|
/** Cyclic-chain depth cap (default `MAX_TREE_DEPTH`). */
|
||||||
|
maxDepth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic leaf-driven hierarchy builder. Turns a flat list of per-category
|
||||||
|
* leaves into a parent/child tree with subtotal rows, ordered by magnitude
|
||||||
|
* within each type and grouped into contiguous income → expense → transfer
|
||||||
|
* sections. Driven by the leaves that actually exist (not the full category
|
||||||
|
* list), so netted-to-zero groups and soft-deleted categories with history are
|
||||||
|
* preserved and no empty rows appear.
|
||||||
|
*
|
||||||
|
* Behavior — including the `MAX_TREE_DEPTH` guard and buildNode loop-break
|
||||||
|
* against a corrupted parent_id cycle — is shared verbatim by every
|
||||||
|
* hierarchical report; the row arithmetic is injected via `opts`. Extracted
|
||||||
|
* from `buildCompareTree` (Issue #263) so the trends tree (Issue #264) reuses
|
||||||
|
* the exact same skeleton.
|
||||||
|
*/
|
||||||
|
export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<T>): T[] {
|
||||||
|
const {
|
||||||
|
categories,
|
||||||
|
categoryIdOf,
|
||||||
|
makeLeaf,
|
||||||
|
makeSubtotal,
|
||||||
|
decorateDirectLeaf,
|
||||||
|
makeOrphan,
|
||||||
|
sortKey,
|
||||||
|
isSubtotal,
|
||||||
|
sectionOf,
|
||||||
|
sectionOrder,
|
||||||
|
unknownSectionRank = 9,
|
||||||
|
maxDepth = MAX_TREE_DEPTH,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const catById = new Map<number, TreeCatMeta>();
|
||||||
|
for (const c of categories) catById.set(c.id, c);
|
||||||
|
|
||||||
|
// Flat leaf lookup by category id. Rows whose id is null (Uncategorized) or
|
||||||
|
// absent from the table (hard-deleted) become orphans: depth-0 leaves
|
||||||
|
// preserved in their incoming order.
|
||||||
|
const leafByCat = new Map<number, T>();
|
||||||
|
const orphans: T[] = [];
|
||||||
|
for (const l of leaves) {
|
||||||
|
const id = categoryIdOf(l);
|
||||||
|
if (id != null && catById.has(id)) {
|
||||||
|
leafByCat.set(id, l);
|
||||||
|
} else {
|
||||||
|
orphans.push(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Relevant" = every category that has a leaf plus all of its ancestors.
|
||||||
|
const relevant = new Set<number>();
|
||||||
|
for (const id of leafByCat.keys()) {
|
||||||
|
let cur: number | null | undefined = id;
|
||||||
|
let guard = 0;
|
||||||
|
while (cur != null && guard <= maxDepth) {
|
||||||
|
if (relevant.has(cur)) break;
|
||||||
|
relevant.add(cur);
|
||||||
|
cur = catById.get(cur)?.parent_id ?? null;
|
||||||
|
guard++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjacency among relevant categories only, preserving DB order.
|
||||||
|
const childrenByParent = new Map<number, TreeCatMeta[]>();
|
||||||
|
for (const c of categories) {
|
||||||
|
if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) {
|
||||||
|
let arr = childrenByParent.get(c.parent_id);
|
||||||
|
if (!arr) childrenByParent.set(c.parent_id, (arr = []));
|
||||||
|
arr.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Block {
|
||||||
|
rows: T[];
|
||||||
|
sortKey: number; // magnitude of the block head — orders siblings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a node's block: a pure leaf, or a subtotal followed by its
|
||||||
|
// (recursively built) child blocks, siblings ordered by magnitude desc.
|
||||||
|
const buildNode = (cat: TreeCatMeta, depth: number): Block | null => {
|
||||||
|
// Stop descending past the depth cap so a corrupted parent_id cycle can
|
||||||
|
// never recurse forever — deeper nodes collapse to leaves (real category
|
||||||
|
// trees are ≤ 3 levels).
|
||||||
|
const children = depth >= maxDepth ? [] : (childrenByParent.get(cat.id) ?? []);
|
||||||
|
const hasDirect = leafByCat.has(cat.id);
|
||||||
|
|
||||||
|
if (children.length === 0) {
|
||||||
|
if (!hasDirect) return null;
|
||||||
|
const leaf = makeLeaf(cat, leafByCat.get(cat.id)!, cat.parent_id ?? null, depth);
|
||||||
|
return { rows: [leaf], sortKey: sortKey(leaf) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const childBlocks: Block[] = [];
|
||||||
|
// A category with both children AND its own transactions surfaces the direct
|
||||||
|
// spend as a "(direct)" leaf so the subtotal stays the sum of its visible
|
||||||
|
// rows. Rare: a parent auto-loses is_inputable when a child is added.
|
||||||
|
if (hasDirect) {
|
||||||
|
const direct = decorateDirectLeaf(
|
||||||
|
makeLeaf(cat, leafByCat.get(cat.id)!, cat.id, depth + 1),
|
||||||
|
cat,
|
||||||
|
);
|
||||||
|
childBlocks.push({ rows: [direct], sortKey: sortKey(direct) });
|
||||||
|
}
|
||||||
|
for (const child of children) {
|
||||||
|
const b = buildNode(child, depth + 1);
|
||||||
|
if (b) childBlocks.push(b);
|
||||||
|
}
|
||||||
|
childBlocks.sort((a, b) => b.sortKey - a.sortKey);
|
||||||
|
|
||||||
|
const childRows = childBlocks.flatMap((b) => b.rows);
|
||||||
|
const descendantLeaves = childRows.filter((r) => !isSubtotal(r));
|
||||||
|
const subtotal = makeSubtotal(cat, descendantLeaves, cat.parent_id ?? null, depth);
|
||||||
|
return { rows: [subtotal, ...childRows], sortKey: sortKey(subtotal) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Roots = relevant categories with no relevant parent. Ordered by magnitude.
|
||||||
|
const rootBlocks: Block[] = [];
|
||||||
|
for (const c of categories) {
|
||||||
|
if (!relevant.has(c.id)) continue;
|
||||||
|
if (c.parent_id != null && relevant.has(c.parent_id)) continue;
|
||||||
|
const b = buildNode(c, 0);
|
||||||
|
if (b) rootBlocks.push(b);
|
||||||
|
}
|
||||||
|
rootBlocks.sort((a, b) => b.sortKey - a.sortKey);
|
||||||
|
|
||||||
|
const rows = rootBlocks.flatMap((b) => b.rows);
|
||||||
|
// Orphans (Uncategorized + hard-deleted) appended in their incoming order.
|
||||||
|
for (const l of orphans) rows.push(makeOrphan(l));
|
||||||
|
|
||||||
|
// Stable sort by section so types (income → expense → transfer) are
|
||||||
|
// contiguous; magnitude/tree order within a type is preserved via the index.
|
||||||
|
const order = new Map<T, number>();
|
||||||
|
rows.forEach((r, i) => order.set(r, i));
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const ta = sectionOrder[sectionOf(a)] ?? unknownSectionRank;
|
||||||
|
const tb = sectionOrder[sectionOf(b)] ?? unknownSectionRank;
|
||||||
|
if (ta !== tb) return ta - tb;
|
||||||
|
return order.get(a)! - order.get(b)!;
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Compare hierarchy (Issue #247) ---
|
// --- Compare hierarchy (Issue #247) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -443,13 +643,7 @@ function monthBoundaries(year: number, month: number): { start: string; end: str
|
||||||
* historic transactions keep their place in the hierarchy — matching the raw
|
* historic transactions keep their place in the hierarchy — matching the raw
|
||||||
* LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown).
|
* LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown).
|
||||||
*/
|
*/
|
||||||
interface CompareCatMeta {
|
type CompareCatMeta = TreeCatMeta;
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
color: string | null;
|
|
||||||
type: "expense" | "income" | "transfer" | null;
|
|
||||||
parent_id: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
|
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
|
||||||
|
|
||||||
|
|
@ -457,9 +651,6 @@ const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM cat
|
||||||
// (netted) transfers — the two result lines are injected around them in the UI.
|
// (netted) transfers — the two result lines are injected around them in the UI.
|
||||||
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
||||||
|
|
||||||
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
|
|
||||||
const MAX_TREE_DEPTH = 5;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns the flat per-category deltas returned by COMPARE_DELTA_SQL into a
|
* Turns the flat per-category deltas returned by COMPARE_DELTA_SQL into a
|
||||||
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
|
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
|
||||||
|
|
@ -468,9 +659,12 @@ const MAX_TREE_DEPTH = 5;
|
||||||
* The #243 transfer netting is untouched: leaf values are exactly what the SQL
|
* The #243 transfer netting is untouched: leaf values are exactly what the SQL
|
||||||
* returned, and a subtotal is the arithmetic sum of its descendant leaves — so a
|
* returned, and a subtotal is the arithmetic sum of its descendant leaves — so a
|
||||||
* group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
|
* group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
|
||||||
* net. Driven by `leaves` (the categories that actually had transactions in the
|
* net.
|
||||||
* window) rather than the full category list, so netted-to-zero transfers and
|
*
|
||||||
* soft-deleted categories with history are preserved and no empty rows appear.
|
* A thin specialisation of `buildLeafDrivenTree` (Issue #263): the generic
|
||||||
|
* skeleton drives the hierarchy walk, sibling ordering, depth guard and section
|
||||||
|
* sort; this only supplies the delta arithmetic. Behavior is byte-identical to
|
||||||
|
* the previous hand-rolled builder — the compare tests are the guard.
|
||||||
*
|
*
|
||||||
* Exported for unit testing.
|
* Exported for unit testing.
|
||||||
*/
|
*/
|
||||||
|
|
@ -478,65 +672,19 @@ export function buildCompareTree(
|
||||||
leaves: CategoryDelta[],
|
leaves: CategoryDelta[],
|
||||||
categories: CompareCatMeta[],
|
categories: CompareCatMeta[],
|
||||||
): CategoryDelta[] {
|
): CategoryDelta[] {
|
||||||
const catById = new Map<number, CompareCatMeta>();
|
const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense";
|
||||||
for (const c of categories) catById.set(c.id, c);
|
|
||||||
|
|
||||||
// Flat delta lookup by category id. Rows whose category id is null
|
return buildLeafDrivenTree<CategoryDelta>(leaves, {
|
||||||
// (Uncategorized) or absent from the table (hard-deleted) become orphans:
|
categories,
|
||||||
// depth-0 leaves preserved in their incoming order.
|
categoryIdOf: (d) => d.categoryId,
|
||||||
const deltaByCat = new Map<number, CategoryDelta>();
|
makeLeaf: (cat, d, parentId, depth) => ({
|
||||||
const orphans: CategoryDelta[] = [];
|
...d,
|
||||||
for (const d of leaves) {
|
|
||||||
if (d.categoryId != null && catById.has(d.categoryId)) {
|
|
||||||
deltaByCat.set(d.categoryId, d);
|
|
||||||
} else {
|
|
||||||
orphans.push(d);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Relevant" = every category that has a delta plus all of its ancestors.
|
|
||||||
const relevant = new Set<number>();
|
|
||||||
for (const id of deltaByCat.keys()) {
|
|
||||||
let cur: number | null | undefined = id;
|
|
||||||
let guard = 0;
|
|
||||||
while (cur != null && guard <= MAX_TREE_DEPTH) {
|
|
||||||
if (relevant.has(cur)) break;
|
|
||||||
relevant.add(cur);
|
|
||||||
cur = catById.get(cur)?.parent_id ?? null;
|
|
||||||
guard++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjacency among relevant categories only, preserving DB order.
|
|
||||||
const childrenByParent = new Map<number, CompareCatMeta[]>();
|
|
||||||
for (const c of categories) {
|
|
||||||
if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) {
|
|
||||||
let arr = childrenByParent.get(c.parent_id);
|
|
||||||
if (!arr) childrenByParent.set(c.parent_id, (arr = []));
|
|
||||||
arr.push(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeOf = (c: CompareCatMeta): "expense" | "income" | "transfer" => c.type ?? "expense";
|
|
||||||
|
|
||||||
const leafRow = (
|
|
||||||
cat: CompareCatMeta,
|
|
||||||
parentId: number | null,
|
|
||||||
depth: number,
|
|
||||||
): CategoryDelta => ({
|
|
||||||
...deltaByCat.get(cat.id)!,
|
|
||||||
parent_id: parentId,
|
parent_id: parentId,
|
||||||
is_parent: false,
|
is_parent: false,
|
||||||
depth,
|
depth,
|
||||||
category_type: typeOf(cat),
|
category_type: typeOf(cat),
|
||||||
});
|
}),
|
||||||
|
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
|
||||||
const subtotalRow = (
|
|
||||||
cat: CompareCatMeta,
|
|
||||||
descendantLeaves: CategoryDelta[],
|
|
||||||
parentId: number | null,
|
|
||||||
depth: number,
|
|
||||||
): CategoryDelta => {
|
|
||||||
let previousAmount = 0;
|
let previousAmount = 0;
|
||||||
let currentAmount = 0;
|
let currentAmount = 0;
|
||||||
let cumulativePreviousAmount = 0;
|
let cumulativePreviousAmount = 0;
|
||||||
|
|
@ -571,81 +719,25 @@ export function buildCompareTree(
|
||||||
depth,
|
depth,
|
||||||
category_type: typeOf(cat),
|
category_type: typeOf(cat),
|
||||||
};
|
};
|
||||||
};
|
},
|
||||||
|
// A parent with both children and its own transactions surfaces the direct
|
||||||
interface Block {
|
// spend as a "(direct)" leaf (mirrors getBudgetVsActualData).
|
||||||
rows: CategoryDelta[];
|
decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
|
||||||
sortKey: number; // |monthly delta| of the block head — orders siblings
|
// Orphans keep their own already-computed delta; only the hierarchy fields
|
||||||
}
|
// are stamped. Uncategorized rows (category_type undefined) → 'expense'.
|
||||||
|
makeOrphan: (d) => ({
|
||||||
// Builds a node's block: a pure leaf, or a subtotal followed by its
|
...d,
|
||||||
// (recursively built) child blocks, siblings ordered by |monthly delta| desc.
|
parent_id: null,
|
||||||
const buildNode = (cat: CompareCatMeta, depth: number): Block | null => {
|
is_parent: false,
|
||||||
// Stop descending past the depth cap so a corrupted parent_id cycle can
|
depth: 0,
|
||||||
// never recurse forever — deeper nodes collapse to leaves (real category
|
category_type: d.category_type ?? "expense",
|
||||||
// trees are ≤ 3 levels).
|
}),
|
||||||
const children = depth >= MAX_TREE_DEPTH ? [] : (childrenByParent.get(cat.id) ?? []);
|
sortKey: (row) => Math.abs(row.deltaAbs),
|
||||||
const hasDirect = deltaByCat.has(cat.id);
|
isSubtotal: (row) => row.is_parent === true,
|
||||||
|
sectionOf: (row) => row.category_type ?? "expense",
|
||||||
if (children.length === 0) {
|
sectionOrder: COMPARE_TYPE_ORDER,
|
||||||
if (!hasDirect) return null;
|
|
||||||
const leaf = leafRow(cat, cat.parent_id ?? null, depth);
|
|
||||||
return { rows: [leaf], sortKey: Math.abs(leaf.deltaAbs) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const childBlocks: Block[] = [];
|
|
||||||
// A category with both children AND its own transactions surfaces the direct
|
|
||||||
// spend as a "(direct)" leaf so the subtotal stays the sum of its visible
|
|
||||||
// rows (mirrors getBudgetVsActualData). Rare: a parent auto-loses
|
|
||||||
// is_inputable when a child is added.
|
|
||||||
if (hasDirect) {
|
|
||||||
const direct = leafRow(cat, cat.id, depth + 1);
|
|
||||||
childBlocks.push({
|
|
||||||
rows: [{ ...direct, categoryName: `${cat.name} (direct)` }],
|
|
||||||
sortKey: Math.abs(direct.deltaAbs),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const child of children) {
|
|
||||||
const b = buildNode(child, depth + 1);
|
|
||||||
if (b) childBlocks.push(b);
|
|
||||||
}
|
|
||||||
childBlocks.sort((a, b) => b.sortKey - a.sortKey);
|
|
||||||
|
|
||||||
const childRows = childBlocks.flatMap((b) => b.rows);
|
|
||||||
const descendantLeaves = childRows.filter((r) => !r.is_parent);
|
|
||||||
const subtotal = subtotalRow(cat, descendantLeaves, cat.parent_id ?? null, depth);
|
|
||||||
return { rows: [subtotal, ...childRows], sortKey: Math.abs(subtotal.deltaAbs) };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Roots = relevant categories with no relevant parent. Ordered by magnitude.
|
|
||||||
const rootBlocks: Block[] = [];
|
|
||||||
for (const c of categories) {
|
|
||||||
if (!relevant.has(c.id)) continue;
|
|
||||||
if (c.parent_id != null && relevant.has(c.parent_id)) continue;
|
|
||||||
const b = buildNode(c, 0);
|
|
||||||
if (b) rootBlocks.push(b);
|
|
||||||
}
|
|
||||||
rootBlocks.sort((a, b) => b.sortKey - a.sortKey);
|
|
||||||
|
|
||||||
const rows = rootBlocks.flatMap((b) => b.rows);
|
|
||||||
// Orphans (Uncategorized + hard-deleted) appended in their incoming order.
|
|
||||||
for (const d of orphans) {
|
|
||||||
rows.push({ ...d, parent_id: null, is_parent: false, depth: 0, category_type: d.category_type ?? "expense" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stable sort by type so sections (income → expense → transfer) are
|
|
||||||
// contiguous; magnitude/tree order within a type is preserved via the index.
|
|
||||||
const order = new Map<CategoryDelta, number>();
|
|
||||||
rows.forEach((r, i) => order.set(r, i));
|
|
||||||
rows.sort((a, b) => {
|
|
||||||
const ta = COMPARE_TYPE_ORDER[a.category_type ?? "expense"] ?? 9;
|
|
||||||
const tb = COMPARE_TYPE_ORDER[b.category_type ?? "expense"] ?? 9;
|
|
||||||
if (ta !== tb) return ta - tb;
|
|
||||||
return order.get(a)! - order.get(b)!;
|
|
||||||
});
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function previousMonth(year: number, month: number): { year: number; month: number } {
|
function previousMonth(year: number, month: number): { year: number; month: number } {
|
||||||
if (month === 1) return { year: year - 1, month: 12 };
|
if (month === 1) return { year: year - 1, month: 12 };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue