Files
obdx-web/src/components/DTCCarousel.tsx
kang 5b22e94bba feat(data): wire sample report to real 87-DTC reference database
Created web/src/lib/dtc-fixtures.ts — the 5 DTC records displayed on
landing (P0420, P0301, P0171, P0442, P0700) now pull their factual fields
verbatim from backend/src/dtc_data.py:

  • description_en  (code title)
  • category         (pill)
  • common_causes[]  (probability bars)
  • diagnostic_steps[] (numbered procedure)
  • estimated_cost_min/max  (real DB ranges, e.g. P0420 = $200–$1,500)
  • labor_hours      (real DB hours)

Narrative fields (simple_explanation, technical_explanation, urgency
label, when_monitored, set_condition, multi-code correlation, freeze-
frame data, readiness monitors) remain hardcoded as Tier 2/3 "AI would
generate this at runtime" demo content until AI Key is wired.

Visibility:
  • SampleReport adds a "87-record OBD-II database" provenance line under
    the section header
  • Each Pro-view fault card now shows a green "DB REF ✓" badge next to
    the existing CHARM ✓ badge
  • Pro view has a small "DB" tag on the Probable Causes and Diagnostic
    Steps column headers to distinguish DB-sourced fields
  • DTCCarousel header gets the same "real DB" byline
  • Total repair cost strip now reflects real summed DB ranges

Total cost was $490–$990 (hand-tuned). Now $240–$1,885 (P0420 200-1500 +
P0171 30-300 + P0442 10-85 = real database math).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:05:00 +08:00

343 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { motion, AnimatePresence } from "framer-motion";
import {
ChevronLeft,
ChevronRight,
Car,
Clock,
Sparkles,
Database,
} from "lucide-react";
import { useState, useEffect, useRef } from "react";
import {
DTC_FIXTURES,
DTC_DB_TOTAL,
formatCostRange,
} from "@/lib/dtc-fixtures";
/**
* Tier 1 (DB) fields — code, title, cost, labor — come from dtc-fixtures
* (synced from backend/src/dtc_data.py). Narrative fields (vehicle, plain
* English, drivable, action) are Tier 3 (what AI would generate at runtime)
* and hardcoded here until the AI Key is wired up.
*/
const SCENARIOS: Record<
keyof typeof DTC_FIXTURES,
{
vehicle: string;
mileage: string;
plainEnglish: string;
severity: string;
severityColor: string;
drivable: string;
action: string;
}
> = {
P0420: {
vehicle: "2018 Honda Civic 1.5L Turbo",
mileage: "108,432 mi",
plainEnglish:
"Your catalytic converter is past its prime. Not urgent — you have 12 weeks — but past 80k miles on this engine it's almost expected.",
severity: "Plan within 2 weeks",
severityColor: "#F59E0B",
drivable: "Yes, safely 12 weeks",
action: "Independent mechanic is fine — no dealer needed.",
},
P0301: {
vehicle: "2020 Toyota Camry 2.5L",
mileage: "64,100 mi",
plainEnglish:
"One of your 4 cylinders is firing inconsistently. Usually a spark plug or ignition coil on cylinder 1. Cheap to rule out before assuming anything bigger.",
severity: "Easy fix",
severityColor: "#10B981",
drivable: "Drive gently until fixed",
action:
"Start with a $30 plug + 30 min labor. If it comes back, swap the coil.",
},
P0171: {
vehicle: "2015 Ford F-150 5.0L V8",
mileage: "108,204 mi",
plainEnglish:
"Engine is getting too much air or not enough fuel. Usually a vacuum leak, dirty MAF sensor, or weak fuel pump — in that order of likelihood.",
severity: "Service soon",
severityColor: "#F59E0B",
drivable: "Yes, but expect worse MPG",
action: "Smoke test for a vacuum leak first (~$30 at any shop).",
},
P0442: {
vehicle: "2019 Chevy Equinox 1.5L",
mileage: "72,800 mi",
plainEnglish:
"A small leak somewhere in your fuel vapor recovery system. 9 times out of 10 it's the gas cap — check whether it clicks when you tighten it.",
severity: "Easy fix",
severityColor: "#10B981",
drivable: "Yes, completely safe",
action: "Swap the gas cap first ($5). If it returns, check the purge hose.",
},
P0700: {
vehicle: "2017 Jeep Grand Cherokee 3.6L",
mileage: "94,100 mi",
plainEnglish:
"Your transmission computer detected an internal fault. P0700 is only the trigger — the specific P07xx code it stored alongside is what you actually need to read next.",
severity: "Urgent — diagnose now",
severityColor: "#EF4444",
drivable: "Drive to a shop, not daily",
action:
"Don't DIY. Dealer or a transmission specialist. Fluid change first if never serviced.",
},
};
const CASES = (Object.keys(SCENARIOS) as Array<keyof typeof DTC_FIXTURES>).map(
(code) => {
const db = DTC_FIXTURES[code];
const s = SCENARIOS[code];
return {
dtc: db.code,
title: db.description_en,
cost: formatCostRange(db.estimated_cost_min, db.estimated_cost_max),
laborHours: db.labor_hours,
...s,
};
},
);
const AUTO_INTERVAL_MS = 6000;
export default function DTCCarousel() {
const [idx, setIdx] = useState(0);
const [paused, setPaused] = useState(false);
const [direction, setDirection] = useState(1);
const reducedMotion = useRef<boolean>(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedMotion.current = mq.matches;
}, []);
useEffect(() => {
if (paused) return;
const timer = setInterval(() => {
setDirection(1);
setIdx((prev) => (prev + 1) % CASES.length);
}, AUTO_INTERVAL_MS);
return () => clearInterval(timer);
}, [paused]);
const current = CASES[idx];
const goTo = (next: number) => {
setDirection(next > idx ? 1 : -1);
setIdx((next + CASES.length) % CASES.length);
};
const goPrev = () => goTo(idx - 1);
const goNext = () => goTo(idx + 1);
return (
<section
id="cases"
className="px-4 md:px-6 lg:px-8 pb-8 md:pb-12 space-y-4 md:space-y-5"
>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.5 }}
className="rounded-[28px] bg-white border border-black/5 p-8 md:p-12 text-center"
>
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[#2563EB]/10 text-[#2563EB] text-xs font-semibold tracking-wider mb-4">
REAL DTC LOOKUPS
</span>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight text-[#1A1A1A] leading-[1.05]">
Five codes.
<br />
<span className="text-[#1A1A1A]/40">
Five plain-English answers.
</span>
</h2>
<p className="mt-4 text-base md:text-lg text-[#1A1A1A]/60 max-w-2xl mx-auto">
Type a DTC into OBDX. Here's exactly what you'd get back.
</p>
<div className="mt-4 inline-flex items-center gap-2 text-[11px] font-mono text-[#1A1A1A]/45">
<Database size={12} />
Costs + diagnostic steps pulled from a live{" "}
<span className="font-semibold text-[#1A1A1A]/70">
{DTC_DB_TOTAL}-record OBD-II database
</span>
</div>
</motion.div>
{/* Carousel container */}
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5 }}
className="rounded-[28px] bg-[#FAFAF7] border border-black/5 p-5 md:p-7 relative"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onTouchStart={() => setPaused(true)}
>
{/* Slide area */}
<div className="relative overflow-hidden">
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={idx}
custom={direction}
initial={{ opacity: 0, x: reducedMotion.current ? 0 : 30 * direction }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: reducedMotion.current ? 0 : -30 * direction }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="grid grid-cols-1 md:grid-cols-12 gap-4 md:gap-5"
>
{/* INPUT card */}
<div className="md:col-span-5 rounded-[24px] bg-[#1A1A1A] text-white p-7 md:p-8 min-h-[340px] flex flex-col justify-between relative overflow-hidden">
<div className="flex items-center gap-1.5 text-[10px] font-mono text-white/40 tracking-widest">
<ChevronRight size={12} /> INPUT
</div>
<div>
<div
className="font-mono text-6xl md:text-7xl font-extrabold tracking-tight leading-none"
style={{ color: current.severityColor }}
>
{current.dtc}
</div>
<div className="mt-4 text-lg md:text-xl font-bold text-white leading-snug">
{current.title}
</div>
</div>
<div className="space-y-2 pt-5 border-t border-white/10">
<div className="flex items-center gap-2 text-xs text-white/65">
<Car size={12} /> {current.vehicle}
</div>
<div className="flex items-center gap-2 text-xs font-mono text-white/40">
<Clock size={12} /> {current.mileage}
</div>
</div>
</div>
{/* OUTPUT card */}
<div className="md:col-span-7 rounded-[24px] bg-white border border-black/5 p-7 md:p-8 min-h-[340px] flex flex-col gap-5">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-1.5 text-[10px] font-mono text-[#2563EB] tracking-widest">
<Sparkles size={12} /> AI DIAGNOSIS
</div>
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wide"
style={{
background: `${current.severityColor}1F`,
color: current.severityColor,
}}
>
<span
className="w-1 h-1 rounded-full"
style={{ background: current.severityColor }}
/>
{current.severity}
</span>
</div>
<p className="text-base md:text-lg text-[#1A1A1A]/85 italic leading-relaxed flex-1">
{current.plainEnglish}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4 border-t border-black/5">
<div>
<div className="text-[10px] font-mono text-[#1A1A1A]/40 uppercase tracking-widest">
Cost estimate
</div>
<div className="text-base md:text-lg font-bold text-[#1A1A1A] mt-1 tabular-nums">
{current.cost}
</div>
</div>
<div>
<div className="text-[10px] font-mono text-[#1A1A1A]/40 uppercase tracking-widest">
Can you drive?
</div>
<div className="text-sm font-semibold text-[#1A1A1A] mt-1">
{current.drivable}
</div>
</div>
<div>
<div className="text-[10px] font-mono text-[#1A1A1A]/40 uppercase tracking-widest">
First move
</div>
<div className="text-xs text-[#1A1A1A]/70 mt-1 leading-snug">
{current.action}
</div>
</div>
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
{/* Progress bar (auto-advance indicator) */}
<div className="mt-5 h-[3px] bg-[#1A1A1A]/5 rounded-full overflow-hidden">
<motion.div
key={`bar-${idx}-${paused ? "p" : "r"}`}
initial={{ width: "0%" }}
animate={{ width: paused ? "0%" : "100%" }}
transition={{ duration: paused ? 0 : AUTO_INTERVAL_MS / 1000, ease: "linear" }}
className="h-full bg-[#2563EB]"
/>
</div>
{/* Controls row */}
<div className="mt-5 flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
{CASES.map((c, i) => (
<button
key={c.dtc}
type="button"
onClick={() => goTo(i)}
className={`h-1.5 rounded-full transition-all ${
i === idx
? "w-8 bg-[#1A1A1A]"
: "w-1.5 bg-[#1A1A1A]/20 hover:bg-[#1A1A1A]/40"
}`}
aria-label={`Show example ${i + 1}: ${c.dtc}`}
aria-current={i === idx}
/>
))}
</div>
<div className="flex items-center gap-3">
<span className="text-[11px] font-mono text-[#1A1A1A]/40 tabular-nums">
{String(idx + 1).padStart(2, "0")}
<span className="text-[#1A1A1A]/20 mx-1">/</span>
{String(CASES.length).padStart(2, "0")}
</span>
<button
type="button"
onClick={goPrev}
className="w-10 h-10 rounded-full bg-white border border-black/10 text-[#1A1A1A] flex items-center justify-center hover:bg-[#1A1A1A] hover:text-white hover:border-[#1A1A1A] transition"
aria-label="Previous example"
>
<ChevronLeft size={18} />
</button>
<button
type="button"
onClick={goNext}
className="w-10 h-10 rounded-full bg-[#1A1A1A] text-white flex items-center justify-center hover:bg-[#2563EB] transition"
aria-label="Next example"
>
<ChevronRight size={18} />
</button>
</div>
</div>
{/* Status indicator */}
<div className="mt-3 flex items-center gap-2 text-[10px] font-mono text-[#1A1A1A]/30">
<span
className={`w-1.5 h-1.5 rounded-full ${
paused ? "bg-[#1A1A1A]/20" : "bg-[#10B981] animate-pulse"
}`}
/>
{paused
? "Paused — move mouse out to resume"
: `Auto-advancing every ${AUTO_INTERVAL_MS / 1000}s · hover to pause`}
</div>
</motion.div>
</section>
);
}