feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect
CI / ruff · pytest (pull_request) Successful in 2m1s
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:
@@ -0,0 +1,188 @@
|
||||
"""Visual system of the doctor dashboard: status palette (ADR-006), text tokens, CSS and the plotly template.
|
||||
|
||||
Status colours are reserved for alert state (red = acute, yellow = high/moderate, green = nothing open,
|
||||
grey = info only). They always ship together with an icon and a text label, never colour alone.
|
||||
Chart series use a separate neutral blue so that a line is never confused with a status.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
Tone = Literal["red", "yellow", "green", "grey", "blue"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToneSpec:
|
||||
solid: str # marks, stripes, dots
|
||||
ink: str # text on light surface (WCAG AA on white / on the tint)
|
||||
tint: str # badge / banner background
|
||||
icon: str
|
||||
label: str
|
||||
|
||||
|
||||
TONES: dict[str, ToneSpec] = {
|
||||
"red": ToneSpec("#D64545", "#A32D2D", "#FCEBEB", "🔴", "Красная зона"),
|
||||
"yellow": ToneSpec("#E0A800", "#7F5A00", "#FFF5D6", "🟡", "Жёлтая зона"),
|
||||
"green": ToneSpec("#2E9E5B", "#1D6E3F", "#E6F4EC", "🟢", "Стабильно"),
|
||||
"grey": ToneSpec("#9AA0A6", "#565C64", "#F0F1F3", "⚪", "Нет ответа"),
|
||||
"blue": ToneSpec("#2A78D6", "#1F5DA8", "#E8F1FC", "🔵", ""),
|
||||
}
|
||||
|
||||
INK = "#1B1F24"
|
||||
INK_2 = "#4B535C"
|
||||
INK_3 = "#7A828C"
|
||||
SURFACE = "#FFFFFF"
|
||||
CANVAS = "#F6F7F9"
|
||||
LINE = "#E3E6EA"
|
||||
SERIES = TONES["blue"].solid
|
||||
FONT_STACK = "'Inter', 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif"
|
||||
|
||||
CSS = f"""
|
||||
<style>
|
||||
:root {{
|
||||
--ink: {INK}; --ink-2: {INK_2}; --ink-3: {INK_3}; --line: {LINE}; --surface: {SURFACE}; --canvas: {CANVAS};
|
||||
--red: {TONES["red"].solid}; --red-ink: {TONES["red"].ink}; --red-tint: {TONES["red"].tint};
|
||||
--yellow: {TONES["yellow"].solid}; --yellow-ink: {TONES["yellow"].ink}; --yellow-tint: {TONES["yellow"].tint};
|
||||
--green: {TONES["green"].solid}; --green-ink: {TONES["green"].ink}; --green-tint: {TONES["green"].tint};
|
||||
--grey: {TONES["grey"].solid}; --grey-ink: {TONES["grey"].ink}; --grey-tint: {TONES["grey"].tint};
|
||||
--blue: {TONES["blue"].solid}; --blue-ink: {TONES["blue"].ink}; --blue-tint: {TONES["blue"].tint};
|
||||
}}
|
||||
.stApp {{ background: var(--canvas); }}
|
||||
.block-container {{ padding-top: 2.2rem; max-width: 1320px; }}
|
||||
h1, h2, h3 {{ color: var(--ink); letter-spacing: -0.01em; }}
|
||||
|
||||
/* ---------- page header ---------- */
|
||||
.mt-head {{ display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap;
|
||||
margin: 0 0 6px 0; }}
|
||||
.mt-head > div:first-child {{ flex: 1 1 420px; min-width: 0; }}
|
||||
.mt-head .mt-side {{ flex: 0 1 320px; text-align: right; }}
|
||||
.mt-head h1 {{ font-size: 1.75rem; font-weight: 700; margin: 0; padding: 0; line-height: 1.2; }}
|
||||
.mt-sub {{ color: var(--ink-2); font-size: .95rem; margin-top: 4px; }}
|
||||
.mt-kicker {{ color: var(--ink-3); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em;
|
||||
font-weight: 600; }}
|
||||
|
||||
/* ---------- KPI tiles ---------- */
|
||||
.mt-kpis {{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 12px; margin: 14px 0 6px; }}
|
||||
.mt-kpi {{ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px;
|
||||
position: relative; overflow: hidden; }}
|
||||
.mt-kpi::before {{ content:""; position:absolute; left:0; top:0; bottom:0; width:4px; background: var(--kpi-accent,
|
||||
var(--line)); }}
|
||||
.mt-kpi .lbl {{ color: var(--ink-2); font-size: .85rem; font-weight: 500; }}
|
||||
.mt-kpi .val {{ color: var(--ink); font-size: 2rem; font-weight: 650; line-height: 1.15; margin-top: 4px; }}
|
||||
.mt-kpi .hint {{ color: var(--ink-3); font-size: .78rem; margin-top: 2px; }}
|
||||
|
||||
/* ---------- patient rows ---------- */
|
||||
.mt-cols, .mt-row {{ display:grid; align-items:center; gap: 14px;
|
||||
grid-template-columns: minmax(0,2fr) minmax(0,1.5fr) 58px 58px minmax(0,1fr) minmax(0,1.2fr) minmax(0,2fr) 40px; }}
|
||||
.mt-row > div {{ min-width: 0; overflow-wrap: anywhere; }}
|
||||
.mt-cols {{ color: var(--ink-3); font-size: .74rem; text-transform: uppercase; letter-spacing: .06em;
|
||||
font-weight: 600; padding: 0 0 0 21px; margin-top: 8px; }}
|
||||
.mt-row {{ font-size: .9rem; color: var(--ink); }}
|
||||
.mt-name {{ font-weight: 600; font-size: 1rem; display:flex; align-items:center; gap:8px; }}
|
||||
.mt-meta {{ color: var(--ink-3); font-size: .8rem; margin-top: 3px; }}
|
||||
.mt-dim {{ color: var(--ink-3); }}
|
||||
.mt-num {{ font-variant-numeric: tabular-nums; font-weight: 600; }}
|
||||
.mt-event {{ color: var(--ink-2); font-size: .85rem; line-height: 1.3; }}
|
||||
.mt-dot {{ width:10px; height:10px; border-radius:50%; display:inline-block; flex: none; }}
|
||||
div[class*="st-key-prow-"] {{ background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 10px 12px 10px 16px; border-left-width: 5px; transition: box-shadow .15s ease; }}
|
||||
div[class*="st-key-prow-"]:hover {{ box-shadow: 0 2px 10px rgba(20,30,40,.08); }}
|
||||
div[class*="st-key-prow-red"] {{ border-left-color: var(--red); background: linear-gradient(90deg, var(--red-tint) 0,
|
||||
var(--surface) 22%); }}
|
||||
div[class*="st-key-prow-yellow"] {{ border-left-color: var(--yellow); }}
|
||||
div[class*="st-key-prow-green"] {{ border-left-color: var(--green); }}
|
||||
div[class*="st-key-prow-grey"] {{ border-left-color: var(--grey); }}
|
||||
|
||||
/* ---------- chips, badges, bars ---------- */
|
||||
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px;
|
||||
background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }}
|
||||
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.8rem; font-weight:600; padding: 3px 10px;
|
||||
border-radius: 999px; white-space: nowrap; }}
|
||||
.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }}
|
||||
.mt-badge.t-yellow {{ background: var(--yellow-tint); color: var(--yellow-ink); }}
|
||||
.mt-badge.t-green {{ background: var(--green-tint); color: var(--green-ink); }}
|
||||
.mt-badge.t-grey {{ background: var(--grey-tint); color: var(--grey-ink); }}
|
||||
.mt-badge.t-blue {{ background: var(--blue-tint); color: var(--blue-ink); }}
|
||||
.mt-bar {{ height: 6px; background: var(--grey-tint); border-radius: 999px; overflow: hidden; margin-top: 5px; }}
|
||||
.mt-bar > span {{ display:block; height: 100%; border-radius: 999px; }}
|
||||
.mt-count {{ display:inline-flex; min-width: 26px; height: 26px; border-radius: 999px; align-items:center;
|
||||
justify-content:center; font-weight: 700; font-size: .85rem; }}
|
||||
.mt-tip {{ position: relative; cursor: help; }}
|
||||
.mt-tip:hover::after {{ content: attr(data-tip); position: absolute; z-index: 50; left: 0; top: 120%;
|
||||
min-width: 220px; max-width: 320px; white-space: pre-line; background: var(--ink); color: #fff;
|
||||
font-size: .78rem; font-weight: 400; line-height: 1.4; padding: 8px 10px; border-radius: 8px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.18); }}
|
||||
|
||||
/* ---------- banners & panels ---------- */
|
||||
.mt-banner {{ border-radius: 12px; padding: 12px 16px; display:flex; gap: 12px; align-items:flex-start;
|
||||
border: 1px solid; margin-bottom: 6px; }}
|
||||
.mt-banner.t-red {{ background: var(--red-tint); border-color: #F2C4C4; color: var(--red-ink); }}
|
||||
.mt-banner.t-grey {{ background: var(--grey-tint); border-color: var(--line); color: var(--grey-ink); }}
|
||||
.mt-banner b {{ color: inherit; }}
|
||||
.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none;
|
||||
box-shadow: 0 0 0 0 rgba(214,69,69,.6); animation: mtpulse 1.6s infinite; }}
|
||||
@keyframes mtpulse {{ 0% {{ box-shadow: 0 0 0 0 rgba(214,69,69,.55); }} 70% {{ box-shadow: 0 0 0 10px
|
||||
rgba(214,69,69,0); }} 100% {{ box-shadow: 0 0 0 0 rgba(214,69,69,0); }} }}
|
||||
.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650;
|
||||
color: var(--ink-3); margin: 2px 0 8px; }}
|
||||
.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-yellow {{ border-left-color: var(--yellow); }}
|
||||
.mt-alert.t-grey {{ border-left-color: var(--grey); }}
|
||||
.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.acked .why {{ color: var(--ink-3); font-weight: 500; }}
|
||||
.mt-summary {{ background: var(--blue-tint); border-radius: 12px; padding: 14px 16px; color: var(--ink);
|
||||
line-height: 1.5; font-size: .93rem; }}
|
||||
.mt-summary .disc {{ color: var(--blue-ink); font-size: .76rem; margin-top: 8px; }}
|
||||
.mt-plan ol {{ margin: 0; padding-left: 1.2rem; }}
|
||||
.mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }}
|
||||
|
||||
/* ---------- dose strip ---------- */
|
||||
.mt-doses {{ display:grid; gap: 4px; align-items:center; }}
|
||||
.mt-doses .cell {{ height: 26px; border-radius: 6px; display:flex; align-items:center; justify-content:center;
|
||||
font-size: .72rem; font-weight: 700; color: #fff; }}
|
||||
.mt-doses .c-taken {{ background: var(--green); }}
|
||||
.mt-doses .c-missed {{ background: var(--red); }}
|
||||
.mt-doses .c-skipped {{ background: var(--yellow); color: #3D2B00; }}
|
||||
.mt-doses .c-pending {{ background: var(--surface); border: 1.5px dashed var(--grey); color: var(--ink-3); }}
|
||||
.mt-doses .c-none {{ background: transparent; }}
|
||||
.mt-doses .lab {{ color: var(--ink-3); font-size: .72rem; text-align:center; font-variant-numeric: tabular-nums; }}
|
||||
.mt-legend {{ display:flex; gap: 14px; flex-wrap: wrap; color: var(--ink-2); font-size: .8rem; margin-top: 8px; }}
|
||||
.mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px;
|
||||
vertical-align: -1px; }}
|
||||
|
||||
/* ---------- connect result ---------- */
|
||||
.mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink);
|
||||
font-variant-numeric: tabular-nums; }}
|
||||
.mt-footer {{ color: var(--ink-3); font-size: .78rem; text-align: center; margin: 36px 0 8px;
|
||||
border-top: 1px solid var(--line); padding-top: 12px; }}
|
||||
|
||||
@media (max-width: 900px) {{
|
||||
.mt-kpis {{ grid-template-columns: repeat(2, minmax(0,1fr)); }}
|
||||
.mt-cols {{ display: none; }}
|
||||
.mt-row {{ grid-template-columns: 1fr 1fr; }}
|
||||
}}
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def plotly_layout(**overrides: object) -> dict[str, object]:
|
||||
"""Shared plotly layout: recessive grid, no chart junk, the same font as the page."""
|
||||
layout: dict[str, object] = {
|
||||
"font": {"family": FONT_STACK, "size": 12, "color": INK_2},
|
||||
"paper_bgcolor": "rgba(0,0,0,0)",
|
||||
"plot_bgcolor": "rgba(0,0,0,0)",
|
||||
"margin": {"l": 8, "r": 8, "t": 36, "b": 8},
|
||||
"hoverlabel": {"bgcolor": INK, "font": {"color": "#FFFFFF", "family": FONT_STACK}},
|
||||
"showlegend": False,
|
||||
"hovermode": "x unified",
|
||||
}
|
||||
layout.update(overrides)
|
||||
return layout
|
||||
|
||||
|
||||
AXIS = {"showgrid": True, "gridcolor": LINE, "zeroline": False, "linecolor": LINE, "tickcolor": LINE, "ticks": ""}
|
||||
PLOTLY_CONFIG = {"displayModeBar": False, "responsive": True}
|
||||
Reference in New Issue
Block a user