feat(notes): structured ROS + PE + ICD-10 diagnosis pickers (WellVisit, SickVisit)
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.
This commit is contained in:
parent
61749a7881
commit
10dbabf83b
6 changed files with 606 additions and 4 deletions
143
client/src/components/DxPicker.tsx
Normal file
143
client/src/components/DxPicker.tsx
Normal file
|
|
@ -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<DxEntry[]> {
|
||||
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<DxEntry[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const timerRef = useRef<number | null>(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 (
|
||||
<div className="space-y-2" data-testid={testIdPrefix + '-picker'}>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder='Search ICD-10 (e.g. "otitis", "J06")…'
|
||||
autoComplete="off"
|
||||
value={query}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="absolute z-30 left-0 right-0 mt-1 max-h-60 overflow-auto rounded-md border border-border bg-card shadow-lg">
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.code}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
add(r);
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-muted border-b border-border last:border-0"
|
||||
data-testid={testIdPrefix + '-result-' + r.code}
|
||||
>
|
||||
<span className="inline-block w-20 text-xs font-mono text-muted-foreground">{r.code}</span>
|
||||
<span>{r.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5" data-testid={testIdPrefix + '-tags'}>
|
||||
{value.map((d, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 rounded-full bg-primary/10 border border-primary/30 text-primary px-2 py-0.5 text-xs">
|
||||
{d.code && <strong className="font-semibold">{d.code}</strong>}
|
||||
<span>{d.name}</span>
|
||||
<button type="button" onClick={() => remove(i)} className="text-xs text-muted-foreground hover:text-destructive" title="Remove">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground mb-1">Common pediatric diagnoses:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{COMMON_DX.map((dx) => (
|
||||
<button
|
||||
key={dx.code}
|
||||
type="button"
|
||||
onClick={() => add(dx)}
|
||||
className="text-[11px] rounded border border-border bg-background px-2 py-0.5 hover:bg-muted"
|
||||
title={dx.name}
|
||||
data-testid={testIdPrefix + '-chip-' + dx.code}
|
||||
>
|
||||
<span className="font-mono text-muted-foreground mr-1">{dx.code}</span>
|
||||
{dx.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
client/src/components/RosPeTable.tsx
Normal file
97
client/src/components/RosPeTable.tsx
Normal file
|
|
@ -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<SystemEntry>;
|
||||
data: RosData;
|
||||
onChange: (next: RosData) => void;
|
||||
btnLabels?: BtnLabels;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export function rosAllWnl(systems: ReadonlyArray<SystemEntry>, 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<SystemEntry>): 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 (
|
||||
<div className="divide-y divide-border" data-testid={testIdPrefix + '-table'}>
|
||||
{systems.map((sys) => {
|
||||
const cell = data[sys.key] || {};
|
||||
const active = cell.status || '';
|
||||
return (
|
||||
<div key={sys.key} className="flex flex-wrap items-center gap-2 px-2 py-1.5" data-testid={testIdPrefix + '-row-' + sys.key}>
|
||||
<div className="flex-1 min-w-[180px]" title={sys.detail}>
|
||||
<span className="text-sm font-medium">{sys.label}</span>
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">({sys.detail})</span>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{(['wnl', 'abnormal', 'notrev'] as const).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => setStatus(sys.key, kind)}
|
||||
className={'text-[10px] uppercase tracking-wider px-2 py-1 rounded border ' + statusClass(active === kind, kind)}
|
||||
data-testid={testIdPrefix + '-btn-' + sys.key + '-' + kind}
|
||||
>
|
||||
{labels[kind]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{active === 'abnormal' && (
|
||||
<input
|
||||
type="text"
|
||||
value={cell.note || ''}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<RosData>({});
|
||||
const [peData, setPeData] = useState<RosData>({});
|
||||
const [diagnoses, setDiagnoses] = useState<DxEntry[]>([]);
|
||||
const [dxFreetext, setDxFreetext] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [recError, setRecError] = useState<string | null>(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() {
|
|||
/>
|
||||
</label>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">Review of Systems</span>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={() => setRosData(rosAllWnl(ROS_SYSTEMS, rosData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">✓ All WNL</button>
|
||||
<button type="button" onClick={() => setRosData(rosClear(rosData, ROS_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<RosPeTable
|
||||
systems={ROS_SYSTEMS}
|
||||
data={rosData}
|
||||
onChange={setRosData}
|
||||
btnLabels={{ wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' }}
|
||||
testIdPrefix="sv-ros"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">Physical Examination</span>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={() => setPeData(rosAllWnl(PE_SYSTEMS, peData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">✓ All Normal</button>
|
||||
<button type="button" onClick={() => setPeData(rosClear(peData, PE_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<RosPeTable
|
||||
systems={PE_SYSTEMS}
|
||||
data={peData}
|
||||
onChange={setPeData}
|
||||
btnLabels={{ wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' }}
|
||||
testIdPrefix="sv-pe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">Diagnoses (ICD-10)</span>
|
||||
<DxPicker value={diagnoses} onChange={setDiagnoses} testIdPrefix="sv-dx" />
|
||||
<label className="block">
|
||||
<span className="text-[11px] text-muted-foreground">Additional free-text diagnosis / note (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={dxFreetext}
|
||||
onChange={(e) => setDxFreetext(e.target.value)}
|
||||
className={input + ' text-sm'}
|
||||
placeholder="e.g. Follow up in 48 hours if not improving"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
|
||||
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<RosData>({});
|
||||
const [peData, setPeData] = useState<RosData>({});
|
||||
const [diagnoses, setDiagnoses] = useState<DxEntry[]>([]);
|
||||
const [dxFreetext, setDxFreetext] = useState('');
|
||||
const [noteStyle, setNoteStyle] = useState<'full' | 'short'>('full');
|
||||
const [result, setResult] = useState<string | null>(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() {
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">Review of Systems</span>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={() => setRosData(rosAllWnl(ROS_SYSTEMS, rosData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">✓ All WNL</button>
|
||||
<button type="button" onClick={() => setRosData(rosClear(rosData, ROS_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<RosPeTable
|
||||
systems={ROS_SYSTEMS}
|
||||
data={rosData}
|
||||
onChange={setRosData}
|
||||
btnLabels={{ wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' }}
|
||||
testIdPrefix="wv-ros"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">Physical Examination</span>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={() => setPeData(rosAllWnl(PE_SYSTEMS, peData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">✓ All Normal</button>
|
||||
<button type="button" onClick={() => setPeData(rosClear(peData, PE_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<RosPeTable
|
||||
systems={PE_SYSTEMS}
|
||||
data={peData}
|
||||
onChange={setPeData}
|
||||
btnLabels={{ wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' }}
|
||||
testIdPrefix="wv-pe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">Diagnoses (ICD-10)</span>
|
||||
<DxPicker value={diagnoses} onChange={setDiagnoses} testIdPrefix="wv-dx" />
|
||||
<label className="block">
|
||||
<span className="text-[11px] text-muted-foreground">Additional free-text diagnosis / note (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={dxFreetext}
|
||||
onChange={(e) => setDxFreetext(e.target.value)}
|
||||
className={input + ' text-sm'}
|
||||
placeholder="e.g. Rule out iron-deficiency anaemia pending labs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Note style</span>
|
||||
<select className={input} value={noteStyle} onChange={(e) => setNoteStyle(e.target.value as 'full' | 'short')}>
|
||||
|
|
|
|||
80
shared/clinical/ros-pe-dx.test.ts
Normal file
80
shared/clinical/ros-pe-dx.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Tests for shared/clinical/ros-pe-dx.ts. The clinical tables here
|
||||
// are load-bearing — a silent reorder or deletion changes AI output.
|
||||
// These tests catch that class of regression.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ROS_SYSTEMS,
|
||||
PE_SYSTEMS,
|
||||
COMMON_DX,
|
||||
formatRosForAI,
|
||||
formatDxForAI,
|
||||
} from './ros-pe-dx';
|
||||
|
||||
describe('reference tables', () => {
|
||||
it('ROS has the 15 systems vanilla shipped, in order', () => {
|
||||
expect(ROS_SYSTEMS.length).toBe(15);
|
||||
expect(ROS_SYSTEMS[0].key).toBe('constitutional');
|
||||
expect(ROS_SYSTEMS[ROS_SYSTEMS.length - 1].key).toBe('developmental');
|
||||
});
|
||||
it('PE has the 17 systems vanilla shipped, in order', () => {
|
||||
expect(PE_SYSTEMS.length).toBe(17);
|
||||
expect(PE_SYSTEMS[0].key).toBe('general');
|
||||
expect(PE_SYSTEMS[PE_SYSTEMS.length - 1].key).toBe('pePsych');
|
||||
});
|
||||
it('COMMON_DX has the 28 pediatric diagnoses vanilla shipped', () => {
|
||||
expect(COMMON_DX.length).toBe(28);
|
||||
// Z00.129 is the default pediatric well-visit code — must be first.
|
||||
expect(COMMON_DX[0]).toEqual({ code: 'Z00.129', name: 'Routine child health exam, no abnormal findings' });
|
||||
});
|
||||
it('every ROS/PE entry has a non-empty label + detail string', () => {
|
||||
for (const s of [...ROS_SYSTEMS, ...PE_SYSTEMS]) {
|
||||
expect(s.key).toBeTruthy();
|
||||
expect(s.label).toBeTruthy();
|
||||
expect(s.detail).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRosForAI', () => {
|
||||
const TWO = [ROS_SYSTEMS[0], ROS_SYSTEMS[1]];
|
||||
it('returns empty string when nothing is reviewed', () => {
|
||||
expect(formatRosForAI(TWO, {}, 'ROS')).toBe('');
|
||||
});
|
||||
it('emits NORMAL with the domain hint for WNL systems', () => {
|
||||
const out = formatRosForAI(TWO, { constitutional: { status: 'wnl' } }, 'ROS');
|
||||
expect(out).toContain('Constitutional: NORMAL [domain: fever, fatigue, weight loss/gain, appetite]');
|
||||
});
|
||||
it('emits ABNORMAL with the user note inline', () => {
|
||||
const out = formatRosForAI(TWO, { eyes: { status: 'abnormal', note: 'conjunctival injection' } }, 'ROS');
|
||||
expect(out).toContain('Eyes: ABNORMAL — conjunctival injection');
|
||||
});
|
||||
it('calls out systems marked Not Reviewed explicitly', () => {
|
||||
const out = formatRosForAI(TWO, { eyes: { status: 'notrev' } }, 'ROS');
|
||||
expect(out).toContain('Eyes: Not reviewed');
|
||||
});
|
||||
it('omits systems with no status set', () => {
|
||||
const out = formatRosForAI(TWO, { constitutional: { status: 'wnl' } }, 'ROS');
|
||||
expect(out).not.toContain('Eyes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDxForAI', () => {
|
||||
it('returns empty when no dx AND no free text', () => {
|
||||
expect(formatDxForAI([], '')).toBe('');
|
||||
});
|
||||
it('prefixes codes when present', () => {
|
||||
const out = formatDxForAI([{ code: 'J06.9', name: 'URI' }], '');
|
||||
expect(out).toContain('- J06.9 URI');
|
||||
});
|
||||
it('skips code prefix for free-text-only diagnoses', () => {
|
||||
const out = formatDxForAI([{ code: '', name: 'Parental anxiety' }], '');
|
||||
expect(out).toContain('- Parental anxiety');
|
||||
expect(out).not.toContain(' Parental');
|
||||
});
|
||||
it('appends trimmed free-text as its own bullet', () => {
|
||||
const out = formatDxForAI([{ code: 'R50.9', name: 'Fever' }], ' Rule out UTI ');
|
||||
expect(out).toMatch(/Fever[\s\S]*Rule out UTI/);
|
||||
expect(out).not.toContain(' Rule out UTI');
|
||||
});
|
||||
});
|
||||
130
shared/clinical/ros-pe-dx.ts
Normal file
130
shared/clinical/ros-pe-dx.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// ============================================================
|
||||
// ROS / PE systems + common ICD-10 diagnoses + AI formatters.
|
||||
// Ported verbatim from public/js/shadess.js (@be14578):
|
||||
// ROS_SYSTEMS — 15 systems
|
||||
// PE_SYSTEMS — 17 systems
|
||||
// COMMON_DX — 28 pediatric diagnoses quick-pick
|
||||
// formatRosForAI / formatDxForAI — string format the AI receives.
|
||||
// These tables are clinical reference data — do NOT re-order or
|
||||
// silently "simplify" them during maintenance. Copy from the vanilla
|
||||
// source when it changes.
|
||||
// ============================================================
|
||||
|
||||
export interface SystemEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const ROS_SYSTEMS: ReadonlyArray<SystemEntry> = [
|
||||
{ key: 'constitutional', label: 'Constitutional', detail: 'fever, fatigue, weight loss/gain, appetite' },
|
||||
{ key: 'eyes', label: 'Eyes', detail: 'vision changes, discharge, redness' },
|
||||
{ key: 'ent', label: 'ENT', detail: 'ear pain, congestion, sore throat, mouth sores' },
|
||||
{ key: 'cardiovascular', label: 'Cardiovascular', detail: 'chest pain, palpitations, murmur' },
|
||||
{ key: 'respiratory', label: 'Respiratory', detail: 'cough, wheeze, dyspnea' },
|
||||
{ key: 'gastrointestinal', label: 'Gastrointestinal', detail: 'nausea, vomiting, diarrhea, constipation, abdominal pain' },
|
||||
{ key: 'genitourinary', label: 'Genitourinary', detail: 'dysuria, frequency, discharge, menstrual' },
|
||||
{ key: 'musculoskeletal', label: 'Musculoskeletal', detail: 'joint pain, swelling, gait issues, back pain' },
|
||||
{ key: 'skin', label: 'Skin', detail: 'rash, lesions, itch, hair/nail changes' },
|
||||
{ key: 'neurological', label: 'Neurological', detail: 'headache, dizziness, seizures, numbness' },
|
||||
{ key: 'psychiatric', label: 'Psychiatric', detail: 'mood, anxiety, sleep, behavior changes' },
|
||||
{ key: 'endocrine', label: 'Endocrine', detail: 'polyuria/polydipsia, heat/cold intolerance, growth concerns' },
|
||||
{ key: 'hematologic', label: 'Hematologic/Lymphatic', detail: 'bruising, bleeding, lymph nodes' },
|
||||
{ key: 'allergic', label: 'Allergic/Immunologic', detail: 'allergic reactions, frequent infections' },
|
||||
{ key: 'developmental', label: 'Developmental/Behavioral', detail: 'milestones, school, social' },
|
||||
];
|
||||
|
||||
export const PE_SYSTEMS: ReadonlyArray<SystemEntry> = [
|
||||
{ key: 'general', label: 'General Appearance', detail: 'WDWN, alert, NAD, etc.' },
|
||||
{ key: 'peEyes', label: 'Eyes', detail: 'PERRL, conjunctiva, EOM' },
|
||||
{ key: 'ears', label: 'Ears', detail: 'TMs, canals, hearing' },
|
||||
{ key: 'nose', label: 'Nose', detail: 'mucosa, septum, turbinates' },
|
||||
{ key: 'mouthThroat', label: 'Mouth/Throat', detail: 'lips, gums, teeth, tonsils, pharynx' },
|
||||
{ key: 'neck', label: 'Neck', detail: 'lymph nodes, thyroid, masses' },
|
||||
{ key: 'chestLungs', label: 'Chest/Lungs', detail: 'air entry, wheezing, retractions' },
|
||||
{ key: 'peCv', label: 'Cardiovascular', detail: 'S1/S2, murmur, pulses, cap refill' },
|
||||
{ key: 'abdomen', label: 'Abdomen', detail: 'soft, tenderness, organomegaly, BS' },
|
||||
{ key: 'guTanner', label: 'Genitourinary/Tanner', detail: 'Tanner stage, genitalia, hernias' },
|
||||
{ key: 'msk', label: 'Musculoskeletal', detail: 'strength, ROM, gait, spine' },
|
||||
{ key: 'extremities', label: 'Extremities', detail: 'tone, reflexes, clubbing, edema' },
|
||||
{ key: 'peSkin', label: 'Skin', detail: 'rashes, lesions, cafe au lait, bruises' },
|
||||
{ key: 'peNeuro', label: 'Neurological', detail: 'CN intact, DTRs, coordination' },
|
||||
{ key: 'lymphatic', label: 'Lymphatic', detail: 'cervical, axillary, inguinal nodes' },
|
||||
{ key: 'breast', label: 'Breast', detail: 'if indicated by age/Tanner' },
|
||||
{ key: 'pePsych', label: 'Psychiatric', detail: 'affect, mood, behavior during exam' },
|
||||
];
|
||||
|
||||
export interface DxEntry { code: string; name: string }
|
||||
|
||||
export const COMMON_DX: ReadonlyArray<DxEntry> = [
|
||||
{ code: 'Z00.129', name: 'Routine child health exam, no abnormal findings' },
|
||||
{ code: 'Z00.121', name: 'Routine child health exam, with abnormal findings' },
|
||||
{ code: 'J06.9', name: 'Upper Respiratory Infection, NOS' },
|
||||
{ code: 'J02.9', name: 'Acute pharyngitis, NOS' },
|
||||
{ code: 'J03.90', name: 'Acute tonsillitis, NOS' },
|
||||
{ code: 'H66.90', name: 'Otitis media, NOS' },
|
||||
{ code: 'J20.9', name: 'Acute bronchitis' },
|
||||
{ code: 'J45.20', name: 'Mild intermittent asthma, uncomplicated' },
|
||||
{ code: 'J45.30', name: 'Mild persistent asthma' },
|
||||
{ code: 'A09', name: 'Gastroenteritis/colitis' },
|
||||
{ code: 'L20.9', name: 'Atopic dermatitis, NOS' },
|
||||
{ code: 'L30.9', name: 'Dermatitis NOS' },
|
||||
{ code: 'R50.9', name: 'Fever NOS' },
|
||||
{ code: 'R10.9', name: 'Abdominal pain NOS' },
|
||||
{ code: 'R05.9', name: 'Cough' },
|
||||
{ code: 'B34.9', name: 'Viral infection NOS' },
|
||||
{ code: 'Z23', name: 'Immunization encounter' },
|
||||
{ code: 'Z41.9', name: 'Procedure for non-remedial reason' },
|
||||
{ code: 'K21.0', name: 'GERD with esophagitis' },
|
||||
{ code: 'K21.9', name: 'GERD without esophagitis' },
|
||||
{ code: 'F90.0', name: 'ADHD, predominantly inattentive' },
|
||||
{ code: 'F90.2', name: 'ADHD, combined type' },
|
||||
{ code: 'F41.1', name: 'Generalized anxiety disorder' },
|
||||
{ code: 'F32.9', name: 'Major depressive episode NOS' },
|
||||
{ code: 'E11.9', name: 'Type 2 diabetes' },
|
||||
{ code: 'E03.9', name: 'Hypothyroidism NOS' },
|
||||
{ code: 'M54.5', name: 'Low back pain' },
|
||||
{ code: 'G43.909', name: 'Migraine NOS' },
|
||||
];
|
||||
|
||||
// Per-system status — undefined/'' = Not Reviewed → omit when formatted.
|
||||
export type RosStatus = '' | 'wnl' | 'abnormal' | 'notrev';
|
||||
|
||||
export interface RosCell { status?: RosStatus; note?: string }
|
||||
export type RosData = Record<string, RosCell>;
|
||||
|
||||
// Format ROS/PE for the AI. Mirrors window.formatRosForAI exactly —
|
||||
// tells the AI which systems to expand with pertinent negatives vs
|
||||
// which to treat as abnormal findings to describe.
|
||||
export function formatRosForAI(
|
||||
systems: ReadonlyArray<SystemEntry>,
|
||||
data: RosData,
|
||||
heading: string,
|
||||
): string {
|
||||
const lines: string[] = [heading + ':'];
|
||||
let hasAny = false;
|
||||
for (const sys of systems) {
|
||||
const cell = data[sys.key] || {};
|
||||
if (!cell.status) continue;
|
||||
hasAny = true;
|
||||
if (cell.status === 'wnl') {
|
||||
lines.push(' ' + sys.label + ': NORMAL [domain: ' + sys.detail + ']');
|
||||
} else if (cell.status === 'abnormal') {
|
||||
lines.push(' ' + sys.label + ': ABNORMAL' + (cell.note ? ' — ' + cell.note : '') + ' [domain: ' + sys.detail + ']');
|
||||
} else {
|
||||
lines.push(' ' + sys.label + ': Not reviewed');
|
||||
}
|
||||
}
|
||||
return hasAny ? lines.join('\n') : '';
|
||||
}
|
||||
|
||||
// Format diagnoses list for the AI. Mirrors window.formatDxForAI.
|
||||
export function formatDxForAI(dx: ReadonlyArray<DxEntry>, freetext?: string): string {
|
||||
const lines: string[] = [];
|
||||
for (const d of dx) {
|
||||
lines.push((d.code ? d.code + ' ' : '') + d.name);
|
||||
}
|
||||
const t = (freetext || '').trim();
|
||||
if (t) lines.push(t);
|
||||
return lines.length ? 'Diagnoses:\n' + lines.map((l) => ' - ' + l).join('\n') : '';
|
||||
}
|
||||
Loading…
Reference in a new issue