From 6d542f90a7be09cd0beb7aee9bc338a64219aa84 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 1 Jun 2026 19:54:48 +0300 Subject: [PATCH] Clickable card dialogs, hover sparkline tooltips, fix row cropping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each patient card is now a Dialog trigger (nativeButton=false for accessible click/keyboard) that opens a roomier detail dialog with expanded per-topic info (full demographics, vitals/labs min·max·latest with a larger chart, all labs with dates, full visit notes). - Sparkline shows the value (dot + tooltip) at the nearest reading on mousemove; reused larger in the dialogs. - Row padding pb-2 → p-2 so card tops and the first/last card edges (ring/shadow/rounded corners) are no longer clipped by the scroller. Co-Authored-By: Claude Opus 4.8 --- frontend/components/chat/patient-cards.tsx | 415 +++++++++++++++------ frontend/components/chat/sparkline.tsx | 96 +++-- 2 files changed, 361 insertions(+), 150 deletions(-) diff --git a/frontend/components/chat/patient-cards.tsx b/frontend/components/chat/patient-cards.tsx index 7490ae7..d8bed64 100644 --- a/frontend/components/chat/patient-cards.tsx +++ b/frontend/components/chat/patient-cards.tsx @@ -11,9 +11,18 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Sparkline } from "@/components/chat/sparkline"; +import { cn } from "@/lib/utils"; import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients"; type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; @@ -45,8 +54,10 @@ const statusVariant: Record = { const sexLabel: Record = { 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"; +// Fixed width so the cards sit in a horizontal scroll row instead of squashing, +// plus a subtle clickable affordance (they open a detail dialog). +const rowCard = + "w-80 shrink-0 cursor-pointer snap-start text-left outline-none transition hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring"; function SectionLabel({ children }: { children: ReactNode }) { return ( @@ -75,24 +86,121 @@ function Row({ label, value }: { label: ReactNode; value: ReactNode }) { } function TrendBlock({ trend }: { trend: Trend }) { - const latest = trend.points.at(-1); return (
{`${trend.label} · last ${trend.points.length}`} - {latest} + {trend.points.at(-1)} {trend.unit}
- +
); } -function SummaryCard({ patient }: { patient: Patient }) { +function TrendDetail({ trend }: { trend: Trend }) { + const min = Math.min(...trend.points); + const max = Math.max(...trend.points); return ( - +
+
+ {`${trend.label} · last ${trend.points.length} readings`} +
+ + Latest{" "} + + {trend.points.at(-1)} {trend.unit} + + + + Min {min} + + + Max {max} + +
+
+ +
+ ); +} + +function AlertBadges({ alerts }: { alerts: string[] }) { + if (alerts.length === 0) { + return null; + } + return ( +
+ {alerts.map((alert) => ( + + {alert} + + ))} +
+ ); +} + +// A card that previews `children` and opens a roomier dialog of `detail` on click. +function ExpandableCard({ + title, + description, + detail, + children, +}: { + title: ReactNode; + description?: ReactNode; + detail: ReactNode; + children: ReactNode; +}) { + return ( + + } + > + {children} + + + + {title} + {description ? {description} : null} + + {detail} + + + ); +} + +function SummaryCard({ patient }: { patient: Patient }) { + const idLine = `${patient.age} · ${sexLabel[patient.sex]} · MRN ${patient.fileNumber}`; + return ( + +
+ + + + + + {patient.status}} + /> + + +
+ + + } + title={patient.name} + >
@@ -100,9 +208,7 @@ function SummaryCard({ patient }: { patient: Patient }) {
{patient.name} - - {patient.age} · {sexLabel[patient.sex]} · MRN {patient.fileNumber} - + {idLine}
{patient.status} @@ -116,17 +222,9 @@ function SummaryCard({ patient }: { patient: Patient }) {
- {patient.alerts.length > 0 && ( -
- {patient.alerts.map((alert) => ( - - {alert} - - ))} -
- )} + -
+ ); } @@ -138,29 +236,78 @@ function VitalsCard({ patient }: { patient: Patient }) { { label: "Temp", value: vitals.temp }, { label: "SpO₂", value: vitals.spo2 }, ]; + const vitalsGrid = (gapY: string) => ( +
+ {vitalItems.map((item) => ( + + ))} +
+ ); return ( - + + {vitalsGrid("gap-y-3")} + + + + } + title="Vitals" + > Vitals Taken {vitals.takenAt} -
- {vitalItems.map((item) => ( - - ))} -
+ {vitalsGrid("gap-y-3")}
-
+ + ); +} + +function labValue(value: string, flag: LabFlag) { + return ( + + {value} + + {flag} + + ); } function LabsCard({ patient }: { patient: Patient }) { return ( - + +
+ {patient.labs.map((lab) => ( +
+
+ {lab.name} + + {lab.takenAt} + +
+ {labValue(lab.value, lab.flag)} +
+ ))} +
+ + + + } + title="Labs" + > Labs As of {patient.labs[0]?.takenAt ?? "—"} @@ -171,147 +318,169 @@ function LabsCard({ patient }: { patient: Patient }) { - {lab.value} - - {lab.flag} - - - } + value={labValue(lab.value, lab.flag)} /> ))} -
+ ); } function MedicationsCard({ patient }: { patient: Patient }) { + const list = ( +
+ {patient.medications.map((med) => ( + + ))} +
+ ); return ( - + Medications {patient.medications.length} active - - {patient.medications.map((med) => ( - - ))} - - + {list} + ); } function ProblemsCard({ patient }: { patient: Patient }) { + const list = ( +
+ {patient.problems.map((problem) => ( + + ))} +
+ ); return ( - + Problems {patient.problems.length} active - - {patient.problems.map((problem) => ( - - ))} - - + {list} + + ); +} + +function AllergiesList({ patient }: { patient: Patient }) { + return ( +
+ +
+ Allergies + {patient.allergies.length === 0 ? ( +

No known allergies.

+ ) : ( + patient.allergies.map((allergy) => ( + + {allergy.substance} + + {" "} + — {allergy.reaction} + + + } + value={ + + {allergy.severity} + + } + /> + )) + )} +
+
); } function AllergiesCard({ patient }: { patient: Patient }) { return ( - + } + title="Allergies & alerts" + > Allergies & alerts - - {patient.alerts.length > 0 && ( -
- {patient.alerts.map((alert) => ( - - {alert} - - ))} -
- )} -
- Allergies - {patient.allergies.length === 0 ? ( -

No known allergies.

- ) : ( - patient.allergies.map((allergy) => ( - - {allergy.substance} - - {" "} - — {allergy.reaction} - - - } - value={ - - {allergy.severity} - - } - /> - )) - )} -
+ + -
+ + ); +} + +function VisitsList({ patient }: { patient: Patient }) { + return ( +
+ {patient.encounters.map((encounter) => ( +
+
+ {encounter.type} + + {encounter.date} + +
+ {encounter.summary} + + {encounter.provider} + +
+ ))} +
); } function VisitsCard({ patient }: { patient: Patient }) { return ( - + } + title="Recent visits" + > Recent visits - - {patient.encounters.map((encounter) => ( -
-
- {encounter.type} - - {encounter.date} - -
- {encounter.summary} - - {encounter.provider} - -
- ))} + + -
+ ); } function LoadingCards() { return ( <> - +
@@ -328,7 +497,7 @@ function LoadingCards() { {[0, 1, 2, 3, 4, 5].map((card) => ( - + @@ -358,7 +527,7 @@ export function PatientResult({ status, fileNumber, patient }: PatientResultProp } return ( -
+
{status === "loading" || !patient ? ( ) : ( diff --git a/frontend/components/chat/sparkline.tsx b/frontend/components/chat/sparkline.tsx index f91fa2a..38526cc 100644 --- a/frontend/components/chat/sparkline.tsx +++ b/frontend/components/chat/sparkline.tsx @@ -1,19 +1,23 @@ "use client"; -import { useId } from "react"; +import { type MouseEvent, useId, useState } 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). +// Moving the cursor over it reveals the value at the nearest reading. export function Sparkline({ points, + unit, className, }: { points: number[]; + unit?: string; className?: string; }) { const gradientId = useId(); + const [active, setActive] = useState(null); if (points.length === 0) { return null; @@ -22,7 +26,7 @@ export function Sparkline({ const width = 100; const top = 3; const bottom = 29; - const baseline = 32; + const viewHeight = 32; const min = Math.min(...points); const max = Math.max(...points); const range = max - min; @@ -34,35 +38,73 @@ export function Sparkline({ range === 0 ? (top + bottom) / 2 : bottom - ((value - min) / range) * (bottom - top); - return `${x.toFixed(2)},${y.toFixed(2)}`; + return { value, x, y, xPct: x, yPct: (y / viewHeight) * 100 }; }); - const line = coords.map((c, i) => `${i === 0 ? "M" : "L"}${c}`).join(" "); - const area = `${line} L${width},${baseline} L0,${baseline} Z`; + const line = coords + .map((c, i) => `${i === 0 ? "M" : "L"}${c.x.toFixed(2)},${c.y.toFixed(2)}`) + .join(" "); + const area = `${line} L${width},${viewHeight} L0,${viewHeight} Z`; + + const handleMove = (event: MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const ratio = (event.clientX - rect.left) / rect.width; + const index = Math.round(ratio * (points.length - 1)); + setActive(Math.max(0, Math.min(points.length - 1, index))); + }; + + const point = active === null ? null : coords[active]; return ( - + + + {point && ( + <> + + + {point.value} + {unit ? ( + {unit} + ) : null} + + + )} +
); }