feat(web): two-stage questionnaires, screening result and diary by result

- Sign-in: centred ЕМИАС card without the sidebar; anonymous entry asks «Депрессия / ПТСР».
- Stage 1 is the same for everyone and follows the fixed order of the MVP questionnaire:
  PHQ-2 → trauma filter/TSQ → RUS-AUDIT-S → C-SSRS → context; stage 2 only where stage 1
  is positive. Starts without a profile.
- After stage 1 the patient sees a preliminary result (screening, not a diagnosis), stored
  with the record; it switches on the diary modules (depression / PTSD).
- Stage 2 diary: morning (sleep, sleep quality, alcohol) and evening (mood, medication,
  3 PHQ-9 items in rotation, short PCL-5); C-SSRS since last contact only under risk;
  flags on weekly means against the personal norm; scheduled full scales with change
  (remission, response, worsening).
- Questionnaire log: GET /me/{token}/results and the «Мои результаты» screen.
- Questionnaires centred without the sidebar and without scale names; «Продолжить позже»
  goes home; confirmations are toasts; free-text comments are optional.
- Demo policy config/questionnaire_demo.yaml (INTAKE_DEMO only, not a clinical approval).

ADR-012.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:14:09 +03:00
co-authored by Claude Opus 5.5
parent 6e7c89900b
commit 290b0d6151
36 changed files with 1900 additions and 691 deletions
+92 -67
View File
@@ -1,15 +1,15 @@
import { Fragment, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { Catalog, DialogButton, Section } from "../api/types";
import type { Catalog, DialogButton } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
import { useToast } from "../components/Toast";
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, 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),
@@ -17,16 +17,6 @@ import { dayMonth } from "./useToday";
* 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 }) {
@@ -91,19 +81,6 @@ function Progress({ n, total, adaptive = false }: { n: number; total: number; ad
);
}
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 (
@@ -135,7 +112,8 @@ function DrinksTable({ table }: { table: NonNullable<Catalog["standard_drinks"]>
</table>
</div>
<p className="muted small">
{table.title} {table.note}
{/* The form name stays hidden while answering: only the definition of a standard drink is shown. */}
{table.title.replace(/\s*Таблица бланка RUS-AUDIT:?/, "")} {table.note}
</p>
</section>
);
@@ -248,6 +226,9 @@ function NumberScale({
);
}
// Sent when a free-text step is submitted empty: a comment is never required (app/dialog/flows/meds.py).
const NO_COMMENT: DialogButton = { label: ru.flow.sendEmpty, data: "text:skip" };
function FreeText({
skip,
busy,
@@ -291,20 +272,34 @@ function FreeText({
<button
type="button"
className="btn btn--primary"
disabled={!text.trim() || busy}
onClick={() => onSend(text.trim())}
disabled={busy}
onClick={() => (text.trim() ? onSend(text.trim()) : onSkip(skip ?? NO_COMMENT))}
>
{ru.common.next}
{text.trim() ? ru.common.send : ru.flow.sendEmpty}
</button>
</div>
</>
);
}
function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) => void }) {
const NUMERIC = {
hours: { label: ru.flow.hoursLabel, unit: ru.flow.hoursUnit, hint: ru.flow.hoursHint, max: 24 },
portions: { label: ru.flow.portionsLabel, unit: ru.flow.portionsUnit, hint: ru.flow.portionsHint, max: 50 },
};
function HoursInput({
busy,
onSend,
kind = "hours",
}: {
busy: boolean;
onSend: (text: string) => void;
kind?: keyof typeof NUMERIC;
}) {
const f = NUMERIC[kind];
const [v, setV] = useState("");
const n = Number(v.replace(",", "."));
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= 24;
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= f.max;
return (
<form
className="hours"
@@ -314,7 +309,7 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
}}
>
<label htmlFor="hours" className="hours__label">
{ru.flow.hoursLabel}
{f.label}
</label>
<div className="hours__field">
<input
@@ -325,10 +320,10 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
onChange={(e) => setV(e.target.value.replace(/[^\d.,]/g, "").slice(0, 4))}
aria-describedby="hours-hint"
/>
<span aria-hidden="true">{ru.flow.hoursUnit}</span>
<span aria-hidden="true">{f.unit}</span>
</div>
<p id="hours-hint" className="muted small">
{ru.flow.hoursHint}
{f.hint}
</p>
<button type="submit" className="btn btn--primary" disabled={!ok || busy}>
{ru.common.next}
@@ -337,13 +332,12 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
);
}
// No form name or section above the question: the person should not see what a questionnaire screens for while
// answering it. Names and results appear only after it is finished (Results screen).
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} />
@@ -355,12 +349,6 @@ function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }
{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} />}
</>
@@ -373,12 +361,14 @@ function SurveyAct({
busy,
press,
say,
onPause,
}: {
step: Survey;
stepKey: string;
busy: boolean;
press: (b: DialogButton) => void;
say: (text: string) => void;
onPause: (b: DialogButton) => void;
}) {
let answer: ReactNode;
switch (step.input) {
@@ -386,7 +376,8 @@ function SurveyAct({
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} />;
case "portions":
answer = <HoursInput key={stepKey} busy={busy} onSend={say} kind={step.input} />;
break;
case "text":
answer = (
@@ -410,7 +401,7 @@ function SurveyAct({
{answer}
<div className="survey__aux">
{step.pause && (
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => press(step.pause!)}>
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => onPause(step.pause!)}>
<Icon name="pause" size={18} />
{step.pause.label}
</button>
@@ -422,7 +413,6 @@ function SurveyAct({
</button>
)}
</div>
<p className="muted small survey__saved">{ru.flow.saved}</p>
</>
);
}
@@ -467,25 +457,40 @@ function Breathing() {
);
}
function Done({ notes, onHome }: { notes: string[]; onHome: () => void }) {
// The engine's closing note: its first paragraph is the result headline (config/texts/results.yaml), the rest
// explains it. Results stay in «Мои результаты» with the date.
function Done({ notes, onHome, onResults }: { notes: string[]; onHome: () => void; onResults: () => void }) {
const [head = ru.flow.doneTitle, ...rest] = notes
.join("\n\n")
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean);
return (
<div className="stack narrow done">
<span className="done__badge" aria-hidden="true">
<Icon name="check" size={40} />
</span>
<h1 className="h1 center">{ru.flow.doneTitle}</h1>
<div className="panel">
<Body texts={notes} />
</div>
<h1 className="h1 center">{head}</h1>
{rest.length > 0 && (
<div className="panel">
<Body texts={rest} />
</div>
)}
<button type="button" className="btn btn--primary" onClick={onHome}>
{ru.common.toToday}
</button>
<button type="button" className="btn btn--soft" onClick={onResults}>
{ru.today.results}
</button>
</div>
);
}
const SHORT_PARAGRAPHS = 3;
export function Flow() {
const navigate = useNavigate();
const toast = useToast();
const dialog = useDialog();
const { resources } = useHelp();
const catalog = useCatalog();
@@ -502,11 +507,36 @@ export function Flow() {
dialog.markRead();
}, [dialog.pending]);
// A short confirmation becomes a toast in the top-right corner and the person goes straight home. Only a long
// result (the conclusion of the first questionnaire) keeps its own screen.
const toasted = useRef("");
useEffect(() => {
if (step?.kind === "idle" && step.notes.length === 0) navigate("/today", { replace: true });
}, [step, navigate]);
if (!step || (step.kind !== "idle" && step.kind !== "ack")) return;
const texts = step.kind === "ack" ? step.text : step.notes;
const paragraphs = texts.join("\n\n").split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
if (step.kind === "idle" && paragraphs.length > SHORT_PARAGRAPHS) return;
if (paragraphs.length && toasted.current !== stepKey) {
toasted.current = stepKey;
toast(paragraphs.join(" "));
}
navigate("/today", { replace: true });
}, [step, stepKey, navigate, toast]);
// Questionnaire: answers are saved on the server per question, and there is no going back to change one.
// The browser «back» is absorbed by a duplicate history entry of this same screen.
const testing = step?.kind === "survey" || step?.kind === "choice" || step?.kind === "consent";
useEffect(() => {
if (!testing) return;
window.history.pushState(null, "", window.location.href);
const stay = () => window.history.pushState(null, "", window.location.href);
window.addEventListener("popstate", stay);
return () => window.removeEventListener("popstate", stay);
}, [testing]);
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
// «Продолжить позже»: answers are already saved on the server — pause and go straight home, no confirmation.
const pause = (b: DialogButton) =>
void dialog.send({ type: "button", button: b.data }, b.label).then((ok) => ok && navigate("/today", { replace: true }));
const say = (text: string) => void dialog.send({ type: "text", text }, text);
let content: ReactNode;
@@ -521,8 +551,10 @@ export function Flow() {
) : (
<div className="loader" aria-label="Загрузка" />
);
} else if (step.kind === "ack") {
content = <div className="loader" aria-label="Загрузка" />;
} else if (step.kind === "idle") {
content = <Done notes={step.notes} onHome={home} />;
content = <Done notes={step.notes} onHome={home} onResults={() => navigate("/results")} />;
} else if (step.kind === "notice") {
content = (
<div className="stack narrow done">
@@ -556,7 +588,7 @@ export function Flow() {
) : item ? (
<Progress n={item.index} total={item.total} adaptive={item.adaptive} />
) : null;
const top = <TopBar onBack={home}>{progress}</TopBar>;
const top = <TopBar onBack={testing ? undefined : 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;
@@ -670,19 +702,12 @@ export function Flow() {
break;
case "survey":
lead = <SurveyLead step={step} catalog={catalog} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} onPause={pause} />;
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} />
</>
);
case "choice":
lead = <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}>
@@ -699,7 +724,7 @@ export function Flow() {
}
return (
<AppShell focus sos={step?.kind !== "crisis"}>
<AppShell focus bare sos={step?.kind !== "crisis"}>
{content}
</AppShell>
);