feat(web): screens from docs/mock — focus flow, today, meds, doctor, profile
- /flow renders the dialog engine's current step as a mock screen (consent,
age, onboarding and scales with progress, mood 0–10, free text, dose
reminder, crisis with hotlines and breathing, safety plan), so every
engine scenario gets the design without duplicating its logic
- today / meds / doctor / profile / messages tabs; navy sidebar and
emergency rail on desktop, emergency block and tab bar on phones
- API: GET /me/{token}/today (today's doses, week marks, next visit, demo
clock time); demo ЕМИАС sign-in seeds the persona's synthetic profile and
prescription from the dashboard fixtures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+134
-1
@@ -16,14 +16,19 @@ issued by the server instead of handing out the pseudonymous token itself.
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api import fixtures
|
||||
from app.core import clock
|
||||
from app.core.safety import CrisisResource, crisis_reply
|
||||
from app.db import repo
|
||||
from app.db.clinical import DailyCheckin, Dose, Prescription
|
||||
from app.db.identity import TOKEN_LEN
|
||||
from app.db.session import get_clinical_session
|
||||
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
|
||||
@@ -56,6 +61,43 @@ async def _attach_doctor(token: str, doctor_id: str) -> None:
|
||||
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
|
||||
|
||||
|
||||
async def _seed_demo_persona(token: str) -> None:
|
||||
"""Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses.
|
||||
|
||||
Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
|
||||
doctor's card tell the same story.
|
||||
"""
|
||||
card = fixtures.card(token)
|
||||
item = next((p for p in fixtures.PATIENTS if p.token == token), None)
|
||||
if card is None or item is None:
|
||||
return
|
||||
now = clock.now()
|
||||
today = now.date()
|
||||
async with get_clinical_session() as s:
|
||||
if await repo.get_profile(s, token) is None:
|
||||
await repo.upsert_profile(
|
||||
s,
|
||||
token=token,
|
||||
doctor_id=fixtures.DOCTOR_ID,
|
||||
modules=list(item.modules),
|
||||
therapy_start=today,
|
||||
next_visit=today + timedelta(days=15),
|
||||
)
|
||||
if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None:
|
||||
return
|
||||
for p in card.prescriptions:
|
||||
rx = Prescription(token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=today)
|
||||
s.add(rx)
|
||||
await s.flush()
|
||||
for t in p.schedule.get("times", []):
|
||||
hh, mm = (int(x) for x in t.split(":"))
|
||||
s.add(
|
||||
Dose(
|
||||
token=token, prescription_id=rx.id, planned_at=datetime.combine(today, time(hh, mm), now.tzinfo)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/emias", response_model=AuthOut)
|
||||
async def auth_emias(body: EmiasCodeIn) -> AuthOut:
|
||||
"""Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
|
||||
@@ -74,7 +116,7 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
|
||||
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
|
||||
if persona is None:
|
||||
raise HTTPException(404, "persona not found")
|
||||
await _attach_doctor(persona.token, fixtures.DOCTOR_ID)
|
||||
await _seed_demo_persona(persona.token)
|
||||
log.info("web auth: emias demo tok=%s", persona.token[:6])
|
||||
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
|
||||
|
||||
@@ -87,6 +129,97 @@ async def auth_anonymous() -> AuthOut:
|
||||
return AuthOut(token=token, mode="anonymous")
|
||||
|
||||
|
||||
class DoseItem(BaseModel):
|
||||
id: int
|
||||
drug: str
|
||||
dose: str
|
||||
time: str # HH:MM, patient's local time
|
||||
status: Literal["taken", "missed", "skipped", "pending"]
|
||||
|
||||
|
||||
class WeekDay(BaseModel):
|
||||
day: date
|
||||
status: Literal["taken", "missed", "pending", "none"]
|
||||
|
||||
|
||||
class PrescriptionItem(BaseModel):
|
||||
drug: str
|
||||
dose: str
|
||||
times: list[str]
|
||||
week: list[WeekDay] # Mon..Sun of the current week
|
||||
|
||||
|
||||
class TodayOut(BaseModel):
|
||||
today: date
|
||||
now: str # HH:MM on the (demo) clock — the client never uses its own clock
|
||||
has_doctor: bool
|
||||
checkin_done: bool
|
||||
next_visit: date | None
|
||||
doses: list[DoseItem]
|
||||
prescriptions: list[PrescriptionItem]
|
||||
|
||||
|
||||
def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]:
|
||||
if not statuses:
|
||||
return "none"
|
||||
if any(s in ("missed", "skipped") for s in statuses):
|
||||
return "missed"
|
||||
return "taken" if all(s == "taken" for s in statuses) else "pending"
|
||||
|
||||
|
||||
@router.get("/me/{token}/today", response_model=TodayOut)
|
||||
async def my_today(token: str) -> TodayOut:
|
||||
"""The patient's own plan for today (home screen) and this week's dose marks (medication screen).
|
||||
|
||||
Only the patient's own prescription data; no scores, no alerts. Anonymous tokens have no profile → empty plan.
|
||||
"""
|
||||
now = clock.now()
|
||||
today = now.date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
async with get_clinical_session() as s:
|
||||
profile = await repo.get_profile(s, token)
|
||||
prescriptions = (await s.scalars(select(Prescription).where(Prescription.token == token))).all()
|
||||
doses = (
|
||||
await s.scalars(
|
||||
select(Dose)
|
||||
.where(Dose.token == token, Dose.planned_at >= datetime.combine(week_start, time.min, now.tzinfo))
|
||||
.order_by(Dose.planned_at)
|
||||
)
|
||||
).all()
|
||||
checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today))
|
||||
|
||||
by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions}
|
||||
today_doses: list[DoseItem] = []
|
||||
marks: dict[tuple[int, date], list[str]] = defaultdict(list)
|
||||
for d in doses:
|
||||
local = d.planned_at.astimezone(now.tzinfo)
|
||||
marks[(d.prescription_id, local.date())].append(d.status)
|
||||
rx = by_rx.get(d.prescription_id)
|
||||
if local.date() == today and rx is not None:
|
||||
today_doses.append(
|
||||
DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status)
|
||||
)
|
||||
week = [week_start + timedelta(days=i) for i in range(7)]
|
||||
return TodayOut(
|
||||
today=today,
|
||||
now=now.strftime("%H:%M"),
|
||||
has_doctor=profile is not None,
|
||||
checkin_done=checkin is not None and checkin.mood is not None,
|
||||
next_visit=profile.next_visit if profile else None,
|
||||
doses=today_doses,
|
||||
prescriptions=[
|
||||
PrescriptionItem(
|
||||
drug=p.drug,
|
||||
dose=p.dose,
|
||||
times=list((p.schedule or {}).get("times", [])),
|
||||
week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week],
|
||||
)
|
||||
for p in prescriptions
|
||||
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/crisis/resources", response_model=list[CrisisResource])
|
||||
async def crisis_resources() -> list[CrisisResource]:
|
||||
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
|
||||
|
||||
Reference in New Issue
Block a user