feat(catalog): part-count signposting + drill hint for deep category trees #116
@@ -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()) {
|
||||
|
||||
@@ -737,6 +737,7 @@
|
||||
"categories": "Spare parts categories",
|
||||
"noCategoriesTitle": "No categories for this vehicle",
|
||||
"noCategoriesHint": "The vehicle was decoded but the catalog may not be ready yet. Reach out to support to expedite it.",
|
||||
"drillHint": "Parts live inside the subcategories — open a category to drill down to its part lists.",
|
||||
"noAttrs": "No detailed info available for this vehicle.",
|
||||
"labelModel": "Model",
|
||||
"labelYear": "Model year",
|
||||
|
||||
@@ -737,6 +737,7 @@
|
||||
"categories": "Yedek Parça Kategorileri",
|
||||
"noCategoriesTitle": "Bu araç için kategori bulunamadı",
|
||||
"noCategoriesHint": "Araç decode edildi ama parça kataloğu henüz hazırlanmamış olabilir. Destek ekibimize bildirirseniz katalog hızlandırılır.",
|
||||
"drillHint": "Parçalar alt kategorilerin içinde yer alır. Bir kategoriye girerek ilerleyin — her grup, içindeki parça listesine kadar açılır.",
|
||||
"noAttrs": "Bu araç için ayrıntı bilgisi bulunamadı.",
|
||||
"labelModel": "Model",
|
||||
"labelYear": "Model yılı",
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Skeleton } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft, Info } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
@@ -92,6 +92,15 @@ function VehicleDetailPage() {
|
||||
enabled: !!id && idValid,
|
||||
});
|
||||
|
||||
// A "cold" tree has nothing drilled yet — no part counts anywhere. This is the
|
||||
// normal first-visit state for pcat/emex vehicles, whose top level is entirely
|
||||
// folders with zero parts. Without a nudge users read that folder wall as
|
||||
// "parts feature missing" and bounce (serkan's exact misread), instead of
|
||||
// drilling in. Once any branch is visited the counts light up and the hint
|
||||
// drops away on its own.
|
||||
const treeCold =
|
||||
!!categoryTree && categoryTree.length > 0 && categoryTree.every((c) => !c.partCount);
|
||||
|
||||
const vehicleLabel: string = vehicle?.brandName
|
||||
? `${vehicle.brandName}${cleanModelName(vehicle.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
|
||||
: t("vehicle.title");
|
||||
@@ -305,6 +314,12 @@ function VehicleDetailPage() {
|
||||
if (active) capture("catalog_search_opened", { vehicle_id: id });
|
||||
}}
|
||||
/>
|
||||
{!searchActive && !categoriesLoading && treeCold && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2.5 text-xs text-muted-foreground">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<span>{t("vehicle.drillHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!searchActive &&
|
||||
(categoriesLoading ? (
|
||||
|
||||
@@ -30,4 +30,8 @@ export interface CategoryNode {
|
||||
parentId: string | null;
|
||||
children: CategoryNode[];
|
||||
hasSchema: boolean;
|
||||
// Total parts known anywhere in this node's subtree (rolled up server-side).
|
||||
// Lets the UI surface "N parça" on a folder and steer drilling toward
|
||||
// populated branches. Reflects drilled data — grows as branches are visited.
|
||||
partCount?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user