From 88a45cf19df2c321e368c137c0b3428ee79fb0b0 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 1 Jun 2026 18:56:56 +0300 Subject: [PATCH] Add /patient lookup with patient-info cards, clinical chat input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type `/patient ` to pull up a patient: a skeleton card set appears, then fills in with summary, allergies & alerts, medications & problems, and vitals/labs/visits — composed from existing Card/Badge/ Avatar/Separator/Skeleton primitives (no new design). - lib/patients.ts: typed mock fixture + async getPatient() (swap point for a real API later). - components/chat/patient-cards.tsx: PatientResult (loading/ready/ not-found). - chat-panel.tsx: discriminated-union message model, strict /patient parse, async lookup with update-by-id, clinical heading. - chat-input.tsx: relabel leftover coding-assistant controls to clinical copy + clinical icons; layout/styling unchanged. Co-Authored-By: Claude Opus 4.8 --- frontend/components/chat/chat-input.tsx | 30 +-- frontend/components/chat/chat-panel.tsx | 107 ++++++-- frontend/components/chat/patient-cards.tsx | 279 +++++++++++++++++++++ frontend/lib/patients.ts | 228 +++++++++++++++++ 4 files changed, 608 insertions(+), 36 deletions(-) create mode 100644 frontend/components/chat/patient-cards.tsx create mode 100644 frontend/lib/patients.ts diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx index d1b9547..58d71b6 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -3,14 +3,14 @@ import type { ChatStatus } from "ai"; import { ArrowUp, + Building2, + CalendarRange, ChevronDown, - FolderGit2, - GitBranch, Hand, - Laptop, Mic, Plus, Square, + Stethoscope, } from "lucide-react"; import { type KeyboardEvent, useCallback, useState } from "react"; @@ -72,30 +72,30 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) { 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" onChange={(event) => setValue(event.target.value)} onKeyDown={handleKeyDown} - placeholder="Do anything" + placeholder="Look up a patient — try /patient 10293" rows={1} value={value} />
-
-
diff --git a/frontend/components/chat/chat-panel.tsx b/frontend/components/chat/chat-panel.tsx index be9c225..e947f17 100644 --- a/frontend/components/chat/chat-panel.tsx +++ b/frontend/components/chat/chat-panel.tsx @@ -15,21 +15,31 @@ import { MessageResponse, } from "@/components/ai-elements/message"; import { ChatInput } from "@/components/chat/chat-input"; +import { PatientResult } from "@/components/chat/patient-cards"; +import { getPatient, type Patient } from "@/lib/patients"; -type ChatMessage = { - id: string; - role: "user" | "assistant"; - text: string; -}; +type ChatMessage = + | { id: string; role: "user"; text: string } + | { id: string; role: "assistant"; kind: "text"; text: string } + | { + id: string; + role: "assistant"; + kind: "patient"; + fileNumber: string; + status: "loading" | "ready" | "not-found"; + patient?: Patient; + }; -const HEADING = "What should we build in design-ai?"; +const HEADING = "Which patient would you like to look up?"; + +// Strict trigger: only `/patient ` pulls up records. +const PATIENT_COMMAND = /^\/patient\s+(\S+)$/i; export function ChatPanel() { const [messages, setMessages] = useState([]); const [status, setStatus] = useState("ready"); - // UI-only: append the user's message and a mock assistant reply. - const send = useCallback((text: string) => { + const send = useCallback(async (text: string) => { const trimmed = text.trim(); if (!trimmed) { return; @@ -39,8 +49,46 @@ export function ChatPanel() { ...prev, { id: nanoid(), role: "user", text: trimmed }, ]); - setStatus("submitted"); + const match = trimmed.match(PATIENT_COMMAND); + + // Patient lookup: append a loading card set, then fill it in once the + // (mock) record comes back. + if (match) { + const fileNumber = match[1]; + const resultId = nanoid(); + setStatus("submitted"); + setMessages((prev) => [ + ...prev, + { + id: resultId, + role: "assistant", + kind: "patient", + fileNumber, + status: "loading", + }, + ]); + + const patient = await getPatient(fileNumber); + setMessages((prev) => + prev.map((message) => + message.id === resultId && + message.role === "assistant" && + message.kind === "patient" + ? { + ...message, + status: patient ? "ready" : "not-found", + patient: patient ?? undefined, + } + : message + ) + ); + setStatus("ready"); + return; + } + + // UI-only: mock assistant reply for anything that isn't a command. + setStatus("submitted"); window.setTimeout(() => { setStatus("streaming"); setMessages((prev) => [ @@ -48,7 +96,8 @@ export function ChatPanel() { { id: nanoid(), role: "assistant", - text: `This is a **UI-only preview** of temetro. Connecting to your records and a model comes next.\n\nYou asked:\n\n> ${trimmed}`, + kind: "text", + text: `This is a **UI-only preview** of temetro. Try \`/patient 10293\` to pull up a patient's records.\n\nYou asked:\n\n> ${trimmed}`, }, ]); window.setTimeout(() => setStatus("ready"), 250); @@ -78,17 +127,33 @@ export function ChatPanel() {
- {messages.map((message) => ( - - - {message.role === "assistant" ? ( - {message.text} - ) : ( - {message.text} - )} - - - ))} + {messages.map((message) => { + if (message.role === "assistant" && message.kind === "patient") { + return ( + + + + + + ); + } + + return ( + + + {message.role === "user" ? ( + {message.text} + ) : ( + {message.text} + )} + + + ); + })} diff --git a/frontend/components/chat/patient-cards.tsx b/frontend/components/chat/patient-cards.tsx new file mode 100644 index 0000000..ae6ac98 --- /dev/null +++ b/frontend/components/chat/patient-cards.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { AllergySeverity, LabFlag, Patient } from "@/lib/patients"; + +type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; + +type PatientResultProps = { + status: "loading" | "ready" | "not-found"; + fileNumber: string; + patient?: Patient; +}; + +const severityVariant: Record = { + mild: "outline", + moderate: "secondary", + severe: "destructive", +}; + +const labFlagVariant: Record = { + normal: "outline", + low: "secondary", + high: "secondary", + critical: "destructive", +}; + +const statusVariant: Record = { + active: "secondary", + inpatient: "destructive", + discharged: "outline", +}; + +const sexLabel: Record = { F: "Female", M: "Male" }; + +function SectionLabel({ children }: { children: string }) { + return ( +

+ {children} +

+ ); +} + +function SummaryCard({ patient }: { patient: Patient }) { + return ( + + +
+ + {patient.initials} + +
+ {patient.name} + + {patient.age} · {sexLabel[patient.sex]} · MRN {patient.fileNumber} + +
+ + {patient.status} + +
+
+ + Primary care: + {patient.pcp} + +
+ ); +} + +function AllergiesCard({ patient }: { patient: Patient }) { + return ( + + + 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} + + + + {allergy.severity} + +
+ )) + )} +
+
+
+ ); +} + +function MedicationsCard({ patient }: { patient: Patient }) { + return ( + + + Medications & problems + + +
+ Active medications + {patient.medications.map((med) => ( +
+ {med.name} + + {med.dose} · {med.frequency} + +
+ ))} +
+ +
+ Problem list + {patient.problems.map((problem) => ( +
+ {problem.label} + since {problem.since} +
+ ))} +
+
+
+ ); +} + +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 ( + + + Vitals, labs & visits + Vitals taken {vitals.takenAt} + + +
+ {vitalItems.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ +
+ Recent labs + {patient.labs.map((lab) => ( +
+ {lab.name} +
+ {lab.value} + + {lab.flag} + +
+
+ ))} +
+ +
+ Recent visits + {patient.encounters.map((encounter) => ( +
+
+ {encounter.type} + {encounter.date} +
+ {encounter.summary} + {encounter.provider} +
+ ))} +
+
+
+ ); +} + +function LoadingCards() { + return ( + <> + + +
+ +
+ + +
+
+
+
+ {[0, 1, 2].map((card) => ( + + + + + + {[0, 1, 2].map((row) => ( + + ))} + + + ))} + + ); +} + +export function PatientResult({ status, fileNumber, patient }: PatientResultProps) { + if (status === "not-found") { + return ( + + +

+ No patient found for file #{fileNumber}. +

+
+
+ ); + } + + return ( +
+ {status === "loading" || !patient ? ( + + ) : ( + <> + + + + + + )} +
+ ); +} diff --git a/frontend/lib/patients.ts b/frontend/lib/patients.ts new file mode 100644 index 0000000..a2fcc08 --- /dev/null +++ b/frontend/lib/patients.ts @@ -0,0 +1,228 @@ +// UI-only patient fixtures for the temetro chat demo. +// There is no backend yet — `getPatient` is an async wrapper over a static +// fixture so call sites already look like a real fetch and can be swapped for +// an API call later. All data below is fictional sample data. + +export type AllergySeverity = "mild" | "moderate" | "severe"; +export type LabFlag = "normal" | "high" | "low" | "critical"; + +export type Allergy = { + substance: string; + reaction: string; + severity: AllergySeverity; +}; + +export type Medication = { + name: string; + dose: string; + frequency: string; +}; + +export type Problem = { + label: string; + since: string; +}; + +export type Vitals = { + bp: string; + hr: string; + temp: string; + spo2: string; + takenAt: string; +}; + +export type Lab = { + name: string; + value: string; + flag: LabFlag; + takenAt: string; +}; + +export type Encounter = { + date: string; + type: string; + provider: string; + summary: string; +}; + +export type Patient = { + fileNumber: string; // MRN / file number, e.g. "10293" + name: string; + age: number; + sex: "M" | "F"; + pcp: string; // primary care provider + status: "active" | "inpatient" | "discharged"; + initials: string; // for AvatarFallback + allergies: Allergy[]; + alerts: string[]; + medications: Medication[]; + problems: Problem[]; + vitals: Vitals; + labs: Lab[]; + encounters: Encounter[]; +}; + +const PATIENTS: Record = { + "10293": { + fileNumber: "10293", + name: "Amina Hassan", + age: 64, + sex: "F", + pcp: "Dr. Lena Ortiz", + status: "active", + initials: "AH", + allergies: [ + { substance: "Penicillin", reaction: "Anaphylaxis", severity: "severe" }, + { substance: "Sulfa drugs", reaction: "Rash", severity: "moderate" }, + ], + alerts: ["Anticoagulated", "Fall risk"], + medications: [ + { name: "Metformin", dose: "1000 mg", frequency: "twice daily" }, + { name: "Lisinopril", dose: "20 mg", frequency: "once daily" }, + { name: "Apixaban", dose: "5 mg", frequency: "twice daily" }, + { name: "Atorvastatin", dose: "40 mg", frequency: "at bedtime" }, + ], + problems: [ + { label: "Type 2 diabetes mellitus", since: "2014" }, + { label: "Atrial fibrillation", since: "2019" }, + { label: "Hypertension", since: "2011" }, + { label: "Hyperlipidemia", since: "2015" }, + ], + vitals: { + bp: "148/86 mmHg", + hr: "78 bpm", + temp: "37.0 °C", + spo2: "97%", + takenAt: "May 28, 2026", + }, + 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" }, + ], + encounters: [ + { + date: "May 28, 2026", + type: "Clinic visit", + provider: "Dr. Lena Ortiz", + summary: "Diabetes follow-up; metformin dose increased.", + }, + { + date: "Mar 12, 2026", + type: "Cardiology", + provider: "Dr. Raj Patel", + summary: "AF stable on apixaban; rate well controlled.", + }, + { + date: "Jan 04, 2026", + type: "Emergency", + provider: "Dr. S. Cole", + summary: "Presented with palpitations; discharged same day.", + }, + ], + }, + "20481": { + fileNumber: "20481", + name: "Daniel Okoro", + age: 47, + sex: "M", + pcp: "Dr. Priya Nair", + status: "inpatient", + initials: "DO", + allergies: [ + { substance: "Codeine", reaction: "Nausea", severity: "mild" }, + ], + alerts: ["Isolation — MRSA"], + medications: [ + { name: "Piperacillin-tazobactam", dose: "4.5 g", frequency: "every 8 hours" }, + { name: "Enoxaparin", dose: "40 mg", frequency: "once daily" }, + { name: "Pantoprazole", dose: "40 mg", frequency: "once daily" }, + ], + problems: [ + { label: "Community-acquired pneumonia", since: "2026" }, + { label: "COPD", since: "2018" }, + { label: "Former smoker", since: "2010" }, + ], + vitals: { + bp: "112/70 mmHg", + hr: "94 bpm", + temp: "38.4 °C", + spo2: "92%", + takenAt: "May 31, 2026", + }, + 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" }, + ], + encounters: [ + { + date: "May 30, 2026", + type: "Admission", + provider: "Dr. Priya Nair", + summary: "Admitted with febrile pneumonia; IV antibiotics started.", + }, + { + date: "Feb 18, 2026", + type: "Pulmonology", + provider: "Dr. M. Adeyemi", + summary: "COPD review; inhaler technique reinforced.", + }, + ], + }, + "30574": { + fileNumber: "30574", + name: "Grace Liang", + age: 29, + sex: "F", + pcp: "Dr. Tom Becker", + status: "active", + initials: "GL", + allergies: [], + alerts: ["Pregnant — 2nd trimester"], + medications: [ + { name: "Prenatal vitamins", dose: "1 tab", frequency: "once daily" }, + { name: "Levothyroxine", dose: "75 mcg", frequency: "once daily" }, + ], + problems: [ + { label: "Pregnancy (G1P0, 22 weeks)", since: "2026" }, + { label: "Hypothyroidism", since: "2021" }, + ], + vitals: { + bp: "118/72 mmHg", + hr: "82 bpm", + temp: "36.8 °C", + spo2: "99%", + takenAt: "May 26, 2026", + }, + 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" }, + ], + encounters: [ + { + date: "May 26, 2026", + type: "Antenatal visit", + provider: "Dr. Tom Becker", + summary: "Routine 22-week check; fetal growth on track.", + }, + { + date: "Apr 14, 2026", + type: "Obstetrics", + provider: "Dr. Tom Becker", + summary: "Anatomy scan normal; thyroid dose unchanged.", + }, + ], + }, +}; + +export const SAMPLE_FILE_NUMBERS = Object.keys(PATIENTS); + +export async function getPatient(fileNumber: string): Promise { + // Simulate retrieval latency so the loading (skeleton) state is visible. + await new Promise((resolve) => setTimeout(resolve, 700)); + return PATIENTS[fileNumber.trim()] ?? null; +}