feat(categories): add categoryTaxonomyService + useCategoryTaxonomy hook (#116)
All checks were successful
PR Check / rust (push) Successful in 22m44s
PR Check / frontend (push) Successful in 2m17s
PR Check / rust (pull_request) Successful in 22m4s
PR Check / frontend (pull_request) Successful in 2m15s

Source of truth for the v1 IPC taxonomy on the TS side. Loads the bundled
JSON, exposes typed helpers (findById, findByPath, getLeaves, getParentById)
used by the upcoming Guide (#117) and Migration (#121) pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-04-20 20:53:15 -04:00
parent b37be36ddc
commit 742aa9ec3c
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;
}