Files
PTSRaiAgent/dashboard/components/charts.py
ElebobaandClaude Opus 5.5 2582c73d12
CI / ruff · pytest (pull_request) Successful in 2m1s
feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect
- 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>
2026-10-04 12:51:07 +03:00

174 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Plotly figures of the patient card. Pure builders: data in, `go.Figure` out (tested without Streamlit).
Chart rules: one y-axis per chart (scales of different range are separate charts, daily metrics are small
multiples on a shared time axis), a single neutral series colour, status colours only for thresholds.
"""
from __future__ import annotations
from collections.abc import Iterable
from datetime import date, timedelta
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from app.api.schemas import SeriesPoint
from dashboard import theme
from dashboard.components.helpers import SCALE_MAX, metric_points, severity_bands, short_date
# Band shading: one warm hue with growing opacity (sequential), so "higher = worse" reads without a legend.
_BAND_ALPHA = [0.0, 0.05, 0.09, 0.14, 0.20]
def _day_ticks(days: list[date], max_ticks: int = 10) -> dict[str, object]:
if not days:
return {}
first, last = min(days), max(days)
span = (last - first).days + 1
step = max(1, -(-span // max_ticks))
ticks = [first + timedelta(days=i) for i in range(0, span, step)]
return {"tickmode": "array", "tickvals": ticks, "ticktext": [short_date(d) for d in ticks]}
def _point_label(p: SeriesPoint) -> str:
return f"{p.value:g}" if p.scheduled else f"{p.value:g} · внепл."
def _therapy_day(d: date, start: date | None) -> str:
return f"день {(d - start).days}" if start else short_date(d)
def scale_figure(
series: Iterable[SeriesPoint], scale_id: str, title: str, start: date | None = None
) -> go.Figure | None:
"""Total score over time with the severity gradations from config/scales; unscheduled sessions are hollow."""
pts = metric_points(series, scale_id)
if not pts:
return None
top = SCALE_MAX.get(scale_id, max(p.value for p in pts))
fig = go.Figure()
bands = severity_bands(scale_id)
for i, b in enumerate(bands):
alpha = _BAND_ALPHA[min(i, len(_BAND_ALPHA) - 1)] if len(bands) > 3 else [0.0, 0.08, 0.18][min(i, 2)]
fig.add_hrect(y0=b.lo, y1=b.hi + 1 if b.hi < top else top, line_width=0, fillcolor=f"rgba(214,69,69,{alpha})")
fig.add_annotation(
x=0,
xref="paper",
xanchor="left",
y=(b.lo + min(b.hi + 1, top)) / 2,
text=b.label,
showarrow=False,
font={"size": 10, "color": "rgba(122,130,140,0.85)"},
)
xs = [p.day for p in pts]
hover = [f"{short_date(p.day)} · {_therapy_day(p.day, start)}" for p in pts]
fig.add_trace(
go.Scatter(
x=xs,
y=[p.value for p in pts],
mode="lines+markers+text",
line={"color": theme.SERIES, "width": 2},
marker={
"size": [9 if p.scheduled else 13 for p in pts],
"symbol": ["circle" if p.scheduled else "diamond-open" for p in pts],
"color": theme.SERIES,
"line": {"width": [2 if p.scheduled else 2.5 for p in pts], "color": "#FFFFFF"},
},
text=[_point_label(p) if i == len(pts) - 1 else "" for i, p in enumerate(pts)],
textposition="top left",
textfont={"color": theme.INK, "size": 12},
customdata=[[h, "плановый" if p.scheduled else "внеплановый"] for h, p in zip(hover, pts, strict=True)],
hovertemplate="%{customdata[0]}<br><b>%{y:g}</b> баллов · %{customdata[1]}<extra></extra>",
name=title,
)
)
pad = timedelta(days=1)
fig.update_layout(
theme.plotly_layout(title={"text": title, "font": {"size": 14, "color": theme.INK}, "x": 0}, height=260),
hovermode="closest",
)
fig.update_xaxes(**theme.AXIS, **_day_ticks(xs, 4), tickangle=0, range=[min(xs) - pad, max(xs) + pad])
fig.update_yaxes(**theme.AXIS, range=[0, top], fixedrange=True)
return fig
def wellbeing_figure(series: Iterable[SeriesPoint], start: date | None = None) -> go.Figure | None:
"""Daily check-in as three small multiples on one time axis: mood 0–10, sleep hours, alcohol 0/1/2."""
data = list(series)
mood, sleep, alcohol = (metric_points(data, m) for m in ("mood", "sleep_hours", "alcohol"))
if not (mood or sleep or alcohol):
return None
days = sorted({p.day for p in mood + sleep + alcohol})
fig = make_subplots(
rows=3,
cols=1,
shared_xaxes=True,
vertical_spacing=0.09,
row_heights=[0.42, 0.33, 0.25],
subplot_titles=("Настроение, 0–10", "Сон, часов", "Алкоголь"),
)
red_line = {"color": theme.TONES["red"].solid, "width": 1, "dash": "dot"}
def hov(p: SeriesPoint) -> str:
return f"{short_date(p.day)} · {_therapy_day(p.day, start)}"
if mood:
# Mood ≤ 3 for three days triggers an unscheduled PHQ-9 (config/rules.yaml) — shade that zone.
fig.add_hrect(y0=0, y1=3, line_width=0, fillcolor="rgba(214,69,69,0.08)", row=1, col=1)
fig.add_trace(
go.Scatter(
x=[p.day for p in mood],
y=[p.value for p in mood],
mode="lines+markers",
line={"color": theme.SERIES, "width": 2},
marker={"size": 8, "color": theme.SERIES, "line": {"width": 2, "color": "#FFFFFF"}},
customdata=[hov(p) for p in mood],
hovertemplate="%{customdata}<br>настроение <b>%{y:g}</b>/10<extra></extra>",
),
row=1,
col=1,
)
if sleep:
fig.add_trace(
go.Bar(
x=[p.day for p in sleep],
y=[p.value for p in sleep],
marker={"color": [theme.TONES["red"].solid if p.value < 4 else "#9DBFEA" for p in sleep]},
marker_cornerradius=4,
customdata=[hov(p) for p in sleep],
hovertemplate="%{customdata}<br>сон ≈ <b>%{y:g}</b> ч<extra></extra>",
),
row=2,
col=1,
)
fig.add_hline(y=4, line=red_line, row=2, col=1)
if alcohol:
alc_label = {0: "нет", 1: "1–2 порции", 2: "больше"}
fig.add_trace(
go.Bar(
x=[p.day for p in alcohol],
y=[max(p.value, 0.08) for p in alcohol],
marker={
"color": [
{0: theme.LINE, 1: theme.TONES["yellow"].solid}.get(int(p.value), theme.TONES["red"].solid)
for p in alcohol
]
},
marker_cornerradius=4,
customdata=[[hov(p), alc_label.get(int(p.value), "?")] for p in alcohol],
hovertemplate="%{customdata[0]}<br>алкоголь: <b>%{customdata[1]}</b><extra></extra>",
),
row=3,
col=1,
)
fig.update_layout(theme.plotly_layout(height=430, bargap=0.25, hovermode="x"))
fig.update_annotations(font={"size": 12, "color": theme.INK_2}, x=0, xanchor="left")
pad = timedelta(hours=14)
fig.update_xaxes(**theme.AXIS, range=[days[0] - pad, days[-1] + pad])
fig.update_xaxes(**_day_ticks(days), row=3, col=1)
fig.update_yaxes(**theme.AXIS, fixedrange=True)
fig.update_yaxes(range=[0, 10.5], tickvals=[0, 3, 5, 10], row=1, col=1)
fig.update_yaxes(range=[0, 9], tickvals=[0, 4, 8], row=2, col=1)
fig.update_yaxes(range=[0, 2.2], tickvals=[0, 1, 2], ticktext=["0", "1–2", "3+"], row=3, col=1)
return fig