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
+3
View File
@@ -22,6 +22,9 @@ DOCTOR_NOTIFY_CHAT_ID=
# --- Demo virtual clock (ADR-005)
DEMO_CLOCK=true
# Questionnaires of the intake (#53) on synthetic data, demo only (needs DEMO_CLOCK). The clinical release gate in
# config/intake_runtime.yaml stays closed; without a signed routing policy every finished intake goes to review.
INTAKE_DEMO=false
TZ=Europe/Moscow
# --- Gitea (for Claude Code / team workflow, not used by the app)
+82 -11
View File
@@ -1,4 +1,5 @@
"""Patient web app: sign-in and public reference data (`/api/v1/auth/*`, `/api/v1/crisis/resources`).
"""Patient web app: sign-in, the patient's own plan and public reference data (`/api/v1/auth/*`, `/me/*`,
`/crisis/resources`, `/instruments/catalog`, `/meds/reference`).
The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...`
exactly like the web console, and consent, onboarding, scales and check-ins come from the engine.
@@ -18,7 +19,8 @@ import logging
import secrets
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from typing import Literal
from typing import Any, Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field
@@ -26,10 +28,14 @@ from sqlalchemy import select
from app.api import fixtures
from app.core import clock
from app.core.instrument_catalog import Catalog, catalog
from app.core.intake_runtime import intake_enabled, load_runtime
from app.core.meds_reference import find_drug, indication_for, load_reference
from app.core.safety import CrisisResource, crisis_reply
from app.db import repo
from app.db.clinical import DailyCheckin, Dose, Prescription
from app.db.identity import TOKEN_LEN
from app.db.intake import QuestionnaireRecord
from app.db.session import get_clinical_session
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
@@ -80,15 +86,21 @@ async def _seed_demo_persona(token: str) -> None:
token=token,
doctor_id=fixtures.DOCTOR_ID,
modules=list(item.modules),
therapy_start=today,
therapy_start=min((p.start_date for p in card.prescriptions if p.start_date), default=today),
next_visit=today + timedelta(days=15),
)
if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None:
return
for p in card.prescriptions:
rx = Prescription(token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=today)
rx = Prescription(
token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=p.start_date or today
)
s.add(rx)
await s.flush()
# The dose history of the dashboard story (days before today), so the week marks match the doctor's card.
for d in card.doses:
if d.drug == p.drug and d.planned_at.astimezone(now.tzinfo).date() < today:
s.add(Dose(token=token, prescription_id=rx.id, planned_at=d.planned_at, status=d.status))
for t in p.schedule.get("times", []):
hh, mm = (int(x) for x in t.split(":"))
s.add(
@@ -147,6 +159,18 @@ class PrescriptionItem(BaseModel):
dose: str
times: list[str]
week: list[WeekDay] # Mon..Sun of the current week
ref_id: str | None = None # entry of /meds/reference, if the drug is covered
indication: Literal["depression", "ptsd"] | None = None
start_date: date | None = None
therapy_day: int | None = None # 1 on the start date (spec: M5, «ожидаемый эффект» cards on days 1/7/14)
class Questionnaires(BaseModel):
"""Questionnaire state for the home screen. Answers and scores are not exposed here."""
enabled: bool
intake: Literal["none", "active", "paused", "complete", "review"]
diary_done: bool
class TodayOut(BaseModel):
@@ -157,6 +181,19 @@ class TodayOut(BaseModel):
next_visit: date | None
doses: list[DoseItem]
prescriptions: list[PrescriptionItem]
questionnaires: Questionnaires
def _intake_state(records: list[QuestionnaireRecord]) -> Literal["none", "active", "paused", "complete", "review"]:
intake = [r for r in records if r.kind == "intake"]
if any(r.status == "complete" for r in intake):
return "complete"
if not intake:
return "none"
last = max(intake, key=lambda r: r.started_at).status
if last in ("active", "paused"):
return last
return "review" if last in ("review_required", "interrupted") else "none"
def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]:
@@ -187,6 +224,7 @@ async def my_today(token: str) -> TodayOut:
)
).all()
checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today))
records = list((await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all())
by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions}
today_doses: list[DoseItem] = []
@@ -200,23 +238,40 @@ async def my_today(token: str) -> TodayOut:
DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status)
)
week = [week_start + timedelta(days=i) for i in range(7)]
modules = list(profile.modules or []) if profile else []
diary_day = now.astimezone(ZoneInfo(load_runtime().timezone)).date()
diary_done = any(r.kind == "diary" and r.day == diary_day and r.status == "complete" for r in records)
def item(p: Prescription) -> PrescriptionItem:
ref = find_drug(p.drug)
return PrescriptionItem(
drug=p.drug,
dose=p.dose,
times=list((p.schedule or {}).get("times", [])),
week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week],
ref_id=ref["id"] if ref else None,
indication=indication_for(modules, ref) if ref else None,
start_date=p.start_date,
therapy_day=(today - p.start_date).days + 1 if p.start_date else None,
)
return TodayOut(
today=today,
now=now.strftime("%H:%M"),
has_doctor=profile is not None,
checkin_done=checkin is not None and checkin.mood is not None,
checkin_done=(checkin is not None and checkin.mood is not None) or diary_done,
next_visit=profile.next_visit if profile else None,
doses=today_doses,
prescriptions=[
PrescriptionItem(
drug=p.drug,
dose=p.dose,
times=list((p.schedule or {}).get("times", [])),
week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week],
)
item(p)
for p in prescriptions
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
],
questionnaires=Questionnaires(
enabled=intake_enabled() and profile is not None,
intake=_intake_state(records),
diary_done=diary_done,
),
)
@@ -224,3 +279,19 @@ async def my_today(token: str) -> TodayOut:
async def crisis_resources() -> list[CrisisResource]:
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
return crisis_reply([], "patient").resources
@router.get("/instruments/catalog", response_model=Catalog)
async def instruments_catalog() -> Catalog:
"""Form, position and recall period of every questionnaire item, for the question screen header."""
return catalog()
@router.get("/meds/reference")
async def meds_reference() -> dict[str, Any]:
"""Patient drug reference (draft from the Minzdrav guidelines): expected effect, course, side effects, red flags.
The internal rule parameters stay on the server.
"""
ref = load_reference()
return {k: v for k, v in ref.items() if k != "rules"}
+138
View File
@@ -0,0 +1,138 @@
"""Read-only catalog of questionnaire items for patient clients (web app).
The dialog engine sends a questionnaire question as plain text plus buttons; the button payload carries the item id
(`iq:<nonce>:<item>:<i>`, `scale:<scale>:<item>:<v>`). A client looks the item up here to show which form it belongs
to, its position and recall period. Nothing here changes what is asked or how it is scored — the texts stay those of
the versioned sources in config/.
"""
from functools import lru_cache
from typing import Any, Literal
import yaml
from pydantic import BaseModel
from app.config import get_settings
from app.core.cssrs_content import load_cssrs_russian
from app.core.intake_questions import content, texts
from app.core.scales import load_scale
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
# Forms whose item order depends on earlier answers: a client shows the position without «из N».
ADAPTIVE = {"cssrs", "rus_audit", "diary", "context"}
class FormInfo(BaseModel):
id: str
title: str
section: Section
recall_period: str | None = None
instruction: str | None = None
class ItemInfo(BaseModel):
form: str
index: int # 1-based position in the form
total: int
adaptive: bool
optional: bool = False
numeric: bool = False
class DrinksTable(BaseModel):
"""RUS-AUDIT item 2 standard-drink table, exactly as in config/texts/intake.yaml (the approved form)."""
title: str
header: list[str]
rows: list[tuple[str, list[str]]]
note: str
class Catalog(BaseModel):
forms: dict[str, FormInfo]
items: dict[str, ItemInfo]
standard_drinks: DrinksTable | None
def _drinks() -> DrinksTable | None:
raw = texts().get("standard_drinks")
if not raw:
return None
lines = [line.strip() for line in raw.splitlines() if line.strip()]
title, header_line, *rest = lines
rows = [line for line in rest if ":" in line]
note = " ".join(line for line in rest if ":" not in line)
return DrinksTable(
title=title,
header=[h.strip() for h in header_line.split("·")],
rows=[
(label.strip(), [c.strip() for c in cells.split("·")]) for label, cells in (r.split(":", 1) for r in rows)
],
note=note,
)
@lru_cache(maxsize=1)
def catalog() -> Catalog:
forms: dict[str, FormInfo] = {}
items: dict[str, ItemInfo] = {}
def add(form: FormInfo, ids: list[str], *, optional: set[str] = frozenset(), numeric: set[str] = frozenset()):
forms[form.id] = form
for i, item in enumerate(ids, 1):
items.setdefault(
item,
ItemInfo(
form=form.id,
index=i,
total=len(ids),
adaptive=form.id in ADAPTIVE,
optional=item in optional,
numeric=item in numeric,
),
)
cssrs = load_cssrs_russian()
add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items])
intake_forms: dict[str, Any] = content()["forms"]
for sid, section in (("phq9", "mood"), ("tsq", "trauma"), ("pcl5", "trauma"), ("rus_audit", "alcohol")):
raw = intake_forms.get(sid) or {}
scale = load_scale(sid)
add(
FormInfo(
id=sid,
title=raw.get("title") or scale.title,
section=section,
recall_period=raw.get("recall_period") or scale.recall_period,
instruction=raw.get("instruction") or scale.instruction,
),
list(scale.item_ids),
)
add(FormInfo(id="trauma", title="Травматическое событие", section="trauma"), ["trauma", "recent_trauma"])
add(FormInfo(id="context", title="Общие вопросы", section="about"), ["sex", "tbi", "psychotropics"])
diary = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
diary_items = [item for key in ("core", "ptsd", "depression", "suicide") for item in diary.get(key, [])]
add(
FormInfo(id="diary", title="Дневник самочувствия", section="diary"),
[i["id"] for i in diary_items],
optional={i["id"] for i in diary_items if i.get("optional")},
numeric={i["id"] for i in diary_items if i.get("numeric")},
)
# Scales of the onboarding / scheduled sessions (`scale:<id>:...` buttons): the form header only.
for path in sorted((get_settings().config_dir / "scales").glob("*.yaml")):
scale = load_scale(path.stem)
forms.setdefault(
scale.id,
FormInfo(
id=scale.id,
title=scale.title,
section="scale",
recall_period=scale.recall_period,
instruction=scale.instruction,
),
)
return Catalog(forms=forms, items=items, standard_drinks=_drinks())
+11 -1
View File
@@ -1,5 +1,6 @@
"""Versioned release gate and deterministic follow-up planning."""
import os
from calendar import monthrange
from datetime import date, timedelta
from pathlib import Path
@@ -64,8 +65,17 @@ def load_runtime(path: Path | None = None) -> RuntimePolicy:
return RuntimePolicy.model_validate(yaml.safe_load(path.read_text()))
def demo_intake() -> bool:
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): shows the questionnaires on synthetic data.
It never touches the clinical release gate in config/intake_runtime.yaml; production runs keep the forms off
until the approvals are recorded there.
"""
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
def intake_enabled() -> bool:
return load_runtime().released and load_policy().approval is not None
return (load_runtime().released and load_policy().approval is not None) or demo_intake()
def month_before(day: date, months: int = 1) -> date:
+35
View File
@@ -0,0 +1,35 @@
"""Drug reference of the medication module (data/meds/meds_reference.json, built by scripts/build_meds_reference.py).
Read-only patient information collected from the Minzdrav clinical guidelines: when to expect the effect, how long
the course lasts, common side effects, red flags. It never suggests a drug or a dose — the app only follows the
doctor's prescription. Draft until reviewed by a psychiatrist (`status` in the file).
"""
import json
from functools import lru_cache
from typing import Any, Literal
from app.config import get_settings
Indication = Literal["depression", "ptsd"]
@lru_cache(maxsize=1)
def load_reference() -> dict[str, Any]:
path = get_settings().data_dir / "meds" / "meds_reference.json"
return json.loads(path.read_text(encoding="utf-8"))
def find_drug(name: str) -> dict[str, Any] | None:
"""Reference entry for a prescription's drug (INN in Russian or the latin id), or None if it is not covered."""
key = name.strip().split()[0].casefold() if name.strip() else ""
for drug in load_reference()["drugs"]:
if key in (drug["id"], drug["name_ru"].casefold()):
return drug
return None
def indication_for(modules: list[str], drug: dict[str, Any]) -> Indication | None:
"""The indication the patient is followed for, among those the drug has in the guidelines."""
preferred: list[Indication] = ["ptsd", "depression"] if "veteran" in modules else ["depression", "ptsd"]
return next((i for i in preferred if i in drug["indications"]), None)
File diff suppressed because it is too large Load Diff
+542
View File
@@ -0,0 +1,542 @@
"""Builds data/meds/meds_reference.json — the drug reference of the medication module.
Source of every value: the two Minzdrav clinical guidelines in config/rag/corpus/ —
* «Депрессивный эпизод, рекуррентное депрессивное расстройство» (2024): прил. А3 табл. 2 (дозы),
табл. 3 (профили побочных эффектов), разд. 3.1–3.6, прил. Б5, В;
* «Посттравматическое стрессовое расстройство» (2023, ID 753): разд. 3.2, прил. А3 (дозы).
Patient side-effect items: effects rated «++»/«+++» in табл. 3 (antidepressants) or named in the КР text
(other drugs), worded for a patient. Nothing here is a recommendation: the app only follows the doctor's
prescription (module spec, «Границы»). Texts and draft parameters must be reviewed by a psychiatrist.
Run: uv run python -m scripts.build_meds_reference
"""
import json
from pathlib import Path
OUT = Path(__file__).resolve().parents[1] / "data" / "meds" / "meds_reference.json"
DEP = "КР депрессия 2024"
PTSD = "КР ПТСР 2023 (ID 753)"
# Patient wording of the КР табл. 3 columns (and of effects named in the КР text).
SE = {
"anticholinergic": "Сухость во рту или запоры",
"nausea": "Тошнота",
"sedation": "Сонливость днём",
"insomnia": "Бессонница или беспокойство",
"sexual": "Сексуальные проблемы",
"orthostatic": "Головокружение при вставании",
"weight": "Прибавка веса",
"headache": "Головная боль",
"dizziness": "Головокружение",
"coordination": "Нарушение координации",
"memory": "Снижение внимания и памяти",
"akathisia": "Беспокойство, невозможность усидеть на месте",
"eps": "Скованность или дрожь в теле",
"skin": "Кожная сыпь или зуд",
}
RF_BLEEDING = "Необычные кровотечения или синяки"
RF_EARLY = "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма"
RF_JAUNDICE = "Желтушность кожи или глаз"
RF_RASH = "Кожная сыпь"
RF_PRIAPISM = "Длительная болезненная эрекция"
RF_UTERINE = "Маточное кровотечение"
TCA_MON = "Артериальное давление и ЭКГ"
AP_MON = "Вес и признаки метаболического синдрома"
def ad(id_, name, cls, start, lo, hi, items, *, ptsd=None, red=(), mon=(), withdrawal=None, overdose=False, note=None):
"""Antidepressant: doses from КР депрессия табл. 2, side effects from табл. 3."""
d = {
"id": id_,
"name_ru": name,
"group": "Антидепрессант",
"class": cls,
"indications": {
"depression": {
"start_mg": start,
"min_mg": lo,
"max_mg": hi,
"off_label": False,
"expected_response": "3–4 недели",
"course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев",
"source": f"{DEP}, прил. А3 табл. 2; разд. 3.1",
}
},
"diary_items": [SE[i] for i in items],
"diary_items_source": f"{DEP}, прил. А3 табл. 3 (++/+++)",
"red_flags": [RF_EARLY, *red],
"monitoring": list(mon),
"withdrawal_risk": withdrawal,
"overdose_danger": overdose,
"max_course_days": None,
}
if ptsd:
d["indications"]["ptsd"] = ptsd
if note:
d["note"] = note
return d
def ptsd_ind(lo, hi, off_label, start=None):
return {
"start_mg": start,
"min_mg": lo,
"max_mg": hi,
"off_label": off_label,
"expected_response": "4–6 недель",
"course": "Обычно 6–12 месяцев после стабилизации",
"source": f"{PTSD}, разд. 3.2; прил. А3",
}
def other(
id_, name, cls, lo, hi, off_label, items, *, red=(), mon=(), withdrawal=None, course_days=None, src, note=None
):
"""Non-antidepressant used for PTSD: doses from КР ПТСР прил. А3 / разд. 3.2, effects named in the text."""
ind = ptsd_ind(lo, hi, off_label)
ind["expected_response"] = None
ind["course"] = None
d = {
"id": id_,
"name_ru": name,
"group": next(g for key, g in GROUPS.items() if key in cls),
"class": cls,
"indications": {"ptsd": ind},
"diary_items": [SE[i] for i in items],
"diary_items_source": src,
"red_flags": list(red),
"monitoring": list(mon),
"withdrawal_risk": withdrawal,
"overdose_danger": False,
"max_course_days": course_days,
}
if note:
d["note"] = note
return d
# Patient-facing drug group by the КР class wording.
GROUPS = {
"бензодиазепин": "Анксиолитик (транквилизатор)",
"Анксиолитик": "Анксиолитик",
"Антипсихотическое": "Антипсихотик",
"Противоэпилептический": "Противоэпилептический препарат",
}
SSRI = ["nausea", "insomnia", "sexual", "headache"] # табл. 3 + головная боль из разд. 3.1 (СИОЗС)
BENZO = ["sedation", "dizziness", "coordination", "memory"] # КР ПТСР 3.2: седация, головокружение, координация…
BENZO_SRC = f"{PTSD}, разд. 3.2 (комментарии о производных бензодиазепина)"
AP_SRC = f"{PTSD}, разд. 3.2: акатизия, экстрапирамидные расстройства, метаболический синдром"
DRUGS = [
ad(
"paroxetine",
"Пароксетин",
"СИОЗС",
20,
20,
50,
SSRI,
ptsd=ptsd_ind(20, 50, False),
red=[RF_BLEEDING],
withdrawal="high",
),
ad("sertraline", "Сертралин", "СИОЗС", 50, 50, 200, SSRI, ptsd=ptsd_ind(25, 200, False), red=[RF_BLEEDING]),
ad(
"fluvoxamine",
"Флувоксамин",
"СИОЗС",
50,
100,
300,
SSRI,
red=[RF_BLEEDING],
note="Стартовая доза по табл. 2: 50–100 мг",
),
ad(
"fluoxetine",
"Флуоксетин",
"СИОЗС",
20,
20,
80,
SSRI,
ptsd=ptsd_ind(20, 40, True),
red=[RF_BLEEDING],
withdrawal="low",
),
ad("citalopram", "Циталопрам", "СИОЗС", 20, 20, 40, SSRI, red=[RF_BLEEDING]),
ad("escitalopram", "Эсциталопрам", "СИОЗС", 10, 10, 20, SSRI, red=[RF_BLEEDING]),
ad(
"venlafaxine",
"Венлафаксин",
"СИОЗСН",
75,
75,
375,
["nausea", "insomnia", "sexual"],
ptsd=ptsd_ind(75, 225, True),
mon=["Артериальное давление"],
withdrawal="high",
note="Депрессия: >225 мг/сут — в стационарных условиях",
),
ad("duloxetine", "Дулоксетин", "СИОЗСН", 60, 60, 120, ["nausea", "insomnia"]),
ad(
"amitriptyline",
"Амитриптилин",
"ТЦА",
50,
150,
300,
["anticholinergic", "sedation", "orthostatic", "weight"],
ptsd=ptsd_ind(75, 150, True),
mon=[TCA_MON],
withdrawal="high",
overdose=True,
note="Депрессия: старт 50–75 мг; >150 мг/сут — в стационарных условиях",
),
ad(
"imipramine",
"Имипрамин",
"ТЦА",
25,
150,
300,
["anticholinergic", "insomnia", "orthostatic", "weight"],
ptsd=ptsd_ind(75, 250, True),
mon=[TCA_MON],
withdrawal="high",
overdose=True,
note="Депрессия: старт 25–75 мг; >200 мг/сут — в стационарных условиях",
),
ad(
"clomipramine",
"Кломипрамин",
"ТЦА",
50,
100,
250,
["anticholinergic", "sexual", "orthostatic", "weight"],
mon=[TCA_MON],
withdrawal="high",
overdose=True,
note="Старт 50–75 мг; летальность при передозировке средняя",
),
ad(
"pipofezine",
"Пипофезин",
"ТЦА (АТХ — другие антидепрессанты)",
25,
150,
500,
[],
note="В табл. 3 КР не описан — побочные эффекты уточнять у врача; старт 25–50 мг",
),
ad("mianserin", "Миансерин", "НаССА", 30, 60, 90, ["sedation"], mon=["Формула крови (по КР — изменения редко)"]),
ad(
"mirtazapine",
"Миртазапин",
"НаССА",
15,
15,
45,
["sedation", "weight"],
ptsd=ptsd_ind(30, 60, True),
note="Старт 15–30 мг",
),
ad(
"trazodone",
"Тразодон",
"Специфический серотонинергический АД",
100,
150,
600,
["sedation", "sexual"],
red=[RF_PRIAPISM],
note=">450 мг/сут — в стационарных условиях",
),
ad(
"pirlindole",
"Пирлиндол",
"Ингибитор МАО-А",
50,
150,
400,
[],
note="В табл. 3 КР не описан — побочные эффекты уточнять у врача; старт 50–75 мг",
),
ad(
"agomelatine",
"Агомелатин",
"Мелатонинергический АД",
25,
25,
50,
[],
red=[RF_JAUNDICE],
mon=["Билирубин, АСТ, АЛТ, щелочная фосфатаза — в начале лечения и при повышении дозы"],
),
ad("vortioxetine", "Вортиоксетин", "Серотонинергический модулятор", 10, 10, 20, ["nausea", "insomnia"]),
# КР ПТСР: анксиолитики, антипсихотические средства, противоэпилептические препараты
other(
"diazepam",
"Диазепам",
"Производное бензодиазепина",
5,
15,
False,
BENZO,
withdrawal="high",
course_days=28,
src=BENZO_SRC,
note="Курс не более 4 недель, в тяжёлых случаях до 8–12 недель",
),
other(
"lorazepam",
"Лоразепам",
"Производное бензодиазепина",
2,
10,
False,
BENZO,
withdrawal="high",
course_days=21,
src=BENZO_SRC,
note="Дозы — из текста разд. 3.2 (в табл. А3 не приведён)",
),
other(
"clonazepam",
"Клоназепам",
"Производное бензодиазепина",
1,
4,
False,
BENZO,
withdrawal="high",
course_days=21,
src=BENZO_SRC,
note="Дозы — из текста разд. 3.2 (в табл. А3 не приведён)",
),
other(
"alprazolam",
"Алпразолам",
"Производное бензодиазепина",
0.25,
4.5,
False,
BENZO,
withdrawal="high",
course_days=90,
src=BENZO_SRC,
note="Курс до 3 месяцев; отмена в течение 2–6 недель",
),
other(
"tofisopam",
"Тофизопам",
"Производное бензодиазепина (дневной транквилизатор)",
50,
300,
False,
[],
course_days=84,
src=f"{PTSD}, разд. 3.2",
note="По КР не вызывает физической зависимости и седации; курс 4–12 недель",
),
other(
"etifoxine",
"Этифоксин",
"Анксиолитик",
150,
200,
False,
["sedation", "skin"],
red=[RF_JAUNDICE, RF_UTERINE],
course_days=42,
src=f"{PTSD}, разд. 3.2",
note="Курс от нескольких дней до 4–6 недель",
),
other(
"hydroxyzine",
"Гидроксизин",
"Анксиолитик",
25,
100,
True,
["sedation"],
course_days=56,
src=f"{PTSD}, разд. 3.2",
note="Курс обычно 4–8 недель",
),
other(
"buspirone",
"Буспирон",
"Анксиолитик",
20,
30,
False,
[],
src=f"{PTSD}, разд. 3.2",
note="Эффект с 7–14 дня, максимум к 4-й неделе",
),
other(
"alimemazine",
"Алимемазин",
"Антипсихотическое средство",
15,
80,
False,
[],
mon=[AP_MON],
src=f"{PTSD}, разд. 3.2",
note="По КР побочные эффекты редки и слабо выражены",
),
other(
"risperidone",
"Рисперидон",
"Антипсихотическое средство",
0.5,
8,
True,
["akathisia", "eps", "weight"],
mon=[AP_MON],
src=AP_SRC,
),
other(
"quetiapine",
"Кветиапин",
"Антипсихотическое средство",
50,
300,
True,
["akathisia", "eps", "weight"],
mon=[AP_MON],
src=AP_SRC,
),
other(
"olanzapine",
"Оланзапин",
"Антипсихотическое средство",
5,
20,
True,
["weight", "sedation"],
mon=[AP_MON],
src=f"{DEP}, разд. 3.3 (увеличение веса, седация); {PTSD}, разд. 3.2",
),
other(
"tiapride", "Тиаприд", "Антипсихотическое средство", 200, 300, False, [], mon=[AP_MON], src=f"{PTSD}, разд. 3.2"
),
other(
"chlorprothixene",
"Хлорпротиксен",
"Антипсихотическое средство",
25,
150,
True,
[],
mon=[AP_MON],
src=f"{PTSD}, разд. 3.2",
note="Расхождение в КР ПТСР: в тексте 15–100 мг, в табл. А3 — 25–150 мг",
),
other(
"lamotrigine",
"Ламотриджин",
"Противоэпилептический препарат",
25,
500,
True,
[],
red=[RF_RASH],
src=f"{PTSD}, разд. 3.2",
note="Частые кожные высыпания вплоть до токсического эпидермального некролиза",
),
other("topiramate", "Топирамат", "Противоэпилептический препарат", 200, 400, True, [], src=f"{PTSD}, разд. 3.2"),
]
TEXTS = {
"expect_effect_depression": (
"Лекарства от депрессии действуют не сразу. Первые изменения иногда заметны уже через 1–2 недели, "
"а эффект врач оценивает через 3–4 недели. Принимайте препарат по схеме, даже если пока не чувствуете разницы."
),
"expect_effect_ptsd": (
"Эффект препарата при ПТСР развивается постепенно — его оценивают через 4–6 недель. В первую неделю тревога "
"может временно усилиться: это известная реакция, сообщите о ней врачу. Не прекращайте приём самостоятельно."
),
"dont_stop_alone": (
"Не прекращайте приём самостоятельно: резкая отмена может ухудшить самочувствие, а лечение — потерять эффект. "
"Если препарат мешает жить или вы в нём сомневаетесь, обсудите это с врачом — он может изменить дозу "
"или подобрать другой."
),
"why_continue_depression": (
"Даже когда становится лучше, лечение продолжают ещё несколько месяцев в той же дозе: так ниже риск, "
"что депрессия вернётся."
),
"withdrawal_missed": (
"Вы пропустили приём два дня подряд. При резком прекращении этого препарата бывают неприятные ощущения: "
"головокружение, раздражительность, нарушения сна, состояние как при гриппе. Вернитесь к обычной схеме "
"и сообщите врачу, если стало хуже."
),
"ran_out": (
"Лекарство закончилось? Не откладывайте — попросите у врача новый рецепт: "
"перерыв в приёме может ухудшить самочувствие."
),
"red_flag": "Этот симптом нужно обсудить с врачом сегодня. Если состояние тяжёлое — вызывайте скорую: 103 или 112.",
"side_effects_general": (
"Спасибо, что сообщили. Побочные эффекты часто ослабевают в первые недели. Если они мешают жить — это повод "
"обсудить с врачом дозу или замену препарата. Не меняйте схему сами."
),
"instruction_fallback": "Уточните у врача или в инструкции к препарату.",
}
RULES = [
{"id": "M1", "params": {"missed_days_in_row": 2}, "status": "черновик"},
{"id": "M2", "params": {}, "status": "черновик"},
{"id": "M3", "params": {"ran_out_days_in_row": 2}, "status": "черновик"},
{"id": "M4", "params": {"default_max_course_days": 21}, "status": "КР ПТСР 3.2"},
{
"id": "M5",
"params": {"check_day": 28, "baseline_lookback_days": 14, "phq9_min_drop_pct": 50, "pcl5_min_drop": 10},
"status": "черновик",
},
{"id": "M6", "params": {}, "status": "КР депрессия 3.1.3"},
{"id": "M7", "params": {}, "status": "черновик"},
{"id": "M8", "params": {"fibser_burden_min": 5, "side_effect_misses_in_7_days": 2}, "status": "черновик"},
{"id": "M9", "params": {}, "status": "в сводке врачу"},
{"id": "M10", "params": {}, "status": "приложения А3 обеих КР"},
]
def main() -> None:
OUT.parent.mkdir(parents=True, exist_ok=True)
doc = {
"version": "draft-2026-10-04",
"status": "черновик: собран по КР Минздрава, тексты и параметры вычитывает психиатр",
"sources": [
{
"id": "kr_depression_2024",
"title": "Депрессивный эпизод, рекуррентное депрессивное расстройство",
"year": 2024,
"file": "docs/source/rag/kr_depression.pdf",
},
{
"id": "kr_ptsd_2023",
"title": "Посттравматическое стрессовое расстройство",
"year": 2023,
"kr_id": 753,
"file": "docs/source/rag/kr_ptsd.pdf",
},
],
"drugs": DRUGS,
"texts": TEXTS,
"rules": RULES,
}
OUT.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"{OUT}: {len(DRUGS)} drugs")
if __name__ == "__main__":
main()
+69
View File
@@ -117,3 +117,72 @@ async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None:
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()]
assert "112" in phones
async def test_today_links_prescription_to_reference_and_therapy_day(client: AsyncClient) -> None:
today = clock.now().date()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", modules=["veteran"])
s.add(Prescription(token=TOKEN, drug="Сертралин", dose="50 мг", schedule={}, start_date=today - timedelta(13)))
rx = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["prescriptions"][0]
assert rx["ref_id"] == "sertraline" and rx["indication"] == "ptsd" and rx["therapy_day"] == 14
async def test_demo_persona_week_mirrors_dashboard_story(client: AsyncClient) -> None:
persona = fixtures.DEMO_PERSONAS[0].token
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
rx = (await client.get(f"/api/v1/me/{persona}/today")).json()["prescriptions"][0]
assert rx["ref_id"] == "sertraline" and rx["therapy_day"] > 1
async def test_questionnaires_are_off_without_release_or_demo_flag(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("INTAKE_DEMO", raising=False)
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1")
q = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["questionnaires"]
assert q == {"enabled": False, "intake": "none", "diary_done": False}
async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "1")
persona = fixtures.DEMO_PERSONAS[0].token
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
async def press(button: str | None = None) -> list[str]:
event = {"type": "button", "button": button} if button else {"type": "start"}
replies = (await client.post(f"/api/v1/dialog/{persona}/events", json=event)).json()["replies"]
return [b["data"] for r in replies for row in r["buttons"] for b in row]
await press()
await press("consent:yes")
buttons = await press("age:adult")
assert any(b.startswith("iq:") and ":cssrs_q1:" in b for b in buttons)
assert "intake:help" in buttons and "intake:pause" in buttons
q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"]
assert q["enabled"] and q["intake"] == "active"
async def test_instrument_catalog_describes_intake_items(client: AsyncClient) -> None:
body = (await client.get("/api/v1/instruments/catalog")).json()
phq = body["items"]["phq9_3"]
assert phq["form"] == "phq9" and phq["index"] == 3 and phq["total"] == 9 and not phq["adaptive"]
assert body["items"]["cssrs_q1"]["adaptive"] and body["forms"]["cssrs"]["section"] == "safety"
assert body["items"]["sleep_hours"]["numeric"] and body["items"]["comment"]["optional"]
assert body["forms"]["pcl5"]["recall_period"] and body["forms"]["gad7"]["section"] == "scale"
drinks = body["standard_drinks"]
assert len(drinks["header"]) == 4 and len(drinks["rows"]) == 5 and all(len(c) == 4 for _, c in drinks["rows"])
async def test_meds_reference_is_sourced_and_hides_rule_params(client: AsyncClient) -> None:
body = (await client.get("/api/v1/meds/reference")).json()
assert "rules" not in body and body["texts"]["dont_stop_alone"]
drugs = {d["id"]: d for d in body["drugs"]}
sertraline = drugs["sertraline"]
assert sertraline["indications"]["depression"]["max_mg"] == 200
assert (
sertraline["indications"]["ptsd"]["min_mg"] == 25 and "КР ПТСР" in sertraline["indications"]["ptsd"]["source"]
)
assert drugs["diazepam"]["max_course_days"] == 28 and drugs["alprazolam"]["max_course_days"] == 90
assert all(ind["source"] for d in body["drugs"] for ind in d["indications"].values())
+17
View File
@@ -44,6 +44,23 @@ src/screens/ экраны по docs/mock: вход A1–A3, фокус
Данные для «Сегодня» и «Лекарства» — `GET /api/v1/me/{token}/today` (дозы на сегодня, отметки за неделю,
следующий визит, время по демо-часам).
### Анкеты, дневник и модуль «Лекарства»
- **Анкеты этапа 1** (C-SSRS, PHQ-2/PHQ-9, фильтр травмы, TSQ, PCL-5, RUS-AUDIT-S/RUS-AUDIT) и **дневник этапа 2** —
тот же фокус-экран. Пункт распознаётся по кнопкам `iq:…`; чип раздела, «Вопрос n из N» (для адаптивных форм —
без «из N»), период, «Продолжить позже», «Мне нужна помощь сейчас», пропуск необязательных пунктов, ввод часов сна,
шкалы 0–10 с подписями концов, таблица стандартных порций для п. 2 RUS-AUDIT (из утверждённого бланка, без
калькулятора). Метаданные — `GET /api/v1/instruments/catalog`.
- Анкеты включаются только в демо: `INTAKE_DEMO=1` (вместе с `DEMO_CLOCK`). Клинический замок в
`config/intake_runtime.yaml` не трогается. Пока нет подписанной политики маршрутизации и формы C-SSRS «с последнего
контакта», завершённый опрос и дневник уходят на оценку специалиста — UI показывает это отдельным экраном.
- **Карточка препарата** `/meds/:ref` — справочник `data/meds/meds_reference.json` (собирается
`scripts/build_meds_reference.py` по КР Минздрава: депрессия 2024, ПТСР 2023): когда ждать эффекта (с днём лечения),
сколько принимать, что может беспокоить, когда срочно к врачу, «не прекращать самостоятельно». Дозы не показываются.
Черновик — вычитывает психиатр. `GET /api/v1/meds/reference`.
- «Сообщить о побочном эффекте» доступно всегда (`med:side_effects:<доза или дата>`), карточка «День N лечения» на
«Сегодня» — в 1-й, 7-й и 14-й день.
Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается
Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API).
+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,