Skip to content

Commit 07e24a2

Browse files
Sonra0claude
andcommitted
feat: add medical chart components for assessment analytics
ScoreGauge (radial bar), TrendChart (multi-axis line), VocalRadarChart (spider web), HeatmapChart (mood patterns), RiskCard (animated circular progress), QuestionList (expandable Q&A), HistoryTimeline (session selector). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e19ee44 commit 07e24a2

7 files changed

Lines changed: 596 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"use client";
2+
3+
import { ResponsiveHeatMap } from "@nivo/heatmap";
4+
5+
interface HeatmapChartProps {
6+
data: { day: string; week: number; value: number }[];
7+
}
8+
9+
export function HeatmapChart({ data }: HeatmapChartProps) {
10+
if (data.length === 0) {
11+
return (
12+
<div className="flex items-center justify-center h-48 text-gray-500 text-sm">
13+
No mood pattern data available
14+
</div>
15+
);
16+
}
17+
18+
// Transform into nivo heatmap format: rows = days, columns = weeks
19+
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
20+
const maxWeek = Math.max(...data.map((d) => d.week), 3);
21+
22+
const heatmapData = days.map((day) => ({
23+
id: day,
24+
data: Array.from({ length: maxWeek + 1 }, (_, week) => {
25+
const match = data.find((d) => d.day === day && d.week === week);
26+
return { x: `W${week + 1}`, y: match ? match.value : null };
27+
}),
28+
}));
29+
30+
return (
31+
<div style={{ height: 220 }}>
32+
<ResponsiveHeatMap
33+
data={heatmapData}
34+
margin={{ top: 20, right: 20, bottom: 20, left: 50 }}
35+
forceSquare={false}
36+
colors={{
37+
type: "sequential",
38+
scheme: "greens",
39+
minValue: 0,
40+
maxValue: 100,
41+
}}
42+
emptyColor="rgba(255,255,255,0.04)"
43+
borderRadius={4}
44+
borderWidth={2}
45+
borderColor="rgba(0,0,0,0.3)"
46+
enableLabels={false}
47+
animate
48+
motionConfig="gentle"
49+
theme={{
50+
background: "transparent",
51+
text: { fill: "#9ca3af", fontSize: 11 },
52+
tooltip: {
53+
container: {
54+
background: "#1f2937",
55+
color: "#f3f4f6",
56+
borderRadius: "8px",
57+
fontSize: 12,
58+
},
59+
},
60+
}}
61+
/>
62+
</div>
63+
);
64+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"use client";
2+
3+
import { Badge } from "@/components/ui/Badge";
4+
5+
interface Session {
6+
id: string;
7+
date: string;
8+
overallScore: number | null;
9+
severity: "GREEN" | "YELLOW" | "RED" | null;
10+
}
11+
12+
interface HistoryTimelineProps {
13+
sessions: Session[];
14+
activeSessionId: string | null;
15+
onSelect: (sessionId: string) => void;
16+
}
17+
18+
const severityVariant: Record<string, "success" | "warning" | "danger"> = {
19+
GREEN: "success",
20+
YELLOW: "warning",
21+
RED: "danger",
22+
};
23+
24+
export function HistoryTimeline({
25+
sessions,
26+
activeSessionId,
27+
onSelect,
28+
}: HistoryTimelineProps) {
29+
if (sessions.length === 0) {
30+
return (
31+
<p className="text-sm text-gray-500 text-center py-4">
32+
No assessment history
33+
</p>
34+
);
35+
}
36+
37+
return (
38+
<div className="overflow-x-auto pb-2 -mx-2 px-2">
39+
<div className="flex gap-3 min-w-min">
40+
{sessions.map((session) => {
41+
const isActive = session.id === activeSessionId;
42+
return (
43+
<button
44+
key={session.id}
45+
onClick={() => onSelect(session.id)}
46+
className={`flex flex-col items-center gap-1.5 px-4 py-3 rounded-xl border transition-all shrink-0 ${
47+
isActive
48+
? "border-indigo-500 bg-indigo-500/10"
49+
: "border-white/5 bg-white/[0.03] hover:bg-white/[0.06]"
50+
}`}
51+
>
52+
<span className="text-xs text-gray-500">{session.date}</span>
53+
<span
54+
className={`text-lg font-bold ${
55+
isActive ? "text-indigo-400" : "text-white"
56+
}`}
57+
>
58+
{session.overallScore !== null
59+
? `${Math.round(session.overallScore)}%`
60+
: "—"}
61+
</span>
62+
{session.severity && (
63+
<Badge variant={severityVariant[session.severity]} size="sm">
64+
{session.severity}
65+
</Badge>
66+
)}
67+
</button>
68+
);
69+
})}
70+
</div>
71+
</div>
72+
);
73+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { Badge } from "@/components/ui/Badge";
5+
import { motion, AnimatePresence } from "framer-motion";
6+
7+
interface Answer {
8+
id: string;
9+
questionText: string;
10+
correctAnswer: string | null;
11+
elderAnswer: string | null;
12+
result: string | null;
13+
audioUrl: string | null;
14+
orderIndex: number;
15+
}
16+
17+
interface QuestionListProps {
18+
answers: Answer[];
19+
}
20+
21+
export function QuestionList({ answers }: QuestionListProps) {
22+
const [expanded, setExpanded] = useState<string | null>(null);
23+
24+
if (answers.length === 0) {
25+
return (
26+
<p className="text-sm text-gray-500 text-center py-4">
27+
No questions in this session
28+
</p>
29+
);
30+
}
31+
32+
return (
33+
<div className="space-y-2">
34+
{answers.map((answer) => (
35+
<div
36+
key={answer.id}
37+
className="rounded-xl border border-white/5 bg-white/[0.03] overflow-hidden"
38+
>
39+
<button
40+
onClick={() =>
41+
setExpanded(expanded === answer.id ? null : answer.id)
42+
}
43+
className="w-full flex items-center justify-between px-4 py-3 text-left"
44+
>
45+
<div className="flex items-center gap-3 min-w-0">
46+
<span className="text-xs text-gray-500 shrink-0">
47+
Q{answer.orderIndex + 1}
48+
</span>
49+
<span className="text-sm text-gray-300 truncate">
50+
{answer.questionText}
51+
</span>
52+
</div>
53+
<div className="flex items-center gap-2 shrink-0 ml-2">
54+
{answer.result && (
55+
<Badge
56+
variant={
57+
answer.result === "CORRECT"
58+
? "success"
59+
: answer.result === "WRONG"
60+
? "danger"
61+
: "warning"
62+
}
63+
>
64+
{answer.result}
65+
</Badge>
66+
)}
67+
<svg
68+
className={`w-4 h-4 text-gray-500 transition-transform ${
69+
expanded === answer.id ? "rotate-180" : ""
70+
}`}
71+
fill="none"
72+
viewBox="0 0 24 24"
73+
strokeWidth={2}
74+
stroke="currentColor"
75+
>
76+
<path
77+
strokeLinecap="round"
78+
strokeLinejoin="round"
79+
d="m19.5 8.25-7.5 7.5-7.5-7.5"
80+
/>
81+
</svg>
82+
</div>
83+
</button>
84+
85+
<AnimatePresence>
86+
{expanded === answer.id && (
87+
<motion.div
88+
initial={{ height: 0, opacity: 0 }}
89+
animate={{ height: "auto", opacity: 1 }}
90+
exit={{ height: 0, opacity: 0 }}
91+
transition={{ duration: 0.2 }}
92+
className="overflow-hidden"
93+
>
94+
<div className="px-4 pb-3 border-t border-white/5 pt-3 space-y-2">
95+
{answer.elderAnswer && (
96+
<div>
97+
<span className="text-xs text-gray-500">
98+
Elder&apos;s answer:
99+
</span>
100+
<p className="text-sm text-gray-300">
101+
{answer.elderAnswer}
102+
</p>
103+
</div>
104+
)}
105+
{answer.correctAnswer && (
106+
<div>
107+
<span className="text-xs text-gray-500">
108+
Correct answer:
109+
</span>
110+
<p className="text-sm text-emerald-400">
111+
{answer.correctAnswer}
112+
</p>
113+
</div>
114+
)}
115+
{answer.audioUrl && (
116+
<audio
117+
controls
118+
src={answer.audioUrl}
119+
className="w-full h-8 mt-2"
120+
/>
121+
)}
122+
</div>
123+
</motion.div>
124+
)}
125+
</AnimatePresence>
126+
</div>
127+
))}
128+
</div>
129+
);
130+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"use client";
2+
3+
import { ResponsiveRadar } from "@nivo/radar";
4+
5+
interface RadarChartProps {
6+
data: Record<string, number> | null;
7+
}
8+
9+
const metricLabels: Record<string, string> = {
10+
parkinsons: "Parkinson's Risk",
11+
depression: "Depression",
12+
wellness: "Wellness",
13+
mood: "Mood",
14+
speechFluency: "Speech Fluency",
15+
};
16+
17+
export function VocalRadarChart({ data }: RadarChartProps) {
18+
if (!data || Object.keys(data).length === 0) {
19+
return (
20+
<div className="flex items-center justify-center h-64 text-gray-500 text-sm">
21+
No vocal analysis data available
22+
</div>
23+
);
24+
}
25+
26+
const chartData = Object.entries(data).map(([key, value]) => ({
27+
metric: metricLabels[key] || key,
28+
value: Math.min(100, Math.max(0, value)),
29+
}));
30+
31+
return (
32+
<div style={{ height: 300 }}>
33+
<ResponsiveRadar
34+
data={chartData}
35+
keys={["value"]}
36+
indexBy="metric"
37+
maxValue={100}
38+
margin={{ top: 40, right: 60, bottom: 40, left: 60 }}
39+
curve="linearClosed"
40+
borderWidth={2}
41+
borderColor="#6366f1"
42+
gridLevels={4}
43+
gridShape="circular"
44+
gridLabelOffset={16}
45+
dotSize={8}
46+
dotColor="#6366f1"
47+
dotBorderWidth={2}
48+
dotBorderColor="#1e1b4b"
49+
colors={["#6366f1"]}
50+
fillOpacity={0.2}
51+
blendMode="normal"
52+
animate
53+
motionConfig="gentle"
54+
theme={{
55+
background: "transparent",
56+
text: { fill: "#9ca3af", fontSize: 11 },
57+
grid: { line: { stroke: "rgba(255,255,255,0.08)" } },
58+
tooltip: {
59+
container: {
60+
background: "#1f2937",
61+
color: "#f3f4f6",
62+
borderRadius: "8px",
63+
fontSize: 12,
64+
},
65+
},
66+
}}
67+
/>
68+
</div>
69+
);
70+
}

0 commit comments

Comments
 (0)