pediatric-ai-scribe-v3/client/src/pages/PeGuide.tsx
Daniel aafe7981dc feat(client): port full PE_DATA checklist + Generate Exam Report flow
Biggest data port of the migration so far. PE_DATA is the 1000+ line
age-group × system × component × step hierarchy driving the pediatric
physical-exam checklist; every entry, pearl, significance note, and
abnormal-hint array is now available in the React tree.

client/src/data/pe-data.ts — verbatim port
  Extracted lines 316-1334 of public/js/peGuide.js with awk/sed, then
  wrapped in TS types. Every byte of the data body is byte-identical
  to the vanilla source. Added interfaces:
    PeStep { label, method, normal }
    PeComponent { name, steps[], abnormalHints[], pearl?, significance? }
    PeSystem { overview, components[] }
    PeAgeGroup { label, msk, neuro, resp, cv }
  …plus AGE_GROUP_ORDER / SYSTEM_ORDER / SYSTEM_LABELS canonical
  orderings for the UI.

client/src/data/pe-data.test.ts — parity lock
  Vitest suite that asserts every count captured from the vanilla
  source so any accidental drop surfaces as a red test:
    • 6 age groups × 4 systems
    • 103 components total
    • 27 pearl entries
    • 23 significance entries
    • per-cell component counts (e.g. toddler.neuro = 7, adolescent.cv = 5)
  Counts captured 2026-04-24 against peGuide.js commit 313ba7f.

client/src/pages/PeGuide.tsx — full viewer (replaces legacy-link stub)
  • Age-group pills (6) + system pills (4) drive the visible section
  • Overview banner per combination
  • CV system shows APTM legend + cardiac sounds library + innocent
    murmurs reference (unchanged clinical content from the earlier
    commit that added the scales/sounds file)
  • Resp system shows the respiratory sounds library
  • Collapsible grading-scales reference pulls from SYSTEM_SCALES
  • Component checklist: per-step Normal / Abnormal toggle, abnormal-
    hint list, pearl + significance callouts
  • Mark-all-normal + Reset shortcuts
  • Generate Exam Report posts the full step payload to
    /api/generate-pe-narrative, renders the returned narrative inline
  • No more "Open checklist in legacy viewer" amber banner — the
    React port now does the whole thing

e2e/tests/peguide-react.spec.js
  Age-group pills, system pills, overview rewrite on age change,
  CV/resp system-specific reference panels, mark-all-normal + summary,
  and a mocked /api/generate-pe-narrative round-trip.

Client tsc -b + vite build clean. Bundle 580.30 kB / 166.08 kB gz
(up ~100 kB from the shell-only port — the 1000-line PE_DATA is the
bulk; acceptable for the clinical reference data it surfaces).
2026-04-24 01:21:44 +02:00

