Two things in one commit because they're coupled by a gitignore fix:
(1) PE Guide minimum-viable port, (2) a pre-existing bug where
client/src/data/faq.ts was silently gitignored and never committed.
.gitignore — narrow scope
Changed `data/` to `/data/`. The old rule matched every nested
`data/` directory in the tree, including client/src/data/, which
meant faq.ts from the FAQ port (commit c0038da) never landed in
git — the Faq page built locally only because the file existed
on the dev machine. A fresh clone or CI build would fail the
Vite build at '@/data/faq'. The narrowed rule still ignores the
runtime DB directory at repo root while allowing project data
modules to be tracked. This commit also commits the missing
faq.ts so the FAQ page is actually buildable from git again.
client/src/data/pe-guide.ts
Verbatim port of lines 23-311 of public/js/peGuide.js — the stable
reference content:
• SCALES (12 scales: MRC, DTR, plantar, Beighton, ATR, RR, SpO2,
Silverman, Westley, Levine murmur, pulse amp, cap refill)
• SYSTEM_SCALES (per-body-system scale mapping)
• APTM_LEGEND (5 cardiac auscultation points)
• INNOCENT_MURMURS (5 benign childhood murmurs)
• RESP_SOUNDS (7 entries with /audio/respiratory/*.ogg paths)
• CARDIAC_SOUNDS (6 entries with /audio/cardiac/* paths)
Counts preserved exactly. Audio files stay under public/audio/ and
are served unchanged.
What is NOT in this commit — on purpose
PE_DATA (the ~1000-line age-group × system × component × step
hierarchy) stays in the vanilla app. The migration checkpoint memory
explicitly warns about the class of bug where an LLM silently drops
entries from long clinical arrays. PE_DATA porting needs its own
session with per-entry counts + visual diff against the vanilla
source. An amber banner at the top of the React page links to
/#peGuide (the legacy checklist viewer) so users still reach the
full exam-step checklist + Generate-Exam-Report flow.
Also skipped: the big inline APTM_SVG chest diagram. The letter
legend (A/P/E/T/M) carries the clinical content; the pictorial
SVG can land later without content risk.
client/src/pages/PeGuide.tsx — viewer
Grid-of-cards layout: one card per scale, per APTM point, per
innocent-murmur, and per sound entry. Sound cards use native
<audio controls> so the browser does the usual play/pause/seek —
no custom player. data-testid hooks throughout for the spec.
e2e/tests/peguide-react.spec.js — four smoke tests:
all 12 scales render (this is the count that would fail loudly if
someone trimmed SCALES later), APTM has all 5 letters, sound
libraries have the exact respiratory + cardiac keys, and the
legacy-viewer link is present.
Client tsc -b + vite build clean. Bundle 452.91 kB / 129.35 kB gz.
173 lines
7.6 KiB
TypeScript
173 lines
7.6 KiB
TypeScript
// ============================================================
|
||
// PHYSICAL EXAM GUIDE — minimum-viable React port.
|
||
//
|
||
// What lands in this commit:
|
||
// • Scales reference — MRC, DTR, Plantar, Beighton, ATR, RR by age,
|
||
// SpO2, Silverman, Westley, murmur Levine grade, pulse amp, cap refill
|
||
// • APTM auscultation-points legend (5 classic cardiac points)
|
||
// • Innocent murmurs of childhood reference
|
||
// • Respiratory sounds library with audio playback
|
||
// • Cardiac sounds library with audio playback
|
||
//
|
||
// What is intentionally NOT in this commit:
|
||
// • PE_DATA — the ~1000-line age-group × system × component × step
|
||
// hierarchy. The migration checkpoint memory flags this as the
|
||
// class of data where an LLM silently drops entries; it belongs in
|
||
// its own session with per-entry counts verified against the
|
||
// vanilla source. The exam-step checklist + Generate-Exam-Report
|
||
// flow (POST /api/generate-pe-narrative) continues to live in the
|
||
// vanilla app at / until that session lands.
|
||
// • APTM SVG diagram — big inline SVG; the letter legend is preserved,
|
||
// the pictorial chest outline can follow without clinical risk.
|
||
// ============================================================
|
||
|
||
import { useRef } from 'react';
|
||
import {
|
||
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 btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
|
||
|
||
function ScaleCard({ id, scale }: { id: string; scale: ScaleDef }) {
|
||
return (
|
||
<section className={card} data-testid={'scale-' + id}>
|
||
<h3 className="text-base font-semibold">{scale.title}</h3>
|
||
<table className="w-full text-sm">
|
||
<tbody>
|
||
{scale.rows.map(([label, desc], i) => (
|
||
<tr key={i} className="border-b border-border last:border-0">
|
||
<td className="py-1.5 pr-3 font-mono font-semibold whitespace-nowrap">{label}</td>
|
||
<td className="py-1.5 text-muted-foreground">{desc}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function SoundCard({ entry }: { entry: SoundEntry }) {
|
||
const ref = useRef<HTMLAudioElement>(null);
|
||
return (
|
||
<section className={card} data-testid={'sound-' + entry.key}>
|
||
<h4 className="text-sm font-semibold">{entry.title}</h4>
|
||
<audio ref={ref} controls preload="none" className="w-full">
|
||
<source src={entry.src} />
|
||
Your browser does not support HTML5 audio.
|
||
</audio>
|
||
<div className="text-xs space-y-1">
|
||
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Where:</span> {entry.where}</div>
|
||
{entry.rate && (
|
||
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Rate:</span> {entry.rate}</div>
|
||
)}
|
||
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Features:</span> {entry.features}</div>
|
||
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Clinical:</span> {entry.clinical}</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export default function PeGuide() {
|
||
return (
|
||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||
<header>
|
||
<h1 className="text-2xl font-semibold">Physical Exam Guide</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
Reference scales, the five cardiac auscultation points, innocent childhood murmurs, and sound libraries.
|
||
</p>
|
||
</header>
|
||
|
||
<section className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm space-y-2">
|
||
<div className="font-semibold text-amber-900 dark:text-amber-100">Exam-step checklist lives in the legacy viewer</div>
|
||
<p className="text-amber-900 dark:text-amber-100">
|
||
The full age-group × system checklist and the Generate Exam Report flow still run in the legacy app. They port in a
|
||
dedicated session so the clinical content can be verified entry-for-entry against the vanilla source.
|
||
</p>
|
||
<a
|
||
href="/#peGuide"
|
||
className={btnPrimary + ' inline-block'}
|
||
>
|
||
Open checklist in legacy viewer
|
||
</a>
|
||
</section>
|
||
|
||
{/* Scales */}
|
||
<section className="space-y-3">
|
||
<h2 className="text-lg font-semibold">Grading scales & reference ranges</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{Object.entries(SCALES).map(([id, scale]) => (
|
||
<ScaleCard key={id} id={id} scale={scale} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{/* APTM legend */}
|
||
<section className="space-y-3">
|
||
<h2 className="text-lg font-semibold">Cardiac auscultation — APTM (the five points)</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{APTM_LEGEND.map((a) => (
|
||
<div key={a.letter} className={card} data-testid={'aptm-' + a.letter.toLowerCase()}>
|
||
<div className="flex items-center gap-3">
|
||
<div
|
||
className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white"
|
||
style={{ background: a.color }}
|
||
>
|
||
{a.letter}
|
||
</div>
|
||
<h3 className="text-base font-semibold">{a.title}</h3>
|
||
</div>
|
||
<div className="text-sm text-muted-foreground"><strong>Location:</strong> {a.location}</div>
|
||
<div className="text-sm text-muted-foreground"><strong>Listen for:</strong> {a.listen}</div>
|
||
{a.innocent && (
|
||
<div className="text-xs text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/30 rounded px-2 py-1">
|
||
<strong>Innocent:</strong> {a.innocent}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Innocent murmurs */}
|
||
<section className="space-y-3">
|
||
<h2 className="text-lg font-semibold">Innocent murmurs of childhood</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{INNOCENT_MURMURS.map((m) => (
|
||
<div key={m.name} className={card}>
|
||
<h3 className="text-base font-semibold">{m.name}</h3>
|
||
<div className="text-sm space-y-1">
|
||
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Age:</span> {m.age}</div>
|
||
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Location:</span> {m.location}</div>
|
||
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Character:</span> {m.character}</div>
|
||
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Confirm benign:</span> {m.confirm}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Respiratory sounds */}
|
||
<section className="space-y-3">
|
||
<h2 className="text-lg font-semibold">Respiratory sounds library</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{RESP_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Cardiac sounds */}
|
||
<section className="space-y-3">
|
||
<h2 className="text-lg font-semibold">Cardiac sounds library</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{CARDIAC_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|