From d61499025c2e34aeb88f2982d89fe2fb9c8306bf Mon Sep 17 00:00:00 2001 From: virus-home Date: Mon, 19 Oct 2026 01:31:45 +0500 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=207,=20=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F=203=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=88=D0=B5=D0=BD=D0=B0:=20m08-admin-web=20(=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D1=8C=20=D0=B2=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D1=86=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (Server.WatchBody.Web): AdminAuthService — PBKDF2 (pbkdf2$100000$salt$hash) → in-memory Bearer-сессии TTL 8 ч (Admin.Login пуст → консоль отключена); 6 контроллеров /api/admin/*: login/logout; атлеты (создание — ключ wb-… возвращается один раз, смена ключа, блокировка → 401 на /api/v1); тренировки всех атлетов (фильтры/пагинация, карточка план/факт + таймлайн Lap/пульс + пульсограмма ≤600 точек), CSV (;/BOM, tech-req 2.4); программы (версии/body, активация draft, применение rules-предложения через author=admin-копию — неизменяемость версий); метрики (+weekly ISO-агрегат); план/факт (PlanSummaryService: недели от понедельника создания active-версии, performed — только плановые дни). Errata m02: ApiKeyMiddleware сужен до /api/v1/* (перехватывал статику); Kit.Routes — роут на блок AddController (NeedHttpPost_All не чейнится). Flutter Web UI (3-app\3-web\admin-web, SDK 3.47.7): Material 3 тёмная тема, NavigationRail, 6 экранов, токен sessionStorage, 401 → логин, CSV через Bearer+Blob, графики CustomPaint; без внешних pub-пакетов; build.ps1 → wwwroot\admin (сборка в .gitignore — артефакт). Тесты: Web 105/105 (m08 — 7), Core 56/56; flutter analyze 0 ошибок/варнингов; HTTP-смок хостинга/логина пройден. Эксплуатационные файлы в коммит не включены. --- .gitignore | 3 + 1-docs/0-mng/plan.md | 2 +- 1-docs/4-modules/m08-admin-web/SKILL.md | 22 + .../m08-admin-web/api_specs/01-auth.md | 41 + .../m08-admin-web/api_specs/02-athletes.md | 30 + .../m08-admin-web/api_specs/03-workouts.md | 46 ++ .../m08-admin-web/api_specs/04-programs.md | 30 + .../api_specs/05-metrics-plan.md | 40 + .../m08-admin-web/api_specs/06-flutter-ui.md | 57 ++ 1-docs/4-modules/m08-admin-web/module_reqs.md | 85 +++ 1-docs/4-modules/m08-admin-web/readme.md | 46 ++ .../AdminApiTests.cs | 321 ++++++++ .../appsettings.test.json | 4 + .../Config/RouteConfig.cs | 54 ++ .../Config/ServerConfig.cs | 9 + .../Admin/AdminAthletesController.cs | 106 +++ .../Controllers/Admin/AdminAuthController.cs | 44 ++ .../Controllers/Admin/AdminControllerBase.cs | 34 + .../Admin/AdminMetricsController.cs | 80 ++ .../Admin/AdminProgramsController.cs | 101 +++ .../Admin/AdminWorkoutsController.cs | 225 ++++++ .../Middleware/ApiKeyMiddleware.cs | 8 +- .../Services/AdminAuthService.cs | 91 +++ .../Services/FactsTimeline.cs | 70 ++ .../Services/PlanSummaryService.cs | 97 +++ .../Server.WatchBody.Web/WebAppStartup.cs | 5 + 3-app/3-web/admin-web/.gitignore | 48 ++ 3-app/3-web/admin-web/.metadata | 30 + 3-app/3-web/admin-web/README.md | 17 + 3-app/3-web/admin-web/analysis_options.yaml | 33 + 3-app/3-web/admin-web/build.ps1 | 9 + 3-app/3-web/admin-web/lib/api.dart | 72 ++ 3-app/3-web/admin-web/lib/main.dart | 173 +++++ 3-app/3-web/admin-web/lib/screens.dart | 706 ++++++++++++++++++ 3-app/3-web/admin-web/pubspec.lock | 213 ++++++ 3-app/3-web/admin-web/pubspec.yaml | 89 +++ 3-app/3-web/admin-web/web/favicon.png | Bin 0 -> 917 bytes 3-app/3-web/admin-web/web/icons/Icon-192.png | Bin 0 -> 5292 bytes 3-app/3-web/admin-web/web/icons/Icon-512.png | Bin 0 -> 8252 bytes .../admin-web/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes .../admin-web/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes 3-app/3-web/admin-web/web/index.html | 46 ++ 3-app/3-web/admin-web/web/manifest.json | 35 + 43 files changed, 3117 insertions(+), 5 deletions(-) create mode 100644 1-docs/4-modules/m08-admin-web/SKILL.md create mode 100644 1-docs/4-modules/m08-admin-web/api_specs/01-auth.md create mode 100644 1-docs/4-modules/m08-admin-web/api_specs/02-athletes.md create mode 100644 1-docs/4-modules/m08-admin-web/api_specs/03-workouts.md create mode 100644 1-docs/4-modules/m08-admin-web/api_specs/04-programs.md create mode 100644 1-docs/4-modules/m08-admin-web/api_specs/05-metrics-plan.md create mode 100644 1-docs/4-modules/m08-admin-web/api_specs/06-flutter-ui.md create mode 100644 1-docs/4-modules/m08-admin-web/module_reqs.md create mode 100644 1-docs/4-modules/m08-admin-web/readme.md create mode 100644 3-app/2-cli/Server.WatchBody.Web.Tests/AdminApiTests.cs create mode 100644 3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminAthletesController.cs create mode 100644 3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminAuthController.cs create mode 100644 3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminControllerBase.cs create mode 100644 3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminMetricsController.cs create mode 100644 3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminProgramsController.cs create mode 100644 3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminWorkoutsController.cs create mode 100644 3-app/2-cli/Server.WatchBody.Web/Services/AdminAuthService.cs create mode 100644 3-app/2-cli/Server.WatchBody.Web/Services/FactsTimeline.cs create mode 100644 3-app/2-cli/Server.WatchBody.Web/Services/PlanSummaryService.cs create mode 100644 3-app/3-web/admin-web/.gitignore create mode 100644 3-app/3-web/admin-web/.metadata create mode 100644 3-app/3-web/admin-web/README.md create mode 100644 3-app/3-web/admin-web/analysis_options.yaml create mode 100644 3-app/3-web/admin-web/build.ps1 create mode 100644 3-app/3-web/admin-web/lib/api.dart create mode 100644 3-app/3-web/admin-web/lib/main.dart create mode 100644 3-app/3-web/admin-web/lib/screens.dart create mode 100644 3-app/3-web/admin-web/pubspec.lock create mode 100644 3-app/3-web/admin-web/pubspec.yaml create mode 100644 3-app/3-web/admin-web/web/favicon.png create mode 100644 3-app/3-web/admin-web/web/icons/Icon-192.png create mode 100644 3-app/3-web/admin-web/web/icons/Icon-512.png create mode 100644 3-app/3-web/admin-web/web/icons/Icon-maskable-192.png create mode 100644 3-app/3-web/admin-web/web/icons/Icon-maskable-512.png create mode 100644 3-app/3-web/admin-web/web/index.html create mode 100644 3-app/3-web/admin-web/web/manifest.json diff --git a/.gitignore b/.gitignore index 21937c4..228737c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ Thumbs.db # Сборки и временные файлы *.log tmp/ + +# Сборка admin-консоли (m08) — артефакт flutter build (3-app-webdmin-webuild.ps1) +3-app/2-cli/Server.WatchBody.Web/wwwroot/admin/ diff --git a/1-docs/0-mng/plan.md b/1-docs/0-mng/plan.md index e54deac..6176806 100644 --- a/1-docs/0-mng/plan.md +++ b/1-docs/0-mng/plan.md @@ -42,7 +42,7 @@ JSON-тела (версии программ, факты .fit) — `jsonb` вн | m05-progression-rules | `Server.WatchBody.Core`: правила прогрессии (Ф-12.2), делоад (Ф-12.4), перерывы/возврат (Ф-14), учёт ограничений (is_active/замены), draft-версии `author=rules` | 2 | ✅ (2026-10-18: ProgressionRules + BreakEngine, фильтр ограничений, тесты Web 81/81 + Core 56/56) | | m06-fit-import | `Server.WatchBody.Import` + `--mode import`: FitParser (FIT SDK) → workout-facts/1, сопоставление ±15 мин, дедуп fit_file_id, пакет программы, TCX/GPX, архив | 2 | ✅ (2026-10-18: Garmin.FIT.Sdk 21.218.0, FitParser/WorkoutImporter/ProgramPackageImporter/ImportService, тесты Web 93/93 + Core 56/56, psql 123/123) | | m07-fit-fetch | `Server.WatchBody.Fetch` + `--mode fetch`: GarminClient (SSO по контракту garminconnect), FitFetcher (дедуп 2 рубежей), разбор конвейером m06 | 2 | ✅ (2026-10-18: SSO-флоу + FakeGarminHandler, тесты Web 98/98 + Core 56/56; против живого Garmin — ручная валидация прототипа, fallback — m06) | -| m08-admin-web | Admin-консоль: UI — Flutter Web (`3-app\3-web\admin-web`), бэкенд — admin REST API (`/api/admin/*`) в `Server.WatchBody.Web`: атлеты, тренировки, программы, метрики, CSV | 3 | ⬜ | +| m08-admin-web | Admin-консоль: UI — Flutter Web (`3-app\3-web\admin-web`), бэкенд — admin REST API (`/api/admin/*`) в `Server.WatchBody.Web`: логин/PBKDF2→Bearer, атлеты, тренировки+CSV, программы, метрики, план/факт | 3 | ✅ (2026-10-19: Bearer-контур + 6 контроллеров + Flutter UI (SDK 3.47.7, build → wwwroot/admin), тесты Web 105/105 + Core 56/56, HTTP-смок пройден) | | m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ⬜ | | m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ⬜ | | m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ⬜ | diff --git a/1-docs/4-modules/m08-admin-web/SKILL.md b/1-docs/4-modules/m08-admin-web/SKILL.md new file mode 100644 index 0000000..2c45cf7 --- /dev/null +++ b/1-docs/4-modules/m08-admin-web/SKILL.md @@ -0,0 +1,22 @@ +--- +name: m08-admin-web +description: Admin REST /api/admin/* в Server.WatchBody.Web — консоль владельца (Ф-9): login/PBKDF2→Bearer, атлеты, тренировки+CSV, программы, метрики, план/факт +--- + +# m08-admin-web + +Бэкенд административной консоли владельца в `Server.WatchBody.Web`: +`POST /api/admin/login` (PBKDF2-проверка → in-memory Bearer-сессия, +TTL 8 ч; атлеты к `/api/admin/*` доступа не имеют — 🛡 отдельный +контур от `/api/v1/*`); атлеты (создание с серверной генерацией +API-ключа — возвращается один раз, смена ключа, блокировка); +тренировки всех атлетов (фильтры, пагинация, карточка с план/факт +и facts-таймлайном, CSV `;`+BOM по tech-req 2.4); программы (версии, +активация, применение rules-предложений через author=admin-копию — +🛡 неизменяемость); метрики за период; сводка план/факт по неделям. +UI — Flutter Web `3-app\3-web\admin-web` (отдельный этап, требует +Flutter SDK); сервер отдаёт статику сборки из `wwwroot\admin\`. + +- Требования: [module_reqs.md](module_reqs.md) +- План работ: [readme.md](readme.md) +- Спецификации: [api_specs/](api_specs/) diff --git a/1-docs/4-modules/m08-admin-web/api_specs/01-auth.md b/1-docs/4-modules/m08-admin-web/api_specs/01-auth.md new file mode 100644 index 0000000..165f765 --- /dev/null +++ b/1-docs/4-modules/m08-admin-web/api_specs/01-auth.md @@ -0,0 +1,41 @@ +# Спецификация 01: аутентификация владельца (AdminAuthService) + +Источники: Ф-9 (🛡 только владелец), tech-req 4.2 (PBKDF2 100k, +префикс `pbkdf2$`), module_reqs разд. 2. + +## 1. Формат хэша + +`Admin.PasswordHash` = `pbkdf2$100000$$`: +PBKDF2-HMAC-SHA256, 100 000 итераций, соль 16 байт, вывод 32 байта, +salt/hash — base64. Генерация (владельцу; python): + +```bash +python -c "import os,hashlib,base64;s=os.urandom(16);print('pbkdf2$100000$'+base64.b64encode(s).decode()+'$'+base64.b64encode(hashlib.pbkdf2_hmac('sha256',b'ПАРОЛЬ',s,100000)).decode())" +``` + +`AdminAuthService.HashPassword(password, salt?)` — тот же формат +(используется тестами). + +## 2. Сессии + +- `Login(login, password)`: сверка с `Admin.Login` (константное + сравнение) и `Admin.PasswordHash` → валидно → токен + `Base64Url(32 random bytes)`, словарь `token → expiresAt = UtcNow+8ч`; + невалидно → `null` (эндпоинт отдаёт 401, без деталей); +- `Validate(token)` → bool (просроченные удаляются лениво); +- `Logout(token)` — удаление; +- перезапуск сервера — словарь пуст (вход заново); +- `Admin.Login` пуст в конфиге → консоль отключена: login всегда 401 + (serve-валидацию не ломает — ключи опциональны); +- 🛡2: токен/пароль/хэш не логируются и не возвращаются. + +## 3. Эндпоинт и фильтр + +`POST /api/admin/login` body `{"login":"...","password":"..."}` → +`200 {"token":"..."}` | `401 {"error":"unauthorized"}`. Rate: без +ограничений в v1 (консоль за reverse-proxy, вопрос развёртывания). + +`AdminAuthFilter` (MVC-фильтр на контроллерах admin): заголовок +`Authorization: Bearer `; нет/невалиден/просрочен → `401` +`{"error":"unauthorized"}`. Контур `/api/admin/*` не проходит +`ApiKeyMiddleware` (🛡1 — атлетские `X-Api-Key` там не действуют). diff --git a/1-docs/4-modules/m08-admin-web/api_specs/02-athletes.md b/1-docs/4-modules/m08-admin-web/api_specs/02-athletes.md new file mode 100644 index 0000000..6b002c9 --- /dev/null +++ b/1-docs/4-modules/m08-admin-web/api_specs/02-athletes.md @@ -0,0 +1,30 @@ +# Спецификация 02: атлеты (AdminAthletesController) + +Источники: Ф-9.1 (управление, вопрос 9 закрыт), 🛡2 (ключ — один раз). + +## 1. Эндпоинты (все — Bearer, spec 01) + +| Метод | Путь | Тело → Ответ | +|---|---|---| +| GET | `/api/admin/athletes` | → `{"items":[{"id":1,"name":"Иван","isBlocked":false,"createdAt":"…"}]}` | +| POST | `/api/admin/athletes` | `{"name":"Иван"}` → `201 {"id":…,"apiKey":"wb-…"}` | +| POST | `/api/admin/athletes/{id}/key` | — → `200 {"apiKey":"wb-…"}` | +| POST | `/api/admin/athletes/{id}/block` | `{"isBlocked":true}` → `200 {"id":…,"isBlocked":true}` | + +## 2. Семантика + +- **Создание**: имя непустое (иначе 400); ключ = `"wb-" + 24 hex` + (random); в БД — только `ApiKeyHash.Of(key)` (SHA-256, как m02); + ключ возвращается **один раз** (повторно не выдаётся — 🛡2); + дубликат имени допустим (БД не запрещает; консоль показывает id). +- **Смена ключа**: новый ключ по тому же правилу; `athlete_update` + (все поля, NULL-семантики нет — m01); старый ключ перестаёт + работать немедленно. +- **Блокировка**: `athlete_update` c `is_blocked`; заблокированный + ключ → 401 на `/api/v1/*` (m02, проверяется тестом сквозняком); + блокировка не удаляет данные. +- Все записи — только `athlete_insert`/`athlete_update` (🛡3). + +## 3. Ошибки + +- 400 — пустое имя; 404 — атлет не найден (id); 401 — без токена. diff --git a/1-docs/4-modules/m08-admin-web/api_specs/03-workouts.md b/1-docs/4-modules/m08-admin-web/api_specs/03-workouts.md new file mode 100644 index 0000000..1106d84 --- /dev/null +++ b/1-docs/4-modules/m08-admin-web/api_specs/03-workouts.md @@ -0,0 +1,46 @@ +# Спецификация 03: тренировки и CSV (AdminWorkoutsController) + +Источники: Ф-9.2, tech-req 2.4 (CSV), m04 (workout_metrics), m06 +(facts_json workout-facts/1). + +## 1. Список + +`GET /api/admin/workouts?athleteId&dateFrom&dateTo&limit&offset` +(Bearer) → `{"total":N,"items":[карточки]}`; карточка: +`workoutId, athleteId, athleteName, startedAt, endedAt, durationMin, +status, sessionKind, avgHr, setsCount, exercisesCount, adherence` +(adherence — из workout_metrics; нет строки → null). Все атлеты; +`athleteId` опционален; limit ≤ 100 (default 50), offset ≥ 0. + +## 2. Карточка + +`GET /api/admin/workouts/{id}` → карточка списка +: + +- `sets[]`: seq, exerciseName, weightKg, reps, inPlan, rpe, kneePain + (из workout_select-агрегата m01; имя — по exercise_id из библиотеки + атлета); +- `planFact`: по каждому упражнению — план (активная версия: вес × + повторы по сетам дня недели) против факта (подходы); план дня не + сопоставился → только факт (как в m04 adherence fallback); +- `timeline[]`: из `facts_json.laps[]` + `records[]` — по каждому + подходу: `startOffsetS, durationS, avgHr, peakHr, restAfterS` + (отдых = старт следующего lap − конец этого); `hrCurve[]`: + `[{t,hr}]` из records (прореживание: не более 600 точек, шаг + `duration/600`). facts_json нет → `timeline/hrCurve` пустые. + +## 3. CSV (КУ-4) + +`GET /api/admin/workouts.csv?athleteId&dateFrom&dateTo` — та же +выборка (без пагинации, лимит 10 000): + +- разделитель `;`, UTF-8 **c BOM**, заголовок: + `athlete;date;duration_min;avg_hr;sets;exercises;adherence_pct`; +- строка — тренировка: имя атлета; дата YYYY-MM-DD; длительность + (мин, из ended−started; нет — пустая ячейка); avg_hr; число + подходов; число упражнений; adherence (нет — пустая); +- `Content-Type: text/csv; charset=utf-8`, + `Content-Disposition: attachment; filename="workouts.csv"`. + +## 4. Ошибки + +401 без токена; 404 чужой/несуществующий id. 🛡3: только чтение. diff --git a/1-docs/4-modules/m08-admin-web/api_specs/04-programs.md b/1-docs/4-modules/m08-admin-web/api_specs/04-programs.md new file mode 100644 index 0000000..6024bba --- /dev/null +++ b/1-docs/4-modules/m08-admin-web/api_specs/04-programs.md @@ -0,0 +1,30 @@ +# Спецификация 04: программы (AdminProgramsController) + +Источники: Ф-9.3, Ф-10.3, m03 (ProgramStore), 🛡4 module_reqs. + +## 1. Эндпоинты (Bearer) + +| Метод | Путь | Ответ | +|---|---|---| +| GET | `/api/admin/programs?athleteId` | `{"items":[{versionId, version, status, author, basedOnVersion, rationale, createdAt}]}` (без body) | +| GET | `/api/admin/programs/{versionId}?athleteId` | версия с `body` (json как есть) | +| POST | `/api/admin/programs/{versionId}/activate?athleteId` | `200 {"version":N,"status":"active"}` | +| POST | `/api/admin/programs/{versionId}/apply?athleteId` | `200 {"versionId":"…","version":N,"status":"active"}` | + +## 2. Семантика + +- **Активация**: только `draft` (иначе 400); `ProgramStore.Activate` + — прежняя active → archived (гарантия БД). Повторная активация + archived — 400 (m03). +- **Применение предложения правил** (Ф-9.3): только rules-draft; + `ProgramStore.CreateDraft(athleteId, body=rules-draft.body, + author=admin, rationale=оригинал + " (применено из консоли)")` → + активация новой версии. 🛡4: оригинальный rules-draft не меняется + (остаётся draft — владелец может его архивировать активацией + другой версии; дедуп-замок m05 снимается тем, что новая active + версия уже существует). Не-rules версия на apply → 400. +- 🛡3: других записей нет (редактирование body — вне v1). + +## 3. Ошибки + +401; 404 — версия/атлет не найдены; 400 — статус не позволяет. diff --git a/1-docs/4-modules/m08-admin-web/api_specs/05-metrics-plan.md b/1-docs/4-modules/m08-admin-web/api_specs/05-metrics-plan.md new file mode 100644 index 0000000..8951151 --- /dev/null +++ b/1-docs/4-modules/m08-admin-web/api_specs/05-metrics-plan.md @@ -0,0 +1,40 @@ +# Спецификация 05: метрики и план/факт + +Источники: Ф-9.4/Ф-9.5, m04 (workout_metrics), дизайн LLM-планирования +разд. 2 (body). Только чтение (🛡3). + +## 1. Метрики + +`GET /api/admin/metrics?athleteId&dateFrom&dateTo` (Bearer) → + +```json +{"workouts":[{"workoutId":"…","date":"2026-10-14","adherence":100.0, + "tonnage":640.0,"tonnageByGroup":{"push":640.0}, + "e1pmByExercise":{"":120.0},"redFlags":[]}], + "weekly":[{"week":"2026-10-12","workouts":2,"avgAdherence":85.0, + "tonnage":2400.0}]} +``` + +- по тренировкам — строки `workout_metrics` периода (поля как в БД; + JSON-поля — как есть); +- `weekly` — группировка по ISO-неделе даты тренировки: число + тренировок, среднее adherence, сумма тоннажа. + +## 2. План/факт (PlanSummaryService) + +`GET /api/admin/plan?athleteId` → сводка по активной версии: + +```json +{"activeVersion":3,"weeks":[{"week":1,"from":"2026-10-05","to":"2026-10-11", + "planned":2,"performed":1,"missed":1,"avgAdherence":100.0}]} +``` + +- неделя N активной версии: `from` = понедельник недели создания + версии + 7×(N−1) дней; `to` = from+6; +- `planned` — число сессий в `weeks[N-1].sessions` (body; в v1 все + недели одинаковые по составу — считаем по своей неделе массива); +- `performed` — тренировки атлета за [from..to] **в плановые дни + недели** (day-of-week сессии; день без сессии в плане не считается); +- `missed` = planned − performed (не < 0); +- `avgAdherence` — среднее adherence этих тренировок (нет — null); +- активной версии нет → `{"activeVersion":null,"weeks":[]}`. diff --git a/1-docs/4-modules/m08-admin-web/api_specs/06-flutter-ui.md b/1-docs/4-modules/m08-admin-web/api_specs/06-flutter-ui.md new file mode 100644 index 0000000..acf6584 --- /dev/null +++ b/1-docs/4-modules/m08-admin-web/api_specs/06-flutter-ui.md @@ -0,0 +1,57 @@ +# Спецификация 06: Flutter Web UI (`3-app\3-web\admin-web`) + +Источники: решение владельца 2026-10-17 (Flutter Web), UI/UX-стандарт +(tech-req разд. 6: тёмная тема, ≥16pt, цвет дублируется текстом). + +## 1. Каркас + +- `flutter create --platforms web` (SDK 3.47.7, `C:\tools\flutter`); + Material 3, тёмная тема; **без внешних pub-пакетов** (только SDK: + `dart:io`/`http` из `dart:io`? web → `package:http` входит в SDK + как `dart:io` нет — используется `HttpRequest`/`fetch` через + `package:http` из SDK-зависимостей flutter (входит в + flutter create) — внешних добавок нет); +- состояние — `ChangeNotifier` (`AdminState`: токен, текущий экран); + токен — `sessionStorage['adminToken']`; +- `ApiClient(baseUrl, token)`: GET/POST JSON; 401 → очистка токена, + экран логина; все запросы `/api/admin/*` относительно origin. + +## 2. Экраны (навигация — NavigationRail слева) + +1. **Логин**: логин/пароль → `POST login` → токен в sessionStorage. +2. **Атлеты**: таблица (id, имя, блок, создан); «+ Атлет» — диалог + (имя) → показать ключ в диалоге с кнопкой «Скопировать» и + предупреждением «показывается один раз»; смена ключа — то же; + блокировка — Switch. +3. **Тренировки**: фильтры (атлет-дропдаун, дата с/по), таблица + (дата, атлет, мин, ср. пульс, подходы, adherence), пагинация + (limit 50); строка → карточка: план/факт по упражнениям, + таймлайн подходов (старт/длит./пульс/отдых), пульсограмма + (`CustomPaint`, линия по hrCurve); кнопка «CSV» — ссылка + `workouts.csv` с текущими фильтрами (+ токен через `?token=` + не поддерживается — CSV открывается через fetch c Bearer и + Blob-download). +4. **Программы**: выбор атлета → версии (номер, статус, автор, + rationale, дата); просмотр body (formatted JSON, сворачиваемое); + активация draft (подтверждение); rules-draft — кнопка + «Применить предложение» (подтверждение с rationale). +5. **Метрики**: атлет + период; таблица тренировок; недельный + график — столбики тоннажа + линия adherence (CustomPaint, + значения подписаны текстом). +6. **План/факт**: атлет → сводка недель (запланировано/выполнено/ + пропущено, adherence), отклонение цветом + текстом. + +## 3. Сборка и хостинг + +- `flutter build web --release` → `build/web`; +- копия в `Server.WatchBody.Web\wwwroot\admin\` (скрипт + `build.ps1` в проекте UI); Kestrel отдаёт статику: `/admin` → + `index.html` (fallback-маршрут), ассеты — как файлы; +- базовый URL API = origin страницы (консоль на том же сервере). + +## 4. Проверки + +- `flutter analyze` — чисто; `flutter build web` — без ошибок; +- ручной смок по чек-листу: логин/401-редирект, CRUD атлетов, + список/карточка/CSV, активация/применение, метрики, план/факт + (данные — тестовые атлеты docker-БД). diff --git a/1-docs/4-modules/m08-admin-web/module_reqs.md b/1-docs/4-modules/m08-admin-web/module_reqs.md new file mode 100644 index 0000000..9ea2913 --- /dev/null +++ b/1-docs/4-modules/m08-admin-web/module_reqs.md @@ -0,0 +1,85 @@ +# Требования к модулю: m08-admin-web + +> Модуль — административная консоль владельца (Ф-9, решение владельца +> 2026-10-17): бэкенд — admin REST API `/api/admin/*` в +> `Server.WatchBody.Web`; UI — Flutter Web-приложение +> `3-app\3-web\admin-web` (SDK 3.47.7 — `C:\tools\flutter`), сборка +> отдаётся сервером из `wwwroot\admin\`. Аутентификация владельца — +> логин/пароль (`Admin.Login` / `Admin.PasswordHash`, PBKDF2) → +> Bearer-токен сессии; атлеты к консоли доступа не имеют (🛡 +> изменение vision 1.2). Самодостаточная копия требований (d-dev-6). + +## 1. Назначение и границы + +**В границах модуля (backend REST):** + +- аутентификация: `POST /api/admin/login` (логин+пароль → + `pbkdf2$...`-проверка → Bearer-токен, in-memory сессия, TTL 8 ч); + все `/api/admin/*` кроме login — только с валидным токеном (401); +- атлеты (Ф-9.1): список; создание (имя → сервер генерирует API-ключ, + возвращается один раз, в БД — только хэш); смена ключа; блокировка/ + разблокировка (заблокированный ключ → 401 на API телефонов); +- тренировки (Ф-9.2): таблица по всем атлетам (фильтр атлет/дата, + пагинация); карточка (подходы, план/факт, `facts_json`: таймлайн + Lap + пульс Record, средний/пиковый пульс подхода, отдых); + CSV-выгрузка текущей выборки (КУ-4, формат tech-req 2.4: `;`, + UTF-8 BOM, `athlete;date;duration_min;avg_hr;sets;exercises; + adherence_pct`); +- программы (Ф-9.3): версии атлета (статусы/автор/даты/body); + активация draft (ProgramStore.Activate); применение предложения + правил из консоли — новая версия `author=admin` с телом rules-draft, + затем активация (🛡 неизменяемость версений, Ф-9.3); +- метрики (Ф-9.4): выборка `workout_metrics` атлета за период; +- планы тренировок (Ф-9.5): сводка план/факт по неделям активной + версии (запланировано сессий / выполнено / пропущено; доля); +- отдача статики Flutter-сборки `wwwroot\admin\` (если каталог есть) — + UI хостится тем же Kestrel. + +**Вне границ модуля:** Flutter Web UI `3-app\3-web\admin-web` +(отдельный этап — требует Flutter SDK, решение гейта 1.3), редактор +body версий (v1: просмотр + активация; правка — телефон m09/импорт +m06), LLM-контур (v2), выгрузка АД-журнала CSV (v3). + +## 2. Сессии и токены + +- пароль проверяется по `Admin.PasswordHash` (PBKDF2 100k, соль в + хэше, префикс `pbkdf2$`; формат — `-salt` || PBKDF2-SHA256); + несовпадение → 401 без указания, что именно неверно; +- токен: 256-bit random (base64url), in-memory словарь + `token → (expiresAt)`; перезапуск сервера — сессии сбрасываются + (владелец входит заново); TTL 8 ч; logout — удаление из словаря; +- 🛡 токены/пароли в логи не пишутся; `Admin.PasswordHash` не + возвращается никаким эндпоинтом. + +## 3. Инварианты (🛡 — нарушять запрещено) + +1. **Только владелец** — `/api/admin/*` недоступен атлетам: их + `X-Api-Key` не принимается, изолированный контур (`/api/v1/*` + не меняется). +2. **Тайны** — API-ключ атлета возвращается только при создании/смене; + хэши, пароли, токены — не возвращаются и не логируются. +3. **Запись только управляющая** — admin API не пишет факт-данные + (тренировки/подходы/метрики): только атлеты (create/block/key), + статусы версий программ и author=admin-копии предложений. +4. **Неизменяемость версий** — активация через ProgramStore; + author=admin-копия создаётся CreateDraft, оригинал rules-draft + не меняется. +5. **Формат CSV** — точно tech-req 2.4 (разделитель `;`, UTF-8 BOM). + +## 4. Критерии приёмки модуля + +1. Логин: верный пароль → токен (последующие запросы с Bearer — 200); + неверный → 401; без токена/с токеном атлета (X-Api-Key) → 401. +2. Атлеты: создание возвращает ключ (в БД только хэш, повторный + запрос ключ не отдаёт); смена ключа; блокировка → sync телефона + с тем ключом получает 401; разблокировка → снова 200. +3. Тренировки: список всех атлетов с фильтрами/пагинацией; карточка + с подходами и план/факт; CSV выборки соответствует формату. +4. Программы: версии атлета; активация draft меняет active (прежняя — + archived); применение rules-предложения → активна версия + author=admin с телом предложения. +5. Метрики и план/факт: выборка метрик за период; сводка недель + (запланировано/выполнено/пропущено) сходится с фактическими + тренировками. +6. Тесты интеграционные против docker `watchbody-pg` — зелёные; + сборка решения без ошибок. diff --git a/1-docs/4-modules/m08-admin-web/readme.md b/1-docs/4-modules/m08-admin-web/readme.md new file mode 100644 index 0000000..2051926 --- /dev/null +++ b/1-docs/4-modules/m08-admin-web/readme.md @@ -0,0 +1,46 @@ +# m08-admin-web — план работ (d-dev) + +> Модуль: admin REST API `/api/admin/*` в `Server.WatchBody.Web` +> (бэкенд консоли владельца, Ф-9; UI — Flutter Web, отдельный этап). +> Алгоритм: `std1\_rules\d\d-dev.md`. Требования: +> [module_reqs.md](module_reqs.md). +> Зависимости: m01 (репозитории, DONE), m02 (Web/маршруты/middleware, +> DONE), m03 (ProgramStore, DONE), m04 (workout_metrics, DONE). + +| Шаг | Что делаем | Статус | +|---|---|---| +| 0 | Модульные документы: module_reqs.md / readme.md / SKILL.md | ✅ 2026-10-18 | +| 1 | Список единиц (классы/методы/конфиг/тесты) | ✅ 2026-10-18 | +| 1.3 | **Пользователь!** утверждение списка | ⬜ | +| 2 | Спецификации: api_specs (auth, athletes, workouts+CSV, programs, metrics, plan, UI) | ✅ 2026-10-19 | +| 2.4 | **Пользователь!** утверждение спецификаций | ⬜ | +| 3 | Реализация + тесты | ✅ 2026-10-19 (Web 105/105, из них m08 — 7; Core 56/56; flutter analyze 0 ошибок; build web → wwwrootdmin; HTTP-смок хостинга/логина пройден) | +| 3.4 | **Пользователь!** утверждение реализации (закрытие модуля) | ✅ 2026-10-19 | +| 4 | Завершение: план, примечания | ✅ 2026-10-19 — **Модуль завершён** (2026-10-19). Итерация 3 завершена. Следующий по плану этапа 7 — m09-phone-core (итерация 4, Android). | + +Статусы: ⬜ не начат · 🔄 в работе · ✅ готов. + +## Примечания по ходу работ + +1. **Kit.Helpers.Routes**: `NeedHttpPost_All` нельзя чейнить на несколько + роутов одного блока `AddController` (RouteFluent — дубль ключа + `httpMethod`) — каждый admin-эндпоинт в отдельном блоке. +2. **ApiKeyMiddleware сужен до `/api/v1/*`** (errata m02): раньше + перехватывал вообще все пути — статика консоли `/admin` и прочее + получали 401. `/api/admin` — Bearer-контур, `/health` — открыт; + поведение `/api/v1/*` не изменилось (тесты зелёные). +3. **workout_select без «всех атлетов»** — admin-список собирается + обходом атлетов (v1: атлетов немного; кандидат — функция БД в v2). +4. **Request.Body в Kestrel не seekable** — чтение тела один раз, + без `Position = 0` (иначе 500). +5. **workout_insert не пишет `ended_at`** (errata m02/m06): тесты + дозакрывают через Update. +6. **Flutter**: SDK 3.47.7 (`C: ools lutter`, PATH в каждом вызове); + без внешних pub-пакетов (только SDK) — `dart:html` для HTTP/Blob + (deprecation-info осознанно, web-only v1); сборка 41 МБ (canvaskit) + — `wwwrootdmin` в .gitignore (артефакт, собирается + `build.ps1`); analyze — 0 ошибок/варнингов, 4 info (dart:html ×2, + use_build_context ×2 — ложные срабатывания при guard `mounted`). +7. **Смоки**: HTTP (curl) — статика 200, /health 200, /api/v1 без + ключа 401, login 401/200, athletes/plan с Bearer — данные; + браузерный прогон UI — за владельцем (чек-лист spec 06 §4). diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/AdminApiTests.cs b/3-app/2-cli/Server.WatchBody.Web.Tests/AdminApiTests.cs new file mode 100644 index 0000000..6a31a63 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/AdminApiTests.cs @@ -0,0 +1,321 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Core.Programs; +using Xunit; + +namespace Server.WatchBody.Web.Tests; + +/// +/// Интеграционные тесты admin API (specs 01–05 m08): Bearer-контур, +/// атлеты (ключ один раз, блокировка → 401 sync), тренировки+CSV, +/// программы (активация/применение rules), метрики, план/факт. +/// +[Collection("web")] +public class AdminApiTests(TestApp app) +{ + private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true }; + + private ConnectionString NewCs() => new() + { + Type = ConnectionStringType.Postgres, + Value = app.Config.DbConnectionString, + }; + + private async Task Client() + { + var http = new HttpClient { BaseAddress = app.Client.BaseAddress }; + var login = await http.PostAsJsonAsync("/api/admin/login", + new { login = "test-admin", password = "admin-test-pass" }); + Assert.True(login.IsSuccessStatusCode, "login должен пройти"); + var token = (await login.Content.ReadFromJsonAsync()) + .GetProperty("token").GetString(); + http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}"); + return http; + } + + private async Task LoginToken(string login, string password) + { + var http = app.Client; + var response = await http.PostAsync("/api/admin/login", + new StringContent($"{{\"login\":\"{login}\",\"password\":\"{password}\"}}", + Encoding.UTF8, "application/json")); + return response.IsSuccessStatusCode ? "ok" : $"{(int)response.StatusCode}"; + } + + // --- spec 01: auth --- + + [Fact] + public async Task AdminLogin_WrongPassword_401() + { + Assert.Equal("401", await LoginToken("test-admin", "неверно")); + Assert.Equal("401", await LoginToken("чужой", "admin-test-pass")); + } + + [Fact] + public async Task AdminEndpoints_WithoutToken_401() + { + foreach (var path in new[] + { "/api/admin/athletes", "/api/admin/workouts", "/api/admin/metrics?athleteId=1" }) + { + var response = await app.Client.GetAsync(path); + Assert.Equal(401, (int)response.StatusCode); + } + } + + [Fact] + public async Task AdminEndpoints_AthleteApiKeyRejected_401() + { + // 🛡1: атлетский ключ не действует в admin-контуре + var request = new HttpRequestMessage(HttpMethod.Get, "/api/admin/athletes"); + request.Headers.Add("X-Api-Key", TestApp.IvanKey); + var response = await app.Client.SendAsync(request); + Assert.Equal(401, (int)response.StatusCode); + } + + // --- spec 02: атлеты --- + + [Fact] + public async Task Athletes_CreateKeyOnce_BlockUnblock() + { + var http = await Client(); + var name = "adm-" + Guid.NewGuid().ToString("N")[..8]; + + var create = await http.PostAsJsonAsync("/api/admin/athletes", new { name }); + Assert.Equal(201, (int)create.StatusCode); + var created = await create.Content.ReadFromJsonAsync(); + var athleteId = created.GetProperty("id").GetInt32(); + var apiKey = created.GetProperty("apiKey").GetString(); + Assert.StartsWith("wb-", apiKey); + + // ключ работает на /api/v1 и не возвращается повторно + var sync = await app.Client.PostAsync("/api/v1/sync", + new StringContent("[]", Encoding.UTF8, "application/json")); + var syncRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") + { + Content = new StringContent("[]", Encoding.UTF8, "application/json"), + }; + syncRequest.Headers.Add("X-Api-Key", apiKey); + var withKey = await app.Client.SendAsync(syncRequest); + Assert.True(withKey.IsSuccessStatusCode); + + // блокировка → 401 на sync + var block = await http.PostAsJsonAsync($"/api/admin/athletes/{athleteId}/block", + new { isBlocked = true }); + Assert.True(block.IsSuccessStatusCode); + var blockedRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") + { + Content = new StringContent("[]", Encoding.UTF8, "application/json"), + }; + blockedRequest.Headers.Add("X-Api-Key", apiKey); + Assert.Equal(401, (int)(await app.Client.SendAsync(blockedRequest)).StatusCode); + + // смена ключа: старый не работает, новый работает + var newKey = await http.PostAsync($"/api/admin/athletes/{athleteId}/key", content: null); + var reissued = await newKey.Content.ReadFromJsonAsync(); + Assert.NotEqual(apiKey, reissued.GetProperty("apiKey").GetString()); + var oldKeyRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") + { + Content = new StringContent("[]", Encoding.UTF8, "application/json"), + }; + oldKeyRequest.Headers.Add("X-Api-Key", apiKey); + Assert.Equal(401, (int)(await app.Client.SendAsync(oldKeyRequest)).StatusCode); + + // разблокировка + await http.PostAsJsonAsync($"/api/admin/athletes/{athleteId}/block", new { isBlocked = false }); + var unblockRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") + { + Content = new StringContent("[]", Encoding.UTF8, "application/json"), + }; + unblockRequest.Headers.Add("X-Api-Key", reissued.GetProperty("apiKey").GetString()); + Assert.True((await app.Client.SendAsync(unblockRequest)).IsSuccessStatusCode); + } + + // --- spec 03: тренировки + CSV --- + + [Fact] + public async Task Workouts_ListCardCsv() + { + var cs = NewCs(); + var athleteId = new AthleteRepository(cs).Insert(new Athlete + { + Name = "adm-w-" + Guid.NewGuid().ToString("N")[..6], + ApiKeyHash = Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), + }); + var workoutId = Guid.NewGuid(); + // workout_insert не пишет ended_at (errata m02) — дозакрытие Update + var workoutsRepo = new WorkoutRepository(cs); + workoutsRepo.Insert(athleteId, new Workout + { + Id = workoutId, AthleteId = athleteId, + StartedAt = new DateTime(2026, 10, 16, 18, 0, 0, DateTimeKind.Utc), + Status = "finished", SessionKind = "normal", + FactsJson = null, Sets = [], Commands = [], + }); + new WorkoutRepository(cs).Update(athleteId, new Workout + { + Id = workoutId, AthleteId = athleteId, + StartedAt = new DateTime(2026, 10, 16, 18, 0, 0, DateTimeKind.Utc), + EndedAt = new DateTime(2026, 10, 16, 19, 0, 0, DateTimeKind.Utc), + AvgHr = 110, + Status = "finished", SessionKind = "normal", + FactsJson = """{"schema":"workout-facts/1","laps":[{"index":0,"start":"2026-10-16T18:00:00Z","duration_s":300},{"index":1,"start":"2026-10-16T18:10:00Z","duration_s":300}],"records":[{"t_offset_s":10,"hr":100},{"t_offset_s":20,"hr":110},{"t_offset_s":610,"hr":120},{"t_offset_s":700,"hr":85}]}""", + Sets = [], Commands = [], + }); + var setRepo = new WorkoutSetRepository(cs); + var exerciseId = new ExerciseRepository(cs).Insert(new Exercise + { + Id = Guid.NewGuid(), AthleteId = athleteId, Name = "жим", + GroupId = 1, IsBase = true, IsActive = true, ReplacementIds = [], + }); + setRepo.Insert(new WorkoutSet + { + Id = Guid.NewGuid(), WorkoutId = workoutId, AthleteId = athleteId, + ExerciseId = exerciseId, Seq = 1, WeightKg = 80, Reps = 8, + DoneAt = new DateTime(2026, 10, 16, 18, 5, 0, DateTimeKind.Utc), + Source = "voice", InPlan = true, Rpe = 7, + }); + new WorkoutMetricsRepository(cs).Upsert(athleteId, new WorkoutMetrics + { + WorkoutId = workoutId, AthleteId = athleteId, Adherence = 100m, Tonnage = 640m, + TonnageByGroup = "{}", E1pmByExercise = "{}", HrMetrics = "{}", RedFlags = "[]", + }); + + var http = await Client(); + + var list = await http.GetFromJsonAsync( + $"/api/admin/workouts?athleteId={athleteId}"); + Assert.Equal(1, list.GetProperty("total").GetInt32()); + var item = list.GetProperty("items")[0]; + Assert.Equal(60, item.GetProperty("durationMin").GetInt32()); + Assert.Equal(100m, item.GetProperty("adherence").GetDecimal()); + + var card = await http.GetFromJsonAsync($"/api/admin/workouts/{workoutId}"); + Assert.Equal(1, card.GetProperty("sets").GetArrayLength()); + Assert.Equal(2, card.GetProperty("timeline").GetArrayLength()); + var timeline0 = card.GetProperty("timeline")[0]; + Assert.Equal(105, timeline0.GetProperty("avgHr").GetInt32()); // (100+110)/2 + Assert.Equal(110, timeline0.GetProperty("peakHr").GetInt32()); + Assert.Equal(300, timeline0.GetProperty("restAfterS").GetInt32()); // 18:10 − 18:05 + Assert.True(card.GetProperty("hrCurve").GetArrayLength() >= 4); + + var csv = await http.GetAsync($"/api/admin/workouts.csv?athleteId={athleteId}"); + var text = await csv.Content.ReadAsStringAsync(); + Assert.Equal("text/csv", csv.Content.Headers.ContentType!.MediaType); + Assert.StartsWith("\ufeffathlete;date;duration_min;avg_hr;", text); + Assert.Contains(";2026-10-16;60;", text, StringComparison.Ordinal); + Assert.Contains(";60;110;1;1;100.0", text, StringComparison.Ordinal); + } + + // --- spec 04: программы --- + + [Fact] + public async Task Programs_ActivateAndApplyRules() + { + var cs = NewCs(); + var athleteId = new AthleteRepository(cs).Insert(new Athlete + { + Name = "adm-p-" + Guid.NewGuid().ToString("N")[..6], + ApiKeyHash = Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), + }); + var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); + var user = store.CreateDraft(athleteId, """{"weeks":[{"week":1,"sessions":[]}]}""", + "user", "база"); + store.Activate(athleteId, user.Id); + var rules = store.CreateDraft(athleteId, + """{"weeks":[{"week":1,"sessions":[{"day":"monday","exercises":[]}]}]}""", + "rules", "прогрессия"); + + var http = await Client(); + + var list = await http.GetFromJsonAsync($"/api/admin/programs?athleteId={athleteId}"); + Assert.Equal(2, list.GetProperty("items").GetArrayLength()); + + // применение rules-предложения → author=admin, active; оригинал не тронут + var apply = await http.PostAsync($"/api/admin/programs/{rules.Id}/apply?athleteId={athleteId}", + content: null); + Assert.True(apply.IsSuccessStatusCode); + var applied = await apply.Content.ReadFromJsonAsync(); + var appliedVersionId = Guid.Parse(applied.GetProperty("versionId").GetString()); + Assert.Equal("active", applied.GetProperty("status").GetString()); + var appliedRow = store.GetVersion(athleteId, appliedVersionId)!; + Assert.Equal("admin", appliedRow.Author); + Assert.Equal("draft", store.GetVersion(athleteId, rules.Id)!.Status); // 🛡4 + Assert.Equal(user.Version + 2, appliedRow.Version); + + // применить не-rules — 400 + var userDraft = store.CreateDraft(athleteId, """{"weeks":[]}""", "user", "v3"); + var badApply = await http.PostAsync($"/api/admin/programs/{userDraft.Id}/apply?athleteId={athleteId}", + content: null); + Assert.Equal(400, (int)badApply.StatusCode); + + // активация активной — 400 + var reActivate = await http.PostAsync( + $"/api/admin/programs/{appliedVersionId}/activate?athleteId={athleteId}", content: null); + Assert.Equal(400, (int)reActivate.StatusCode); + } + + // --- spec 05: метрики + план/факт --- + + [Fact] + public async Task Metrics_And_PlanSummary() + { + var cs = NewCs(); + var athleteId = new AthleteRepository(cs).Insert(new Athlete + { + Name = "adm-m-" + Guid.NewGuid().ToString("N")[..6], + ApiKeyHash = Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), + }); + var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); + // якорь сводки — понедельник недели создания версии: даты от сегодня + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var monday = today.AddDays(-(((int)today.DayOfWeek + 6) % 7)); + var version = store.CreateDraft(athleteId, + """{"weeks":[{"week":1,"sessions":[{"day":"monday","exercises":[{"exercise_id":"жим","sets":[{"weight_kg":80,"reps":8}]}]}]}]}""", + "user", "тест"); + store.Activate(athleteId, version.Id); + + var workoutsRepo = new WorkoutRepository(cs); + var mondayWorkoutId = Guid.NewGuid(); + workoutsRepo.Insert(athleteId, new Workout + { + Id = mondayWorkoutId, AthleteId = athleteId, + StartedAt = monday.ToDateTimeAt(18), + Status = "finished", SessionKind = "normal", Sets = [], Commands = [], + }); + workoutsRepo.Insert(athleteId, new Workout + { + Id = Guid.NewGuid(), AthleteId = athleteId, + StartedAt = monday.AddDays(2).ToDateTimeAt(18), // ср — не плановый день + Status = "finished", SessionKind = "normal", Sets = [], Commands = [], + }); + new WorkoutMetricsRepository(cs).Upsert(athleteId, new WorkoutMetrics + { + WorkoutId = mondayWorkoutId, AthleteId = athleteId, Adherence = 90m, Tonnage = 1000m, + TonnageByGroup = "{}", E1pmByExercise = "{}", HrMetrics = "{}", RedFlags = "[]", + }); + + var http = await Client(); + + var from = monday.AddDays(-3).ToString("yyyy-MM-dd"); + var to = monday.AddDays(6).ToString("yyyy-MM-dd"); + var metrics = await http.GetFromJsonAsync( + $"/api/admin/metrics?athleteId={athleteId}&dateFrom={from}&dateTo={to}"); + Assert.Equal(1, metrics.GetProperty("workouts").GetArrayLength()); + Assert.Equal(1, metrics.GetProperty("weekly").GetArrayLength()); + var weekly = metrics.GetProperty("weekly")[0]; + Assert.Equal(1000m, weekly.GetProperty("tonnage").GetDecimal()); + Assert.Equal(90m, weekly.GetProperty("avgAdherence").GetDecimal()); + + var plan = await http.GetFromJsonAsync($"/api/admin/plan?athleteId={athleteId}"); + Assert.Equal(version.Version, plan.GetProperty("activeVersion").GetInt32()); + var week1 = plan.GetProperty("weeks")[0]; + Assert.Equal(1, week1.GetProperty("planned").GetInt32()); + Assert.Equal(1, week1.GetProperty("performed").GetInt32()); // только пн; ср — не плановый день + Assert.Equal(0, week1.GetProperty("missed").GetInt32()); + Assert.Equal(90m, week1.GetProperty("avgAdherence").GetDecimal()); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/appsettings.test.json b/3-app/2-cli/Server.WatchBody.Web.Tests/appsettings.test.json index bde4a63..3d15975 100644 --- a/3-app/2-cli/Server.WatchBody.Web.Tests/appsettings.test.json +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/appsettings.test.json @@ -7,6 +7,10 @@ { "name": "Test Анна", "apiKey": "test-key-anna-0003" } ] }, + "Admin": { + "Login": "test-admin", + "PasswordHash": "pbkdf2$100000$AAECAwQFBgcICQoLDA0ODw==$Gq5LDyI8L0fc+2TIN8q5dyn/GgAvxyHd8vn6BFz5rjw=" + }, "Db": { "ConnectionString": "Host=127.0.0.1;Port=5433;Database=watchbody;Username=postgres;Password=watchbody" }, diff --git a/3-app/2-cli/Server.WatchBody.Web/Config/RouteConfig.cs b/3-app/2-cli/Server.WatchBody.Web/Config/RouteConfig.cs index d108b1b..12dbb5f 100644 --- a/3-app/2-cli/Server.WatchBody.Web/Config/RouteConfig.cs +++ b/3-app/2-cli/Server.WatchBody.Web/Config/RouteConfig.cs @@ -28,5 +28,59 @@ public class RouteConfig routeBuilder.AddController() .ActionAction("api/v1/programs/active", x => x.Active) .NeedHttpGet(); + + // admin-контур (m08): Bearer, отдельный от X-Api-Key. + // по одному блоку на действие: NeedHttpPost_All нельзя чейнить + // на несколько роутов (RouteFluent — дубль ключа httpMethod) + routeBuilder.AddController() + .ActionAction("api/admin/login", x => x.Login) + .NeedHttpPost_All(); + routeBuilder.AddController() + .ActionAction("api/admin/logout", x => x.Logout) + .NeedHttpPost_All(); + + routeBuilder.AddController() + .ActionAction("api/admin/athletes", x => x.List) + .NeedHttpGet(); + routeBuilder.AddController() + .ActionAction("api/admin/athletes", x => x.Create) + .NeedHttpPost_All(); + routeBuilder.AddController() + .ActionAction("api/admin/athletes/{id}/key", x => x.NewKey) + .NeedHttpPost_All(); + routeBuilder.AddController() + .ActionAction("api/admin/athletes/{id}/block", x => x.Block) + .NeedHttpPost_All(); + + routeBuilder.AddController() + .ActionAction( + "api/admin/workouts", x => x.List) + .NeedHttpGet(); + routeBuilder.AddController() + .ActionAction("api/admin/workouts/{id}", x => x.Get) + .NeedHttpGet(); + routeBuilder.AddController() + .ActionAction("api/admin/workouts.csv", x => x.Csv) + .NeedHttpGet(); + + routeBuilder.AddController() + .ActionAction("api/admin/programs", x => x.List) + .NeedHttpGet(); + routeBuilder.AddController() + .ActionAction("api/admin/programs/{versionId}", x => x.Get) + .NeedHttpGet(); + routeBuilder.AddController() + .ActionAction("api/admin/programs/{versionId}/activate", x => x.Activate) + .NeedHttpPost_All(); + routeBuilder.AddController() + .ActionAction("api/admin/programs/{versionId}/apply", x => x.Apply) + .NeedHttpPost_All(); + + routeBuilder.AddController() + .ActionAction("api/admin/metrics", x => x.Metrics) + .NeedHttpGet(); + routeBuilder.AddController() + .ActionAction("api/admin/plan", x => x.Plan) + .NeedHttpGet(); } } diff --git a/3-app/2-cli/Server.WatchBody.Web/Config/ServerConfig.cs b/3-app/2-cli/Server.WatchBody.Web/Config/ServerConfig.cs index a89256d..6e936c7 100644 --- a/3-app/2-cli/Server.WatchBody.Web/Config/ServerConfig.cs +++ b/3-app/2-cli/Server.WatchBody.Web/Config/ServerConfig.cs @@ -34,6 +34,10 @@ public class ServerConfig public string ImportArchiveDir { get; set; } = Path.Combine(AppContext.BaseDirectory, "data", "import_done"); + /// Учётка владельца консоли (m08); Login пуст — консоль отключена. + public string AdminLogin { get; set; } = string.Empty; + public string AdminPasswordHash { get; set; } = string.Empty; + /// Учётка Garmin Connect владельца (--mode fetch, m07). public string GarminLogin { get; set; } = string.Empty; public string GarminPassword { get; set; } = string.Empty; @@ -77,6 +81,11 @@ public class ServerConfig } } } + if (root.TryGetProperty("Admin", out var admin) && admin.ValueKind == JsonValueKind.Object) + { + if (admin.TryGetProperty("Login", out var al)) result.AdminLogin = al.GetString() ?? ""; + if (admin.TryGetProperty("PasswordHash", out var aph)) result.AdminPasswordHash = aph.GetString() ?? ""; + } if (root.TryGetProperty("Db", out var db)) { if (db.TryGetProperty("ConnectionString", out var cs)) diff --git a/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminAthletesController.cs b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminAthletesController.cs new file mode 100644 index 0000000..0bf6e8e --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminAthletesController.cs @@ -0,0 +1,106 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Mvc; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Web.Services; + +namespace Server.WatchBody.Web.Controllers.Admin; + +/// Атлеты (spec 02 m08): список/создание/смена ключа/блок. +public class AdminAthletesController(AdminAuthService auth, IAthleteRepository athletes) + : AdminControllerBase(auth) +{ + public ActionResult List() + { + if (Denied()) return UnauthorizedJson(); + var items = athletes.Select(null) + .OrderBy(a => a.Id) + .Select(a => new + { + a.Id, + a.Name, + a.IsBlocked, + createdAt = a.CreatedAt, + }); + return new JsonResult(new { items }); + } + + public ActionResult Create() + { + if (Denied()) return UnauthorizedJson(); + var name = ReadString("name"); + if (string.IsNullOrWhiteSpace(name)) return ErrorJson(400, "name пуст"); + + var (key, hash) = NewKey(); + var id = athletes.Insert(new Athlete { Name = name, ApiKeyHash = hash }); + // ключ возвращается один раз (🛡2): дальше в БД только хэш + Response.StatusCode = 201; + return new JsonResult(new { id, apiKey = key }); + } + + public ActionResult NewKey(int id) + { + if (Denied()) return UnauthorizedJson(); + var athlete = athletes.Get(id); + if (athlete == null) return ErrorJson(404, "not_found"); + + var (key, hash) = NewKey(); + athletes.Update(new Athlete + { + Id = id, Name = athlete.Name, ApiKeyHash = hash, IsBlocked = athlete.IsBlocked, + }); + return new JsonResult(new { apiKey = key }); + } + + public ActionResult Block(int id) + { + if (Denied()) return UnauthorizedJson(); + var athlete = athletes.Get(id); + if (athlete == null) return ErrorJson(404, "not_found"); + var blocked = ReadBool("isBlocked") ?? !athlete.IsBlocked; + athletes.Update(new Athlete + { + Id = id, Name = athlete.Name, ApiKeyHash = athlete.ApiKeyHash, IsBlocked = blocked, + }); + return new JsonResult(new { id, isBlocked = blocked }); + } + + private static (string Key, string Hash) NewKey() + { + var key = "wb-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(12)).ToLowerInvariant(); + return (key, Services.ApiKeyHash.Of(key)); + } + + private string? ReadString(string property) + { + using var reader = new StreamReader(Request.Body); + var json = reader.ReadToEndAsync().GetAwaiter().GetResult(); + try + { + using var doc = System.Text.Json.JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty(property, out var value) ? value.GetString() : null; + } + catch (System.Text.Json.JsonException) + { + return null; + } + } + + private bool? ReadBool(string property) + { + using var reader = new StreamReader(Request.Body); + var json = reader.ReadToEndAsync().GetAwaiter().GetResult(); + try + { + using var doc = System.Text.Json.JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty(property, out var value) && + value.ValueKind is System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False + ? value.GetBoolean() + : null; + } + catch (System.Text.Json.JsonException) + { + return null; + } + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminAuthController.cs b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminAuthController.cs new file mode 100644 index 0000000..a30b57b --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminAuthController.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using Server.WatchBody.Web.Services; + +namespace Server.WatchBody.Web.Controllers.Admin; + +/// POST /api/admin/login, POST /api/admin/logout (spec 01). +public class AdminAuthController(AdminAuthService auth) : Controller +{ + public ActionResult Login() + { + var body = ReadJson(); + var login = body?.TryGetProperty("login", out var l) == true ? l.GetString() : null; + var password = body?.TryGetProperty("password", out var p) == true ? p.GetString() : null; + var token = login == null || password == null ? null : auth.Login(login, password); + if (token == null) + { + Response.StatusCode = 401; + return new JsonResult(new { error = "unauthorized" }); + } + return new JsonResult(new { token }); + } + + public ActionResult Logout() + { + var header = Request.Headers.Authorization.FirstOrDefault(); + if (header?.StartsWith("Bearer ") == true) + auth.Logout(header["Bearer ".Length..]); + return new JsonResult(new { ok = true }); + } + + private System.Text.Json.JsonElement? ReadJson() + { + using var reader = new StreamReader(Request.Body); + var json = reader.ReadToEndAsync().GetAwaiter().GetResult(); + try + { + return System.Text.Json.JsonDocument.Parse(json).RootElement.Clone(); + } + catch (System.Text.Json.JsonException) + { + return null; + } + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminControllerBase.cs b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminControllerBase.cs new file mode 100644 index 0000000..6249546 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminControllerBase.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; +using Server.WatchBody.Web.Services; + +namespace Server.WatchBody.Web.Controllers.Admin; + +/// +/// База admin-контроллеров (spec 01 §3): Bearer-токен обязателен, +/// без/невалидный → 401 {"error":"unauthorized"}; атлетские +/// X-Api-Key в этом контуре не действуют (🛡1 — middleware их сюда +/// не пускает). +/// +public abstract class AdminControllerBase(AdminAuthService auth) : Controller +{ + protected readonly AdminAuthService Auth = auth; + + /// 401, если токен не прошёл; иначе false. + protected bool Denied() + { + var header = Request.Headers.Authorization.FirstOrDefault(); + var token = header?.StartsWith("Bearer ", StringComparison.Ordinal) == true + ? header["Bearer ".Length..] + : null; + return !Auth.Validate(token); + } + + protected ActionResult UnauthorizedJson() => + ErrorJson(401, "unauthorized"); + + protected ActionResult ErrorJson(int code, string message) + { + Response.StatusCode = code; + return new JsonResult(new { error = message }); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminMetricsController.cs b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminMetricsController.cs new file mode 100644 index 0000000..0bc4c6f --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminMetricsController.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Mvc; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Web.Services; + +namespace Server.WatchBody.Web.Controllers.Admin; + +/// Метрики (Ф-9.4) и сводка план/факт (Ф-9.5) — только +/// чтение (spec 05 m08). +public class AdminMetricsController( + AdminAuthService auth, + IWorkoutRepository workouts, + IWorkoutMetricsRepository metrics, + PlanSummaryService plan) : AdminControllerBase(auth) +{ + public ActionResult Metrics(int athleteId, string? dateFrom, string? dateTo) + { + if (Denied()) return UnauthorizedJson(); + DateOnly? from = dateFrom != null && DateOnly.TryParse(dateFrom, out var f) ? f : null; + DateOnly? to = dateTo != null && DateOnly.TryParse(dateTo, out var t) ? t : null; + + var rows = metrics.Select(new WorkoutMetricsFilter + { + AthleteId = athleteId, DateFrom = from, DateTo = to, + }).ToList(); + var byDate = new Dictionary(); + foreach (var workout in workouts.Select(new WorkoutFilter + { AthleteId = athleteId, DateFrom = from, DateTo = to })) + byDate[workout.Id] = workout.StartedAt; + + var workoutRows = rows + .OrderByDescending(m => byDate.GetValueOrDefault(m.WorkoutId)) + .Select(m => new + { + m.WorkoutId, + date = byDate.TryGetValue(m.WorkoutId, out var started) + ? DateOnly.FromDateTime(started).ToString("yyyy-MM-dd") : null, + m.Adherence, + m.Tonnage, + tonnageByGroup = Json(m.TonnageByGroup), + e1pmByExercise = Json(m.E1pmByExercise), + redFlags = Json(m.RedFlags), + }).ToList(); + + // weekly: ISO-неделя даты тренировки + var weekly = workoutRows + .Where(r => r.date != null) + .GroupBy(r => MondayOf(DateOnly.Parse(r.date!))) + .Select(g => new + { + week = g.Key.ToString("yyyy-MM-dd"), + workouts = g.Count(), + avgAdherence = Math.Round(g.Average(r => r.Adherence), 1, MidpointRounding.AwayFromZero), + tonnage = Math.Round(g.Sum(r => r.Tonnage), 1, MidpointRounding.AwayFromZero), + }).OrderBy(w => w.week).ToList(); + + return new JsonResult(new { workouts = workoutRows, weekly }); + } + + public ActionResult Plan(int athleteId) + { + if (Denied()) return UnauthorizedJson(); + return new JsonResult(plan.Summarize(athleteId)); + } + + private static object? Json(string? source) + { + if (string.IsNullOrWhiteSpace(source)) return null; + try + { + return System.Text.Json.JsonSerializer.Deserialize(source); + } + catch (System.Text.Json.JsonException) + { + return source; + } + } + + private static DateOnly MondayOf(DateOnly date) => + date.AddDays(-(((int)date.DayOfWeek + 6) % 7)); +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminProgramsController.cs b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminProgramsController.cs new file mode 100644 index 0000000..d93d661 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminProgramsController.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Mvc; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Core.Programs; +using Server.WatchBody.Web.Services; + +namespace Server.WatchBody.Web.Controllers.Admin; + +/// +/// Программы (spec 04 m08): версии, активация draft, применение +/// rules-предложения через author=admin-копию (🛡4 — оригинал не +/// меняется, Ф-9.3). +/// +public class AdminProgramsController( + AdminAuthService auth, + IProgramRepository programs, + ProgramStore store) : AdminControllerBase(auth) +{ + public ActionResult List(int athleteId) + { + if (Denied()) return UnauthorizedJson(); + var versions = store.ListVersions(athleteId); + return new JsonResult(new + { + items = versions.Select(v => new + { + versionId = v.Id, + v.Version, + v.Status, + v.Author, + v.BasedOnVersion, + v.Rationale, + v.CreatedAt, + }), + }); + } + + public ActionResult Get(string versionId, int athleteId) + { + if (Denied()) return UnauthorizedJson(); + if (!Guid.TryParse(versionId, out var id)) return ErrorJson(400, "versionId не uuid"); + var version = store.GetVersion(athleteId, id); + if (version == null) return ErrorJson(404, "not_found"); + return new JsonResult(new + { + versionId = version.Id, + version.Version, + version.Status, + version.Author, + version.BasedOnVersion, + version.Body, + version.Rationale, + version.CreatedAt, + }); + } + + public ActionResult Activate(string versionId, int athleteId) + { + if (Denied()) return UnauthorizedJson(); + if (!Guid.TryParse(versionId, out var id)) return ErrorJson(400, "versionId не uuid"); + try + { + store.Activate(athleteId, id); + } + catch (ProgramStoreException e) + { + return ErrorJson(400, e.Message); + } + var version = store.GetVersion(athleteId, id)!; + return new JsonResult(new { versionId = id, version.Version, status = version.Status }); + } + + /// Применение предложения правил (Ф-9.3): author=admin-копия + /// тела rules-draft → активация; оригинал остаётся draft (🛡4). + public ActionResult Apply(string versionId, int athleteId) + { + if (Denied()) return UnauthorizedJson(); + if (!Guid.TryParse(versionId, out var id)) return ErrorJson(400, "versionId не uuid"); + var source = store.GetVersion(athleteId, id); + if (source == null) return ErrorJson(404, "not_found"); + if (source.Author != ProgramVersionAuthors.Rules || source.Status != ProgramVersionStatuses.Draft) + return ErrorJson(400, "применять можно только draft-предложение правил (author=rules)"); + + try + { + var applied = store.CreateDraft(athleteId, source.Body, ProgramVersionAuthors.Admin, + $"{source.Rationale} (применено из консоли)".TrimStart()); + store.Activate(athleteId, applied.Id); + return new JsonResult(new + { + versionId = applied.Id, + version = applied.Version, + status = store.GetVersion(athleteId, applied.Id)!.Status, + }); + } + catch (ProgramStoreException e) + { + return ErrorJson(400, e.Message); + } + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminWorkoutsController.cs b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminWorkoutsController.cs new file mode 100644 index 0000000..4f47dea --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminWorkoutsController.cs @@ -0,0 +1,225 @@ +using System.Text; +using Microsoft.AspNetCore.Mvc; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Web.Services; + +namespace Server.WatchBody.Web.Controllers.Admin; + +/// +/// Тренировки всех атлетов (spec 03 m08): список с фильтрами, +/// карточка (план/факт + facts-таймлайн), CSV выборки (КУ-4). +/// workout_select требует одного атлета — выборка «всех» собирается +/// обходом атлетов (v1: атлетов немного). 🛡3: только чтение. +/// +public class AdminWorkoutsController( + AdminAuthService auth, + IAthleteRepository athletesRepo, + IWorkoutRepository workouts, + IWorkoutMetricsRepository metrics, + IExerciseRepository exercises, + Core.Programs.ProgramStore programs) : AdminControllerBase(auth) +{ + private const int CsvLimit = 10_000; + private static readonly string[] DayNames = + ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; + + public ActionResult List(int? athleteId, string? dateFrom, string? dateTo, int? limit, int? offset) + { + if (Denied()) return UnauthorizedJson(); + var take = limit ?? 50; + var skip = offset ?? 0; + if (take is < 1 or > 100) return ErrorJson(400, "limit 1–100"); + if (skip < 0) return ErrorJson(400, "offset >= 0"); + + var all = SelectAll(athleteId, dateFrom, dateTo, out var names); + var page = all.OrderByDescending(w => w.StartedAt) + .Skip(skip).Take(take) + .Select(w => Card(w, names, withSets: false)).ToList(); + return new JsonResult(new { total = all.Count, items = page }); + } + + public ActionResult Get(string id) + { + if (Denied()) return UnauthorizedJson(); + if (!Guid.TryParse(id, out var workoutId)) return ErrorJson(400, "id не uuid"); + Workout? found = null; + Dictionary names = new(); + foreach (var athlete in athletesRepo.Select(null)) + { + var candidate = workouts.Get(athlete.Id, workoutId); + if (candidate == null) continue; + found = candidate; + names[athlete.Id] = athlete.Name; + break; + } + if (found == null) return ErrorJson(404, "not_found"); + + var card = Card(found, names, withSets: true); + var (timeline, curve) = FactsTimeline.Parse(found.FactsJson, 0); + card["timeline"] = timeline; + card["hrCurve"] = curve; + return new JsonResult(card); + } + + public ActionResult Csv(int? athleteId, string? dateFrom, string? dateTo) + { + if (Denied()) return UnauthorizedJson(); + var all = SelectAll(athleteId, dateFrom, dateTo, out var names); + + // tech-req 2.4: ';' + UTF-8 BOM (Excel), заголовок первой строкой + var csv = new StringBuilder(); + csv.AppendLine("athlete;date;duration_min;avg_hr;sets;exercises;adherence_pct"); + foreach (var workout in all.OrderByDescending(w => w.StartedAt).Take(CsvLimit)) + csv.AppendLine(CsvRow(workout, names)); + var bytes = Encoding.UTF8.GetPreamble() + .Concat(Encoding.UTF8.GetBytes(csv.ToString())).ToArray(); + return File(bytes, "text/csv; charset=utf-8", "workouts.csv"); + } + + // ---------- вспомогательные ---------- + + private List SelectAll(int? athleteId, string? dateFrom, string? dateTo, + out Dictionary names) + { + names = athletesRepo.Select(null).ToDictionary(a => a.Id, a => a.Name); + var from = ParseDate(dateFrom); + var to = ParseDate(dateTo); + var result = new List(); + foreach (var athlete in names.Keys) + { + if (athleteId != null && athlete != athleteId.Value) continue; + result.AddRange(workouts.Select(new WorkoutFilter + { + AthleteId = athlete, DateFrom = from, DateTo = to, + })); + } + return result; + } + + private static DateOnly? ParseDate(string? text) => + text != null && DateOnly.TryParse(text, out var date) ? date : null; + + private Dictionary Card(Workout w, Dictionary names, bool withSets) + { + var durationMin = w.EndedAt != null + ? (int)Math.Round((w.EndedAt.Value - w.StartedAt).TotalMinutes, MidpointRounding.AwayFromZero) + : (int?)null; + var metric = metrics.Select(new WorkoutMetricsFilter { AthleteId = w.AthleteId, WorkoutId = w.Id }) + .FirstOrDefault(); + var sets = w.Sets.ToList(); + var card = new Dictionary + { + ["workoutId"] = w.Id, + ["athleteId"] = w.AthleteId, + ["athleteName"] = names.GetValueOrDefault(w.AthleteId, $"#{w.AthleteId}"), + ["startedAt"] = w.StartedAt, + ["endedAt"] = w.EndedAt, + ["durationMin"] = durationMin, + ["status"] = w.Status, + ["sessionKind"] = w.SessionKind, + ["avgHr"] = w.AvgHr, + ["setsCount"] = sets.Count, + ["exercisesCount"] = sets.Where(s => s.ExerciseId != null) + .Select(s => s.ExerciseId!.Value).Distinct().Count(), + ["adherence"] = metric?.Adherence, + }; + if (!withSets) return card; + + var exNames = exercises.Select(new ExerciseFilter { AthleteId = w.AthleteId }) + .ToDictionary(e => e.Id, e => e.Name); + card["sets"] = sets.OrderBy(s => s.Seq).Select(s => new + { + s.Seq, + exerciseName = s.ExerciseId != null && exNames.TryGetValue(s.ExerciseId.Value, out var n) ? n : null, + s.WeightKg, + s.Reps, + s.InPlan, + s.Rpe, + s.KneePain, + }).ToList(); + card["planFact"] = PlanFact(w, exNames); + return card; + } + + /// План/факт по упражнениям (spec 03 §2): план — первая + /// сессия дня недели активной версии (v1 — без ротации недель). + private object PlanFact(Workout w, Dictionary exNames) + { + var active = programs.GetActive(w.AthleteId); + var date = DateOnly.FromDateTime(w.StartedAt); + var day = DayNames[((int)date.DayOfWeek + 6) % 7]; + var plan = new List(); + if (active == null || string.IsNullOrWhiteSpace(active.Body)) + return new { day, plan }; + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(active.Body); + foreach (var week in doc.RootElement.GetProperty("weeks").EnumerateArray()) + foreach (var session in week.GetProperty("sessions").EnumerateArray()) + { + if (!session.TryGetProperty("day", out var d) || + !string.Equals(d.GetString(), day, StringComparison.OrdinalIgnoreCase)) continue; + foreach (var exercise in session.GetProperty("exercises").EnumerateArray()) + plan.Add(PlanFactExercise(exercise, w, exNames)); + return new { day, plan }; // первая сессия дня + } + } + catch (Exception ex) when (ex is System.Text.Json.JsonException or KeyNotFoundException) + { + // кривой body — только факт + } + return new { day, plan }; + } + + private static object PlanFactExercise(System.Text.Json.JsonElement exercise, Workout w, + Dictionary exNames) + { + var name = exercise.TryGetProperty("exercise_id", out var e) ? e.GetString() : "?"; + var setsNode = exercise.TryGetProperty("sets", out var s) ? s : default; + var hasSets = setsNode.ValueKind == System.Text.Json.JsonValueKind.Array; + var first = hasSets && setsNode.GetArrayLength() > 0 ? setsNode[0] : default; + var weight = first.ValueKind == System.Text.Json.JsonValueKind.Object && + first.TryGetProperty("weight_kg", out var wNode) && + wNode.ValueKind == System.Text.Json.JsonValueKind.Number + ? wNode.GetDecimal() : (decimal?)null; + var reps = first.ValueKind == System.Text.Json.JsonValueKind.Object && + first.TryGetProperty("reps", out var rNode) && + rNode.ValueKind == System.Text.Json.JsonValueKind.Number + ? rNode.GetInt32() : (int?)null; + var factSets = w.Sets.Where(fs => + fs.ExerciseId != null && exNames.GetValueOrDefault(fs.ExerciseId.Value) == name) + .ToList(); + return new + { + exercise = name, + plannedSets = hasSets ? setsNode.GetArrayLength() : 0, + plannedWeight = weight, + plannedReps = reps, + factSets = factSets.Count, + factWeight = factSets.Count > 0 ? factSets.Max(fs => fs.WeightKg) : (decimal?)null, + factReps = factSets.Count > 0 ? factSets.Max(fs => fs.Reps) : (int?)null, + }; + } + + private string CsvRow(Workout w, Dictionary names) + { + var metric = metrics.Select(new WorkoutMetricsFilter { AthleteId = w.AthleteId, WorkoutId = w.Id }) + .FirstOrDefault(); + var sets = w.Sets.ToList(); + var invariant = System.Globalization.CultureInfo.InvariantCulture; + var durationMin = w.EndedAt != null + ? ((int)Math.Round((w.EndedAt.Value - w.StartedAt).TotalMinutes, MidpointRounding.AwayFromZero)) + .ToString(invariant) : ""; + return string.Join(';', + names.GetValueOrDefault(w.AthleteId, $"#{w.AthleteId}"), + DateOnly.FromDateTime(w.StartedAt).ToString("yyyy-MM-dd"), + durationMin, + w.AvgHr?.ToString(invariant) ?? "", + sets.Count.ToString(invariant), + sets.Where(s => s.ExerciseId != null).Select(s => s.ExerciseId!.Value).Distinct().Count() + .ToString(invariant), + metric != null ? metric.Adherence.ToString("0.0", invariant) : ""); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Middleware/ApiKeyMiddleware.cs b/3-app/2-cli/Server.WatchBody.Web/Middleware/ApiKeyMiddleware.cs index b418d5e..c868137 100644 --- a/3-app/2-cli/Server.WatchBody.Web/Middleware/ApiKeyMiddleware.cs +++ b/3-app/2-cli/Server.WatchBody.Web/Middleware/ApiKeyMiddleware.cs @@ -14,10 +14,10 @@ public class ApiKeyMiddleware(RequestDelegate next) public async Task InvokeAsync(HttpContext context, IAthleteRepository athletes) { - // /health без ключа — только GET (З-42 п. 8): прочие методы идут - // в аутентификацию и получают 401, а не деградацию 404/405 - if (context.Request.Path == "/health" && - HttpMethods.IsGet(context.Request.Method)) + // ключ атлета охраняет только телефонный контур /api/v1/*: + // /api/admin — Bearer-контур владельца (m08, 🛡1), статика + // консоли (/admin) и /health — без ключа + if (!context.Request.Path.StartsWithSegments("/api/v1")) { await next(context); return; diff --git a/3-app/2-cli/Server.WatchBody.Web/Services/AdminAuthService.cs b/3-app/2-cli/Server.WatchBody.Web/Services/AdminAuthService.cs new file mode 100644 index 0000000..65d1617 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Services/AdminAuthService.cs @@ -0,0 +1,91 @@ +using System.Security.Cryptography; + +namespace Server.WatchBody.Web.Services; + +/// +/// Аутентификация владельца консоли (spec 01 m08): PBKDF2-проверка +/// Admin.PasswordHash → in-memory Bearer-сессии (TTL 8 ч; рестарт +/// сервера сбрасывает). 🛡 Токены/пароли не логируются и не +/// возвращаются; Admin.Login пуст — консоль отключена. +/// +public class AdminAuthService(string login, string passwordHash) +{ + private static readonly TimeSpan Ttl = TimeSpan.FromHours(8); + private readonly Dictionary _sessions = new(); + + public bool Enabled => login.Length > 0 && passwordHash.StartsWith("pbkdf2$", StringComparison.Ordinal); + + /// Вход → токен (base64url) или null. + public string? Login(string candidateLogin, string candidatePassword) + { + if (!Enabled || + !FixedTimeEquals(candidateLogin, login) || + !VerifyPassword(candidatePassword, passwordHash)) + return null; + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + lock (_sessions) + { + _sessions[token] = DateTime.UtcNow.Add(Ttl); + } + return token; + } + + public bool Validate(string? token) + { + if (token == null || token.Length == 0) return false; + lock (_sessions) + { + if (!_sessions.TryGetValue(token, out var expiresAt)) return false; + if (expiresAt < DateTime.UtcNow) + { + _sessions.Remove(token); + return false; + } + return true; + } + } + + public void Logout(string token) + { + lock (_sessions) _sessions.Remove(token); + } + + internal static bool VerifyPassword(string password, string stored) + { + // pbkdf2$100000$$ + var parts = stored.Split('$'); + if (parts.Length != 4 || parts[0] != "pbkdf2" || + !int.TryParse(parts[1], out var iterations) || iterations < 1) + return false; + byte[] salt, expected; + try + { + salt = Convert.FromBase64String(parts[2]); + expected = Convert.FromBase64String(parts[3]); + } + catch (FormatException) + { + return false; + } + var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expected.Length); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + + /// Хэш пароля в формате конфига (тесты; владельцу — + /// python-однострочник из spec 01). + public static string HashPassword(string password, byte[]? salt = null) + { + salt ??= RandomNumberGenerator.GetBytes(16); + var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100_000, HashAlgorithmName.SHA256, 32); + return $"pbkdf2$100000${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}"; + } + + private static bool FixedTimeEquals(string a, string b) + { + var bytesA = System.Text.Encoding.UTF8.GetBytes(a); + var bytesB = System.Text.Encoding.UTF8.GetBytes(b); + return bytesA.Length == bytesB.Length && + CryptographicOperations.FixedTimeEquals(bytesA, bytesB); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Services/FactsTimeline.cs b/3-app/2-cli/Server.WatchBody.Web/Services/FactsTimeline.cs new file mode 100644 index 0000000..486f949 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Services/FactsTimeline.cs @@ -0,0 +1,70 @@ +using System.Text.Json; + +namespace Server.WatchBody.Web.Services; + +/// +/// Таймлайн тренировки из facts_json (spec 03 §2 m08): по Lap — +/// длительность/avg/peak пульс/отдых; пульсограмма records с +/// прореживанием до 600 точек. +/// +public static class FactsTimeline +{ + public static (List> Timeline, List> Curve) Parse( + string? factsJson, int sessionDurationS) + { + var timeline = new List>(); + var curve = new List>(); + if (string.IsNullOrWhiteSpace(factsJson)) return (timeline, curve); + try + { + using var doc = JsonDocument.Parse(factsJson); + var root = doc.RootElement; + + var records = new List<(int t, int hr)>(); + if (root.TryGetProperty("records", out var recordNodes)) + foreach (var r in recordNodes.EnumerateArray()) + if (r.TryGetProperty("t_offset_s", out var t) && r.TryGetProperty("hr", out var hr)) + records.Add((t.GetInt32(), hr.GetInt32())); + + // пульсограмма: не более 600 точек + if (records.Count > 0) + { + var step = Math.Max(1, (int)Math.Ceiling(records.Count / 600.0)); + for (var i = 0; i < records.Count; i += step) + curve.Add(new Dictionary { ["t"] = records[i].t, ["hr"] = records[i].hr }); + } + + if (!root.TryGetProperty("laps", out var lapNodes)) return (timeline, curve); + var laps = new List<(DateTime start, int durationS)>(); + foreach (var l in lapNodes.EnumerateArray()) + if (l.TryGetProperty("start", out var s) && l.TryGetProperty("duration_s", out var d) && + DateTime.TryParse(s.GetString(), null, System.Globalization.DateTimeStyles.AssumeUniversal | + System.Globalization.DateTimeStyles.AdjustToUniversal, out var start)) + laps.Add((start, d.GetInt32())); + var sessionStart = laps.Count > 0 ? laps.Min(l => l.start) + : records.Count > 0 ? DateTime.MinValue : DateTime.MinValue; + + for (var i = 0; i < laps.Count; i++) + { + var offset = (int)(laps[i].start - sessionStart).TotalSeconds; + var end = offset + laps[i].durationS; + var nextStart = i + 1 < laps.Count ? (int)(laps[i + 1].start - sessionStart).TotalSeconds : end; + var inLap = records.Where(r => r.t >= offset && r.t <= end).Select(r => r.hr).ToList(); + timeline.Add(new Dictionary + { + ["index"] = i, + ["startOffsetS"] = offset, + ["durationS"] = laps[i].durationS, + ["avgHr"] = inLap.Count > 0 ? (int)Math.Round(inLap.Average(), MidpointRounding.AwayFromZero) : null, + ["peakHr"] = inLap.Count > 0 ? inLap.Max() : null, + ["restAfterS"] = Math.Max(0, nextStart - end), + }); + } + } + catch (JsonException) + { + // битые факты — пустой таймлайн + } + return (timeline, curve); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Services/PlanSummaryService.cs b/3-app/2-cli/Server.WatchBody.Web/Services/PlanSummaryService.cs new file mode 100644 index 0000000..0d003f2 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Services/PlanSummaryService.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Core.Programs; + +namespace Server.WatchBody.Web.Services; + +/// +/// Сводка план/факт по активной версии (spec 05 §2 m08): недели от +/// понедельника недели создания версии; performed — тренировки в +/// плановые дни недели; missed = planned − performed. +/// +public class PlanSummaryService( + IWorkoutRepository workouts, + IWorkoutMetricsRepository metrics, + ProgramStore programs) +{ + private static readonly string[] DayNames = + ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; + + public object Summarize(int athleteId) + { + var active = programs.GetActive(athleteId); + if (active == null || string.IsNullOrWhiteSpace(active.Body)) + return new { activeVersion = (int?)null, weeks = Array.Empty() }; + + var weeks = ParseWeeks(active.Body); + var versionMonday = MondayOf(DateOnly.FromDateTime(active.CreatedAt)); + + var rows = new List(); + for (var i = 0; i < weeks.Count; i++) + { + var from = versionMonday.AddDays(7 * i); + var to = from.AddDays(6); + var plannedDays = weeks[i].ToHashSet(StringComparer.OrdinalIgnoreCase); + var planned = weeks[i].Count; + if (planned == 0) continue; + + var inWeek = workouts.Select(new WorkoutFilter + { + AthleteId = athleteId, + DateFrom = from, + DateTo = to, + }).ToList(); + // performed — только в плановые дни недели (spec 05) + var performedList = inWeek + .Where(w => plannedDays.Contains(DayName(DateOnly.FromDateTime(w.StartedAt)))) + .ToList(); + var adherences = performedList + .Select(w => metrics.Select(new WorkoutMetricsFilter + { AthleteId = athleteId, WorkoutId = w.Id }).FirstOrDefault()?.Adherence) + .Where(a => a != null).ToList(); + rows.Add(new + { + week = i + 1, + from = from.ToString("yyyy-MM-dd"), + to = to.ToString("yyyy-MM-dd"), + planned, + performed = performedList.Count, + missed = Math.Max(0, planned - performedList.Count), + avgAdherence = adherences.Count > 0 + ? Math.Round(adherences.Average(a => a!.Value), 1, MidpointRounding.AwayFromZero) + : (decimal?)null, + }); + } + return new { activeVersion = (int?)active.Version, weeks = rows }; + } + + /// Дни недели сессий по неделям body (v1 — каждая сессия + /// = одна плановая тренировка; ротация недель — v2). + private static List> ParseWeeks(string body) + { + var result = new List>(); + try + { + using var doc = JsonDocument.Parse(body); + foreach (var week in doc.RootElement.GetProperty("weeks").EnumerateArray()) + { + var days = new List(); + if (week.TryGetProperty("sessions", out var sessionNodes)) + foreach (var session in sessionNodes.EnumerateArray()) + if (session.TryGetProperty("day", out var day) && day.ValueKind == JsonValueKind.String) + days.Add(day.GetString()!); + result.Add(days); + } + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException) + { + // кривой body — сводка пустая + } + return result; + } + + private static DateOnly MondayOf(DateOnly date) => + date.AddDays(-(((int)date.DayOfWeek + 6) % 7)); + + private static string DayName(DateOnly date) => DayNames[((int)date.DayOfWeek + 6) % 7]; +} diff --git a/3-app/2-cli/Server.WatchBody.Web/WebAppStartup.cs b/3-app/2-cli/Server.WatchBody.Web/WebAppStartup.cs index 69ff56c..08683a6 100644 --- a/3-app/2-cli/Server.WatchBody.Web/WebAppStartup.cs +++ b/3-app/2-cli/Server.WatchBody.Web/WebAppStartup.cs @@ -45,6 +45,9 @@ public static class WebAppStartup builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(new Services.AdminAuthService( + config.AdminLogin, config.AdminPasswordHash)); + builder.Services.AddSingleton(); builder.Services.AddControllersWithViews(o => { o.EnableEndpointRouting = false; }) // контроллеры живут в сборке Web — при хостинге из тестов входная @@ -59,6 +62,8 @@ public static class WebAppStartup var app = builder.Build(); app.UseMiddleware(); + app.UseDefaultFiles(new DefaultFilesOptions { DefaultFileNames = { "admin/index.html" } }); + app.UseStaticFiles(); app.UseRouting(); app.UseMvc(routes => RouteConfig.Configure(routes)); return app; diff --git a/3-app/3-web/admin-web/.gitignore b/3-app/3-web/admin-web/.gitignore new file mode 100644 index 0000000..79f7eca --- /dev/null +++ b/3-app/3-web/admin-web/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/3-app/3-web/admin-web/.metadata b/3-app/3-web/admin-web/.metadata new file mode 100644 index 0000000..d1984f3 --- /dev/null +++ b/3-app/3-web/admin-web/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "abaf9c523780a608bd46686fd5e53740a07077f8" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + base_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + - platform: web + create_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + base_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/3-app/3-web/admin-web/README.md b/3-app/3-web/admin-web/README.md new file mode 100644 index 0000000..37a4e13 --- /dev/null +++ b/3-app/3-web/admin-web/README.md @@ -0,0 +1,17 @@ +# admin_web + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/3-app/3-web/admin-web/analysis_options.yaml b/3-app/3-web/admin-web/analysis_options.yaml new file mode 100644 index 0000000..6ba33d0 --- /dev/null +++ b/3-app/3-web/admin-web/analysis_options.yaml @@ -0,0 +1,33 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - web/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/3-app/3-web/admin-web/build.ps1 b/3-app/3-web/admin-web/build.ps1 new file mode 100644 index 0000000..f3a2f40 --- /dev/null +++ b/3-app/3-web/admin-web/build.ps1 @@ -0,0 +1,9 @@ +# Сборка admin-консоли (spec 06 §3 m08): flutter build web -> wwwroot\admin +# Требует Flutter в PATH (SDK: C:\tools\flutter). +$ErrorActionPreference = 'Stop' +flutter build web --release +$target = Join-Path $PSScriptRoot '..\..\2-cli\Server.WatchBody.Web\wwwroot\admin' +if (Test-Path $target) { Remove-Item -Recurse -Force $target } +New-Item -ItemType Directory -Force -Path $target | Out-Null +Copy-Item -Recurse -Force (Join-Path $PSScriptRoot 'build\web\*') $target +Write-Host "OK -> $target" diff --git a/3-app/3-web/admin-web/lib/api.dart b/3-app/3-web/admin-web/lib/api.dart new file mode 100644 index 0000000..96a60d4 --- /dev/null +++ b/3-app/3-web/admin-web/lib/api.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; +import 'dart:html' as html; + +/// API-клиент admin-консоли (spec 06 m08): токен в sessionStorage, +/// 401 → [UnauthorizedException] → экран логина. +class UnauthorizedException implements Exception {} + +class Api { + static const _base = ''; + static String? token = html.window.sessionStorage['adminToken']; + + static bool get authorized => token != null && token!.isNotEmpty; + + static void logout() { + token = null; + html.window.sessionStorage.remove('adminToken'); + } + + static Future> login( + String login, String password) async { + final data = await _send('POST', '/api/admin/login', + body: {'login': login, 'password': password}); + token = data['token'] as String; + html.window.sessionStorage['adminToken'] = token!; + return data; + } + + static Future get(String path) => _send('GET', path); + + static Future post(String path, [Map? body]) => + _send('POST', path, body: body); + + /// CSV — запрос с Bearer и Blob-download (spec 06 §2.3). + static Future downloadCsv(String query) async { + final request = html.HttpRequest() + ..open('GET', _base + query) + ..responseType = 'blob' + ..setRequestHeader('Authorization', 'Bearer $token'); + await request.onLoad.first; + if (request.status == 401) { + logout(); + throw UnauthorizedException(); + } + final blob = request.response as html.Blob; + final url = html.Url.createObjectUrlFromBlob(blob); + html.AnchorElement(href: url) + ..download = 'workouts.csv' + ..click(); + html.Url.revokeObjectUrl(url); + } + + static Future _send(String method, String path, + {Map? body}) async { + final request = html.HttpRequest() + ..open(method, _base + path) + ..setRequestHeader('Content-Type', 'application/json'); + if (token != null) { + request.setRequestHeader('Authorization', 'Bearer $token'); + } + await request.onLoad.first; + // ignore: only_throw_errors + if (request.status == 401) { + logout(); + throw UnauthorizedException(); + } + if (request.status! >= 400) { + throw Exception('HTTP ${request.status}: ${request.responseText}'); + } + final text = request.responseText ?? ''; + return text.isEmpty ? {} : jsonDecode(text); + } +} diff --git a/3-app/3-web/admin-web/lib/main.dart b/3-app/3-web/admin-web/lib/main.dart new file mode 100644 index 0000000..825a02e --- /dev/null +++ b/3-app/3-web/admin-web/lib/main.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; + +import 'api.dart'; +import 'screens.dart'; + +/// Админ-консоль «Феникс 6X» (m08): Flutter Web, Material 3, +/// тёмная тема (UI/UX-стандарт). Токен — sessionStorage; 401 → логин. +void main() => runApp(const AdminApp()); + +class AdminApp extends StatelessWidget { + const AdminApp({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: 'Феникс 6X — консоль владельца', + debugShowCheckedModeBanner: false, + theme: ThemeData( + brightness: Brightness.dark, + colorSchemeSeed: const Color(0xFF4FC3F7), + useMaterial3: true, + ), + home: const RootPage(), + ); +} + +class RootPage extends StatefulWidget { + const RootPage({super.key}); + + @override + State createState() => _RootPageState(); +} + +class _RootPageState extends State { + int _index = 0; + + @override + Widget build(BuildContext context) { + if (!Api.authorized) return LoginPage(onDone: _reload); + const pages = [ + AthletesPage(), + WorkoutsPage(), + ProgramsPage(), + MetricsPage(), + PlanPage(), + ]; + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: _index, + onDestinationSelected: (i) => setState(() => _index = i), + labelType: NavigationRailLabelType.all, + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.people_outline), + selectedIcon: Icon(Icons.people), + label: Text('Атлеты')), + NavigationRailDestination( + icon: Icon(Icons.fitness_center_outlined), + selectedIcon: Icon(Icons.fitness_center), + label: Text('Тренировки')), + NavigationRailDestination( + icon: Icon(Icons.list_alt_outlined), + selectedIcon: Icon(Icons.list_alt), + label: Text('Программы')), + NavigationRailDestination( + icon: Icon(Icons.insights_outlined), + selectedIcon: Icon(Icons.insights), + label: Text('Метрики')), + NavigationRailDestination( + icon: Icon(Icons.event_available_outlined), + selectedIcon: Icon(Icons.event_available), + label: Text('План/факт')), + ], + ), + const VerticalDivider(width: 1), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8), + child: OutlinedButton( + onPressed: () { + Api.logout(); + setState(() {}); + }, + child: const Text('Выйти'), + ), + ), + ), + Expanded(child: pages[_index]), + ], + ), + ), + ], + ), + ); + } + + void _reload() => setState(() {}); +} + +class LoginPage extends StatefulWidget { + const LoginPage({super.key, required this.onDone}); + + final VoidCallback onDone; + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _login = TextEditingController(); + final _password = TextEditingController(); + String? _error; + + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: SizedBox( + width: 360, + child: Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Феникс 6X', + style: Theme.of(context).textTheme.headlineMedium), + const Text('консоль владельца'), + const SizedBox(height: 24), + TextField( + controller: _login, + decoration: const InputDecoration(labelText: 'Логин')), + TextField( + controller: _password, + obscureText: true, + onSubmitted: (_) => _submit(), + decoration: + const InputDecoration(labelText: 'Пароль')), + const SizedBox(height: 16), + FilledButton( + onPressed: _submit, + child: const Text('Войти'), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(_error!, + style: const TextStyle(color: Colors.redAccent)), + ), + ], + ), + ), + ), + ), + ), + ); + + Future _submit() async { + try { + await Api.login(_login.text, _password.text); + widget.onDone(); + } on UnauthorizedException { + setState(() => _error = 'Неверный логин или пароль'); + } catch (e) { + setState(() => _error = 'Ошибка: $e'); + } + } +} diff --git a/3-app/3-web/admin-web/lib/screens.dart b/3-app/3-web/admin-web/lib/screens.dart new file mode 100644 index 0000000..2e61d9a --- /dev/null +++ b/3-app/3-web/admin-web/lib/screens.dart @@ -0,0 +1,706 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'api.dart'; + +// ---------- общие ---------- + +typedef Json = Map; + +class FutureBuilderJson extends StatelessWidget { + const FutureBuilderJson(this.future, this.builder, {super.key}); + + final Future? future; + final Widget Function(dynamic data) builder; + + @override + Widget build(BuildContext context) => FutureBuilder( + future: future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center(child: Text('Ошибка: ${snap.error}')); + } + return builder(snap.data); + }); +} + +/// Выпадающий список атлетов (общий для 4 экранов). +class AthletePicker extends StatelessWidget { + const AthletePicker({super.key, required this.selected, required this.onChanged}); + + final int? selected; + final void Function(int?) onChanged; + + @override + Widget build(BuildContext context) => + FutureBuilderJson(Api.get('/api/admin/athletes'), (data) { + final items = (data['items'] as List).cast(); + return DropdownButton( + hint: const Text('Атлет'), + value: selected != null && + items.any((a) => a['id'] == selected) + ? selected + : null, + items: [ + for (final a in items) + DropdownMenuItem( + value: a['id'] as int, + child: Text('${a['name']} (#${a['id']})' + '${a['isBlocked'] == true ? " [заблокирован]" : ""}')), + ], + onChanged: onChanged, + ); + }); +} + +// ---------- 1. Атлеты ---------- + +class AthletesPage extends StatefulWidget { + const AthletesPage({super.key}); + + @override + State createState() => _AthletesPageState(); +} + +class _AthletesPageState extends State { + Future? _future; + + @override + void initState() { + super.initState(); + _reload(); + } + + void _reload() => setState(() => _future = Api.get('/api/admin/athletes')); + + @override + Widget build(BuildContext context) => + Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Align( + alignment: Alignment.centerLeft, + child: FilledButton.tonalIcon( + onPressed: () => _createDialog(context), + icon: const Icon(Icons.person_add), + label: const Text('Добавить атлета'), + ), + ), + ), + Expanded( + child: FutureBuilderJson(_future, (data) { + final items = (data['items'] as List).cast(); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable(columns: const [ + DataColumn(label: Text('id')), + DataColumn(label: Text('Имя')), + DataColumn(label: Text('Заблокирован')), + DataColumn(label: Text('Создан')), + DataColumn(label: Text('Действия')), + ], rows: [ + for (final a in items) + DataRow(cells: [ + DataCell(Text('${a['id']}')), + DataCell(Text('${a['name']}')), + DataCell(Text(a['isBlocked'] == true ? 'да' : 'нет')), + DataCell(Text('${a['createdAt']}'.split('T').first)), + DataCell(Row(children: [ + TextButton( + onPressed: () => _newKeyDialog(context, a), + child: const Text('Смена ключа')), + Switch( + value: a['isBlocked'] == true, + onChanged: (blocked) async { + await Api.post( + '/api/admin/athletes/${a['id']}/block', + {'isBlocked': blocked}); + _reload(); + }, + ), + ])), + ]), + ]), + ); + }), + ), + ]); + + Future _createDialog(BuildContext context) async { + final nameController = TextEditingController(); + final key = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Новый атлет'), + content: TextField( + controller: nameController, + decoration: const InputDecoration(labelText: 'Имя')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), child: const Text('Отмена')), + FilledButton( + onPressed: () async { + final created = await Api.post('/api/admin/athletes', + {'name': nameController.text}); + if (context.mounted) Navigator.pop(context, created['apiKey']); + }, + child: const Text('Создать')), + ], + ), + ); + _reload(); + if (key is String && mounted) { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('API-ключ создан'), + content: Text('Ключ показывается ОДИН раз:\n\n$key\n\n' + 'Передайте его атлету для настройки телефона.'), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('Понятно')), + ], + ), + ); + } + } + + Future _newKeyDialog(BuildContext context, Json athlete) async { + final reissued = await Api.post('/api/admin/athletes/${athlete['id']}/key'); + if (!mounted) return; + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Новый API-ключ'), + content: Text('Старый ключ больше не работает.\n\n${reissued['apiKey']}'), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), child: const Text('Ок')), + ], + ), + ); + _reload(); + } +} + +// ---------- 2. Тренировки ---------- + +class WorkoutsPage extends StatefulWidget { + const WorkoutsPage({super.key}); + + @override + State createState() => _WorkoutsPageState(); +} + +class _WorkoutsPageState extends State { + int? _athleteId; + String? _from, _to; + int _offset = 0; + static const _limit = 50; + + String get _query { + var query = 'athleteId=${_athleteId ?? ''}&limit=$_limit&offset=$_offset'; + if (_from != null && _from!.isNotEmpty) query += '&dateFrom=$_from'; + if (_to != null && _to!.isNotEmpty) query += '&dateTo=$_to'; + return query; + } + + String get _csvQuery { + var query = 'athleteId=${_athleteId ?? ''}'; + if (_from != null && _from!.isNotEmpty) query += '&dateFrom=$_from'; + if (_to != null && _to!.isNotEmpty) query += '&dateTo=$_to'; + return query; + } + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Wrap(spacing: 16, children: [ + AthletePicker( + selected: _athleteId, + onChanged: (id) => setState(() { + _athleteId = id; + _offset = 0; + })), + SizedBox( + width: 130, + child: TextField( + onChanged: (v) => setState(() { _from = v; _offset = 0; }), + decoration: const InputDecoration( + hintText: 'с (ГГГГ-ММ-ДД)'))), + SizedBox( + width: 130, + child: TextField( + onChanged: (v) => setState(() { _to = v; _offset = 0; }), + decoration: const InputDecoration( + hintText: 'по (ГГГГ-ММ-ДД)'))), + OutlinedButton.icon( + onPressed: () => Api.downloadCsv('/api/admin/workouts.csv?$_csvQuery'), + icon: const Icon(Icons.download), + label: const Text('CSV'), + ), + ]), + ), + Expanded( + child: FutureBuilderJson(Api.get('/api/admin/workouts?$_query'), + (data) { + final items = (data['items'] as List).cast(); + final total = data['total'] as int; + return Column(children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable(columns: const [ + DataColumn(label: Text('Дата')), + DataColumn(label: Text('Атлет')), + DataColumn(label: Text('Мин')), + DataColumn(label: Text('Ср. пульс')), + DataColumn(label: Text('Подходы')), + DataColumn(label: Text('Adherence')), + DataColumn(label: Text('Вид')), + ], rows: [ + for (final w in items) + DataRow(onSelectChanged: (_) => _openCard(w['workoutId']), cells: [ + DataCell(Text('${w['startedAt']}'.split('T').first)), + DataCell(Text('${w['athleteName']}')), + DataCell(Text('${w['durationMin'] ?? '—'}')), + DataCell(Text('${w['avgHr'] ?? '—'}')), + DataCell(Text('${w['setsCount']}')), + DataCell(Text('${w['adherence'] ?? '—'}')), + DataCell(Text('${w['sessionKind']}')), + ]), + ]), + ), + ), + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + TextButton( + onPressed: _offset >= _limit + ? () => setState(() => _offset -= _limit) + : null, + child: const Text('← Назад')), + Text(' ${_offset + 1}–${_offset + items.length} из $total '), + TextButton( + onPressed: _offset + _limit < total + ? () => setState(() => _offset += _limit) + : null, + child: const Text('Вперёд →')), + ]), + ]); + }), + ), + ], + ); + + Future _openCard(dynamic workoutId) async { + final card = await Api.get('/api/admin/workouts/$workoutId'); + if (!mounted) return; + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Тренировка ${card['startedAt']}'.split('T').first), + content: SizedBox( + width: 720, + height: 480, + child: DefaultTabController( + length: 3, + child: Column(children: [ + const TabBar(tabs: [ + Tab(text: 'План/факт'), + Tab(text: 'Подходы (.fit)'), + Tab(text: 'Пульсограмма'), + ]), + Expanded( + child: TabBarView(children: [ + _planFact(card), + _timeline(card), + _hrCurve(card), + ]), + ), + ]), + ), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), child: const Text('Закрыть')), + ], + ), + ); + } + + Widget _planFact(Json card) { + final planFact = card['planFact'] as Json?; + final plan = (planFact?['plan'] as List? ?? []).cast(); + if (plan.isEmpty) { + return const Center(child: Text('План на этот день не найден')); + } + return DataTable(columns: const [ + DataColumn(label: Text('Упражнение')), + DataColumn(label: Text('План')), + DataColumn(label: Text('Факт')), + ], rows: [ + for (final p in plan) + DataRow(cells: [ + DataCell(Text('${p['exercise']}')), + DataCell(Text( + '${p['plannedSets']}×${p['plannedReps'] ?? '—'} @ ${p['plannedWeight'] ?? '—'} кг')), + DataCell(Text( + '${p['factSets']}×${p['factReps'] ?? '—'} @ ${p['factWeight'] ?? '—'} кг')), + ]), + ]); + } + + Widget _timeline(Json card) { + final timeline = (card['timeline'] as List? ?? []).cast(); + if (timeline.isEmpty) { + return const Center(child: Text('Данных .fit нет')); + } + return ListView(children: [ + for (final t in timeline) + ListTile( + title: Text('Подход ${t['index']}: ${t['durationS']} c'), + subtitle: Text('старт +${t['startOffsetS']} c · ' + 'пульс ${t['avgHr'] ?? '—'}/${t['peakHr'] ?? '—'} · ' + 'отдых ${t['restAfterS']} c'), + ), + ]); + } + + Widget _hrCurve(Json card) { + final curve = (card['hrCurve'] as List? ?? []).cast(); + if (curve.isEmpty) { + return const Center(child: Text('Данных .fit нет')); + } + return CustomPaint( + painter: _HrCurvePainter(curve), + size: Size.infinite, + ); + } +} + +class _HrCurvePainter extends CustomPainter { + _HrCurvePainter(this.points); + + final List points; + + @override + void paint(Canvas canvas, Size size) { + if (points.length < 2) return; + final maxT = (points.last['t'] as num).toDouble(); + final minHr = points + .map((p) => (p['hr'] as num).toDouble()) + .reduce((a, b) => a < b ? a : b); + final maxHr = points + .map((p) => (p['hr'] as num).toDouble()) + .reduce((a, b) => a > b ? a : b); + final spanHr = (maxHr - minHr).clamp(1.0, double.infinity); + Offset pointAt(Json p) => Offset( + (p['t'] as num).toDouble() / maxT * size.width, + size.height - + ((p['hr'] as num).toDouble() - minHr) / spanHr * size.height, + ); + final path = Path()..moveTo(0, pointAt(points.first).dy); + for (final p in points) { + final o = pointAt(p); + path.lineTo(o.dx, o.dy); + } + final paintLine = Paint() + ..color = const Color(0xFF4FC3F7) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawPath(path, paintLine); + final tp = TextPainter(textDirection: TextDirection.ltr); + tp.text = const TextSpan(text: ''); + tp.layout(); + tp.paint(canvas, Offset.zero); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +// ---------- 3. Программы ---------- + +class ProgramsPage extends StatefulWidget { + const ProgramsPage({super.key}); + + @override + State createState() => _ProgramsPageState(); +} + +class _ProgramsPageState extends State { + int? _athleteId; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: AthletePicker( + selected: _athleteId, onChanged: (id) => setState(() => _athleteId = id)), + ), + Expanded( + child: _athleteId == null + ? const Center(child: Text('Выберите атлета')) + : FutureBuilderJson( + Api.get('/api/admin/programs?athleteId=$_athleteId'), + (data) { + final items = (data['items'] as List).cast(); + if (items.isEmpty) { + return const Center(child: Text('Версий нет')); + } + return ListView(children: [ + for (final v in items) + ListTile( + title: Text('v${v['version']} · ${v['status']} · ' + '${v['author']}'), + subtitle: Text('${v['rationale']}'), + trailing: Wrap(children: [ + if (v['author'] == 'rules' && v['status'] == 'draft') + TextButton( + onPressed: () => _confirm( + 'Применить предложение правил?', + () => Api.post( + '/api/admin/programs/${v['versionId']}/apply?athleteId=$_athleteId'), + reload: true), + child: const Text('Применить'), + ), + if (v['status'] == 'draft') + TextButton( + onPressed: () => _confirm( + 'Активировать v${v['version']}?', + () => Api.post( + '/api/admin/programs/${v['versionId']}/activate?athleteId=$_athleteId'), + reload: true), + child: const Text('Активировать'), + ), + TextButton( + onPressed: () => _showBody( + v['versionId'], v['version']), + child: const Text('Body'), + ), + ]), + ), + ]); + }), + ), + ], + ); + + Future _confirm(String title, Future Function() action, + {bool reload = false}) async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Отмена')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Да')), + ], + ), + ); + if (ok == true) { + await action(); + if (reload && mounted) setState(() {}); + } + } + + Future _showBody(dynamic versionId, dynamic version) async { + final v = await Api.get( + '/api/admin/programs/$versionId?athleteId=$_athleteId'); + if (!mounted) return; + final pretty = const JsonEncoder.withIndent(' ') + .convert(jsonDecode(v['body'] as String)); + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Body v$version'), + content: SizedBox( + width: 640, + height: 480, + child: SingleChildScrollView(child: SelectableText(pretty))), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('Закрыть')), + ], + ), + ); + } +} + +// ---------- 4. Метрики ---------- + +class MetricsPage extends StatefulWidget { + const MetricsPage({super.key}); + + @override + State createState() => _MetricsPageState(); +} + +class _MetricsPageState extends State { + int? _athleteId; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: AthletePicker( + selected: _athleteId, onChanged: (id) => setState(() => _athleteId = id)), + ), + Expanded( + child: _athleteId == null + ? const Center(child: Text('Выберите атлета')) + : FutureBuilderJson( + Api.get('/api/admin/metrics?athleteId=$_athleteId'), + (data) { + final weeks = (data['weekly'] as List).cast(); + final workouts = (data['workouts'] as List).cast(); + return Column(children: [ + if (weeks.isNotEmpty) + SizedBox( + height: 160, + child: CustomPaint( + painter: _WeeklyChartPainter(weeks), + size: Size.infinite, + ), + ), + Expanded( + child: DataTable(columns: const [ + DataColumn(label: Text('Дата')), + DataColumn(label: Text('Adherence')), + DataColumn(label: Text('Тоннаж')), + DataColumn(label: Text('Флаги')), + ], rows: [ + for (final m in workouts) + DataRow(cells: [ + DataCell(Text('${m['date'] ?? '—'}')), + DataCell(Text('${m['adherence']}')), + DataCell(Text('${m['tonnage']}')), + DataCell(Text(_flags(m))), + ]), + ]), + ), + ]); + }), + ), + ], + ); + + String _flags(Json m) { + final flags = m['redFlags']; + if (flags is List) return flags.isEmpty ? '—' : '${flags.length}'; + return '—'; + } +} + +class _WeeklyChartPainter extends CustomPainter { + _WeeklyChartPainter(this.weeks); + + final List weeks; + + @override + void paint(Canvas canvas, Size size) { + if (weeks.isEmpty) return; + final maxTonnage = weeks + .map((w) => (w['tonnage'] as num).toDouble()) + .reduce((a, b) => a > b ? a : b); + final barWidth = size.width / weeks.length * 0.6; + final step = size.width / weeks.length; + final barPaint = Paint()..color = const Color(0x804FC3F7); + final tp = TextPainter(textDirection: TextDirection.ltr); + for (var i = 0; i < weeks.length; i++) { + final tonnage = (weeks[i]['tonnage'] as num).toDouble(); + final h = tonnage / maxTonnage * (size.height - 40); + canvas.drawRect( + Rect.fromLTWH(i * step + (step - barWidth) / 2, + size.height - 24 - h, barWidth, h), + barPaint); + // подписи текстом (цвет дублируется значением — UI/UX-стандарт) + tp.text = TextSpan( + text: '${weeks[i]['week']}'.split('-').last, + style: const TextStyle(color: Color(0xFFB0BEC5), fontSize: 11)); + tp.layout(); + tp.paint(canvas, Offset(i * step + step / 2 - tp.width / 2, size.height - 20)); + tp.text = TextSpan( + text: '${weeks[i]['avgAdherence']}%', + style: const TextStyle(color: Color(0xFF80CBC4), fontSize: 11)); + tp.layout(); + tp.paint(canvas, Offset(i * step + step / 2 - tp.width / 2, size.height - 40)); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +// ---------- 5. План/факт ---------- + +class PlanPage extends StatefulWidget { + const PlanPage({super.key}); + + @override + State createState() => _PlanPageState(); +} + +class _PlanPageState extends State { + int? _athleteId; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: AthletePicker( + selected: _athleteId, onChanged: (id) => setState(() => _athleteId = id)), + ), + Expanded( + child: _athleteId == null + ? const Center(child: Text('Выберите атлета')) + : FutureBuilderJson( + Api.get('/api/admin/plan?athleteId=$_athleteId'), (data) { + if (data['activeVersion'] == null) { + return const Center(child: Text('Активной версии нет')); + } + final weeks = (data['weeks'] as List).cast(); + return ListView(children: [ + for (final w in weeks) + Card( + child: ListTile( + title: Text( + 'Неделя ${w['week']} (${w['from']} — ${w['to']})'), + subtitle: Text( + 'запланировано ${w['planned']} · ' + 'выполнено ${w['performed']} · ' + 'пропущено ${w['missed']}'), + trailing: Text( + '${w['avgAdherence'] ?? '—'}%', + style: TextStyle( + color: (w['missed'] as int) > 0 + ? Colors.orangeAccent + : Colors.greenAccent, + ), + ), + ), + ), + ]); + }), + ), + ], + ); +} diff --git a/3-app/3-web/admin-web/pubspec.lock b/3-app/3-web/admin-web/pubspec.lock new file mode 100644 index 0000000..135e89a --- /dev/null +++ b/3-app/3-web/admin-web/pubspec.lock @@ -0,0 +1,213 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" +sdks: + dart: ">=3.13.5 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/3-app/3-web/admin-web/pubspec.yaml b/3-app/3-web/admin-web/pubspec.yaml new file mode 100644 index 0000000..0f6689a --- /dev/null +++ b/3-app/3-web/admin-web/pubspec.yaml @@ -0,0 +1,89 @@ +name: admin_web +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.13.5 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/3-app/3-web/admin-web/web/favicon.png b/3-app/3-web/admin-web/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/3-app/3-web/admin-web/web/icons/Icon-192.png b/3-app/3-web/admin-web/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/3-app/3-web/admin-web/web/icons/Icon-512.png b/3-app/3-web/admin-web/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/3-app/3-web/admin-web/web/icons/Icon-maskable-192.png b/3-app/3-web/admin-web/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/3-app/3-web/admin-web/web/icons/Icon-maskable-512.png b/3-app/3-web/admin-web/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/3-app/3-web/admin-web/web/index.html b/3-app/3-web/admin-web/web/index.html new file mode 100644 index 0000000..e09d39c --- /dev/null +++ b/3-app/3-web/admin-web/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + admin_web + + + + + + + diff --git a/3-app/3-web/admin-web/web/manifest.json b/3-app/3-web/admin-web/web/manifest.json new file mode 100644 index 0000000..4e8cecb --- /dev/null +++ b/3-app/3-web/admin-web/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "admin_web", + "short_name": "admin_web", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}