471 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// PHYSICAL EXAM GUIDE — full React port.
//
// Renders:
// • Age-group + system pills (6 × 4 = 24 combinations)
// • System overview banner
// • CV system extras: APTM legend, cardiac sounds, innocent murmurs
// • Resp system extras: respiratory sounds library
// • Collapsible grading-scales reference (system-scoped)
// • Component checklist with per-step normal / abnormal / (unset)
// toggle, abnormal-hints hint list, pearl + significance callouts
// • Patient age / gender + model inputs
// • Generate Exam Report → POST /api/generate-pe-narrative
//
// PE_DATA is the full hierarchy ported verbatim from vanilla
// peGuide.js (see client/src/data/pe-data.ts). Clinical reference
// libraries (scales, APTM, sound files) live in pe-guide.ts.
// ============================================================
import { useMemo, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { PeNarrativeOk } from '@/shared/types';
import {
PE_DATA,
AGE_GROUP_ORDER,
SYSTEM_ORDER,
SYSTEM_LABELS,
type PeComponent,
type PeStep,
} from '@/data/pe-data';
import {
SCALES,
SYSTEM_SCALES,
APTM_LEGEND,
INNOCENT_MURMURS,
RESP_SOUNDS,
CARDIAC_SOUNDS,
type ScaleDef,
type SoundEntry,
} from '@/data/pe-guide';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const pill = 'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors cursor-pointer';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50';
const btnGhost = 'rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
type StepStatus = 'normal' | 'abnormal' | null;
// Key used to identify a step in the status map across age-group / system.
function stepKey(age: string, sys: string, componentIdx: number, stepIdx: number) {
return `${age}/${sys}/${componentIdx}/${stepIdx}`;
}
function ScaleCard({ id, scale }: { id: string; scale: ScaleDef }) {
return (
<section className="rounded-md border border-border bg-background p-3" data-testid={'scale-' + id}>
<h4 className="text-sm font-semibold mb-2">{scale.title}</h4>
<table className="w-full text-xs">
<tbody>
{scale.rows.map(([labelText, desc], i) => (
<tr key={i} className="border-b border-border last:border-0">
<td className="py-1 pr-3 font-mono font-semibold whitespace-nowrap">{labelText}</td>
<td className="py-1 text-muted-foreground">{desc}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function SoundCard({ entry }: { entry: SoundEntry }) {
return (
<div className="rounded-md border border-border bg-background p-3 space-y-2" data-testid={'sound-' + entry.key}>
<div className="text-sm font-semibold">{entry.title}</div>
<audio controls preload="none" className="w-full">
<source src={entry.src} />
</audio>
<div className="text-xs space-y-0.5 text-muted-foreground">
<div><span className="font-semibold">Where:</span> {entry.where}</div>
{entry.rate && <div><span className="font-semibold">Rate:</span> {entry.rate}</div>}
<div><span className="font-semibold">Features:</span> {entry.features}</div>
<div><span className="font-semibold">Clinical:</span> {entry.clinical}</div>
</div>
</div>
);
}
function StepRow({
step,
status,
onStatus,
}: {
step: PeStep;
status: StepStatus;
onStatus: (next: StepStatus) => void;
}) {
const base = 'text-xs font-medium px-2 py-1 rounded border';
return (
<div className="flex items-start gap-2 py-2 border-b border-border last:border-0">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{step.label}</div>
<div className="text-xs text-muted-foreground mt-0.5">
<span className="font-semibold uppercase tracking-wide">Method:</span> {step.method}
</div>
<div className="text-xs text-muted-foreground">
<span className="font-semibold uppercase tracking-wide">Normal:</span> {step.normal}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-1 flex-shrink-0">
<button
type="button"
onClick={() => onStatus(status === 'normal' ? null : 'normal')}
className={
base + ' ' +
(status === 'normal'
? 'bg-green-600 text-white border-green-600'
: 'border-green-600 text-green-700 hover:bg-green-50 dark:hover:bg-green-950/30')
}
>
Normal
</button>
<button
type="button"
onClick={() => onStatus(status === 'abnormal' ? null : 'abnormal')}
className={
base + ' ' +
(status === 'abnormal'
? 'bg-destructive text-white border-destructive'
: 'border-destructive text-destructive hover:bg-red-50 dark:hover:bg-red-950/30')
}
>
Abnormal
</button>
</div>
</div>
);
}
function ComponentCard({
age,
sys,
idx,
comp,
getStatus,
setStatus,
}: {
age: string;
sys: string;
idx: number;
comp: PeComponent;
getStatus: (k: string) => StepStatus;
setStatus: (k: string, next: StepStatus) => void;
}) {
return (
<div className={card} data-testid={`pe-component-${age}-${sys}-${idx}`}>
<h3 className="text-base font-semibold">{comp.name}</h3>
<div>
{comp.steps.map((step, si) => {
const k = stepKey(age, sys, idx, si);
return (
<StepRow
key={si}
step={step}
status={getStatus(k)}
onStatus={(next) => setStatus(k, next)}
/>
);
})}
</div>
{comp.abnormalHints.length > 0 && (
<div className="rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-destructive mb-1">Watch for</div>
<ul className="list-disc pl-5 text-xs text-red-900 dark:text-red-200 space-y-0.5">
{comp.abnormalHints.map((h, hi) => <li key={hi}>{h}</li>)}
</ul>
</div>
)}
{comp.pearl && (
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-300 dark:border-amber-800 p-3 text-xs text-amber-900 dark:text-amber-100">
<span className="font-semibold uppercase tracking-wide">Pearl:</span> {comp.pearl}
</div>
)}
{comp.significance && (
<div className="rounded-md bg-sky-50 dark:bg-sky-950/30 border border-sky-200 dark:border-sky-900 p-3 text-xs text-sky-900 dark:text-sky-100">
<span className="font-semibold uppercase tracking-wide">Significance:</span> {comp.significance}
</div>
)}
</div>
);
}
export default function PeGuide() {
const [age, setAge] = useState<(typeof AGE_GROUP_ORDER)[number]>('toddler');
const [sys, setSys] = useState<(typeof SYSTEM_ORDER)[number]>('msk');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [format, setFormat] = useState<'narrative' | 'list'>('narrative');
const [statusMap, setStatusMap] = useState<Record<string, StepStatus>>({});
const [narrative, setNarrative] = useState<string | null>(null);
const group = PE_DATA[age];
const section = group[sys];
const generate = useMutation({
mutationFn: (body: unknown) => api.post<PeNarrativeOk>('/api/generate-pe-narrative', body),
onSuccess: (data) => setNarrative(data.narrative),
onError: (e: Error) => setNarrative('Generation failed: ' + e.message),
});
const summary = useMemo(() => {
let normal = 0, abnormal = 0, notAssessed = 0;
section.components.forEach((c, ci) =>
c.steps.forEach((_, si) => {
const k = stepKey(age, sys, ci, si);
const v = statusMap[k] ?? null;
if (v === 'normal') normal++;
else if (v === 'abnormal') abnormal++;
else notAssessed++;
}),
);
return { normal, abnormal, notAssessed };
}, [age, sys, section, statusMap]);
function reset() {
// Only clear the current system's entries, not all state.
setStatusMap((prev) => {
const next = { ...prev };
section.components.forEach((c, ci) =>
c.steps.forEach((_, si) => { delete next[stepKey(age, sys, ci, si)]; }),
);
return next;
});
setNarrative(null);
}
function setAllNormal() {
setStatusMap((prev) => {
const next = { ...prev };
section.components.forEach((c, ci) =>
c.steps.forEach((_, si) => { next[stepKey(age, sys, ci, si)] = 'normal'; }),
);
return next;
});
}
function onGenerate() {
setNarrative(null);
const steps: Array<{ component: string; label: string; method: string; normal: string; status: StepStatus; note?: string }> = [];
section.components.forEach((c, ci) =>
c.steps.forEach((st, si) => {
steps.push({
component: c.name,
label: st.label,
method: st.method,
normal: st.normal,
status: statusMap[stepKey(age, sys, ci, si)] ?? null,
});
}),
);
generate.mutate({
steps,
ageGroup: age,
system: sys,
patientAge: patientAge || undefined,
patientGender: patientGender || undefined,
format,
});
}
const totalAssessed = summary.normal + summary.abnormal;
return (
<div className="max-w-5xl mx-auto p-6 space-y-5">
<header>
<h1 className="text-2xl font-semibold">Physical Exam Guide</h1>
<p className="text-sm text-muted-foreground">
Age-group and system-specific exam checklist with abnormal-finding hints. Toggle normal / abnormal
on each step, then generate a narrative for your note.
</p>
</header>
{/* Age-group pills */}
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Age group</div>
<div className="flex flex-wrap gap-2" data-testid="pe-age-group-pills">
{AGE_GROUP_ORDER.map((g) => (
<button
key={g}
type="button"
onClick={() => setAge(g)}
className={pill + (age === g ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80 border-border')}
data-testid={'pe-age-' + g}
>
{PE_DATA[g].label}
</button>
))}
</div>
</div>
{/* System pills */}
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">System</div>
<div className="flex flex-wrap gap-2" data-testid="pe-system-pills">
{SYSTEM_ORDER.map((s) => (
<button
key={s}
type="button"
onClick={() => setSys(s)}
className={pill + (sys === s ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80 border-border')}
data-testid={'pe-system-' + s}
>
{SYSTEM_LABELS[s]}
</button>
))}
</div>
</div>
{/* Overview */}
<section className={card + ' border-l-4 border-l-primary'} data-testid="pe-overview">
<h2 className="text-lg font-semibold">{group.label} {SYSTEM_LABELS[sys]}</h2>
<p className="text-sm text-muted-foreground">{section.overview}</p>
</section>
{/* System-specific references */}
{sys === 'cv' && (
<section className={card} data-testid="pe-cv-aptm">
<h3 className="text-base font-semibold">Auscultation landmarks (APTM + Erb's)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{APTM_LEGEND.map((p) => (
<div key={p.letter} className="flex gap-3 items-start rounded-md border border-border p-3">
<div
className="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white flex-shrink-0"
style={{ background: p.color }}
>
{p.letter}
</div>
<div className="min-w-0 text-sm">
<div className="font-semibold">{p.title}</div>
<div className="text-xs text-muted-foreground">{p.location}</div>
<div className="text-xs mt-1"><strong>Listen for:</strong> {p.listen}</div>
{p.innocent && <div className="text-xs text-green-700 dark:text-green-300 mt-1"><em>Innocent:</em> {p.innocent}</div>}
</div>
</div>
))}
</div>
<h3 className="text-base font-semibold mt-3">Cardiac sounds library</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{CARDIAC_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
</div>
<h3 className="text-base font-semibold mt-3">Classic innocent murmurs</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{INNOCENT_MURMURS.map((m) => (
<div key={m.name} className="rounded-md border border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/30 p-3 text-sm space-y-1">
<div className="font-semibold">{m.name}</div>
<div className="text-xs text-muted-foreground">Age: {m.age} · Location: {m.location}</div>
<div className="text-xs"><strong>Sound:</strong> {m.character}</div>
<div className="text-xs"><strong>Confirm innocent:</strong> {m.confirm}</div>
</div>
))}
</div>
</section>
)}
{sys === 'resp' && (
<section className={card} data-testid="pe-resp-sounds">
<h3 className="text-base font-semibold">Respiratory sounds library</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{RESP_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
</div>
</section>
)}
{/* Grading scales (system-scoped, collapsible) */}
{SYSTEM_SCALES[sys] && SYSTEM_SCALES[sys].length > 0 && (
<details className={card} data-testid="pe-scales">
<summary className="cursor-pointer font-semibold text-sm">Grading scales &amp; reference</summary>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
{SYSTEM_SCALES[sys].map((sk: string) => {
const scale = SCALES[sk];
if (!scale) return null;
return <ScaleCard key={sk} id={sk} scale={scale} />;
})}
</div>
</details>
)}
{/* Checklist */}
<section className="space-y-3" data-testid="pe-checklist">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Exam checklist</h2>
<div className="text-xs text-muted-foreground flex items-center gap-3">
<span className="text-green-600">{summary.normal} normal</span>
<span className="text-destructive">{summary.abnormal} abnormal</span>
<span>{summary.notAssessed} not assessed</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={setAllNormal} className={btnGhost} data-testid="btn-pe-all-normal">
Mark all normal
</button>
<button type="button" onClick={reset} className={btnGhost} data-testid="btn-pe-reset">
Reset
</button>
</div>
<div className="grid grid-cols-1 gap-3">
{section.components.map((c, ci) => (
<ComponentCard
key={ci}
age={age}
sys={sys}
idx={ci}
comp={c}
getStatus={(k) => statusMap[k] ?? null}
setStatus={(k, next) => setStatusMap((prev) => ({ ...prev, [k]: next }))}
/>
))}
</div>
</section>
{/* Generate narrative */}
<section className={card} data-testid="pe-generate">
<h2 className="text-lg font-semibold">Generate Exam Report</h2>
<p className="text-sm text-muted-foreground">Uses the statuses above + optional patient context.</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<input
className={input}
placeholder="Patient age (e.g. 3y)"
value={patientAge}
onChange={(e) => setPatientAge(e.target.value)}
/>
<input
className={input}
placeholder="Patient gender (optional)"
value={patientGender}
onChange={(e) => setPatientGender(e.target.value)}
/>
<select
className={input}
value={format}
onChange={(e) => setFormat(e.target.value as 'narrative' | 'list')}
>
<option value="narrative">Narrative</option>
<option value="list">List</option>
</select>
</div>
<div className="flex items-center gap-3">
<button
type="button"
className={btnPrimary}
onClick={onGenerate}
disabled={generate.isPending || totalAssessed === 0}
data-testid="btn-pe-generate"
>
{generate.isPending ? 'Generating' : 'Generate Exam Report'}
</button>
{totalAssessed === 0 && (
<span className="text-xs text-muted-foreground">Mark at least one step before generating.</span>
)}
</div>
{narrative && (
<div className="rounded-md border border-border bg-muted/40 p-3 whitespace-pre-wrap text-sm" data-testid="pe-narrative">
{narrative}
</div>
)}
</section>
</div>
);
}