pediatric-ai-scribe-v3/client/src/pages/wellvisit/Shadess.tsx
Daniel 8253b34dc5 feat(wellvisit): port the 4 sub-tabs vanilla had — By Visit / Milestones / SSHADESS / Note
Earlier WellVisit was a single-pane skeleton — the comment in that
file even admitted "Milestones and SSHADESS sub-tabs land in a
follow-up". This commit lands them, faithful to public/components/
wellvisit.html and the three modules behind it (wellVisit.js,
milestones.js, shadess.js, all @be14578).

WellVisit.tsx is now an 83-line shell that lazy-loads four panels:

  client/src/pages/wellvisit/
    ByVisitAge.tsx  — per-visit AAP Bright Futures recommendations
                       (billing codes, measurements, vaccines,
                       sensory/dev/proc/oral screens, growth +
                       feeding, expected reflexes, BMI table,
                       notes). Status buttons mirror vanilla
                       (Given/Refused/Deferred/Already-Done for
                       vaccines; Done/Refused/N-A for screens).
                       Statuses persist to localStorage under
                       ped_visit_statuses (same key as vanilla).
    Milestones.tsx  — checklist per age group, three-state toggle
                       (✓/✗/blank=not assessed), All-Yes/Clear,
                       Generate → /api/generate-milestone-narrative,
                       3-sentence summary → /api/generate-milestone-
                       summary, Copy-to-Note bridge.
    Shadess.tsx     — 8 SSHADESS domains verbatim from shadess.js
                       (Strengths, School, Home, Activities, Drugs,
                       Emotions/Eating, Sexuality, Safety) with
                       concern_if auto-flag, skip toggle, manual
                       concern toggle, optional Listen-In recorder,
                       Generate → /api/well-visit/shadess.
    VisitNote.tsx   — pre-existing note generator + carry-over
                       pickup from sessionStorage so the user can
                       flow Milestones → SSHADESS → Note without
                       retyping. Now also includes the recorder.

Server: GET /api/schedule-data now also returns wellVisitCodes,
growthReference, reflexesReference, and bmiClassification so the
React side has everything it needs without a second round trip.

Tests:
  shared/clinical/visit-status.ts (+ .test.ts) — pure helpers
  for the visitId → growth/reflex key mapping (the vanilla
  tables collapse 6y/7y/8y/9y/10y onto one window, etc.) and
  the reflex-status color rules. Lives in shared/ so the root
  vitest config picks it up; ByVisitAge.tsx imports from there.
2026-04-24 05:24:09 +02:00

363 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

