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.
363 lines
17 KiB
TypeScript
363 lines
17 KiB
TypeScript
// ============================================================
|
||
// 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>
|
||
);
|
||
}
|