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
+12 -1
View File
@@ -1,4 +1,13 @@
import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply, TodayPlan } from "./types";
import type {
AuthResult,
Catalog,
CrisisResource,
DemoPersona,
DialogEvent,
MedsReference,
Reply,
TodayPlan,
} from "./types";
// Web: same-origin /api/v1 (Vite proxy in dev). Mobile shell: set VITE_API_BASE to the absolute API URL at build.
const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1";
@@ -35,6 +44,8 @@ export const api = {
signInAnonymous: () => post<AuthResult>("/auth/anonymous"),
demoPersonas: () => request<DemoPersona[]>("/demo/personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
catalog: () => request<Catalog>("/instruments/catalog"),
medsReference: () => request<MedsReference>("/meds/reference"),
today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`),
sendEvent: async (token: string, event: DialogEvent) =>
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
+82
View File
@@ -50,11 +50,23 @@ export interface TodayDose {
status: DoseStatus;
}
export type Indication = "depression" | "ptsd";
export interface TodayPrescription {
drug: string;
dose: string;
times: string[];
week: { day: string; status: "taken" | "missed" | "pending" | "none" }[];
ref_id: string | null;
indication: Indication | null;
start_date: string | null;
therapy_day: number | null;
}
export interface Questionnaires {
enabled: boolean;
intake: "none" | "active" | "paused" | "complete" | "review";
diary_done: boolean;
}
export interface TodayPlan {
@@ -65,4 +77,74 @@ export interface TodayPlan {
next_visit: string | null;
doses: TodayDose[];
prescriptions: TodayPrescription[];
questionnaires: Questionnaires;
}
// app/core/instrument_catalog.py
export type Section = "safety" | "mood" | "trauma" | "alcohol" | "about" | "diary" | "scale";
export interface FormInfo {
id: string;
title: string;
section: Section;
recall_period: string | null;
instruction: string | null;
}
export interface ItemInfo {
form: string;
index: number;
total: number;
adaptive: boolean;
optional: boolean;
numeric: boolean;
}
export interface Catalog {
forms: Record<string, FormInfo>;
items: Record<string, ItemInfo>;
standard_drinks: { title: string; header: string[]; rows: [string, string[]][]; note: string } | null;
}
// data/meds/meds_reference.json (draft from the Minzdrav guidelines)
export interface DrugIndication {
off_label: boolean;
expected_response: string | null;
course: string | null;
source: string;
}
export interface DrugRef {
id: string;
name_ru: string;
group: string;
class: string;
indications: Partial<Record<Indication, DrugIndication>>;
diary_items: string[];
diary_items_source: string;
red_flags: string[];
monitoring: string[];
withdrawal_risk: "high" | "low" | null;
overdose_danger: boolean;
max_course_days: number | null;
note?: string;
}
export interface MedsReference {
version: string;
status: string;
sources: { id: string; title: string; year: number }[];
drugs: DrugRef[];
texts: Record<
| "expect_effect_depression"
| "expect_effect_ptsd"
| "dont_stop_alone"
| "why_continue_depression"
| "withdrawal_missed"
| "ran_out"
| "red_flag"
| "side_effects_general"
| "instruction_fallback",
string
>;
}
+13
View File
@@ -121,6 +121,19 @@ const paths = {
),
message: <path d="M4 5.5h16v11H9.5L5 20v-3.5H4z" />,
plus: <path d="M12 5v14M5 12h14" />,
clipboard: (
<>
<rect x="5" y="5" width="14" height="16" rx="2" />
<path d="M9 3.5h6v3H9zM8.5 11h7M8.5 14.5h7M8.5 18h4" />
</>
),
book: (
<>
<path d="M5 4.5h10.5A2.5 2.5 0 0 1 18 7v13H7.5A2.5 2.5 0 0 1 5 17.5z" />
<path d="M5 17.5A2.5 2.5 0 0 1 7.5 15H18M9 8.5h5" />
</>
),
pause: <path d="M9 6v12M15 6v12" />,
} as const;
export type IconName = keyof typeof paths;
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { Catalog, MedsReference } from "../api/types";
/*
* Static reference data from the server (questionnaire catalog, drug reference): fetched once per app run and
* shared by every screen. A failed fetch is retried on the next mount; screens work without it, only less rich.
*/
function cached<T>(load: () => Promise<T>) {
let value: T | null = null;
let inflight: Promise<T> | null = null;
const get = () => {
inflight ??= load().then(
(v) => (value = v),
(e) => {
inflight = null;
throw e;
},
);
return inflight;
};
return function useCached(): T | null {
const [v, setV] = useState<T | null>(value);
useEffect(() => {
if (value) return;
let alive = true;
get()
.then((x) => alive && setV(x))
.catch(() => undefined);
return () => {
alive = false;
};
}, []);
return v;
};
}
export const useCatalog = cached<Catalog>(api.catalog);
export const useMedsReference = cached<MedsReference>(api.medsReference);
+59
View File
@@ -104,6 +104,20 @@ export const ru = {
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.",
newMessage: "Новое сообщение",
open: "Открыть",
continue: "Продолжить",
surveyStep: (section: string, n: number) => `${section} · вопрос ${n}`,
intakeStart: "Первичный опрос",
intakeStartHint: "15–20 минут, можно прерваться и продолжить позже",
intakeResume: "Продолжить первичный опрос",
intakeResumeHint: "Ответы сохранены, можно продолжить",
intakeReview:
"Опрос приостановлен: продолжить можно после оценки специалиста, врач уже получил уведомление. Если нужна помощь сейчас — 112.",
intakeDone: "Первичный опрос пройден",
diary: "Дневник самочувствия",
diaryHint: "2–3 минуты: настроение, сон, лекарства",
diaryDone: "Дневник заполнен",
therapyDay: (day: number) => `День ${day} лечения`,
effectHint: "Когда ждать эффекта и что важно знать",
},
meds: {
title: "Моя схема приёма",
@@ -119,6 +133,32 @@ export const ru = {
taken: "Принял(а)",
skip: "Пропущу",
sideEffects: "Есть побочные эффекты",
about: "О препарате",
reportSideEffect: "Сообщить о побочном эффекте",
therapyDay: (day: number) => `День лечения: ${day}`,
card: {
back: "К схеме приёма",
prescribed: "Назначено",
effect: "Когда ждать эффекта",
effectAt: (when: string) => `Эффект оценивают через ${when}`,
progress: (day: number) => `Вы принимаете препарат ${day} ${day % 10 === 1 && day % 100 !== 11 ? "день" : [2, 3, 4].includes(day % 10) && ![12, 13, 14].includes(day % 100) ? "дня" : "дней"}`,
course: "Сколько принимать",
courseShort: (days: number) => `Обычно не дольше ${Math.round(days / 7)} недель — срок определяет врач`,
sideEffects: "Что может беспокоить",
sideEffectsHint:
"Побочные эффекты часто ослабевают в первые недели. Если они мешают жить, сообщите врачу — он может изменить дозу или подобрать другой препарат. Не меняйте схему сами.",
sideEffectsNone: "Частые побочные эффекты в клинических рекомендациях не описаны — спросите врача.",
redFlags: "Когда срочно к врачу",
important: "Важно",
withdrawal: "Резкая отмена этого препарата может вызвать неприятные ощущения: головокружение, раздражительность, нарушения сна. Отменяют его постепенно и только с врачом.",
overdose: "Храните препарат в недоступном для других месте и принимайте строго в назначенной дозе.",
monitoring: "Врач может назначить обследования",
instruction: "Как принимать, что делать при пропуске, алкоголь и вождение",
ask: "Задать вопрос о лекарстве",
source: (sources: string) => `Источник: ${sources}. Черновик справочника, проверяется врачом-психиатром.`,
offLabel: "Назначение вне зарегистрированных показаний — так бывает, решение принимает врач.",
unknown: "Справки по этому препарату пока нет. Уточните у врача или в инструкции к препарату.",
},
},
doctor: {
title: "Врач",
@@ -163,6 +203,25 @@ export const ru = {
exit: "К главной",
planTitle: "Мой план безопасности",
consentTitle: "Согласие на обработку данных о здоровье",
sections: {
safety: "Безопасность",
mood: "Настроение",
trauma: "Пережитый опыт",
alcohol: "Алкоголь",
about: "О вас",
diary: "Дневник",
scale: "Опросник",
},
questionN: (n: number) => `Вопрос ${n}`,
recall: (period: string) => `Период: ${period}`,
asOf: (date: string) => `Считайте от ${date}`,
saved: "Ответы сохраняются после каждого вопроса",
hoursLabel: "Сколько часов",
hoursUnit: "ч",
hoursHint: "От 0 до 24, можно с половиной: 6,5",
drinks: "Таблица стандартных порций",
drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.",
noticeTitle: "Опрос приостановлен",
},
messages: {
title: "Сообщения",
+77 -3
View File
@@ -12,8 +12,27 @@ export type Step =
| { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] }
| { kind: "number"; lead: string[]; title: string; options: DialogButton[] }
| { kind: "actions"; lead: string[]; title: string; options: DialogButton[] }
| { kind: "choice"; lead: string[]; title: string; options: DialogButton[]; progress?: Progress }
| { kind: "text"; lead: string[]; title: string; skip?: DialogButton };
| { kind: "choice"; lead: string[]; title: string; options: DialogButton[]; progress?: Progress; formId?: string }
| { kind: "text"; lead: string[]; title: string; skip?: DialogButton }
| Survey
// A questionnaire stopped: paused by the patient (resume button) or held for a specialist (help button only).
| { kind: "notice"; text: string[]; primary?: DialogButton; help?: DialogButton };
/** One item of a versioned questionnaire (app/dialog/flows/intake.py → question_reply). */
export interface Survey {
kind: "survey";
itemId: string;
instruction: string[]; // form instruction / recall period above the question
title: string; // the item text, verbatim
footnotes: string[]; // «*…» explanations printed under the item on the paper form
options: DialogButton[];
input: "choice" | "number" | "hours" | "text";
anchors?: string[]; // «0 — самое плохое», «10 — самое хорошее»: the scale ends as printed in the item
asOf?: string; // ISO date the recall period counts from
skip?: DialogButton;
help?: DialogButton;
pause?: DialogButton;
}
export interface Progress {
n: number;
@@ -24,6 +43,12 @@ export interface Progress {
const MENU_PROMPT = "Чем могу помочь?";
// config/texts/dialog.yaml → scale.question: "{n}/{total}. {text}"
const SCALE_ITEM = /^(\d+)\/(\d+)\.\s+([\s\S]+)$/;
// config/texts/intake.yaml → as_of: "Период отсчитывается от даты начала этого опроса: {day}."
const AS_OF = /^Период отсчитывается[^:]*:\s*(\d{4}-\d{2}-\d{2})\.?$/;
// config/texts/intake.yaml → standard_drinks: printed inside the RUS-AUDIT item 2 instruction; shown as a table.
const DRINKS_START = "Стандартная порция";
const ANCHORS = /^\d+\s*—/;
const IQ = /^iq:[^:]+:([^:]+):(\d+|skip)$/;
const isMenu = (r: Reply) => r.buttons.flat().some((b) => b.data.startsWith("menu:"));
const flat = (r: Reply) => r.buttons.flat();
@@ -44,6 +69,48 @@ function titled(texts: string[]): { lead: string[]; title: string; progress?: Pr
return { lead: parts, title: last };
}
function survey(batch: Reply[], qIndex: number): Survey | null {
const buttons = flat(batch[qIndex]);
const iq = buttons.map((b) => IQ.exec(b.data)).find(Boolean);
const help = buttons.find((b) => b.data === "intake:help");
const pause = buttons.find((b) => b.data === "intake:pause");
if (!pause || !help) return null;
// A free-text / numeric item has no answer buttons: the item id is then unknown, the text is still verbatim.
const itemId = iq?.[1] ?? "";
const options = buttons.filter((b) => IQ.test(b.data) && !b.data.endsWith(":skip"));
const skip = buttons.find((b) => b.data.startsWith("iq:") && b.data.endsWith(":skip"));
let asOf: string | undefined;
const parts = paragraphs(batch[qIndex].text).filter((p) => {
const m = AS_OF.exec(p);
if (m) asOf = m[1];
return !m;
});
const footnotes = parts.filter((p) => p.startsWith("*"));
const body = parts.filter((p) => !p.startsWith("*"));
let title = body.pop() ?? "";
let anchors: string[] | undefined;
if (ANCHORS.test(title) && body.length) {
anchors = title.replace(/\.$/, "").split(/,\s*(?=\d+\s*—)/);
title = body.pop() ?? "";
}
const instruction = body
.map((p) => (p.includes(DRINKS_START) ? p.slice(0, p.indexOf(DRINKS_START)).trim() : p))
.filter(Boolean);
// Replies before the question in the same batch (e.g. «invalid answer» note) stay visible above it.
const before = batch.slice(0, qIndex).map((r) => r.text.trim()).filter(Boolean);
const input =
options.length >= 5 && options.every((b) => /^\d{1,2}$/.test(b.label.trim()))
? "number"
: options.length
? "choice"
: skip
? "text"
: "hours";
return { kind: "survey", itemId, instruction: [...before, ...instruction], title, footnotes, options, input, anchors, asOf, skip, help, pause };
}
export function classify(batch: Reply[]): Step | null {
if (batch.length === 0) return null;
@@ -79,6 +146,12 @@ export function classify(batch: Reply[]): Step | null {
return { kind: "text", lead, title };
}
const options = flat(batch[qIndex]);
const s = survey(batch, qIndex);
if (s) return s;
const resume = options.find((b) => b.data === "intake:resume" || b.data === "intake:start");
const helpOnly = options.length === 1 && options[0].data === "intake:help";
if ((resume && options.length === 1) || helpOnly)
return { kind: "notice", text: texts.filter(Boolean), primary: resume, help: helpOnly ? options[0] : undefined };
const head = titled(texts.slice(0, qIndex + 1));
const tail = texts.slice(qIndex + 1); // rare: info after the question
const lead = [...head.lead, ...tail];
@@ -90,7 +163,8 @@ export function classify(batch: Reply[]): Step | null {
return { kind: "number", lead, title: head.title, options };
if (options.some((b) => /^med:(taken|skip|side_effects):/.test(b.data)))
return { kind: "actions", lead, title: head.title, options };
return { kind: "choice", lead, title: head.title, options, progress: head.progress };
const formId = /^scale:([^:]+):/.exec(options[0]?.data ?? "")?.[1];
return { kind: "choice", lead, title: head.title, options, progress: head.progress, formId };
}
/** Blocks of an engine text: «• item» lines become list items; wrapped plain lines are joined into paragraphs. */
+2
View File
@@ -13,6 +13,7 @@ import { HelpProvider } from "./components/HelpSheet";
import { DialogProvider } from "./dialog/DialogContext";
import { AnonymousSignIn } from "./screens/AnonymousSignIn";
import { Doctor } from "./screens/Doctor";
import { DrugInfo } from "./screens/DrugInfo";
import { EmiasSignIn } from "./screens/EmiasSignIn";
import { Flow } from "./screens/Flow";
import { DoseReminder, Meds } from "./screens/Meds";
@@ -39,6 +40,7 @@ function AppRoutes() {
<Route path="/today" element={<Private><Today /></Private>} />
<Route path="/dose/:id" element={<Private><DoseReminder /></Private>} />
<Route path="/meds" element={<Private><Meds /></Private>} />
<Route path="/meds/:ref" element={<Private><DrugInfo /></Private>} />
<Route path="/doctor" element={<Private><Doctor /></Private>} />
<Route path="/profile" element={<Private><Profile /></Private>} />
<Route path="/messages" element={<Private><Messages /></Private>} />
+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]}`;
}
+394 -4
View File
@@ -782,7 +782,7 @@ button.row-card:hover:not(:disabled) {
.numscale__row {
display: grid;
grid-template-columns: repeat(11, 1fr);
grid-template-columns: repeat(var(--n, 11), 1fr);
gap: 4px;
}
@@ -1900,14 +1900,16 @@ button.row-card:hover:not(:disabled) {
/* Wide content (consent text, 0–10 scale) reads better in one centred column. */
.split--consent,
.split--number {
.split--number,
.split--in-number {
grid-template-columns: minmax(0, 1fr);
max-width: 760px;
gap: var(--s-6);
}
.split--consent .split__lead,
.split--number .split__lead {
.split--number .split__lead,
.split--in-number .split__lead {
position: static;
}
@@ -1915,7 +1917,8 @@ button.row-card:hover:not(:disabled) {
grid-template-columns: 1fr 1fr;
}
.split--number .numscale__row {
.split--number .numscale__row,
.split--in-number .numscale__row {
gap: var(--s-2);
}
@@ -2035,6 +2038,393 @@ button.row-card:hover:not(:disabled) {
}
}
/* ------------------------------------------------------------------ questionnaires (stage 1 / stage 2) */
.formchip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2) var(--s-3);
}
.formchip__title {
font-size: var(--fs-13);
color: var(--c-text-2);
}
.chip--sec {
gap: 6px;
padding: 5px var(--s-3) 5px 10px;
background: var(--c-blue-soft);
color: var(--c-blue);
}
.chip--mood,
.chip--diary {
background: var(--c-teal-soft);
color: var(--c-teal-strong);
}
.chip--trauma {
background: var(--c-amber-soft);
color: #a86a00;
}
.chip--alcohol,
.chip--about {
background: var(--c-navy-soft);
color: var(--c-navy);
}
.survey {
gap: var(--s-3);
}
.survey__instr {
padding-left: var(--s-3);
border-left: 3px solid var(--c-border-strong);
color: var(--c-text-2);
}
.survey__instr .body-text {
font-size: var(--fs-16);
}
.survey__q {
font-size: var(--fs-24);
line-height: 1.3;
}
.survey__asof {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-13);
color: var(--c-text-2);
}
.survey__aux {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
padding-top: var(--s-2);
border-top: 1px solid var(--c-border);
}
.survey__saved {
margin: 0;
}
.btn--sm {
width: auto;
min-height: 40px;
padding: 0 var(--s-3);
font-size: var(--fs-14);
}
.linkbtn {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 0 var(--s-1);
border: 0;
background: none;
font-weight: 600;
font-size: var(--fs-14);
text-decoration: underline;
text-underline-offset: 3px;
}
.linkbtn--danger {
color: var(--c-red-strong);
}
.btn--ghost-red {
background: transparent;
color: var(--c-red-strong);
}
.btn--outline-red {
background: var(--c-surface);
border-color: var(--c-red);
color: var(--c-red-strong);
}
.center-self {
align-self: center;
justify-self: center;
}
/* RUS-AUDIT item 2: standard-drink table from the approved form */
.drinks {
display: grid;
gap: var(--s-2);
padding: var(--s-4);
border: 1px solid var(--c-border);
border-radius: var(--r-tile);
background: var(--c-surface);
}
.drinks__scroll {
overflow-x: auto;
margin: 0 calc(-1 * var(--s-2));
}
.drinks table {
width: 100%;
min-width: 420px;
border-collapse: collapse;
font-size: var(--fs-13);
font-variant-numeric: tabular-nums;
}
.drinks th,
.drinks td {
padding: 8px;
text-align: left;
border-bottom: 1px solid var(--c-border);
vertical-align: top;
}
.drinks thead th {
color: var(--c-text-2);
font-weight: 600;
}
.drinks tbody th {
white-space: nowrap;
color: var(--c-navy);
}
/* diary: hours of sleep */
.hours {
display: grid;
gap: var(--s-2);
}
.hours__label {
font-weight: 600;
}
.hours__field {
display: flex;
align-items: center;
gap: var(--s-2);
width: 180px;
padding: 0 var(--s-4);
border: 1.5px solid var(--c-border-strong);
border-radius: var(--r-btn);
background: var(--c-surface);
}
.hours__field:focus-within {
border-color: var(--c-focus);
box-shadow: 0 0 0 3px var(--c-blue-soft);
}
.hours__field input {
flex: 1;
min-width: 0;
height: var(--tap);
border: 0;
outline: none;
background: none;
font-size: var(--fs-28);
font-weight: 700;
color: var(--c-navy);
}
.hours__field span {
font-size: var(--fs-18);
color: var(--c-text-2);
}
.hours .btn {
margin-top: var(--s-2);
}
/* ------------------------------------------------------------------ medication: drug card, therapy day */
.rx__foot {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
padding-top: var(--s-3);
border-top: 1px solid var(--c-border);
}
.effect {
display: flex;
align-items: center;
gap: var(--s-4);
width: 100%;
padding: var(--s-3) var(--s-4);
border: 1px solid #cfe0f5;
border-radius: var(--r-tile);
background: var(--c-blue-soft);
text-align: left;
color: var(--c-text);
}
.effect > svg:last-child {
flex: none;
color: var(--c-blue);
}
.effect__day {
flex: none;
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: var(--c-surface);
color: var(--c-blue);
line-height: 1;
}
.effect__num {
font-size: var(--fs-24);
font-weight: 700;
}
.effect__unit {
font-size: var(--fs-12);
color: var(--c-text-2);
}
.drug {
display: grid;
gap: var(--s-6);
}
.drug__head {
display: flex;
align-items: center;
gap: var(--s-4);
}
.drug__head .tile-icon--xl {
width: 72px;
height: 72px;
}
.drug__rx {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: var(--s-2);
padding: 6px var(--s-3);
border-radius: var(--r-chip);
background: var(--c-teal-soft);
color: var(--c-teal-strong);
font-size: var(--fs-14);
font-weight: 600;
}
.drug__grid,
.drug__col {
display: grid;
gap: var(--s-4);
align-content: start;
}
.drug__sec {
display: grid;
gap: var(--s-3);
padding: var(--s-5);
border: 1px solid var(--c-border);
border-radius: var(--r-tile);
background: var(--c-surface);
}
.drug__sec p {
margin: 0;
line-height: 1.5;
}
.drug__sec--red {
border-color: var(--c-red-line);
background: var(--c-red-soft);
}
.drug__h {
display: flex;
align-items: center;
gap: var(--s-3);
margin: 0;
font-size: var(--fs-18);
font-weight: 700;
}
.drug__sub {
font-weight: 600;
}
.badge-icon--blue {
background: var(--c-blue);
}
.timeline {
display: grid;
gap: var(--s-2);
}
.timeline__bar {
position: relative;
height: 10px;
border-radius: 999px;
background: var(--c-tile);
border: 1px solid var(--c-border);
}
.timeline__fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--c-teal-soft), var(--c-teal));
}
.timeline__mark {
position: absolute;
top: 50%;
width: 18px;
height: 18px;
border: 3px solid var(--c-surface);
border-radius: 50%;
background: var(--c-teal);
box-shadow: 0 1px 3px rgb(15 39 71 / 25%);
transform: translate(-50%, -50%);
}
.timeline__legend {
display: flex;
justify-content: space-between;
gap: var(--s-3);
font-size: var(--fs-13);
color: var(--c-text-2);
}
@media (max-width: 1023px) {
.btn-row--stack-phone {
grid-template-columns: 1fr;
}
}
@media (min-width: 1024px) {
.drug__grid {
grid-template-columns: 1fr 1fr;
gap: var(--s-5);
}
.survey__q {
font-size: var(--fs-28);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,