/** * PatientTimeline — Feature 3.4 * * Shows a patient's complete prediction history as a vertical timeline, * with a risk-trend sparkline at the top. Requires the user to be logged in * (token from AuthContext) since patient APIs require clinical roles. * * Props: * patientId — UUID of the patient (required) * patientName — display name (optional, for the header) * onClose — callback to close/dismiss the panel (optional) */ import { useState, useEffect, useCallback } from 'react' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer, } from 'recharts' import { Activity, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, AlertCircle, TrendingUp, TrendingDown, Clock, X, } from 'lucide-react' import { useAuth } from '../context/AuthContext' const BASE = '/api/v4' async function apiFetch(path, token) { const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body?.error || body?.detail || `HTTP ${res.status}`) } return res.json() } // ── Risk trend sparkline tooltip ────────────────────────────────────────────── function SparkTooltip({ active, payload, label }) { if (!active || !payload?.length) return null const p = payload[0] return (

{label}

= 0.5 ? 'text-red-600' : 'text-green-600'}`}> Confidence: {Math.round(p.value * 100)}%

) } // ── Prediction entry card ───────────────────────────────────────────────────── function PredictionCard({ prediction, index }) { const [expanded, setExpanded] = useState(false) const isPositive = prediction.prediction === 1 const conf = Math.round((prediction.confidence ?? 0) * 100) const dateStr = new Date(prediction.created_at).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', }) const timeStr = new Date(prediction.created_at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', }) const features = prediction.input_features || {} const featureKeys = Object.keys(features) return (
{/* Timeline dot + line */}
{/* Card */}
{/* Header */} {/* Expanded details */} {expanded && (

Input Features

{featureKeys.map(k => (

{k}

{String(features[k])}

))} {featureKeys.length === 0 && (

No feature data recorded

)}
{prediction.shap_chart_data && (

Top SHAP Features

{prediction.shap_chart_data.slice(0, 5).map((f, i) => { const isNeg = f.shap_value < 0 const barWidth = Math.min(100, Math.abs(f.shap_value) * 100) return (
{f.feature}
{isNeg && (
)} {!isNeg && (
)}
{f.shap_value > 0 ? '+' : ''}{f.shap_value.toFixed(3)}
) })}
)}
)}
) } // ── Risk trend summary ──────────────────────────────────────────────────────── function RiskTrendCard({ predictions }) { if (predictions.length < 2) return null const chartData = [...predictions] .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)) .map(p => ({ date: new Date(p.created_at).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }), confidence: p.prediction === 1 ? p.confidence : (1 - p.confidence), raw: p.confidence, positive: p.prediction === 1, })) const latest = chartData[chartData.length - 1] const prev = chartData[chartData.length - 2] const trend = latest.confidence - prev.confidence const improving = trend < 0 return (

Risk Trend

{improving ? : } {improving ? 'Improving' : 'Worsening'} vs prior visit
`${Math.round(v * 100)}%`} tick={{ fontSize: 10 }} /> } />

Risk score = model confidence toward positive prediction

) } // ── Main PatientTimeline ────────────────────────────────────────────────────── export default function PatientTimeline({ patientId, patientName, onClose }) { const { token } = useAuth() const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [page, setPage] = useState(1) const [disease, setDisease] = useState('') const load = useCallback(async () => { if (!patientId || !token) return setLoading(true) setError(null) try { const qs = new URLSearchParams({ page, limit: 10, ...(disease ? { disease } : {}) }) const d = await apiFetch(`/patients/${patientId}/predictions?${qs}`, token) setData(d) } catch (e) { setError(e.message) } finally { setLoading(false) } }, [patientId, token, page, disease]) useEffect(() => { load() }, [load]) if (!token) { return (

Sign in to view patient history.

) } const diseases = data?.items ? [...new Set(data.items.map(p => p.disease))].filter(Boolean) : [] return (
{/* Header */}

Patient History

{patientName && (

{patientName}

)}
{/* Disease filter */} {diseases.length > 0 && ( )} {onClose && ( )}
{error && (
{error}
)} {loading && !data && (
{[...Array(3)].map((_, i) => (
))}
)} {data && ( <> {/* Summary */}
{data.total} prediction{data.total !== 1 ? 's' : ''} {data.items.length > 0 && ( <> · {data.items.filter(p => p.prediction === 1).length} positive · {data.items.filter(p => p.prediction === 0).length} negative )}
{/* Risk trend */} {/* Timeline */} {data.items.length === 0 ? (

No predictions recorded yet.

) : (
{data.items.map((pred, i) => ( ))}
)} {/* Pagination */} {data.pages > 1 && (
Page {data.page} of {data.pages}
)} )}
) }