Spaces:
Sleeping
Sleeping
Initial deployment: ClinicalMatch AI v2.0 — FHIR R4 · MCP (9 tools) · A2A workflow · SHARP compliance · 100k synthetic patients · Neo4j graph · GraphRAG chatbot
59abb4f | "use client"; | |
| import { useState } from "react"; | |
| import { searchTrials, getTrialIntelligence } from "@/lib/api"; | |
| import { Search, MapPin, Calendar, Users, ChevronDown, ChevronUp, FlaskConical, Brain, ExternalLink, Clock, CheckCircle } from "lucide-react"; | |
| import { clsx } from "clsx"; | |
| const PHASES = ["", "1", "2", "3", "4"]; | |
| const PHASE_LABELS: Record<string, string> = { "": "All Phases", "1": "Phase I", "2": "Phase II", "3": "Phase III", "4": "Phase IV" }; | |
| const PHASE_COLORS: Record<string, string> = { | |
| PHASE1: "bg-blue-100 text-blue-700", | |
| PHASE2: "bg-violet-100 text-violet-700", | |
| PHASE3: "bg-emerald-100 text-emerald-700", | |
| PHASE4: "bg-amber-100 text-amber-700", | |
| "N/A": "bg-slate-100 text-slate-600", | |
| }; | |
| function daysAgo(dateStr: string): string { | |
| if (!dateStr) return ""; | |
| const d = new Date(dateStr); | |
| if (isNaN(d.getTime())) return ""; | |
| const days = Math.floor((Date.now() - d.getTime()) / 86400000); | |
| if (days === 0) return "Updated today"; | |
| if (days === 1) return "Updated yesterday"; | |
| if (days < 30) return `Updated ${days}d ago`; | |
| if (days < 365) return `Updated ${Math.floor(days / 30)}mo ago`; | |
| return `Updated ${Math.floor(days / 365)}y ago`; | |
| } | |
| export default function TrialFinder() { | |
| const [condition, setCondition] = useState(""); | |
| const [phase, setPhase] = useState(""); | |
| const [trials, setTrials] = useState<any[]>([]); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState(""); | |
| const [expanded, setExpanded] = useState<string | null>(null); | |
| const [searched, setSearched] = useState(false); | |
| const [intelligence, setIntelligence] = useState<Record<string, any>>({}); | |
| const [loadingIntel, setLoadingIntel] = useState<string | null>(null); | |
| const handleSearch = async () => { | |
| if (!condition.trim()) return; | |
| setLoading(true); | |
| setError(""); | |
| setSearched(true); | |
| setExpanded(null); | |
| setIntelligence({}); | |
| try { | |
| const data = await searchTrials(condition, phase || undefined, 20); | |
| setTrials(data.trials); | |
| } catch (e: any) { | |
| setError(e.message); | |
| setTrials([]); | |
| } | |
| setLoading(false); | |
| }; | |
| const handleExpand = async (nctId: string) => { | |
| const next = expanded === nctId ? null : nctId; | |
| setExpanded(next); | |
| if (next && !intelligence[next]) { | |
| setLoadingIntel(next); | |
| try { | |
| const intel = await getTrialIntelligence(next); | |
| setIntelligence((prev) => ({ ...prev, [next]: intel })); | |
| } catch {} | |
| setLoadingIntel(null); | |
| } | |
| }; | |
| const handleKey = (e: React.KeyboardEvent) => { | |
| if (e.key === "Enter") handleSearch(); | |
| }; | |
| return ( | |
| <div className="p-6 max-w-5xl mx-auto"> | |
| <div className="mb-8"> | |
| <h1 className="text-2xl font-bold text-slate-900 mb-1">Clinical Trial Finder</h1> | |
| <p className="text-slate-500 text-sm">Search ClinicalTrials.gov for recruiting studies — powered by real-time data</p> | |
| </div> | |
| {/* Search bar */} | |
| <div className="flex gap-3 mb-6"> | |
| <div className="flex-1 relative"> | |
| <Search className="absolute left-3 top-3 w-4 h-4 text-slate-400" /> | |
| <input | |
| type="text" | |
| value={condition} | |
| onChange={(e) => setCondition(e.target.value)} | |
| onKeyDown={handleKey} | |
| placeholder="e.g. breast cancer, NSCLC, Alzheimer's disease..." | |
| className="w-full pl-9 pr-4 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white" | |
| /> | |
| </div> | |
| <select | |
| value={phase} | |
| onChange={(e) => setPhase(e.target.value)} | |
| className="border border-slate-200 rounded-lg px-3 py-2.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500" | |
| > | |
| {PHASES.map((p) => <option key={p} value={p}>{PHASE_LABELS[p]}</option>)} | |
| </select> | |
| <button | |
| onClick={handleSearch} | |
| disabled={loading || !condition.trim()} | |
| className="bg-indigo-600 text-white px-5 py-2.5 rounded-lg text-sm font-medium hover:bg-indigo-700 disabled:opacity-50 transition-colors" | |
| > | |
| {loading ? "Searching..." : "Search"} | |
| </button> | |
| </div> | |
| {error && ( | |
| <div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm mb-4">{error}</div> | |
| )} | |
| {searched && !loading && trials.length === 0 && !error && ( | |
| <div className="text-center py-12 text-slate-400"> | |
| <FlaskConical className="w-10 h-10 mx-auto mb-3 opacity-40" /> | |
| <p>No trials found. Try a different condition or phase.</p> | |
| </div> | |
| )} | |
| {trials.length > 0 && ( | |
| <div className="space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <p className="text-sm text-slate-500">{trials.length} trials found for <strong>{condition}</strong></p> | |
| <div className="flex items-center gap-1.5 text-xs text-slate-400"> | |
| <Clock className="w-3 h-3" /> | |
| Sorted by most recently updated | |
| </div> | |
| </div> | |
| {trials.map((trial) => { | |
| const isExpanded = expanded === trial.nct_id; | |
| const phaseKey = trial.phase?.replace("PHASE", "Phase ") || "N/A"; | |
| const recency = daysAgo(trial.last_updated); | |
| const intel = intelligence[trial.nct_id]; | |
| const isLoadingIntel = loadingIntel === trial.nct_id; | |
| return ( | |
| <div key={trial.nct_id} className="bg-white rounded-xl border border-slate-200 overflow-hidden"> | |
| <button | |
| className="w-full text-left p-5 hover:bg-slate-50 transition-colors" | |
| onClick={() => handleExpand(trial.nct_id)} | |
| > | |
| <div className="flex items-start justify-between gap-4"> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2 flex-wrap mb-1"> | |
| <span className={clsx("text-xs font-semibold px-2 py-0.5 rounded-full", PHASE_COLORS[trial.phase] || PHASE_COLORS["N/A"])}> | |
| {phaseKey} | |
| </span> | |
| <span className="text-xs text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full font-medium"> | |
| {trial.status} | |
| </span> | |
| {trial.eligible_patients_in_graph > 0 && ( | |
| <span className="text-xs text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-full font-medium flex items-center gap-1"> | |
| <Brain className="w-3 h-3" />{trial.eligible_patients_in_graph} matched | |
| </span> | |
| )} | |
| {recency && ( | |
| <span className="text-xs text-slate-400 flex items-center gap-1"> | |
| <Clock className="w-3 h-3" />{recency} | |
| </span> | |
| )} | |
| </div> | |
| <h3 className="font-semibold text-slate-900 text-sm leading-snug">{trial.title}</h3> | |
| <p className="text-xs text-slate-500 mt-1">{trial.nct_id} · {trial.sponsor}</p> | |
| </div> | |
| <div className="flex items-center gap-4 shrink-0 text-xs text-slate-500"> | |
| <span className="flex items-center gap-1"><MapPin className="w-3 h-3" />{trial.location_count} sites</span> | |
| <span className="flex items-center gap-1"><Users className="w-3 h-3" />{trial.enrollment}</span> | |
| {isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} | |
| </div> | |
| </div> | |
| </button> | |
| {isExpanded && ( | |
| <div className="border-t border-slate-100 px-5 py-4 bg-slate-50 space-y-4"> | |
| {trial.brief_summary && ( | |
| <p className="text-sm text-slate-600 leading-relaxed">{trial.brief_summary.slice(0, 500)}{trial.brief_summary.length > 500 ? "…" : ""}</p> | |
| )} | |
| <div className="grid grid-cols-2 gap-4 text-xs"> | |
| <div><div className="font-semibold text-slate-700 mb-1">Age Range</div><div className="text-slate-600">{trial.min_age || "18 years"} – {trial.max_age || "No max"}</div></div> | |
| <div><div className="font-semibold text-slate-700 mb-1">Sex</div><div className="text-slate-600 capitalize">{trial.sex?.toLowerCase() || "All"}</div></div> | |
| <div><div className="font-semibold text-slate-700 mb-1">Start Date</div><div className="text-slate-600">{trial.start_date || "N/A"}</div></div> | |
| <div><div className="font-semibold text-slate-700 mb-1">Completion</div><div className="text-slate-600">{trial.completion_date || "N/A"}</div></div> | |
| </div> | |
| {trial.locations?.length > 0 && ( | |
| <div> | |
| <div className="font-semibold text-xs text-slate-700 mb-1">Sites</div> | |
| <div className="flex flex-wrap gap-2"> | |
| {trial.locations.slice(0, 4).map((loc: any, i: number) => ( | |
| <span key={i} className="text-xs bg-white border border-slate-200 rounded px-2 py-0.5 text-slate-600"> | |
| {loc.facility || `${loc.city}, ${loc.state}`} | |
| </span> | |
| ))} | |
| {trial.location_count > 4 && <span className="text-xs text-slate-400">+{trial.location_count - 4} more</span>} | |
| </div> | |
| </div> | |
| )} | |
| {trial.primary_outcomes?.length > 0 && ( | |
| <div> | |
| <div className="font-semibold text-xs text-slate-700 mb-1">Primary Outcomes</div> | |
| <ul className="text-xs text-slate-600 space-y-0.5"> | |
| {trial.primary_outcomes.map((o: string, i: number) => <li key={i}>· {o}</li>)} | |
| </ul> | |
| </div> | |
| )} | |
| {/* Graph intelligence panel */} | |
| <div className="border border-indigo-100 rounded-lg p-3 bg-white"> | |
| <div className="flex items-center gap-1.5 text-xs font-semibold text-indigo-700 mb-2"> | |
| <Brain className="w-3.5 h-3.5" />Graph Intelligence | |
| {trial.eligible_patients_in_graph === 0 && !intel && ( | |
| <span className="ml-auto text-slate-400 font-normal">Enriching graph…</span> | |
| )} | |
| </div> | |
| {isLoadingIntel ? ( | |
| <p className="text-xs text-slate-400">Loading graph data…</p> | |
| ) : intel ? ( | |
| <div className="space-y-2 text-xs"> | |
| <div className="flex items-center gap-2"> | |
| <CheckCircle className="w-3.5 h-3.5 text-emerald-500" /> | |
| <span className="text-slate-700"><strong>{intel.eligible_patients}</strong> patients in graph eligible for this trial</span> | |
| </div> | |
| {intel.top_biomarkers?.length > 0 && ( | |
| <div> | |
| <div className="text-slate-500 mb-1">Top biomarkers among eligible patients:</div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {intel.top_biomarkers.map((b: any, i: number) => ( | |
| <span key={i} className="bg-indigo-50 text-indigo-700 px-2 py-0.5 rounded text-xs">{b.biomarker} ({b.patient_count})</span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {intel.similar_trials?.length > 0 && ( | |
| <div> | |
| <div className="text-slate-500 mb-1">Similar trials (by shared eligible patients):</div> | |
| <div className="space-y-1"> | |
| {intel.similar_trials.map((t: any, i: number) => ( | |
| <div key={i} className="flex items-center gap-2"> | |
| <span className="font-mono text-slate-500">{t.nct_id}</span> | |
| <span className="text-slate-600 truncate">{t.title?.slice(0, 50)}…</span> | |
| <span className="ml-auto text-indigo-600 shrink-0">{t.shared_patients} shared</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <p className="text-xs text-slate-400">Trial not yet in graph — being ingested now.</p> | |
| )} | |
| </div> | |
| <div className="flex items-center justify-between pt-1"> | |
| <span className="text-xs text-slate-400 flex items-center gap-1"> | |
| <CheckCircle className="w-3 h-3 text-emerald-400" /> | |
| Ingested to graph | |
| </span> | |
| {trial.ctgov_url && ( | |
| <a href={trial.ctgov_url} target="_blank" rel="noopener noreferrer" | |
| className="flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-800 font-medium"> | |
| View on ClinicalTrials.gov <ExternalLink className="w-3 h-3" /> | |
| </a> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |