feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect
CI / ruff · pytest (pull_request) Successful in 2m1s

- Patient list sorted red -> yellow -> grey -> green with KPI row, trend arrows,
  7-day adherence bars, discontinuation-risk badge with factor tooltip.
- Patient card: scales with severity bands from config/scales (unscheduled marked),
  mood/sleep/alcohol small multiples, dose heat-strip, alerts feed with ack,
  safety plan, AI summary with disclaimer.
- Live alerts: st.fragment(run_every=2.5s) polling, toast + pinned acute banner on
  every page; full rerun on new alert only on doctor pages.
- Connect form -> POST /patients/connect -> code, QR, deep link.
- Thin httpx ApiClient over the frozen schemas with graceful "API недоступен".
- app.py discovers dashboard/pages/*.py automatically (st.navigation); demo pages
  of #17 plug in by adding a file.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 12:51:07 +03:00
co-authored by Claude Opus 5.5
parent 178cab5b1b
commit 2582c73d12
15 changed files with 2170 additions and 13 deletions
+109
View File
@@ -0,0 +1,109 @@
"""HTML fragments of the dashboard. Builders return strings (testable); `st.html` renders them.
Every value coming from the API is escaped: patient free text never reaches these views, but the API is still
treated as untrusted input.
"""
from __future__ import annotations
from html import escape
import streamlit as st
from app.api.schemas import PatientListItem
from dashboard import theme
from dashboard.components import helpers as h
def chip(text: str) -> str:
return f'<span class="mt-chip">{escape(text)}</span>'
def badge(text: str, tone: str, tip: str = "", icon: str = "") -> str:
tip_attr = f' data-tip="{escape(tip)}"' if tip else ""
cls = " mt-tip" if tip else ""
lead = f'<span class="mt-dot" style="background:{theme.TONES[tone].solid}"></span>' if icon == "dot" else icon
return f'<span class="mt-badge t-{tone}{cls}"{tip_attr}>{lead}{escape(text)}</span>'
def bar(fraction: float | None, tone: str) -> str:
pct = 0 if fraction is None else max(0, min(100, round(fraction * 100)))
return f'<div class="mt-bar"><span style="width:{pct}%;background:{theme.TONES[tone].solid}"></span></div>'
def page_header(title: str, subtitle: str = "", kicker: str = "") -> str:
k = f'<div class="mt-kicker">{escape(kicker)}</div>' if kicker else ""
s = f'<div class="mt-sub">{subtitle}</div>' if subtitle else ""
return f'<div class="mt-head"><div>{k}<h1>{escape(title)}</h1>{s}</div></div>'
def kpi_tiles(k: h.Kpis) -> str:
tiles = [
("На сопровождении", str(k.total), "пациентов в кабинете", theme.TONES["blue"].solid),
("🔴 Красная зона", str(k.red), "острый сигнал — связаться сегодня", theme.TONES["red"].solid),
("🟡 Жёлтая зона", str(k.yellow), "врачу ≤ 24 ч или к визиту", theme.TONES["yellow"].solid),
("Риск прерывания терапии", str(k.high_risk), "высокий индекс (правила, не ML)", theme.TONES["red"].solid),
]
body = "".join(
f'<div class="mt-kpi" style="--kpi-accent:{accent}"><div class="lbl">{escape(lbl)}</div>'
f'<div class="val">{val}</div><div class="hint">{escape(hint)}</div></div>'
for lbl, val, hint, accent in tiles
)
return f'<div class="mt-kpis">{body}</div>'
LIST_COLUMNS = ["Пациент", "Терапия", "PHQ-9", "PCL-5", "Приём 7 дн.", "Риск прерывания", "Последнее событие", "Алерты"]
def list_header() -> str:
return '<div class="mt-cols">' + "".join(f"<div>{c}</div>" for c in LIST_COLUMNS) + "</div>"
def _score(value: int | None, trend: str) -> str:
if value is None:
return '<span class="mt-dim">—</span>'
t = h.trend_view(trend)
return (
f'<span class="mt-num">{value}</span> '
f'<span class="mt-tip" data-tip="{escape(t.hint)}" style="color:{theme.TONES[t.tone].ink};font-weight:700">'
f"{t.text}</span>"
)
def patient_row(p: PatientListItem) -> str:
"""One row of the patient list; the grid matches `list_header()`."""
status = h.status_view(p.status_color)
tone = theme.TONES[status.tone]
chips = "".join(chip(h.MODULE_LABELS.get(m, m)) for m in p.modules)
adh = h.adherence_view(p.adherence_7d)
risk = h.risk_view(p.discontinuation_risk)
if p.open_alerts:
count_style = f"background:{tone.tint};color:{tone.ink}"
else:
count_style = f"background:{theme.TONES['grey'].tint};color:{theme.INK_3}"
return (
'<div class="mt-row">'
f'<div><div class="mt-name"><span class="mt-dot mt-tip" data-tip="{escape(status.text + " — " + status.hint)}"'
f' style="background:{tone.solid}"></span>{escape(p.display_name)}</div>'
f'<div class="mt-meta">{h.age_label(p.age)} · {chips}</div></div>'
f'<div>{escape(p.drug or "—")}<div class="mt-meta">день терапии {p.therapy_day}</div></div>'
f"<div>{_score(p.phq9_last, p.trend)}</div>"
f"<div>{_score(p.pcl5_last, p.trend)}</div>"
f'<div><span class="mt-num">{adh.text}</span>{bar(p.adherence_7d, adh.tone)}</div>'
f"<div>{badge(risk.text, risk.tone, risk.hint)}</div>"
f'<div class="mt-event">{escape(p.last_event or "—")}</div>'
f'<div><span class="mt-count" style="{count_style}">{p.open_alerts}</span></div>'
"</div>"
)
def banner(html_body: str, tone: str = "red", pulse: bool = False) -> str:
lead = '<span class="mt-pulse"></span>' if pulse else ""
return f'<div class="mt-banner t-{tone}">{lead}<div>{html_body}</div></div>'
def footer() -> None:
st.html(
'<div class="mt-footer">Прототип на синтетических данных · MedITron · '
"ИИ не ставит диагнозы и не назначает лечение — решения принимает врач</div>"
)