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>
);
}
+258 -12
View File
@@ -1,19 +1,32 @@
import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
import { Fragment, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { DialogButton } from "../api/types";
import type { Catalog, DialogButton, Section } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
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 } from "../dialog/step";
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),
* onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3).
* onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3),
* 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 }) {
@@ -60,7 +73,14 @@ function Body({ texts }: { texts: string[] }) {
);
}
function Progress({ n, total }: { n: number; total: number }) {
function Progress({ n, total, adaptive = false }: { n: number; total: number; adaptive?: boolean }) {
// Adaptive forms (C-SSRS, alcohol, diary) skip items by earlier answers: «из N» would be wrong, so no bar.
if (adaptive)
return (
<div className="progress">
<span className="progress__label">{ru.flow.questionN(n)}</span>
</div>
);
return (
<div className="progress" role="progressbar" aria-valuemin={1} aria-valuemax={total} aria-valuenow={n}>
<span className="progress__bar">
@@ -71,6 +91,56 @@ function Progress({ n, total }: { n: number; total: number }) {
);
}
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 (
<section className="drinks" aria-label={ru.flow.drinks}>
<h2 className="h3">{ru.flow.drinks}</h2>
<p className="muted small">{ru.flow.drinksLead}</p>
<div className="drinks__scroll">
<table>
<thead>
<tr>
<th scope="col">СП</th>
{table.header.map((h) => (
<th key={h} scope="col">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{table.rows.map(([label, cells]) => (
<tr key={label}>
<th scope="row">{label.replace(/\s*СП$/, "")}</th>
{cells.map((c, i) => (
<td key={i}>{c}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="muted small">
{table.title} {table.note}
</p>
</section>
);
}
function Head({ title, lead, icon }: { title: string; lead: string[]; icon?: IconName }) {
return (
<header className="head">
@@ -131,10 +201,12 @@ function NumberScale({
options,
busy,
onSubmit,
anchors,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
anchors?: string[];
}) {
const [picked, setPicked] = useState<DialogButton | null>(null);
return (
@@ -143,7 +215,7 @@ function NumberScale({
<span className="numscale__value" aria-live="polite">
{picked ? picked.label : "–"}
</span>
<div className="numscale__row" role="radiogroup">
<div className="numscale__row" role="radiogroup" style={{ "--n": options.length } as CSSProperties}>
{options.map((b) => (
<button
key={b.data}
@@ -157,6 +229,12 @@ function NumberScale({
</button>
))}
</div>
{anchors && anchors.length === 2 && (
<div className="numscale__ends">
<span>{anchors[0]}</span>
<span>{anchors[1]}</span>
</div>
)}
</div>
<button
type="button"
@@ -223,6 +301,132 @@ function FreeText({
);
}
function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) => void }) {
const [v, setV] = useState("");
const n = Number(v.replace(",", "."));
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= 24;
return (
<form
className="hours"
onSubmit={(e) => {
e.preventDefault();
if (ok) onSend(v.trim());
}}
>
<label htmlFor="hours" className="hours__label">
{ru.flow.hoursLabel}
</label>
<div className="hours__field">
<input
id="hours"
inputMode="decimal"
autoComplete="off"
value={v}
onChange={(e) => setV(e.target.value.replace(/[^\d.,]/g, "").slice(0, 4))}
aria-describedby="hours-hint"
/>
<span aria-hidden="true">{ru.flow.hoursUnit}</span>
</div>
<p id="hours-hint" className="muted small">
{ru.flow.hoursHint}
</p>
<button type="submit" className="btn btn--primary" disabled={!ok || busy}>
{ru.common.next}
</button>
</form>
);
}
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} />
</div>
)}
<h1 className="h1 survey__q">{step.title}</h1>
{step.footnotes.map((f) => (
<p key={f} className="muted small">
{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} />}
</>
);
}
function SurveyAct({
step,
stepKey,
busy,
press,
say,
}: {
step: Survey;
stepKey: string;
busy: boolean;
press: (b: DialogButton) => void;
say: (text: string) => void;
}) {
let answer: ReactNode;
switch (step.input) {
case "number":
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} />;
break;
case "text":
answer = (
<FreeText key={stepKey} skip={step.skip} busy={busy} onSend={say} onSkip={press} onExit={() => undefined} />
);
break;
default:
answer = (
<>
<Choice key={stepKey} options={step.options} busy={busy} onSubmit={press} />
{step.skip && (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => press(step.skip!)}>
{step.skip.label}
</button>
)}
</>
);
}
return (
<>
{answer}
<div className="survey__aux">
{step.pause && (
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => press(step.pause!)}>
<Icon name="pause" size={18} />
{step.pause.label}
</button>
)}
{step.help && (
<button type="button" className="linkbtn linkbtn--danger" disabled={busy} onClick={() => press(step.help!)}>
<Icon name="alert" size={18} />
{step.help.label}
</button>
)}
</div>
<p className="muted small survey__saved">{ru.flow.saved}</p>
</>
);
}
function MedActions({ options, busy, onPress }: { options: DialogButton[]; busy: boolean; onPress: (b: DialogButton) => void }) {
const style = (b: DialogButton) =>
b.data.startsWith("med:taken")
@@ -284,6 +488,7 @@ export function Flow() {
const navigate = useNavigate();
const dialog = useDialog();
const { resources } = useHelp();
const catalog = useCatalog();
const step: Step | null = useMemo(() => classify(dialog.pending), [dialog.pending]);
const stepKey = useMemo(() => dialog.pending.map((r) => r.text).join("|"), [dialog.pending]);
const home = () => navigate("/today");
@@ -318,10 +523,40 @@ export function Flow() {
);
} else if (step.kind === "idle") {
content = <Done notes={step.notes} onHome={home} />;
} else {
const top = (
<TopBar onBack={home}>{step.kind === "choice" && step.progress && <Progress {...step.progress} />}</TopBar>
} else if (step.kind === "notice") {
content = (
<div className="stack narrow done">
<span className="tile-icon tile-icon--blue tile-icon--xl center-self" aria-hidden="true">
<Icon name={step.help ? "shield" : "pause"} size={32} />
</span>
<h1 className="h1 center">{ru.flow.noticeTitle}</h1>
<div className="panel">
<Body texts={step.text} />
</div>
{step.primary && (
<button type="button" className="btn btn--primary" disabled={dialog.busy} onClick={() => press(step.primary!)}>
{step.primary.label}
</button>
)}
{step.help && (
<button type="button" className="btn btn--danger" disabled={dialog.busy} onClick={() => press(step.help!)}>
{step.help.label}
</button>
)}
<button type="button" className="btn btn--soft" onClick={home}>
{ru.common.toToday}
</button>
</div>
);
} else {
const item = step.kind === "survey" ? catalog?.items[step.itemId] : undefined;
const progress =
step.kind === "choice" && step.progress ? (
<Progress {...step.progress} />
) : item ? (
<Progress n={item.index} total={item.total} adaptive={item.adaptive} />
) : null;
const top = <TopBar onBack={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;
@@ -433,15 +668,26 @@ export function Flow() {
</>
);
break;
case "choice":
lead = <Head title={step.title} lead={step.lead} />;
case "survey":
lead = <SurveyLead step={step} catalog={catalog} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} />;
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} />
</>
);
act = <Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
break;
}
}
content = (
<div className="focus" key={stepKey}>
{top}
<div className={`split split--${step.kind}`}>
<div className={`split split--${step.kind}${step.kind === "survey" ? ` split--in-${step.input}` : ""}`}>
<div className="split__lead">{lead}</div>
<div className="split__act">
{act}
+37 -1
View File
@@ -1,6 +1,6 @@
import { useNavigate, useParams } from "react-router-dom";
import type { TodayPrescription } from "../api/types";
import type { TodayPlan, TodayPrescription } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
@@ -30,6 +30,24 @@ export function WeekDots({ week }: { week: TodayPrescription["week"] }) {
);
}
/**
* «Сообщить о побочном эффекте» — always available (spec «Модуль Лекарства»). Same engine step as the reminder's
* «Есть побочные эффекты»: `med:side_effects:<ref>`, where ref is today's dose of the drug or, without a dose row,
* today's ISO date (app/dialog/flows/meds.py).
*/
export function sendSideEffect(
dialog: ReturnType<typeof useDialog>,
navigate: (to: string) => void,
plan: TodayPlan | null,
drug: string,
label: string,
) {
if (!plan) return;
const dose = plan.doses.find((d) => d.drug === drug && d.status === "pending") ?? plan.doses.find((d) => d.drug === drug);
const ref = dose ? String(dose.id) : plan.today;
void dialog.send({ type: "button", button: `med:side_effects:${ref}` }, label).then((ok) => ok && navigate("/flow"));
}
// E4 · Моя схема приёма
export function Meds() {
const t = ru.meds;
@@ -39,6 +57,7 @@ export function Meds() {
const ask = () =>
void dialog.send({ type: "button", button: "menu:med_question" }, t.question).then((ok) => ok && navigate("/flow"));
const reportSideEffect = (drug: string) => sendSideEffect(dialog, navigate, plan, drug, t.reportSideEffect);
return (
<AppShell>
@@ -74,6 +93,23 @@ export function Meds() {
))}
</ul>
<WeekDots week={p.week} />
<div className="rx__foot">
{p.ref_id && (
<button type="button" className="btn btn--soft btn--sm" onClick={() => navigate(`/meds/${p.ref_id}`)}>
<Icon name="info" size={18} />
{t.about}
</button>
)}
<button
type="button"
className="btn btn--ghost-red btn--sm"
disabled={dialog.busy}
onClick={() => reportSideEffect(p.drug)}
>
<Icon name="alert" size={18} />
{t.reportSideEffect}
</button>
</div>
</article>
))}
</div>
+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>
+6
View File
@@ -40,3 +40,9 @@ export function humanDate(iso: string): string {
const wd = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
return `${d} ${MONTHS[m - 1]}, ${WEEKDAYS[wd]}`;
}
/** "4 октября" — from an ISO date. */
export function dayMonth(iso: string): string {
const [, m, d] = iso.split("-").map(Number);
return `${d} ${MONTHS[m - 1]}`;
}