Merge pull request 'feat(categories): categoryTaxonomyService + useCategoryTaxonomy (#116)' (#127) from issue-116-category-taxonomy-service into main

This commit is contained in:
maximus 2026-04-21 00:54:58 +00:00
commit b8fa089c5f
3 changed files with 222 additions and 0 deletions

View file

@ -0,0 +1,32 @@
import { useMemo } from "react";
import {
getTaxonomyV1,
findById,
findByPath,
getLeaves,
getParentById,
type Taxonomy,
type TaxonomyNode,
type TaxonomyLeaf,
} from "../services/categoryTaxonomyService";
export interface UseCategoryTaxonomyResult {
taxonomy: Taxonomy;
findById: (id: number) => TaxonomyNode | null;
findByPath: (path: string[]) => TaxonomyNode | null;
getLeaves: () => TaxonomyLeaf[];
getParentById: (id: number) => TaxonomyNode | null;
}
export function useCategoryTaxonomy(): UseCategoryTaxonomyResult {
return useMemo<UseCategoryTaxonomyResult>(
() => ({
taxonomy: getTaxonomyV1(),
findById,
findByPath,
getLeaves,
getParentById,
}),
[],
);
}

View file

@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
getTaxonomyV1,
findById,
findByPath,
getLeaves,
getParentById,
resetTaxonomyCache,
} from "./categoryTaxonomyService";
beforeEach(() => {
resetTaxonomyCache();
});
describe("getTaxonomyV1", () => {
it("returns version v1 with non-empty roots", () => {
const tax = getTaxonomyV1();
expect(tax.version).toBe("v1");
expect(tax.roots.length).toBeGreaterThan(0);
});
it("caches the taxonomy across calls (same reference)", () => {
const a = getTaxonomyV1();
const b = getTaxonomyV1();
expect(a).toBe(b);
});
});
describe("findById", () => {
it("finds a root-level node", () => {
const node = findById(1000);
expect(node).not.toBeNull();
expect(node?.name).toBe("Revenus");
});
it("finds a nested leaf node", () => {
const node = findById(1011);
expect(node).not.toBeNull();
expect(node?.i18n_key).toBe("categoriesSeed.revenus.emploi.paie");
});
it("returns null for an unknown id", () => {
expect(findById(99999)).toBeNull();
});
});
describe("findByPath", () => {
it("returns null for an empty path", () => {
expect(findByPath([])).toBeNull();
});
it("finds a root by single-segment path", () => {
const node = findByPath(["Revenus"]);
expect(node?.id).toBe(1000);
});
it("finds a nested leaf by multi-segment path", () => {
const node = findByPath(["Revenus", "Emploi", "Paie régulière"]);
expect(node?.id).toBe(1011);
});
it("returns null when a segment does not match", () => {
expect(findByPath(["Revenus", "Emploi", "NoSuchLeaf"])).toBeNull();
expect(findByPath(["Unknown"])).toBeNull();
});
});
describe("getLeaves", () => {
it("returns only nodes with no children", () => {
const leaves = getLeaves();
expect(leaves.length).toBeGreaterThan(0);
for (const leaf of leaves) {
expect(leaf.children.length).toBe(0);
}
});
it("includes a known leaf by id", () => {
const leaves = getLeaves();
expect(leaves.some((l) => l.id === 1011)).toBe(true);
});
});
describe("getParentById", () => {
it("returns the direct parent of a leaf", () => {
const parent = getParentById(1011);
expect(parent?.id).toBe(1010);
expect(parent?.name).toBe("Emploi");
});
it("returns the root as parent of a level-2 node", () => {
const parent = getParentById(1010);
expect(parent?.id).toBe(1000);
});
it("returns null for a root-level node", () => {
expect(getParentById(1000)).toBeNull();
});
it("returns null for an unknown id", () => {
expect(getParentById(99999)).toBeNull();
});
});

View file

@ -0,0 +1,88 @@
import taxonomyData from "../data/categoryTaxonomyV1.json";
export type TaxonomyCategoryType = "expense" | "income" | "transfer";
export interface TaxonomyNode {
id: number;
name: string;
i18n_key: string;
type: TaxonomyCategoryType;
color: string;
sort_order: number;
children: TaxonomyNode[];
}
export type TaxonomyLeaf = TaxonomyNode & { children: [] };
export type TaxonomyRoot = TaxonomyNode;
export interface Taxonomy {
version: string;
description: string;
roots: TaxonomyRoot[];
}
let cached: Taxonomy | null = null;
function loadTaxonomy(): Taxonomy {
if (cached !== null) return cached;
cached = taxonomyData as Taxonomy;
return cached;
}
export function getTaxonomyV1(): Taxonomy {
return loadTaxonomy();
}
export function findById(id: number): TaxonomyNode | null {
const stack: TaxonomyNode[] = [...loadTaxonomy().roots];
while (stack.length > 0) {
const node = stack.pop() as TaxonomyNode;
if (node.id === id) return node;
stack.push(...node.children);
}
return null;
}
export function findByPath(path: string[]): TaxonomyNode | null {
if (path.length === 0) return null;
const { roots } = loadTaxonomy();
let current: TaxonomyNode | undefined = roots.find((r) => r.name === path[0]);
for (let i = 1; i < path.length && current !== undefined; i++) {
current = current.children.find((c) => c.name === path[i]);
}
return current ?? null;
}
export function getLeaves(): TaxonomyLeaf[] {
const leaves: TaxonomyLeaf[] = [];
const walk = (node: TaxonomyNode): void => {
if (node.children.length === 0) {
leaves.push(node as TaxonomyLeaf);
return;
}
for (const child of node.children) walk(child);
};
for (const root of loadTaxonomy().roots) walk(root);
return leaves;
}
export function getParentById(id: number): TaxonomyNode | null {
const visit = (node: TaxonomyNode): TaxonomyNode | null => {
for (const child of node.children) {
if (child.id === id) return node;
const deeper = visit(child);
if (deeper !== null) return deeper;
}
return null;
};
for (const root of loadTaxonomy().roots) {
const result = visit(root);
if (result !== null) return result;
}
return null;
}
export function resetTaxonomyCache(): void {
cached = null;
}