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
+225
View File
@@ -0,0 +1,225 @@
import { useNavigate, useParams } from "react-router-dom";
import type { DrugRef, Indication, MedsReference } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { useMedsReference } from "../content/reference";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { sendSideEffect } from "./Meds";
import { useToday } from "./useToday";
/*
* E6 · О препарате (spec «Модуль Лекарства»: карточка препарата). Patient information from the drug reference
* built on the Minzdrav guidelines: when to expect the effect, how long, what may bother, when to see a doctor
* urgently. No doses or ranges are shown — the prescription comes from the doctor only.
*/
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
/** «3–4 недели» → the upper bound in days, for the progress bar (null when the text has no weeks). */
function weeksToDays(text: string | null | undefined): number | null {
const m = /(\d+)(?:\s*[–-]\s*(\d+))?\s*недел/.exec(text ?? "");
return m ? Number(m[2] ?? m[1]) * 7 : null;
}
function Section({ icon, tone, title, children }: { icon: IconName; tone?: string; title: string; children: React.ReactNode }) {
return (
<section className="drug__sec">
<h2 className="drug__h">
<span className={`badge-icon ${tone ? `badge-icon--${tone}` : ""}`}>
<Icon name={icon} size={18} />
</span>
{title}
</h2>
{children}
</section>
);
}
function EffectTimeline({ day, horizon, when }: { day: number; horizon: number; when: string }) {
const pct = Math.min(100, (day / horizon) * 100);
return (
<div className="timeline" aria-label={ru.meds.card.progress(day)}>
<div className="timeline__bar">
<span className="timeline__fill" style={{ width: `${pct}%` }} />
<span className="timeline__mark" style={{ left: `${pct}%` }} />
</div>
<div className="timeline__legend">
<span>{ru.meds.card.progress(day)}</span>
<span>{ru.meds.card.effectAt(when)}</span>
</div>
</div>
);
}
function Card({
drug,
reference: ref,
indication,
day,
prescribed,
}: {
drug: DrugRef;
reference: MedsReference;
indication: Indication | null;
day: number | null;
prescribed?: string;
}) {
const c = ru.meds.card;
const ind = indication ? drug.indications[indication] : Object.values(drug.indications)[0];
const expect = indication === "ptsd" ? ref.texts.expect_effect_ptsd : ref.texts.expect_effect_depression;
const horizon = weeksToDays(ind?.expected_response);
const sources = ind?.source ?? "";
return (
<div className="drug">
<header className="drug__head">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="pill" size={32} />
</span>
<div>
<h1 className="h1">{drug.name_ru}</h1>
<p className="muted">
{drug.group}
{drug.group !== drug.class && `, ${drug.class}`}
</p>
{prescribed && (
<p className="drug__rx">
<Icon name="doctor" size={18} />
{c.prescribed}: {prescribed}
</p>
)}
</div>
</header>
<div className="drug__grid">
<div className="drug__col">
{ind?.expected_response && (
<Section icon="clock" tone="blue" title={c.effect}>
{day !== null && horizon && <EffectTimeline day={day} horizon={horizon} when={ind.expected_response} />}
<p>{expect}</p>
</Section>
)}
{(ind?.course || drug.max_course_days) && (
<Section icon="calendar" tone="blue" title={c.course}>
{ind?.course && <p>{ind.course}.</p>}
{drug.max_course_days && <p>{c.courseShort(drug.max_course_days)}.</p>}
{indication === "depression" && <p className="muted">{ref.texts.why_continue_depression}</p>}
</Section>
)}
<Section icon="smile" tone="teal" title={c.sideEffects}>
{drug.diary_items.length ? (
<ul className="bullets">
{drug.diary_items.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
) : (
<p>{c.sideEffectsNone}</p>
)}
<p className="muted">{c.sideEffectsHint}</p>
</Section>
</div>
<div className="drug__col">
{drug.red_flags.length > 0 && (
<section className="drug__sec drug__sec--red">
<h2 className="drug__h">
<span className="badge-icon badge-icon--red">
<Icon name="alert" size={18} />
</span>
{c.redFlags}
</h2>
<ul className="bullets">
{drug.red_flags.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
<p>{ref.texts.red_flag}</p>
</section>
)}
<Section icon="shield" tone="blue" title={c.important}>
<p>{ref.texts.dont_stop_alone}</p>
{drug.withdrawal_risk === "high" && <p>{c.withdrawal}</p>}
{drug.overdose_danger && <p>{c.overdose}</p>}
{ind?.off_label && <p className="muted">{c.offLabel}</p>}
{drug.monitoring.length > 0 && (
<>
<p className="drug__sub">{c.monitoring}:</p>
<ul className="bullets">
{drug.monitoring.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
</>
)}
</Section>
<Section icon="doc" tone="blue" title={c.instruction}>
<p>{ref.texts.instruction_fallback}</p>
</Section>
</div>
</div>
<p className="muted small">{c.source(sources)}</p>
</div>
);
}
export function DrugInfo() {
const c = ru.meds.card;
const { ref: refId } = useParams();
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const ref = useMedsReference();
const drug = ref?.drugs.find((d) => d.id === refId);
const rx = plan?.prescriptions.find((p) => p.ref_id === refId);
const ask = () =>
void dialog.send({ type: "button", button: "menu:med_question" }, c.ask).then((ok) => ok && navigate("/flow"));
return (
<AppShell>
<div className="stack">
<TopBar onBack={() => navigate("/meds")} />
{!ref ? (
<div className="loader" aria-label="Загрузка" />
) : !drug ? (
<Notice>{c.unknown}</Notice>
) : (
<Card
drug={drug}
reference={ref}
indication={rx?.indication ?? null}
day={rx?.therapy_day ?? null}
prescribed={rx ? `${cap(rx.drug)}, ${rx.dose} · ${rx.times.join(", ")}` : undefined}
/>
)}
{rx && (
<div className="btn-row btn-row--stack-phone">
<button
type="button"
className="btn btn--outline-red"
disabled={dialog.busy}
onClick={() => sendSideEffect(dialog, navigate, plan, rx.drug, ru.meds.reportSideEffect)}
>
<Icon name="alert" />
{ru.meds.reportSideEffect}
</button>
{plan?.has_doctor && (
<button type="button" className="btn btn--outline" disabled={dialog.busy} onClick={ask}>
<Icon name="question" />
{c.ask}
</button>
)}
</div>
)}
</div>
</AppShell>
);
}