// ============================================================
// SSHADESS — psychosocial screening (age 12+).
// Faithful port of public/js/shadess.js (@be14578) — same 8 domains,
// same questions, same concern_if flags, same skip toggle.
//
// Flow:
// 1. Clinician fills Yes/No + free-text + comments per domain
// (or hits "Listen in" and dictates the whole thing)
// 2. Generate → POST /api/well-visit/shadess with {patientAge,
// patientGender, domains, dictationText}
// 3. Result auto-fills sessionStorage so the Visit Note tab can
// carry it into the well-visit note (matches the vanilla
// wv-shadess-text auto-fill behaviour).
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import Recorder from '@/components/Recorder';
import OutputActions from '@/components/OutputActions';
interface YnQ { id: string; text: string; type: 'yn'; concern_if?: boolean }
interface TxtQ { id: string; text: string; type: 'text'; placeholder?: string }
type Q = YnQ | TxtQ;
interface Domain {
key: string;
label: string;
icon: string; // emoji stand-in
color: string; // border tint
intro: string;
questions: Q[];
}
// Verbatim from public/js/shadess.js — 8 domains, exact question wording.
const SHADESS_DOMAINS: Domain[] = [
{ key: 'strengths', label: 'Strengths', icon: '⭐', color: '#f59e0b',
intro: 'Starting with what you do well helps us get to know you better.',
questions: [
{ id: 'str1', text: 'Has something they are proud of or enjoy', type: 'yn' },
{ id: 'str2', text: 'Describes self positively when asked', type: 'yn' },
{ id: 'str3', text: 'Has at least one trusted adult they can talk to', type: 'yn' },
]
},
{ key: 'school', label: 'School', icon: '🎓', color: '#3b82f6',
intro: 'Ask about school performance, attendance, and future plans.',
questions: [
{ id: 'sch1', text: 'Grades are satisfactory / doing their best', type: 'yn' },
{ id: 'sch2', text: 'Likes school or finds something enjoyable about it', type: 'yn' },
{ id: 'sch3', text: 'Regular attendance (no truancy concerns)', type: 'yn' },
{ id: 'sch4', text: 'Has plans or goals for the future', type: 'yn' },
]
},
{ key: 'home', label: 'Home', icon: '🏠', color: '#10b981',
intro: 'Ask about living situation and family relationships.',
questions: [
{ id: 'hom1', text: 'Stable living situation', type: 'yn' },
{ id: 'hom2', text: 'Gets along with people at home', type: 'yn' },
{ id: 'hom3', text: 'Would talk to family member if stressed', type: 'yn' },
{ id: 'hom4', text: 'Has experienced household violence or instability (concern if YES)', type: 'yn', concern_if: true },
]
},
{ key: 'activities', label: 'Activities', icon: '👥', color: '#8b5cf6',
intro: 'Ask about friends, hobbies, and peer relationships.',
questions: [
{ id: 'act1', text: 'Has friends and spends time with them', type: 'yn' },
{ id: 'act2', text: 'Involved in sports, clubs, or hobbies', type: 'yn' },
{ id: 'act3', text: 'Social media/screen use within healthy limits', type: 'yn' },
{ id: 'act4', text: 'Has experienced bullying (concern if YES)', type: 'yn', concern_if: true },
]
},
{ key: 'drugs', label: 'Drugs / Substances', icon: '💊', color: '#ef4444',
intro: 'This is confidential. Ask in private.',
questions: [
{ id: 'drg1', text: 'Has tried cigarettes / vaping / tobacco', type: 'yn', concern_if: true },
{ id: 'drg2', text: 'Has tried alcohol', type: 'yn', concern_if: true },
{ id: 'drg3', text: 'Has tried marijuana or other drugs', type: 'yn', concern_if: true },
{ id: 'drg4', text: 'Friends use substances', type: 'yn' },
{ id: 'drg5', text: 'CRAFFT screen result (if done)', type: 'text', placeholder: 'e.g., Score 0 — low risk' },
]
},
{ key: 'emotions', label: 'Emotions / Eating', icon: '❤️', color: '#ec4899',
intro: 'Screen for depression, anxiety, and disordered eating.',
questions: [
{ id: 'emo1', text: 'Feeling down, sad, or hopeless recently', type: 'yn', concern_if: true },
{ id: 'emo2', text: 'Feeling unusually stressed or anxious', type: 'yn', concern_if: true },
{ id: 'emo3', text: 'Trouble sleeping', type: 'yn' },
{ id: 'emo4', text: 'PHQ-A / depression screen result (if done)', type: 'text', placeholder: 'e.g., PHQ-A score 3 — minimal' },
{ id: 'emo5', text: 'Happy with eating habits and body image', type: 'yn' },
{ id: 'emo6', text: 'Restricting food / purging / using diet pills (concern if YES)', type: 'yn', concern_if: true },
]
},
{ key: 'sexuality', label: 'Sexuality', icon: '🛡️', color: '#f97316',
intro: 'Ask in private. Normalize the questions.',
questions: [
{ id: 'sex1', text: 'Comfortable discussing attraction/identity', type: 'yn' },
{ id: 'sex2', text: 'Sexually active', type: 'yn' },
{ id: 'sex3', text: 'Uses protection consistently if sexually active', type: 'yn' },
{ id: 'sex4', text: 'History of unwanted sexual contact (concern if YES)', type: 'yn', concern_if: true },
{ id: 'sex5', text: 'STI screening indicated/done', type: 'yn' },
]
},
{ key: 'safety', label: 'Safety', icon: '🛡', color: '#6366f1',
intro: 'Ask about violence, weapons, and suicidal ideation.',
questions: [
{ id: 'saf1', text: 'Feels safe at school and home', type: 'yn' },
{ id: 'saf2', text: 'Carries a weapon (concern if YES)', type: 'yn', concern_if: true },
{ id: 'saf3', text: 'Has been in physical fights recently', type: 'yn', concern_if: true },
{ id: 'saf4', text: 'Wears seatbelt; safe driving practices', type: 'yn' },
{ id: 'saf5', text: 'Thoughts of hurting self or suicide (concern if YES — STAT eval)', type: 'yn', concern_if: true },
{ id: 'saf6', text: 'Columbia/ASQ suicide screen result (if done)', type: 'text', placeholder: 'e.g., ASQ: negative' },
]
}
];
interface DomainAnswers {
questions: Record<string, string>; // qid -> 'yes'|'no'|free text
comment: string;
concern: boolean;
skipped: boolean;
}
type AnswersMap = Record<string, DomainAnswers>;
interface ShadessOk { success: true; assessment: string; model: string }
const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-semibold disabled:opacity-50';
function emptyAnswers(): AnswersMap {
const out: AnswersMap = {};
SHADESS_DOMAINS.forEach((d) => {
out[d.key] = { questions: {}, comment: '', concern: false, skipped: false };
});
return out;
}
export default function Shadess() {
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [answers, setAnswers] = useState<AnswersMap>(emptyAnswers);
const [dictationText, setDictationText] = useState('');
const [recError, setRecError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const generate = useMutation<ShadessOk, Error, void>({
mutationFn: async () => {
const domains: Record<string, { skipped: boolean; comment: string; concern: boolean; questions: { id: string; text: string; answer: string }[] }> = {};
SHADESS_DOMAINS.forEach((d) => {
const a = answers[d.key];
const qs = d.questions
.map((q) => ({ id: q.id, text: q.text, answer: a.questions[q.id] || '' }))
.filter((q) => q.answer !== '');
domains[d.key] = { skipped: a.skipped, comment: a.comment, concern: a.concern, questions: qs };
});
const hasData = Object.values(domains).some((d) => !d.skipped && (d.questions.length > 0 || d.comment));
if (!hasData && !dictationText.trim()) {
throw new Error('Fill in at least one domain or dictate something');
}
return api.post<ShadessOk>('/api/well-visit/shadess', {
patientAge, patientGender, domains, dictationText: dictationText.trim() || null,
});
},
onSuccess: (d) => {
setResult(d.assessment);
setMsg({ kind: 'ok', text: 'Generated' });
try { sessionStorage.setItem('wv-shadess-assessment', d.assessment); } catch { /* ignore */ }
},
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Generation failed' }),
});
function setQ(domainKey: string, qid: string, value: string, concernIf?: boolean) {
setAnswers((m) => {
const cur = m[domainKey];
const newQuestions = { ...cur.questions, [qid]: value };
// Auto-flag concern when the answer matches the concern_if rule.
let concern = cur.concern;
if (concernIf !== undefined && (value === 'yes') === concernIf) {
concern = true;
}
return { ...m, [domainKey]: { ...cur, questions: newQuestions, concern } };
});
}
function setComment(domainKey: string, value: string) {
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], comment: value } }));
}
function toggleSkip(domainKey: string) {
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], skipped: !m[domainKey].skipped } }));
}
function toggleConcern(domainKey: string) {
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], concern: !m[domainKey].concern } }));
}
function clearAll() {
setAnswers(emptyAnswers());
setDictationText('');
setResult(null);
setMsg(null);
}
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Patient age</span>
<input className={input + ' w-32'} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} placeholder="e.g. 14 years" />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Gender</span>
<select className={input + ' w-40'} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option>
<option>Female</option>
<option>Non-binary/Other</option>
</select>
</label>
<div className="ml-auto text-xs text-muted-foreground">
Recommended age 12 and older. Ask in private.
</div>
</div>
<div>
<span className="text-xs font-semibold uppercase text-muted-foreground">Listen in (optional dictation)</span>
<Recorder
module="shadess"
onTranscript={(text, meta) => {
setDictationText((prev) => meta.appended ? (prev ? prev + ' ' + text : text) : text);
setRecError(null);
}}
onError={(m) => setRecError(m)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<textarea
className={input + ' w-full mt-1 min-h-[60px] font-mono text-xs'}
placeholder="Or type the dictation directly. Used as supplementary input alongside the structured answers below."
value={dictationText}
onChange={(e) => setDictationText(e.target.value)}
/>
</div>
</div>
{SHADESS_DOMAINS.map((d) => {
const a = answers[d.key];
return (
<div
key={d.key}
className="rounded-lg border border-border bg-card overflow-hidden"
style={{ borderLeftWidth: 3, borderLeftColor: d.color }}
data-testid={'shadess-domain-' + d.key}
>
<div className="px-4 py-2 flex items-center gap-2 bg-muted/30">
<span style={{ color: d.color }}>{d.icon}</span>
<strong className="text-sm">{d.label}</strong>
<span className="text-xs text-muted-foreground flex-1 ml-2">{d.intro}</span>
{a.concern && (
<span className="text-xs text-amber-700 bg-amber-100 px-2 py-0.5 rounded"> Concern</span>
)}
<button
type="button"
onClick={() => toggleConcern(d.key)}
className="text-xs text-muted-foreground hover:text-foreground"
title="Toggle concern flag"
>🚩</button>
<label className="text-xs text-muted-foreground flex items-center gap-1">
<input type="checkbox" checked={a.skipped} onChange={() => toggleSkip(d.key)} /> Skip
</label>
</div>
<div className={'px-4 py-2 space-y-2 ' + (a.skipped ? 'opacity-30' : '')}>
{d.questions.map((q) => {
if (q.type === 'yn') {
return (
<div key={q.id} className="flex items-center gap-2 text-sm">
<span className="flex-1">
{q.text}
{q.concern_if !== undefined && (
<span className="text-[10px] text-muted-foreground ml-1">
(flag if {q.concern_if ? 'Yes' : 'No'})
</span>
)}
</span>
<select
className={input + ' text-xs py-1 w-24'}
value={a.questions[q.id] || ''}
onChange={(e) => setQ(d.key, q.id, e.target.value, q.concern_if)}
disabled={a.skipped}
data-testid={'shadess-q-' + q.id}
>
<option value=""></option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
);
}
return (
<div key={q.id} className="flex items-center gap-2 text-sm">
<span className="flex-1">{q.text}</span>
<input
type="text"
className={input + ' text-xs py-1 flex-1 max-w-[260px]'}
placeholder={q.placeholder}
value={a.questions[q.id] || ''}
onChange={(e) => setQ(d.key, q.id, e.target.value)}
disabled={a.skipped}
data-testid={'shadess-q-' + q.id}
/>
</div>
);
})}
<div className="flex items-start gap-2 text-sm">
<span className="flex-1 pt-1">Additional notes:</span>
<textarea
rows={2}
className={input + ' text-xs flex-1 min-w-[200px] resize-y'}
placeholder="Free text comments for this domain…"
value={a.comment}
onChange={(e) => setComment(d.key, e.target.value)}
disabled={a.skipped}
/>
</div>
</div>
</div>
);
})}
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => generate.mutate()} disabled={generate.isPending} className={btnPrimary} data-testid="shadess-generate">
{generate.isPending ? 'Generating…' : '✨ Generate SSHADESS Assessment'}
</button>
<button type="button" onClick={clearAll} className={btn}> New patient</button>
</div>
{msg && (
<div className={'text-sm ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
{msg.text}
</div>
)}
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h3 className="text-sm font-semibold">SSHADESS Assessment</h3>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={dictationText}
exportLabel="sshadess"
exportType="sshadess"
/>
</div>
<div className="px-4 pb-3 text-xs text-muted-foreground">
Auto-saved to session for the Visit Note tab switch tabs to incorporate.
</div>
</section>
)}
</div>
);
}