From a6607d588270194ebcb625a5922f464e3dda9d23 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:12:09 -0600 Subject: [PATCH] feat: Add fuzzy matching to VIN decode for partial model/trim names (refs #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: Enhanced matchField function with prefix and contains matching so NHTSA values like "Sierra" match dropdown options like "Sierra 1500". Matching hierarchy: 1. Exact match (case-insensitive) -> high confidence 2. Normalized match (remove special chars) -> medium confidence 3. Prefix match (option starts with value) -> medium confidence (NEW) 4. Contains match (option contains value) -> medium confidence (NEW) Frontend: Fixed VIN decode form population by loading dropdown options before setting form values, preventing cascade useEffects from clearing decoded values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../vehicles/domain/vehicles.service.ts | 15 +++- .../vehicles/components/VehicleForm.tsx | 74 ++++++++++--------- 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index a710480..4df852c 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -441,8 +441,9 @@ export class VehiclesService { } /** - * Match a value against dropdown options using case-insensitive exact matching + * Match a value against dropdown options using fuzzy matching * Returns the matched dropdown value with confidence level + * Matching order: exact -> normalized -> prefix -> contains */ private matchField(nhtsaValue: string, options: string[]): MatchedField { if (!nhtsaValue || options.length === 0) { @@ -466,6 +467,18 @@ export class VehiclesService { return { value: normalizedMatch, nhtsaValue, confidence: 'medium' }; } + // Try prefix match - option starts with NHTSA value + const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedNhtsa)); + if (prefixMatch) { + return { value: prefixMatch, nhtsaValue, confidence: 'medium' }; + } + + // Try contains match - option contains NHTSA value + const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedNhtsa)); + if (containsMatch) { + return { value: containsMatch, nhtsaValue, confidence: 'medium' }; + } + // No match found - return NHTSA value as hint with no match return { value: null, nhtsaValue, confidence: 'none' }; } diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx index ca289cb..5732e6c 100644 --- a/frontend/src/features/vehicles/components/VehicleForm.tsx +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -448,13 +448,45 @@ export const VehicleForm: React.FC = ({ isVinDecoding.current = true; setLoadingDropdowns(true); - // Track which values we're setting (only populate empty fields) - const yearToSet = !watchedYear && decoded.year.value ? decoded.year.value : watchedYear; - const makeToSet = !watchedMake && decoded.make.value ? decoded.make.value : watchedMake; - const modelToSet = !watchedModel && decoded.model.value ? decoded.model.value : watchedModel; - const trimToSet = !watchedTrim && decoded.trimLevel.value ? decoded.trimLevel.value : watchedTrim; + // Determine final values (decoded value if field is empty, otherwise keep existing) + const yearValue = !watchedYear && decoded.year.value ? decoded.year.value : watchedYear; + const makeValue = !watchedMake && decoded.make.value ? decoded.make.value : watchedMake; + const modelValue = !watchedModel && decoded.model.value ? decoded.model.value : watchedModel; + const trimValue = !watchedTrim && decoded.trimLevel.value ? decoded.trimLevel.value : watchedTrim; + const engineValue = !watchedEngine && decoded.engine.value ? decoded.engine.value : watchedEngine; + const transmissionValue = !watchedTransmission && decoded.transmission.value ? decoded.transmission.value : watchedTransmission; - // Set form values + // FIRST: Load all dropdown options hierarchically (like edit mode initialization) + // Options must exist BEFORE setting form values for selects to display correctly + if (yearValue) { + prevYear.current = yearValue; + const makesData = await vehiclesApi.getMakes(yearValue); + setMakes(makesData); + + if (makeValue) { + prevMake.current = makeValue; + const modelsData = await vehiclesApi.getModels(yearValue, makeValue); + setModels(modelsData); + + if (modelValue) { + prevModel.current = modelValue; + const trimsData = await vehiclesApi.getTrims(yearValue, makeValue, modelValue); + setTrims(trimsData); + + if (trimValue) { + prevTrim.current = trimValue; + const [enginesData, transmissionsData] = await Promise.all([ + vehiclesApi.getEngines(yearValue, makeValue, modelValue, trimValue), + vehiclesApi.getTransmissions(yearValue, makeValue, modelValue, trimValue) + ]); + setEngines(enginesData); + setTransmissions(transmissionsData); + } + } + } + } + + // THEN: Set form values (after options are loaded) if (!watchedYear && decoded.year.value) { setValue('year', decoded.year.value); } @@ -474,36 +506,6 @@ export const VehicleForm: React.FC = ({ setValue('transmission', decoded.transmission.value); } - // Load dropdown options hierarchically (like edit mode initialization) - // This ensures dropdowns have the right options for the decoded values - if (yearToSet) { - prevYear.current = yearToSet; - const makesData = await vehiclesApi.getMakes(yearToSet); - setMakes(makesData); - - if (makeToSet) { - prevMake.current = makeToSet; - const modelsData = await vehiclesApi.getModels(yearToSet, makeToSet); - setModels(modelsData); - - if (modelToSet) { - prevModel.current = modelToSet; - const trimsData = await vehiclesApi.getTrims(yearToSet, makeToSet, modelToSet); - setTrims(trimsData); - - if (trimToSet) { - prevTrim.current = trimToSet; - const [enginesData, transmissionsData] = await Promise.all([ - vehiclesApi.getEngines(yearToSet, makeToSet, modelToSet, trimToSet), - vehiclesApi.getTransmissions(yearToSet, makeToSet, modelToSet, trimToSet) - ]); - setEngines(enginesData); - setTransmissions(transmissionsData); - } - } - } - } - setLoadingDropdowns(false); isVinDecoding.current = false; } catch (error: any) {