feat(web): приложение пациента — двухэтапные опросы, результат скрининга, дневник по результату #79
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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>} />
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user