Compare commits

..
Author SHA1 Message Date
Pavel Eliseev a4ee3d25f0 fix(web): allow returning home from questionnaires
CI / ruff · pytest (pull_request) Successful in 2m28s
Keep the shared dialog and submitted answers while leaving the focus screen. Remove the history trap and wire the text-step home action.

Refs #85
2026-10-04 16:14:37 +03:00
Pavel Eliseev 2a2fdb171e feat(ui): extend minimal doctor design across all screens
CI / ruff · pytest (pull_request) Successful in 2m16s
Refs #85
2026-10-04 16:09:57 +03:00
Soyavo afbfbad19e Merge pull request 'feat(backend): prepare MAX linking, webhook API and durable notifications' (#86) from feat/82-max-backend into dev
CI / ruff · pytest (push) Successful in 2m16s
2026-10-04 16:09:13 +03:00
Pavel Eliseev 461c3d02ea fix(backend): redact retryable MAX storage failures
CI / ruff · pytest (pull_request) Successful in 2m4s
Refs #82
2026-10-04 16:04:38 +03:00
Pavel Eliseev 64655ad934 feat(backend): add durable MAX linking and notification queue
Refs #82
2026-10-04 16:04:38 +03:00
Soyavo db3072d759 Merge pull request '[contract] Add opt-in MAX configuration and lifecycle' (#84) from feat/81-max-contract into dev
CI / ruff · pytest (push) Successful in 2m4s
2026-10-04 16:02:52 +03:00
24 changed files with 1145 additions and 209 deletions
+6 -6
View File
@@ -1,15 +1,15 @@
# Dashboard-only Streamlit settings (doctor dashboard + demo console). # Dashboard-only Streamlit settings (doctor dashboard + demo console).
[theme] [theme]
base = "light" base = "light"
primaryColor = "#2A78D6" primaryColor = "#285E4E"
backgroundColor = "#F6F7F9" backgroundColor = "#F6F7F3"
secondaryBackgroundColor = "#FFFFFF" secondaryBackgroundColor = "#FFFFFF"
textColor = "#1B1F24" textColor = "#213B38"
borderColor = "#E3E6EA" borderColor = "#E1E7E3"
baseRadius = "medium" baseRadius = "medium"
# Falls back to the system sans-serif when offline (ADR-004). # Falls back to the system sans-serif when offline (ADR-004).
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap, sans-serif" font = "sans-serif"
headingFontWeights = 700 headingFontWeights = 600
[client] [client]
toolbarMode = "minimal" toolbarMode = "minimal"
+115
View File
@@ -0,0 +1,115 @@
"""Server API for a future MAX connect UI; webhook processing is supplied by the channel adapter."""
import hmac
import json
import re
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import get_settings
from app.core import clock
from app.db import repo
from app.db.max_notifications import MaxBinding, MaxDelivery
from app.db.session import get_clinical_session, get_identity_session
from app.integrations import max_store
router = APIRouter(tags=["max-notifications"])
bearer = HTTPBearer(auto_error=False)
async def patient_token(auth: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> str:
if auth is None or auth.scheme.lower() != "bearer" or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", auth.credentials):
raise HTTPException(401, "patient authentication required", headers={"WWW-Authenticate": "Bearer"})
async with get_clinical_session() as session:
if await repo.get_profile(session, auth.credentials) is None:
raise HTTPException(401, "patient authentication required")
return auth.credentials
PatientToken = Annotated[str, Depends(patient_token)]
class MaxStatus(BaseModel):
available: bool
connected: bool
last_delivery: str | None = None
class LinkConsent(BaseModel):
model_config = ConfigDict(extra="forbid")
consent: Literal[True]
class MaxLink(BaseModel):
url: str
expires_at: datetime
@router.get("/me/notifications/max", response_model=MaxStatus)
async def status(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
binding = await session.get(MaxBinding, token)
last = await session.scalar(
select(MaxDelivery.state)
.where(MaxDelivery.token == token)
.order_by(MaxDelivery.created_at.desc(), MaxDelivery.id.desc())
.limit(1)
)
return MaxStatus(
available=get_settings().max_enabled, connected=bool(binding and binding.enabled), last_delivery=last
)
@router.post("/me/notifications/max/link", response_model=MaxLink)
async def link(body: LinkConsent, token: PatientToken) -> MaxLink:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
if not await max_store.active_patient(token):
raise HTTPException(403, "active consent and adult confirmation required")
async with get_identity_session() as session:
raw, expires = await max_store.issue_code(session, token, clock.now(), settings.max_link_ttl_seconds)
return MaxLink(url=f"https://max.ru/{settings.max_bot_username}?start={raw}", expires_at=expires)
@router.delete("/me/notifications/max", response_model=MaxStatus)
async def unlink(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
await max_store.disconnect(session, token)
return MaxStatus(available=get_settings().max_enabled, connected=False)
@router.post("/webhooks/max")
async def webhook(request: Request) -> dict[str, bool]:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
expected = settings.max_webhook_secret.get_secret_value().encode()
supplied = request.headers.get("X-Max-Bot-Api-Secret", "").encode()
if not expected or not hmac.compare_digest(supplied, expected):
raise HTTPException(403, "invalid webhook secret")
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > 65536:
raise HTTPException(413, "event is too large")
try:
event = json.loads(raw)
except (ValueError, UnicodeError) as exc:
raise HTTPException(400, "invalid JSON") from exc
if not isinstance(event, dict):
raise HTTPException(422, "event must be an object")
from app.channels.max_bot import handle_update
try:
await handle_update(event)
except SQLAlchemyError:
# Let MAX retry; do not log DB exception parameters containing identity data.
raise HTTPException(503, "notification storage unavailable") from None
return {"success": True}
+2 -1
View File
@@ -15,7 +15,7 @@ from sqlalchemy import select
from app.api import demo as console from app.api import demo as console
from app.api import doctor_views as views from app.api import doctor_views as views
from app.api import fixtures from app.api import fixtures, max_notifications
from app.api.patient_web import router as patient_web_router from app.api.patient_web import router as patient_web_router
from app.api.questionnaire import router as questionnaire_router from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import ( from app.api.schemas import (
@@ -46,6 +46,7 @@ from app.scheduler import jobs as scheduler
from app.services.monitoring import refresh_status from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
router.include_router(max_notifications.router)
router.include_router(questionnaire_router) router.include_router(questionnaire_router)
router.include_router(patient_web_router) router.include_router(patient_web_router)
+1
View File
@@ -5,6 +5,7 @@ The only module (besides registration code) that touches both schemas — and on
from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata) from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata)
from app.db import intake as _intake # noqa: F401 from app.db import intake as _intake # noqa: F401
from app.db import max_notifications as _max_notifications # noqa: F401
from app.db import scheduler as _scheduler # noqa: F401 from app.db import scheduler as _scheduler # noqa: F401
from app.db.clinical import ClinicalBase from app.db.clinical import ClinicalBase
from app.db.identity import IdentityBase from app.db.identity import IdentityBase
+50
View File
@@ -0,0 +1,50 @@
"""MAX identifiers and delivery metadata live only in the identity DB; no medical message bodies."""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.identity import IdentityBase
from app.db.types import TZDateTime
class MaxBinding(IdentityBase):
__tablename__ = "max_bindings"
# No FK: anonymous web profiles intentionally have no patients row in identity.
token: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, unique=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
connected_at: Mapped[datetime] = mapped_column(TZDateTime)
class MaxLinkCode(IdentityBase):
__tablename__ = "max_link_codes"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str] = mapped_column(String(64), unique=True)
expires_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
used_at: Mapped[datetime | None] = mapped_column(TZDateTime)
class MaxReceipt(IdentityBase):
__tablename__ = "max_webhook_receipts"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
received_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
class MaxDelivery(IdentityBase):
__tablename__ = "max_deliveries"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str | None] = mapped_column(String(64), index=True)
user_id: Mapped[int] = mapped_column(BigInteger)
kind: Mapped[str] = mapped_column(String(24))
state: Mapped[str] = mapped_column(String(16), default="queued", index=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(TZDateTime)
next_attempt_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
lease: Mapped[str | None] = mapped_column(String(32))
error_code: Mapped[str | None] = mapped_column(String(32))
+201
View File
@@ -0,0 +1,201 @@
"""Registration boundary and durable MAX queue. Callers own the identity transaction.
Only this boundary joins a pseudonymous profile to a MAX recipient. Clinical modules never import it.
Queue rows contain a template name, never replies, scores, prescriptions or patient-auth tokens in URLs.
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import repo
from app.db.clinical import ScaleResult
from app.db.intake import QuestionnaireRecord
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxLinkCode
from app.db.session import get_clinical_session
MAX_ATTEMPTS = 6
LEASE_SECONDS = 60
def digest(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
async def active_patient(token: str) -> bool:
"""Same consent/adult gate used by the scheduler, checked again at delivery time."""
async with get_clinical_session() as session:
state = await repo.get_dialog_state(session, token)
return bool(
await repo.get_profile(session, token)
and state
and state.data.get("consent") is True
and state.data.get("adult") is True
)
async def issue_code(session: AsyncSession, token: str, now: datetime, ttl: int) -> tuple[str, datetime]:
raw = secrets.token_urlsafe(32)
expires = now + timedelta(seconds=ttl)
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
session.add(MaxLinkCode(digest=digest(raw), token=token, expires_at=expires))
await session.flush()
return raw, expires
async def code_token(session: AsyncSession, raw: str, now: datetime) -> str | None:
row = await session.get(MaxLinkCode, digest(raw))
return row.token if row and row.used_at is None and row.expires_at > now else None
async def bind(session: AsyncSession, raw: str, user_id: int, now: datetime) -> str | None:
"""Atomically consume a code. Existing active identities cannot be silently reassigned."""
token = await code_token(session, raw, now)
if token is None:
return None
owner = await session.scalar(select(MaxBinding).where(MaxBinding.user_id == user_id))
binding = await session.get(MaxBinding, token)
if owner and owner.token != token and owner.enabled:
return None
if binding and binding.user_id != user_id and binding.enabled:
return None
used = await session.execute(
update(MaxLinkCode)
.where(MaxLinkCode.digest == digest(raw), MaxLinkCode.used_at.is_(None), MaxLinkCode.expires_at > now)
.values(used_at=now)
.returning(MaxLinkCode.token)
)
if used.scalar_one_or_none() is None:
return None
if owner and owner.token != token:
await session.delete(owner)
await session.flush()
if binding is None:
binding = MaxBinding(token=token, user_id=user_id, connected_at=now)
session.add(binding)
# Cancel stale deliveries even when the same account reconnects later.
await cancel_pending(session, token)
binding.user_id, binding.enabled, binding.connected_at = user_id, True, now
await session.flush()
return token
async def cancel_pending(session: AsyncSession, token: str) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.token == token, MaxDelivery.state.in_(("queued", "sending")))
.values(state="cancelled", lease=None)
)
async def disconnect(session: AsyncSession, token: str) -> None:
binding = await session.get(MaxBinding, token)
if binding:
binding.enabled = False
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
await cancel_pending(session, token)
async def enqueue(
session: AsyncSession, *, key: str, user_id: int, kind: str, now: datetime, token: str | None = None
) -> None:
if await session.get(MaxDelivery, key) is not None:
return
try:
async with session.begin_nested():
session.add(
MaxDelivery(id=key, user_id=user_id, token=token, kind=kind, created_at=now, next_attempt_at=now)
)
await session.flush()
except IntegrityError:
# Another worker may enqueue the same event between SELECT and INSERT.
if await session.get(MaxDelivery, key) is None:
raise
async def collect_completed(session: AsyncSession, now: datetime) -> None:
"""Recover completion events from committed clinical records, including after a restart.
No clinical hooks depend on identity. Read only IDs/dates, then enqueue a generic template.
A binding's activation time bounds history; repeated scans are idempotent.
"""
bindings = (await session.scalars(select(MaxBinding).where(MaxBinding.enabled.is_(True)))).all()
for binding in bindings:
if not await active_patient(binding.token):
continue
async with get_clinical_session() as clinical:
forms = (
await clinical.scalars(
select(QuestionnaireRecord.id).where(
QuestionnaireRecord.token == binding.token,
QuestionnaireRecord.status == "complete",
QuestionnaireRecord.completed_at >= binding.connected_at,
)
)
).all()
scales = (
await clinical.scalars(
select(ScaleResult.id).where(
ScaleResult.token == binding.token,
ScaleResult.complete.is_(True),
ScaleResult.ts >= binding.connected_at,
)
)
).all()
for source, ids in (("form", forms), ("scale", scales)):
for record_id in ids:
key = digest(f"completed:{binding.token}:{binding.connected_at.isoformat()}:{source}:{record_id}")
await enqueue(session, key=key, token=binding.token, user_id=binding.user_id, kind="completed", now=now)
async def claim(session: AsyncSession, now: datetime) -> MaxDelivery | None:
"""One lease per row; recovery after a worker crash is bounded by the lease deadline."""
ready = (MaxDelivery.state.in_(("queued", "sending"))) & (MaxDelivery.next_attempt_at <= now)
row = await session.scalar(
select(MaxDelivery)
.where(ready)
.order_by(MaxDelivery.created_at, MaxDelivery.id)
.with_for_update(skip_locked=True)
.limit(1)
)
if row is None:
return None
if row.attempts >= MAX_ATTEMPTS:
row.state, row.error_code, row.lease = "failed", "attempt_limit", None
return None
lease = secrets.token_hex(16)
result = await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, ready)
.values(
state="sending",
attempts=MaxDelivery.attempts + 1,
lease=lease,
next_attempt_at=now + timedelta(seconds=LEASE_SECONDS),
)
.returning(MaxDelivery.id)
)
if result.scalar_one_or_none() is None:
return None
await session.refresh(row)
return row
async def finish(
session: AsyncSession,
row: MaxDelivery,
*,
state: str,
now: datetime,
error: str | None = None,
retry_seconds: float = 0,
) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, MaxDelivery.lease == row.lease, MaxDelivery.state == "sending")
.values(state=state, lease=None, error_code=error, next_attempt_at=now + timedelta(seconds=retry_seconds))
)
+11 -8
View File
@@ -42,7 +42,10 @@ def dose_strip_html(grid: h.DoseGrid, start: date | None) -> str:
f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>' f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>'
"</div>" "</div>"
) )
return f'<div class="mt-doses" style="grid-template-columns:{cols}">{"".join(cells)}</div>{legend}' return (
f'<div class="mt-dose-scroll"><div class="mt-doses" style="grid-template-columns:{cols}">'
f"{''.join(cells)}</div></div>{legend}"
)
def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str: def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str:
@@ -181,7 +184,7 @@ def render(card: PatientCard, today: date | None) -> None:
main, side = st.columns([1.75, 1], gap="large") main, side = st.columns([1.75, 1], gap="large")
with main: with main:
with st.container(border=True): with st.container(border=True, key="panel-scales"):
c1, c2 = st.columns(2) c1, c2 = st.columns(2)
for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")): for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")):
fig = charts.scale_figure(card.series, scale_id, title, start) fig = charts.scale_figure(card.series, scale_id, title, start)
@@ -191,22 +194,22 @@ def render(card: PatientCard, today: date | None) -> None:
else: else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}") st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}")
st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.") st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.")
with st.container(border=True): with st.container(border=True, key="panel-wellbeing"):
fig = charts.wellbeing_figure(card.series, start) fig = charts.wellbeing_figure(card.series, start)
if fig is None: if fig is None:
st.caption("Ежедневных чек-инов пока нет") st.caption("Ежедневных чек-инов пока нет")
else: else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing") st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing")
st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.") st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.")
with st.container(border=True): with st.container(border=True, key="panel-doses"):
st.html('<div class="mt-panel-title">Календарь приёма</div>') st.html('<div class="mt-panel-title">Календарь приёма</div>')
st.html(dose_strip_html(grid, start)) st.html(dose_strip_html(grid, start))
with side: with side:
with st.container(border=True): with st.container(border=True, key="panel-alerts"):
_alerts_feed(card.alerts, today) _alerts_feed(card.alerts, today)
with st.container(border=True): with st.container(border=True, key="panel-summary"):
_summary(card.summary) _summary(card.summary)
with st.container(border=True): with st.container(border=True, key="panel-safety"):
_safety_plan(card.safety_plan) _safety_plan(card.safety_plan)
with st.container(border=True): with st.container(border=True, key="panel-prescriptions"):
_prescriptions(card) _prescriptions(card)
+1 -1
View File
@@ -51,7 +51,7 @@ def render_playback_panel(persona: dict[str, Any]) -> None:
token = persona["token"] token = persona["token"]
scenario, source = w.scenario_for(persona) scenario, source = w.scenario_for(persona)
state = w.playback_state(token) state = w.playback_state(token)
st.subheader("🎬 Сценарий демо") st.subheader("Сценарий демо")
_scenario_source_controls(persona, source) _scenario_source_controls(persona, source)
if scenario.errors: if scenario.errors:
with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days): with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days):
+2 -3
View File
@@ -368,10 +368,9 @@ def render_doctor_panel(highlight_token: str | None, *, key: str = "doc") -> Non
] ]
for a in fresh_acute[:1]: for a in fresh_acute[:1]:
st.markdown( st.markdown(
"<div style='background:#c62828;color:#fff;padding:18px 20px;border-radius:12px;" "<div class='mt-banner t-red mt-console-alert'><div>"
"font-size:1.35rem;font-weight:700;margin-bottom:8px'>"
f"🚨 ОСТРЫЙ АЛЕРТ · {_escape(a.get('patient_name', ''))} · {_fmt_ts(a.get('ts'))}<br>" f"🚨 ОСТРЫЙ АЛЕРТ · {_escape(a.get('patient_name', ''))} · {_fmt_ts(a.get('ts'))}<br>"
f"<span style='font-size:1.05rem;font-weight:500'>{_escape(a.get('reason', ''))}</span></div>", f"<div class='mt-sub'>{_escape(a.get('reason', ''))}</div></div></div>",
unsafe_allow_html=True, unsafe_allow_html=True,
) )
c1, c2 = st.columns(2) c1, c2 = st.columns(2)
@@ -68,7 +68,7 @@ def _reset() -> None:
def _result(res: ConnectPatientOut, name: str) -> None: def _result(res: ConnectPatientOut, name: str) -> None:
link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501")) link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501"))
with st.container(border=True): with st.container(border=True, key="panel-connect-result"):
st.html( st.html(
f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>' f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>'
'<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит ' '<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит '
@@ -108,7 +108,7 @@ def main() -> None:
st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,)) st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,))
left, right = st.columns(2, gap="large") left, right = st.columns(2, gap="large")
with left, st.container(border=True): with left, st.container(border=True, key="panel-connect-person"):
st.html('<div class="mt-panel-title">Пациент</div>') st.html('<div class="mt-panel-title">Пациент</div>')
st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО") st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО")
a, b = st.columns(2) a, b = st.columns(2)
@@ -128,7 +128,7 @@ def main() -> None:
key="cp_modules", key="cp_modules",
format_func=lambda m: h.MODULE_LABELS[m], format_func=lambda m: h.MODULE_LABELS[m],
) )
with right, st.container(border=True): with right, st.container(border=True, key="panel-connect-prescription"):
st.html('<div class="mt-panel-title">Назначение</div>') st.html('<div class="mt-panel-title">Назначение</div>')
d1, d2 = st.columns([1.4, 1]) d1, d2 = st.columns([1.4, 1])
with d1: with d1:
+17 -7
View File
@@ -14,21 +14,31 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402 from console import widgets as w # noqa: E402
from console.playback_ui import render_playback_panel # noqa: E402 from console.playback_ui import render_playback_panel # noqa: E402
from dashboard import theme # noqa: E402
from dashboard.components import ui # noqa: E402
st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide") st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide")
st.html(theme.CSS)
st.html(
ui.page_header(
"Демо-консоль",
"Чат пациента и сценарий сопровождения. Прототип на синтетических данных.",
kicker="Демонстрация",
)
)
persona = w.select_persona() persona = w.select_persona()
w.render_connect_hero() w.render_connect_hero()
w.show_flash() w.show_flash()
st.sidebar.caption(f"API: {w.get_api().base_url}") st.sidebar.caption(f"API: {w.get_api().base_url}")
st.title("Демо-консоль") with st.container(border=True, key="panel-console-clock"):
st.caption("Прототип на синтетических данных. Время — виртуальные демо-часы (ADR-005).") w.render_clock_bar(persona, key="console-clock")
w.render_clock_bar(persona, key="console-clock")
st.divider()
chat_col, play_col = st.columns([1.05, 1], gap="large") chat_col, play_col = st.columns([1.05, 1], gap="large")
with chat_col: with chat_col, st.container(border=True, key="panel-console-chat"):
st.subheader(f"💬 Чат пациента — {persona.get('display_name') or persona['token']}") st.subheader(f"Чат пациента — {persona.get('display_name') or persona['token']}")
w.render_chat(persona["token"], height=560, key="console-chat") w.render_chat(persona["token"], height=560, key="console-chat")
with play_col: with play_col, st.container(border=True, key="panel-console-scenario"):
render_playback_panel(persona) render_playback_panel(persona)
+10 -10
View File
@@ -15,12 +15,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402 from console import widgets as w # noqa: E402
from dashboard import theme # noqa: E402
from dashboard.components import ui # noqa: E402
st.set_page_config(page_title="Сплит-экран", page_icon="🖥️", layout="wide", initial_sidebar_state="collapsed") st.set_page_config(page_title="Сплит-экран", page_icon="🖥️", layout="wide", initial_sidebar_state="collapsed")
st.markdown( st.html(theme.CSS)
"<style>.block-container{padding-top:1.2rem;max-width:100%} " st.html(ui.page_header("Сплит-экран", "Пациент и врач: сопровождение в реальном времени.", kicker="Демонстрация"))
"[data-testid='stMarkdownContainer'] p, [data-testid='stMarkdownContainer'] li{font-size:1.08rem}</style>",
unsafe_allow_html=True,
)
persona = w.select_persona() persona = w.select_persona()
w.show_flash() w.show_flash()
@@ -44,15 +44,15 @@ if clicked and nxt is not None:
st.rerun() st.rerun()
left, right = st.columns([1.1, 1], gap="large") left, right = st.columns([1.1, 1], gap="large")
with left: with left, st.container(border=True, key="panel-split-patient"):
url = w.patient_web_url() url = w.patient_web_url()
if url: if url:
st.markdown(f"#### 📱 Пациент — {persona.get('display_name') or token}") st.markdown(f"#### Пациент — {persona.get('display_name') or token}")
st.iframe(url, height=760) st.iframe(url, height=760)
st.caption(f"Веб-приложение пациента: {url}") st.caption(f"Веб-приложение пациента: {url}")
else: else:
st.markdown(f"#### 💬 Пациент — {persona.get('display_name') or token}") st.markdown(f"#### Пациент — {persona.get('display_name') or token}")
w.render_chat(token, height=600, key="split-chat") w.render_chat(token, height=600, key="split-chat")
with right: with right, st.container(border=True, key="panel-split-doctor"):
st.markdown("#### 🩺 Кабинет врача — д-р Смирнова Е. А.") st.markdown("#### Кабинет врача — д-р Смирнова Е. А.")
w.polled_doctor_panel(token, key="split-doc") w.polled_doctor_panel(token, key="split-doc")
+23 -6
View File
@@ -138,8 +138,8 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-empty p {{ font-size:.85rem; margin:8px 0 0; }} .mt-empty p {{ font-size:.85rem; margin:8px 0 0; }}
/* ---------- chips, badges, bars ---------- */ /* ---------- chips, badges, bars ---------- */
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px; .mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 5px; margin-right: 4px;
background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }} background: #edf3ee; color: #38634b; font-weight: 500; }}
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px; .mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px;
border-radius: 5px; white-space: normal; }} border-radius: 5px; white-space: normal; }}
.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }} .mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }}
@@ -166,7 +166,7 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-banner b {{ color: inherit; }} .mt-banner b {{ color: inherit; }}
.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none; .mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none;
box-shadow: 0 0 0 4px #faeeee; }} box-shadow: 0 0 0 4px #faeeee; }}
.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650; .mt-panel-title {{ font-size: .82rem; text-transform: uppercase; letter-spacing: .03em; font-weight: 550;
color: var(--ink-3); margin: 2px 0 8px; }} color: var(--ink-3); margin: 2px 0 8px; }}
.mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }} .mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }}
.mt-alert.t-red {{ border-left-color: var(--red); }} .mt-alert.t-red {{ border-left-color: var(--red); }}
@@ -175,9 +175,9 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }} .mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }}
.mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }} .mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }}
.mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }} .mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }}
.mt-summary {{ background: var(--blue-tint); border-radius: 12px; padding: 14px 16px; color: var(--ink); .mt-summary {{ background: #f4f6f2; border:1px solid var(--line); border-radius: 12px; padding: 14px 16px;
line-height: 1.5; font-size: .93rem; }} color: var(--ink); line-height: 1.5; font-size: .93rem; }}
.mt-summary .disc {{ color: var(--blue-ink); font-size: .76rem; margin-top: 8px; }} .mt-summary .disc {{ color: var(--ink-2); font-size: .76rem; margin-top: 8px; }}
.mt-plan ol {{ margin: 0; padding-left: 1.2rem; }} .mt-plan ol {{ margin: 0; padding-left: 1.2rem; }}
.mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }} .mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }}
@@ -195,6 +195,21 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px; .mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px;
vertical-align: -1px; }} vertical-align: -1px; }}
/* ---------- internal pages ---------- */
div[class*="st-key-panel-"] {{ background:var(--surface); border:1px solid var(--line); border-radius:12px;
padding:20px; }}
[data-testid="stExpander"] {{ background:var(--surface); border-color:var(--line); border-radius:12px; }}
[data-testid="stChatMessage"] {{ background:#f4f6f2; border:1px solid var(--line); border-radius:12px; }}
[data-testid="stChatInput"] {{ background:var(--surface); border:1px solid var(--line); border-radius:8px; }}
[data-testid="stNumberInputContainer"], [data-testid="stDateInput"] input {{
background:var(--surface); color:var(--ink); border-color:var(--line); border-radius:8px; }}
[data-testid="stApp"] [data-variant="pills"][data-selected] {{ background:#edf3ee; color:#285e4e;
border-color:#b4c6ba; }}
.mt-console-alert {{ font-size:1.1rem; font-weight:600; }}
.mt-console-alert .mt-sub {{ color:var(--red-ink); font-weight:400; }}
.mt-dose-scroll {{ overflow-x:auto; padding-bottom:4px; }}
.mt-doses {{ min-width:max-content; }}
/* ---------- connect result ---------- */ /* ---------- connect result ---------- */
.mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink); .mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink);
font-variant-numeric: tabular-nums; }} font-variant-numeric: tabular-nums; }}
@@ -222,6 +237,8 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }} .mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }}
.mt-row-details {{ grid-template-columns:1fr; gap:12px; }} .mt-row-details {{ grid-template-columns:1fr; gap:12px; }}
.mt-list-footer {{ flex-wrap:wrap; }} .mt-list-footer {{ flex-wrap:wrap; }}
.mt-head .mt-side {{ text-align:left; }}
div[class*="st-key-panel-"] {{ padding:16px; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }} .mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }}
}} }}
@media (prefers-reduced-motion: reduce) {{ @media (prefers-reduced-motion: reduce) {{
+103
View File
@@ -0,0 +1,103 @@
"""MAX endpoints authenticate before reading bindings or accepting events."""
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, urlsplit
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.exc import OperationalError
from app.config import get_settings
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.integrations import max_store
from app.main import app
BASE = "/api/v1/me/notifications/max"
HOOK = "/api/v1/webhooks/max"
TOKEN = "synthetic-patient"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
SECRET = "test-secret-" * 4
@pytest.fixture
async def client(monkeypatch):
for key, value in {
"MAX_ENABLED": "true",
"MAX_BOT_TOKEN": "fake-token",
"MAX_BOT_USERNAME": "test_bot",
"MAX_WEBHOOK_SECRET": SECRET,
"MAX_WEBHOOK_URL": "https://api.example.org" + HOOK,
"MAX_SITE_URL": "https://example.org",
}.items():
monkeypatch.setenv(key, value)
get_settings.cache_clear()
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="")
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now()
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
get_settings.cache_clear()
@pytest.mark.parametrize("auth", [{}, {"Authorization": "Bearer unknown"}, {"Authorization": "Basic test"}])
async def test_auth_required(client, auth):
assert (await client.get(BASE, headers=auth)).status_code == 401
assert (await client.post(BASE + "/link", headers=auth, json={"consent": True})).status_code == 401
assert (await client.delete(BASE, headers=auth)).status_code == 401
async def test_link_requires_consent_and_hides_patient_credential(client):
for body in ({}, {"consent": False}, {"consent": True, "user_id": 100}):
assert (await client.post(BASE + "/link", headers=AUTH, json=body)).status_code == 422
response = await client.post(BASE + "/link", headers=AUTH, json={"consent": True})
assert response.status_code == 200
assert TOKEN not in response.text
url = urlsplit(response.json()["url"])
assert url.netloc == "max.ru" and url.path == "/test_bot"
code = parse_qs(url.query)["start"][0]
async with session.get_identity_session() as s:
assert await max_store.bind(s, code, 100, clock.now()) == TOKEN
assert (await client.get(BASE, headers=AUTH)).json()["connected"] is True
assert (await client.delete(BASE, headers=AUTH)).json()["connected"] is False
async def test_revoke_consent_prevents_linking(client):
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": False, "adult": True}, now=clock.now()
)
assert (await client.post(BASE + "/link", headers=AUTH, json={"consent": True})).status_code == 403
async def test_disabled_service_still_allows_disconnect(client, monkeypatch):
monkeypatch.setenv("MAX_ENABLED", "false")
get_settings.cache_clear()
assert (await client.get(BASE, headers=AUTH)).json()["available"] is False
assert (await client.post(BASE + "/link", headers=AUTH, json={"consent": True})).status_code == 503
assert (await client.post(HOOK, json={})).status_code == 503
assert (await client.delete(BASE, headers=AUTH)).status_code == 200
async def test_webhook_secret_checked_before_body_or_adapter(client):
assert (await client.post(HOOK, content="not-json")).status_code == 403
assert (await client.post(HOOK, headers={"X-Max-Bot-Api-Secret": "wrong"}, json={})).status_code == 403
headers = {"X-Max-Bot-Api-Secret": SECRET}
assert (await client.post(HOOK, headers=headers, content="not-json")).status_code == 400
assert (await client.post(HOOK, headers=headers, json=[])).status_code == 422
assert (await client.post(HOOK, headers=headers, content=" " * 65537)).status_code == 413
async def test_webhook_storage_failure_is_retryable_without_identity_details(client, monkeypatch):
handler = AsyncMock(side_effect=OperationalError("private SQL", {"token": TOKEN}, Exception("private")))
monkeypatch.setitem(sys.modules, "app.channels.max_bot", SimpleNamespace(handle_update=handler))
response = await client.post(HOOK, headers={"X-Max-Bot-Api-Secret": SECRET}, json={})
assert response.status_code == 503 and TOKEN not in response.text and "private" not in response.text
+9 -1
View File
@@ -53,7 +53,15 @@ async def _tables(engine_fn: Callable[[], AsyncEngine]) -> set[str]:
async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None: async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
identity_tables = await _tables(session.identity_engine) identity_tables = await _tables(session.identity_engine)
clinical_tables = await _tables(session.clinical_engine) clinical_tables = await _tables(session.clinical_engine)
assert identity_tables == {"patients", "doctors", "connect_codes"} assert identity_tables == {
"patients",
"doctors",
"connect_codes",
"max_bindings",
"max_link_codes",
"max_webhook_receipts",
"max_deliveries",
}
assert { assert {
"patient_profiles", "patient_profiles",
"item_responses", "item_responses",
+123
View File
@@ -0,0 +1,123 @@
"""Persistent, one-time linking and queue recovery; synthetic identities only."""
from datetime import timedelta
import pytest
from sqlalchemy import select
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import ScaleResult
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxLinkCode
from app.integrations import max_store as store
@pytest.fixture(autouse=True)
async def database():
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
yield
await session.configure()
async def test_codes_are_hashed_expiring_one_time_and_replaced():
now = clock.now()
async with session.get_identity_session() as s:
old, _ = await store.issue_code(s, "patient", now, 60)
code, expires = await store.issue_code(s, "patient", now, 60)
assert await store.code_token(s, old, now) is None
row = (await s.scalars(select(MaxLinkCode))).one()
assert row.digest != code and row.digest == store.digest(code)
assert await store.code_token(s, code, expires) is None
assert await store.bind(s, code, 100, now) == "patient"
assert await store.bind(s, code, 100, now) is None
async with session.get_identity_session() as s:
assert (await s.get(MaxBinding, "patient")).user_id == 100
async def test_active_binding_cannot_be_reassigned_in_either_direction():
now = clock.now()
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, "one", now, 60)
await store.bind(s, code, 100, now)
code, _ = await store.issue_code(s, "two", now, 60)
assert await store.bind(s, code, 100, now) is None
assert await store.code_token(s, code, now) == "two"
code, _ = await store.issue_code(s, "one", now, 60)
assert await store.bind(s, code, 200, now) is None
await store.disconnect(s, "one")
assert await store.code_token(s, code, now) is None
code, _ = await store.issue_code(s, "two", now, 60)
assert await store.bind(s, code, 100, now) == "two"
async def test_disconnect_cancels_pending_delivery_and_prevents_stale_ack():
now = clock.now()
async with session.get_identity_session() as s:
await store.enqueue(s, key="job", user_id=100, token="one", kind="notification", now=now)
row = await store.claim(s, now)
async with session.get_identity_session() as s:
await store.disconnect(s, "one")
await store.finish(s, row, state="sent", now=now)
async with session.get_identity_session() as s:
assert (await s.get(MaxDelivery, "job")).state == "cancelled"
async def test_queue_survives_sessions_deduplicates_and_recovers_expired_lease():
now = clock.now()
async with session.get_identity_session() as s:
for _ in range(2):
await store.enqueue(s, key="job", user_id=100, kind="help", now=now)
async with session.get_identity_session() as s:
first = await store.claim(s, now)
assert first.attempts == 1
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(seconds=59)) is None
async with session.get_identity_session() as s:
second = await store.claim(s, now + timedelta(seconds=61))
assert second.id == first.id and second.lease != first.lease and second.attempts == 2
async with session.get_identity_session() as s:
await store.finish(s, first, state="sent", now=now)
async with session.get_identity_session() as s:
assert (await s.get(MaxDelivery, "job")).state == "sending"
await store.finish(s, second, state="sent", now=now)
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(days=1)) is None
async def test_retry_deadline_and_attempt_limit():
now = clock.now()
async with session.get_identity_session() as s:
await store.enqueue(s, key="job", user_id=100, kind="help", now=now)
for attempt in range(store.MAX_ATTEMPTS):
when = now + timedelta(seconds=attempt * 120)
async with session.get_identity_session() as s:
row = await store.claim(s, when)
async with session.get_identity_session() as s:
await store.finish(s, row, state="queued", now=when, retry_seconds=120, error="http_429")
async with session.get_identity_session() as s:
assert await store.claim(s, when + timedelta(seconds=119)) is None
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(days=1)) is None
async with session.get_identity_session() as s:
row = await s.get(MaxDelivery, "job")
assert row.state == "failed" and row.attempts == store.MAX_ATTEMPTS
async def test_completion_scan_ignores_old_and_incomplete_tests_and_recovers_without_duplicates():
now = clock.now()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token="one", doctor_id="")
await repo.set_dialog_state(s, token="one", state="idle", data={"consent": True, "adult": True}, now=now)
for ts, complete in ((now - timedelta(days=1), True), (now, True), (now, False)):
s.add(ScaleResult(token="one", ts=ts, scale="phq9", total=0, severity="none", complete=complete))
async with session.get_identity_session() as s:
s.add(MaxBinding(token="one", user_id=100, enabled=True, connected_at=now))
for _ in range(2):
async with session.get_identity_session() as s:
await store.collect_completed(s, now)
async with session.get_identity_session() as s:
row = (await s.scalars(select(MaxDelivery))).one()
assert row.kind == "completed" and row.token == "one"
assert "phq9" not in row.id
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0a4da2"/><path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="9" fill="#285e4e"/><path d="M16 8v16M8 16h16" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 175 B

After

Width:  |  Height:  |  Size: 201 B

+27 -2
View File
@@ -3,13 +3,14 @@ import { Link, NavLink } from "react-router-dom";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext"; import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
import { HotlineCard, useHelp } from "./HelpSheet"; import { HotlineCard, useHelp } from "./HelpSheet";
import { Icon, LogoMark, type IconName } from "./Icon"; import { Icon, LogoMark, type IconName } from "./Icon";
/* /*
* Layout from docs/mock (series 2). * Shared patient workspace, following the approved doctor dashboard.
* phone: content → «Экстренная помощь» block → bottom tab bar (signed in) * phone: content → «Экстренная помощь» block → bottom tab bar (signed in)
* desktop: navy sidebar | content | right rail (optional `aside` + emergency panel) * desktop: white sidebar | workspace header | content + emergency panel
* bare: one centred column on every width (questionnaires) * bare: one centred column on every width (questionnaires)
*/ */
@@ -87,6 +88,10 @@ function Sidebar({ signedIn }: { signedIn: boolean }) {
</li> </li>
</ul> </ul>
)} )}
<div className="sidebar__note">
<strong>{ru.product.supportTitle}</strong>
<span>{ru.product.supportText}</span>
</div>
</nav> </nav>
); );
} }
@@ -122,6 +127,24 @@ function Rail({ aside }: { aside?: ReactNode }) {
); );
} }
function WorkspaceHeader({ signedIn, bare = false }: { signedIn: boolean; bare?: boolean }) {
const { session } = useSession();
return (
<header className={`workspace${bare ? " workspace--bare" : ""}`}>
<div className="workspace__brand">
<LogoMark size={30} />
<span>{bare ? ru.product.name : signedIn ? ru.product.workspace : ru.nav.signIn}</span>
</div>
{signedIn && !bare ? (
<Link className="workspace__profile" to="/profile">
<span className="avatar"><Icon name="user" size={18} /></span>
<span>{session?.displayName || ru.nav.profile}</span>
</Link>
) : <span className="workspace__tag">{ru.product.tagline}</span>}
</header>
);
}
export function AppShell({ export function AppShell({
children, children,
signedIn = true, signedIn = true,
@@ -144,6 +167,7 @@ export function AppShell({
return ( return (
<div className="app app--bare"> <div className="app app--bare">
<div className="app__main"> <div className="app__main">
<WorkspaceHeader signedIn={signedIn} bare />
<main className="screen" id="main"> <main className="screen" id="main">
{children} {children}
{sos && <EmergencyBlock />} {sos && <EmergencyBlock />}
@@ -155,6 +179,7 @@ export function AppShell({
<div className={`app${signedIn && !focus ? " app--tabs" : ""}`}> <div className={`app${signedIn && !focus ? " app--tabs" : ""}`}>
<Sidebar signedIn={signedIn} /> <Sidebar signedIn={signedIn} />
<div className="app__main"> <div className="app__main">
<WorkspaceHeader signedIn={signedIn} />
<main className="screen" id="main"> <main className="screen" id="main">
{children} {children}
{sos && <EmergencyBlock />} {sos && <EmergencyBlock />}
+2 -2
View File
@@ -163,8 +163,8 @@ export function Icon({ name, size = 22, label }: { name: IconName; size?: number
export function LogoMark({ size = 32 }: { size?: number }) { export function LogoMark({ size = 32 }: { size?: number }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false"> <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<rect width="32" height="32" rx="9" fill="var(--c-teal)" /> <rect width="32" height="32" rx="9" fill="var(--c-primary)" />
<path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff" /> <path d="M16 8v16M8 16h16" stroke="#fff" strokeWidth="2" strokeLinecap="round" />
</svg> </svg>
); );
} }
+4
View File
@@ -6,6 +6,9 @@ export const ru = {
product: { product: {
name: "Сопровождение пациента", name: "Сопровождение пациента",
tagline: "между визитами к врачу", tagline: "между визитами к врачу",
workspace: "Личный кабинет",
supportTitle: "Рядом между визитами",
supportText: "Наблюдение за самочувствием и поддержка назначенного лечения.",
}, },
nav: { nav: {
today: "Сегодня", today: "Сегодня",
@@ -201,6 +204,7 @@ export const ru = {
toIntake: "Пройти первичный опрос", toIntake: "Пройти первичный опрос",
}, },
flow: { flow: {
home: "На главный экран",
question: (n: number, total: number) => `Вопрос ${n} из ${total}`, question: (n: number, total: number) => `Вопрос ${n} из ${total}`,
period: "За последний месяц", period: "За последний месяц",
textPlaceholder: "Напишите несколько слов", textPlaceholder: "Напишите несколько слов",
-4
View File
@@ -1,7 +1,3 @@
import "@fontsource/golos-text/400.css";
import "@fontsource/golos-text/500.css";
import "@fontsource/golos-text/600.css";
import "@fontsource/golos-text/700.css";
import "./theme/tokens.css"; import "./theme/tokens.css";
import "./theme/global.css"; import "./theme/global.css";
+14 -15
View File
@@ -265,7 +265,7 @@ function FreeText({
{skip.label} {skip.label}
</button> </button>
) : ( ) : (
<button type="button" className="btn btn--ghost" onClick={onExit}> <button type="button" className="btn btn--ghost" disabled={busy} onClick={onExit}>
{ru.flow.exit} {ru.flow.exit}
</button> </button>
)} )}
@@ -362,6 +362,7 @@ function SurveyAct({
press, press,
say, say,
onPause, onPause,
onHome,
}: { }: {
step: Survey; step: Survey;
stepKey: string; stepKey: string;
@@ -369,6 +370,7 @@ function SurveyAct({
press: (b: DialogButton) => void; press: (b: DialogButton) => void;
say: (text: string) => void; say: (text: string) => void;
onPause: (b: DialogButton) => void; onPause: (b: DialogButton) => void;
onHome: () => void;
}) { }) {
let answer: ReactNode; let answer: ReactNode;
switch (step.input) { switch (step.input) {
@@ -381,7 +383,7 @@ function SurveyAct({
break; break;
case "text": case "text":
answer = ( answer = (
<FreeText key={stepKey} skip={step.skip} busy={busy} onSend={say} onSkip={press} onExit={() => undefined} /> <FreeText key={stepKey} skip={step.skip} busy={busy} onSend={say} onSkip={press} onExit={onHome} />
); );
break; break;
default: default:
@@ -522,17 +524,6 @@ export function Flow() {
navigate("/today", { replace: true }); navigate("/today", { replace: true });
}, [step, stepKey, navigate, toast]); }, [step, stepKey, navigate, toast]);
// Questionnaire: answers are saved on the server per question, and there is no going back to change one.
// The browser «back» is absorbed by a duplicate history entry of this same screen.
const testing = step?.kind === "survey" || step?.kind === "choice" || step?.kind === "consent";
useEffect(() => {
if (!testing) return;
window.history.pushState(null, "", window.location.href);
const stay = () => window.history.pushState(null, "", window.location.href);
window.addEventListener("popstate", stay);
return () => window.removeEventListener("popstate", stay);
}, [testing]);
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label); const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
// «Продолжить позже»: answers are already saved on the server — pause and go straight home, no confirmation. // «Продолжить позже»: answers are already saved on the server — pause and go straight home, no confirmation.
const pause = (b: DialogButton) => const pause = (b: DialogButton) =>
@@ -588,7 +579,7 @@ export function Flow() {
) : item ? ( ) : item ? (
<Progress n={item.index} total={item.total} adaptive={item.adaptive} /> <Progress n={item.index} total={item.total} adaptive={item.adaptive} />
) : null; ) : null;
const top = <TopBar onBack={testing ? undefined : home}>{progress}</TopBar>; const top = progress ? <TopBar>{progress}</TopBar> : null;
// Phone: one column. Desktop: question and context on the left, answers and actions on the right. // Phone: one column. Desktop: question and context on the left, answers and actions on the right.
let lead: ReactNode; let lead: ReactNode;
let act: ReactNode; let act: ReactNode;
@@ -702,7 +693,7 @@ export function Flow() {
break; break;
case "survey": case "survey":
lead = <SurveyLead step={step} catalog={catalog} />; lead = <SurveyLead step={step} catalog={catalog} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} onPause={pause} />; act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} onPause={pause} onHome={home} />;
break; break;
case "choice": case "choice":
lead = <Head title={step.title} lead={step.lead} />; lead = <Head title={step.title} lead={step.lead} />;
@@ -725,6 +716,14 @@ export function Flow() {
return ( return (
<AppShell focus bare sos={step?.kind !== "crisis"}> <AppShell focus bare sos={step?.kind !== "crisis"}>
<nav className="flow-nav" aria-label={ru.flow.home}>
{/* Leaving does not send an answer or clear pending: Today can reopen this same question.
Wait for an in-flight answer before leaving so its result has reached the shared dialog. */}
<button type="button" className="btn btn--ghost btn--auto" disabled={dialog.busy} onClick={home}>
<Icon name="home" size={20} />
{ru.flow.home}
</button>
</nav>
{content} {content}
</AppShell> </AppShell>
); );
+387 -106
View File
File diff suppressed because it is too large Load Diff
+33 -33
View File
@@ -1,45 +1,45 @@
/* /*
* Design tokens — single source of colours, type and spacing (docs/mock, series 2). * Shared visual language with dashboard/theme.py and the approved doctor workspace.
* Capacitor wraps this same bundle; a React Native port maps these 1:1 to a theme object. * Capacitor wraps this same bundle; a React Native port maps these 1:1 to a theme object.
*/ */
:root { :root {
/* Ink & surfaces */ /* Ink & surfaces */
--c-navy: #0f2747; /* primary buttons, headings, desktop sidebar */ --c-primary: #285e4e; /* primary buttons and active navigation */
--c-navy-hover: #0a1d37; --c-primary-hover: #204b3e;
--c-navy-soft: #e8eef6; --c-primary-soft: #edf3ee;
--c-text: #13294b; --c-text: #213b38;
--c-text-2: #4d5d73; --c-text-2: #526660;
--c-text-3: #7a889b; --c-text-3: #63736f;
--c-page: #ffffff; --c-page: #f6f7f3;
--c-page-desk: #f4f7fb; /* desktop canvas behind white panels */ --c-page-desk: #f6f7f3; /* desktop canvas behind white panels */
--c-surface: #ffffff; --c-surface: #ffffff;
--c-tile: #f3f7fb; /* soft card fill */ --c-tile: #f4f6f2; /* soft card fill */
--c-border: #e2e9f1; --c-border: #e1e7e3;
--c-border-strong: #c9d4e1; --c-border-strong: #b4c6ba;
/* Selection & positive: teal */ /* Selection & positive */
--c-teal: #17a08f; --c-teal: #527f61;
--c-teal-strong: #0e7e71; --c-teal-strong: #38634b;
--c-teal-soft: #e3f5f2; --c-teal-soft: #eef4ee;
/* Info: blue */ /* Informational surfaces */
--c-blue: #2f7fdb; --c-blue: #38634b;
--c-blue-soft: #eaf3fd; --c-blue-soft: #edf3ee;
/* Help & crisis: red */ /* Help & crisis: red */
--c-red: #e2383b; --c-red: #a83236;
--c-red-strong: #c62b2e; --c-red-strong: #a83236;
--c-red-soft: #fdeded; --c-red-soft: #faeeee;
--c-red-line: #f7d3d3; --c-red-line: #e7cbcb;
/* Warning */ /* Warning */
--c-amber: #e69a12; --c-amber: #805714;
--c-amber-soft: #fff4dd; --c-amber-soft: #fbf3e4;
--c-focus: #2f7fdb; --c-focus: #598d7a;
/* Type: Golos Text (OFL), bundled locally — works offline. */ /* Same offline system stack as the doctor workspace. */
--font: "Golos Text", system-ui, -apple-system, "Segoe UI", sans-serif; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
--fs-12: 0.75rem; --fs-12: 0.75rem;
--fs-13: 0.8125rem; --fs-13: 0.8125rem;
--fs-14: 0.875rem; --fs-14: 0.875rem;
@@ -63,14 +63,14 @@
--s-12: 48px; --s-12: 48px;
/* Radii: tiles > buttons > chips */ /* Radii: tiles > buttons > chips */
--r-tile: 16px; --r-tile: 12px;
--r-btn: 12px; --r-btn: 8px;
--r-chip: 999px; --r-chip: 5px;
--tap: 52px; --tap: 52px;
--nav-h: 64px; --nav-h: 64px;
--sidebar-w: 232px; --sidebar-w: 228px;
--rail-w: 300px; --rail-w: 264px;
--safe-top: env(safe-area-inset-top, 0px); --safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px);