feat(categories): add categoryTaxonomyService + useCategoryTaxonomy hook (#116)
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:
parent
b37be36ddc
commit
742aa9ec3c
3 changed files with 222 additions and 0 deletions
32
src/hooks/useCategoryTaxonomy.ts
Normal file
32
src/hooks/useCategoryTaxonomy.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/services/categoryTaxonomyService.test.ts
Normal file
102
src/services/categoryTaxonomyService.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
88
src/services/categoryTaxonomyService.ts
Normal file
88
src/services/categoryTaxonomyService.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue