feat(catalog): part-count signposting + drill hint for deep category trees
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
pcat/emex vehicles decode into a tree whose top level is ~100% folders with zero parts (6746/9458 root nodes are parents, 0 direct leaves across 477 vehicles). Users — serkan filazi among them — saw an opaque wall of identical folder cards, assumed the parts feature was missing, and bounced without ever drilling (his session: 3 view-mode toggles, 0 category clicks; his data was 2-3 clicks deep and fully reachable). Two changes, no upstream/proxy cost: - getCategoryTree now rolls up a subtree partCount per node (one grouped parts read + JS post-order sum). The grid already renders "N parça", so folders now read "Fren sistemi · 142 parça" and steer drilling toward the branches that actually contain parts; empty branches stay quiet. Counts reflect drilled data and grow as branches are visited. - A one-line drill hint on a "cold" tree (nothing drilled yet → no counts) so first-visit users know parts live inside the subcategories instead of reading the folder wall as a broken/empty catalog. Drops away once any branch is hit. Verified: subtree roll-up unit test + existing suite (10/10), tsc + biome clean across api/web/shared. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,7 @@ describe("CategoriesService", () => {
|
||||
// getCategoryTree:
|
||||
// 1: select().from(vehicles).where().limit(1) — where→limit
|
||||
// 2: select().from(categories).where() — where is terminal
|
||||
// 3: select().from(parts).where().groupBy() — per-category part counts
|
||||
let selectCall = 0;
|
||||
const db = {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
@@ -108,6 +109,8 @@ describe("CategoriesService", () => {
|
||||
if (captured === 2) return dbCategories; // terminal for categories
|
||||
return c;
|
||||
});
|
||||
// 3 direct parts on the child leaf c2; none directly on the root c1.
|
||||
c.groupBy = vi.fn().mockReturnValue([{ categoryId: "c2", count: 3 }]);
|
||||
c.limit = vi.fn().mockReturnValue([vehicle]);
|
||||
return c;
|
||||
}),
|
||||
@@ -118,6 +121,10 @@ describe("CategoriesService", () => {
|
||||
expect(result).toBeInstanceOf(Array);
|
||||
expect(result.length).toBe(1); // root node
|
||||
expect(result[0].children.length).toBe(1); // child node
|
||||
// Subtree roll-up: the root reports its child leaf's parts so the UI can
|
||||
// steer drilling toward populated branches.
|
||||
expect(result[0].partCount).toBe(3);
|
||||
expect(result[0].children[0].partCount).toBe(3);
|
||||
expect(redis.setJson).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -418,8 +418,21 @@ export class CategoriesService {
|
||||
}
|
||||
}
|
||||
|
||||
// Direct part counts per category for this vehicle — one grouped read, no
|
||||
// upstream calls. buildTree rolls these up into per-node subtree totals so
|
||||
// the UI can show "142 parça" on a folder and steer drilling toward the
|
||||
// branches that actually contain parts.
|
||||
const partCountRows = await this.db
|
||||
.select({ categoryId: parts.categoryId, count: sql<number>`count(*)::int` })
|
||||
.from(parts)
|
||||
.where(eq(parts.vehicleId, vehicleId))
|
||||
.groupBy(parts.categoryId);
|
||||
const directPartCounts = new Map<string | null, number>(
|
||||
partCountRows.map((r) => [r.categoryId, r.count]),
|
||||
);
|
||||
|
||||
// Build tree
|
||||
const tree = this.buildTree(dbCategories);
|
||||
const tree = this.buildTree(dbCategories, directPartCounts);
|
||||
// Cache a populated tree for an hour; an EMPTY tree (transient decode/proxy
|
||||
// failure) only for 60s so a blip doesn't poison the catalog for an hour —
|
||||
// it self-heals on the next request after the source recovers, while still
|
||||
@@ -1870,12 +1883,17 @@ export class CategoriesService {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
private buildTree(items: any[]): any[] {
|
||||
private buildTree(items: any[], directPartCounts?: Map<string | null, number>): any[] {
|
||||
const map = new Map<string, any>();
|
||||
const roots: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
map.set(item.id, { ...item, children: [] });
|
||||
map.set(item.id, {
|
||||
...item,
|
||||
children: [],
|
||||
// Direct parts on this exact node; rolled up into a subtree total below.
|
||||
partCount: directPartCounts?.get(item.id) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
@@ -1888,6 +1906,21 @@ export class CategoriesService {
|
||||
}
|
||||
}
|
||||
|
||||
// Roll up subtree part counts (post-order) so a folder reports how many
|
||||
// parts live anywhere beneath it. Deep pcat/emex trees are otherwise an
|
||||
// opaque wall of identical folders (no parts at the top level at all) — a
|
||||
// count lets the UI point users straight to the populated branches and skip
|
||||
// the empty ones, instead of blind drilling. Counts reflect what's been
|
||||
// drilled so far (lazy trees undercount → 0 hides cleanly); they grow as
|
||||
// branches are visited. Must run before the empty-children pruning below.
|
||||
const rollup = (node: any): number => {
|
||||
let total = node.partCount ?? 0;
|
||||
for (const child of node.children) total += rollup(child);
|
||||
node.partCount = total;
|
||||
return total;
|
||||
};
|
||||
for (const root of roots) rollup(root);
|
||||
|
||||
// Remove empty children arrays so frontend can distinguish
|
||||
// leaf nodes (children: []) from unexplored nodes (children: undefined)
|
||||
for (const node of map.values()) {
|
||||
|
||||
Reference in New Issue
Block a user