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:
+121
-25
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user