feat(web): questionnaires, diary and medication module in the patient app

- Focus screen renders versioned questionnaire items (iq: buttons): section chip, position
  (adaptive forms without "of N"), recall date, pause / help-now actions, optional skip,
  sleep-hours input, 0-10 scale anchors, RUS-AUDIT item 2 standard-drink table.
- Paused / held-for-review questionnaires get their own screen.
- Today: intake and diary tasks, review notice, therapy-day card on days 1/7/14.
- Drug card /meds/:ref from data/meds/meds_reference.json (draft built from the Minzdrav
  guidelines: depression 2024, PTSD 2023) — expected effect, course, side effects, red flags;
  no doses shown. "Report a side effect" always available (med:side_effects:<ref>).
- API: /instruments/catalog, /meds/reference; today gains questionnaire state, reference id,
  indication and therapy day; demo persona seeds the dashboard dose history.
- INTAKE_DEMO=1 (demo clock only) opens the questionnaires on synthetic data without touching
  the clinical release gate in config/intake_runtime.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 08:53:13 +03:00
co-authored by Claude Opus 5.5
parent 40c242bb1a
commit 464b474abd
22 changed files with 3393 additions and 58 deletions
+258 -12
View File
@@ -1,19 +1,32 @@
import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
import { Fragment, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { DialogButton } from "../api/types";
import type { Catalog, DialogButton, Section } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify, lines, type Step } from "../dialog/step";
import { classify, lines, type Step, type Survey } from "../dialog/step";
import { dayMonth } from "./useToday";
/*
* Focus screen: renders the engine's current step as one screen of docs/mock — consent (A4), age (A5),
* onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3).
* onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3),
* versioned questionnaires and the diary (stage 1 / stage 2: C-SSRS, PHQ, TSQ, PCL-5, RUS-AUDIT, дневник).
*/
export const SECTION_ICON: Record<Section, IconName> = {
safety: "shield",
mood: "smile",
trauma: "heart",
alcohol: "info",
about: "user",
diary: "book",
scale: "clipboard",
};
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|\b112\b|\b10[23]\b)/g;
function RichText({ text }: { text: string }) {
@@ -60,7 +73,14 @@ function Body({ texts }: { texts: string[] }) {
);
}
function Progress({ n, total }: { n: number; total: number }) {
function Progress({ n, total, adaptive = false }: { n: number; total: number; adaptive?: boolean }) {
// Adaptive forms (C-SSRS, alcohol, diary) skip items by earlier answers: «из N» would be wrong, so no bar.
if (adaptive)
return (
<div className="progress">
<span className="progress__label">{ru.flow.questionN(n)}</span>
</div>
);
return (
<div className="progress" role="progressbar" aria-valuemin={1} aria-valuemax={total} aria-valuenow={n}>
<span className="progress__bar">
@@ -71,6 +91,56 @@ function Progress({ n, total }: { n: number; total: number }) {
);
}
function FormChip({ section, title }: { section: Section; title?: string }) {
return (
<div className="formchip">
<span className={`chip chip--sec chip--${section}`}>
<Icon name={SECTION_ICON[section]} size={16} />
{ru.flow.sections[section]}
</span>
{/* The C-SSRS form title names suicide risk in capitals: the patient sees the section name only. */}
{title && section !== "safety" && <span className="formchip__title">{title}</span>}
</div>
);
}
// RUS-AUDIT item 2: the standard-drink table of the approved form (no calculator — config/scales/rus_audit.yaml).
function DrinksTable({ table }: { table: NonNullable<Catalog["standard_drinks"]> }) {
return (
<section className="drinks" aria-label={ru.flow.drinks}>
<h2 className="h3">{ru.flow.drinks}</h2>
<p className="muted small">{ru.flow.drinksLead}</p>
<div className="drinks__scroll">
<table>
<thead>
<tr>
<th scope="col">СП</th>
{table.header.map((h) => (
<th key={h} scope="col">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{table.rows.map(([label, cells]) => (
<tr key={label}>
<th scope="row">{label.replace(/\s*СП$/, "")}</th>
{cells.map((c, i) => (
<td key={i}>{c}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="muted small">
{table.title} {table.note}
</p>
</section>
);
}
function Head({ title, lead, icon }: { title: string; lead: string[]; icon?: IconName }) {
return (
<header className="head">
@@ -131,10 +201,12 @@ function NumberScale({
options,
busy,
onSubmit,
anchors,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
anchors?: string[];
}) {
const [picked, setPicked] = useState<DialogButton | null>(null);
return (
@@ -143,7 +215,7 @@ function NumberScale({
<span className="numscale__value" aria-live="polite">
{picked ? picked.label : "–"}
</span>
<div className="numscale__row" role="radiogroup">
<div className="numscale__row" role="radiogroup" style={{ "--n": options.length } as CSSProperties}>
{options.map((b) => (
<button
key={b.data}
@@ -157,6 +229,12 @@ function NumberScale({
</button>
))}
</div>
{anchors && anchors.length === 2 && (
<div className="numscale__ends">
<span>{anchors[0]}</span>
<span>{anchors[1]}</span>
</div>
)}
</div>
<button
type="button"
@@ -223,6 +301,132 @@ function FreeText({
);
}
function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) => void }) {
const [v, setV] = useState("");
const n = Number(v.replace(",", "."));
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= 24;
return (
<form
className="hours"
onSubmit={(e) => {
e.preventDefault();
if (ok) onSend(v.trim());
}}
>
<label htmlFor="hours" className="hours__label">
{ru.flow.hoursLabel}
</label>
<div className="hours__field">
<input
id="hours"
inputMode="decimal"
autoComplete="off"
value={v}
onChange={(e) => setV(e.target.value.replace(/[^\d.,]/g, "").slice(0, 4))}
aria-describedby="hours-hint"
/>
<span aria-hidden="true">{ru.flow.hoursUnit}</span>
</div>
<p id="hours-hint" className="muted small">
{ru.flow.hoursHint}
</p>
<button type="submit" className="btn btn--primary" disabled={!ok || busy}>
{ru.common.next}
</button>
</form>
);
}
function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }) {
const item = catalog?.items[step.itemId];
const form = item ? catalog?.forms[item.form] : undefined;
return (
<>
<header className="head survey">
{form && <FormChip section={form.section} title={form.title} />}
{step.instruction.length > 0 && (
<div className="survey__instr">
<Body texts={step.instruction} />
</div>
)}
<h1 className="h1 survey__q">{step.title}</h1>
{step.footnotes.map((f) => (
<p key={f} className="muted small">
{f}
</p>
))}
{step.asOf && (
<p className="survey__asof">
<Icon name="calendar" size={16} />
{ru.flow.asOf(dayMonth(step.asOf))}
</p>
)}
</header>
{step.itemId === "rus_audit_2" && catalog?.standard_drinks && <DrinksTable table={catalog.standard_drinks} />}
</>
);
}
function SurveyAct({
step,
stepKey,
busy,
press,
say,
}: {
step: Survey;
stepKey: string;
busy: boolean;
press: (b: DialogButton) => void;
say: (text: string) => void;
}) {
let answer: ReactNode;
switch (step.input) {
case "number":
answer = <NumberScale key={stepKey} options={step.options} busy={busy} onSubmit={press} anchors={step.anchors} />;
break;
case "hours":
answer = <HoursInput key={stepKey} busy={busy} onSend={say} />;
break;
case "text":
answer = (
<FreeText key={stepKey} skip={step.skip} busy={busy} onSend={say} onSkip={press} onExit={() => undefined} />
);
break;
default:
answer = (
<>
<Choice key={stepKey} options={step.options} busy={busy} onSubmit={press} />
{step.skip && (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => press(step.skip!)}>
{step.skip.label}
</button>
)}
</>
);
}
return (
<>
{answer}
<div className="survey__aux">
{step.pause && (
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => press(step.pause!)}>
<Icon name="pause" size={18} />
{step.pause.label}
</button>
)}
{step.help && (
<button type="button" className="linkbtn linkbtn--danger" disabled={busy} onClick={() => press(step.help!)}>
<Icon name="alert" size={18} />
{step.help.label}
</button>
)}
</div>
<p className="muted small survey__saved">{ru.flow.saved}</p>
</>
);
}
function MedActions({ options, busy, onPress }: { options: DialogButton[]; busy: boolean; onPress: (b: DialogButton) => void }) {
const style = (b: DialogButton) =>
b.data.startsWith("med:taken")
@@ -284,6 +488,7 @@ export function Flow() {
const navigate = useNavigate();
const dialog = useDialog();
const { resources } = useHelp();
const catalog = useCatalog();
const step: Step | null = useMemo(() => classify(dialog.pending), [dialog.pending]);
const stepKey = useMemo(() => dialog.pending.map((r) => r.text).join("|"), [dialog.pending]);
const home = () => navigate("/today");
@@ -318,10 +523,40 @@ export function Flow() {
);
} else if (step.kind === "idle") {
content = <Done notes={step.notes} onHome={home} />;
} else {
const top = (
<TopBar onBack={home}>{step.kind === "choice" && step.progress && <Progress {...step.progress} />}</TopBar>
} else if (step.kind === "notice") {
content = (
<div className="stack narrow done">
<span className="tile-icon tile-icon--blue tile-icon--xl center-self" aria-hidden="true">
<Icon name={step.help ? "shield" : "pause"} size={32} />
</span>
<h1 className="h1 center">{ru.flow.noticeTitle}</h1>
<div className="panel">
<Body texts={step.text} />
</div>
{step.primary && (
<button type="button" className="btn btn--primary" disabled={dialog.busy} onClick={() => press(step.primary!)}>
{step.primary.label}
</button>
)}
{step.help && (
<button type="button" className="btn btn--danger" disabled={dialog.busy} onClick={() => press(step.help!)}>
{step.help.label}
</button>
)}
<button type="button" className="btn btn--soft" onClick={home}>
{ru.common.toToday}
</button>
</div>
);
} else {
const item = step.kind === "survey" ? catalog?.items[step.itemId] : undefined;
const progress =
step.kind === "choice" && step.progress ? (
<Progress {...step.progress} />
) : item ? (
<Progress n={item.index} total={item.total} adaptive={item.adaptive} />
) : null;
const top = <TopBar onBack={home}>{progress}</TopBar>;
// Phone: one column. Desktop: question and context on the left, answers and actions on the right.
let lead: ReactNode;
let act: ReactNode;
@@ -433,15 +668,26 @@ export function Flow() {
</>
);
break;
case "choice":
lead = <Head title={step.title} lead={step.lead} />;
case "survey":
lead = <SurveyLead step={step} catalog={catalog} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} />;
break;
case "choice": {
const form = step.formId ? catalog?.forms[step.formId] : undefined;
lead = (
<>
{form && <FormChip section={form.section} title={form.title} />}
<Head title={step.title} lead={step.lead} />
</>
);
act = <Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
break;
}
}
content = (
<div className="focus" key={stepKey}>
{top}
<div className={`split split--${step.kind}`}>
<div className={`split split--${step.kind}${step.kind === "survey" ? ` split--in-${step.input}` : ""}`}>
<div className="split__lead">{lead}</div>
<div className="split__act">
{act}