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
+121 -25
View File
@@ -2,14 +2,50 @@ import { useNavigate } from "react-router-dom";
import type { TodayDose, TodayPrescription } from "../api/types";
import { AppShell, Notice } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify } from "../dialog/step";
import { useSession } from "../session/SessionContext";
import { SECTION_ICON } from "./Flow";
import { WeekDots } from "./Meds";
import { humanDate, useToday } from "./useToday";
// Spec «Модуль Лекарства»: the expected-effect card is pushed on these days of therapy.
const EFFECT_DAYS = [1, 7, 14];
function Task({
icon,
title,
sub,
hero,
chip,
onClick,
disabled,
}: {
icon: IconName;
title: string;
sub: string;
hero?: boolean;
chip?: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button type="button" className={hero ? "task task--hero" : "task"} onClick={onClick} disabled={disabled}>
<span className={hero ? "task__icon" : "task__icon task__icon--teal"}>
<Icon name={icon} />
</span>
<span className="task__text">
<span className="task__title">{title}</span>
<span className="task__sub">{sub}</span>
</span>
{chip ? <span className="chip chip--teal-solid">{chip}</span> : <Icon name="chevronRight" />}
</button>
);
}
function VisitCard({ date, onOpen }: { date: string; onOpen: () => void }) {
return (
<button type="button" className="row-card" onClick={onOpen}>
@@ -52,17 +88,35 @@ export function Today() {
const { session } = useSession();
const dialog = useDialog();
const { plan, failed } = useToday();
const catalog = useCatalog();
// An unanswered engine question (reminder, check-in, scale, unfinished onboarding) waits on the focus screen.
const waiting = classify(dialog.pending);
const hasOpenStep = waiting !== null && waiting.kind !== "idle";
const surveyItem = waiting?.kind === "survey" ? catalog?.items[waiting.itemId] : undefined;
const surveyForm = surveyItem ? catalog?.forms[surveyItem.form] : undefined;
const go = (button: string, label: string) =>
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
// Questionnaires (stage 1 intake → stage 2 diary) replace the simple check-in once they are switched on.
const q = plan?.questionnaires;
const surveys = q?.enabled === true;
const intakeOpen = surveys && q.intake !== "complete" && q.intake !== "review";
const diaryDue = surveys && q.intake === "complete" && !q.diary_done;
const effectRx = plan?.prescriptions.find((p) => p.ref_id && p.therapy_day && EFFECT_DAYS.includes(p.therapy_day));
const pendingDoses = plan?.doses.filter((d) => d.status === "pending") ?? [];
const takenDoses = plan?.doses.filter((d) => d.status === "taken") ?? [];
const allDone = plan !== null && pendingDoses.length === 0 && plan.checkin_done && !hasOpenStep;
const doseNow = pendingDoses.some((d) => d.time <= (plan?.now ?? "00:00"));
const allDone =
plan !== null &&
pendingDoses.length === 0 &&
plan.checkin_done &&
!hasOpenStep &&
!intakeOpen &&
!diaryDue &&
q?.intake !== "review";
const nextDose = plan?.prescriptions.flatMap((p) => p.times).sort()[0];
const noDoctor = session?.mode === "anonymous" || (plan !== null && !plan.has_doctor);
@@ -93,16 +147,44 @@ export function Today() {
{failed && <Notice tone="danger">{ru.common.networkError}</Notice>}
<section className="dash__main" aria-label={t.title}>
{hasOpenStep && (
<button type="button" className="task task--hero" onClick={() => navigate("/flow")}>
<span className="task__icon">
<Icon name="message" />
{hasOpenStep &&
(waiting?.kind === "survey" ? (
<Task
hero
icon={surveyForm ? SECTION_ICON[surveyForm.section] : "clipboard"}
title={surveyForm?.section === "diary" ? t.diary : t.intakeResume}
sub={
surveyForm && surveyItem
? t.surveyStep(ru.flow.sections[surveyForm.section], surveyItem.index)
: t.intakeResumeHint
}
chip={t.continue}
onClick={() => navigate("/flow")}
/>
) : (
<Task
hero
icon="message"
title={t.newMessage}
sub={dialog.pending[0]?.text.split("\n")[0].slice(0, 90) ?? ""}
chip={t.open}
onClick={() => navigate("/flow")}
/>
))}
{surveys && q.intake === "review" && <Notice>{t.intakeReview}</Notice>}
{effectRx && (
<button type="button" className="effect" onClick={() => navigate(`/meds/${effectRx.ref_id}`)}>
<span className="effect__day">
<span className="effect__num">{effectRx.therapy_day}</span>
<span className="effect__unit">день</span>
</span>
<span className="task__text">
<span className="task__title">{t.newMessage}</span>
<span className="task__sub">{dialog.pending[0]?.text.split("\n")[0].slice(0, 90)}</span>
<span className="task__title">{t.therapyDay(effectRx.therapy_day!)}</span>
<span className="task__sub">{t.effectHint}</span>
</span>
<span className="chip chip--teal-solid">{t.open}</span>
<Icon name="chevronRight" />
</button>
)}
@@ -142,22 +224,36 @@ export function Today() {
<Icon name="chevronRight" />
</button>
))}
{plan && !plan.checkin_done && (
<button
type="button"
className="task"
onClick={() => go("menu:checkin", t.checkin)}
{intakeOpen && waiting?.kind !== "survey" && (
<Task
hero={!hasOpenStep && !doseNow}
icon="clipboard"
title={t.intakeStart}
sub={q.intake === "none" ? t.intakeStartHint : t.intakeResumeHint}
chip={q.intake === "none" ? undefined : t.continue}
disabled={dialog.busy}
>
<span className="task__icon task__icon--teal">
<Icon name="smile" />
</span>
<span className="task__text">
<span className="task__title">{t.checkin}</span>
<span className="task__sub">{t.checkinHint}</span>
</span>
<Icon name="chevronRight" />
</button>
onClick={() =>
q.intake === "none" ? go("intake:start", t.intakeStart) : go("intake:resume", t.intakeResume)
}
/>
)}
{diaryDue && waiting?.kind !== "survey" && (
<Task
icon="book"
title={t.diary}
sub={t.diaryHint}
disabled={dialog.busy}
onClick={() => go("menu:checkin", t.diary)}
/>
)}
{plan && !surveys && !plan.checkin_done && (
<Task
icon="smile"
title={t.checkin}
sub={t.checkinHint}
disabled={dialog.busy}
onClick={() => go("menu:checkin", t.checkin)}
/>
)}
</div>
)}
@@ -190,7 +286,7 @@ export function Today() {
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
<span className="done-row__title">{t.checkinDone}</span>
<span className="done-row__title">{surveys ? t.diaryDone : t.checkinDone}</span>
</div>
)}
</div>