CTA / frontend /src /app /page.tsx
TheQuantEd's picture
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>
);
}