/**
* 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 && (
)}
{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}
)}
>
)}
)
}