Files
PTSRaiAgent/web/src/screens/Today.tsx
T
Arxip222andClaude Opus 5.5 290b0d6151 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>
2026-10-04 15:14:09 +03:00

333 lines
13 KiB
TypeScript

import { useNavigate } from "react-router-dom";
import type { TodayDose, TodayPrescription } from "../api/types";
import { AppShell, Notice } from "../components/AppShell";
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 { 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}>
<span className="tile-icon tile-icon--blue">
<Icon name="calendar" />
</span>
<span className="row-card__text">
<span className="row-card__sub">{ru.today.visit}</span>
<span className="row-card__title">{humanDate(date)}</span>
</span>
<Icon name="chevronRight" />
</button>
);
}
function WeekCard({ rx, onOpen }: { rx: TodayPrescription; onOpen: () => void }) {
const taken = rx.week.filter((w) => w.status === "taken").length;
const counted = rx.week.filter((w) => w.status !== "none").length;
return (
<button type="button" className="card weekcard" onClick={onOpen}>
<span className="card__head">
<span className="tile-icon tile-icon--teal">
<Icon name="pill" />
</span>
<span className="row-card__text">
<span className="row-card__sub">{ru.today.week}</span>
<span className="row-card__title">{ru.today.weekTaken(taken, counted)}</span>
</span>
<Icon name="chevronRight" />
</span>
<WeekDots week={rx.week} />
</button>
);
}
// C1 / C2 · Сегодня
export function Today() {
const t = ru.today;
const navigate = useNavigate();
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);
// A paused questionnaire is not an open step: the «continue the questionnaire» card below resumes it.
const paused = waiting?.kind === "notice" && waiting.primary?.data === "intake:resume";
const hasOpenStep = waiting !== null && waiting.kind !== "idle" && waiting.kind !== "ack" && !paused;
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 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);
const now = plan?.now ?? "00:00";
const openDose = (d: TodayDose) => navigate(`/dose/${d.id}`);
const visit = plan?.next_visit ? <VisitCard date={plan.next_visit} onOpen={() => navigate("/doctor")} /> : null;
const hasDone = takenDoses.length > 0 || plan?.checkin_done;
return (
<AppShell aside={visit}>
<div className="dash">
<header className="today-head dash__head">
<div>
<h1 className="h1">{t.title}</h1>
{plan && <p className="muted">{humanDate(plan.today)}</p>}
</div>
<button
type="button"
className="icon-btn icon-btn--bell"
onClick={() => navigate("/messages")}
aria-label={ru.nav.messages}
>
<Icon name="bell" />
{dialog.unread > 0 && <span className="dot" />}
</button>
</header>
{failed && <Notice tone="danger">{ru.common.networkError}</Notice>}
<section className="dash__main" aria-label={t.title}>
{hasOpenStep &&
(waiting?.kind === "survey" ? (
<Task
hero
icon={surveyForm?.section === "diary" ? "book" : "clipboard"}
title={surveyForm?.section === "diary" ? t.diary : t.intakeResume}
sub={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.therapyDay(effectRx.therapy_day!)}</span>
<span className="task__sub">{t.effectHint}</span>
</span>
<Icon name="chevronRight" />
</button>
)}
{allDone ? (
<div className="alldone">
<span className="done__badge" aria-hidden="true">
<Icon name="check" size={36} />
</span>
<h2 className="h2 center">{t.allDoneTitle}</h2>
<p className="muted center">{nextDose ? t.allDoneNext(`завтра в ${nextDose}`) : t.allDoneNoMeds}</p>
{plan && plan.prescriptions.length > 0 && (
<button type="button" className="btn btn--primary btn--auto" onClick={() => navigate("/meds")}>
{t.viewMeds}
<Icon name="chevronRight" size={20} />
</button>
)}
</div>
) : (
<div className="tasks">
{pendingDoses.map((d) => (
<button
key={d.id}
type="button"
className={d.time <= now && !hasOpenStep ? "task task--hero" : "task"}
onClick={() => openDose(d)}
>
<span className="task__icon task__icon--teal">
<Icon name="pill" />
</span>
<span className="task__text">
<span className="task__title">{t.takeMed}</span>
<span className="task__sub">
{d.time} · {d.drug}, {d.dose}
</span>
</span>
{d.time <= now && <span className="chip chip--teal-solid">{t.now}</span>}
<Icon name="chevronRight" />
</button>
))}
{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}
onClick={() =>
q.intake === "none" ? go("intake:start", t.intakeStart) : go("intake:resume", t.intakeResume)
}
/>
)}
{diaryDue && waiting?.kind !== "survey" && (
<Task
icon="book"
title={q.diary_next ? t.diaryPart[q.diary_next] : t.diary}
sub={q.diary_next ? t.diaryPartHint[q.diary_next] : t.diaryHint}
disabled={dialog.busy}
onClick={() => go("menu:checkin", q.diary_next ? t.diaryPart[q.diary_next] : 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>
)}
</section>
<section className="dash__side">
{plan?.next_visit && (
<div className="only-phone">
<VisitCard date={plan.next_visit} onOpen={() => navigate("/doctor")} />
</div>
)}
{!allDone && hasDone && (
<div className="card">
<h2 className="h3">{t.done}</h2>
{takenDoses.map((d) => (
<div key={d.id} className="done-row">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
<span>
<span className="done-row__title">{t.medTaken}</span>
<span className="done-row__sub">{t.medTakenAt(d.time)}</span>
</span>
</div>
))}
{plan?.checkin_done && (
<div className="done-row">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
<span className="done-row__title">{surveys ? t.diaryDone : t.checkinDone}</span>
</div>
)}
</div>
)}
{plan?.prescriptions[0] && <WeekCard rx={plan.prescriptions[0]} onOpen={() => navigate("/meds")} />}
{noDoctor ? (
<Notice>{t.noDoctor}</Notice>
) : (
<div className="quick">
<button
type="button"
className="quick__btn"
onClick={() => go("menu:med_question", t.medQuestion)}
disabled={dialog.busy}
>
<span className="tile-icon tile-icon--blue">
<Icon name="question" />
</span>
<span>
<span className="quick__title">{t.medQuestion}</span>
<span className="quick__sub">{t.medQuestionHint}</span>
</span>
</button>
<button
type="button"
className="quick__btn"
onClick={() => go("menu:contact_doctor", t.messageDoctor)}
disabled={dialog.busy}
>
<span className="tile-icon tile-icon--blue">
<Icon name="message" />
</span>
<span>
<span className="quick__title">{t.messageDoctor}</span>
<span className="quick__sub">{t.messageDoctorHint}</span>
</span>
</button>
</div>
)}
</section>
</div>
</AppShell>
);
}