From 10dbabf83b8cc3191ea752de03d986b610ca8741 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 24 Apr 2026 05:16:51 +0200 Subject: [PATCH] feat(notes): structured ROS + PE + ICD-10 diagnosis pickers (WellVisit, SickVisit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the vanilla ROS / Physical Exam / ICD-10 diagnosis cards from public/js/shadess.js (@be14578) — renderRosRows, wireRosContainer, renderDxComponent — which the earlier React port had collapsed into plain-text textareas. Clinical data → shared/clinical/ros-pe-dx.ts: • ROS_SYSTEMS (15 systems, each with label + detail hint) • PE_SYSTEMS (17 systems) • COMMON_DX (28 pediatric quick-pick ICD-10 codes) • formatRosForAI / formatDxForAI — byte-identical to vanilla so the server-side prompt context is unchanged. Tests: ros-pe-dx.test.ts asserts the table counts, ordering, and format strings — catches the class of bug where an LLM silently drops an entry or re-orders the clinical reference during a port. New React components: client/src/components/RosPeTable.tsx — tri-state row (WNL/Abnormal/ Not reviewed) with auto-revealed note field on Abnormal. rosAllWnl / rosClear helpers mirror the "All WNL" / "Clear" buttons from vanilla. Accepts a btnLabels prop so the same component renders ROS ("WNL"/"Not reviewed") and PE ("Normal"/ "Not examined"). client/src/components/DxPicker.tsx — ICD-10 live search via NLM Clinical Tables API (free, no auth, CORS-OK) with 280ms debounce + AbortController; chip-style selected tags with × remove; 28-entry common-diagnosis quick-pick grid. Enter key adds the top result. Wired into WellVisit / VisitNote.tsx and SickVisit.tsx: • Submit now sends ros / physicalExam / diagnoses as formatted strings to /api/well-visit/note and /api/sick-visit/note. • State persists through the Save/Load encounter flow via partialData → the rosData/peData/diagnoses round-trip. --- client/src/components/DxPicker.tsx | 143 +++++++++++++++++++++++ client/src/components/RosPeTable.tsx | 97 +++++++++++++++ client/src/pages/SickVisit.tsx | 81 ++++++++++++- client/src/pages/wellvisit/VisitNote.tsx | 79 ++++++++++++- shared/clinical/ros-pe-dx.test.ts | 80 +++++++++++++ shared/clinical/ros-pe-dx.ts | 130 +++++++++++++++++++++ 6 files changed, 606 insertions(+), 4 deletions(-) create mode 100644 client/src/components/DxPicker.tsx create mode 100644 client/src/components/RosPeTable.tsx create mode 100644 shared/clinical/ros-pe-dx.test.ts create mode 100644 shared/clinical/ros-pe-dx.ts diff --git a/client/src/components/DxPicker.tsx b/client/src/components/DxPicker.tsx new file mode 100644 index 0000000..a8471ea --- /dev/null +++ b/client/src/components/DxPicker.tsx @@ -0,0 +1,143 @@ +// ============================================================ +// DxPicker — ICD-10 diagnosis picker. Live search via NLM Clinical +// Tables API (free, no auth, CORS-enabled) + a grid of common +// pediatric diagnoses for one-click add. Mirrors the vanilla +// renderDxComponent / searchIcd10 in public/js/shadess.js (@be14578). +// +// Selected diagnoses render as removable chips. Consumers pass the +// current array + a setter; the component owns search state only. +// ============================================================ + +import { useEffect, useRef, useState } from 'react'; +import { COMMON_DX, type DxEntry } from '@shared/clinical/ros-pe-dx'; + +interface Props { + value: DxEntry[]; + onChange: (next: DxEntry[]) => void; + testIdPrefix?: string; +} + +const sm = 'rounded-md border border-input bg-background px-2 py-1 text-sm'; + +async function searchIcd10(q: string, signal: AbortSignal): Promise { + const url = 'https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search?sf=code,name&terms=' + + encodeURIComponent(q) + '&maxList=12'; + const r = await fetch(url, { signal }); + const data = await r.json(); + // NLM returns [count, [codes], null, [[code, name], …]] + const rows = (data[3] || []) as Array<[string, string]>; + return rows.map(([code, name]) => ({ code, name })); +} + +export default function DxPicker({ value, onChange, testIdPrefix = 'dx' }: Props) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(false); + const abortRef = useRef(null); + const timerRef = useRef(null); + + useEffect(() => { + if (!query.trim() || query.trim().length < 2) { + setResults([]); setOpen(false); + return; + } + if (timerRef.current) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + if (abortRef.current) abortRef.current.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + searchIcd10(query.trim(), ctrl.signal) + .then((rows) => { setResults(rows); setOpen(rows.length > 0); }) + .catch(() => { /* fetch aborted or failed — leave previous state */ }); + }, 280); + return () => { if (timerRef.current) window.clearTimeout(timerRef.current); }; + }, [query]); + + function add(dx: DxEntry) { + const already = value.some((d) => d.code === dx.code && d.name === dx.name); + if (already) return; + onChange([...value, dx]); + } + function remove(i: number) { + onChange(value.filter((_, idx) => idx !== i)); + } + + return ( +
+
+ setQuery(e.target.value)} + onFocus={() => results.length && setOpen(true)} + onBlur={() => setTimeout(() => setOpen(false), 150)} + onKeyDown={(e) => { + if (e.key === 'Escape') setOpen(false); + if (e.key === 'Enter' && results[0]) { + e.preventDefault(); + add(results[0]); + setQuery(''); + setOpen(false); + } + }} + className={sm + ' w-full'} + data-testid={testIdPrefix + '-search'} + /> + {open && results.length > 0 && ( +
+ {results.map((r) => ( + + ))} +
+ )} +
+ + {value.length > 0 && ( +
+ {value.map((d, i) => ( + + {d.code && {d.code}} + {d.name} + + + ))} +
+ )} + +
+
Common pediatric diagnoses:
+
+ {COMMON_DX.map((dx) => ( + + ))} +
+
+
+ ); +} diff --git a/client/src/components/RosPeTable.tsx b/client/src/components/RosPeTable.tsx new file mode 100644 index 0000000..4ca0bc2 --- /dev/null +++ b/client/src/components/RosPeTable.tsx @@ -0,0 +1,97 @@ +// ============================================================ +// RosPeTable — structured ROS or PE input. Per-system row with +// WNL / Abnormal / Not reviewed toggle + a note field that reveals +// when "Abnormal" is selected. Mirrors window.renderRosRows / +// wireRosContainer from public/js/shadess.js (@be14578). +// +// Consumers (WellVisit Note, Sick Visit Note) pass systems list + +// data object + label set so the same component renders both +// review-of-systems and physical-exam tables. +// ============================================================ + +import type { RosData, RosStatus, SystemEntry } from '@shared/clinical/ros-pe-dx'; + +interface BtnLabels { wnl: string; abnormal: string; notrev: string } + +interface Props { + systems: ReadonlyArray; + data: RosData; + onChange: (next: RosData) => void; + btnLabels?: BtnLabels; + testIdPrefix?: string; +} + +export function rosAllWnl(systems: ReadonlyArray, data: RosData): RosData { + const next: RosData = { ...data }; + for (const s of systems) next[s.key] = { status: 'wnl', note: next[s.key]?.note }; + return next; +} +export function rosClear(data: RosData, systems: ReadonlyArray): RosData { + const next: RosData = { ...data }; + for (const s of systems) next[s.key] = {}; + return next; +} + +const DEFAULT_LABELS: BtnLabels = { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' }; + +function statusClass(active: boolean, kind: RosStatus) { + if (!active) return 'bg-background border-border hover:bg-muted'; + if (kind === 'wnl') return 'bg-green-100 text-green-800 border-green-300'; + if (kind === 'abnormal') return 'bg-red-100 text-red-800 border-red-300'; + return 'bg-muted text-muted-foreground border-border'; +} + +export default function RosPeTable({ + systems, data, onChange, btnLabels, testIdPrefix = 'ros', +}: Props) { + const labels = btnLabels || DEFAULT_LABELS; + + function setStatus(key: string, status: RosStatus) { + const cur = data[key]?.status || ''; + const nextStatus: RosStatus = cur === status ? '' : status; + onChange({ ...data, [key]: { status: nextStatus, note: data[key]?.note || '' } }); + } + function setNote(key: string, note: string) { + onChange({ ...data, [key]: { status: data[key]?.status || '', note } }); + } + + return ( +
+ {systems.map((sys) => { + const cell = data[sys.key] || {}; + const active = cell.status || ''; + return ( +
+
+ {sys.label} + ({sys.detail}) +
+
+ {(['wnl', 'abnormal', 'notrev'] as const).map((kind) => ( + + ))} +
+ {active === 'abnormal' && ( + setNote(sys.key, e.target.value)} + placeholder="Describe finding…" + className="flex-1 min-w-[200px] rounded-md border border-input bg-background px-2 py-1 text-xs" + data-testid={testIdPrefix + '-note-' + sys.key} + /> + )} +
+ ); + })} +
+ ); +} diff --git a/client/src/pages/SickVisit.tsx b/client/src/pages/SickVisit.tsx index cecb0c3..8980c11 100644 --- a/client/src/pages/SickVisit.tsx +++ b/client/src/pages/SickVisit.tsx @@ -10,6 +10,16 @@ import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas' import Recorder from '@/components/Recorder'; import EncounterToolbar from '@/components/EncounterToolbar'; import EditableResult from '@/components/EditableResult'; +import RosPeTable, { rosAllWnl, rosClear } from '@/components/RosPeTable'; +import DxPicker from '@/components/DxPicker'; +import { + ROS_SYSTEMS, + PE_SYSTEMS, + formatRosForAI, + formatDxForAI, + type RosData, + type DxEntry, +} from '@shared/clinical/ros-pe-dx'; const TYPE = 'sickvisit' as const; @@ -20,6 +30,10 @@ export default function SickVisit() { const [chiefComplaint, setChiefComplaint] = useState(''); const [transcript, setTranscript] = useState(''); const [interim, setInterim] = useState(''); + const [rosData, setRosData] = useState({}); + const [peData, setPeData] = useState({}); + const [diagnoses, setDiagnoses] = useState([]); + const [dxFreetext, setDxFreetext] = useState(''); const [result, setResult] = useState(null); const [validationError, setValidationError] = useState(null); const [recError, setRecError] = useState(null); @@ -32,7 +46,16 @@ export default function SickVisit() { function submit(e: React.FormEvent) { e.preventDefault(); setValidationError(null); - const body: SickVisitRequest = { patientAge, patientGender, chiefComplaint, transcript: (interim || transcript).trim() }; + const rosText = formatRosForAI(ROS_SYSTEMS, rosData, 'Review of Systems'); + const peText = formatRosForAI(PE_SYSTEMS, peData, 'Physical Examination'); + const dxText = formatDxForAI(diagnoses, dxFreetext); + const body: SickVisitRequest = { + patientAge, patientGender, chiefComplaint, + transcript: (interim || transcript).trim(), + ros: rosText || undefined, + physicalExam: peText || undefined, + diagnoses: dxText || undefined, + }; const parsed = SickVisitRequestSchema.safeParse(body); if (!parsed.success) { setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', ')); @@ -58,7 +81,7 @@ export default function SickVisit() { type={TYPE} label={label} setLabel={setLabel} transcript={transcript} generatedNote={result || ''} - partialData={{ age: patientAge, gender: patientGender, chiefComplaint }} + partialData={{ age: patientAge, gender: patientGender, chiefComplaint, rosData, peData, diagnoses, dxFreetext }} onLoad={(enc) => { setTranscript(enc.transcript || ''); setInterim(''); @@ -68,12 +91,17 @@ export default function SickVisit() { if (pd?.age) setPatientAge(pd.age); if (pd?.gender) setPatientGender(pd.gender); if (pd?.chiefComplaint) setChiefComplaint(pd.chiefComplaint); + if (pd?.rosData) setRosData(pd.rosData); + if (pd?.peData) setPeData(pd.peData); + if (pd?.diagnoses) setDiagnoses(pd.diagnoses); + if (pd?.dxFreetext) setDxFreetext(pd.dxFreetext); } catch { /* ignore */ } setLabel(enc.label || ''); }} onClear={() => { setTranscript(''); setInterim(''); setResult(null); setValidationError(null); setPatientAge(''); setPatientGender(''); setChiefComplaint(''); + setRosData({}); setPeData({}); setDiagnoses([]); setDxFreetext(''); }} /> @@ -119,6 +147,55 @@ export default function SickVisit() { /> +
+
+ Review of Systems +
+ + +
+
+ +
+ +
+
+ Physical Examination +
+ + +
+
+ +
+ +
+ Diagnoses (ICD-10) + + +
+ {validationError &&
{validationError}
} {generate.error &&
{(generate.error as ApiError).message}
} diff --git a/client/src/pages/wellvisit/VisitNote.tsx b/client/src/pages/wellvisit/VisitNote.tsx index dab0d50..32f2ba0 100644 --- a/client/src/pages/wellvisit/VisitNote.tsx +++ b/client/src/pages/wellvisit/VisitNote.tsx @@ -12,6 +12,16 @@ import type { VisitNoteOk } from '@/shared/types'; import EncounterToolbar from '@/components/EncounterToolbar'; import EditableResult from '@/components/EditableResult'; import Recorder from '@/components/Recorder'; +import RosPeTable, { rosAllWnl, rosClear } from '@/components/RosPeTable'; +import DxPicker from '@/components/DxPicker'; +import { + ROS_SYSTEMS, + PE_SYSTEMS, + formatRosForAI, + formatDxForAI, + type RosData, + type DxEntry, +} from '@shared/clinical/ros-pe-dx'; const TYPE = 'wellvisit' as const; const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm'; @@ -32,6 +42,10 @@ export default function VisitNote() { const [screenings, setScreenings] = useState(''); const [vaccines, setVaccines] = useState(''); const [byvisit, setByvisit] = useState(''); + const [rosData, setRosData] = useState({}); + const [peData, setPeData] = useState({}); + const [diagnoses, setDiagnoses] = useState([]); + const [dxFreetext, setDxFreetext] = useState(''); const [noteStyle, setNoteStyle] = useState<'full' | 'short'>('full'); const [result, setResult] = useState(null); @@ -57,6 +71,9 @@ export default function VisitNote() { function submit(e: React.FormEvent) { e.preventDefault(); setResult(null); + const rosText = formatRosForAI(ROS_SYSTEMS, rosData, 'ROS'); + const peText = formatRosForAI(PE_SYSTEMS, peData, 'PHYSICAL EXAM'); + const dxText = formatDxForAI(diagnoses, dxFreetext); generate.mutate({ patientAge, patientGender, visitAge, vitals, measurements, parentConcerns, @@ -65,6 +82,9 @@ export default function VisitNote() { // The byvisit summary is appended to screenings so the AI sees it. screenings: [screenings, byvisit].filter(Boolean).join('\n\n'), vaccines, + ros: rosText || undefined, + physicalExam: peText || undefined, + diagnoses: dxText || undefined, // Milestones get folded into transcript context as a developmental block. physicianMemories: milestones ? '[DEVELOPMENTAL ASSESSMENT]\n' + milestones : undefined, noteStyle, @@ -79,7 +99,7 @@ export default function VisitNote() { type={TYPE} label={label} setLabel={setLabel} transcript={transcript} generatedNote={result || ''} - partialData={{ age: patientAge, gender: patientGender, visitAge, vitals, measurements, parentConcerns, shadess, milestones, byvisit, screenings, vaccines, noteStyle }} + partialData={{ age: patientAge, gender: patientGender, visitAge, vitals, measurements, parentConcerns, shadess, milestones, byvisit, screenings, vaccines, rosData, peData, diagnoses, dxFreetext, noteStyle }} onLoad={(enc) => { setTranscript(enc.transcript || ''); setInterim(''); @@ -97,6 +117,10 @@ export default function VisitNote() { if (pd?.byvisit) setByvisit(pd.byvisit); if (pd?.screenings) setScreenings(pd.screenings); if (pd?.vaccines) setVaccines(pd.vaccines); + if (pd?.rosData) setRosData(pd.rosData); + if (pd?.peData) setPeData(pd.peData); + if (pd?.diagnoses) setDiagnoses(pd.diagnoses); + if (pd?.dxFreetext) setDxFreetext(pd.dxFreetext); if (pd?.noteStyle) setNoteStyle(pd.noteStyle); } catch { /* ignore */ } setLabel(enc.label || ''); @@ -106,7 +130,9 @@ export default function VisitNote() { setPatientAge(''); setPatientGender(''); setVisitAge(''); setVitals(''); setMeasurements(''); setParentConcerns(''); setShadess(''); setMilestones(''); setByvisit(''); - setScreenings(''); setVaccines(''); setNoteStyle('full'); + setScreenings(''); setVaccines(''); + setRosData({}); setPeData({}); setDiagnoses([]); setDxFreetext(''); + setNoteStyle('full'); }} /> @@ -201,6 +227,55 @@ export default function VisitNote() { +
+
+ Review of Systems +
+ + +
+
+ +
+ +
+
+ Physical Examination +
+ + +
+
+ +
+ +
+ Diagnoses (ICD-10) + + +
+