feat(web): приложение пациента — двухэтапные опросы, результат скрининга, дневник по результату #79

Merged
Arxip222 merged 10 commits from feat/rag-clinical-guidelines into dev 2026-10-04 15:25:17 +03:00
28 changed files with 4697 additions and 5 deletions
Showing only changes of commit b8a93ce9d4 - Show all commits
+6
View File
@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
from app.api.schemas import ( from app.api.schemas import (
AlertOut, AlertOut,
DemoPersona,
DiscontinuationRisk, DiscontinuationRisk,
DoseOut, DoseOut,
PatientCard, PatientCard,
@@ -21,6 +22,11 @@ TZ = ZoneInfo("Europe/Moscow")
TODAY = date(2026, 10, 3) TODAY = date(2026, 10, 3)
DOCTOR_ID = "doc-smirnova" DOCTOR_ID = "doc-smirnova"
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
DEMO_PERSONAS: list[DemoPersona] = [
DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
]
def _risk(score: int, factors: list[str]) -> DiscontinuationRisk: def _risk(score: int, factors: list[str]) -> DiscontinuationRisk:
level = "high" if score >= 60 else "medium" if score >= 30 else "low" level = "high" if score >= 60 else "medium" if score >= 30 else "low"
+93
View File
@@ -0,0 +1,93 @@
"""Patient web app: sign-in and public reference data (`/api/v1/auth/*`, `/api/v1/crisis/resources`).
The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...`
exactly like the web console, and consent, onboarding, scales and check-ins come from the engine.
Two ways in:
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The
patient is linked to the attending doctor, who sees the data on the dashboard.
- **Anonymous**: a random pseudonymous token and nothing else — no identity record, no doctor. The engine
supports tokens without an identity record (consent is kept in the dialog state), so no personal data is
stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor.
The token returned here is the session credential of the prototype. Production needs short-lived sessions
issued by the server instead of handing out the pseudonymous token itself.
"""
import logging
import secrets
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from app.api import fixtures
from app.core.safety import CrisisResource, crisis_reply
from app.db import repo
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
log = logging.getLogger(__name__)
router = APIRouter(tags=["patient-web"])
_emias = EmiasMock()
class AuthOut(BaseModel):
token: str
mode: Literal["emias", "anonymous"]
display_name: str | None = None # only for ЕМИАС sign-in; anonymous has none by design
class EmiasCodeIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
class EmiasDemoIn(BaseModel):
model_config = ConfigDict(extra="forbid")
persona_token: str = Field(min_length=1, max_length=64)
async def _attach_doctor(token: str, doctor_id: str) -> None:
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=doctor_id)
@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)."""
person = await _emias.verify(body.code)
if person is None:
log.info("web auth: emias code rejected")
raise HTTPException(401, "code not accepted")
await _attach_doctor(person.token, person.doctor_id)
log.info("web auth: emias tok=%s", person.token[:6])
return AuthOut(token=person.token, mode="emias")
@router.post("/auth/emias/demo", response_model=AuthOut)
async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
"""Demo ЕМИАС account: signs in as one of the synthetic personas (`/demo/personas`)."""
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)
log.info("web auth: emias demo tok=%s", persona.token[:6])
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
@router.post("/auth/anonymous", response_model=AuthOut)
async def auth_anonymous() -> AuthOut:
"""Anonymous sign-in: a fresh random token; nothing about the person is stored anywhere."""
token = secrets.token_hex(TOKEN_LEN // 2)
log.info("web auth: anonymous tok=%s", token[:6])
return AuthOut(token=token, mode="anonymous")
@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."""
return crisis_reply([], "patient").resources
+3 -5
View File
@@ -7,6 +7,7 @@ Lane B replaces bodies in #12 without changing paths or schemas (those are the f
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from app.api import fixtures from app.api import fixtures
from app.api.patient_web import router as patient_web_router
from app.api.questionnaire import router as questionnaire_router from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import ( from app.api.schemas import (
AckIn, AckIn,
@@ -29,6 +30,7 @@ from app.scheduler import jobs as scheduler
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
router.include_router(questionnaire_router) router.include_router(questionnaire_router)
router.include_router(patient_web_router)
# ------------------------------------------------------------------ doctor dashboard # ------------------------------------------------------------------ doctor dashboard
@@ -114,8 +116,4 @@ async def demo_reset() -> dict[str, str]:
@router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"]) @router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"])
async def demo_personas() -> list[DemoPersona]: async def demo_personas() -> list[DemoPersona]:
return [ return fixtures.DEMO_PERSONAS
DemoPersona(
token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"
)
]
+35
View File
@@ -0,0 +1,35 @@
"""ЕМИАС sign-in mock for the patient web app (SPEC §9 `esia_mock`: registration by the doctor's code).
Real ЕМИАС / ЕСИА OAuth needs a registered information system, so the prototype accepts the one-time connect
code the doctor hands out after the visit (323-FZ art. 36.2): code → patient token + attending doctor. The
class implements `IdentityProvider`, so the real OAuth client replaces it without touching the web channel.
It is deliberately NOT installed into `integrations.identity`: the Telegram channel verifies a code twice
(on `/start` and in the core flow), which a one-time redeeming provider would break. Lane B wires the
production provider for all channels (#14).
This module may use the identity DB: it is the registration boundary (guardrail #5).
"""
from app.core import clock
from app.db import identity_repo
from app.db.identity import ConnectCode
from app.db.session import get_identity_session
from app.integrations.base import VerifiedPerson
CODE_MAX_LEN = 32
class EmiasMock:
async def verify(self, auth_code: str) -> VerifiedPerson | None:
code = auth_code.strip().upper()
if not code or len(code) > CODE_MAX_LEN:
return None
async with get_identity_session() as s:
row = await s.get(ConnectCode, code)
if row is None:
return None
token = await identity_repo.redeem_connect_code(s, code=code, now=clock.now())
if token is None:
return None
return VerifiedPerson(token=token, doctor_id=row.doctor_id)
+86
View File
@@ -0,0 +1,86 @@
"""Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine."""
from collections.abc import AsyncIterator
import pytest
from httpx import ASGITransport, AsyncClient
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.main import app
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "b" * 32
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
async def _patient_with_code(code: str) -> None:
async with session.get_identity_session() as s:
await identity_repo.create_patient(
s,
token=TOKEN,
display_name="Синтетический П.",
snils="000-000-000 00",
clinic_id="clinic-1",
doctor_id="doc-1",
now=clock.now(),
)
await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-1", now=clock.now())
async def test_anonymous_tokens_are_fresh_and_store_nothing(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()
second = (await client.post("/api/v1/auth/anonymous")).json()
assert first["mode"] == "anonymous" and first["display_name"] is None
assert len(first["token"]) == 32 and first["token"] != second["token"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, first["token"]) is None
async with session.get_clinical_session() as s:
assert await repo.get_profile(s, first["token"]) is None
async def test_anonymous_token_starts_the_engine_dialog(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
replies = (await client.post(f"/api/v1/dialog/{token}/events", json={"type": "start"})).json()["replies"]
buttons = [b["data"] for r in replies for row in r["buttons"] for b in row]
assert "consent:yes" in buttons and "consent:no" in buttons
async def test_emias_code_is_one_time_and_attaches_doctor(client: AsyncClient) -> None:
await _patient_with_code("DEMO-0042")
ok = await client.post("/api/v1/auth/emias", json={"code": " demo-0042 "})
assert ok.status_code == 200 and ok.json() == {"token": TOKEN, "mode": "emias", "display_name": None}
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).doctor_id == "doc-1"
again = await client.post("/api/v1/auth/emias", json={"code": "DEMO-0042"})
assert again.status_code == 401
async def test_emias_rejects_unknown_code(client: AsyncClient) -> None:
assert (await client.post("/api/v1/auth/emias", json={"code": "NOPE"})).status_code == 401
assert (await client.post("/api/v1/auth/emias", json={"code": ""})).status_code == 422
async def test_emias_demo_persona(client: AsyncClient) -> None:
persona = fixtures.DEMO_PERSONAS[0]
res = await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})
assert res.json() == {"token": persona.token, "mode": "emias", "display_name": persona.display_name}
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, persona.token)).doctor_id == fixtures.DOCTOR_ID
missing = await client.post("/api/v1/auth/emias/demo", json={"persona_token": "tok-nobody"})
assert missing.status_code == 404
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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+43
View File
@@ -0,0 +1,43 @@
# Веб-приложение пациента
Ещё один канал к тому же движку диалога, что и Telegram-бот (ADR-004): согласие, онбординг, шкалы, чек-ины и
кризисный скрипт приходят из `app/dialog` через `/api/v1/dialog/{token}/...`. Бот продолжает работать параллельно.
## Запуск
```bash
make up # или: uv run uvicorn app.main:app --port 8000
cd web && npm install
npm run dev # http://localhost:5173 — /api проксируется на :8000
```
Интерфейс открывается на **:5173**; на :8000 работает только API (`/docs`).
## Вход
| Способ | Что происходит | API |
|---|---|---|
| ЕМИАС (демо) | одноразовый код от врача → токен пациента, привязка к врачу; или синтетический демо-пациент | `POST /auth/emias`, `POST /auth/emias/demo` |
| Анонимно | случайный токен, в identity-БД ничего не пишется, врач не привязан | `POST /auth/anonymous` |
Реальная авторизация ЕМИАС/ЕСИА подключается как `IdentityProvider` (`app/integrations/emias_mock.py`) без
изменений во фронтенде.
## Структура (под перенос в мобильное приложение)
```
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
```
Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается
Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API).
## Известные ограничения прототипа
- Токен пациента используется как учётные данные сессии; в проде — короткоживущие серверные сессии.
- Подбор кодов не ограничен по частоте.
- В демо нет сида кодов подключения: для показа используйте демо-пациента.
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<meta name="description" content="Сопровождение пациентов между визитами к врачу" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<title>Сопровождение пациента</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1802
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "ptsr-patient-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b"
},
"dependencies": {
"@fontsource/golos-text": "^5.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "~5.6.3",
"vite": "^5.4.10"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0a4da2"/><path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 175 B

+42
View File
@@ -0,0 +1,42 @@
import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply } 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";
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(`${BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
} catch {
throw new ApiError(0, "network");
}
if (!res.ok) throw new ApiError(res.status, res.statusText);
return (await res.json()) as T;
}
const post = <T>(path: string, body?: unknown) =>
request<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) });
export const api = {
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
signInAnonymous: () => post<AuthResult>("/auth/anonymous"),
demoPersonas: () => request<DemoPersona[]>("/demo/personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
sendEvent: async (token: string, event: DialogEvent) =>
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
outbox: async (token: string) =>
(await request<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/outbox`)).replies,
};
+41
View File
@@ -0,0 +1,41 @@
// Mirrors of the backend contracts (app/dialog/contracts.py, app/api/schemas.py, app/api/patient_web.py).
// Keep in sync by hand: the frozen contracts change only through a [contract] PR.
export interface DialogButton {
label: string;
data: string;
}
export type ReplyKind = "normal" | "question" | "crisis" | "safety_plan" | "info";
export interface Reply {
text: string;
buttons: DialogButton[][];
kind: ReplyKind;
}
export type DialogEvent =
| { type: "start"; params?: Record<string, unknown> }
| { type: "text"; text: string }
| { type: "button"; button: string };
export type AuthMode = "emias" | "anonymous";
export interface AuthResult {
token: string;
mode: AuthMode;
display_name: string | null;
}
export interface CrisisResource {
name: string;
phone: string;
note: string;
}
export interface DemoPersona {
token: string;
display_name: string;
therapy_day: number;
scenario: string | null;
}
+64
View File
@@ -0,0 +1,64 @@
import { useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { ru } from "../content/ru";
import { HelpSheet } from "./HelpSheet";
import { Icon, LogoMark } from "./Icon";
export function AppShell({ children, footer = true }: { children: ReactNode; footer?: boolean }) {
const [helpOpen, setHelpOpen] = useState(false);
return (
<div className="shell">
<header className="topbar">
<div className="topbar__inner">
<Link to="/" className="brand" aria-label={ru.product.name}>
<LogoMark />
<span className="brand__text">
<span className="brand__name">{ru.product.name}</span>
<span className="brand__tagline">{ru.product.tagline}</span>
</span>
</Link>
<button type="button" className="help-btn" onClick={() => setHelpOpen(true)}>
<Icon name="phone" size={18} />
<span>{ru.help.button}</span>
</button>
</div>
</header>
{children}
{footer && (
<footer className="footer">
<div className="footer__inner">
<p className="footer__line">
<Icon name="lock" size={16} />
<span>{ru.footer.law}</span>
</p>
<p className="footer__line footer__line--muted">{ru.footer.prototype}</p>
</div>
</footer>
)}
<HelpSheet open={helpOpen} onClose={() => setHelpOpen(false)} />
</div>
);
}
export function BackLink({ to, children }: { to: string; children: ReactNode }) {
return (
<Link to={to} className="back-link">
<Icon name="arrowLeft" size={18} />
<span>{children}</span>
</Link>
);
}
export function Notice({ tone = "info", children }: { tone?: "info" | "warning"; children: ReactNode }) {
return (
<div className={`notice notice--${tone}`} role="note">
<Icon name={tone === "warning" ? "alert" : "info"} size={20} />
<p>{children}</p>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } 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 }];
const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null);
const [resources, setResources] = useState<CrisisResource[]>(FALLBACK);
useEffect(() => {
api
.crisisResources()
.then((r) => r.length && setResources(r))
.catch(() => undefined);
}, []);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={ref}
className="sheet"
aria-labelledby="help-title"
onClose={onClose}
onClick={(e) => e.target === ref.current && onClose()}
>
<div className="sheet__body">
<div className="sheet__head">
<h2 id="help-title" className="sheet__title">
{ru.help.title}
</h2>
<button type="button" className="icon-btn" onClick={onClose} aria-label={ru.help.close}>
<Icon name="close" />
</button>
</div>
<p className="sheet__lead">{ru.help.lead}</p>
<ul className="hotlines">
{resources.map((r) => (
<li key={r.phone}>
<a className="hotline" href={telHref(r.phone)}>
<span className="hotline__icon">
<Icon name="phone" size={22} />
</span>
<span className="hotline__text">
<span className="hotline__phone">{r.phone}</span>
<span className="hotline__name">{r.name}</span>
{r.note && <span className="hotline__note">{r.note}</span>}
</span>
<span className="hotline__call">{ru.help.call}</span>
</a>
</li>
))}
</ul>
</div>
</dialog>
);
}
+78
View File
@@ -0,0 +1,78 @@
// Minimal line icon set (24px grid, 1.75 stroke). Decorative by default: pass `label` to expose to screen readers.
const paths = {
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
minus: <path d="M6 12h12" />,
phone: (
<path d="M6.6 3.8h2.6l1.4 4-2 1.4a12 12 0 0 0 5.9 5.9l1.4-2 4 1.4v2.6c0 1-.8 1.8-1.8 1.8A15.8 15.8 0 0 1 4.8 5.6c0-1 .8-1.8 1.8-1.8z" />
),
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
arrowRight: <path d="M5 12h14m-6-6l6 6-6 6" />,
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />,
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5m0-8.5v.5" />
</>
),
alert: (
<>
<path d="M12 3.5l9.5 16.5h-19L12 3.5z" />
<path d="M12 10v4.5m0 2.5v.5" />
</>
),
lock: (
<>
<rect x="5" y="10.5" width="14" height="10" rx="1.5" />
<path d="M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3" />
</>
),
pill: (
<>
<rect x="3.5" y="8.5" width="17" height="7" rx="3.5" transform="rotate(-45 12 12)" />
<path d="M9.5 9.5l5 5" />
</>
),
pulse: <path d="M3 12h4l2-5 4 10 2-5h6" />,
doctor: (
<>
<circle cx="12" cy="7.5" r="3.5" />
<path d="M5 20.5c0-3.9 3.1-6.5 7-6.5s7 2.6 7 6.5" />
<path d="M12 15.5v3m-1.5-1.5h3" />
</>
),
close: <path d="M6 6l12 12M18 6L6 18" />,
logout: <path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14M10 8l-4 4 4 4M6 12h10" />,
} as const;
export type IconName = keyof typeof paths;
export function Icon({ name, size = 20, label }: { name: IconName; size?: number; label?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
focusable="false"
>
{paths[name]}
</svg>
);
}
export function LogoMark({ size = 32 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<rect width="32" height="32" rx="6" fill="var(--c-primary)" />
<path d="M13 7h6v6h6v6h-6v6h-6v-6H7v-6h6z" fill="#fff" />
</svg>
);
}
+103
View File
@@ -0,0 +1,103 @@
/*
* 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.
*/
export const ru = {
product: {
name: "Сопровождение пациента",
tagline: "между визитами к врачу",
prototype: "Прототип · синтетические данные",
},
help: {
button: "Нужна помощь",
title: "Если вам сейчас очень тяжело",
lead: "Не ждите ответа врача, если помощь нужна прямо сейчас. Позвоните:",
call: "Позвонить",
close: "Закрыть",
fallbackName: "Экстренные службы",
fallbackNote: "если есть угроза жизни прямо сейчас",
},
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: "Как вы хотите войти?",
emias: {
title: "Через ЕМИАС",
badge: "Рекомендуем",
facts: [
{ ok: true, text: "Назначения врача подгружаются автоматически" },
{ ok: true, text: "Врач видит ваши ответы и получает сигнал при ухудшении" },
{ ok: true, text: "История доступна с любого устройства" },
],
action: "Войти через ЕМИАС",
},
anonymous: {
title: "Анонимно",
facts: [
{ ok: true, text: "Не нужны имя, полис и телефон" },
{ ok: true, text: "Опросы самочувствия и помощь в кризисе доступны" },
{ ok: false, text: "Врач не видит ваши ответы и не получает сигналов" },
{ ok: false, text: "История доступна только на этом устройстве" },
],
action: "Продолжить анонимно",
},
disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.",
},
emias: {
back: "Способы входа",
title: "Вход через ЕМИАС",
demoNotice:
"Демонстрационный режим. Авторизация в ЕМИАС не выполняется: вход подтверждается одноразовым кодом, который врач выдаёт на приёме.",
codeLabel: "Код подключения от врача",
codeHint: "Код указан в памятке или под QR-кодом, который вы получили на приёме.",
codePlaceholder: "Код из памятки",
submit: "Войти",
submitting: "Проверяем код…",
codeError: "Код не подошёл. Проверьте написание или попросите врача выдать новый код.",
networkError: "Сервис временно недоступен. Попробуйте ещё раз через минуту.",
or: "или",
demoTitle: "Демо-пациенты",
demoLead: "Нет кода? Войдите под синтетической учётной записью, чтобы посмотреть сценарий.",
demoDay: (day: number) => `${day}-й день терапии`,
demoAction: "Войти",
demoEmpty: "Демо-пациенты недоступны.",
},
anonymous: {
back: "Способы входа",
title: "Анонимный вход",
lead: "Мы не спрашиваем имя, полис и телефон. Ваши ответы хранятся под случайным идентификатором, по которому вас нельзя узнать.",
points: [
"Доступны опросы самочувствия, дневник и помощь в кризисной ситуации.",
"Лечащий врач не видит ваши ответы и не получает сигналов об ухудшении.",
"Перед началом вы дадите согласие на обработку ответов — без него ничего не сохраняется.",
],
warning:
"История привязана к этому устройству. Если вы выйдете или очистите данные браузера, восстановить её будет нельзя.",
submit: "Начать анонимно",
submitting: "Создаём анонимный профиль…",
},
chat: {
modeEmias: "ЕМИАС",
modeAnonymous: "Анонимно",
logout: "Выйти",
logoutConfirm: "Выйти? Анонимную историю восстановить будет нельзя.",
inputLabel: "Сообщение",
inputPlaceholder: "Напишите сообщение",
send: "Отправить",
loading: "Подключаемся…",
sendError: "Сообщение не отправлено. Проверьте соединение и попробуйте ещё раз.",
you: "Вы",
assistant: "Помощник",
crisisLabel: "Важно",
},
footer: {
law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).",
prototype: "Прототип для хакатона MedITron. Используются только синтетические данные.",
},
} as const;
+39
View File
@@ -0,0 +1,39 @@
import "@fontsource/golos-text/400.css";
import "@fontsource/golos-text/500.css";
import "@fontsource/golos-text/600.css";
import "./theme/tokens.css";
import "./theme/global.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import { AnonymousSignIn } from "./screens/AnonymousSignIn";
import { Chat } from "./screens/Chat";
import { EmiasSignIn } from "./screens/EmiasSignIn";
import { Welcome } from "./screens/Welcome";
import { SessionProvider, useSession } from "./session/SessionContext";
// Hash routing works the same on a static host, file:// and inside the Capacitor WebView.
function AppRoutes() {
const { session } = useSession();
return (
<Routes>
<Route path="/" element={session ? <Navigate to="/chat" replace /> : <Welcome />} />
<Route path="/emias" element={<EmiasSignIn />} />
<Route path="/anonymous" element={<AnonymousSignIn />} />
<Route path="/chat" element={<Chat />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<SessionProvider>
<HashRouter>
<AppRoutes />
</HashRouter>
</SessionProvider>
</StrictMode>,
);
+61
View File
@@ -0,0 +1,61 @@
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 { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
export function AnonymousSignIn() {
const t = ru.anonymous;
const navigate = useNavigate();
const { signIn } = useSession();
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const start = async () => {
setBusy(true);
setError(false);
try {
signIn(await api.signInAnonymous());
navigate("/chat", { replace: true });
} catch {
setError(true);
setBusy(false);
}
};
return (
<AppShell>
<main className="page" id="main">
<div className="narrow">
<BackLink to="/">{t.back}</BackLink>
<section className="panel" aria-labelledby="anon-title">
<h1 id="anon-title" className="h2">
{t.title}
</h1>
<p className="lead lead--sm">{t.lead}</p>
<ul className="facts facts--spaced">
{t.points.map((p) => (
<li key={p} className="fact">
<Icon name="check" size={18} />
<span>{p}</span>
</li>
))}
</ul>
<Notice tone="warning">{t.warning}</Notice>
{error && (
<p className="field__error" role="alert">
{ru.emias.networkError}
</p>
)}
<button type="button" className="btn btn--primary btn--block" onClick={start} disabled={busy}>
{busy ? t.submitting : t.submit}
</button>
</section>
</div>
</main>
</AppShell>
);
}
+267
View File
@@ -0,0 +1,267 @@
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) => (
<Fragment key={i}>
{i > 0 && <br />}
{line.split(PHONE_RE).map((part, j) =>
j % 2 === 1 ? (
<a key={j} href={`tel:${part.replace(/[^\d+]/g, "")}`} className="tel">
{part}
</a>
) : (
<Fragment key={j}>{part}</Fragment>
),
)}
</Fragment>
))}
</>
);
}
function AssistantMessage({
entry,
disabled,
onPress,
}: {
entry: Extract<ChatEntry, { from: "assistant" }>;
disabled: boolean;
onPress: (button: DialogButton) => void;
}) {
const { reply } = entry;
const special = reply.kind === "crisis" || reply.kind === "safety_plan";
return (
<li className={`msg msg--assistant msg--${reply.kind}`}>
<div className="msg__bubble">
{special && (
<p className="msg__label">
<Icon name="alert" size={16} />
{ru.chat.crisisLabel}
</p>
)}
<p className="msg__text">
<RichText text={reply.text} />
</p>
</div>
{reply.buttons.length > 0 && !entry.answered && (
<div className="choices">
{reply.buttons.map((row, r) => (
<div key={r} className="choices__row">
{row.map((b) => (
<button
key={b.data}
type="button"
className="choice"
disabled={disabled}
onClick={() => onPress(b)}
>
{b.label}
</button>
))}
</div>
))}
</div>
)}
</li>
);
}
export function Chat() {
const t = ru.chat;
const navigate = useNavigate();
const { session, signOut } = useSession();
const token = session?.token ?? "";
const [entries, setEntries] = useState<ChatEntry[]>(() => (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<HTMLDivElement>(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<string>();
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<HTMLTextAreaElement>) => {
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 (
<AppShell footer={false}>
<div className="chat-bar">
<div className="chat-bar__inner">
<span className={`mode mode--${session.mode}`}>
<Icon name={session.mode === "emias" ? "doctor" : "lock"} size={16} />
{session.mode === "emias" ? t.modeEmias : t.modeAnonymous}
{session.displayName && <span className="mode__name">· {session.displayName}</span>}
</span>
{confirmLogout ? (
<span className="logout-confirm" role="group" aria-label={t.logout}>
<span className="logout-confirm__text">{t.logoutConfirm}</span>
<button type="button" className="btn btn--danger btn--sm" onClick={signOut}>
{t.logout}
</button>
<button type="button" className="icon-btn" aria-label={ru.help.close} onClick={() => setConfirmLogout(false)}>
<Icon name="close" size={18} />
</button>
</span>
) : (
<button
type="button"
className="link-btn"
onClick={() => (session.mode === "anonymous" ? setConfirmLogout(true) : signOut())}
>
<Icon name="logout" size={18} />
{t.logout}
</button>
)}
</div>
</div>
<main className="chat" id="main">
<ol className="messages" aria-live="polite" aria-label={t.assistant}>
{entries.map((entry) =>
entry.from === "assistant" ? (
<AssistantMessage key={entry.id} entry={entry} disabled={busy} onPress={press} />
) : (
<li key={entry.id} className="msg msg--patient">
<span className="visually-hidden">{t.you}: </span>
<div className="msg__bubble">
<p className="msg__text">{entry.text}</p>
</div>
</li>
),
)}
{busy && (
<li className="msg msg--assistant msg--typing" aria-label={t.loading}>
<div className="msg__bubble">
<span className="dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</div>
</li>
)}
</ol>
<div ref={endRef} />
</main>
<form className="composer" onSubmit={sendText}>
{error && (
<p className="composer__error" role="alert">
{t.sendError}
</p>
)}
<div className="composer__row">
<label htmlFor="msg" className="visually-hidden">
{t.inputLabel}
</label>
<textarea
id="msg"
className="composer__input"
rows={1}
value={draft}
placeholder={t.inputPlaceholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
maxLength={2000}
/>
<button type="submit" className="send-btn" disabled={busy || !draft.trim()} aria-label={t.send}>
<Icon name="send" size={22} />
</button>
</div>
</form>
</AppShell>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError, api } from "../api/client";
import type { AuthResult, DemoPersona } from "../api/types";
import { AppShell, BackLink, Notice } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
export function EmiasSignIn() {
const t = ru.emias;
const navigate = useNavigate();
const { signIn } = useSession();
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [personas, setPersonas] = useState<DemoPersona[] | null>(null);
useEffect(() => {
api
.demoPersonas()
.then(setPersonas)
.catch(() => setPersonas([]));
}, []);
const finish = (auth: AuthResult) => {
signIn(auth);
navigate("/chat", { replace: true });
};
const fail = (e: unknown) => {
setError(e instanceof ApiError && e.status === 401 ? t.codeError : t.networkError);
setBusy(false);
};
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!code.trim() || busy) return;
setBusy(true);
setError(null);
try {
finish(await api.signInEmias(code.trim()));
} catch (err) {
fail(err);
}
};
const demo = async (persona: DemoPersona) => {
setBusy(true);
setError(null);
try {
finish(await api.signInEmiasDemo(persona.token));
} catch (err) {
fail(err);
}
};
return (
<AppShell>
<main className="page" id="main">
<div className="narrow">
<BackLink to="/">{t.back}</BackLink>
<section className="panel" aria-labelledby="emias-title">
<h1 id="emias-title" className="h2">
{t.title}
</h1>
<Notice>{t.demoNotice}</Notice>
<form className="form" onSubmit={submit} noValidate>
<div className={error ? "field field--error" : "field"}>
<label htmlFor="code" className="field__label">
{t.codeLabel}
</label>
<input
id="code"
className="input"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t.codePlaceholder}
autoComplete="one-time-code"
autoCapitalize="characters"
spellCheck={false}
maxLength={32}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "code-error" : "code-hint"}
/>
{error ? (
<p id="code-error" className="field__error" role="alert">
{error}
</p>
) : (
<p id="code-hint" className="field__hint">
{t.codeHint}
</p>
)}
</div>
<button type="submit" className="btn btn--primary btn--block" disabled={busy || !code.trim()}>
{busy ? t.submitting : t.submit}
</button>
</form>
<div className="divider" role="separator">
<span>{t.or}</span>
</div>
<h2 className="h3">{t.demoTitle}</h2>
<p className="muted">{t.demoLead}</p>
{personas === null ? null : personas.length === 0 ? (
<p className="muted">{t.demoEmpty}</p>
) : (
<ul className="list">
{personas.map((p) => (
<li key={p.token}>
<button type="button" className="list-row" onClick={() => demo(p)} disabled={busy}>
<span className="avatar" aria-hidden="true">
{p.display_name.charAt(0)}
</span>
<span className="list-row__text">
<span className="list-row__title">{p.display_name}</span>
<span className="list-row__sub">{t.demoDay(p.therapy_day)}</span>
</span>
<span className="list-row__action">
{t.demoAction}
<Icon name="arrowRight" size={18} />
</span>
</button>
</li>
))}
</ul>
)}
</section>
</div>
</main>
</AppShell>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useNavigate } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
const SCALE = [1, 2, 3, 4, 5] as const;
const WEEK = [38, 52, 46, 60, 58, 72, 80]; // illustrative trend, %
function Facts({ items }: { items: readonly { ok: boolean; text: string }[] }) {
return (
<ul className="facts">
{items.map((f) => (
<li key={f.text} className={f.ok ? "fact" : "fact fact--no"}>
<Icon name={f.ok ? "check" : "minus"} size={18} />
<span>{f.text}</span>
</li>
))}
</ul>
);
}
// Illustration of a day with the service (decorative: the same facts are stated in text).
function DayPreview() {
const d = ru.welcome.day;
return (
<figure className="day" aria-hidden="true">
<figcaption className="day__caption">{d.caption}</figcaption>
<ol className="day__list">
<li className="day__item day__item--meds">
<span className="day__time">{d.meds.time}</span>
<div className="day__card">
<span className="day__track">
<Icon name="pill" size={16} />
{d.meds.track}
</span>
<div className="day__row">
<span className="day__text">{d.meds.text}</span>
<span className="day__done">
<Icon name="check" size={16} />
{d.meds.done}
</span>
</div>
</div>
</li>
<li className="day__item day__item--mood">
<span className="day__time">{d.mood.time}</span>
<div className="day__card">
<span className="day__track">
<Icon name="pulse" size={16} />
{d.mood.track}
</span>
<span className="day__text">{d.mood.text}</span>
<div className="scale">
{SCALE.map((n) => (
<span key={n} className={`scale__dot scale__dot--${n}${n === 4 ? " is-picked" : ""}`}>
{n}
</span>
))}
</div>
<div className="scale__ends">
<span>{d.mood.low}</span>
<span>{d.mood.high}</span>
</div>
</div>
</li>
<li className="day__item day__item--doctor">
<span className="day__time">{d.doctor.time}</span>
<div className="day__card">
<span className="day__track">
<Icon name="doctor" size={16} />
{d.doctor.track}
</span>
<div className="day__row">
<span className="day__text">{d.doctor.text}</span>
<span className="spark">
{WEEK.map((v, i) => (
<i key={i} style={{ height: `${v}%` }} />
))}
</span>
</div>
</div>
</li>
</ol>
</figure>
);
}
export function Welcome() {
const navigate = useNavigate();
const t = ru.welcome;
return (
<AppShell>
<main className="welcome" id="main">
<div className="welcome__inner">
<section className="welcome__intro" aria-labelledby="welcome-title">
<h1 id="welcome-title" className="display">
{t.title}
</h1>
<p className="lead">{t.lead}</p>
</section>
<section className="signin" aria-labelledby="choice-title">
<h2 id="choice-title" className="signin__title">
{t.choose}
</h2>
<article className="option option--emias">
<div className="option__head">
<span className="option__icon">
<Icon name="doctor" size={22} />
</span>
<h3 className="option__title">{t.emias.title}</h3>
<span className="badge">{t.emias.badge}</span>
</div>
<Facts items={t.emias.facts} />
<button type="button" className="btn btn--primary btn--block" onClick={() => navigate("/emias")}>
{t.emias.action}
</button>
</article>
<article className="option option--anon">
<div className="option__head">
<span className="option__icon">
<Icon name="lock" size={22} />
</span>
<h3 className="option__title">{t.anonymous.title}</h3>
</div>
<Facts items={t.anonymous.facts} />
<button type="button" className="btn btn--secondary btn--block" onClick={() => navigate("/anonymous")}>
{t.anonymous.action}
</button>
</article>
</section>
<div className="welcome__aside">
<DayPreview />
<p className="disclaimer">{t.disclaimer}</p>
</div>
</div>
</main>
</AppShell>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import type { AuthResult } from "../api/types";
import { sessionStore, type Session } from "./store";
interface SessionApi {
session: Session | null;
signIn: (auth: AuthResult) => void;
signOut: () => void;
}
const Ctx = createContext<SessionApi | null>(null);
export function SessionProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(() => sessionStore.load());
const signIn = useCallback((auth: AuthResult) => {
const next: Session = { token: auth.token, mode: auth.mode, displayName: auth.display_name };
sessionStore.save(next);
setSession(next);
}, []);
const signOut = useCallback(() => {
setSession((current) => {
sessionStore.clear(current);
return null;
});
}, []);
const value = useMemo(() => ({ session, signIn, signOut }), [session, signIn, signOut]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useSession(): SessionApi {
const value = useContext(Ctx);
if (!value) throw new Error("useSession outside SessionProvider");
return value;
}
+74
View File
@@ -0,0 +1,74 @@
import type { AuthMode, Reply } from "../api/types";
/*
* Session persistence behind one small interface. Web uses localStorage; the mobile shell swaps the backend for
* secure device storage (e.g. Capacitor Preferences / Keychain) without touching screens.
*/
export interface Session {
token: string;
mode: AuthMode;
displayName: string | null;
}
export type ChatEntry =
| { id: string; from: "assistant"; reply: Reply; answered: boolean }
| { id: string; from: "patient"; text: string };
interface KeyValue {
get(key: string): string | null;
set(key: string, value: string): void;
remove(key: string): void;
}
const browserStorage: KeyValue = {
get: (k) => {
try {
return window.localStorage.getItem(k);
} catch {
return null;
}
},
set: (k, v) => {
try {
window.localStorage.setItem(k, v);
} catch {
/* private mode / quota: the session simply is not remembered */
}
},
remove: (k) => {
try {
window.localStorage.removeItem(k);
} catch {
/* ignore */
}
},
};
const SESSION_KEY = "ptsr.session.v1";
const chatKey = (token: string) => `ptsr.chat.v1.${token}`;
function read<T>(storage: KeyValue, key: string): T | null {
const raw = storage.get(key);
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export function createSessionStore(storage: KeyValue = browserStorage) {
return {
load: () => read<Session>(storage, SESSION_KEY),
save: (s: Session) => storage.set(SESSION_KEY, JSON.stringify(s)),
clear: (s: Session | null) => {
storage.remove(SESSION_KEY);
if (s) storage.remove(chatKey(s.token));
},
loadChat: (token: string) => read<ChatEntry[]>(storage, chatKey(token)) ?? [],
saveChat: (token: string, entries: ChatEntry[]) => storage.set(chatKey(token), JSON.stringify(entries)),
};
}
export const sessionStore = createSessionStore();
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
/*
* Design tokens — the single source of colours, type and spacing.
* When the app moves to native (Capacitor wraps this same bundle; a React Native port would map these 1:1 to a
* theme object), only this file changes. Components never use raw colours or pixel sizes outside the scale.
*/
:root {
/* Base: cool ink on cool white. */
--c-ink: #13233a;
--c-text: #13233a;
--c-text-2: #46566b;
--c-text-3: #6b7a8e;
--c-page: #f4f7fb;
--c-surface: #ffffff;
--c-band: #e9f0fb; /* solid tint behind the welcome hero */
--c-border: #dbe3ee;
--c-border-strong: #b3c0d1;
/* Care tracks: one colour per thing the service does. The same colour marks the track everywhere
(welcome preview, chat questions), so colour carries meaning, never decoration. */
--c-meds: #0e8c7f;
--c-meds-soft: #e2f4f1;
--c-mood: #e79a0c;
--c-mood-soft: #fdf1da;
--c-mood-ink: #8a5800; /* text on mood-soft (AA) */
--c-doctor: #2350c8;
--c-doctor-soft: #e7eefc;
/* Action colour = the doctor/ЕМИАС blue. */
--c-primary: #2350c8;
--c-primary-hover: #1c43ac;
--c-primary-active: #163689;
--c-primary-soft: #e7eefc;
/* Mood scale 1 → 5 (heavy → good); used only for the scale. */
--c-scale-1: #d9485f;
--c-scale-2: #ef8a3c;
--c-scale-3: #f2c230;
--c-scale-4: #7cc35b;
--c-scale-5: #2fa36b;
--c-danger: #c8283a;
--c-danger-hover: #a91f2f;
--c-danger-soft: #fdebed;
--c-success: #1e8a55;
--c-warning-soft: #fff4dc;
--c-warning-border: #e7b448;
--c-focus: #3b82f6;
/* Type: Golos Text — Russian open-source UI typeface (OFL), bundled locally so the app works offline. */
--font: "Golos Text", system-ui, -apple-system, "Segoe UI", sans-serif;
--fs-12: 0.75rem;
--fs-14: 0.875rem;
--fs-16: 1rem;
--fs-18: 1.125rem;
--fs-22: 1.375rem;
--fs-28: 1.75rem;
--fs-40: 2.5rem;
--lh-tight: 1.15;
--lh-text: 1.5;
/* Spacing: 4 px grid. */
--s-1: 4px;
--s-2: 8px;
--s-3: 12px;
--s-4: 16px;
--s-5: 20px;
--s-6: 24px;
--s-8: 32px;
--s-10: 40px;
--s-12: 48px;
--s-16: 64px;
/* Radius hierarchy: containers > controls > fields > chips. */
--r-card: 14px;
--r-control: 10px;
--r-field: 8px;
--r-chip: 6px;
--tap: 48px; /* minimum touch target */
--header-h: 64px;
--content-max: 1160px;
/* Safe areas (notch, home indicator) for the mobile shell. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--ease: cubic-bezier(0.2, 0, 0, 1);
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// Dev: the API runs on :8000 (uvicorn / docker compose); /api is proxied so the app needs no CORS.
// Build output is a static bundle (dist/) — the same bundle is wrapped by Capacitor for the mobile app.
export default defineConfig({
plugins: [react()],
base: "./",
server: {
port: 5173,
proxy: { "/api": { target: process.env.API_TARGET ?? "http://localhost:8000", changeOrigin: true } },
},
});