From b4b1bc51c86b6d37f8a1bef2e2eb1c127b6c8b50 Mon Sep 17 00:00:00 2001 From: Arxip222 Date: Sun, 4 Oct 2026 08:11:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20screens=20from=20docs/mock=20?= =?UTF-8?q?=E2=80=94=20focus=20flow,=20today,=20meds,=20doctor,=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- app/api/patient_web.py | 135 +- tests/test_api_patient_web.py | 33 + web/.gitignore | 1 + web/README.md | 13 +- web/src/api/client.ts | 3 +- web/src/api/types.ts | 27 + web/src/components/AppShell.tsx | 206 ++- web/src/components/HelpSheet.tsx | 98 +- web/src/components/Icon.tsx | 130 +- web/src/content/ru.ts | 191 +- web/src/dialog/DialogContext.tsx | 115 ++ web/src/dialog/step.ts | 108 ++ web/src/main.tsx | 37 +- web/src/screens/AnonymousSignIn.tsx | 88 +- web/src/screens/Chat.tsx | 267 --- web/src/screens/Doctor.tsx | 73 + web/src/screens/EmiasSignIn.tsx | 132 +- web/src/screens/Flow.tsx | 466 +++++ web/src/screens/Meds.tsx | 147 ++ web/src/screens/Messages.tsx | 96 + web/src/screens/Profile.tsx | 133 ++ web/src/screens/Today.tsx | 177 ++ web/src/screens/Welcome.tsx | 164 +- web/src/screens/useToday.ts | 42 + web/src/session/SessionContext.tsx | 7 +- web/src/session/store.ts | 20 +- web/src/theme/global.css | 2555 ++++++++++++++++----------- web/src/theme/tokens.css | 101 +- 28 files changed, 3795 insertions(+), 1770 deletions(-) create mode 100644 web/src/dialog/DialogContext.tsx create mode 100644 web/src/dialog/step.ts delete mode 100644 web/src/screens/Chat.tsx create mode 100644 web/src/screens/Doctor.tsx create mode 100644 web/src/screens/Flow.tsx create mode 100644 web/src/screens/Meds.tsx create mode 100644 web/src/screens/Messages.tsx create mode 100644 web/src/screens/Profile.tsx create mode 100644 web/src/screens/Today.tsx create mode 100644 web/src/screens/useToday.ts diff --git a/app/api/patient_web.py b/app/api/patient_web.py index 129f975..3e58ec5 100644 --- a/app/api/patient_web.py +++ b/app/api/patient_web.py @@ -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.""" diff --git a/tests/test_api_patient_web.py b/tests/test_api_patient_web.py index e8da9eb..68db13e 100644 --- a/tests/test_api_patient_web.py +++ b/tests/test_api_patient_web.py @@ -1,6 +1,7 @@ """Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine.""" from collections.abc import AsyncIterator +from datetime import datetime, time, timedelta import pytest from httpx import ASGITransport, AsyncClient @@ -9,6 +10,7 @@ from app.api import fixtures from app.core import clock from app.db import identity_repo, repo, session from app.db.bootstrap import init_db +from app.db.clinical import Dose, Prescription from app.main import app MEM = "sqlite+aiosqlite:///:memory:" @@ -81,6 +83,37 @@ async def test_emias_demo_persona(client: AsyncClient) -> None: assert missing.status_code == 404 +async def test_today_plan_and_week_marks(client: AsyncClient) -> None: + now = clock.now() + today = now.date() + async with session.get_clinical_session() as s: + await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", next_visit=today + timedelta(days=5)) + rx = Prescription(token=TOKEN, drug="sertraline", dose="50 мг", schedule={"times": ["09:00", "21:00"]}) + s.add(rx) + await s.flush() + morning = datetime.combine(today, time(9, 0), now.tzinfo) + s.add_all( + [ + Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning, status="taken"), + Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning + timedelta(hours=12)), + ] + ) + await repo.upsert_checkin(s, token=TOKEN, day=today, now=now, mood=6) + + body = (await client.get(f"/api/v1/me/{TOKEN}/today")).json() + assert body["has_doctor"] and body["checkin_done"] + assert body["next_visit"] == (today + timedelta(days=5)).isoformat() + assert [(d["time"], d["status"]) for d in body["doses"]] == [("09:00", "taken"), ("21:00", "pending")] + week = body["prescriptions"][0]["week"] + assert len(week) == 7 and next(w for w in week if w["day"] == today.isoformat())["status"] == "pending" + + +async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None: + token = (await client.post("/api/v1/auth/anonymous")).json()["token"] + body = (await client.get(f"/api/v1/me/{token}/today")).json() + assert body["has_doctor"] is False and body["doses"] == [] and body["prescriptions"] == [] + + async def test_crisis_resources_come_from_config(client: AsyncClient) -> None: phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()] assert "112" in phones diff --git a/web/.gitignore b/web/.gitignore index f4e2c6d..e4cd754 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ *.tsbuildinfo +.vite/ diff --git a/web/README.md b/web/README.md index db2e6a3..d235068 100644 --- a/web/README.md +++ b/web/README.md @@ -30,9 +30,20 @@ src/theme/tokens.css цвета, шрифт, отступы — единств src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок) src/api/ типы-зеркала контрактов бэкенда и клиент src/session/store.ts хранение сессии за интерфейсом: localStorage → Capacitor Preferences/Keychain -src/screens/ экраны: Welcome, EmiasSignIn, AnonymousSignIn, Chat +src/dialog/ общий разговор с движком (DialogContext) и выбор вида экрана по ответу движка (step.ts) +src/screens/ экраны по docs/mock: вход A1–A3, фокус-экран /flow (A4–A6, B, D, E1–E3, E5, G2–G4), + «Сегодня» C1/C2, «Лекарства» E4, «Врач» F, «Профиль» H, «Сообщения» I1 ``` +### Как устроены экраны + +Логика сценариев живёт только в движке диалога. Экран `/flow` берёт последний ответ движка и рисует его +как отдельный экран мокапа: вопрос анкеты с прогрессом «Вопрос n из N», шкала 0–10, свободный текст, +напоминание о лекарстве, согласие, кризисный экран. Новый сценарий в движке появится в вебе без правок фронтенда. + +Данные для «Сегодня» и «Лекарства» — `GET /api/v1/me/{token}/today` (дозы на сегодня, отметки за неделю, +следующий визит, время по демо-часам). + Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API). diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c512b5c..aeaeb2a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,4 @@ -import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply } from "./types"; +import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply, TodayPlan } from "./types"; // Web: same-origin /api/v1 (Vite proxy in dev). Mobile shell: set VITE_API_BASE to the absolute API URL at build. const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1"; @@ -35,6 +35,7 @@ export const api = { signInAnonymous: () => post("/auth/anonymous"), demoPersonas: () => request("/demo/personas"), crisisResources: () => request("/crisis/resources"), + today: (token: string) => request(`/me/${encodeURIComponent(token)}/today`), sendEvent: async (token: string, event: DialogEvent) => (await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies, outbox: async (token: string) => diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1e40513..1786f60 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -39,3 +39,30 @@ export interface DemoPersona { therapy_day: number; scenario: string | null; } + +export type DoseStatus = "taken" | "missed" | "skipped" | "pending"; + +export interface TodayDose { + id: number; + drug: string; + dose: string; + time: string; + status: DoseStatus; +} + +export interface TodayPrescription { + drug: string; + dose: string; + times: string[]; + week: { day: string; status: "taken" | "missed" | "pending" | "none" }[]; +} + +export interface TodayPlan { + today: string; + now: string; + has_doctor: boolean; + checkin_done: boolean; + next_visit: string | null; + doses: TodayDose[]; + prescriptions: TodayPrescription[]; +} diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 42dbd69..10dd890 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -1,64 +1,176 @@ -import { useState, type ReactNode } from "react"; -import { Link } from "react-router-dom"; +import type { ReactNode } from "react"; +import { Link, NavLink } from "react-router-dom"; import { ru } from "../content/ru"; -import { HelpSheet } from "./HelpSheet"; -import { Icon, LogoMark } from "./Icon"; +import { useDialog } from "../dialog/DialogContext"; +import { HotlineCard, useHelp } from "./HelpSheet"; +import { Icon, LogoMark, type IconName } from "./Icon"; -export function AppShell({ children, footer = true }: { children: ReactNode; footer?: boolean }) { - const [helpOpen, setHelpOpen] = useState(false); +/* + * Layout from docs/mock (series 2). + * phone: content → «Экстренная помощь» block → bottom tab bar (signed in) + * desktop: navy sidebar | content | right rail (optional `aside` + emergency panel) + */ +const TABS: { to: string; label: string; icon: IconName }[] = [ + { to: "/today", label: ru.nav.today, icon: "home" }, + { to: "/meds", label: ru.nav.meds, icon: "pill" }, + { to: "/doctor", label: ru.nav.doctor, icon: "doctor" }, + { to: "/profile", label: ru.nav.profile, icon: "user" }, +]; + +/** Inline emergency block shown at the bottom of every phone screen (hidden on desktop: the rail has it). */ +export function EmergencyBlock() { + const { resources, open } = useHelp(); return ( -
-
-
- - - - {ru.product.name} - {ru.product.tagline} - - - -
-
+
+ +
+ {resources.slice(0, 2).map((r) => ( + + ))} +
+
+ ); +} - {children} - - {footer && ( -
-
-

- - {ru.footer.law} -

-

{ru.footer.prototype}

-
-
+function Sidebar({ signedIn }: { signedIn: boolean }) { + const { open } = useHelp(); + const { unread } = useDialog(); + return ( + + ); +} - setHelpOpen(false)} /> +function BottomNav() { + return ( + + ); +} + +function Rail({ aside }: { aside?: ReactNode }) { + const { resources, open } = useHelp(); + return ( + + ); +} + +export function AppShell({ + children, + signedIn = true, + aside, + focus = false, + sos = true, +}: { + children: ReactNode; + signedIn?: boolean; + aside?: ReactNode; + /** Focus screens (one question per screen) hide the tab bar on phones, like in the mocks. */ + focus?: boolean; + /** The crisis screen shows the hotlines itself, so it drops the bottom block. */ + sos?: boolean; +}) { + return ( +
+ +
+
+ {children} + {sos && } +
+ +
+ {signedIn && !focus && }
); } -export function BackLink({ to, children }: { to: string; children: ReactNode }) { - return ( - - - {children} - - ); -} - -export function Notice({ tone = "info", children }: { tone?: "info" | "warning"; children: ReactNode }) { +export function Notice({ tone = "info", children }: { tone?: "info" | "warn" | "danger" | "ok"; children: ReactNode }) { + const icon: IconName = tone === "info" ? "info" : tone === "ok" ? "check" : tone === "danger" ? "x" : "alert"; return (
- -

{children}

+ +
{children}
+
+ ); +} + +export function TopBar({ onBack, title, children }: { onBack?: () => void; title?: string; children?: ReactNode }) { + return ( +
+ {onBack && ( + + )} + {title && {title}} + {children}
); } diff --git a/web/src/components/HelpSheet.tsx b/web/src/components/HelpSheet.tsx index b112a9f..7c82bda 100644 --- a/web/src/components/HelpSheet.tsx +++ b/web/src/components/HelpSheet.tsx @@ -1,18 +1,27 @@ -import { useEffect, useRef, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; import { api } from "../api/client"; import type { CrisisResource } from "../api/types"; import { ru } from "../content/ru"; import { Icon } from "./Icon"; -// 112 is shown even when the API is unreachable: the help sheet must never be empty. -const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: ru.help.fallbackNote }]; +// 112 is shown even when the API is unreachable: emergency help must never be empty (S2 «нет сети»). +const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: "" }]; -const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`; +export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`; -export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => void }) { - const ref = useRef(null); +interface HelpApi { + resources: CrisisResource[]; + open: () => void; +} + +const Ctx = createContext({ resources: FALLBACK, open: () => undefined }); +export const useHelp = () => useContext(Ctx); + +/** Crisis lines (from config/texts/crisis.yaml via the API) + the G1 «Экстренная помощь» sheet. */ +export function HelpProvider({ children }: { children: ReactNode }) { const [resources, setResources] = useState(FALLBACK); + const [isOpen, setOpen] = useState(false); useEffect(() => { api @@ -21,6 +30,44 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi .catch(() => undefined); }, []); + const open = useCallback(() => setOpen(true), []); + return ( + + {children} + setOpen(false)} resources={resources} /> + + ); +} + +export function HotlineCard({ r, size = "lg" }: { r: CrisisResource; size?: "lg" | "sm" }) { + const emergency = r.phone.replace(/\D/g, "").length <= 3; + return ( + + + + + + {emergency ? ( + <> + {r.phone} + {r.name} + + ) : ( + <> + {r.name} + {r.phone} + + )} + {r.note && size === "lg" && {r.note}} + + {size === "lg" && } + + ); +} + +function HelpSheet({ open, onClose, resources }: { open: boolean; onClose: () => void; resources: CrisisResource[] }) { + const ref = useRef(null); + useEffect(() => { const dialog = ref.current; if (!dialog) return; @@ -37,32 +84,31 @@ export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => voi onClick={(e) => e.target === ref.current && onClose()} >
-
-

- {ru.help.title} -

+
+

+ {ru.help.title} +

-

{ru.help.lead}

- +
+

+ + {ru.help.alwaysNote} +

); diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx index d9279ec..8ecd5d1 100644 --- a/web/src/components/Icon.tsx +++ b/web/src/components/Icon.tsx @@ -1,14 +1,37 @@ -// Minimal line icon set (24px grid, 1.75 stroke). Decorative by default: pass `label` to expose to screen readers. +// Line icon set (24px grid, 1.8 stroke). Decorative by default: pass `label` to expose to screen readers. const paths = { - check: , - minus: , - phone: ( - + home: , + pill: ( + <> + + + ), + doctor: ( + <> + + + + + ), + user: ( + <> + + + + ), + bell: ( + <> + + + + ), + chevronRight: , arrowLeft: , - arrowRight: , - send: , + close: , + check: , + x: , info: ( <> @@ -21,33 +44,88 @@ const paths = { ), + phone: ( + + ), + headset: ( + <> + + + + + ), + calendar: ( + <> + + + + ), + clock: ( + <> + + + + ), + pin: ( + <> + + + + ), + smile: ( + <> + + + + ), + doc: ( + <> + + + + ), + docPlus: ( + <> + + + + ), + shield: ( + <> + + + + ), lock: ( <> - + ), - pill: ( - <> - - - - ), - pulse: , - doctor: ( - <> - - - - - ), - close: , + edit: , + trash: , logout: , + send: , + heart: , + lungs: ( + <> + + + + ), + question: ( + <> + + + + ), + message: , + plus: , } as const; export type IconName = keyof typeof paths; -export function Icon({ name, size = 20, label }: { name: IconName; size?: number; label?: string }) { +export function Icon({ name, size = 22, label }: { name: IconName; size?: number; label?: string }) { return ( ); diff --git a/web/src/content/ru.ts b/web/src/content/ru.ts index e8b851f..89b9a83 100644 --- a/web/src/content/ru.ts +++ b/web/src/content/ru.ts @@ -1,100 +1,165 @@ /* - * All interface texts of the patient app. No user-facing strings inside components (same rule as config/texts/ - * for the bot). Dialog content (consent, questions, scales) comes from the engine and is NOT duplicated here. + * All interface texts of the patient app (copy from docs/mock). Dialog content — consent, questions, scales, + * reminders — comes from the engine (config/texts/) and is NOT duplicated here. */ export const ru = { product: { name: "Сопровождение пациента", tagline: "между визитами к врачу", - prototype: "Прототип · синтетические данные", + }, + nav: { + today: "Сегодня", + meds: "Лекарства", + doctor: "Врач", + profile: "Профиль", + messages: "Сообщения", + signIn: "Вход", + help: "Помощь", }, help: { - button: "Нужна помощь", - title: "Если вам сейчас очень тяжело", - lead: "Не ждите ответа врача, если помощь нужна прямо сейчас. Позвоните:", + block: "Экстренная помощь", + title: "Экстренная помощь", + lead: "Если вам нужна срочная помощь, позвоните сразу.", call: "Позвонить", close: "Закрыть", - fallbackName: "Экстренные службы", - fallbackNote: "если есть угроза жизни прямо сейчас", + alwaysNote: "Вы не одни. Помощь доступна круглосуточно.", + fallbackName: "Единый номер экстренных служб", + fallbackNote: "", + }, + common: { + back: "Назад", + next: "Далее", + skip: "Пропустить", + send: "Отправить", + retry: "Повторить", + toToday: "Вернуться на сегодня", + recommended: "Рекомендуем", + networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.", }, welcome: { - title: "Лечение продолжается и между приёмами", - lead: "Напомним о лекарствах, раз в день спросим о самочувствии и сообщим врачу, если состоянию нужно внимание. На ответы уходит пара минут.", - day: { - caption: "Обычный день с сервисом", - meds: { time: "09:00", track: "Лекарства", text: "Утренний приём", done: "Принято" }, - mood: { time: "14:00", track: "Самочувствие", text: "Как вы сегодня?", low: "Тяжело", high: "Хорошо" }, - doctor: { time: "20:00", track: "Врач", text: "Динамика за неделю видна врачу" }, - }, - choose: "Как вы хотите войти?", + title: "Как вы хотите войти в приложение?", + lead: "Выберите удобный способ. Вы всегда можете сменить его позже в профиле.", emias: { title: "Через ЕМИАС", - badge: "Рекомендуем", - facts: [ - { ok: true, text: "Назначения врача подгружаются автоматически" }, - { ok: true, text: "Врач видит ваши ответы и получает сигнал при ухудшении" }, - { ok: true, text: "История доступна с любого устройства" }, - ], - action: "Войти через ЕМИАС", + text: "Назначения врача подгружаются сами, врач видит ответы и получает сигнал при ухудшении, история доступна с любого устройства.", }, anonymous: { title: "Анонимно", - facts: [ - { ok: true, text: "Не нужны имя, полис и телефон" }, - { ok: true, text: "Опросы самочувствия и помощь в кризисе доступны" }, - { ok: false, text: "Врач не видит ваши ответы и не получает сигналов" }, - { ok: false, text: "История доступна только на этом устройстве" }, - ], - action: "Продолжить анонимно", + text: "Не нужны имя, полис и телефон, врач ничего не видит, история только на этом устройстве.", }, disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.", }, emias: { - back: "Способы входа", - title: "Вход через ЕМИАС", - demoNotice: - "Демонстрационный режим. Авторизация в ЕМИАС не выполняется: вход подтверждается одноразовым кодом, который врач выдаёт на приёме.", - codeLabel: "Код подключения от врача", - codeHint: "Код указан в памятке или под QR-кодом, который вы получили на приёме.", + title: "Введите код от врача", + lead: "Это одноразовый код. Он нужен, чтобы безопасно подключить ваши назначения и план наблюдения.", + demoNotice: "Демонстрационный режим: авторизация в ЕМИАС не выполняется, вход подтверждается кодом от врача.", + codeLabel: "Код подключения", codePlaceholder: "Код из памятки", - submit: "Войти", + submit: "Продолжить", submitting: "Проверяем код…", - codeError: "Код не подошёл. Проверьте написание или попросите врача выдать новый код.", - networkError: "Сервис временно недоступен. Попробуйте ещё раз через минуту.", + codeError: "Код не подошёл — проверьте написание или попросите врача выдать новый.", or: "или", demoTitle: "Демо-пациенты", - demoLead: "Нет кода? Войдите под синтетической учётной записью, чтобы посмотреть сценарий.", + demoLead: "Нет кода? Войдите под синтетической учётной записью.", demoDay: (day: number) => `${day}-й день терапии`, - demoAction: "Войти", - demoEmpty: "Демо-пациенты недоступны.", }, anonymous: { - back: "Способы входа", title: "Анонимный вход", - lead: "Мы не спрашиваем имя, полис и телефон. Ваши ответы хранятся под случайным идентификатором, по которому вас нельзя узнать.", - points: [ - "Доступны опросы самочувствия, дневник и помощь в кризисной ситуации.", - "Лечащий врач не видит ваши ответы и не получает сигналов об ухудшении.", - "Перед началом вы дадите согласие на обработку ответов — без него ничего не сохраняется.", - ], - warning: - "История привязана к этому устройству. Если вы выйдете или очистите данные браузера, восстановить её будет нельзя.", - submit: "Начать анонимно", - submitting: "Создаём анонимный профиль…", + lead: "Вы сможете пользоваться основными функциями приложения без входа через ЕМИАС.", + yesTitle: "Что будет доступно:", + yes: ["Ежедневные отметки", "Напоминания", "Анкеты и помощь в кризисе"], + noTitle: "Чего не будет:", + no: ["Врач не увидит ответы", "История только на этом устройстве"], + warning: "После выхода историю восстановить нельзя.", + submit: "Продолжить анонимно", + submitting: "Создаём профиль…", }, - chat: { - modeEmias: "ЕМИАС", - modeAnonymous: "Анонимно", + today: { + title: "Сегодня", + takeMed: "Принять лекарство", + now: "Сейчас", + checkin: "Отметка самочувствия", + checkinHint: "меньше минуты", + done: "Уже выполнено", + medTaken: "Лекарство", + medTakenAt: (time: string) => `принято, ${time}`, + checkinDone: "Самочувствие отмечено", + allDoneTitle: "На сегодня всё сделано", + allDoneNext: (time: string) => `Следующее напоминание о лекарстве — ${time}`, + allDoneNoMeds: "Следующая отметка самочувствия — завтра", + viewMeds: "Посмотреть лекарства", + visit: "Следующий визит к врачу", + medQuestion: "Вопрос о лекарстве", + messageDoctor: "Сообщение врачу", + noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.", + newMessage: "Новое сообщение", + open: "Открыть", + }, + meds: { + title: "Моя схема приёма", + lead: "По назначению врача. Отмечайте приём препаратов, чтобы не пропускать.", + byDoctor: "По назначению врача", + tablet: "1 приём", + week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"], + onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.", + empty: "Назначения появятся здесь после входа через ЕМИАС.", + question: "Вопрос о лекарстве", + reminderTitle: "Время принять препарат", + reminderLead: "По назначению врача", + taken: "Принял(а)", + skip: "Пропущу", + sideEffects: "Есть побочные эффекты", + }, + doctor: { + title: "Врач", + visit: "Следующий визит", + visitNone: "Дата следующего визита пока не назначена", + attending: "Ваш лечащий врач", + write: "Написать врачу", + writeNote: "Обычно врач отвечает в рабочее время. Если срочно — 112.", + earlier: "Записаться к врачу раньше", + plan: "Мой план безопасности", + noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы связаться с лечащим врачом.", + }, + profile: { + title: "Профиль", + signInMode: "Способ входа", + emias: "ЕМИАС", + anonymous: "Анонимно", + connectEmias: "Подключить ЕМИАС", + data: "Мои данные", + revoke: "Отозвать согласие", + revokeTitle: "Отозвать согласие?", + revokeText: "Ваши ответы больше не будут обрабатываться в приложении.", + revokeConfirm: "Да, отозвать", + cancel: "Отмена", + wipe: "Удалить данные с устройства", + wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.", logout: "Выйти", - logoutConfirm: "Выйти? Анонимную историю восстановить будет нельзя.", + messages: "Сообщения", + }, + flow: { + question: (n: number, total: number) => `Вопрос ${n} из ${total}`, + period: "За последний месяц", + textPlaceholder: "Напишите несколько слов", + sending: "Отправляем…", + doneTitle: "Готово", + doneLead: "Врач увидит отметку.", + breathe: "Вдох на 4 — выдох на 6", + breatheIn: "Вдох", + breatheOut: "Выдох", + openPlan: "Открыть мой план безопасности", + supportTitle: "Вы не одни", + exit: "К главной", + planTitle: "Мой план безопасности", + consentTitle: "Согласие на обработку данных о здоровье", + }, + messages: { + title: "Сообщения", inputLabel: "Сообщение", inputPlaceholder: "Напишите сообщение", - send: "Отправить", - loading: "Подключаемся…", - sendError: "Сообщение не отправлено. Проверьте соединение и попробуйте ещё раз.", you: "Вы", - assistant: "Помощник", - crisisLabel: "Важно", + empty: "Здесь будут сообщения помощника.", }, footer: { law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).", diff --git a/web/src/dialog/DialogContext.tsx b/web/src/dialog/DialogContext.tsx new file mode 100644 index 0000000..13d4793 --- /dev/null +++ b/web/src/dialog/DialogContext.tsx @@ -0,0 +1,115 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; + +import { api } from "../api/client"; +import type { DialogEvent, Reply } from "../api/types"; +import { useSession } from "../session/SessionContext"; +import { sessionStore, type ChatEntry, type DialogSnapshot } from "../session/store"; + +/* + * One conversation with the dialog engine per signed-in patient, shared by every screen: + * focus screens render `pending` (the latest unanswered batch), «Сообщения» renders `entries`. + * The engine owns the logic (consent, onboarding, scales, check-ins, meds, crisis) — exactly as in Telegram. + */ + +const OUTBOX_POLL_MS = 6000; + +let seq = 0; +const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`; +const asEntries = (replies: Reply[]): ChatEntry[] => replies.map((reply) => ({ id: newId(), from: "assistant", reply })); + +interface DialogApi extends DialogSnapshot { + busy: boolean; + error: boolean; + started: boolean; + /** Sends an event; `echo` is what the patient «said» (button label / text) for the history. */ + send: (event: DialogEvent, echo?: string) => Promise; + start: () => Promise; + markRead: () => void; +} + +const Ctx = createContext(null); +const EMPTY: DialogSnapshot = { entries: [], pending: [], unread: 0 }; + +export function DialogProvider({ children }: { children: ReactNode }) { + const { session } = useSession(); + const token = session?.token ?? null; + const [owner, setOwner] = useState(token); + const [snap, setSnap] = useState(() => (token ? sessionStore.loadDialog(token) : EMPTY)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const tokenRef = useRef(token); + tokenRef.current = token; + + // Switching accounts: reset to that token's snapshot during render, so it is never saved under another token. + if (owner !== token) { + setOwner(token); + setSnap(token ? sessionStore.loadDialog(token) : EMPTY); + setError(false); + } + + useEffect(() => { + if (token && owner === token) sessionStore.saveDialog(token, snap); + }, [token, owner, snap]); + + const send = useCallback( + async (event: DialogEvent, echo?: string) => { + const t = tokenRef.current; + if (!t) return false; + setBusy(true); + setError(false); + try { + const replies = await api.sendEvent(t, event); + setSnap((prev) => ({ + entries: [ + ...prev.entries, + ...(echo ? [{ id: newId(), from: "patient" as const, text: echo }] : []), + ...asEntries(replies), + ], + pending: replies, + unread: 0, + })); + return true; + } catch { + setError(true); + return false; + } finally { + setBusy(false); + } + }, + [], + ); + + const start = useCallback(() => send({ type: "start" }), [send]); + const markRead = useCallback(() => setSnap((prev) => (prev.unread ? { ...prev, unread: 0 } : prev)), []); + + // System-initiated messages (reminders, check-ins, scales) arrive through the outbox. + useEffect(() => { + if (!token) return; + const timer = window.setInterval(() => { + api + .outbox(token) + .then((replies) => { + if (!replies.length || tokenRef.current !== token) return; + setSnap((prev) => ({ + entries: [...prev.entries, ...asEntries(replies)], + pending: replies, + unread: prev.unread + replies.length, + })); + }) + .catch(() => undefined); + }, OUTBOX_POLL_MS); + return () => window.clearInterval(timer); + }, [token]); + + const value = useMemo( + () => ({ ...snap, busy, error, started: snap.entries.length > 0, send, start, markRead }), + [snap, busy, error, send, start, markRead], + ); + return {children}; +} + +export function useDialog(): DialogApi { + const value = useContext(Ctx); + if (!value) throw new Error("useDialog outside DialogProvider"); + return value; +} diff --git a/web/src/dialog/step.ts b/web/src/dialog/step.ts new file mode 100644 index 0000000..8afbff3 --- /dev/null +++ b/web/src/dialog/step.ts @@ -0,0 +1,108 @@ +import type { DialogButton, Reply } from "../api/types"; + +/* + * Maps a batch of engine replies to a focus-screen layout (docs/mock). Pure function: the engine decides WHAT is + * asked, this only decides HOW it looks. Unknown shapes fall back to a plain choice / text screen. + */ + +export type Step = + | { kind: "idle"; notes: string[] } // engine is back at the menu: the flow is over + | { kind: "consent"; lead: string[]; title: string; buttons: DialogButton[] } + | { kind: "crisis"; text: string; buttons: DialogButton[] } + | { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] } + | { kind: "number"; lead: string[]; title: string; options: DialogButton[] } + | { kind: "actions"; lead: string[]; title: string; options: DialogButton[] } + | { kind: "choice"; lead: string[]; title: string; options: DialogButton[]; progress?: Progress } + | { kind: "text"; lead: string[]; title: string; skip?: DialogButton }; + +export interface Progress { + n: number; + total: number; +} + +// config/texts/dialog.yaml → idle.menu_prompt: the bare menu carries no message of its own. +const MENU_PROMPT = "Чем могу помочь?"; +// config/texts/dialog.yaml → scale.question: "{n}/{total}. {text}" +const SCALE_ITEM = /^(\d+)\/(\d+)\.\s+([\s\S]+)$/; + +const isMenu = (r: Reply) => r.buttons.flat().some((b) => b.data.startsWith("menu:")); +const flat = (r: Reply) => r.buttons.flat(); +const paragraphs = (text: string) => + text + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter(Boolean); + +/** Splits a reply into a title (the actual question) and lead paragraphs above it. */ +function titled(texts: string[]): { lead: string[]; title: string; progress?: Progress } { + const parts = texts.flatMap(paragraphs); + const last = parts.pop() ?? ""; + const m = SCALE_ITEM.exec(last); + if (m) return { lead: parts, title: m[3].trim(), progress: { n: Number(m[1]), total: Number(m[2]) } }; + // A long last paragraph is body text, not a heading. + if (last.length > 220) return { lead: [...parts, last], title: "" }; + return { lead: parts, title: last }; +} + +export function classify(batch: Reply[]): Step | null { + if (batch.length === 0) return null; + + const crisis = batch.find((r) => r.kind === "crisis"); + if (crisis) return { kind: "crisis", text: crisis.text, buttons: batch.flatMap(flat) }; + + if (batch.some(isMenu)) { + const notes = batch + .filter((r) => !isMenu(r) || r.text.trim() !== MENU_PROMPT) + .map((r) => r.text.trim()) + .filter((t) => t && t !== MENU_PROMPT); + return { kind: "idle", notes }; + } + + const plan = batch.find((r) => r.kind === "safety_plan"); + if (plan) { + const [title, ...rest] = plan.text.split("\n").map((l) => l.trim()); + const lead = batch.filter((r) => r !== plan).map((r) => r.text); + return { + kind: "plan", + lead, + title, + lines: rest.filter(Boolean).map((l) => l.replace(/^[•\-–]\s*/, "")), + buttons: batch.flatMap(flat), + }; + } + + // The question is the last reply with buttons; everything before it is context. + const qIndex = batch.map((r) => r.buttons.length > 0).lastIndexOf(true); + const texts = batch.map((r) => r.text); + if (qIndex === -1) { + const { lead, title } = titled(texts); + return { kind: "text", lead, title }; + } + const options = flat(batch[qIndex]); + const head = titled(texts.slice(0, qIndex + 1)); + const tail = texts.slice(qIndex + 1); // rare: info after the question + const lead = [...head.lead, ...tail]; + + if (options.some((b) => b.data === "consent:yes")) return { kind: "consent", lead, title: head.title, buttons: options }; + if (options.every((b) => b.data.endsWith(":skip"))) + return { kind: "text", lead, title: head.title, skip: options[0] }; + if (options.length >= 5 && options.every((b) => /^\d{1,2}$/.test(b.label.trim()))) + return { kind: "number", lead, title: head.title, options }; + if (options.some((b) => /^med:(taken|skip|side_effects):/.test(b.data))) + return { kind: "actions", lead, title: head.title, options }; + return { kind: "choice", lead, title: head.title, options, progress: head.progress }; +} + +/** Blocks of an engine text: «• item» lines become list items; wrapped plain lines are joined into paragraphs. */ +export function lines(text: string): { list: boolean; text: string }[] { + const out: { list: boolean; text: string }[] = []; + for (const raw of text.split("\n")) { + const l = raw.trim(); + if (!l) continue; + if (/^[•\-–]\s+/.test(l)) out.push({ list: true, text: l.replace(/^[•\-–]\s+/, "") }); + else if (out.length && !out[out.length - 1].list && !/[.!?:»)]$/.test(out[out.length - 1].text)) + out[out.length - 1] = { list: false, text: `${out[out.length - 1].text} ${l}` }; + else out.push({ list: false, text: l }); + } + return out; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index a1563b2..20f895f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,28 +1,47 @@ import "@fontsource/golos-text/400.css"; import "@fontsource/golos-text/500.css"; import "@fontsource/golos-text/600.css"; +import "@fontsource/golos-text/700.css"; import "./theme/tokens.css"; import "./theme/global.css"; -import { StrictMode } from "react"; +import { StrictMode, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; +import { HelpProvider } from "./components/HelpSheet"; +import { DialogProvider } from "./dialog/DialogContext"; import { AnonymousSignIn } from "./screens/AnonymousSignIn"; -import { Chat } from "./screens/Chat"; +import { Doctor } from "./screens/Doctor"; import { EmiasSignIn } from "./screens/EmiasSignIn"; +import { Flow } from "./screens/Flow"; +import { DoseReminder, Meds } from "./screens/Meds"; +import { Messages } from "./screens/Messages"; +import { Profile } from "./screens/Profile"; +import { Today } from "./screens/Today"; import { Welcome } from "./screens/Welcome"; import { SessionProvider, useSession } from "./session/SessionContext"; +function Private({ children }: { children: ReactNode }) { + const { session } = useSession(); + return session ? <>{children} : ; +} + // Hash routing works the same on a static host, file:// and inside the Capacitor WebView. function AppRoutes() { const { session } = useSession(); return ( - : } /> + : } /> } /> } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> ); @@ -31,9 +50,13 @@ function AppRoutes() { createRoot(document.getElementById("root")!).render( - - - + + + + + + + , ); diff --git a/web/src/screens/AnonymousSignIn.tsx b/web/src/screens/AnonymousSignIn.tsx index 364ce8b..ccfe4a5 100644 --- a/web/src/screens/AnonymousSignIn.tsx +++ b/web/src/screens/AnonymousSignIn.tsx @@ -2,11 +2,15 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { api } from "../api/client"; -import { AppShell, BackLink, Notice } from "../components/AppShell"; -import { Icon } from "../components/Icon"; +import { AppShell, Notice, TopBar } from "../components/AppShell"; +import { Icon, type IconName } from "../components/Icon"; import { ru } from "../content/ru"; import { useSession } from "../session/SessionContext"; +const YES_ICONS: IconName[] = ["calendar", "pill", "doc"]; +const NO_ICONS: IconName[] = ["user", "doc"]; + +// A3 · Анонимный вход export function AnonymousSignIn() { const t = ru.anonymous; const navigate = useNavigate(); @@ -19,7 +23,7 @@ export function AnonymousSignIn() { setError(false); try { signIn(await api.signInAnonymous()); - navigate("/chat", { replace: true }); + navigate("/flow", { replace: true }); } catch { setError(true); setBusy(false); @@ -27,35 +31,55 @@ export function AnonymousSignIn() { }; return ( - -
-
- {t.back} -
-

- {t.title} -

-

{t.lead}

-
    - {t.points.map((p) => ( -
  • - - {p} -
  • - ))} -
- {t.warning} - {error && ( -

- {ru.emias.networkError} -

- )} - -
-
-
+ +
+ navigate("/")} /> +
+

{t.title}

+

{t.lead}

+
+ +
+

+ + + + {t.yesTitle} +

+
    + {t.yes.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+ +
+

+ + + + {t.noTitle} +

+
    + {t.no.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+ + {t.warning} + {error && {ru.common.networkError}} + + +
); } diff --git a/web/src/screens/Chat.tsx b/web/src/screens/Chat.tsx deleted file mode 100644 index 041c986..0000000 --- a/web/src/screens/Chat.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { Fragment, useCallback, useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react"; -import { useNavigate } from "react-router-dom"; - -import { api } from "../api/client"; -import type { DialogButton, DialogEvent, Reply } from "../api/types"; -import { AppShell } from "../components/AppShell"; -import { Icon } from "../components/Icon"; -import { ru } from "../content/ru"; -import { useSession } from "../session/SessionContext"; -import { sessionStore, type ChatEntry } from "../session/store"; - -// The engine pushes reminders and check-ins to the outbox; the web channel polls it (Telegram gets them pushed). -const OUTBOX_POLL_MS = 6000; - -let seq = 0; -const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`; - -const assistantEntries = (replies: Reply[]): ChatEntry[] => - replies.map((reply) => ({ id: newId(), from: "assistant", reply, answered: false })); - -// Phone numbers in engine texts (crisis script, help lines) become tap-to-call links. -const PHONE_RE = /(\+7[\d\s()-]{9,}\d|8 \(\d{3}\) \d{3}|\b11[2]\b|\b10[23]\b)/g; - -function RichText({ text }: { text: string }) { - return ( - <> - {text.split("\n").map((line, i) => ( - - {i > 0 &&
} - {line.split(PHONE_RE).map((part, j) => - j % 2 === 1 ? ( - - {part} - - ) : ( - {part} - ), - )} -
- ))} - - ); -} - -function AssistantMessage({ - entry, - disabled, - onPress, -}: { - entry: Extract; - disabled: boolean; - onPress: (button: DialogButton) => void; -}) { - const { reply } = entry; - const special = reply.kind === "crisis" || reply.kind === "safety_plan"; - return ( -
  • -
    - {special && ( -

    - - {ru.chat.crisisLabel} -

    - )} -

    - -

    -
    - {reply.buttons.length > 0 && !entry.answered && ( -
    - {reply.buttons.map((row, r) => ( -
    - {row.map((b) => ( - - ))} -
    - ))} -
    - )} -
  • - ); -} - -export function Chat() { - const t = ru.chat; - const navigate = useNavigate(); - const { session, signOut } = useSession(); - const token = session?.token ?? ""; - const [entries, setEntries] = useState(() => (token ? sessionStore.loadChat(token) : [])); - const [draft, setDraft] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(false); - const [confirmLogout, setConfirmLogout] = useState(false); - const endRef = useRef(null); - const started = useRef(false); - - useEffect(() => { - if (token) sessionStore.saveChat(token, entries); - endRef.current?.scrollIntoView({ block: "end", behavior: "smooth" }); - }, [entries, token]); - - // Any patient action closes the open buttons: the engine has moved on, old choices are stale. - const dispatch = useCallback( - async (event: DialogEvent, optimistic: ChatEntry[]) => { - setBusy(true); - setError(false); - let reopened = new Set(); - setEntries((prev) => { - reopened = new Set(prev.filter((e) => e.from === "assistant" && !e.answered).map((e) => e.id)); - return [...prev.map((e) => (reopened.has(e.id) ? { ...e, answered: true } : e)), ...optimistic]; - }); - try { - const replies = await api.sendEvent(token, event); - setEntries((prev) => [...prev, ...assistantEntries(replies)]); - return true; - } catch { - // Roll back the optimistic patient message and re-open the buttons. - const ids = new Set(optimistic.map((o) => o.id)); - setEntries((prev) => - prev.filter((e) => !ids.has(e.id)).map((e) => (reopened.has(e.id) ? { ...e, answered: false } : e)), - ); - setError(true); - return false; - } finally { - setBusy(false); - } - }, - [token], - ); - - // First visit: the engine opens the dialog (consent → onboarding) exactly as for /start in Telegram. - useEffect(() => { - if (!token || started.current) return; - started.current = true; - if (entries.length === 0) void dispatch({ type: "start" }, []); - }, [token, entries.length, dispatch]); - - useEffect(() => { - if (!token) return; - const timer = window.setInterval(() => { - api - .outbox(token) - .then((replies) => replies.length && setEntries((prev) => [...prev, ...assistantEntries(replies)])) - .catch(() => undefined); - }, OUTBOX_POLL_MS); - return () => window.clearInterval(timer); - }, [token]); - - useEffect(() => { - if (!session) navigate("/", { replace: true }); - }, [session, navigate]); - - if (!session) return null; - - const sendText = async (e?: FormEvent) => { - e?.preventDefault(); - const text = draft.trim(); - if (!text || busy) return; - setDraft(""); - const ok = await dispatch({ type: "text", text }, [{ id: newId(), from: "patient", text }]); - if (!ok) setDraft(text); - }; - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) void sendText(); - }; - - const press = (b: DialogButton) => - void dispatch({ type: "button", button: b.data }, [{ id: newId(), from: "patient", text: b.label }]); - - return ( - -
    -
    - - - {session.mode === "emias" ? t.modeEmias : t.modeAnonymous} - {session.displayName && · {session.displayName}} - - {confirmLogout ? ( - - {t.logoutConfirm} - - - - ) : ( - - )} -
    -
    - -
    -
      - {entries.map((entry) => - entry.from === "assistant" ? ( - - ) : ( -
    1. - {t.you}: -
      -

      {entry.text}

      -
      -
    2. - ), - )} - {busy && ( -
    3. -
      - -
      -
    4. - )} -
    -
    -
    - -
    - {error && ( -

    - {t.sendError} -

    - )} -
    - -