fix(emex): dynamic filter chain with proper field dedup across all catalogs
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled

- Detect available filter fields from actual data per catalog
- Skip fields whose values are substrings of optionsRaw (e.g. driveType
  redundant when optionsRaw="LEFT (GSX)")
- Skip fields that duplicate already-added filters (e.g. engine=LHD same
  as driveType=LHD in Land Rover)
- Use optionsRaw as primary variant text in vehicle rows
- Works for all 54 catalogs: Renault (structured), Fiat, BMW, Volvo, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 08:38:01 +00:00
parent 01d5367deb
commit e7b1350876

View File

@@ -25,14 +25,7 @@ interface EmexVehicle {
optionsRaw: string | null;
}
// ── Filter definition ────────────────────────────────
interface FilterDef {
key: string;
label: string;
/** Extract the filterable value from a vehicle */
getter: (v: EmexVehicle) => string | null | undefined;
}
// ── Helpers ──────────────────────────────────────────
/** Parse "Key: Value;Key2: Value2" into a map */
function parseOptions(raw: string | null): Record<string, string> {
@@ -47,89 +40,11 @@ function parseOptions(raw: string | null): Record<string, string> {
return map;
}
/** Check if options_raw has key:value structure */
function hasStructuredOptions(vehicles: EmexVehicle[]): boolean {
const sample = vehicles.find((v) => v.optionsRaw);
return sample?.optionsRaw?.includes(":") ?? false;
}
/** Build the list of filter definitions based on what data actually exists */
function buildFilterDefs(
vehicles: EmexVehicle[],
structured: boolean,
): FilterDef[] {
const defs: FilterDef[] = [];
if (structured) {
// Renault-style: extract filters from parsed options_raw
const optionKeys = new Map<string, number>();
for (const v of vehicles) {
const opts = parseOptions(v.optionsRaw);
for (const k of Object.keys(opts)) {
optionKeys.set(k, (optionKeys.get(k) || 0) + 1);
}
}
// "Engine type" and "Engine type and suffix" — use "Engine type" as filter
if (optionKeys.has("Engine type")) {
defs.push({
key: "engineType",
label: "engine",
getter: (v) => parseOptions(v.optionsRaw)["Engine type"] || v.engine,
});
}
if (optionKeys.has("Gearbox type")) {
defs.push({
key: "gearboxType",
label: "gearbox",
getter: (v) =>
parseOptions(v.optionsRaw)["Gearbox type"] || v.transmission,
});
}
if (optionKeys.has("Vehicle type")) {
defs.push({
key: "vehicleType",
label: "vehicleType",
getter: (v) => parseOptions(v.optionsRaw)["Vehicle type"],
});
}
} else {
// Standard fields — add in a sensible order
defs.push({
key: "engine",
label: "engine",
getter: (v) => v.engine,
});
defs.push({
key: "bodyType",
label: "body",
getter: (v) => v.bodyType,
});
defs.push({
key: "transmission",
label: "gearbox",
getter: (v) => v.transmission,
});
defs.push({
key: "driveType",
label: "drive",
getter: (v) => v.driveType,
});
defs.push({
key: "fuelType",
label: "fuel",
getter: (v) => v.fuelType,
});
defs.push({
key: "optionsRaw",
label: "variant",
getter: (v) => v.optionsRaw,
});
}
return defs;
}
/** Get distinct sorted values for a getter */
function distinctValues(
vehicles: EmexVehicle[],
getter: (v: EmexVehicle) => string | null | undefined,
@@ -142,17 +57,127 @@ function distinctValues(
return [...set].sort();
}
// ── Label map ────────────────────────────────────────
// ── Dynamic filter builder ───────────────────────────
const FILTER_LABELS: Record<string, string> = {
engine: "Motor",
gearbox: "Şanzıman",
body: "Kasa Tipi",
drive: "Sürüş",
fuel: "Yakıt",
variant: "Varyant",
vehicleType: "Araç Tipi",
};
interface FilterDef {
key: string;
label: string;
getter: (v: EmexVehicle) => string | null | undefined;
}
/**
* Build filter chain dynamically from what the data actually contains.
* For structured options_raw (Renault/Dacia/Toyota/Lexus), parse the key:value pairs.
* For others, use DB fields in a sensible order and skip optionsRaw if it
* duplicates another field or is the only remaining differentiator (shown as row text).
*/
function buildFilterDefs(
vehicles: EmexVehicle[],
structured: boolean,
): FilterDef[] {
if (structured) {
return buildStructuredFilters(vehicles);
}
return buildFieldFilters(vehicles);
}
function buildStructuredFilters(vehicles: EmexVehicle[]): FilterDef[] {
// Collect all option keys and count usage
const keyCount = new Map<string, number>();
for (const v of vehicles) {
for (const k of Object.keys(parseOptions(v.optionsRaw))) {
keyCount.set(k, (keyCount.get(k) || 0) + 1);
}
}
const defs: FilterDef[] = [];
const labelMap: Record<string, string> = {
"Engine type": "Motor",
"Gearbox type": "Şanzıman",
"Vehicle type": "Araç Tipi",
};
for (const [optKey, label] of Object.entries(labelMap)) {
if (keyCount.has(optKey)) {
const k = optKey;
defs.push({
key: k.replace(/\s+/g, "_"),
label,
getter: (v) => parseOptions(v.optionsRaw)[k] || null,
});
}
}
return defs;
}
function buildFieldFilters(vehicles: EmexVehicle[]): FilterDef[] {
// Candidate fields in priority order
const candidates: {
key: string;
label: string;
getter: (v: EmexVehicle) => string | null | undefined;
}[] = [
{ key: "engine", label: "Motor", getter: (v) => v.engine },
{ key: "bodyType", label: "Kasa Tipi", getter: (v) => v.bodyType },
{ key: "transmission", label: "Şanzıman", getter: (v) => v.transmission },
{ key: "driveType", label: "Sürüş", getter: (v) => v.driveType },
{ key: "fuelType", label: "Yakıt", getter: (v) => v.fuelType },
{ key: "optionsRaw", label: "Varyant", getter: (v) => v.optionsRaw },
];
// Collect distinct values per field
const fieldVals = new Map<string, string[]>();
for (const c of candidates) {
const vals = distinctValues(vehicles, c.getter);
if (vals.length >= 2) fieldVals.set(c.key, vals);
}
const optRawVals = fieldVals.get("optionsRaw") || [];
// Pre-compute optionsRaw values as lowercase set for containment check
const optRawLower = new Set(
vehicles
.map((v) => v.optionsRaw?.toLowerCase())
.filter((x): x is string => !!x),
);
const defs: FilterDef[] = [];
for (const c of candidates) {
if (!fieldVals.has(c.key)) continue;
const vals = fieldVals.get(c.key)!;
const valSet = new Set(vals);
// Skip if this field's distinct values exactly match an already-added field
// (e.g. engine="LHD/RHD" and driveType="LHD/RHD")
const isDuplicateOfPrev = defs.some((d) => {
const dVals = fieldVals.get(d.key) || [];
return (
dVals.length === vals.length && dVals.every((v) => valSet.has(v))
);
});
if (isDuplicateOfPrev) continue;
// Skip optionsRaw if its values exactly match another already-added field
if (c.key === "optionsRaw" && isDuplicateOfPrev) continue;
// Skip a field if every one of its values is a substring of the
// corresponding vehicle's optionsRaw (e.g. driveType="LEFT" inside
// optionsRaw="LEFT (GSX)"). The optionsRaw filter alone is enough.
if (c.key !== "optionsRaw" && optRawVals.length >= 2) {
const isRedundant = vehicles.every((v) => {
const fv = c.getter(v);
const ov = v.optionsRaw;
if (!fv) return true; // no value → not contradicting
if (!ov) return false; // field has value but no optionsRaw → keep field
return ov.toLowerCase().includes(fv.toLowerCase());
});
if (isRedundant) continue;
}
defs.push(c);
}
return defs;
}
// ── Component ────────────────────────────────────────
@@ -171,23 +196,19 @@ function EmexVehicleListPage() {
[vehicles],
);
// All possible filter definitions for this catalog
const allFilterDefs = useMemo(
() => (vehicles ? buildFilterDefs(vehicles, structured) : []),
[vehicles, structured],
);
// Distinct model names
const modelNames = useMemo(
() => (vehicles ? distinctValues(vehicles, (v) => v.name) : []),
[vehicles],
);
const [selectedModel, setSelectedModel] = useState("");
// Dynamic filter selections: { filterKey: selectedValue }
const [filters, setFilters] = useState<Record<string, string>>({});
// Vehicles matching selected model
const modelVehicles = useMemo(
() =>
selectedModel && vehicles
@@ -196,33 +217,25 @@ function EmexVehicleListPage() {
[vehicles, selectedModel],
);
// For each filter def, compute available options (cascading: each filter
// narrows the pool for subsequent filters)
// Cascading filter chain: compute options per filter, narrowing the pool
const filterChain = useMemo(() => {
const chain: {
def: FilterDef;
options: string[];
}[] = [];
const chain: { def: FilterDef; options: string[] }[] = [];
let pool = modelVehicles;
for (const def of allFilterDefs) {
const opts = distinctValues(pool, def.getter);
// Only show filter if there are >1 options
if (opts.length > 1) {
chain.push({ def, options: opts });
}
// If user selected a value for this filter, narrow the pool
const selected = filters[def.key];
if (selected) {
pool = pool.filter((v) => def.getter(v) === selected);
}
}
return chain;
}, [modelVehicles, allFilterDefs, filters]);
// Final filtered vehicles (apply all selected filters)
// Final filtered list
const filteredVehicles = useMemo(() => {
let list = modelVehicles;
for (const def of allFilterDefs) {
@@ -241,7 +254,6 @@ function EmexVehicleListPage() {
const handleFilterChange = useCallback(
(key: string, value: string) => {
// Clear this filter and all downstream filters
const idx = allFilterDefs.findIndex((d) => d.key === key);
const newFilters: Record<string, string> = {};
for (let i = 0; i < idx; i++) {
@@ -266,11 +278,6 @@ function EmexVehicleListPage() {
<h1 className="text-xl font-bold">
{decodeURIComponent(catalogCode)}
</h1>
{vehicles && (
<span className="text-sm text-muted-foreground">
({vehicles.length} {t("catalog.models").toLowerCase()})
</span>
)}
</div>
{isLoading ? (
@@ -287,7 +294,6 @@ function EmexVehicleListPage() {
<>
{/* Dropdown chain */}
<div className="flex flex-wrap gap-3">
{/* Model — always visible */}
<FilterSelect
label={t("catalog.models")}
placeholder={t("catalog.selectModel")}
@@ -296,12 +302,11 @@ function EmexVehicleListPage() {
onChange={handleModelChange}
/>
{/* Dynamic filters — only shown after model selection */}
{selectedModel &&
filterChain.map(({ def, options }) => (
<FilterSelect
key={def.key}
label={FILTER_LABELS[def.label] || def.label}
label={def.label}
placeholder={t("catalog.fordVariant.showAll")}
value={filters[def.key] || ""}
options={options}
@@ -389,32 +394,40 @@ function VehicleRow({
catalogCode: string;
structured: boolean;
}) {
const opts = structured ? parseOptions(vehicle.optionsRaw) : null;
// Build descriptive subtitle
const parts: string[] = [];
if (opts) {
if (structured) {
const opts = parseOptions(vehicle.optionsRaw);
if (opts["Engine type and suffix"])
parts.push(opts["Engine type and suffix"]);
else if (opts["Engine type"]) parts.push(opts["Engine type"]);
if (opts["Gearbox type"]) parts.push(opts["Gearbox type"]);
if (opts["Vehicle type"]) parts.push(opts["Vehicle type"]);
} else {
if (vehicle.engine) parts.push(vehicle.engine);
if (vehicle.bodyType) parts.push(vehicle.bodyType);
if (vehicle.transmission) parts.push(vehicle.transmission);
if (vehicle.driveType) parts.push(vehicle.driveType);
if (vehicle.fuelType) parts.push(vehicle.fuelType);
if (vehicle.optionsRaw) parts.push(vehicle.optionsRaw);
// Use optionsRaw as primary description when available — it's the most
// informative string in most catalogs (trim, engine spec, market etc.)
if (vehicle.optionsRaw) {
parts.push(vehicle.optionsRaw);
}
// Add fields not already contained in optionsRaw
const raw = vehicle.optionsRaw?.toLowerCase() || "";
if (vehicle.engine && !raw.includes(vehicle.engine.toLowerCase()))
parts.push(vehicle.engine);
if (vehicle.bodyType && !raw.includes(vehicle.bodyType.toLowerCase()))
parts.push(vehicle.bodyType);
if (
vehicle.transmission &&
!raw.includes(vehicle.transmission.toLowerCase())
)
parts.push(vehicle.transmission);
if (vehicle.driveType && !raw.includes(vehicle.driveType.toLowerCase()))
parts.push(vehicle.driveType);
if (vehicle.fuelType && !raw.includes(vehicle.fuelType.toLowerCase()))
parts.push(vehicle.fuelType);
}
// Deduplicate (optionsRaw may repeat other fields)
const seen = new Set<string>();
const unique = parts.filter((p) => {
if (seen.has(p)) return false;
seen.add(p);
return true;
});
// Fallback if nothing
if (parts.length === 0 && vehicle.engine) parts.push(vehicle.engine);
return (
<Link
@@ -424,8 +437,8 @@ function VehicleRow({
>
<Car className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
{unique.length > 0 ? (
<p className="truncate text-sm">{unique.join(" · ")}</p>
{parts.length > 0 ? (
<p className="truncate text-sm">{parts.join(" · ")}</p>
) : (
<p className="text-sm text-muted-foreground">{vehicle.vehicleId}</p>
)}