Redesign patient cards: 7 focused cards + sparklines; lighten input

- Split the overloaded card into 7 single-topic cards (Summary, Vitals,
  Labs, Medications, Problems, Allergies & alerts, Visits) so the
  equal-height row is evenly filled instead of stretched-and-empty.
- Add a dependency-free inline-SVG shaded-area Sparkline; Vitals shows a
  heart-rate trend and Labs a headline-lab trend, fed by new vitalsTrend/
  labTrend series in the fixture.
- Lighten the chat input's two surfaces so it reads as a raised layer
  above the new (lighter) page background.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-01 19:42:30 +03:00
parent 011c71a3ad
commit 9a7e694d5a
4 changed files with 315 additions and 112 deletions
+2 -2
View File
@@ -192,10 +192,10 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
event.preventDefault();
submit();
}}
className="w-full overflow-hidden rounded-[28px] border border-border/60 bg-[oklch(0.172_0.006_277)] shadow-sm"
className="w-full overflow-hidden rounded-[28px] border border-border/60 bg-[oklch(0.195_0.006_277)] shadow-sm"
>
{/* Top (lighter) card: textarea + toolbar, with a slightly smaller bottom radius */}
<div className="rounded-b-[22px] bg-card">
<div className="rounded-b-[22px] bg-[oklch(0.218_0.006_277)]">
<textarea
aria-label="Message"
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
+205 -110
View File
@@ -1,5 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
@@ -11,7 +13,8 @@ import {
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import type { AllergySeverity, LabFlag, Patient } from "@/lib/patients";
import { Sparkline } from "@/components/chat/sparkline";
import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
@@ -45,7 +48,7 @@ const sexLabel: Record<Patient["sex"], string> = { F: "Female", M: "Male" };
// Fixed width so the cards sit in a horizontal scroll row instead of squashing.
const rowCard = "w-80 shrink-0 snap-start";
function SectionLabel({ children }: { children: string }) {
function SectionLabel({ children }: { children: ReactNode }) {
return (
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{children}
@@ -53,6 +56,40 @@ function SectionLabel({ children }: { children: string }) {
);
}
function Stat({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-foreground">{value}</span>
</div>
);
}
function Row({ label, value }: { label: ReactNode; value: ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3">
<span className="text-foreground">{label}</span>
<span className="shrink-0 text-muted-foreground">{value}</span>
</div>
);
}
function TrendBlock({ trend }: { trend: Trend }) {
const latest = trend.points.at(-1);
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<SectionLabel>{`${trend.label} · last ${trend.points.length}`}</SectionLabel>
<span className="text-foreground">
{latest}
<span className="text-muted-foreground"> {trend.unit}</span>
</span>
</div>
<Sparkline points={trend.points} />
</div>
);
}
function SummaryCard({ patient }: { patient: Patient }) {
return (
<Card className={rowCard} size="sm">
@@ -72,9 +109,121 @@ function SummaryCard({ patient }: { patient: Patient }) {
</Badge>
</div>
</CardHeader>
<CardContent>
<span className="text-muted-foreground">Primary care: </span>
<span className="text-foreground">{patient.pcp}</span>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
<Stat label="Primary care" value={patient.pcp} />
<Stat label="Last seen" value={patient.encounters[0]?.date ?? "—"} />
<Stat label="Active meds" value={patient.medications.length} />
<Stat label="Open problems" value={patient.problems.length} />
</div>
{patient.alerts.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{patient.alerts.map((alert) => (
<Badge key={alert} variant="outline">
{alert}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
);
}
function VitalsCard({ patient }: { patient: Patient }) {
const { vitals } = patient;
const vitalItems = [
{ label: "BP", value: vitals.bp },
{ label: "HR", value: vitals.hr },
{ label: "Temp", value: vitals.temp },
{ label: "SpO₂", value: vitals.spo2 },
];
return (
<Card className={rowCard} size="sm">
<CardHeader>
<CardTitle>Vitals</CardTitle>
<CardDescription>Taken {vitals.takenAt}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
{vitalItems.map((item) => (
<Stat key={item.label} label={item.label} value={item.value} />
))}
</div>
<Separator />
<TrendBlock trend={patient.vitalsTrend} />
</CardContent>
</Card>
);
}
function LabsCard({ patient }: { patient: Patient }) {
return (
<Card className={rowCard} size="sm">
<CardHeader>
<CardTitle>Labs</CardTitle>
<CardDescription>As of {patient.labs[0]?.takenAt ?? "—"}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{patient.labs.map((lab) => (
<Row
key={lab.name}
label={lab.name}
value={
<span className="flex items-center gap-2">
{lab.value}
<Badge className="capitalize" variant={labFlagVariant[lab.flag]}>
{lab.flag}
</Badge>
</span>
}
/>
))}
</div>
<Separator />
<TrendBlock trend={patient.labTrend} />
</CardContent>
</Card>
);
}
function MedicationsCard({ patient }: { patient: Patient }) {
return (
<Card className={rowCard} size="sm">
<CardHeader>
<CardTitle>Medications</CardTitle>
<CardDescription>{patient.medications.length} active</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{patient.medications.map((med) => (
<Row
key={med.name}
label={med.name}
value={`${med.dose} · ${med.frequency}`}
/>
))}
</CardContent>
</Card>
);
}
function ProblemsCard({ patient }: { patient: Patient }) {
return (
<Card className={rowCard} size="sm">
<CardHeader>
<CardTitle>Problems</CardTitle>
<CardDescription>{patient.problems.length} active</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{patient.problems.map((problem) => (
<Row
key={problem.label}
label={problem.label}
value={`since ${problem.since}`}
/>
))}
</CardContent>
</Card>
);
@@ -102,24 +251,26 @@ function AllergiesCard({ patient }: { patient: Patient }) {
<p className="text-muted-foreground">No known allergies.</p>
) : (
patient.allergies.map((allergy) => (
<div
className="flex items-center justify-between gap-3"
<Row
key={allergy.substance}
>
<span className="text-foreground">
{allergy.substance}
<span className="text-muted-foreground">
{" "}
{allergy.reaction}
</span>
</span>
<Badge
className="capitalize"
variant={severityVariant[allergy.severity]}
>
{allergy.severity}
</Badge>
</div>
label={
<>
{allergy.substance}
<span className="text-muted-foreground">
{" "}
{allergy.reaction}
</span>
</>
}
value={
<Badge
className="capitalize"
variant={severityVariant[allergy.severity]}
>
{allergy.severity}
</Badge>
}
/>
))
)}
</div>
@@ -128,95 +279,30 @@ function AllergiesCard({ patient }: { patient: Patient }) {
);
}
function MedicationsCard({ patient }: { patient: Patient }) {
function VisitsCard({ patient }: { patient: Patient }) {
return (
<Card className={rowCard} size="sm">
<CardHeader>
<CardTitle>Medications & problems</CardTitle>
<CardTitle>Recent visits</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<SectionLabel>Active medications</SectionLabel>
{patient.medications.map((med) => (
<div className="flex items-baseline justify-between gap-3" key={med.name}>
<span className="text-foreground">{med.name}</span>
<span className="text-muted-foreground">
{med.dose} · {med.frequency}
<CardContent className="flex flex-col gap-3">
{patient.encounters.map((encounter) => (
<div
className="flex flex-col gap-0.5"
key={encounter.date + encounter.type}
>
<div className="flex items-baseline justify-between gap-3">
<span className="text-foreground">{encounter.type}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{encounter.date}
</span>
</div>
))}
</div>
<Separator />
<div className="flex flex-col gap-2">
<SectionLabel>Problem list</SectionLabel>
{patient.problems.map((problem) => (
<div
className="flex items-baseline justify-between gap-3"
key={problem.label}
>
<span className="text-foreground">{problem.label}</span>
<span className="text-muted-foreground">since {problem.since}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
function VitalsCard({ patient }: { patient: Patient }) {
const { vitals } = patient;
const vitalItems = [
{ label: "BP", value: vitals.bp },
{ label: "HR", value: vitals.hr },
{ label: "Temp", value: vitals.temp },
{ label: "SpO₂", value: vitals.spo2 },
];
return (
<Card className={rowCard} size="sm">
<CardHeader>
<CardTitle>Vitals, labs & visits</CardTitle>
<CardDescription>Vitals taken {vitals.takenAt}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{vitalItems.map((item) => (
<div className="flex flex-col gap-0.5" key={item.label}>
<span className="text-xs text-muted-foreground">{item.label}</span>
<span className="text-foreground">{item.value}</span>
</div>
))}
</div>
<Separator />
<div className="flex flex-col gap-2">
<SectionLabel>Recent labs</SectionLabel>
{patient.labs.map((lab) => (
<div className="flex items-center justify-between gap-3" key={lab.name}>
<span className="text-foreground">{lab.name}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{lab.value}</span>
<Badge className="capitalize" variant={labFlagVariant[lab.flag]}>
{lab.flag}
</Badge>
</div>
</div>
))}
</div>
<Separator />
<div className="flex flex-col gap-3">
<SectionLabel>Recent visits</SectionLabel>
{patient.encounters.map((encounter) => (
<div className="flex flex-col gap-0.5" key={encounter.date + encounter.type}>
<div className="flex items-baseline justify-between gap-3">
<span className="text-foreground">{encounter.type}</span>
<span className="text-xs text-muted-foreground">{encounter.date}</span>
</div>
<span className="text-muted-foreground">{encounter.summary}</span>
<span className="text-xs text-muted-foreground">{encounter.provider}</span>
</div>
))}
</div>
<span className="text-muted-foreground">{encounter.summary}</span>
<span className="text-xs text-muted-foreground">
{encounter.provider}
</span>
</div>
))}
</CardContent>
</Card>
);
@@ -231,20 +317,26 @@ function LoadingCards() {
<Skeleton className="size-10 rounded-full" />
<div className="grid flex-1 gap-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
<Skeleton className="h-3 w-52" />
</div>
</div>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3">
{[0, 1, 2, 3].map((cell) => (
<Skeleton className="h-8 w-full" key={cell} />
))}
</CardContent>
</Card>
{[0, 1, 2].map((card) => (
{[0, 1, 2, 3, 4, 5].map((card) => (
<Card className={rowCard} key={card} size="sm">
<CardHeader>
<Skeleton className="h-4 w-44" />
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="flex flex-col gap-2.5">
{[0, 1, 2].map((row) => (
<Skeleton className="h-3.5 w-full" key={row} />
))}
{card % 2 === 1 && <Skeleton className="mt-1.5 h-10 w-full" />}
</CardContent>
</Card>
))}
@@ -272,9 +364,12 @@ export function PatientResult({ status, fileNumber, patient }: PatientResultProp
) : (
<>
<SummaryCard patient={patient} />
<AllergiesCard patient={patient} />
<MedicationsCard patient={patient} />
<VitalsCard patient={patient} />
<LabsCard patient={patient} />
<MedicationsCard patient={patient} />
<ProblemsCard patient={patient} />
<AllergiesCard patient={patient} />
<VisitsCard patient={patient} />
</>
)}
</div>
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useId } from "react";
import { cn } from "@/lib/utils";
// A tiny dependency-free trend chart: a line with a soft shaded fill beneath it.
// Stretches to its container width; colored via `currentColor` (default text-primary).
export function Sparkline({
points,
className,
}: {
points: number[];
className?: string;
}) {
const gradientId = useId();
if (points.length === 0) {
return null;
}
const width = 100;
const top = 3;
const bottom = 29;
const baseline = 32;
const min = Math.min(...points);
const max = Math.max(...points);
const range = max - min;
const coords = points.map((value, index) => {
const x =
points.length === 1 ? width / 2 : (index / (points.length - 1)) * width;
const y =
range === 0
? (top + bottom) / 2
: bottom - ((value - min) / range) * (bottom - top);
return `${x.toFixed(2)},${y.toFixed(2)}`;
});
const line = coords.map((c, i) => `${i === 0 ? "M" : "L"}${c}`).join(" ");
const area = `${line} L${width},${baseline} L0,${baseline} Z`;
return (
<svg
aria-hidden="true"
className={cn("h-10 w-full text-primary", className)}
preserveAspectRatio="none"
viewBox={`0 0 ${width} ${baseline}`}
>
<defs>
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.28" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill={`url(#${gradientId})`} stroke="none" />
<path
d={line}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
+40
View File
@@ -45,6 +45,14 @@ export type Encounter = {
summary: string;
};
// A short series for a sparkline. `points` are most-recent-last, so the
// latest reading is points.at(-1).
export type Trend = {
label: string;
unit: string;
points: number[];
};
export type Patient = {
fileNumber: string; // MRN / file number, e.g. "10293"
name: string;
@@ -58,7 +66,9 @@ export type Patient = {
medications: Medication[];
problems: Problem[];
vitals: Vitals;
vitalsTrend: Trend; // headline vital plotted as a sparkline
labs: Lab[];
labTrend: Trend; // headline lab plotted as a sparkline
encounters: Encounter[];
};
@@ -95,12 +105,22 @@ const PATIENTS: Record<string, Patient> = {
spo2: "97%",
takenAt: "May 28, 2026",
},
vitalsTrend: {
label: "Heart rate",
unit: "bpm",
points: [72, 75, 74, 79, 83, 80, 78],
},
labs: [
{ name: "HbA1c", value: "8.2 %", flag: "high", takenAt: "May 20, 2026" },
{ name: "eGFR", value: "52 mL/min", flag: "low", takenAt: "May 20, 2026" },
{ name: "Potassium", value: "4.4 mmol/L", flag: "normal", takenAt: "May 20, 2026" },
{ name: "INR", value: "2.6", flag: "normal", takenAt: "May 20, 2026" },
],
labTrend: {
label: "HbA1c",
unit: "%",
points: [9.4, 9.1, 8.8, 8.6, 8.2],
},
encounters: [
{
date: "May 28, 2026",
@@ -151,12 +171,22 @@ const PATIENTS: Record<string, Patient> = {
spo2: "92%",
takenAt: "May 31, 2026",
},
vitalsTrend: {
label: "Heart rate",
unit: "bpm",
points: [88, 91, 96, 99, 97, 95, 94],
},
labs: [
{ name: "WBC", value: "14.8 ×10⁹/L", flag: "high", takenAt: "May 31, 2026" },
{ name: "CRP", value: "112 mg/L", flag: "critical", takenAt: "May 31, 2026" },
{ name: "Lactate", value: "1.8 mmol/L", flag: "normal", takenAt: "May 31, 2026" },
{ name: "Hemoglobin", value: "11.9 g/dL", flag: "low", takenAt: "May 31, 2026" },
],
labTrend: {
label: "CRP",
unit: "mg/L",
points: [38, 64, 98, 126, 112],
},
encounters: [
{
date: "May 30, 2026",
@@ -197,11 +227,21 @@ const PATIENTS: Record<string, Patient> = {
spo2: "99%",
takenAt: "May 26, 2026",
},
vitalsTrend: {
label: "Heart rate",
unit: "bpm",
points: [78, 80, 79, 83, 85, 84, 82],
},
labs: [
{ name: "TSH", value: "2.1 mIU/L", flag: "normal", takenAt: "May 19, 2026" },
{ name: "Hemoglobin", value: "11.2 g/dL", flag: "low", takenAt: "May 19, 2026" },
{ name: "Fasting glucose", value: "84 mg/dL", flag: "normal", takenAt: "May 19, 2026" },
],
labTrend: {
label: "Hemoglobin",
unit: "g/dL",
points: [12.4, 12.0, 11.7, 11.4, 11.2],
},
encounters: [
{
date: "May 26, 2026",