"""Patient card: header, key numbers, charts, dose calendar, alerts feed with ack, safety plan, AI summary."""
from __future__ import annotations
from datetime import date
from html import escape
import streamlit as st
from app.api.schemas import AlertOut, PatientCard
from dashboard import theme
from dashboard.api_client import ApiError
from dashboard.components import charts, state, ui
from dashboard.components import helpers as h
def dose_strip_html(grid: h.DoseGrid, start: date | None) -> str:
"""Heat-strip: columns = days, rows = scheduled times. Glyphs duplicate colour for colour-blind readers."""
if not grid.days:
return '
Данных о приёме пока нет
'
glyph = {"taken": "✓", "missed": "✕", "skipped": "–", "pending": "·"}
label = {"taken": "принял", "missed": "нет ответа", "skipped": "пропустил", "pending": "ожидается"}
cols = f"56px repeat({len(grid.days)}, minmax(18px, 1fr))"
cells = ['']
cells += [f'{d.day}
' for d in grid.days]
for slot in grid.slots:
cells.append(f'{slot}
')
for d in grid.days:
status = grid.cells.get((slot, d))
if status is None:
cells.append('')
continue
day = f" · день {(d - start).days}" if start else ""
tip = f"{h.short_date(d)}{day}, {slot}: {label[status]}"
cells.append(f'{glyph[status]}
')
c = grid.counts
legend = (
''
f'принял · {c["taken"]}'
f'пропустил · {c["skipped"]}'
f'нет ответа · {c["missed"]}'
f'ожидается · {c["pending"]}'
"
"
)
return (
f'{legend}"
)
def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str:
return (
f'{escape(label)}
'
f'
{value}
{escape(hint)}
'
)
def _score_tile(card: PatientCard, scale_id: str, title: str) -> str:
last, prev = h.last_and_prev(card.series, scale_id)
if last is None:
return _tile(title, '—', "не проводился", "grey")
delta = ""
if prev is not None and last != prev:
arrow = "↑" if last > prev else "↓"
tone = "red" if last > prev else "green"
delta = f' {arrow} с {prev:g}'
sev = h.severity_label(scale_id, last)
return _tile(title, f"{last:g}{delta}", sev, "red" if last > (prev or last) else "blue")
def _header(card: PatientCard, today: date | None) -> None:
p = card.patient
status = h.status_view(p.status_color)
risk = h.risk_view(p.discontinuation_risk)
chips = "".join(ui.chip(h.MODULE_LABELS.get(m, m)) for m in p.modules)
meta = (
f"{h.age_label(p.age)} · {chips} · {escape(p.drug or 'без назначения')} · день терапии {p.therapy_day}"
f" · {h.visit_label(p.next_visit, today)}"
)
st.html(
''
f'
{ui.badge(status.text, status.tone, status.hint, icon="dot")}
'
f'
{escape(p.display_name)}
{meta}
'
f'
Риск прерывания терапии
'
f"{ui.badge(risk.text, risk.tone, risk.hint)}"
f'
{escape(", ".join(p.discontinuation_risk.factors))}
'
"
"
)
def _ack(alert: AlertOut) -> None:
try:
res = state.client().ack(alert.id)
except ApiError as exc:
st.toast(f"Не удалось подтвердить: {exc.message}", icon="⚠️")
return
state.local_acks()[alert.id] = (res.acked_by or state.client().doctor_id, res.acked_at or state.now() or alert.ts)
st.toast(f"Алерт подтверждён: {alert.reason}", icon="✅")
def _alerts_feed(alerts: list[AlertOut], today: date | None) -> None:
st.html('Алерты
')
if not alerts:
st.caption("Сигналов нет")
return
for a in h.sort_alerts(alerts):
tone = h.level_tone(a.level)
acked = a.acked_at is not None
level = h.LEVEL_LABELS.get(a.level, a.level)
status = f"подтверждён {h.when_label(a.acked_at, today)}" if a.acked_at else "открыт"
st.html(
f''
f'
{theme.TONES[tone].icon} {level} · {escape(h.when_label(a.ts, today))}
'
f'
{escape(a.reason)}
'
f'
{escape(status)}
'
)
if not acked:
st.button(
"Подтвердить",
key=f"ack-{a.id}",
on_click=_ack,
args=(a,),
icon=":material/check:",
type="primary" if a.level == "acute" else "secondary",
help="Врач видел сигнал и принял меры (запись в журнал аудита)",
)
def _summary(text: str) -> None:
st.html('Саммари (ИИ, по структурированным данным)
')
body = escape(text) if text else 'Саммари ещё не сформировано'
st.html(
f'{body}
Сформировано по структурированным данным '
"(шкалы, чек-ины, приём, алерты) без свободного текста пациента. Решения принимает врач.
"
)
def _safety_plan(plan: list[str] | None) -> None:
st.html('План безопасности
')
if not plan:
st.caption("Не составлен. Составляется с пациентом при острых сигналах (Stanley–Brown).")
return
items = "".join(f"{escape(step)}" for step in plan)
st.html(f'')
def _prescriptions(card: PatientCard) -> None:
st.html('Назначения
')
if not card.prescriptions:
st.caption(card.patient.drug or "Нет активных назначений")
return
for rx in card.prescriptions:
times = ", ".join(rx.schedule.get("times", []))
since = f" · с {h.short_date(rx.start_date)}" if rx.start_date else ""
st.html(
f'{escape(rx.drug)} {escape(rx.dose)}'
f'
{escape(times)} · {escape(rx.instructions or "без особых указаний")}{since}
'
"
"
)
def render(card: PatientCard, today: date | None) -> None:
card = card.model_copy(update={"alerts": h.apply_local_acks(card.alerts, state.local_acks())})
start = card.prescriptions[0].start_date if card.prescriptions else None
_header(card, today)
grid = h.dose_grid(card.doses)
adh = h.adherence_view(card.patient.adherence_7d)
open_alerts = [a for a in card.alerts if a.acked_at is None]
taken = f"{grid.counts['taken']} из {sum(grid.counts[s] for s in ('taken', 'missed', 'skipped'))} доз за курс"
st.html(
''
+ _score_tile(card, "phq9", "PHQ-9 · депрессия")
+ _score_tile(card, "pcl5", "PCL-5 · ПТСР")
+ _tile("Приём за 7 дней", adh.text, taken if grid.days else "", adh.tone)
+ _tile(
"Открытые алерты",
str(len(open_alerts)),
"есть кризисный" if any(a.level == "acute" for a in open_alerts) else "",
"red" if any(a.level == "acute" for a in open_alerts) else ("yellow" if open_alerts else "green"),
)
+ "
"
)
main, side = st.columns([1.75, 1], gap="large")
with main:
with st.container(border=True, key="panel-scales"):
c1, c2 = st.columns(2)
for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")):
fig = charts.scale_figure(card.series, scale_id, title, start)
with col:
if fig is None:
st.caption("Шкала ещё не проводилась")
else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}")
st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.")
with st.container(border=True, key="panel-wellbeing"):
fig = charts.wellbeing_figure(card.series, start)
if fig is None:
st.caption("Ежедневных чек-инов пока нет")
else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing")
st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.")
with st.container(border=True, key="panel-doses"):
st.html('Календарь приёма
')
st.html(dose_strip_html(grid, start))
with side:
with st.container(border=True, key="panel-alerts"):
_alerts_feed(card.alerts, today)
with st.container(border=True, key="panel-summary"):
_summary(card.summary)
with st.container(border=True, key="panel-safety"):
_safety_plan(card.safety_plan)
with st.container(border=True, key="panel-prescriptions"):
_prescriptions(card)