diff --git a/src/hooks/useCategoryTaxonomy.ts b/src/hooks/useCategoryTaxonomy.ts new file mode 100644 index 0000000..c01eadb --- /dev/null +++ b/src/hooks/useCategoryTaxonomy.ts @@ -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( + () => ({ + taxonomy: getTaxonomyV1(), + findById, + findByPath, + getLeaves, + getParentById, + }), + [], + ); +} diff --git a/src/services/categoryTaxonomyService.test.ts b/src/services/categoryTaxonomyService.test.ts new file mode 100644 index 0000000..3c834a6 --- /dev/null +++ b/src/services/categoryTaxonomyService.test.ts @@ -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(); + }); +}); diff --git a/src/services/categoryTaxonomyService.ts b/src/services/categoryTaxonomyService.ts new file mode 100644 index 0000000..3b9aab5 --- /dev/null +++ b/src/services/categoryTaxonomyService.ts @@ -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; +}