Compare commits

..
Author SHA1 Message Date
Eleboba 78312f5076 Merge pull request 'feat(web): сад на экране «Сегодня»' (#125) from feat/119-garden-web into dev
CI / ruff · pytest (push) Successful in 2m23s
CD / ruff · pytest (push) Successful in 2m24s
CD / deploy dev (push) Successful in 1m21s
2026-10-31 12:02:08 +03:00
ElebobaandClaude Opus 5.5 8bf56291c9 feat(web): garden on the «Сегодня» screen — stages, progress, today's checklist
CI / ruff · pytest (pull_request) Successful in 2m34s
SVG garden drawn from GET /me/{token}/garden: plants grow one after another
(sprout → stem → bud → flower), «Новый росток N из 10», today's activities,
«Пропуск — не страшно, сад не вянет». A gentle grow-in and a toast when the
garden grew since the last visit — but not right after a crisis reply.
No streaks, no «missed» marks, no red.

Refs #119

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:57:51 +03:00
Eleboba fd9df40c28 Merge pull request 'feat(web): голосовой ввод — запись, проверка текста, отправка' (#124) from feat/117-voice-web into dev
CI / ruff · pytest (push) Successful in 2m37s
CD / ruff · pytest (push) Successful in 2m26s
CD / deploy dev (push) Successful in 1m35s
2026-10-31 11:54:45 +03:00
ElebobaandClaude Opus 5.5 e20da1f21c feat(web): voice input — record, check the recognised text, send
CI / ruff · pytest (pull_request) Successful in 2m24s
Microphone button in the chat composer and in free-text steps. The browser
records 16 kHz mono WAV (no server-side ffmpeg), /me/{token}/stt transcribes,
the text lands in the field for the patient to check and edit — never sent
automatically, so safety and scrub() apply on send. Hidden when the server has
voice input off or the browser lacks a microphone API. nginx: 4 MB body limit.

Refs #117

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:45:36 +03:00
Eleboba 63cc5372be Merge pull request 'feat: сад пациента — рост от активностей (без давления)' (#123) from feat/118-garden into dev
CI / ruff · pytest (push) Successful in 2m24s
CD / ruff · pytest (push) Successful in 2m16s
CD / deploy dev (push) Successful in 1m17s
Reviewed-on: #123
2026-10-31 11:37:39 +03:00
Eleboba 4eb506bf65 Merge pull request 'feat: распознавание голоса пациента (STT через OpenRouter + mock)' (#122) from feat/116-stt into dev
CI / ruff · pytest (push) Successful in 2m25s
CD / ruff · pytest (push) Successful in 2m29s
CD / deploy dev (push) Successful in 3m8s
Reviewed-on: #122
2026-10-31 11:37:05 +03:00
ElebobaandClaude Opus 5.5 1cafaa87ec feat(backend): patient garden — deterministic growth from activities
CI / ruff · pytest (pull_request) Successful in 2m17s
GET /api/v1/me/{token}/garden computes the garden on the fly from taken doses,
check-ins/diary and finished questionnaires (no new table). Rules and texts in
config/garden.yaml: per-day caps, points only add up, answers never count.

Refs #118

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:22:44 +03:00
16 changed files with 1060 additions and 1 deletions
+13
View File
@@ -46,6 +46,7 @@ from app.db.session import get_clinical_session, get_identity_session
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
from app.llm.stt import InvalidAudio, SttError, SttNoSpeech, get_stt, wav_seconds from app.llm.stt import InvalidAudio, SttError, SttNoSpeech, get_stt, wav_seconds
from app.services import anonymous_care from app.services import anonymous_care
from app.services.garden import Garden, garden_for
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -426,6 +427,18 @@ async def my_today(token: str) -> TodayOut:
) )
@router.get("/me/{token}/garden", response_model=Garden)
async def my_garden(token: str) -> Garden:
"""The patient's garden (#118): grows from medication marks, check-ins/diary and finished questionnaires.
Never shrinks and never depends on answers. Seen only by the patient — not on the dashboard, not in summaries.
"""
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
return await garden_for(s, token)
class ResultFinding(BaseModel): class ResultFinding(BaseModel):
code: str code: str
title: str title: str
+156
View File
@@ -0,0 +1,156 @@
"""The patient's garden (#118, slide 13): light gamification over all modules.
Deterministic, no LLM (guardrail 3), rules in `config/garden.yaml` (ADR-008). Computed on the fly from existing
activity rows — no own table, no migration (ADR-003), and changing the rules replays the whole history.
«Without pressure»: points only add up (a skipped day takes nothing away), growth counts the fact of an activity
and never its content (scale scores, mood), and there is no comparison between patients. Only the patient sees it.
"""
from collections import defaultdict
from collections.abc import Iterable
from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock
from app.core.intake_questions import DIARY_KINDS
from app.db.clinical import DailyCheckin, Dose, ScaleResult
from app.db.intake import QuestionnaireRecord
Activity = Literal["meds", "diary", "questionnaire"]
class ActivityRule(BaseModel):
points: int
daily_cap: int
class TitleRule(BaseModel):
from_steps: int
title: str
class GardenConfig(BaseModel):
activities: dict[Activity, ActivityRule]
step_points: int
stages_per_plant: int
max_plants: int
titles: list[TitleRule]
today: dict[Activity, str]
@lru_cache
def load_config(path: Path | None = None) -> GardenConfig:
from app.config import get_settings
path = path or get_settings().config_dir / "garden.yaml"
return GardenConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
class Plant(BaseModel):
stage: int # 0 = not yet sprouted … stages_per_plant = in bloom
class TodayItem(BaseModel):
activity: Activity
title: str
done: bool
detail: str | None = None # e.g. "1 из 2" for doses
class Garden(BaseModel):
points: int
steps: int
title: str
step_points: int
points_in_step: int # progress towards the next growth step: «8 из 10»
plants: list[Plant]
today: list[TodayItem]
def compute(events: Iterable[tuple[Activity, date]], today: date, cfg: GardenConfig | None = None) -> Garden:
"""Points from (activity, day) events: per-day caps, events after `today` (demo clock) ignored."""
cfg = cfg or load_config()
per_day: dict[tuple[Activity, date], int] = defaultdict(int)
for kind, day in events:
if day <= today:
per_day[(kind, day)] += 1
points = sum(
min(n, cfg.activities[kind].daily_cap) * cfg.activities[kind].points for (kind, _), n in per_day.items()
)
steps = points // cfg.step_points
plants = [
Plant(stage=max(0, min(cfg.stages_per_plant, steps - i * cfg.stages_per_plant))) for i in range(cfg.max_plants)
]
title = next(t.title for t in sorted(cfg.titles, key=lambda t: -t.from_steps) if steps >= t.from_steps)
return Garden(
points=points,
steps=steps,
title=title,
step_points=cfg.step_points,
points_in_step=points % cfg.step_points,
plants=plants,
today=[],
)
def _day(ts: datetime, tz) -> date:
return ts.astimezone(tz).date()
async def garden_for(s: AsyncSession, token: str) -> Garden:
cfg = load_config()
now = clock.now()
tz, today = now.tzinfo, now.date()
events: list[tuple[Activity, date]] = []
doses = (await s.scalars(select(Dose).where(Dose.token == token))).all()
for d in doses:
if d.status == "taken":
events.append(("meds", _day(d.answered_at or d.planned_at, tz)))
checkins = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all()
for c in checkins:
if c.mood is not None:
events.append(("diary", c.day))
records = (await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all()
for r in records:
if r.status != "complete":
continue
if r.kind in DIARY_KINDS:
events.append(("diary", r.day))
else:
events.append(("questionnaire", _day(r.completed_at, tz) if r.completed_at else r.day))
scales = (await s.scalars(select(ScaleResult).where(ScaleResult.token == token))).all()
for sc in scales:
if sc.complete:
events.append(("questionnaire", _day(sc.ts, tz)))
garden = compute(events, today, cfg)
done_today = {kind for kind, day in events if day == today}
items: list[TodayItem] = []
due = [d for d in doses if _day(d.planned_at, tz) == today]
if due:
taken = sum(d.status == "taken" for d in due)
due_now = sum(d.planned_at <= now for d in due)
items.append(
TodayItem(
activity="meds",
title=cfg.today["meds"],
done=taken > 0 and taken >= due_now,
detail=f"{taken} из {len(due)}",
)
)
items.append(TodayItem(activity="diary", title=cfg.today["diary"], done="diary" in done_today))
# A questionnaire is not due every day: show it only once done, so an empty day is never a «missed» item.
if "questionnaire" in done_today:
items.append(TodayItem(activity="questionnaire", title=cfg.today["questionnaire"], done=True))
garden.today = items
return garden
+38
View File
@@ -0,0 +1,38 @@
# Сад (#118, слайд 13): лёгкая геймификация поверх модулей. Расчёт детерминированный, без LLM.
# Принципы «без давления» (вычитка — #120):
# 1. Сад не вянет: очки только прибавляются, пропуск ничего не отнимает.
# 2. Рост не зависит от ответов: считается факт активности, а не баллы шкал или настроение.
# 3. Нет соревнования: только свой сад, никаких сравнений.
# Очки за активность и дневной лимит по каждому типу (чтобы пять одинаковых действий подряд не давали пятикратный рост).
activities:
meds: # отметка «Принял(а)» по назначенной дозе
points: 2
daily_cap: 4
diary: # ежедневный чек-ин или запись дневника
points: 3
daily_cap: 1
questionnaire: # завершённый опрос (шкала, этап опроса)
points: 4
daily_cap: 1
# Каждые step_points очков — один шаг роста. Растения растут по очереди: у каждого stages_per_plant стадий
# (росток → стебель → бутон → цветок); распустился один — проклёвывается следующий.
step_points: 10
stages_per_plant: 4
max_plants: 6
# Название сада по числу шагов роста (берётся последний порог ≤ шагов).
titles:
- {from_steps: 0, title: "Сад ждёт первых ростков"}
- {from_steps: 1, title: "Появились первые ростки"}
- {from_steps: 4, title: "Распустился первый цветок"}
- {from_steps: 10, title: "Сад растёт"}
- {from_steps: 18, title: "Сад цветёт"}
- {from_steps: 24, title: "Цветущий сад"}
# Подписи пунктов «Сегодня».
today:
meds: "Приём лекарств"
diary: "Запись в дневнике"
questionnaire: "Назначенный опрос"
+88
View File
@@ -0,0 +1,88 @@
"""Garden (#118): deterministic growth from activities, daily caps, never shrinks, ignores answers."""
from collections.abc import AsyncIterator
from datetime import date, datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.config import Settings
from app.core import clock
from app.db import session
from app.db.clinical import DailyCheckin, Dose
from app.main import app
from app.services.garden import compute, load_config
from scripts.seed_demo import seed
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 5, 14, 0)
D = date(2026, 10, 5)
def test_points_steps_and_daily_caps() -> None:
cfg = load_config()
one_diary = compute([("diary", D)], D)
assert one_diary.points == cfg.activities["diary"].points
# Five diary entries on one day count as one; on five days — five times.
assert compute([("diary", D)] * 5, D).points == one_diary.points
five_days = compute([("diary", D - timedelta(days=i)) for i in range(5)], D)
assert five_days.points == 5 * one_diary.points
assert five_days.steps == five_days.points // cfg.step_points
assert five_days.points_in_step == five_days.points % cfg.step_points
def test_never_shrinks_and_future_ignored() -> None:
events = [("meds", D), ("diary", D), ("questionnaire", D)]
now = compute(events, D)
# A week without any activity takes nothing away.
assert compute(events, D + timedelta(days=7)).points == now.points
# Rows dated after the (demo) today are not counted yet.
assert compute([*events, ("diary", D + timedelta(days=1))], D).points == now.points
def test_plants_grow_one_after_another() -> None:
cfg = load_config()
per_plant = cfg.stages_per_plant
g = compute([("questionnaire", D - timedelta(days=i)) for i in range(100)], D)
stages = [p.stage for p in g.plants]
assert stages == sorted(stages, reverse=True) and stages[0] == per_plant
assert sum(stages) == min(g.steps, per_plant * cfg.max_plants)
assert compute([], D).title == cfg.titles[0].title
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
async def test_andrey_has_a_living_garden_that_grows_and_ignores_mood(client: AsyncClient) -> None:
token = (await seed(12)).andrey_token
url = f"/api/v1/me/{token}/garden"
before = (await client.get(url)).json()
assert before["steps"] >= 4 and before["plants"][0]["stage"] > 0
# Mood is an answer: a bad day and a good day grow the garden the same way.
async with session.get_clinical_session() as s:
rows = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all()
for r in rows:
r.mood = 1
assert (await client.get(url)).json()["points"] == before["points"]
# Taking a dose today adds points.
async with session.get_clinical_session() as s:
dose = Dose(token=token, prescription_id=1, planned_at=clock.now(), status="taken", answered_at=clock.now())
s.add(dose)
after = (await client.get(url)).json()
assert after["points"] >= before["points"]
assert any(i["activity"] == "diary" for i in after["today"])
async def test_unknown_patient_404(client: AsyncClient) -> None:
await seed(0)
assert (await client.get("/api/v1/me/nobody/garden")).status_code == 404
+1
View File
@@ -14,5 +14,6 @@ server {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 120s; proxy_read_timeout 120s;
client_max_body_size 4m; # voice input: up to 60 s of 16 kHz WAV (~1.9 MB)
} }
} }
+19 -1
View File
@@ -4,6 +4,7 @@ import type {
CrisisResource, CrisisResource,
DemoPersona, DemoPersona,
DialogEvent, DialogEvent,
Garden,
Focus, Focus,
MedsReference, MedsReference,
Reply, Reply,
@@ -46,13 +47,30 @@ export const api = {
signInAnonymous: (focus?: Focus) => post<AuthResult>("/auth/anonymous", { focus }), signInAnonymous: (focus?: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
// Presentation entry: a fresh private copy of the demo patient, straight to the chat. // Presentation entry: a fresh private copy of the demo patient, straight to the chat.
signInDemoFresh: () => post<AuthResult>("/auth/demo/fresh", {}), signInDemoFresh: () => post<AuthResult>("/auth/demo/fresh", {}),
patientCapabilities: () => request<{ primary_screening: boolean }>("/patient/capabilities"), patientCapabilities: () =>
request<{ primary_screening: boolean; voice_input?: boolean }>("/patient/capabilities"),
// Voice → text for the patient to check (#117). Raw WAV body; the server only transcribes.
transcribe: async (token: string, wav: Blob): Promise<string> => {
let res: Response;
try {
res = await fetch(`${BASE}/me/${encodeURIComponent(token)}/stt`, {
method: "POST",
headers: { "Content-Type": "audio/wav" },
body: wav,
});
} catch {
throw new ApiError(0, "network");
}
if (!res.ok) throw new ApiError(res.status, res.statusText);
return ((await res.json()) as { text: string }).text;
},
connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`), connectDoctor: (token: string) => post<{ has_doctor: boolean }>(`/me/${encodeURIComponent(token)}/doctor/connect`),
demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"), demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"), crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
catalog: () => request<Catalog>("/instruments/catalog"), catalog: () => request<Catalog>("/instruments/catalog"),
medsReference: () => request<MedsReference>("/meds/reference"), medsReference: () => request<MedsReference>("/meds/reference"),
today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`), today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`),
garden: (token: string) => request<Garden>(`/me/${encodeURIComponent(token)}/garden`),
results: (token: string) => request<Results>(`/me/${encodeURIComponent(token)}/results`), results: (token: string) => request<Results>(`/me/${encodeURIComponent(token)}/results`),
sendEvent: async (token: string, event: DialogEvent) => sendEvent: async (token: string, event: DialogEvent) =>
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies, (await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
+13
View File
@@ -180,3 +180,16 @@ export interface MedsReference {
string string
>; >;
} }
/** GET /me/{token}/garden (#118): grows from activities, never shrinks, ignores answers. */
export type GardenActivity = "meds" | "diary" | "questionnaire";
export interface Garden {
points: number;
steps: number;
title: string;
step_points: number;
points_in_step: number;
plants: { stage: number }[];
today: { activity: GardenActivity; title: string; done: boolean; detail: string | null }[];
}
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { Garden as GardenData, GardenActivity } from "../api/types";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
import { Icon, type IconName } from "./Icon";
import { useToast } from "./Toast";
/*
* «Ваш сад» (#119, slide 13): every useful activity grows the garden. Without pressure — it never wilts, does not
* depend on answers, nobody competes; there are no streaks, no «missed» marks and no red. The numbers come from the
* server (#118); this only draws them.
*/
const FLOWERS = ["#d9604c", "#e9a23b", "#c96f9a", "#e07a3f", "#b9577a", "#d4a02a"];
const SLOTS = [44, 92, 140, 188, 236, 284];
const GROUND = 128;
const ICONS: Record<GardenActivity, IconName> = { meds: "pill", diary: "book", questionnaire: "clipboard" };
const seenKey = (token: string) => `ptsr.garden.steps.${token}`;
function Plant({ x, stage, color, fresh }: { x: number; stage: number; color: string; fresh: boolean }) {
if (stage <= 0) return <ellipse cx={x} cy={GROUND + 1} rx={5} ry={2} fill="#c9b08a" opacity={0.7} />;
const h = [0, 18, 38, 54, 62][Math.min(stage, 4)];
const top = GROUND - h;
return (
<g
className={fresh ? "garden__plant garden__plant--fresh" : "garden__plant"}
style={{ transformOrigin: `${x}px ${GROUND}px` }}
>
<path
d={`M${x} ${GROUND} C ${x - 2} ${GROUND - h / 2}, ${x + 2} ${top + h / 3}, ${x} ${top}`}
stroke="#4f7d5a"
strokeWidth={2.4}
fill="none"
strokeLinecap="round"
/>
<path d={`M${x} ${GROUND - h * 0.35} q -11 -3 -13 -11 q 10 0 13 9`} fill="#6f9c6a" />
{stage >= 2 && <path d={`M${x} ${GROUND - h * 0.6} q 11 -3 13 -11 q -10 0 -13 9`} fill="#6f9c6a" />}
{stage === 1 && <path d={`M${x} ${top + 2} q 8 -4 9 -10 q -8 1 -9 8`} fill="#7fae74" />}
{stage === 3 && <ellipse cx={x} cy={top - 3} rx={4.5} ry={7} fill={color} />}
{stage >= 4 && (
<g>
{[0, 72, 144, 216, 288].map((a) => (
<ellipse key={a} cx={x} cy={top - 8} rx={4.6} ry={8} fill={color} transform={`rotate(${a} ${x} ${top})`} />
))}
<circle cx={x} cy={top} r={4.2} fill="#f6d36b" />
</g>
)}
</g>
);
}
export function Garden({ refreshKey }: { refreshKey?: unknown }) {
const { session } = useSession();
const dialog = useDialog();
const toast = useToast();
const [garden, setGarden] = useState<GardenData | null>(null);
const [freshFrom, setFreshFrom] = useState<number | null>(null);
const announced = useRef(false);
useEffect(() => {
if (!session) return;
let alive = true;
api
.garden(session.token)
.then((g) => {
if (!alive) return;
setGarden(g);
const raw = window.localStorage.getItem(seenKey(session.token));
const seen = raw === null ? null : Number(raw);
// No celebration right after a crisis: growth stays, the toast and the animation do not.
const recentCrisis = dialog.entries.slice(-12).some((e) => e.from === "assistant" && e.reply.kind === "crisis");
if (seen !== null && g.steps > seen && !recentCrisis) {
setFreshFrom(seen);
if (!announced.current) toast(ru.garden.grew);
announced.current = true;
}
window.localStorage.setItem(seenKey(session.token), String(g.steps));
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [session, refreshKey]);
if (!garden) return null;
const t = ru.garden;
const perPlant = 4;
const bloom = garden.plants.filter((p) => p.stage >= perPlant).length;
const growing = garden.plants.filter((p) => p.stage > 0 && p.stage < perPlant).length;
const share = garden.points_in_step / garden.step_points;
return (
<section className="card garden" aria-labelledby="garden-title">
<div>
<p className="garden__eyebrow">{t.eyebrow}</p>
<h2 id="garden-title" className="h3">
{t.lead}
</h2>
</div>
<svg
className="garden__scene"
viewBox="0 0 328 150"
role="img"
aria-label={t.label(garden.title, bloom, growing)}
>
<defs>
<linearGradient id="garden-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#eef3ea" />
<stop offset="1" stopColor="#f7f3ea" />
</linearGradient>
</defs>
<rect width="328" height="150" rx="12" fill="url(#garden-sky)" />
<circle cx="282" cy="36" r="17" fill="#f2cc72" opacity={0.9} />
<path
d={`M0 ${GROUND + 4} Q 90 ${GROUND - 8} 170 ${GROUND + 2} T 328 ${GROUND - 2} V150 H0 Z`}
fill="#e5d3b0"
/>
<path
d={`M0 ${GROUND + 12} Q 110 ${GROUND + 2} 210 ${GROUND + 12} T 328 ${GROUND + 8} V150 H0 Z`}
fill="#dcc59c"
/>
{garden.plants.map((p, i) => {
// The plant that just grew (its stage covers steps after the last seen count) gets a gentle grow-in.
const fresh =
freshFrom !== null && garden.steps > freshFrom && Math.floor((garden.steps - 1) / perPlant) === i;
return (
<Plant
key={i}
x={SLOTS[i % SLOTS.length]}
stage={p.stage}
color={FLOWERS[i % FLOWERS.length]}
fresh={fresh}
/>
);
})}
</svg>
<p className="garden__title">{garden.title}</p>
<div className="garden__progress">
<div className="garden__progress-head">
<span>{t.next}</span>
<span className="muted small">{t.of(garden.points_in_step, garden.step_points)}</span>
</div>
<div
className="garden__bar"
role="progressbar"
aria-valuemin={0}
aria-valuemax={garden.step_points}
aria-valuenow={garden.points_in_step}
aria-label={t.next}
>
<span style={{ width: `${Math.round(share * 100)}%` }} />
</div>
</div>
{garden.today.length > 0 && (
<ul className="garden__today" aria-label={t.today}>
{garden.today.map((item) => (
<li key={item.activity} className="garden__item">
<span className="garden__icon" aria-hidden="true">
<Icon name={ICONS[item.activity]} size={16} />
</span>
<span className="garden__item-text">
{item.title}
{item.detail && <span className="muted small"> · {item.detail}</span>}
</span>
<span
className={item.done ? "garden__check garden__check--done" : "garden__check"}
aria-label={item.done ? "сделано" : "ещё нет"}
>
{item.done && <Icon name="check" size={14} />}
</span>
</li>
))}
</ul>
)}
<p className="garden__note">{t.noPressure}</p>
</section>
);
}
+6
View File
@@ -32,6 +32,12 @@ const paths = {
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />, arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
close: <path d="M6 6l12 12M18 6L6 18" />, close: <path d="M6 6l12 12M18 6L6 18" />,
check: <path d="M5 12.5l4.5 4.5L19 7.5" />, check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
mic: (
<>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21" />
</>
),
x: <path d="M7 7l10 10M17 7L7 17" />, x: <path d="M7 7l10 10M17 7L7 17" />,
info: ( info: (
<> <>
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from "react";
import { ApiError, api } from "../api/client";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
import { MAX_SECONDS, Recorder, voiceSupported } from "../voice/recorder";
import { Icon } from "./Icon";
import { useToast } from "./Toast";
/*
* Voice input next to a free-text field (#117, slide 11: «Записать → Проверить»). The recognised text is put into
* the field for the patient to check and edit — never sent automatically: safety and scrub() run when they send it.
*/
let capability: Promise<boolean> | null = null;
function voiceEnabled(): Promise<boolean> {
capability ??= api
.patientCapabilities()
.then((c) => Boolean(c.voice_input) && voiceSupported())
.catch(() => false);
return capability;
}
const BARS = 14;
type State = "idle" | "recording" | "transcribing";
export function VoiceInput({
disabled,
onText,
compact,
}: {
disabled?: boolean;
onText: (text: string) => void;
/** Chat composer: a round icon button; the recording panel floats above the composer. */
compact?: boolean;
}) {
const { session } = useSession();
const toast = useToast();
const [enabled, setEnabled] = useState(false);
const [state, setState] = useState<State>("idle");
const [seconds, setSeconds] = useState(0);
const [levels, setLevels] = useState<number[]>(() => Array(BARS).fill(0));
const rec = useRef<Recorder | null>(null);
const timer = useRef<number>();
useEffect(() => {
let alive = true;
void voiceEnabled().then((on) => alive && setEnabled(on));
return () => {
alive = false;
window.clearInterval(timer.current);
rec.current?.cancel();
};
}, []);
if (!enabled || !session) return null;
const start = async () => {
const r = new Recorder();
try {
await r.start((level) => setLevels((prev) => [...prev.slice(1), level]));
} catch {
r.cancel();
toast(ru.voice.noMic);
return;
}
rec.current = r;
setSeconds(0);
setState("recording");
const began = Date.now();
timer.current = window.setInterval(() => {
const s = Math.floor((Date.now() - began) / 1000);
setSeconds(s);
if (s >= MAX_SECONDS) void stop();
}, 250);
};
const reset = () => {
window.clearInterval(timer.current);
rec.current = null;
setLevels(Array(BARS).fill(0));
};
const stop = async () => {
const wav = rec.current?.stop() ?? null;
reset();
if (!wav) {
setState("idle");
return;
}
setState("transcribing");
try {
const text = await api.transcribe(session.token, wav);
onText(text);
toast(ru.voice.check);
} catch (e) {
const status = e instanceof ApiError ? e.status : 0;
toast(status === 422 ? ru.voice.noSpeech : status === 413 ? ru.voice.tooLong : ru.voice.failed);
} finally {
setState("idle");
}
};
const cancel = () => {
rec.current?.cancel();
reset();
setState("idle");
};
if (state === "idle" && compact) {
return (
<button
type="button"
className="mic"
disabled={disabled}
onClick={() => void start()}
aria-label={ru.voice.start}
title={ru.voice.start}
>
<Icon name="mic" size={20} />
</button>
);
}
if (state === "idle") {
return (
<button type="button" className="voice-btn" disabled={disabled} onClick={() => void start()}>
<Icon name="mic" size={20} />
{ru.voice.start}
</button>
);
}
const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
const ss = String(seconds % 60).padStart(2, "0");
return (
<div className={compact ? "voice voice--float" : "voice"} role="group" aria-label={ru.voice.recording}>
{state === "recording" ? (
<>
<p className="voice__hint">{ru.voice.recording}</p>
<div className="voice__wave" aria-hidden="true">
{levels.map((l, i) => (
<span key={i} style={{ height: `${Math.max(12, Math.round(l * 100))}%` }} />
))}
</div>
<div className="voice__row">
<span className="voice__time" aria-live="off">
{mm}:{ss}
</span>
<button type="button" className="btn btn--ghost voice__cancel" onClick={cancel}>
{ru.voice.cancel}
</button>
<button type="button" className="btn btn--primary voice__stop" onClick={() => void stop()}>
{ru.voice.stop}
</button>
</div>
<p className="voice__privacy">{ru.voice.privacy}</p>
</>
) : (
<p className="voice__hint" aria-live="polite">
{ru.voice.transcribing}
</p>
)}
</div>
);
}
+24
View File
@@ -86,6 +86,17 @@ export const ru = {
unavailable: "Первичный опрос сейчас отключён на сервере. Кабинет и подключение врача доступны без личных данных.", unavailable: "Первичный опрос сейчас отключён на сервере. Кабинет и подключение врача доступны без личных данных.",
submitting: "Создаём профиль…", submitting: "Создаём профиль…",
}, },
garden: {
eyebrow: "Ваш сад",
lead: "Растёт вместе с вами",
next: "Новый росток",
of: (a: number, b: number) => `${a} из ${b}`,
today: "Сегодня",
noPressure: "Пропуск — не страшно, сад не вянет",
grew: "Сад подрос — появился новый росток",
label: (title: string, bloom: number, growing: number) =>
`${title}. Цветов: ${bloom}, растёт: ${growing}.`,
},
today: { today: {
title: "Сегодня", title: "Сегодня",
takeMed: "Принять лекарство", takeMed: "Принять лекарство",
@@ -251,6 +262,19 @@ export const ru = {
drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.", drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.",
noticeTitle: "Опрос приостановлен", noticeTitle: "Опрос приостановлен",
}, },
voice: {
start: "Сказать голосом",
stop: "Остановить запись",
cancel: "Отменить",
recording: "Говорите — запись идёт",
transcribing: "Распознаём речь…",
check: "Проверьте распознанный текст — его можно исправить перед отправкой.",
privacy: "Запись не сохраняется: её видит только система распознавания, текст — специалисты вашей программы.",
noMic: "Нет доступа к микрофону. Разрешите его в настройках браузера или напишите текстом.",
noSpeech: "Не удалось разобрать речь. Попробуйте ещё раз или напишите текстом.",
failed: "Распознавание сейчас недоступно. Напишите, пожалуйста, текстом.",
tooLong: "Запись слишком длинная — до минуты за раз.",
},
messages: { messages: {
typing: "Помощник печатает", typing: "Помощник печатает",
title: "Сообщения", title: "Сообщения",
+2
View File
@@ -5,6 +5,7 @@ import type { Catalog, DialogButton } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell"; import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet"; import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
import { useToast } from "../components/Toast"; import { useToast } from "../components/Toast";
import { VoiceInput } from "../components/VoiceInput";
import { Icon, type IconName } from "../components/Icon"; import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference"; import { useCatalog } from "../content/reference";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
@@ -259,6 +260,7 @@ function FreeText({
/> />
<span className="textarea__count">{text.length}/500</span> <span className="textarea__count">{text.length}/500</span>
</div> </div>
<VoiceInput disabled={busy} onText={(t) => setText((prev) => (prev.trim() ? `${prev.trim()} ${t}` : t).slice(0, 500))} />
<div className="btn-row"> <div className="btn-row">
{skip ? ( {skip ? (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => onSkip(skip)}> <button type="button" className="btn btn--ghost" disabled={busy} onClick={() => onSkip(skip)}>
+6
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { AppShell, TopBar } from "../components/AppShell"; import { AppShell, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
import { VoiceInput } from "../components/VoiceInput";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext"; import { useDialog } from "../dialog/DialogContext";
@@ -112,6 +113,11 @@ export function Messages() {
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
maxLength={2000} maxLength={2000}
/> />
<VoiceInput
compact
disabled={dialog.busy}
onText={(text) => setDraft((prev) => (prev.trim() ? `${prev.trim()} ${text}` : text).slice(0, 2000))}
/>
<button type="submit" className="send" disabled={dialog.busy || !draft.trim()} aria-label={ru.common.send}> <button type="submit" className="send" disabled={dialog.busy || !draft.trim()} aria-label={ru.common.send}>
<Icon name="send" size={20} /> <Icon name="send" size={20} />
</button> </button>
+2
View File
@@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom";
import type { TodayDose, TodayPrescription } from "../api/types"; import type { TodayDose, TodayPrescription } from "../api/types";
import { AppShell, Notice } from "../components/AppShell"; import { AppShell, Notice } from "../components/AppShell";
import { Garden } from "../components/Garden";
import { Icon, type IconName } from "../components/Icon"; import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference"; import { useCatalog } from "../content/reference";
import { ru } from "../content/ru"; import { ru } from "../content/ru";
@@ -253,6 +254,7 @@ export function Today() {
</div> </div>
)} )}
<Garden refreshKey={dialog.pending} />
</section> </section>
<section className="dash__side"> <section className="dash__side">
+225
View File
@@ -1566,6 +1566,231 @@ button.row-card:hover:not(:disabled) {
color: var(--c-text-3); color: var(--c-text-3);
} }
/* ---------------------------------------------------------------- voice input (#117, slide 11) */
.voice-btn {
display: inline-flex;
align-items: center;
gap: var(--s-2);
justify-self: start;
min-height: 44px;
padding: 0 var(--s-4);
border: 1.5px solid var(--c-border-strong);
border-radius: 999px;
background: var(--c-surface);
color: var(--c-teal-strong);
font-weight: 600;
font-size: var(--fs-14);
}
.voice-btn:hover:not(:disabled) {
background: var(--c-teal-soft);
}
.mic {
flex: none;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 1.5px solid var(--c-border);
border-radius: 50%;
background: var(--c-surface);
color: var(--c-teal-strong);
}
.mic:hover:not(:disabled) {
background: var(--c-teal-soft);
}
.voice {
display: grid;
gap: var(--s-3);
padding: var(--s-4);
border: 1px solid var(--c-border);
border-radius: var(--r-tile);
background: #fbf6ee; /* warm, as on the slide */
}
.voice--float {
position: absolute;
left: 0;
right: 0;
bottom: calc(100% + var(--s-2));
box-shadow: 0 8px 24px rgb(33 59 56 / 12%);
}
.voice__hint {
margin: 0;
font-weight: 600;
color: var(--c-text);
}
.voice__wave {
display: flex;
align-items: center;
gap: 5px;
height: 44px;
}
.voice__wave span {
flex: 1;
max-width: 6px;
border-radius: 3px;
background: #b5452f;
transition: height 90ms linear;
}
.voice__row {
display: grid;
grid-template-columns: auto 1fr 1.4fr;
align-items: center;
gap: var(--s-2);
}
.voice__time {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--c-text-2);
}
.voice__row .btn {
min-height: 44px;
padding: 0 var(--s-3);
font-size: var(--fs-14);
}
.voice__privacy {
margin: 0;
font-size: var(--fs-12);
color: var(--c-text-3);
}
/* ---------------------------------------------------------------- garden (#119, slide 13) */
.garden {
gap: var(--s-3);
}
.garden__eyebrow {
margin: 0 0 2px;
font-size: var(--fs-12);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--c-teal-strong);
}
.garden__scene {
display: block;
width: 100%;
height: auto;
max-height: 190px;
}
.garden__plant--fresh {
animation: garden-grow 1100ms var(--ease) both;
}
@keyframes garden-grow {
from {
transform: scaleY(0.2);
opacity: 0.3;
}
to {
transform: scaleY(1);
opacity: 1;
}
}
.garden__title {
margin: 0;
font-weight: 600;
color: var(--c-text);
}
.garden__progress {
display: grid;
gap: var(--s-2);
padding: var(--s-3);
border-radius: var(--r-btn);
background: var(--c-tile);
}
.garden__progress-head {
display: flex;
justify-content: space-between;
font-weight: 600;
font-size: var(--fs-14);
}
.garden__bar {
height: 8px;
border-radius: 999px;
background: var(--c-border);
overflow: hidden;
}
.garden__bar span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--c-primary);
transition: width 600ms var(--ease);
}
.garden__today {
display: grid;
gap: var(--s-2);
margin: 0;
padding: 0;
list-style: none;
}
.garden__item {
display: flex;
align-items: center;
gap: var(--s-3);
font-size: var(--fs-14);
}
.garden__icon {
flex: none;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: var(--r-btn);
background: var(--c-teal-soft);
color: var(--c-teal-strong);
}
.garden__item-text {
flex: 1;
}
.garden__check {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 1.5px solid var(--c-border-strong);
border-radius: 50%;
color: #fff;
}
.garden__check--done {
border-color: var(--c-primary);
background: var(--c-primary);
}
.garden__note {
margin: 0;
text-align: center;
font-size: var(--fs-12);
color: var(--c-text-3);
}
/* ---------------------------------------------------------------- emergency */ /* ---------------------------------------------------------------- emergency */
.sosblock { .sosblock {
+121
View File
@@ -0,0 +1,121 @@
/*
* Voice recording for the patient's messages (#117, slide 11): microphone → 16 kHz mono 16-bit WAV in the browser,
* so the server needs no ffmpeg and Chrome, Android and iOS Safari produce the same format (ADR-014).
* The audio lives only in memory until it is sent for transcription; nothing is stored on the device.
*/
export const TARGET_RATE = 16_000;
export const MAX_SECONDS = 60;
type WindowWithWebkit = Window & { webkitAudioContext?: typeof AudioContext };
export function voiceSupported(): boolean {
const w = window as WindowWithWebkit;
return (
typeof navigator.mediaDevices?.getUserMedia === "function" && Boolean(window.AudioContext || w.webkitAudioContext)
);
}
export class Recorder {
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private node: ScriptProcessorNode | null = null;
private chunks: Float32Array[] = [];
private rate = 48_000;
/** `onLevel` gets the loudness 0..1 of each audio block (for the waveform). */
async start(onLevel: (level: number) => void): Promise<void> {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
const Ctx = window.AudioContext || (window as WindowWithWebkit).webkitAudioContext!;
this.ctx = new Ctx();
await this.ctx.resume();
this.rate = this.ctx.sampleRate;
const source = this.ctx.createMediaStreamSource(this.stream);
// ScriptProcessorNode is deprecated but works everywhere (incl. older iOS Safari) without a worklet file.
this.node = this.ctx.createScriptProcessor(4096, 1, 1);
this.node.onaudioprocess = (e) => {
const data = new Float32Array(e.inputBuffer.getChannelData(0));
this.chunks.push(data);
let sum = 0;
for (let i = 0; i < data.length; i++) sum += data[i] * data[i];
onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
};
source.connect(this.node);
this.node.connect(this.ctx.destination);
}
/** Stops the microphone and returns the WAV, or null if nothing was recorded. */
stop(): Blob | null {
const chunks = this.chunks;
const rate = this.rate;
this.release();
if (!chunks.length) return null;
return encodeWav(downsample(concat(chunks), rate, TARGET_RATE), TARGET_RATE);
}
cancel(): void {
this.release();
}
private release(): void {
this.node?.disconnect();
if (this.node) this.node.onaudioprocess = null;
this.stream?.getTracks().forEach((t) => t.stop());
void this.ctx?.close();
this.node = null;
this.stream = null;
this.ctx = null;
this.chunks = [];
}
}
function concat(chunks: Float32Array[]): Float32Array {
const out = new Float32Array(chunks.reduce((n, c) => n + c.length, 0));
let pos = 0;
for (const c of chunks) {
out.set(c, pos);
pos += c.length;
}
return out;
}
/** Averaging downsampler: good enough for speech, avoids aliasing better than dropping samples. */
function downsample(input: Float32Array, from: number, to: number): Float32Array {
if (from === to) return input;
const ratio = from / to;
const out = new Float32Array(Math.floor(input.length / ratio));
for (let i = 0; i < out.length; i++) {
const start = Math.floor(i * ratio);
const end = Math.min(input.length, Math.floor((i + 1) * ratio));
let sum = 0;
for (let j = start; j < end; j++) sum += input[j];
out[i] = sum / Math.max(1, end - start);
}
return out;
}
function encodeWav(samples: Float32Array, rate: number): Blob {
const buf = new ArrayBuffer(44 + samples.length * 2);
const v = new DataView(buf);
const str = (o: number, s: string) => [...s].forEach((ch, i) => v.setUint8(o + i, ch.charCodeAt(0)));
str(0, "RIFF");
v.setUint32(4, 36 + samples.length * 2, true);
str(8, "WAVE");
str(12, "fmt ");
v.setUint32(16, 16, true);
v.setUint16(20, 1, true); // PCM
v.setUint16(22, 1, true); // mono
v.setUint32(24, rate, true);
v.setUint32(28, rate * 2, true); // byte rate
v.setUint16(32, 2, true); // block align
v.setUint16(34, 16, true);
str(36, "data");
v.setUint32(40, samples.length * 2, true);
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
v.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return new Blob([buf], { type: "audio/wav" });
}