commit 39fdd43d8e5eeedf204e14bd963313c8d28d1daa Author: Andrey Parovin Date: Sun Oct 11 14:12:10 2026 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d34299 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.pyc +.venv/ +.env +node_modules/ +dist/ +.DS_Store +*.xlsx.bak +_draft_dump.json +frontend/android/.gradle/ +frontend/android/app/build/ +frontend/android/build/ +frontend/android/local.properties +*.apk +*.aab +*.keystore diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcc2a5e --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Benchpress Secret + +Мобильный web-интерфейс программы жима лёжа на 5 недель. + +## Стек + +- Frontend: React + Vite + TypeScript +- Backend: FastAPI +- DB: PostgreSQL +- Docker: `frontend` + `backend` + `db` + +## Запуск + +```bash +docker compose up --build +``` + +Открыть: http://localhost:3000 +API: http://localhost:8000/api/health + +## Локальная разработка без Docker UI + +```bash +docker compose up db backend --build +cd frontend +npm install +npm run dev +``` + +Vite proxy: `/api` → `http://localhost:8000` + +## Первый вход + +1. Задать пароль +2. Ввести 1ПМ (шаг 1.25 кг) +3. Выбрать 3 дня недели с перерывом ≥1 день +4. Выбрать дату старта + +## Настройки + +Импорт/экспорт JSON и сброс прогресса — в разделе «Настройки». diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..07f85aa --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +__pycache__ +*.pyc +.venv +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f63f44f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..421187c --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,24 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + database_url: str = "postgresql+psycopg://benchpress:benchpress@localhost:5432/benchpress" + secret_key: str = "dev-secret-key-change-me" + access_token_expire_minutes: int = 60 * 24 * 30 + cors_origins: str = ( + "http://localhost:5173,http://localhost:3000,http://localhost," + "https://localhost,capacitor://localhost,http://10.0.2.2:8000" + ) + plate_step_kg: float = 1.25 + pause_limit_minutes: int = 15 + rest_min_days: int = 3 + rest_max_days: int = 5 + + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +settings = Settings() diff --git a/backend/app/data/program.json b/backend/app/data/program.json new file mode 100644 index 0000000..1f1ce1d --- /dev/null +++ b/backend/app/data/program.json @@ -0,0 +1,1689 @@ +{ + "weeks": [ + { + "week": 1, + "days": [ + { + "template_day": "monday", + "label": "Понедельник", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + } + ] + }, + { + "name": "Строгий на бицепс", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + } + ] + }, + { + "name": "Французский жим гантелей", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "wednesday", + "label": "Среда", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + } + ] + }, + { + "name": "Пуловер", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Горизонтальные отведения", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Гиперэкстензия", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + } + ] + }, + { + "template_day": "friday", + "label": "Пятница", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 60.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 55.0, + "reps": 6 + }, + { + "percent": 45.0, + "reps": 8 + } + ] + }, + { + "name": "Молотковые сгибания", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + } + ] + }, + { + "name": "Разгибания с канатом", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + } + ] + }, + { + "week": 2, + "days": [ + { + "template_day": "monday", + "label": "Понедельник", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + } + ] + }, + { + "name": "Вертикальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Горизонтальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Обратная планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "wednesday", + "label": "Среда", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + } + ] + }, + { + "name": "Строгий на бицепс", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Французский жим гантелей", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 20 + }, + { + "type": "reps", + "reps": 20 + }, + { + "type": "reps", + "reps": 20 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "friday", + "label": "Пятница", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 65.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 6 + }, + { + "percent": 45.0, + "reps": 8 + } + ] + }, + { + "name": "Пуловер", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Горизонтальные отведения", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Гиперэкстензия", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + } + ] + } + ] + }, + { + "week": 3, + "days": [ + { + "template_day": "monday", + "label": "Понедельник", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 4 + }, + { + "percent": 80.0, + "reps": 4 + }, + { + "percent": 80.0, + "reps": 4 + } + ] + }, + { + "name": "Молотковые сгибания", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Разгибания с канатом", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "wednesday", + "label": "Среда", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 3 + } + ] + }, + { + "name": "Вертикальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Горизонтальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Обратная планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "friday", + "label": "Пятница", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 3 + }, + { + "percent": 85.0, + "reps": 4 + }, + { + "percent": 85.0, + "reps": 4 + }, + { + "percent": 85.0, + "reps": 4 + }, + { + "percent": 80.0, + "reps": 3 + }, + { + "percent": 70.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 5 + } + ] + }, + { + "name": "Строгий на бицепс", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 8 + }, + { + "type": "reps", + "reps": 8 + }, + { + "type": "reps", + "reps": 8 + } + ] + }, + { + "name": "Французский жим гантелей", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + } + ] + }, + { + "week": 4, + "days": [ + { + "template_day": "monday", + "label": "Понедельник", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 2 + }, + { + "percent": 75.0, + "reps": 2 + }, + { + "percent": 75.0, + "reps": 2 + }, + { + "percent": 80.0, + "reps": 5 + }, + { + "percent": 80.0, + "reps": 5 + } + ] + }, + { + "name": "Пуловер", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Горизонтальные отведения", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Гиперэкстензия", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + } + ] + }, + { + "template_day": "wednesday", + "label": "Среда", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 5 + } + ] + }, + { + "name": "Молотковые сгибания", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 8 + }, + { + "type": "reps", + "reps": 8 + }, + { + "type": "reps", + "reps": 8 + } + ] + }, + { + "name": "Разгибания с канатом", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "friday", + "label": "Пятница", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 6 + }, + { + "percent": 80.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 6 + }, + { + "percent": 45.0, + "reps": 8 + } + ] + }, + { + "name": "Вертикальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Горизонтальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Обратная планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + } + ] + }, + { + "week": 5, + "days": [ + { + "template_day": "monday", + "label": "Понедельник", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 5 + }, + { + "percent": 80.0, + "reps": 5 + } + ] + }, + { + "name": "Строгий на бицепс", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + } + ] + }, + { + "name": "Французский жим гантелей", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "wednesday", + "label": "Среда", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + } + ] + }, + { + "name": "Пуловер", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Горизонтальные отведения", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Гиперэкстензия", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + } + ] + }, + { + "template_day": "friday", + "label": "Пятница", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 3 + }, + { + "percent": 85.0, + "reps": 2 + }, + { + "percent": 90.0, + "reps": 4 + }, + { + "percent": 90.0, + "reps": 4 + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..7c7d5e1 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,21 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import settings + +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +class Base(DeclarativeBase): + pass + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..298f3ee --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.database import Base, engine +from app.routers import actions, auth, workouts + +app = FastAPI(title="Benchpress Secret", version="0.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(workouts.router) +app.include_router(actions.router) + + +@app.on_event("startup") +def on_startup() -> None: + Base.metadata.create_all(bind=engine) + + +@app.get("/api/health") +def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..8f828e5 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from datetime import date, datetime +from typing import Any, Optional + +from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class Profile(Base): + __tablename__ = "profiles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + one_rm_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + training_weekdays: Mapped[Optional[list[Any]]] = mapped_column(JSONB, nullable=True) + start_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + onboarded: Mapped[bool] = mapped_column(Boolean, default=False) + phase: Mapped[str] = mapped_column(String(32), default="active") + # active | rest | probe | completed_cycle + rest_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + probe_available_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + cycle_number: Mapped[int] = mapped_column(Integer, default=1) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class Workout(Base): + __tablename__ = "workouts" + __table_args__ = (UniqueConstraint("scheduled_date", "cycle_number", name="uq_workout_date_cycle"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + cycle_number: Mapped[int] = mapped_column(Integer, default=1) + week_number: Mapped[int] = mapped_column(Integer, nullable=False) + day_index: Mapped[int] = mapped_column(Integer, nullable=False) # 0..2 within week + scheduled_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + status: Mapped[str] = mapped_column(String(32), default="planned") + # planned | in_progress | paused | completed | abandoned + started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + current_exercise_index: Mapped[int] = mapped_column(Integer, default=0) + current_set_index: Mapped[int] = mapped_column(Integer, default=0) + rest_started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + exercises: Mapped[list[WorkoutExercise]] = relationship( + back_populates="workout", cascade="all, delete-orphan", order_by="WorkoutExercise.order_index" + ) + + +class WorkoutExercise(Base): + __tablename__ = "workout_exercises" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + workout_id: Mapped[int] = mapped_column(ForeignKey("workouts.id", ondelete="CASCADE"), index=True) + order_index: Mapped[int] = mapped_column(Integer, nullable=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + kind: Mapped[str] = mapped_column(String(32), nullable=False) # bench | accessory | failure + prescribed_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + weight_locked: Mapped[bool] = mapped_column(Boolean, default=False) + needs_weight_input: Mapped[bool] = mapped_column(Boolean, default=False) + pending_prompt: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + # None | choose_weight_missed | choose_weight_easy | confirm_easy + + workout: Mapped[Workout] = relationship(back_populates="exercises") + sets: Mapped[list[WorkoutSet]] = relationship( + back_populates="exercise", cascade="all, delete-orphan", order_by="WorkoutSet.set_index" + ) + + +class WorkoutSet(Base): + __tablename__ = "workout_sets" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + exercise_id: Mapped[int] = mapped_column(ForeignKey("workout_exercises.id", ondelete="CASCADE"), index=True) + set_index: Mapped[int] = mapped_column(Integer, nullable=False) + target_percent: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + target_reps: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + target_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + is_failure: Mapped[bool] = mapped_column(Boolean, default=False) + completed: Mapped[bool] = mapped_column(Boolean, default=False) + actual_reps: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + actual_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + exercise: Mapped[WorkoutExercise] = relationship(back_populates="sets") + + +class AccessoryState(Base): + """Tracks working weight and lock status for accessory exercises within a cycle.""" + + __tablename__ = "accessory_states" + __table_args__ = (UniqueConstraint("cycle_number", "exercise_name", name="uq_accessory_cycle_name"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + cycle_number: Mapped[int] = mapped_column(Integer, default=1) + exercise_name: Mapped[str] = mapped_column(String(128), nullable=False) + weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + locked: Mapped[bool] = mapped_column(Boolean, default=False) + pending_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class AppMeta(Base): + __tablename__ = "app_meta" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + key: Mapped[str] = mapped_column(String(64), unique=True) + value: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/actions.py b/backend/app/routers/actions.py new file mode 100644 index 0000000..26f3d76 --- /dev/null +++ b/backend/app/routers/actions.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from datetime import date, timedelta + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import User, Workout +from app.routers.auth import require_auth +from app.schemas import ( + AdjustSetRequest, + CompleteSetRequest, + MoveWorkoutRequest, + PromptChoiceRequest, + SetWeightRequest, + WorkoutDetail, +) +from app.services.auth import get_or_create_profile +from app.services.reschedule import plan_move +from app.services.workout import ( + adjust_completed_set, + complete_set, + load_workout, + pause_workout, + resolve_prompt, + resume_workout, + set_exercise_weight, + start_workout, + workout_detail, +) + +router = APIRouter(prefix="/api/workouts", tags=["workout-actions"]) + + +@router.post("/{workout_id}/start", response_model=WorkoutDetail) +def api_start(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail: + w = start_workout(db, load_workout(db, workout_id)) + return workout_detail(db, w) + + +@router.post("/{workout_id}/pause", response_model=WorkoutDetail) +def api_pause(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail: + w = pause_workout(db, load_workout(db, workout_id)) + return workout_detail(db, w) + + +@router.post("/{workout_id}/resume", response_model=WorkoutDetail) +def api_resume(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail: + w = resume_workout(db, load_workout(db, workout_id)) + return workout_detail(db, w) + + +@router.post("/{workout_id}/complete-set", response_model=WorkoutDetail) +def api_complete_set( + workout_id: int, + body: CompleteSetRequest, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> WorkoutDetail: + w = complete_set( + db, + load_workout(db, workout_id), + actual_reps=body.actual_reps, + actual_weight_kg=body.actual_weight_kg, + ) + return workout_detail(db, w) + + +@router.post("/{workout_id}/exercises/{exercise_id}/weight", response_model=WorkoutDetail) +def api_set_weight( + workout_id: int, + exercise_id: int, + body: SetWeightRequest, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> WorkoutDetail: + workout = load_workout(db, workout_id) + exercise = next((e for e in workout.exercises if e.id == exercise_id), None) + if not exercise: + raise HTTPException(404, "Упражнение не найдено") + w = set_exercise_weight(db, workout, exercise, body.weight_kg) + return workout_detail(db, w) + + +@router.post("/{workout_id}/exercises/{exercise_id}/sets/{set_id}", response_model=WorkoutDetail) +def api_adjust_set( + workout_id: int, + exercise_id: int, + set_id: int, + body: AdjustSetRequest, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> WorkoutDetail: + workout = load_workout(db, workout_id) + exercise = next((e for e in workout.exercises if e.id == exercise_id), None) + if not exercise: + raise HTTPException(404, "Упражнение не найдено") + workout_set = next((s for s in exercise.sets if s.id == set_id), None) + if not workout_set: + raise HTTPException(404, "Подход не найден") + w = adjust_completed_set( + db, + workout, + exercise, + workout_set, + actual_weight_kg=body.actual_weight_kg, + actual_reps=body.actual_reps, + ) + return workout_detail(db, w) + + +@router.post("/{workout_id}/move", response_model=WorkoutDetail) +def api_move( + workout_id: int, + body: MoveWorkoutRequest, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> WorkoutDetail: + workout = load_workout(db, workout_id) + profile = get_or_create_profile(db) + weekdays = profile.training_weekdays or [] + if len(weekdays) != 3: + raise HTTPException(400, "Не заданы дни тренировок") + rows = db.query(Workout).filter(Workout.cycle_number == workout.cycle_number).all() + assigned = plan_move(rows, workout_id, body.scheduled_date, list(weekdays)) + for row in rows: + if row.id in assigned: + row.scheduled_date = date(2099, 1, 1) + timedelta(days=row.id % 300) + db.flush() + by_id = {row.id: row for row in rows} + for workout_row_id, scheduled in assigned.items(): + by_id[workout_row_id].scheduled_date = scheduled + db.commit() + return workout_detail(db, load_workout(db, workout_id)) + + +@router.post("/{workout_id}/exercises/{exercise_id}/prompt", response_model=WorkoutDetail) +def api_prompt( + workout_id: int, + exercise_id: int, + body: PromptChoiceRequest, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> WorkoutDetail: + workout = load_workout(db, workout_id) + exercise = next((e for e in workout.exercises if e.id == exercise_id), None) + if not exercise: + raise HTTPException(404, "Упражнение не найдено") + w = resolve_prompt( + db, + workout, + exercise, + choice=body.choice, + weight_kg=body.weight_kg, + increase_kg=body.increase_kg, + decrease_kg=body.decrease_kg, + ) + return workout_detail(db, w) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..c305296 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import User +from app.schemas import LoginRequest, SetupPasswordRequest, StatusResponse, TokenResponse +from app.services.auth import ( + create_access_token, + decode_token, + get_or_create_profile, + get_user, + has_password, + hash_password, + verify_password, +) + +router = APIRouter(prefix="/api/auth", tags=["auth"]) +security = HTTPBearer(auto_error=False) + + +def require_auth( + creds: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: Session = Depends(get_db), +) -> User: + if not creds: + raise HTTPException(401, "Требуется авторизация") + sub = decode_token(creds.credentials) + if not sub: + raise HTTPException(401, "Недействительный токен") + user = get_user(db) + if not user: + raise HTTPException(401, "Пользователь не найден") + return user + + +@router.get("/status", response_model=StatusResponse) +def auth_status(db: Session = Depends(get_db)) -> StatusResponse: + profile = get_or_create_profile(db) + return StatusResponse( + has_password=has_password(db), + onboarded=profile.onboarded, + phase=profile.phase, + ) + + +@router.post("/setup", response_model=TokenResponse) +def setup_password(body: SetupPasswordRequest, db: Session = Depends(get_db)) -> TokenResponse: + if has_password(db): + raise HTTPException(400, "Пароль уже задан") + user = User(password_hash=hash_password(body.password), created_at=datetime.utcnow()) + db.add(user) + get_or_create_profile(db) + db.commit() + profile = get_or_create_profile(db) + return TokenResponse( + access_token=create_access_token(), + needs_setup=False, + onboarded=profile.onboarded, + ) + + +@router.post("/login", response_model=TokenResponse) +def login(body: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse: + user = get_user(db) + if not user: + raise HTTPException(400, "Сначала задайте пароль") + if not verify_password(body.password, user.password_hash): + raise HTTPException(401, "Неверный пароль") + profile = get_or_create_profile(db) + return TokenResponse( + access_token=create_access_token(), + onboarded=profile.onboarded, + ) diff --git a/backend/app/routers/workouts.py b/backend/app/routers/workouts.py new file mode 100644 index 0000000..958517f --- /dev/null +++ b/backend/app/routers/workouts.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +from datetime import date, datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload + +from app.database import get_db +from app.models import AccessoryState, Profile, User, Workout, WorkoutExercise +from app.routers.auth import require_auth +from app.schemas import ( + CalendarResponse, + ExportResponse, + ImportRequest, + MessageResponse, + OnboardingRequest, + ProbeRequest, + ProfileResponse, + StartRestRequest, + TrainingDaysRequest, + WorkoutDetail, + WorkoutSummary, +) +from app.services.reschedule import plan_weekday_change +from app.services.auth import get_or_create_profile +from app.services.schedule import clear_cycle_workouts, generate_schedule +from app.services.workout import ( + load_workout, + profile_response, + start_rest_phase, + submit_probe, + workout_detail, + workout_summary, +) +from app.services.weights import round_to_plate + +router = APIRouter(prefix="/api", tags=["app"]) + + +@router.get("/profile", response_model=ProfileResponse) +def get_profile(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> ProfileResponse: + return profile_response(get_or_create_profile(db)) + + +@router.post("/onboarding", response_model=ProfileResponse) +def onboarding( + body: OnboardingRequest, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> ProfileResponse: + profile = get_or_create_profile(db) + if profile.onboarded: + raise HTTPException(400, "Онбординг уже пройден. Используйте сброс в настройках.") + + one_rm = round_to_plate(body.one_rm_kg) + profile.one_rm_kg = one_rm + profile.training_weekdays = body.training_weekdays + profile.start_date = body.start_date + profile.onboarded = True + profile.phase = "active" + profile.cycle_number = 1 + + clear_cycle_workouts(db, 1) + generate_schedule( + db, + one_rm_kg=one_rm, + training_weekdays=body.training_weekdays, + start_date=body.start_date, + cycle_number=1, + ) + db.commit() + db.refresh(profile) + return profile_response(profile) + + +@router.get("/calendar", response_model=CalendarResponse) +def calendar( + year: int, + month: int, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> CalendarResponse: + if month < 1 or month > 12: + raise HTTPException(400, "Неверный месяц") + profile = get_or_create_profile(db) + workouts = ( + db.query(Workout) + .options(joinedload(Workout.exercises)) + .filter( + Workout.cycle_number == profile.cycle_number, + ) + .order_by(Workout.scheduled_date) + .all() + ) + # filter by month for display but also include all cycle for week strip — return month filtered + month_workouts = [w for w in workouts if w.scheduled_date.year == year and w.scheduled_date.month == month] + return CalendarResponse( + year=year, + month=month, + workouts=[workout_summary(w) for w in month_workouts], + profile=profile_response(profile), + ) + + +@router.post("/training-days", response_model=ProfileResponse) +def update_training_days( + body: TrainingDaysRequest, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> ProfileResponse: + profile = get_or_create_profile(db) + if not profile.onboarded: + raise HTTPException(400, "Сначала создайте программу") + rows = db.query(Workout).filter(Workout.cycle_number == profile.cycle_number).all() + done = [w.scheduled_date for w in rows if w.status == "completed"] + start = date.today() + if done: + start = max(start, max(done) + timedelta(days=1)) + assigned = plan_weekday_change(rows, list(body.training_weekdays), start) + for row in rows: + if row.id in assigned: + row.scheduled_date = date(2099, 1, 1) + timedelta(days=row.id % 300) + db.flush() + by_id = {row.id: row for row in rows} + for workout_row_id, scheduled in assigned.items(): + by_id[workout_row_id].scheduled_date = scheduled + profile.training_weekdays = list(body.training_weekdays) + db.commit() + db.refresh(profile) + return profile_response(profile) + + +@router.get("/workouts/upcoming", response_model=list[WorkoutSummary]) +def upcoming( + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> list[WorkoutSummary]: + profile = get_or_create_profile(db) + workouts = ( + db.query(Workout) + .options(joinedload(Workout.exercises)) + .filter(Workout.cycle_number == profile.cycle_number) + .order_by(Workout.scheduled_date) + .all() + ) + return [workout_summary(w) for w in workouts] + + +@router.get("/workouts/{workout_id}", response_model=WorkoutDetail) +def get_workout( + workout_id: int, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> WorkoutDetail: + return workout_detail(db, load_workout(db, workout_id)) + + +@router.post("/rest/start", response_model=ProfileResponse) +def rest_start( + body: StartRestRequest, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> ProfileResponse: + profile = get_or_create_profile(db) + return profile_response(start_rest_phase(db, profile, body.rest_days)) + + +@router.post("/probe", response_model=ProfileResponse) +def probe( + body: ProbeRequest, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> ProfileResponse: + profile = get_or_create_profile(db) + return profile_response( + submit_probe( + db, + profile, + one_rm_kg=body.one_rm_kg, + start_date=body.start_date, + training_weekdays=body.training_weekdays, + ) + ) + + +@router.get("/export", response_model=ExportResponse) +def export_data(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> ExportResponse: + profile = get_or_create_profile(db) + workouts = ( + db.query(Workout) + .options(joinedload(Workout.exercises).joinedload(WorkoutExercise.sets)) + .order_by(Workout.scheduled_date) + .all() + ) + accessories = db.query(AccessoryState).all() + + def dump_workout(w: Workout) -> dict: + return { + "cycle_number": w.cycle_number, + "week_number": w.week_number, + "day_index": w.day_index, + "scheduled_date": w.scheduled_date.isoformat(), + "status": w.status, + "started_at": w.started_at.isoformat() if w.started_at else None, + "completed_at": w.completed_at.isoformat() if w.completed_at else None, + "current_exercise_index": w.current_exercise_index, + "current_set_index": w.current_set_index, + "exercises": [ + { + "order_index": e.order_index, + "name": e.name, + "kind": e.kind, + "prescribed_weight_kg": e.prescribed_weight_kg, + "weight_locked": e.weight_locked, + "needs_weight_input": e.needs_weight_input, + "pending_prompt": e.pending_prompt, + "sets": [ + { + "set_index": s.set_index, + "target_percent": s.target_percent, + "target_reps": s.target_reps, + "target_weight_kg": s.target_weight_kg, + "is_failure": s.is_failure, + "completed": s.completed, + "actual_reps": s.actual_reps, + "actual_weight_kg": s.actual_weight_kg, + "completed_at": s.completed_at.isoformat() if s.completed_at else None, + } + for s in sorted(e.sets, key=lambda x: x.set_index) + ], + } + for e in sorted(w.exercises, key=lambda x: x.order_index) + ], + } + + return ExportResponse( + exported_at=datetime.utcnow(), + data={ + "profile": { + "one_rm_kg": profile.one_rm_kg, + "training_weekdays": profile.training_weekdays, + "start_date": profile.start_date.isoformat() if profile.start_date else None, + "onboarded": profile.onboarded, + "phase": profile.phase, + "rest_until": profile.rest_until.isoformat() if profile.rest_until else None, + "probe_available_at": profile.probe_available_at.isoformat() + if profile.probe_available_at + else None, + "cycle_number": profile.cycle_number, + }, + "workouts": [dump_workout(w) for w in workouts], + "accessories": [ + { + "cycle_number": a.cycle_number, + "exercise_name": a.exercise_name, + "weight_kg": a.weight_kg, + "locked": a.locked, + "pending_weight_kg": a.pending_weight_kg, + } + for a in accessories + ], + }, + ) + + +@router.post("/import", response_model=MessageResponse) +def import_data( + body: ImportRequest, + _: User = Depends(require_auth), + db: Session = Depends(get_db), +) -> MessageResponse: + if body.version != 1: + raise HTTPException(400, "Неподдерживаемая версия экспорта") + data = body.data + # full replace of progress + db.query(Workout).delete() + db.query(AccessoryState).delete() + profile = get_or_create_profile(db) + p = data.get("profile", {}) + profile.one_rm_kg = p.get("one_rm_kg") + profile.training_weekdays = p.get("training_weekdays") + profile.start_date = datetime.fromisoformat(p["start_date"]).date() if p.get("start_date") else None + profile.onboarded = bool(p.get("onboarded")) + profile.phase = p.get("phase") or "active" + profile.rest_until = datetime.fromisoformat(p["rest_until"]) if p.get("rest_until") else None + profile.probe_available_at = ( + datetime.fromisoformat(p["probe_available_at"]) if p.get("probe_available_at") else None + ) + profile.cycle_number = int(p.get("cycle_number") or 1) + + from app.models import WorkoutSet + + for a in data.get("accessories", []): + db.add( + AccessoryState( + cycle_number=a["cycle_number"], + exercise_name=a["exercise_name"], + weight_kg=a.get("weight_kg"), + locked=bool(a.get("locked")), + pending_weight_kg=a.get("pending_weight_kg"), + ) + ) + + for w in data.get("workouts", []): + workout = Workout( + cycle_number=w["cycle_number"], + week_number=w["week_number"], + day_index=w["day_index"], + scheduled_date=datetime.fromisoformat(w["scheduled_date"]).date(), + status=w.get("status") or "planned", + started_at=datetime.fromisoformat(w["started_at"]) if w.get("started_at") else None, + completed_at=datetime.fromisoformat(w["completed_at"]) if w.get("completed_at") else None, + current_exercise_index=w.get("current_exercise_index") or 0, + current_set_index=w.get("current_set_index") or 0, + ) + db.add(workout) + db.flush() + for e in w.get("exercises", []): + ex = WorkoutExercise( + workout_id=workout.id, + order_index=e["order_index"], + name=e["name"], + kind=e["kind"], + prescribed_weight_kg=e.get("prescribed_weight_kg"), + weight_locked=bool(e.get("weight_locked")), + needs_weight_input=bool(e.get("needs_weight_input")), + pending_prompt=e.get("pending_prompt"), + ) + db.add(ex) + db.flush() + for s in e.get("sets", []): + db.add( + WorkoutSet( + exercise_id=ex.id, + set_index=s["set_index"], + target_percent=s.get("target_percent"), + target_reps=s.get("target_reps"), + target_weight_kg=s.get("target_weight_kg"), + is_failure=bool(s.get("is_failure")), + completed=bool(s.get("completed")), + actual_reps=s.get("actual_reps"), + actual_weight_kg=s.get("actual_weight_kg"), + completed_at=datetime.fromisoformat(s["completed_at"]) + if s.get("completed_at") + else None, + ) + ) + db.commit() + return MessageResponse(message="Данные импортированы") + + +@router.post("/reset", response_model=MessageResponse) +def reset_all(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> MessageResponse: + db.query(Workout).delete() + db.query(AccessoryState).delete() + profile = get_or_create_profile(db) + profile.one_rm_kg = None + profile.training_weekdays = None + profile.start_date = None + profile.onboarded = False + profile.phase = "active" + profile.rest_until = None + profile.probe_available_at = None + profile.cycle_number = 1 + db.commit() + return MessageResponse(message="Прогресс удалён. Пройдите онбординг заново.") diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..ca7ad9b --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from datetime import date, datetime +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field, field_validator + + +Weekday = Literal[0, 1, 2, 3, 4, 5, 6] # Mon=0 .. Sun=6 + + +class SetupPasswordRequest(BaseModel): + password: str = Field(min_length=4, max_length=128) + + +class LoginRequest(BaseModel): + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + needs_setup: bool = False + onboarded: bool = False + + +class StatusResponse(BaseModel): + has_password: bool + onboarded: bool + phase: str = "active" + + +class OnboardingRequest(BaseModel): + one_rm_kg: float = Field(gt=0) + training_weekdays: list[int] = Field(min_length=3, max_length=3) + start_date: date + + @field_validator("training_weekdays") + @classmethod + def validate_days(cls, v: list[int]) -> list[int]: + if len(set(v)) != 3: + raise ValueError("Нужно выбрать 3 разных дня") + for d in v: + if d < 0 or d > 6: + raise ValueError("День недели должен быть 0–6") + sorted_days = sorted(v) + # check circular gap of at least 1 day between consecutive selected days + for i in range(3): + a = sorted_days[i] + b = sorted_days[(i + 1) % 3] + if i < 2: + gap = b - a - 1 + else: + gap = (a + 7) - b - 1 + if gap < 1: + raise ValueError("Между выбранными днями нужен минимум 1 день перерыва") + return sorted(v) + + +class ProfileResponse(BaseModel): + one_rm_kg: Optional[float] + training_weekdays: Optional[list[int]] + start_date: Optional[date] + onboarded: bool + phase: str + rest_until: Optional[datetime] + probe_available_at: Optional[datetime] + cycle_number: int + rest_countdown_seconds: Optional[int] = None + can_probe: bool = False + + +class SetResponse(BaseModel): + id: int + set_index: int + target_percent: Optional[float] + target_reps: Optional[int] + target_weight_kg: Optional[float] + is_failure: bool + completed: bool + actual_reps: Optional[int] + actual_weight_kg: Optional[float] + + +class ExerciseResponse(BaseModel): + id: int + order_index: int + name: str + kind: str + prescribed_weight_kg: Optional[float] + weight_locked: bool + needs_weight_input: bool + pending_prompt: Optional[str] + weight_step_kg: float = 1.25 + sets: list[SetResponse] + + +class WorkoutSummary(BaseModel): + id: int + cycle_number: int + week_number: int + day_index: int + scheduled_date: date + status: str + exercise_count: int = 0 + + +class WorkoutDetail(BaseModel): + id: int + cycle_number: int + week_number: int + day_index: int + scheduled_date: date + status: str + started_at: Optional[datetime] + paused_at: Optional[datetime] + completed_at: Optional[datetime] + current_exercise_index: int + current_set_index: int + rest_started_at: Optional[datetime] + rest_elapsed_seconds: int = 0 + can_start: bool = False + can_resume: bool = False + pause_expires_at: Optional[datetime] = None + exercises: list[ExerciseResponse] + + +class CalendarResponse(BaseModel): + year: int + month: int + workouts: list[WorkoutSummary] + profile: ProfileResponse + + +class CompleteSetRequest(BaseModel): + actual_reps: Optional[int] = None + actual_weight_kg: Optional[float] = None + + +class SetWeightRequest(BaseModel): + weight_kg: float = Field(gt=0) + + +class AdjustSetRequest(BaseModel): + actual_weight_kg: float = Field(gt=0) + actual_reps: int = Field(ge=0) + + +class PromptChoiceRequest(BaseModel): + choice: Literal["easy_yes", "easy_no"] + weight_kg: Optional[float] = None + increase_kg: Optional[float] = None + decrease_kg: Optional[float] = None + + +class StartRestRequest(BaseModel): + rest_days: int = Field(ge=3, le=5) + + +class ProbeRequest(BaseModel): + one_rm_kg: float = Field(gt=0) + start_date: date + training_weekdays: Optional[list[int]] = None + + +class ExportResponse(BaseModel): + version: int = 1 + exported_at: datetime + data: dict[str, Any] + + +class ImportRequest(BaseModel): + version: int + data: dict[str, Any] + + +class MessageResponse(BaseModel): + message: str + workout: Optional[WorkoutDetail] = None + profile: Optional[ProfileResponse] = None + + +class MoveWorkoutRequest(BaseModel): + scheduled_date: date + + +class TrainingDaysRequest(BaseModel): + training_weekdays: list[int] = Field(min_length=3, max_length=3) + + @field_validator("training_weekdays") + @classmethod + def validate_days(cls, v: list[int]) -> list[int]: + return OnboardingRequest.validate_days(v) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..d5b2112 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Optional + +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from app.config import settings +from app.models import Profile, User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + return pwd_context.verify(password, password_hash) + + +def create_access_token(subject: str = "user") -> str: + expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes) + return jwt.encode( + {"sub": subject, "exp": expire}, + settings.secret_key, + algorithm="HS256", + ) + + +def decode_token(token: str) -> Optional[str]: + try: + payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) + return payload.get("sub") + except JWTError: + return None + + +def get_or_create_profile(db: Session) -> Profile: + profile = db.query(Profile).first() + if not profile: + profile = Profile() + db.add(profile) + db.commit() + db.refresh(profile) + return profile + + +def has_password(db: Session) -> bool: + return db.query(User).first() is not None + + +def get_user(db: Session) -> Optional[User]: + return db.query(User).first() diff --git a/backend/app/services/program.py b/backend/app/services/program.py new file mode 100644 index 0000000..85be848 --- /dev/null +++ b/backend/app/services/program.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + + +PROGRAM_PATH = Path(__file__).resolve().parent.parent / "data" / "program.json" + + +@lru_cache(maxsize=1) +def load_program() -> dict[str, Any]: + with PROGRAM_PATH.open(encoding="utf-8") as f: + return json.load(f) + + +def get_week(week_number: int) -> dict[str, Any]: + program = load_program() + for week in program["weeks"]: + if week["week"] == week_number: + return week + raise ValueError(f"Неделя {week_number} не найдена") diff --git a/backend/app/services/reschedule.py b/backend/app/services/reschedule.py new file mode 100644 index 0000000..86297c7 --- /dev/null +++ b/backend/app/services/reschedule.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from datetime import date, timedelta + +from fastapi import HTTPException + + +def _add(d: date, days: int) -> date: + return d + timedelta(days=days) + + +def _step(d: date, weekdays: list[int], direction: int) -> date: + selected = set(weekdays) + cursor = _add(d, direction) + for _ in range(28): + if cursor.weekday() in selected: + return cursor + cursor = _add(cursor, direction) + raise HTTPException(400, "Не удалось найти тренировочный день") + + +def _on_or_after(d: date, weekdays: list[int]) -> date: + if d.weekday() in set(weekdays): + return d + return _step(_add(d, -1), weekdays, 1) + + +def _order(week: int, day_index: int) -> int: + return week * 10 + day_index + + +def _next_training(after: date, weekdays: list[int], taken: set[date]) -> date: + value = _step(after, weekdays, 1) + while value in taken: + value = _step(value, weekdays, 1) + return value + + +def _place(desired: date, previous: date | None, weekdays: list[int], taken: set[date]) -> date: + value = desired + if previous is not None and value <= previous: + value = _next_training(previous, weekdays, taken) + while value in taken: + value = _next_training(value, weekdays, taken) + return value + + +def plan_move(workouts: list, moved_id: int, new_date: date, weekdays: list[int]) -> dict[int, date]: + moved = next((w for w in workouts if w.id == moved_id), None) + if moved is None: + raise HTTPException(404, "Тренировка не найдена") + if moved.status == "completed": + raise HTTPException(400, "Выполненную тренировку переносить нельзя") + if new_date < date.today(): + raise HTTPException(400, "В закрытый период переносить нельзя") + + sorted_items = sorted(workouts, key=lambda w: (_order(w.week_number, w.day_index), w.id)) + completed = [w for w in sorted_items if w.status == "completed"] + if any(w.scheduled_date == new_date for w in completed): + raise HTTPException(400, "На эту дату уже есть выполненная тренировка") + + moved_order = _order(moved.week_number, moved.day_index) + earlier = [w for w in completed if _order(w.week_number, w.day_index) < moved_order] + later = [w for w in completed if _order(w.week_number, w.day_index) > moved_order] + if earlier and new_date <= earlier[-1].scheduled_date: + raise HTTPException(400, "Дата должна быть позже уже выполненной тренировки") + if later and new_date >= later[0].scheduled_date: + raise HTTPException(400, "Дата должна быть раньше уже выполненной следующей тренировки") + + taken = {w.scheduled_date for w in completed} + assigned: dict[int, date] = {} + index = 0 + while index < len(sorted_items): + if sorted_items[index].status == "completed": + index += 1 + continue + start = index + while index < len(sorted_items) and sorted_items[index].status != "completed": + index += 1 + segment = sorted_items[start:index] + prev_anchor = sorted_items[start - 1] if start > 0 else None + next_anchor = sorted_items[index] if index < len(sorted_items) else None + proposed = sorted(new_date if item.id == moved_id else item.scheduled_date for item in segment) + dates: list[date] = [] + for raw in proposed: + value = _place(raw, dates[-1] if dates else None, weekdays, taken) + if prev_anchor is not None and value <= prev_anchor.scheduled_date: + raise HTTPException(400, "Дата должна быть позже уже выполненной тренировки") + if next_anchor is not None and value >= next_anchor.scheduled_date: + raise HTTPException(400, "Дата должна быть раньше уже выполненной следующей тренировки") + dates.append(value) + taken.add(value) + for item, value in zip(segment, dates): + assigned[item.id] = value + + return assigned + + +def plan_weekday_change(workouts: list, weekdays: list[int], start_from: date) -> dict[int, date]: + open_items = sorted( + (w for w in workouts if w.status != "completed"), + key=lambda w: _order(w.week_number, w.day_index), + ) + taken = {w.scheduled_date for w in workouts if w.status == "completed"} + assigned: dict[int, date] = {} + cursor = start_from + for item in open_items: + date_value = _on_or_after(cursor, weekdays) + while date_value in taken: + date_value = _step(date_value, weekdays, 1) + assigned[item.id] = date_value + taken.add(date_value) + cursor = _add(date_value, 1) + return assigned diff --git a/backend/app/services/schedule.py b/backend/app/services/schedule.py new file mode 100644 index 0000000..de88401 --- /dev/null +++ b/backend/app/services/schedule.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from datetime import date, timedelta + +from sqlalchemy.orm import Session + +from app.models import AccessoryState, Workout, WorkoutExercise, WorkoutSet +from app.services.program import load_program +from app.services.weights import bench_weight + + +WEEKDAY_NAMES = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + + +def next_dates_for_weekdays(start: date, weekdays: list[int], count: int) -> list[date]: + """Return `count` dates on selected weekdays starting from start (inclusive if matches).""" + selected = set(weekdays) + dates: list[date] = [] + cursor = start + # safety limit + for _ in range(400): + if cursor.weekday() in selected: + dates.append(cursor) + if len(dates) >= count: + break + cursor += timedelta(days=1) + if len(dates) < count: + raise ValueError("Не удалось построить расписание") + return dates + + +def clear_cycle_workouts(db: Session, cycle_number: int) -> None: + workouts = db.query(Workout).filter(Workout.cycle_number == cycle_number).all() + for w in workouts: + db.delete(w) + db.query(AccessoryState).filter(AccessoryState.cycle_number == cycle_number).delete() + db.flush() + + +def generate_schedule( + db: Session, + *, + one_rm_kg: float, + training_weekdays: list[int], + start_date: date, + cycle_number: int, +) -> list[Workout]: + program = load_program() + total_days = sum(len(w["days"]) for w in program["weeks"]) + dates = next_dates_for_weekdays(start_date, training_weekdays, total_days) + + # Map chronological day_index within week order from template (day 0,1,2 of each week) + date_idx = 0 + created: list[Workout] = [] + + for week in program["weeks"]: + for day_index, day in enumerate(week["days"]): + scheduled = dates[date_idx] + date_idx += 1 + workout = Workout( + cycle_number=cycle_number, + week_number=week["week"], + day_index=day_index, + scheduled_date=scheduled, + status="planned", + ) + db.add(workout) + db.flush() + + for order_index, ex in enumerate(day["exercises"]): + kind = ex["kind"] + accessory_state = None + needs_weight = False + prescribed = None + locked = False + + if kind == "accessory": + accessory_state = ( + db.query(AccessoryState) + .filter( + AccessoryState.cycle_number == cycle_number, + AccessoryState.exercise_name == ex["name"], + ) + .first() + ) + if accessory_state and accessory_state.locked and accessory_state.weight_kg is not None: + prescribed = accessory_state.weight_kg + locked = True + elif accessory_state and accessory_state.pending_weight_kg is not None: + prescribed = accessory_state.pending_weight_kg + needs_weight = False + elif accessory_state and accessory_state.weight_kg is not None and not accessory_state.locked: + prescribed = accessory_state.weight_kg + needs_weight = False + else: + needs_weight = True + + we = WorkoutExercise( + workout_id=workout.id, + order_index=order_index, + name=ex["name"], + kind=kind, + prescribed_weight_kg=prescribed, + weight_locked=locked, + needs_weight_input=needs_weight, + ) + db.add(we) + db.flush() + + for set_index, s in enumerate(ex["sets"]): + if kind == "bench": + tw = bench_weight(one_rm_kg, s["percent"]) + ws = WorkoutSet( + exercise_id=we.id, + set_index=set_index, + target_percent=s["percent"], + target_reps=s["reps"], + target_weight_kg=tw, + is_failure=False, + ) + elif kind == "failure": + ws = WorkoutSet( + exercise_id=we.id, + set_index=set_index, + target_reps=None, + target_weight_kg=None, + is_failure=True, + ) + else: + reps = s.get("reps") if isinstance(s, dict) else None + ws = WorkoutSet( + exercise_id=we.id, + set_index=set_index, + target_reps=reps, + target_weight_kg=prescribed, + is_failure=False, + ) + db.add(ws) + + created.append(workout) + + db.flush() + return created + + +def apply_accessory_weight_to_future( + db: Session, + *, + cycle_number: int, + exercise_name: str, + weight_kg: float, + from_date: date, + lock: bool, +) -> None: + state = ( + db.query(AccessoryState) + .filter( + AccessoryState.cycle_number == cycle_number, + AccessoryState.exercise_name == exercise_name, + ) + .first() + ) + if not state: + state = AccessoryState(cycle_number=cycle_number, exercise_name=exercise_name) + db.add(state) + + if lock: + state.weight_kg = weight_kg + state.locked = True + state.pending_weight_kg = None + else: + state.pending_weight_kg = weight_kg + state.weight_kg = weight_kg + state.locked = False + + # Update future workouts' exercises of same name + future = ( + db.query(WorkoutExercise) + .join(Workout) + .filter( + Workout.cycle_number == cycle_number, + Workout.scheduled_date >= from_date, + Workout.status != "completed", + WorkoutExercise.name == exercise_name, + WorkoutExercise.kind == "accessory", + ) + .all() + ) + for we in future: + if we.sets and all(s.completed for s in we.sets): + continue + we.prescribed_weight_kg = weight_kg + we.weight_locked = lock + we.needs_weight_input = False + for s in we.sets: + if not s.completed: + s.target_weight_kg = weight_kg diff --git a/backend/app/services/weights.py b/backend/app/services/weights.py new file mode 100644 index 0000000..4b1a27f --- /dev/null +++ b/backend/app/services/weights.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from app.config import settings + +# Шаг корректировки/ввода веса вспомогательных (прибавка и снижение). +ACCESSORY_STEP_KG = 0.5 + + +def weight_step_for(exercise_name: str | None = None, *, kind: str | None = None) -> float: + """Жим — блины (config). Вспомогательные — 0.5 кг.""" + if kind == "bench" or (exercise_name == "Жим"): + return settings.plate_step_kg + if kind in ("accessory", "failure"): + return ACCESSORY_STEP_KG + if exercise_name: + return ACCESSORY_STEP_KG + return settings.plate_step_kg + + +def round_to_plate(weight_kg: float, step: float | None = None) -> float: + step = step if step is not None else settings.plate_step_kg + if weight_kg <= 0: + return 0.0 + return round(round(weight_kg / step) * step, 2) + + +def round_exercise_weight(weight_kg: float, exercise_name: str, *, kind: str | None = None) -> float: + return round_to_plate(weight_kg, weight_step_for(exercise_name, kind=kind)) + + +def bench_weight(one_rm_kg: float, percent: float) -> float: + """Жим: округление как раньше — по plate_step_kg (1.25).""" + return round_to_plate(one_rm_kg * (percent / 100.0), settings.plate_step_kg) diff --git a/backend/app/services/workout.py b/backend/app/services/workout.py new file mode 100644 index 0000000..b9900db --- /dev/null +++ b/backend/app/services/workout.py @@ -0,0 +1,589 @@ +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Optional + +from fastapi import HTTPException +from sqlalchemy.orm import Session, joinedload + +from app.config import settings +from app.models import AccessoryState, Profile, Workout, WorkoutExercise, WorkoutSet +from app.schemas import ( + ExerciseResponse, + ProfileResponse, + SetResponse, + WorkoutDetail, + WorkoutSummary, +) +from app.services.schedule import apply_accessory_weight_to_future, generate_schedule +from app.services.weights import round_exercise_weight, round_to_plate, weight_step_for + + + +def profile_response(profile: Profile, now: Optional[datetime] = None) -> ProfileResponse: + now = now or datetime.utcnow() + rest_countdown = None + can_probe = False + if profile.phase == "rest" and profile.probe_available_at: + delta = (profile.probe_available_at - now).total_seconds() + if delta > 0: + rest_countdown = int(delta) + else: + can_probe = True + elif profile.phase == "probe": + can_probe = True + return ProfileResponse( + one_rm_kg=profile.one_rm_kg, + training_weekdays=profile.training_weekdays, + start_date=profile.start_date, + onboarded=profile.onboarded, + phase=profile.phase if not (profile.phase == "rest" and can_probe) else "probe", + rest_until=profile.rest_until, + probe_available_at=profile.probe_available_at, + cycle_number=profile.cycle_number, + rest_countdown_seconds=rest_countdown, + can_probe=can_probe, + ) + + +def set_response(s: WorkoutSet) -> SetResponse: + return SetResponse( + id=s.id, + set_index=s.set_index, + target_percent=s.target_percent, + target_reps=s.target_reps, + target_weight_kg=s.target_weight_kg, + is_failure=s.is_failure, + completed=s.completed, + actual_reps=s.actual_reps, + actual_weight_kg=s.actual_weight_kg, + ) + + +def exercise_response(ex: WorkoutExercise) -> ExerciseResponse: + return ExerciseResponse( + id=ex.id, + order_index=ex.order_index, + name=ex.name, + kind=ex.kind, + prescribed_weight_kg=ex.prescribed_weight_kg, + weight_locked=ex.weight_locked, + needs_weight_input=ex.needs_weight_input, + pending_prompt=ex.pending_prompt, + weight_step_kg=weight_step_for(ex.name, kind=ex.kind), + sets=[set_response(s) for s in sorted(ex.sets, key=lambda x: x.set_index)], + ) + + +def workout_summary(w: Workout) -> WorkoutSummary: + return WorkoutSummary( + id=w.id, + cycle_number=w.cycle_number, + week_number=w.week_number, + day_index=w.day_index, + scheduled_date=w.scheduled_date, + status=w.status, + exercise_count=len(w.exercises) if w.exercises is not None else 0, + ) + + +def maybe_expire_pause(db: Session, workout: Workout, now: Optional[datetime] = None) -> Workout: + now = now or datetime.utcnow() + if workout.status == "paused" and workout.paused_at: + limit = workout.paused_at + timedelta(minutes=settings.pause_limit_minutes) + if now > limit: + workout.status = "abandoned" + workout.paused_at = None + workout.rest_started_at = None + db.commit() + db.refresh(workout) + return workout + + +def workout_detail(db: Session, workout: Workout, today: Optional[date] = None) -> WorkoutDetail: + today = today or date.today() + now = datetime.utcnow() + workout = maybe_expire_pause(db, workout, now) + + active = ( + db.query(Workout) + .filter( + Workout.cycle_number == workout.cycle_number, + Workout.id != workout.id, + Workout.status.in_(["in_progress", "paused"]), + ) + .first() + ) + can_start = workout.status == "planned" and workout.scheduled_date == today and active is None + + can_resume = False + pause_expires_at = None + if workout.status == "paused" and workout.paused_at: + pause_expires_at = workout.paused_at + timedelta(minutes=settings.pause_limit_minutes) + can_resume = now <= pause_expires_at and workout.scheduled_date == today + + rest_elapsed = 0 + if workout.status == "in_progress" and workout.rest_started_at: + rest_elapsed = max(0, int((now - workout.rest_started_at).total_seconds())) + + return WorkoutDetail( + id=workout.id, + cycle_number=workout.cycle_number, + week_number=workout.week_number, + day_index=workout.day_index, + scheduled_date=workout.scheduled_date, + status=workout.status, + started_at=workout.started_at, + paused_at=workout.paused_at, + completed_at=workout.completed_at, + current_exercise_index=workout.current_exercise_index, + current_set_index=workout.current_set_index, + rest_started_at=workout.rest_started_at, + rest_elapsed_seconds=rest_elapsed, + can_start=can_start, + can_resume=can_resume, + pause_expires_at=pause_expires_at, + exercises=[exercise_response(e) for e in sorted(workout.exercises, key=lambda x: x.order_index)], + ) + + +def load_workout(db: Session, workout_id: int) -> Workout: + def _fetch() -> Workout | None: + return ( + db.query(Workout) + .options(joinedload(Workout.exercises).joinedload(WorkoutExercise.sets)) + .filter(Workout.id == workout_id) + .first() + ) + + workout = _fetch() + if not workout: + raise HTTPException(404, "Тренировка не найдена") + if sync_accessory_weights(db, workout): + db.commit() + workout = _fetch() + assert workout is not None + return workout + + +def sync_accessory_weights(db: Session, workout: Workout) -> bool: + """Подтянуть сохранённый вес каждого вспомогательного из AccessoryState.""" + changed = False + for ex in workout.exercises: + if ex.kind != "accessory": + continue + if any(s.completed for s in ex.sets): + continue + state = ( + db.query(AccessoryState) + .filter( + AccessoryState.cycle_number == workout.cycle_number, + AccessoryState.exercise_name == ex.name, + ) + .first() + ) + if not state: + continue + weight = state.pending_weight_kg if state.pending_weight_kg is not None else state.weight_kg + if weight is None: + continue + if ( + ex.prescribed_weight_kg != weight + or ex.needs_weight_input + or ex.weight_locked != bool(state.locked) + ): + ex.prescribed_weight_kg = weight + ex.needs_weight_input = False + ex.weight_locked = bool(state.locked) + for s in ex.sets: + if not s.completed: + s.target_weight_kg = weight + changed = True + return changed + + +def start_workout(db: Session, workout: Workout, today: Optional[date] = None) -> Workout: + today = today or date.today() + if workout.scheduled_date != today: + raise HTTPException(400, "Начать можно только сегодняшнюю тренировку") + if workout.status not in ("planned",): + raise HTTPException(400, "Тренировку нельзя начать") + active = ( + db.query(Workout) + .filter( + Workout.cycle_number == workout.cycle_number, + Workout.status.in_(["in_progress", "paused"]), + ) + .first() + ) + if active: + raise HTTPException(400, "Уже есть активная тренировка") + workout.status = "in_progress" + workout.started_at = datetime.utcnow() + workout.current_exercise_index = 0 + workout.current_set_index = 0 + db.commit() + db.refresh(workout) + return load_workout(db, workout.id) + + +def pause_workout(db: Session, workout: Workout) -> Workout: + if workout.status != "in_progress": + raise HTTPException(400, "Пауза доступна только во время тренировки") + workout.status = "paused" + workout.paused_at = datetime.utcnow() + workout.rest_started_at = None + db.commit() + return load_workout(db, workout.id) + + +def resume_workout(db: Session, workout: Workout, today: Optional[date] = None) -> Workout: + today = today or date.today() + workout = maybe_expire_pause(db, workout) + if workout.status == "abandoned": + raise HTTPException(400, "Тренировка остановлена: пауза дольше 15 минут") + if workout.status != "paused": + raise HTTPException(400, "Тренировка не на паузе") + if workout.scheduled_date != today: + raise HTTPException(400, "Продолжить можно только в тот же день") + assert workout.paused_at + if datetime.utcnow() > workout.paused_at + timedelta(minutes=settings.pause_limit_minutes): + workout.status = "abandoned" + db.commit() + raise HTTPException(400, "Тренировка остановлена: пауза дольше 15 минут") + workout.status = "in_progress" + workout.paused_at = None + db.commit() + return load_workout(db, workout.id) + + +def set_exercise_weight(db: Session, workout: Workout, exercise: WorkoutExercise, weight_kg: float) -> Workout: + if exercise.kind != "accessory": + raise HTTPException(400, "Вес задаётся только для вспомогательных") + if workout.status == "completed": + raise HTTPException(400, "Выполненную тренировку менять нельзя") + if exercise.sets and all(s.completed for s in exercise.sets): + raise HTTPException(400, "Это упражнение уже выполнено") + weight = round_exercise_weight(weight_kg, exercise.name, kind=exercise.kind) + exercise.prescribed_weight_kg = weight + exercise.needs_weight_input = False + for s in exercise.sets: + if not s.completed: + s.target_weight_kg = weight + + apply_accessory_weight_to_future( + db, + cycle_number=workout.cycle_number, + exercise_name=exercise.name, + weight_kg=weight, + from_date=workout.scheduled_date, + lock=False, + ) + db.commit() + return load_workout(db, workout.id) + + +def adjust_completed_set( + db: Session, + workout: Workout, + exercise: WorkoutExercise, + workout_set: WorkoutSet, + *, + actual_weight_kg: float, + actual_reps: int, +) -> Workout: + if exercise.kind != "accessory": + raise HTTPException(400, "Править можно только вспомогательные") + if not workout_set.completed: + raise HTTPException(400, "Можно править только выполненный подход") + workout_set.actual_weight_kg = round_exercise_weight(actual_weight_kg, exercise.name, kind=exercise.kind) + workout_set.actual_reps = actual_reps + db.commit() + return load_workout(db, workout.id) + + +def _advance_pointer(workout: Workout, exercises: list[WorkoutExercise]) -> bool: + """Advance to next incomplete set. Returns True if workout completed.""" + for ei, ex in enumerate(exercises): + for s in sorted(ex.sets, key=lambda x: x.set_index): + if not s.completed: + workout.current_exercise_index = ei + workout.current_set_index = s.set_index + return False + return True + + +def _analyze_accessory(db: Session, workout: Workout, exercise: WorkoutExercise) -> None: + if exercise.kind != "accessory": + return + if exercise.weight_locked: + return + if any(not s.completed for s in exercise.sets): + return + + all_hit = all( + (s.actual_reps or 0) >= (s.target_reps or 0) for s in exercise.sets if s.target_reps is not None + ) + if not all_hit: + exercise.pending_prompt = "choose_weight_missed" + else: + exercise.pending_prompt = "confirm_easy" + + +def complete_set( + db: Session, + workout: Workout, + *, + actual_reps: Optional[int] = None, + actual_weight_kg: Optional[float] = None, +) -> Workout: + if workout.status != "in_progress": + raise HTTPException(400, "Тренировка не активна") + + exercises = sorted(workout.exercises, key=lambda x: x.order_index) + if workout.current_exercise_index >= len(exercises): + raise HTTPException(400, "Нет текущего упражнения") + + exercise = exercises[workout.current_exercise_index] + if exercise.pending_prompt: + raise HTTPException(400, "Сначала ответьте на вопрос по упражнению") + if exercise.kind == "accessory" and exercise.needs_weight_input: + raise HTTPException(400, "Сначала введите рабочий вес") + + sets = sorted(exercise.sets, key=lambda x: x.set_index) + current = next((s for s in sets if s.set_index == workout.current_set_index), None) + if not current or current.completed: + # find first incomplete + current = next((s for s in sets if not s.completed), None) + if not current: + raise HTTPException(400, "Нет активного подхода") + + if exercise.kind == "bench" or exercise.kind == "failure": + current.completed = True + current.completed_at = datetime.utcnow() + current.actual_weight_kg = current.target_weight_kg + current.actual_reps = current.target_reps + else: + if actual_reps is None: + raise HTTPException(400, "Укажите выполненные повторения") + weight = actual_weight_kg if actual_weight_kg is not None else exercise.prescribed_weight_kg + if weight is None: + raise HTTPException(400, "Не задан вес") + current.completed = True + current.completed_at = datetime.utcnow() + current.actual_reps = actual_reps + current.actual_weight_kg = round_exercise_weight(weight, exercise.name, kind=exercise.kind) + + # Check if exercise finished → maybe prompt + if all(s.completed for s in sets): + _analyze_accessory(db, workout, exercise) + if exercise.pending_prompt: + workout.rest_started_at = None + db.commit() + return load_workout(db, workout.id) + + done = _advance_pointer(workout, exercises) + if done: + workout.status = "completed" + workout.completed_at = datetime.utcnow() + workout.rest_started_at = None + _maybe_finish_cycle(db, workout) + else: + workout.rest_started_at = datetime.utcnow() + + db.commit() + return load_workout(db, workout.id) + + +def resolve_prompt( + db: Session, + workout: Workout, + exercise: WorkoutExercise, + *, + choice: str, + weight_kg: Optional[float] = None, + increase_kg: Optional[float] = None, + decrease_kg: Optional[float] = None, +) -> Workout: + if workout.status != "in_progress": + raise HTTPException(400, "Тренировка не активна") + if not exercise.pending_prompt: + raise HTTPException(400, "Нет активного вопроса") + + prompt = exercise.pending_prompt + today = workout.scheduled_date + current_weight = exercise.prescribed_weight_kg + step = weight_step_for(exercise.name, kind=exercise.kind) + + if prompt == "confirm_easy": + if choice == "easy_no": + if current_weight is None: + raise HTTPException(400, "Вес не задан") + apply_accessory_weight_to_future( + db, + cycle_number=workout.cycle_number, + exercise_name=exercise.name, + weight_kg=current_weight, + from_date=today, + lock=True, + ) + exercise.weight_locked = True + exercise.pending_prompt = None + elif choice == "easy_yes": + exercise.pending_prompt = "choose_weight_easy" + db.commit() + return load_workout(db, workout.id) + else: + raise HTTPException(400, "Неверный выбор") + + elif prompt == "choose_weight_easy": + if current_weight is None: + raise HTTPException(400, "Текущий вес не задан") + if increase_kg is None or increase_kg <= 0: + raise HTTPException(400, "Укажите, на сколько поднять вес") + bump = round_to_plate(increase_kg, step) + if bump < step: + bump = step + weight = round_to_plate(current_weight + bump, step) + if weight <= current_weight: + weight = round_to_plate(current_weight + step, step) + apply_accessory_weight_to_future( + db, + cycle_number=workout.cycle_number, + exercise_name=exercise.name, + weight_kg=weight, + from_date=today + timedelta(days=1), + lock=False, + ) + exercise.pending_prompt = None + + elif prompt == "choose_weight_missed": + if current_weight is None: + raise HTTPException(400, "Текущий вес не задан") + if decrease_kg is None or decrease_kg <= 0: + raise HTTPException(400, "Укажите, на сколько снизить вес") + drop = round_to_plate(decrease_kg, step) + if drop < step: + drop = step + weight = round_to_plate(current_weight - drop, step) + if weight >= current_weight: + weight = round_to_plate(current_weight - step, step) + if weight < step: + weight = step + apply_accessory_weight_to_future( + db, + cycle_number=workout.cycle_number, + exercise_name=exercise.name, + weight_kg=weight, + from_date=today + timedelta(days=1), + lock=False, + ) + exercise.pending_prompt = None + else: + raise HTTPException(400, "Неизвестный вопрос") + + exercises = sorted(workout.exercises, key=lambda x: x.order_index) + done = _advance_pointer(workout, exercises) + if done: + workout.status = "completed" + workout.completed_at = datetime.utcnow() + workout.rest_started_at = None + _maybe_finish_cycle(db, workout) + else: + workout.rest_started_at = datetime.utcnow() + + db.commit() + return load_workout(db, workout.id) + + +def _maybe_finish_cycle(db: Session, workout: Workout) -> None: + remaining = ( + db.query(Workout) + .filter( + Workout.cycle_number == workout.cycle_number, + Workout.status.in_(["planned", "in_progress", "paused"]), + Workout.id != workout.id, + ) + .count() + ) + # also count if this one not yet marked - it's being completed + unfinished_others = ( + db.query(Workout) + .filter( + Workout.cycle_number == workout.cycle_number, + Workout.id != workout.id, + Workout.status.notin_(["completed", "abandoned"]), + ) + .count() + ) + if unfinished_others == 0: + profile = db.query(Profile).first() + if profile: + profile.phase = "rest" + # wait for user to choose rest days via API; temporarily set probe far + profile.probe_available_at = None + profile.rest_until = None + + +def start_rest_phase(db: Session, profile: Profile, rest_days: int) -> Profile: + if profile.phase not in ("rest", "probe"): + # allow if cycle just finished + unfinished = ( + db.query(Workout) + .filter( + Workout.cycle_number == profile.cycle_number, + Workout.status.notin_(["completed", "abandoned"]), + ) + .count() + ) + if unfinished > 0 and profile.phase == "active": + raise HTTPException(400, "Цикл ещё не завершён") + if rest_days < settings.rest_min_days or rest_days > settings.rest_max_days: + raise HTTPException(400, f"Отдых от {settings.rest_min_days} до {settings.rest_max_days} дней") + now = datetime.utcnow() + profile.phase = "rest" + profile.probe_available_at = now + timedelta(days=rest_days) + profile.rest_until = profile.probe_available_at + db.commit() + db.refresh(profile) + return profile + + +def submit_probe( + db: Session, + profile: Profile, + *, + one_rm_kg: float, + start_date: date, + training_weekdays: Optional[list[int]] = None, +) -> Profile: + now = datetime.utcnow() + if profile.phase == "rest" and profile.probe_available_at and now < profile.probe_available_at: + raise HTTPException(400, "Ещё рано для проходки") + profile.phase = "probe" if profile.phase == "rest" else profile.phase + + weekdays = training_weekdays or profile.training_weekdays + if not weekdays: + raise HTTPException(400, "Не заданы дни тренировок") + + one_rm = round_to_plate(one_rm_kg) + new_cycle = profile.cycle_number + 1 + profile.one_rm_kg = one_rm + profile.training_weekdays = weekdays + profile.start_date = start_date + profile.cycle_number = new_cycle + profile.phase = "active" + profile.rest_until = None + profile.probe_available_at = None + profile.onboarded = True + + generate_schedule( + db, + one_rm_kg=one_rm, + training_weekdays=weekdays, + start_date=start_date, + cycle_number=new_cycle, + ) + db.commit() + db.refresh(profile) + return profile diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..5de0e99 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +psycopg[binary]==3.2.3 +pydantic==2.10.4 +pydantic-settings==2.7.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +python-jose[cryptography]==3.3.0 +python-multipart==0.0.20 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..daceb9f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: benchpress + POSTGRES_PASSWORD: benchpress + POSTGRES_DB: benchpress + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U benchpress -d benchpress"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: ./backend + environment: + DATABASE_URL: postgresql+psycopg://benchpress:benchpress@db:5432/benchpress + SECRET_KEY: change-me-in-production-benchpress-secret + CORS_ORIGINS: http://localhost:5173,http://localhost:3000,http://localhost,https://localhost,capacitor://localhost + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + + frontend: + build: ./frontend + ports: + - "3000:80" + depends_on: + - backend + +volumes: + pgdata: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..62b26e1 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.git +*.md diff --git a/frontend/.env.android b/frontend/.env.android new file mode 100644 index 0000000..8bbb542 --- /dev/null +++ b/frontend/.env.android @@ -0,0 +1,2 @@ +# Offline V2: данные на устройстве (IndexedDB), сервер не нужен. +VITE_OFFLINE=true diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/ANDROID.md b/frontend/ANDROID.md new file mode 100644 index 0000000..ccdfe47 --- /dev/null +++ b/frontend/ANDROID.md @@ -0,0 +1,32 @@ +# Сборка Android APK (Capacitor) — Offline V2 + +APK работает **без сервера**: PIN, программа и прогресс хранятся на устройстве (IndexedDB). + +## JDK для Gradle +Нужен **JDK 21** (не Java 25 из Android Studio). +Уже скачан в: `%USERPROFILE%\.jdks\temurin-21` + +```powershell +$env:JAVA_HOME = "$env:USERPROFILE\.jdks\temurin-21" +$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk" +``` + +## Сборка APK + +```powershell +cd C:\Dev\Benchpress\frontend +npm run cap:sync + +cd android +$env:JAVA_HOME = "$env:USERPROFILE\.jdks\temurin-21" +$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk" +.\gradlew.bat assembleDebug +``` + +APK: +`C:\Dev\Benchpress\frontend\android\app\build\outputs\apk\debug\app-debug.apk` + +`.env.android` содержит `VITE_OFFLINE=true` — сервер и `VITE_API_BASE_URL` не нужны. + +## Online-режим (опционально) +Уберите `VITE_OFFLINE` / поставьте `false` и задайте `VITE_API_BASE_URL`, затем снова `cap:sync` + сборка. Для этого нужен запущенный backend (`docker compose up -d`). diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b44f7ff --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/android/.gitignore b/frontend/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/frontend/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/frontend/android/app/.gitignore b/frontend/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/frontend/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/frontend/android/app/build.gradle b/frontend/android/app/build.gradle new file mode 100644 index 0000000..3683d10 --- /dev/null +++ b/frontend/android/app/build.gradle @@ -0,0 +1,54 @@ +apply plugin: 'com.android.application' + +android { + namespace = "com.benchpress.secret" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.benchpress.secret" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/frontend/android/app/capacitor.build.gradle b/frontend/android/app/capacitor.build.gradle new file mode 100644 index 0000000..bbfb44f --- /dev/null +++ b/frontend/android/app/capacitor.build.gradle @@ -0,0 +1,19 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/frontend/android/app/proguard-rules.pro b/frontend/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/frontend/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/frontend/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5962264 --- /dev/null +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/android/app/src/main/java/com/benchpress/secret/MainActivity.java b/frontend/android/app/src/main/java/com/benchpress/secret/MainActivity.java new file mode 100644 index 0000000..38527a8 --- /dev/null +++ b/frontend/android/app/src/main/java/com/benchpress/secret/MainActivity.java @@ -0,0 +1,15 @@ +package com.benchpress.secret; + +import android.os.Bundle; + +import androidx.core.view.WindowCompat; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + WindowCompat.setDecorFitsSystemWindows(getWindow(), true); + } +} diff --git a/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/frontend/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml b/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/android/app/src/main/res/drawable/splash.png b/frontend/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable/splash.png differ diff --git a/frontend/android/app/src/main/res/layout/activity_main.xml b/frontend/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/frontend/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..2777ed4 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..804df28 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..2777ed4 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d2157f8 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..03bf783 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..d2157f8 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e2bd1c9 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..03a82f4 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e2bd1c9 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..ecaddfe Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8f93710 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..ecaddfe Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..5871564 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ecb6baf Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..5871564 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/frontend/android/app/src/main/res/values/ic_launcher_background.xml b/frontend/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/frontend/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/values/strings.xml b/frontend/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..faa7838 --- /dev/null +++ b/frontend/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Benchpress Secret + Benchpress Secret + com.benchpress.secret + com.benchpress.secret + diff --git a/frontend/android/app/src/main/res/values/styles.xml b/frontend/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..d27b388 --- /dev/null +++ b/frontend/android/app/src/main/res/values/styles.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/xml/file_paths.xml b/frontend/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/frontend/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/xml/network_security_config.xml b/frontend/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..8e5a458 --- /dev/null +++ b/frontend/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,13 @@ + + + + + + + + + 10.0.2.2 + localhost + 127.0.0.1 + + diff --git a/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/frontend/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/frontend/android/build-apk.ps1 b/frontend/android/build-apk.ps1 new file mode 100644 index 0000000..bfd98a7 --- /dev/null +++ b/frontend/android/build-apk.ps1 @@ -0,0 +1,54 @@ +# Builds a debug APK with a JDK that Gradle 8.14 / AGP 8.13 can run. +# The Java on PATH is Java 8. Android Studio's bundled JBR is Java 25, +# which this Gradle version does not support. Temurin 21 is the right runtime. + +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot + +function Get-JavaMajor([string]$JdkDir) { + $release = Join-Path $JdkDir "release" + if (-not (Test-Path $release)) { return 0 } + $line = Select-String -Path $release -Pattern 'JAVA_VERSION="(\d+)' | Select-Object -First 1 + if (-not $line) { return 0 } + return [int]$line.Matches[0].Groups[1].Value +} + +function Find-Jdk { + $candidates = @( + "$env:USERPROFILE\.jdks\temurin-21", + "$env:USERPROFILE\.jdks\temurin-17", + ${env:JAVA_HOME} + ) | Where-Object { $_ } + + foreach ($dir in $candidates) { + $java = Join-Path $dir "bin\java.exe" + if (-not (Test-Path $java)) { continue } + $major = Get-JavaMajor $dir + if ($major -ge 17 -and $major -le 24) { + return $dir + } + } + return $null +} + +$jdk = Find-Jdk +if (-not $jdk) { + Write-Error @" +No JDK 17-21 found. +Install Temurin 21, or point JAVA_HOME at one. +Android Studio's bundled JBR is Java 25; Gradle 8.14.3 cannot use it. +The Java on PATH is Java 8; AGP 8.13 cannot use it either. +"@ +} + +$env:JAVA_HOME = $jdk +$env:PATH = "$(Join-Path $jdk 'bin');$env:PATH" + +Write-Host "JAVA_HOME=$env:JAVA_HOME" +& (Join-Path $PSScriptRoot "gradlew.bat") assembleDebug @args +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$apk = Join-Path $PSScriptRoot "app\build\outputs\apk\debug\app-debug.apk" +if (Test-Path $apk) { + Write-Host "APK: $apk" +} diff --git a/frontend/android/build.gradle b/frontend/android/build.gradle new file mode 100644 index 0000000..f8f0e43 --- /dev/null +++ b/frontend/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/frontend/android/capacitor.settings.gradle b/frontend/android/capacitor.settings.gradle new file mode 100644 index 0000000..9a5fa87 --- /dev/null +++ b/frontend/android/capacitor.settings.gradle @@ -0,0 +1,3 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') diff --git a/frontend/android/gradle.properties b/frontend/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/frontend/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.jar b/frontend/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/frontend/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.properties b/frontend/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7705927 --- /dev/null +++ b/frontend/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/frontend/android/gradlew b/frontend/android/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/frontend/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/frontend/android/gradlew.bat b/frontend/android/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/frontend/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/frontend/android/settings.gradle b/frontend/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/frontend/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/frontend/android/variables.gradle b/frontend/android/variables.gradle new file mode 100644 index 0000000..ee4ba41 --- /dev/null +++ b/frontend/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} \ No newline at end of file diff --git a/frontend/capacitor.config.ts b/frontend/capacitor.config.ts new file mode 100644 index 0000000..6fb9c3f --- /dev/null +++ b/frontend/capacitor.config.ts @@ -0,0 +1,15 @@ +import type { CapacitorConfig } from "@capacitor/cli"; + +const config: CapacitorConfig = { + appId: "com.benchpress.secret", + appName: "Benchpress Secret", + webDir: "dist", + server: { + androidScheme: "https", + }, + android: { + allowMixedContent: true, + }, +}; + +export default config; diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7963d73 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Benchpress Secret + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d8d079c --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ffc2ce5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2617 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@capacitor/android": "^8.5.2", + "@capacitor/core": "^8.5.2", + "dexie": "^4.4.6", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@capacitor/cli": "^8.5.2", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@capacitor/android": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz", + "integrity": "sha512-rtiaz78egCcS+vs29wkWCL3C+RhazKMSygXcjutwWP8NmO6r5UBxOvGf3Wjz5+ahobyQ723tn9gCePwRwcNDZQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.5.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz", + "integrity": "sha512-O2kpA5ZPJUAmliDCcmpMf8xnJwMezxlFPUrlyOAjbL3U9MFNY3l4ZpPI/UXwvyPmR8ETcVF9pFr+VdWq61rqTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xcode": "^3.0.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.2.tgz", + "integrity": "sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz", + "integrity": "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.87.0.tgz", + "integrity": "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.87.0.tgz", + "integrity": "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.87.0.tgz", + "integrity": "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.87.0.tgz", + "integrity": "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.87.0.tgz", + "integrity": "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.87.0.tgz", + "integrity": "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.87.0.tgz", + "integrity": "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.87.0.tgz", + "integrity": "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.87.0.tgz", + "integrity": "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.87.0.tgz", + "integrity": "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.87.0.tgz", + "integrity": "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.87.0.tgz", + "integrity": "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.87.0.tgz", + "integrity": "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.87.0.tgz", + "integrity": "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.87.0.tgz", + "integrity": "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.87.0.tgz", + "integrity": "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.87.0.tgz", + "integrity": "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.87.0.tgz", + "integrity": "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dexie": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.6.tgz", + "integrity": "sha512-hJP/BO6mjB+tX6hToIO1kmxYLNmun90wYbfcoAoLpKEyDYal/k33dg0TAfoUe2TDsbbLoVIzljJ3BN2UbR5EOg==", + "license": "Apache-2.0" + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oxlint": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz", + "integrity": "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.87.0", + "@oxlint/binding-android-arm64": "1.87.0", + "@oxlint/binding-darwin-arm64": "1.87.0", + "@oxlint/binding-darwin-x64": "1.87.0", + "@oxlint/binding-freebsd-x64": "1.87.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", + "@oxlint/binding-linux-arm-musleabihf": "1.87.0", + "@oxlint/binding-linux-arm64-gnu": "1.87.0", + "@oxlint/binding-linux-arm64-musl": "1.87.0", + "@oxlint/binding-linux-ppc64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-musl": "1.87.0", + "@oxlint/binding-linux-s390x-gnu": "1.87.0", + "@oxlint/binding-linux-x64-gnu": "1.87.0", + "@oxlint/binding-linux-x64-musl": "1.87.0", + "@oxlint/binding-openharmony-arm64": "1.87.0", + "@oxlint/binding-win32-arm64-msvc": "1.87.0", + "@oxlint/binding-win32-ia32-msvc": "1.87.0", + "@oxlint/binding-win32-x64-msvc": "1.87.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vite": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9d28bd6 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "build:android": "tsc -b && vite build --mode android", + "lint": "oxlint", + "preview": "vite preview", + "cap:sync": "npm run build:android && npx cap sync android", + "cap:open": "npx cap open android" + }, + "dependencies": { + "@capacitor/android": "^8.5.2", + "@capacitor/core": "^8.5.2", + "dexie": "^4.4.6", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@capacitor/cli": "^8.5.2", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..529a07b Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/scripts/build-apk.ps1 b/frontend/scripts/build-apk.ps1 new file mode 100644 index 0000000..3cf1f65 --- /dev/null +++ b/frontend/scripts/build-apk.ps1 @@ -0,0 +1,7 @@ +# Собирает debug APK. JDK подставляет android\build-apk.ps1 (Temurin 21). +# Запуск: powershell -ExecutionPolicy Bypass -File C:\Dev\Benchpress\frontend\scripts\build-apk.ps1 + +$ErrorActionPreference = "Stop" +$gradleScript = Join-Path $PSScriptRoot "..\android\build-apk.ps1" +& $gradleScript @args +exit $LASTEXITCODE diff --git a/frontend/scripts/install-emulator.ps1 b/frontend/scripts/install-emulator.ps1 new file mode 100644 index 0000000..d93bc1b --- /dev/null +++ b/frontend/scripts/install-emulator.ps1 @@ -0,0 +1,65 @@ +# Ставит собранный debug APK на эмулятор и открывает приложение. +# Если виртуалка выключена, запускает первый AVD и ждёт загрузки. +# Запуск: powershell -ExecutionPolicy Bypass -File C:\Dev\Benchpress\frontend\scripts\install-emulator.ps1 + +$ErrorActionPreference = "Stop" + +$sdk = Join-Path $env:LOCALAPPDATA "Android\Sdk" +$adb = Join-Path $sdk "platform-tools\adb.exe" +$emulator = Join-Path $sdk "emulator\emulator.exe" +$apk = Resolve-Path (Join-Path $PSScriptRoot "..\android\app\build\outputs\apk\debug\app-debug.apk") -ErrorAction SilentlyContinue +$package = "com.benchpress.secret" + +if (-not (Test-Path $adb)) { + Write-Error "adb не найден: $adb" +} +if (-not $apk) { + Write-Error "APK ещё нет. Сначала запусти scripts\build-apk.ps1" +} + +function Get-EmulatorSerial { + $lines = & $adb devices + foreach ($line in $lines) { + if ($line -match "^(emulator-\d+)\s+device\s*$") { + return $Matches[1] + } + } + return $null +} + +$serial = Get-EmulatorSerial +if (-not $serial) { + if (-not (Test-Path $emulator)) { + Write-Error "Эмулятор не найден: $emulator" + } + $avds = @(& $emulator -list-avds | Where-Object { $_ -and $_.Trim() }) + if ($avds.Count -eq 0) { + Write-Error "Нет виртуальных устройств. Создай его в Android Studio: Device Manager." + } + $avd = $avds[0].Trim() + Write-Host "Запуск эмулятора $avd ..." + Start-Process -FilePath $emulator -ArgumentList @("-avd", $avd) + & $adb wait-for-device + $deadline = (Get-Date).AddMinutes(4) + do { + Start-Sleep -Seconds 2 + $serial = Get-EmulatorSerial + if (-not $serial) { continue } + $boot = (& $adb -s $serial shell getprop sys.boot_completed | Out-String).Trim() + if ($boot -eq "1") { break } + } while ((Get-Date) -lt $deadline) + $serial = Get-EmulatorSerial + if (-not $serial) { + Write-Error "Эмулятор не успел загрузиться." + } +} + +Write-Host "Установка на $serial ..." +& $adb -s $serial install -r $apk +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +& $adb -s $serial shell am force-stop $package +& $adb -s $serial shell am start -n "$package/.MainActivity" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "Приложение открыто на $serial" diff --git a/frontend/scripts/update-app.ps1 b/frontend/scripts/update-app.ps1 new file mode 100644 index 0000000..6bb8888 --- /dev/null +++ b/frontend/scripts/update-app.ps1 @@ -0,0 +1,12 @@ +# После правок в Cursor: собирает веб-приложение и копирует его в Android-проект. +# Запуск: powershell -ExecutionPolicy Bypass -File C:\Dev\Benchpress\frontend\scripts\update-app.ps1 + +$ErrorActionPreference = "Stop" +$frontend = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $frontend + +Write-Host "Сборка веб-части и cap sync..." +npm run cap:sync +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "Готово. Дальше: .\build-apk.ps1, затем .\install-emulator.ps1" diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2af5b1f --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,57 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { AuthProvider, useAuth } from "./auth"; +import { HomePage } from "./pages/HomePage"; +import { LoginPage } from "./pages/LoginPage"; +import { OnboardingPage } from "./pages/OnboardingPage"; +import { SettingsPage } from "./pages/SettingsPage"; +import { WorkoutPage } from "./pages/WorkoutPage"; + +function Guard({ children }: { children: React.ReactNode }) { + const { loading, token, hasPassword, onboarded } = useAuth(); + if (loading) return

Загрузка…

; + if (!hasPassword || !token) return ; + if (!onboarded) return ; + return <>{children}; +} + +function AppRoutes() { + return ( + + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + ); +} + +export default function App() { + return ( + +
+ +
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..655fe84 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,172 @@ +import { localApi } from "./local/api"; +import { ApiError, type Profile, type WorkoutDetail, type WorkoutSummary } from "./apiTypes"; + +export { ApiError }; +export type { Exercise, Profile, WorkoutDetail, WorkoutSet, WorkoutSummary } from "./apiTypes"; + +const TOKEN_KEY = "bps_token"; +const OFFLINE = import.meta.env.VITE_OFFLINE === "true"; + +/** Пустой = относительные /api (web/nginx). Для online APK: полный URL. Offline — не используется. */ +const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") || ""; + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string | null) { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); +} + +async function request(path: string, options: RequestInit = {}): Promise { + const headers = new Headers(options.headers || {}); + headers.set("Content-Type", "application/json"); + const token = getToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); + + const url = path.startsWith("http") ? path : `${API_BASE}${path}`; + const res = await fetch(url, { ...options, headers }); + if (!res.ok) { + let msg = res.statusText; + try { + const data = await res.json(); + msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data); + } catch { + /* ignore */ + } + throw new ApiError(res.status, msg); + } + if (res.status === 204) return undefined as T; + return res.json(); +} + +const remoteApi = { + status: () => request<{ has_password: boolean; onboarded: boolean; phase: string }>("/api/auth/status"), + setup: (password: string) => + request<{ access_token: string; onboarded: boolean }>("/api/auth/setup", { + method: "POST", + body: JSON.stringify({ password }), + }), + login: (password: string) => + request<{ access_token: string; onboarded: boolean }>("/api/auth/login", { + method: "POST", + body: JSON.stringify({ password }), + }), + profile: () => request("/api/profile"), + onboarding: (body: { one_rm_kg: number; training_weekdays: number[]; start_date: string }) => + request("/api/onboarding", { method: "POST", body: JSON.stringify(body) }), + calendar: (year: number, month: number) => + request<{ year: number; month: number; workouts: WorkoutSummary[]; profile: Profile }>( + `/api/calendar?year=${year}&month=${month}`, + ), + upcoming: () => request("/api/workouts/upcoming"), + workout: (id: number) => request(`/api/workouts/${id}`), + start: (id: number) => request(`/api/workouts/${id}/start`, { method: "POST" }), + pause: (id: number) => request(`/api/workouts/${id}/pause`, { method: "POST" }), + resume: (id: number) => request(`/api/workouts/${id}/resume`, { method: "POST" }), + completeSet: (id: number, body: { actual_reps?: number; actual_weight_kg?: number } = {}) => + request(`/api/workouts/${id}/complete-set`, { + method: "POST", + body: JSON.stringify(body), + }), + setWeight: (workoutId: number, exerciseId: number, weight_kg: number) => + request(`/api/workouts/${workoutId}/exercises/${exerciseId}/weight`, { + method: "POST", + body: JSON.stringify({ weight_kg }), + }), + adjustSet: ( + workoutId: number, + exerciseId: number, + setId: number, + body: { actual_weight_kg: number; actual_reps: number }, + ) => + request(`/api/workouts/${workoutId}/exercises/${exerciseId}/sets/${setId}`, { + method: "POST", + body: JSON.stringify(body), + }), + moveWorkout: (id: number, scheduledDate: string) => + request(`/api/workouts/${id}/move`, { + method: "POST", + body: JSON.stringify({ scheduled_date: scheduledDate }), + }), + updateTrainingDays: (training_weekdays: number[]) => + request("/api/training-days", { + method: "POST", + body: JSON.stringify({ training_weekdays }), + }), + prompt: ( + workoutId: number, + exerciseId: number, + body: { + choice: "easy_yes" | "easy_no"; + weight_kg?: number; + increase_kg?: number; + decrease_kg?: number; + }, + ) => + request(`/api/workouts/${workoutId}/exercises/${exerciseId}/prompt`, { + method: "POST", + body: JSON.stringify(body), + }), + startRest: (rest_days: number) => + request("/api/rest/start", { method: "POST", body: JSON.stringify({ rest_days }) }), + probe: (body: { one_rm_kg: number; start_date: string; training_weekdays?: number[] }) => + request("/api/probe", { method: "POST", body: JSON.stringify(body) }), + export: () => request<{ version: number; exported_at: string; data: unknown }>("/api/export"), + import: (payload: { version: number; data: unknown }) => + request<{ message: string }>("/api/import", { method: "POST", body: JSON.stringify(payload) }), + reset: () => request<{ message: string }>("/api/reset", { method: "POST" }), +}; + +export const api = OFFLINE ? localApi : remoteApi; +export const isOfflineMode = OFFLINE; + +export const WEEKDAY_LABELS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; +export const WEEKDAY_FULL = [ + "Понедельник", + "Вторник", + "Среда", + "Четверг", + "Пятница", + "Суббота", + "Воскресенье", +]; + +export function formatKg(v: number | null | undefined): string { + if (v == null) return "—"; + return Number.isInteger(v) ? `${v}` : `${v}`; +} + +export function formatTimer(totalSeconds: number): string { + const s = Math.max(0, Math.floor(totalSeconds)); + const m = Math.floor(s / 60); + const r = s % 60; + return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`; +} + +export function formatRestCountdown(totalSeconds: number): string { + const s = Math.max(0, Math.floor(totalSeconds)); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const mins = Math.floor((s % 3600) / 60); + const secs = s % 60; + const n10 = days % 10; + const n100 = days % 100; + const word = n10 === 1 && n100 !== 11 ? "день" : n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14) ? "дня" : "дней"; + return `${String(days).padStart(2, "0")} ${word} ${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; +} + +export function formatDhms(totalSeconds: number): string { + const s = Math.max(0, Math.floor(totalSeconds)); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const mins = Math.floor((s % 3600) / 60); + const secs = s % 60; + return `${String(days).padStart(2, "0")}, ${String(hours).padStart(2, "0")}. ${String(mins).padStart(2, "0")}. ${String(secs).padStart(2, "0")}`; +} + +export function formatDateRu(isoDate: string): string { + const d = new Date(isoDate + "T12:00:00"); + return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long" }); +} diff --git a/frontend/src/apiTypes.ts b/frontend/src/apiTypes.ts new file mode 100644 index 0000000..e780349 --- /dev/null +++ b/frontend/src/apiTypes.ts @@ -0,0 +1,75 @@ +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export type Profile = { + one_rm_kg: number | null; + training_weekdays: number[] | null; + start_date: string | null; + onboarded: boolean; + phase: string; + rest_until: string | null; + probe_available_at: string | null; + cycle_number: number; + rest_countdown_seconds: number | null; + can_probe: boolean; +}; + +export type WorkoutSummary = { + id: number; + cycle_number: number; + week_number: number; + day_index: number; + scheduled_date: string; + status: string; + exercise_count: number; +}; + +export type WorkoutSet = { + id: number; + set_index: number; + target_percent: number | null; + target_reps: number | null; + target_weight_kg: number | null; + is_failure: boolean; + completed: boolean; + actual_reps: number | null; + actual_weight_kg: number | null; +}; + +export type Exercise = { + id: number; + order_index: number; + name: string; + kind: string; + prescribed_weight_kg: number | null; + weight_locked: boolean; + needs_weight_input: boolean; + pending_prompt: string | null; + weight_step_kg: number; + sets: WorkoutSet[]; +}; + +export type WorkoutDetail = { + id: number; + cycle_number: number; + week_number: number; + day_index: number; + scheduled_date: string; + status: string; + started_at: string | null; + paused_at: string | null; + completed_at: string | null; + current_exercise_index: number; + current_set_index: number; + rest_started_at: string | null; + rest_elapsed_seconds: number; + can_start: boolean; + can_resume: boolean; + pause_expires_at: string | null; + exercises: Exercise[]; +}; diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/auth.tsx b/frontend/src/auth.tsx new file mode 100644 index 0000000..961f65e --- /dev/null +++ b/frontend/src/auth.tsx @@ -0,0 +1,80 @@ +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { api, getToken, setToken } from "./api"; + +type AuthState = { + loading: boolean; + hasPassword: boolean; + onboarded: boolean; + token: string | null; + refresh: () => Promise; + login: (password: string) => Promise; + setup: (password: string) => Promise; + logout: () => void; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [loading, setLoading] = useState(true); + const [hasPassword, setHasPassword] = useState(false); + const [onboarded, setOnboarded] = useState(false); + const [token, setTokenState] = useState(getToken()); + + const refresh = async () => { + const status = await api.status(); + setHasPassword(status.has_password); + setOnboarded(status.onboarded); + if (getToken()) { + try { + const profile = await api.profile(); + setOnboarded(profile.onboarded); + } catch { + setToken(null); + setTokenState(null); + } + } + }; + + useEffect(() => { + refresh() + .catch(() => undefined) + .finally(() => setLoading(false)); + }, []); + + const value = useMemo( + () => ({ + loading, + hasPassword, + onboarded, + token, + refresh, + async login(password: string) { + const res = await api.login(password); + setToken(res.access_token); + setTokenState(res.access_token); + setOnboarded(res.onboarded); + setHasPassword(true); + }, + async setup(password: string) { + const res = await api.setup(password); + setToken(res.access_token); + setTokenState(res.access_token); + setOnboarded(res.onboarded); + setHasPassword(true); + }, + logout() { + setToken(null); + setTokenState(null); + }, + }), + [loading, hasPassword, onboarded, token], + ); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("AuthProvider missing"); + return ctx; +} diff --git a/frontend/src/components/AppCalendar.tsx b/frontend/src/components/AppCalendar.tsx new file mode 100644 index 0000000..b06d419 --- /dev/null +++ b/frontend/src/components/AppCalendar.tsx @@ -0,0 +1,80 @@ +import { useMemo, useState } from "react"; +import { WEEKDAY_LABELS, type WorkoutSummary } from "../api"; + +function daysInMonth(year: number, month: number) { + return new Date(year, month, 0).getDate(); +} + +export function AppCalendar({ + value, + workouts = [], + onPick, +}: { + value: string; + workouts?: WorkoutSummary[]; + onPick: (iso: string) => void; +}) { + const initial = new Date(value + "T12:00:00"); + const [year, setYear] = useState(initial.getFullYear()); + const [month, setMonth] = useState(initial.getMonth() + 1); + const byDate = useMemo(() => { + const map = new Map(); + for (const w of workouts) map.set(w.scheduled_date, w); + return map; + }, [workouts]); + const firstWeekday = (new Date(year, month - 1, 1).getDay() + 6) % 7; + const totalDays = daysInMonth(year, month); + const today = new Date(); + const todayIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + const shift = (delta: number) => { + const d = new Date(year, month - 1 + delta, 1); + setYear(d.getFullYear()); + setMonth(d.getMonth() + 1); + }; + const statusClass = (s: string) => { + if (s === "completed") return "done"; + if (s === "in_progress" || s === "paused") return "active"; + if (s === "abandoned") return "missed"; + return "planned"; + }; + + return ( +
+
+ +

{new Date(year, month - 1, 1).toLocaleString("ru-RU", { month: "long", year: "numeric" })}

+ +
+
+ {WEEKDAY_LABELS.map((d) => ( +
{d}
+ ))} +
+
+ {Array.from({ length: firstWeekday }).map((_, i) => ( +
+ ))} + {Array.from({ length: totalDays }).map((_, i) => { + const day = i + 1; + const key = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + const w = byDate.get(key); + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/WeightStepper.tsx b/frontend/src/components/WeightStepper.tsx new file mode 100644 index 0000000..288521e --- /dev/null +++ b/frontend/src/components/WeightStepper.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from "react"; + +type WeightStepperProps = { + value: number; + step?: number; + min?: number; + onChange: (value: number) => void; + label?: string; + compact?: boolean; + action?: ReactNode; +}; + +export function WeightStepper({ + value, + step = 0.5, + min = 0, + onChange, + label, + compact, + action, +}: WeightStepperProps) { + const bump = (delta: number) => { + const next = Math.round((value + delta) / step) * step; + onChange(Math.max(min, Math.round(next * 100) / 100)); + }; + + return ( +
+ {label && {label}} +
+ +
+ {Number.isInteger(value) ? value : value.toFixed(1)} + кг +
+ + {action} +
+
+ ); +} diff --git a/frontend/src/data/program.json b/frontend/src/data/program.json new file mode 100644 index 0000000..1f1ce1d --- /dev/null +++ b/frontend/src/data/program.json @@ -0,0 +1,1689 @@ +{ + "weeks": [ + { + "week": 1, + "days": [ + { + "template_day": "monday", + "label": "Понедельник", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + } + ] + }, + { + "name": "Строгий на бицепс", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + } + ] + }, + { + "name": "Французский жим гантелей", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "wednesday", + "label": "Среда", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + } + ] + }, + { + "name": "Пуловер", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Горизонтальные отведения", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Гиперэкстензия", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + } + ] + }, + { + "template_day": "friday", + "label": "Пятница", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 60.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 4 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 6 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 55.0, + "reps": 6 + }, + { + "percent": 45.0, + "reps": 8 + } + ] + }, + { + "name": "Молотковые сгибания", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + } + ] + }, + { + "name": "Разгибания с канатом", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + } + ] + }, + { + "week": 2, + "days": [ + { + "template_day": "monday", + "label": "Понедельник", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + } + ] + }, + { + "name": "Вертикальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Горизонтальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Обратная планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "wednesday", + "label": "Среда", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + } + ] + }, + { + "name": "Строгий на бицепс", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Французский жим гантелей", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 20 + }, + { + "type": "reps", + "reps": 20 + }, + { + "type": "reps", + "reps": 20 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "friday", + "label": "Пятница", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 75.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 65.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 6 + }, + { + "percent": 45.0, + "reps": 8 + } + ] + }, + { + "name": "Пуловер", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Горизонтальные отведения", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Гиперэкстензия", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + } + ] + } + ] + }, + { + "week": 3, + "days": [ + { + "template_day": "monday", + "label": "Понедельник", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 4 + }, + { + "percent": 80.0, + "reps": 4 + }, + { + "percent": 80.0, + "reps": 4 + } + ] + }, + { + "name": "Молотковые сгибания", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Разгибания с канатом", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "wednesday", + "label": "Среда", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 3 + } + ] + }, + { + "name": "Вертикальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Горизонтальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Обратная планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "friday", + "label": "Пятница", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 3 + }, + { + "percent": 85.0, + "reps": 4 + }, + { + "percent": 85.0, + "reps": 4 + }, + { + "percent": 85.0, + "reps": 4 + }, + { + "percent": 80.0, + "reps": 3 + }, + { + "percent": 70.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 5 + } + ] + }, + { + "name": "Строгий на бицепс", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 8 + }, + { + "type": "reps", + "reps": 8 + }, + { + "type": "reps", + "reps": 8 + } + ] + }, + { + "name": "Французский жим гантелей", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + } + ] + }, + { + "week": 4, + "days": [ + { + "template_day": "monday", + "label": "Понедельник", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 2 + }, + { + "percent": 75.0, + "reps": 2 + }, + { + "percent": 75.0, + "reps": 2 + }, + { + "percent": 80.0, + "reps": 5 + }, + { + "percent": 80.0, + "reps": 5 + } + ] + }, + { + "name": "Пуловер", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + }, + { + "type": "reps", + "reps": 15 + } + ] + }, + { + "name": "Горизонтальные отведения", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Гиперэкстензия", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + } + ] + }, + { + "template_day": "wednesday", + "label": "Среда", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 5 + } + ] + }, + { + "name": "Молотковые сгибания", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 8 + }, + { + "type": "reps", + "reps": 8 + }, + { + "type": "reps", + "reps": 8 + } + ] + }, + { + "name": "Разгибания с канатом", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "friday", + "label": "Пятница", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 6 + }, + { + "percent": 80.0, + "reps": 6 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 6 + }, + { + "percent": 45.0, + "reps": 8 + } + ] + }, + { + "name": "Вертикальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Горизонтальная тяга", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Обратная планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + } + ] + }, + { + "week": 5, + "days": [ + { + "template_day": "monday", + "label": "Понедельник", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 3 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 5 + }, + { + "percent": 80.0, + "reps": 5 + } + ] + }, + { + "name": "Строгий на бицепс", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + }, + { + "type": "reps", + "reps": 6 + } + ] + }, + { + "name": "Французский жим гантелей", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Планка", + "kind": "failure", + "sets": [ + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + }, + { + "type": "failure", + "sets": 1 + } + ] + } + ] + }, + { + "template_day": "wednesday", + "label": "Среда", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 6 + }, + { + "percent": 55.0, + "reps": 5 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 65.0, + "reps": 2 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + }, + { + "percent": 70.0, + "reps": 5 + } + ] + }, + { + "name": "Пуловер", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + }, + { + "type": "reps", + "reps": 12 + } + ] + }, + { + "name": "Горизонтальные отведения", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + }, + { + "name": "Гиперэкстензия", + "kind": "accessory", + "sets": [ + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + }, + { + "type": "reps", + "reps": 10 + } + ] + } + ] + }, + { + "template_day": "friday", + "label": "Пятница", + "exercises": [ + { + "name": "Жим", + "kind": "bench", + "sets": [ + { + "percent": 45.0, + "reps": 5 + }, + { + "percent": 55.0, + "reps": 4 + }, + { + "percent": 65.0, + "reps": 3 + }, + { + "percent": 70.0, + "reps": 2 + }, + { + "percent": 75.0, + "reps": 3 + }, + { + "percent": 80.0, + "reps": 3 + }, + { + "percent": 85.0, + "reps": 2 + }, + { + "percent": 90.0, + "reps": 4 + }, + { + "percent": 90.0, + "reps": 4 + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5c024bf --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,844 @@ +:root { + --bg0: #0f1412; + --bg1: #18201c; + --bg2: #223028; + --line: #2f3f36; + --text: #eef3ef; + --muted: #9aada2; + --accent: #c4f04d; + --accent-dim: #8fb82f; + --danger: #ff6b5a; + --ok: #7dffb3; + --radius: 14px; + --font: "Segoe UI", "Manrope", system-ui, sans-serif; + color: var(--text); + background: + radial-gradient(1200px 600px at 10% -10%, #1d3324 0%, transparent 55%), + radial-gradient(900px 500px at 100% 0%, #24361f 0%, transparent 50%), + linear-gradient(180deg, #101612, #0c100e); + font-family: var(--font); + line-height: 1.4; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + height: 100%; + overflow: hidden; +} + +body { + min-width: 320px; + overflow: hidden; +} + +button, +input { + font: inherit; +} + +a { + color: inherit; +} + +.app-shell { + height: 100dvh; + max-height: 100dvh; + max-width: 480px; + margin: 0 auto; + padding-top: max(28px, env(safe-area-inset-top, 0px)); + overflow: hidden; + display: flex; + flex-direction: column; + background: #0f1412; +} + +.page { + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 16px 16px calc(24px + env(safe-area-inset-bottom, 0px)); + display: flex; + flex-direction: column; + gap: 16px; +} + +.settings-page { + min-height: 100%; +} + +.settings-foot { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +.brand { + margin: 0; + font-size: 1.35rem; + font-weight: 750; + letter-spacing: -0.02em; +} + +.brand-block { + padding: 12px 4px 4px; +} + +.muted { + color: var(--muted); + margin: 4px 0 0; +} + +.hint { + display: block; + color: var(--muted); + font-size: 0.85rem; + margin-top: 6px; +} + +.error { + color: var(--danger); + margin: 0; +} + +.ok { + color: var(--ok); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.topbar-actions { + margin-left: auto; + display: flex; + gap: 8px; +} + +.card-form, +.phase-banner, +.active-set, +.prompt-box, +.settings-list { + background: color-mix(in srgb, var(--bg1) 88%, black); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 16px; +} + +.card-form, +.settings-list, +.prompt-box, +.active-set, +.phase-banner { + display: flex; + flex-direction: column; + gap: 12px; +} + +label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.92rem; +} + +input[type="number"], +input[type="password"], +input[type="date"], +input[type="text"] { + width: 100%; + border: 1px solid var(--line); + background: var(--bg0); + color: var(--text); + border-radius: 10px; + padding: 12px 12px; +} + +fieldset { + border: 0; + padding: 0; + margin: 0; +} + +legend { + margin-bottom: 8px; +} + +.btn { + border: 1px solid var(--line); + background: var(--bg2); + color: var(--text); + border-radius: 12px; + padding: 12px 14px; + cursor: pointer; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn.primary { + background: linear-gradient(180deg, var(--accent), var(--accent-dim)); + color: #10140f; + border-color: transparent; + font-weight: 700; +} + +.btn.ghost { + background: transparent; +} + +.btn.danger { + background: color-mix(in srgb, var(--danger) 20%, var(--bg2)); + border-color: color-mix(in srgb, var(--danger) 50%, var(--line)); + color: #ffd2cc; +} + +.btn.wide { + width: 100%; + padding: 16px; + font-size: 1.05rem; +} + +.day-grid, +.row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + border: 1px solid var(--line); + background: var(--bg0); + color: var(--text); + border-radius: 999px; + padding: 10px 12px; + cursor: pointer; +} + +.chip.on { + background: color-mix(in srgb, var(--accent) 25%, var(--bg0)); + border-color: var(--accent-dim); +} + +.week-strip h2, +.calendar h2, +.exercise-list h2 { + margin: 0 0 10px; + font-size: 1rem; +} + +.strip-row { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(92px, 1fr); + gap: 8px; + overflow-x: auto; + padding-bottom: 4px; +} + +.strip-card { + border: 1px solid var(--line); + background: var(--bg1); + color: var(--text); + border-radius: 12px; + padding: 12px 10px; + display: flex; + flex-direction: column; + gap: 4px; + text-align: left; + cursor: pointer; +} + +.strip-card.today { + outline: 2px solid var(--accent); +} + +.strip-card.done { + border-color: color-mix(in srgb, var(--ok) 40%, var(--line)); +} + +.strip-card.active { + border-color: var(--accent-dim); +} + +.strip-day { + font-size: 0.8rem; + color: var(--muted); +} + +.strip-date { + font-size: 1.3rem; + font-weight: 700; +} + +.strip-meta { + font-size: 0.75rem; + color: var(--muted); +} + +.cal-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.cal-nav h2 { + margin: 0; + text-transform: capitalize; + font-size: 1.05rem; +} + +.cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 6px; +} + +.cal-grid.head { + margin-bottom: 4px; + color: var(--muted); + font-size: 0.78rem; + text-align: center; +} + +.cal-cell { + aspect-ratio: 1; + border: 1px solid transparent; + background: transparent; + color: var(--text); + border-radius: 10px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 0; +} + +.cal-cell:disabled { + opacity: 0.55; +} + +.cal-cell.planned, +.cal-cell.done, +.cal-cell.active, +.cal-cell.missed { + background: var(--bg1); + border-color: var(--line); + cursor: pointer; +} + +.cal-cell.today { + box-shadow: inset 0 0 0 1px var(--accent); +} + +.cal-cell .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); +} + +.cal-cell.done .dot { + background: var(--ok); +} + +.cal-cell.picked { + box-shadow: inset 0 0 0 2px var(--accent); +} + +.cal-grid:not(.head) { + touch-action: none; +} + +.cal-cell.drop { + box-shadow: inset 0 0 0 2px #3d7ea6; +} + +.cal-cell.dragging { + opacity: 0.45; +} + +.drag-ghost { + position: fixed; + z-index: 30; + width: 44px; + height: 44px; + margin-left: -22px; + margin-top: -46px; + border-radius: 10px; + display: grid; + place-items: center; + pointer-events: none; + background: var(--bg2); + border: 2px solid #3d7ea6; + font-weight: 700; +} + +.workout-page { + min-height: 100%; +} + +.workout-foot { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +.btn.move { + background: #3d7ea6; + border-color: transparent; + color: #f4fbff; + font-weight: 700; +} + +.rest-until { + display: flex; + flex-direction: column; + gap: 2px; +} + +.rest-title { + margin: 0; + font-size: 1.15rem; + font-weight: 750; +} + +.rest-until p { + margin: 0; +} + +.move-prompt { + margin: 0; + font-size: 1.05rem; + font-weight: 700; +} + +.move-calendar { + margin-top: 4px; +} + +.status-line { + font-variant-numeric: tabular-nums; +} + +.active-set .ex-name { + margin: 0; + font-size: 1.2rem; + font-weight: 700; +} + +.set-label { + margin: 0; + color: var(--muted); +} + +.big-numbers { + display: flex; + justify-content: space-around; + gap: 12px; + padding: 10px 0 4px; +} + +.big-numbers .num { + font-size: 2.4rem; + font-weight: 800; + letter-spacing: -0.03em; +} + +.big-numbers .unit { + display: block; + color: var(--muted); + font-size: 0.85rem; + text-align: center; +} + +.exercise-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ex-block { + background: color-mix(in srgb, var(--bg1) 88%, black); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0; + overflow: hidden; +} + +.ex-block.current { + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); +} + +.ex-block.finished:not(.current) { + opacity: 0.78; +} + +.ex-head { + display: flex; + align-items: center; +} + +.ex-toggle { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 0; + background: transparent; + color: var(--text); + padding: 14px 8px 14px 16px; + cursor: pointer; + text-align: left; +} + +.icon-btn { + width: 44px; + height: 44px; + padding: 0; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-radius: 12px; + background: transparent; + color: var(--text); + cursor: pointer; +} + +.ex-edit { + flex-shrink: 0; + width: 36px; + height: 36px; + margin-right: 10px; + padding: 0; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-radius: 10px; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.ex-edit.on { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); +} + +.ex-toggle-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.ex-toggle-main strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ex-toggle-meta { + flex-shrink: 0; + font-size: 0.85rem; +} + +.chevron { + color: var(--muted); + width: 1rem; +} + +.badge-now, +.badge-done { + font-size: 0.72rem; + font-weight: 700; + border-radius: 999px; + padding: 2px 8px; + flex-shrink: 0; +} + +.badge-now { + background: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--accent); +} + +.badge-done { + background: color-mix(in srgb, var(--ok) 18%, transparent); + color: var(--ok); +} + +.ex-body { + padding: 14px 16px 14px; + border-top: 1px solid var(--line); +} + +.ex-body .active-set.nested, +.ex-body .prompt-box.nested { + margin: 0 0 12px; + border-radius: 12px; +} + +.ex-body ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.ex-body li { + color: var(--muted); + font-size: 1.05rem; + line-height: 1.4; + min-height: 40px; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.set-adjust { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.set-step { + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg2); + color: var(--text); + font-size: 1.05rem; + line-height: 1; + cursor: pointer; +} + +.set-step:disabled { + opacity: 0.45; +} + +.set-step-label { + min-width: 2.4rem; + text-align: center; + font-size: 0.75rem; + color: var(--muted); +} + +.ex-body li.done { + color: var(--ok); +} + +.weight-stepper { + display: flex; + flex-direction: column; + gap: 8px; +} + +.weight-stepper-label { + font-size: 0.92rem; +} + +.weight-stepper-row { + display: grid; + grid-template-columns: 44px 1fr 44px; + gap: 8px; + align-items: center; +} + +.weight-stepper .rm-btn { + height: 40px; + font-size: 1.25rem; +} + +.weight-stepper-value { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 2px 8px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--bg0); +} + +.weight-stepper-value .num { + font-size: 1.25rem; + font-weight: 800; + line-height: 1.1; +} + +.weight-stepper-value .unit { + font-size: 0.72rem; +} + +.weight-stepper.compact .weight-stepper-row { + display: flex; + justify-content: flex-start; +} + +.weight-stepper.compact .weight-stepper-value { + width: 76px; + flex: 0 0 76px; + padding: 0 4px; +} + +.weight-stepper.compact .rm-btn { + width: 40px; + flex: 0 0 40px; +} + +.check-btn { + width: 40px; + height: 40px; + flex: 0 0 40px; + border-radius: 10px; + background: var(--accent); + border-color: transparent; + color: #10140f; +} + +.check-btn:disabled { + opacity: 0.45; +} + +.auth-page { + min-height: 100%; + justify-content: center; +} + +.stepper { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.step-dot { + border: 1px solid var(--line); + background: var(--bg1); + color: var(--muted); + border-radius: 999px; + padding: 8px 10px; + font-size: 0.85rem; + cursor: pointer; +} + +.step-dot.on { + color: #10140f; + background: var(--accent); + border-color: transparent; + font-weight: 700; +} + +.step-dot.done { + border-color: var(--accent-dim); + color: var(--text); +} + +.onboard-step h1 { + margin: 0; + font-size: 1.35rem; +} + +.rm-picker { + display: grid; + grid-template-columns: 64px 1fr 64px; + gap: 10px; + align-items: center; + margin: 8px 0 4px; +} + +.rm-btn { + height: 64px; + font-size: 1.8rem; + font-weight: 700; + padding: 0; +} + +.rm-value { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.rm-value input { + text-align: center; + font-size: 2rem; + font-weight: 800; + padding: 14px 10px; +} + +.rm-value span { + color: var(--muted); + font-size: 0.9rem; +} + +.day-grid-lg { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.chip-lg { + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 12px; + padding: 14px 16px; + font-size: 1.05rem; + text-align: left; +} + +.chip-check { + color: var(--accent); + font-weight: 700; +} + +.summary-box { + background: var(--bg0); + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px 14px; +} + +.summary-box p { + margin: 0 0 6px; +} + +.summary-box p:last-child { + margin: 0; +} + +@media (min-width: 480px) { + .app-shell { + border-left: 1px solid var(--line); + border-right: 1px solid var(--line); + } +} diff --git a/frontend/src/local/api.ts b/frontend/src/local/api.ts new file mode 100644 index 0000000..96e54c6 --- /dev/null +++ b/frontend/src/local/api.ts @@ -0,0 +1,711 @@ +import { ApiError, type Exercise, type Profile, type WorkoutDetail, type WorkoutSummary } from "../apiTypes"; +import { db, ensureProfile } from "./db"; +import { planMove, planWeekdayChange } from "./reschedule"; +import { + applyAccessoryWeightToFuture, + generateSchedule, + todayIso, + weightStepFor, +} from "./schedule"; +import type { StoredExercise, StoredWorkout } from "./types"; +import { PAUSE_LIMIT_MINUTES, roundExerciseWeight, roundToStep, ACCESSORY_STEP_KG } from "./weights"; + +async function hashPassword(password: string): Promise { + const data = new TextEncoder().encode(`bps:${password}`); + const buf = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function isoNow(): string { + return new Date().toISOString(); +} + +function addDaysIso(isoDate: string, days: number): string { + const d = new Date(isoDate + "T12:00:00"); + d.setDate(d.getDate() + days); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function profileView(p: Awaited>): Profile { + const now = Date.now(); + let restCountdown: number | null = null; + let canProbe = false; + let phase = p.phase; + if (p.phase === "rest" && p.probe_available_at) { + const left = Math.floor((new Date(p.probe_available_at).getTime() - now) / 1000); + if (left > 0) restCountdown = left; + else { + canProbe = true; + phase = "probe"; + } + } else if (p.phase === "probe") { + canProbe = true; + } + return { + one_rm_kg: p.one_rm_kg, + training_weekdays: p.training_weekdays, + start_date: p.start_date, + onboarded: p.onboarded, + phase, + rest_until: p.rest_until, + probe_available_at: p.probe_available_at, + cycle_number: p.cycle_number, + rest_countdown_seconds: restCountdown, + can_probe: canProbe, + }; +} + +function summaryOf(w: StoredWorkout): WorkoutSummary { + return { + id: w.id, + cycle_number: w.cycle_number, + week_number: w.week_number, + day_index: w.day_index, + scheduled_date: w.scheduled_date, + status: w.status, + exercise_count: w.exercises.length, + }; +} + +function expirePause(w: StoredWorkout): StoredWorkout { + if (w.status === "paused" && w.paused_at) { + const limit = new Date(w.paused_at).getTime() + PAUSE_LIMIT_MINUTES * 60_000; + if (Date.now() > limit) { + w.status = "abandoned"; + w.paused_at = null; + w.rest_started_at = null; + } + } + return w; +} + +function toExercise(ex: StoredExercise): Exercise { + return { + id: ex.id, + order_index: ex.order_index, + name: ex.name, + kind: ex.kind, + prescribed_weight_kg: ex.prescribed_weight_kg, + weight_locked: ex.weight_locked, + needs_weight_input: ex.needs_weight_input, + pending_prompt: ex.pending_prompt, + weight_step_kg: weightStepFor(ex.name, ex.kind), + sets: ex.sets + .slice() + .sort((a, b) => a.set_index - b.set_index) + .map((s) => ({ + id: s.id, + set_index: s.set_index, + target_percent: s.target_percent, + target_reps: s.target_reps, + target_weight_kg: s.target_weight_kg, + is_failure: s.is_failure, + completed: s.completed, + actual_reps: s.actual_reps, + actual_weight_kg: s.actual_weight_kg, + })), + }; +} + +async function syncAccessories(w: StoredWorkout): Promise { + let changed = false; + for (const ex of w.exercises) { + if (ex.kind !== "accessory") continue; + if (ex.sets.length > 0 && ex.sets.every((s) => s.completed)) continue; + const st = await db.accessories + .where({ cycle_number: w.cycle_number, exercise_name: ex.name }) + .first(); + if (!st) continue; + const weight = st.pending_weight_kg ?? st.weight_kg; + if (weight == null) continue; + if ( + ex.prescribed_weight_kg !== weight || + ex.needs_weight_input || + ex.weight_locked !== st.locked + ) { + ex.prescribed_weight_kg = weight; + ex.needs_weight_input = false; + ex.weight_locked = st.locked; + for (const s of ex.sets) { + if (!s.completed) s.target_weight_kg = weight; + } + changed = true; + } + } + if (changed) await db.workouts.put(w); + return w; +} + +async function getWorkout(id: number): Promise { + let w = await db.workouts.get(id); + if (!w) throw new ApiError(404, "Тренировка не найдена"); + w = expirePause(w); + await db.workouts.put(w); + w = await syncAccessories(w); + return w; +} + +function detailOf(w: StoredWorkout, all: StoredWorkout[]): WorkoutDetail { + const today = todayIso(); + const now = Date.now(); + const active = all.find( + (x) => x.id !== w.id && x.cycle_number === w.cycle_number && (x.status === "in_progress" || x.status === "paused"), + ); + const canStart = w.status === "planned" && w.scheduled_date === today && !active; + let canResume = false; + let pauseExpires: string | null = null; + if (w.status === "paused" && w.paused_at) { + const exp = new Date(w.paused_at).getTime() + PAUSE_LIMIT_MINUTES * 60_000; + pauseExpires = new Date(exp).toISOString(); + canResume = now <= exp && w.scheduled_date === today; + } + let restElapsed = 0; + if (w.status === "in_progress" && w.rest_started_at) { + restElapsed = Math.max(0, Math.floor((now - new Date(w.rest_started_at).getTime()) / 1000)); + } + return { + id: w.id, + cycle_number: w.cycle_number, + week_number: w.week_number, + day_index: w.day_index, + scheduled_date: w.scheduled_date, + status: w.status, + started_at: w.started_at, + paused_at: w.paused_at, + completed_at: w.completed_at, + current_exercise_index: w.current_exercise_index, + current_set_index: w.current_set_index, + rest_started_at: w.rest_started_at, + rest_elapsed_seconds: restElapsed, + can_start: canStart, + can_resume: canResume, + pause_expires_at: pauseExpires, + exercises: w.exercises.slice().sort((a, b) => a.order_index - b.order_index).map(toExercise), + }; +} + +function advancePointer(w: StoredWorkout): boolean { + const exercises = w.exercises.slice().sort((a, b) => a.order_index - b.order_index); + for (let ei = 0; ei < exercises.length; ei++) { + const sets = exercises[ei].sets.slice().sort((a, b) => a.set_index - b.set_index); + for (const s of sets) { + if (!s.completed) { + w.current_exercise_index = ei; + w.current_set_index = s.set_index; + return false; + } + } + } + return true; +} + +async function maybeFinishCycle(w: StoredWorkout): Promise { + const others = await db.workouts + .where("cycle_number") + .equals(w.cycle_number) + .filter((x) => x.id !== w.id && !["completed", "abandoned"].includes(x.status)) + .count(); + if (others === 0) { + const p = await ensureProfile(); + p.phase = "rest"; + p.probe_available_at = null; + p.rest_until = null; + await db.profile.put(p); + } +} + +export const localApi = { + async status() { + const auth = await db.auth.get(1); + const p = await ensureProfile(); + return { has_password: !!auth, onboarded: p.onboarded, phase: p.phase }; + }, + + async setup(password: string) { + if (await db.auth.get(1)) throw new ApiError(400, "Пароль уже задан"); + await db.auth.put({ id: 1, password_hash: await hashPassword(password) }); + await ensureProfile(); + const p = await ensureProfile(); + return { access_token: "local", onboarded: p.onboarded }; + }, + + async login(password: string) { + const auth = await db.auth.get(1); + if (!auth) throw new ApiError(400, "Сначала задайте пароль"); + if (auth.password_hash !== (await hashPassword(password))) throw new ApiError(401, "Неверный пароль"); + const p = await ensureProfile(); + return { access_token: "local", onboarded: p.onboarded }; + }, + + async profile() { + return profileView(await ensureProfile()); + }, + + async onboarding(body: { one_rm_kg: number; training_weekdays: number[]; start_date: string }) { + const p = await ensureProfile(); + if (p.onboarded) throw new ApiError(400, "Онбординг уже пройден. Используйте сброс в настройках."); + const oneRm = roundToStep(body.one_rm_kg, 1.25); + p.one_rm_kg = oneRm; + p.training_weekdays = [...body.training_weekdays].sort((a, b) => a - b); + p.start_date = body.start_date; + p.onboarded = true; + p.phase = "active"; + p.cycle_number = 1; + await db.profile.put(p); + await db.workouts.where("cycle_number").equals(1).delete(); + await db.accessories.where("cycle_number").equals(1).delete(); + await generateSchedule({ + oneRmKg: oneRm, + trainingWeekdays: p.training_weekdays, + startDate: body.start_date, + cycleNumber: 1, + }); + return profileView(p); + }, + + async calendar(year: number, month: number) { + const p = await ensureProfile(); + const workouts = await db.workouts.where("cycle_number").equals(p.cycle_number).toArray(); + const monthWorkouts = workouts + .filter((w) => { + const [y, m] = w.scheduled_date.split("-").map(Number); + return y === year && m === month; + }) + .sort((a, b) => a.scheduled_date.localeCompare(b.scheduled_date)); + return { + year, + month, + workouts: monthWorkouts.map(summaryOf), + profile: profileView(p), + }; + }, + + async upcoming() { + const p = await ensureProfile(); + const workouts = await db.workouts + .where("cycle_number") + .equals(p.cycle_number) + .sortBy("scheduled_date"); + return workouts.map(summaryOf); + }, + + async workout(id: number) { + const w = await getWorkout(id); + const all = await db.workouts.where("cycle_number").equals(w.cycle_number).toArray(); + return detailOf(w, all); + }, + + async start(id: number) { + const w = await getWorkout(id); + const today = todayIso(); + if (w.scheduled_date !== today) throw new ApiError(400, "Начать можно только сегодняшнюю тренировку"); + if (w.status !== "planned") throw new ApiError(400, "Тренировку нельзя начать"); + const active = await db.workouts + .where("cycle_number") + .equals(w.cycle_number) + .filter((x) => x.status === "in_progress" || x.status === "paused") + .first(); + if (active) throw new ApiError(400, "Уже есть активная тренировка"); + w.status = "in_progress"; + w.started_at = isoNow(); + w.current_exercise_index = 0; + w.current_set_index = 0; + await db.workouts.put(w); + return this.workout(id); + }, + + async pause(id: number) { + const w = await getWorkout(id); + if (w.status !== "in_progress") throw new ApiError(400, "Пауза доступна только во время тренировки"); + w.status = "paused"; + w.paused_at = isoNow(); + w.rest_started_at = null; + await db.workouts.put(w); + return this.workout(id); + }, + + async resume(id: number) { + let w = await getWorkout(id); + if (w.status === "abandoned") throw new ApiError(400, "Тренировка остановлена: пауза дольше 15 минут"); + if (w.status !== "paused") throw new ApiError(400, "Тренировка не на паузе"); + if (w.scheduled_date !== todayIso()) throw new ApiError(400, "Продолжить можно только в тот же день"); + if (!w.paused_at || Date.now() > new Date(w.paused_at).getTime() + PAUSE_LIMIT_MINUTES * 60_000) { + w.status = "abandoned"; + await db.workouts.put(w); + throw new ApiError(400, "Тренировка остановлена: пауза дольше 15 минут"); + } + w.status = "in_progress"; + w.paused_at = null; + await db.workouts.put(w); + return this.workout(id); + }, + + async setWeight(workoutId: number, exerciseId: number, weightKg: number) { + const w = await getWorkout(workoutId); + if (w.status === "completed") throw new ApiError(400, "Выполненную тренировку менять нельзя"); + const ex = w.exercises.find((e) => e.id === exerciseId); + if (!ex) throw new ApiError(404, "Упражнение не найдено"); + if (ex.kind !== "accessory") throw new ApiError(400, "Вес задаётся только для вспомогательных"); + if (ex.sets.length > 0 && ex.sets.every((s) => s.completed)) { + throw new ApiError(400, "Это упражнение уже выполнено"); + } + const weight = roundExerciseWeight(weightKg, ex.name, ex.kind); + ex.prescribed_weight_kg = weight; + ex.needs_weight_input = false; + for (const s of ex.sets) { + if (!s.completed) s.target_weight_kg = weight; + } + await db.workouts.put(w); + await applyAccessoryWeightToFuture({ + cycleNumber: w.cycle_number, + exerciseName: ex.name, + weightKg: weight, + fromDate: w.scheduled_date, + lock: false, + }); + const fresh = await db.workouts.get(workoutId); + if (fresh) { + const current = fresh.exercises.find((e) => e.id === exerciseId); + if (current) { + current.prescribed_weight_kg = weight; + current.needs_weight_input = false; + for (const s of current.sets) { + if (!s.completed) s.target_weight_kg = weight; + } + await db.workouts.put(fresh); + } + } + return this.workout(workoutId); + }, + + async adjustSet( + workoutId: number, + exerciseId: number, + setId: number, + body: { actual_weight_kg: number; actual_reps: number }, + ) { + const w = await getWorkout(workoutId); + const ex = w.exercises.find((e) => e.id === exerciseId); + if (!ex) throw new ApiError(404, "Упражнение не найдено"); + if (ex.kind !== "accessory") throw new ApiError(400, "Править можно только вспомогательные"); + const set = ex.sets.find((s) => s.id === setId); + if (!set || !set.completed) throw new ApiError(400, "Можно править только выполненный подход"); + set.actual_weight_kg = roundExerciseWeight(body.actual_weight_kg, ex.name, ex.kind); + set.actual_reps = Math.max(0, Math.round(body.actual_reps)); + await db.workouts.put(w); + return this.workout(workoutId); + }, + + async moveWorkout(id: number, scheduledDate: string) { + const w = await getWorkout(id); + const p = await ensureProfile(); + const weekdays = p.training_weekdays; + if (!weekdays || weekdays.length === 0) throw new ApiError(400, "Не заданы дни тренировок"); + const all = await db.workouts.where("cycle_number").equals(w.cycle_number).toArray(); + let assigned: Map; + try { + assigned = planMove(all, id, scheduledDate, weekdays); + } catch (err) { + throw new ApiError(400, err instanceof Error ? err.message : "Не удалось перенести"); + } + for (const item of all) { + const next = assigned.get(item.id); + if (next && next !== item.scheduled_date) { + item.scheduled_date = next; + await db.workouts.put(item); + } + } + return this.workout(id); + }, + + async updateTrainingDays(weekdays: number[]) { + const p = await ensureProfile(); + if (!p.onboarded) throw new ApiError(400, "Сначала создайте программу"); + const all = await db.workouts.where("cycle_number").equals(p.cycle_number).toArray(); + const done = all + .filter((item) => item.status === "completed") + .map((item) => item.scheduled_date) + .sort(); + let start = todayIso(); + if (done.length) { + const after = addDaysIso(done[done.length - 1], 1); + if (after > start) start = after; + } + let assigned: Map; + try { + assigned = planWeekdayChange(all, weekdays, start); + } catch (err) { + throw new ApiError(400, err instanceof Error ? err.message : "Не удалось обновить дни"); + } + for (const item of all) { + const next = assigned.get(item.id); + if (next && next !== item.scheduled_date) { + item.scheduled_date = next; + await db.workouts.put(item); + } + } + p.training_weekdays = [...weekdays].sort((a, b) => a - b); + await db.profile.put(p); + return profileView(p); + }, + + async completeSet(id: number, body: { actual_reps?: number; actual_weight_kg?: number } = {}) { + const w = await getWorkout(id); + if (w.status !== "in_progress") throw new ApiError(400, "Тренировка не активна"); + const exercises = w.exercises.slice().sort((a, b) => a.order_index - b.order_index); + const exercise = exercises[w.current_exercise_index]; + if (!exercise) throw new ApiError(400, "Нет текущего упражнения"); + if (exercise.pending_prompt) throw new ApiError(400, "Сначала ответьте на вопрос по упражнению"); + if (exercise.kind === "accessory" && exercise.needs_weight_input) { + throw new ApiError(400, "Сначала введите рабочий вес"); + } + let current = + exercise.sets.find((s) => s.set_index === w.current_set_index && !s.completed) || + exercise.sets.slice().sort((a, b) => a.set_index - b.set_index).find((s) => !s.completed); + if (!current) throw new ApiError(400, "Нет активного подхода"); + + if (exercise.kind === "bench" || exercise.kind === "failure") { + current.completed = true; + current.completed_at = isoNow(); + current.actual_weight_kg = current.target_weight_kg; + current.actual_reps = current.target_reps; + } else { + if (body.actual_reps == null) throw new ApiError(400, "Укажите выполненные повторения"); + const weight = body.actual_weight_kg ?? exercise.prescribed_weight_kg; + if (weight == null) throw new ApiError(400, "Не задан вес"); + current.completed = true; + current.completed_at = isoNow(); + current.actual_reps = body.actual_reps; + current.actual_weight_kg = roundExerciseWeight(weight, exercise.name, exercise.kind); + } + + if (exercise.sets.every((s) => s.completed) && exercise.kind === "accessory" && !exercise.weight_locked) { + const allHit = exercise.sets.every( + (s) => s.target_reps == null || (s.actual_reps ?? 0) >= s.target_reps, + ); + exercise.pending_prompt = allHit ? "confirm_easy" : "choose_weight_missed"; + w.rest_started_at = null; + await db.workouts.put(w); + return this.workout(id); + } + + const done = advancePointer(w); + if (done) { + w.status = "completed"; + w.completed_at = isoNow(); + w.rest_started_at = null; + await db.workouts.put(w); + await maybeFinishCycle(w); + } else { + w.rest_started_at = isoNow(); + await db.workouts.put(w); + } + return this.workout(id); + }, + + async prompt( + workoutId: number, + exerciseId: number, + body: { + choice: "easy_yes" | "easy_no"; + weight_kg?: number; + increase_kg?: number; + decrease_kg?: number; + }, + ) { + const w = await getWorkout(workoutId); + if (w.status !== "in_progress") throw new ApiError(400, "Тренировка не активна"); + const exercise = w.exercises.find((e) => e.id === exerciseId); + if (!exercise) throw new ApiError(404, "Упражнение не найдено"); + if (!exercise.pending_prompt) throw new ApiError(400, "Нет активного вопроса"); + + const today = w.scheduled_date; + const currentWeight = exercise.prescribed_weight_kg; + const step = weightStepFor(exercise.name, exercise.kind); + const prompt = exercise.pending_prompt; + + if (prompt === "confirm_easy") { + if (body.choice === "easy_no") { + if (currentWeight == null) throw new ApiError(400, "Вес не задан"); + await applyAccessoryWeightToFuture({ + cycleNumber: w.cycle_number, + exerciseName: exercise.name, + weightKg: currentWeight, + fromDate: today, + lock: true, + }); + exercise.weight_locked = true; + exercise.pending_prompt = null; + } else { + exercise.pending_prompt = "choose_weight_easy"; + await db.workouts.put(w); + return this.workout(workoutId); + } + } else if (prompt === "choose_weight_easy") { + if (currentWeight == null) throw new ApiError(400, "Текущий вес не задан"); + if (body.increase_kg == null || body.increase_kg <= 0) { + throw new ApiError(400, "Укажите, на сколько поднять вес"); + } + let bump = roundToStep(body.increase_kg, step); + if (bump < step) bump = step; + let weight = roundToStep(currentWeight + bump, step); + if (weight <= currentWeight) weight = roundToStep(currentWeight + step, step); + await applyAccessoryWeightToFuture({ + cycleNumber: w.cycle_number, + exerciseName: exercise.name, + weightKg: weight, + fromDate: addDaysIso(today, 1), + lock: false, + }); + exercise.pending_prompt = null; + } else if (prompt === "choose_weight_missed") { + if (currentWeight == null) throw new ApiError(400, "Текущий вес не задан"); + if (body.decrease_kg == null || body.decrease_kg <= 0) { + throw new ApiError(400, "Укажите, на сколько снизить вес"); + } + let drop = roundToStep(body.decrease_kg, step); + if (drop < step) drop = step; + let weight = roundToStep(currentWeight - drop, step); + if (weight >= currentWeight) weight = roundToStep(currentWeight - step, step); + if (weight < ACCESSORY_STEP_KG) weight = ACCESSORY_STEP_KG; + await applyAccessoryWeightToFuture({ + cycleNumber: w.cycle_number, + exerciseName: exercise.name, + weightKg: weight, + fromDate: addDaysIso(today, 1), + lock: false, + }); + exercise.pending_prompt = null; + } else { + throw new ApiError(400, "Неизвестный вопрос"); + } + + const done = advancePointer(w); + if (done) { + w.status = "completed"; + w.completed_at = isoNow(); + w.rest_started_at = null; + await db.workouts.put(w); + await maybeFinishCycle(w); + } else { + w.rest_started_at = isoNow(); + await db.workouts.put(w); + } + return this.workout(workoutId); + }, + + async startRest(restDays: number) { + if (restDays < 3 || restDays > 5) throw new ApiError(400, "Отдых от 3 до 5 дней"); + const p = await ensureProfile(); + const available = new Date(Date.now() + restDays * 86400_000).toISOString(); + p.phase = "rest"; + p.probe_available_at = available; + p.rest_until = available; + await db.profile.put(p); + return profileView(p); + }, + + async probe(body: { one_rm_kg: number; start_date: string; training_weekdays?: number[] }) { + const p = await ensureProfile(); + if (p.phase === "rest" && p.probe_available_at && Date.now() < new Date(p.probe_available_at).getTime()) { + throw new ApiError(400, "Ещё рано для проходки"); + } + const weekdays = body.training_weekdays || p.training_weekdays; + if (!weekdays) throw new ApiError(400, "Не заданы дни тренировок"); + const oneRm = roundToStep(body.one_rm_kg, 1.25); + const newCycle = p.cycle_number + 1; + p.one_rm_kg = oneRm; + p.training_weekdays = weekdays; + p.start_date = body.start_date; + p.cycle_number = newCycle; + p.phase = "active"; + p.rest_until = null; + p.probe_available_at = null; + p.onboarded = true; + await db.profile.put(p); + await generateSchedule({ + oneRmKg: oneRm, + trainingWeekdays: weekdays, + startDate: body.start_date, + cycleNumber: newCycle, + }); + return profileView(p); + }, + + async export() { + const p = await ensureProfile(); + const workouts = await db.workouts.toArray(); + const accessories = await db.accessories.toArray(); + return { + version: 1, + exported_at: isoNow(), + data: { + profile: { + one_rm_kg: p.one_rm_kg, + training_weekdays: p.training_weekdays, + start_date: p.start_date, + onboarded: p.onboarded, + phase: p.phase, + rest_until: p.rest_until, + probe_available_at: p.probe_available_at, + cycle_number: p.cycle_number, + }, + workouts, + accessories, + }, + }; + }, + + async import(payload: { version: number; data: unknown }) { + if (payload.version !== 1) throw new ApiError(400, "Неподдерживаемая версия экспорта"); + const data = payload.data as { + profile?: Record; + workouts?: StoredWorkout[]; + accessories?: { cycle_number: number; exercise_name: string; weight_kg: number | null; locked: boolean; pending_weight_kg: number | null }[]; + }; + await db.workouts.clear(); + await db.accessories.clear(); + const p = await ensureProfile(); + const pr = data.profile || {}; + p.one_rm_kg = (pr.one_rm_kg as number) ?? null; + p.training_weekdays = (pr.training_weekdays as number[]) ?? null; + p.start_date = (pr.start_date as string) ?? null; + p.onboarded = Boolean(pr.onboarded); + p.phase = (pr.phase as string) || "active"; + p.rest_until = (pr.rest_until as string) ?? null; + p.probe_available_at = (pr.probe_available_at as string) ?? null; + p.cycle_number = Number(pr.cycle_number || 1); + await db.profile.put(p); + for (const a of data.accessories || []) { + await db.accessories.add({ + cycle_number: a.cycle_number, + exercise_name: a.exercise_name, + weight_kg: a.weight_kg, + locked: a.locked, + pending_weight_kg: a.pending_weight_kg, + }); + } + for (const w of data.workouts || []) { + await db.workouts.put(w); + } + return { message: "Данные импортированы" }; + }, + + async reset() { + await db.workouts.clear(); + await db.accessories.clear(); + const p = await ensureProfile(); + p.one_rm_kg = null; + p.training_weekdays = null; + p.start_date = null; + p.onboarded = false; + p.phase = "active"; + p.rest_until = null; + p.probe_available_at = null; + p.cycle_number = 1; + await db.profile.put(p); + return { message: "Прогресс удалён. Пройдите онбординг заново." }; + }, +}; diff --git a/frontend/src/local/db.ts b/frontend/src/local/db.ts new file mode 100644 index 0000000..1dc2c59 --- /dev/null +++ b/frontend/src/local/db.ts @@ -0,0 +1,51 @@ +import Dexie, { type Table } from "dexie"; +import type { MetaRow, StoredAccessory, StoredAuth, StoredProfile, StoredWorkout } from "./types"; + +export class BenchpressDB extends Dexie { + auth!: Table; + profile!: Table; + workouts!: Table; + accessories!: Table; + meta!: Table; + + constructor() { + super("benchpress_secret_v2"); + this.version(1).stores({ + auth: "id", + profile: "id", + workouts: "++id, cycle_number, scheduled_date, status", + accessories: "++id, [cycle_number+exercise_name], cycle_number", + meta: "key", + }); + } +} + +export const db = new BenchpressDB(); + +export async function nextId(key: string): Promise { + return db.transaction("rw", db.meta, async () => { + const row = await db.meta.get(key); + const next = (row?.value ?? 0) + 1; + await db.meta.put({ key, value: next }); + return next; + }); +} + +export async function ensureProfile(): Promise { + let p = await db.profile.get(1); + if (!p) { + p = { + id: 1, + one_rm_kg: null, + training_weekdays: null, + start_date: null, + onboarded: false, + phase: "active", + rest_until: null, + probe_available_at: null, + cycle_number: 1, + }; + await db.profile.put(p); + } + return p; +} diff --git a/frontend/src/local/reschedule.ts b/frontend/src/local/reschedule.ts new file mode 100644 index 0000000..a09420f --- /dev/null +++ b/frontend/src/local/reschedule.ts @@ -0,0 +1,132 @@ +export type Slot = { + id: number; + week_number: number; + day_index: number; + scheduled_date: string; + status: string; +}; + +function order(w: Slot): number { + return w.week_number * 10 + w.day_index; +} + +function addDays(iso: string, days: number): string { + const d = new Date(iso + "T12:00:00"); + d.setDate(d.getDate() + days); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function weekdayMon(iso: string): number { + const js = new Date(iso + "T12:00:00").getDay(); + return js === 0 ? 6 : js - 1; +} + +function stepToTrainingDay(iso: string, weekdays: number[], direction: 1 | -1): string { + const selected = new Set(weekdays); + let cursor = addDays(iso, direction); + for (let i = 0; i < 28; i++) { + if (selected.has(weekdayMon(cursor))) return cursor; + cursor = addDays(cursor, direction); + } + throw new Error("Не удалось найти тренировочный день"); +} + +function firstTrainingOnOrAfter(iso: string, weekdays: number[]): string { + if (new Set(weekdays).has(weekdayMon(iso))) return iso; + return stepToTrainingDay(addDays(iso, -1), weekdays, 1); +} + +function isOpen(w: Slot): boolean { + return w.status !== "completed"; +} + +function todayIso(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function nextTraining(after: string, weekdays: number[], taken: Set): string { + let date = stepToTrainingDay(after, weekdays, 1); + while (taken.has(date)) date = stepToTrainingDay(date, weekdays, 1); + return date; +} + +function placeInOrder(desired: string, previous: string | undefined, weekdays: number[], taken: Set): string { + let date = desired; + if (previous && date <= previous) date = nextTraining(previous, weekdays, taken); + while (taken.has(date)) date = nextTraining(date, weekdays, taken); + return date; +} + +export function planMove(workouts: Slot[], movedId: number, newDate: string, weekdays: number[]): Map { + const moved = workouts.find((w) => w.id === movedId); + if (!moved) throw new Error("Тренировка не найдена"); + if (!isOpen(moved)) throw new Error("Выполненную тренировку переносить нельзя"); + if (!/^\d{4}-\d{2}-\d{2}$/.test(newDate)) throw new Error("Некорректная дата"); + if (newDate < todayIso()) throw new Error("В закрытый период переносить нельзя"); + + const sorted = [...workouts].sort((a, b) => order(a) - order(b) || a.id - b.id); + const completed = sorted.filter((w) => !isOpen(w)); + if (completed.some((w) => w.scheduled_date === newDate)) { + throw new Error("На эту дату уже есть выполненная тренировка"); + } + const earlierDone = completed.filter((w) => order(w) < order(moved)); + const laterDone = completed.filter((w) => order(w) > order(moved)); + const lastEarlier = earlierDone.at(-1); + const firstLater = laterDone[0]; + if (lastEarlier && newDate <= lastEarlier.scheduled_date) { + throw new Error("Дата должна быть позже уже выполненной тренировки"); + } + if (firstLater && newDate >= firstLater.scheduled_date) { + throw new Error("Дата должна быть раньше уже выполненной следующей тренировки"); + } + + const taken = new Set(completed.map((w) => w.scheduled_date)); + const assigned = new Map(); + let index = 0; + while (index < sorted.length) { + if (!isOpen(sorted[index])) { + index += 1; + continue; + } + const start = index; + while (index < sorted.length && isOpen(sorted[index])) index += 1; + const segment = sorted.slice(start, index); + const prevAnchor = start > 0 ? sorted[start - 1] : undefined; + const nextAnchor = index < sorted.length ? sorted[index] : undefined; + const proposed = segment.map((w) => (w.id === movedId ? newDate : w.scheduled_date)).sort(); + const dates: string[] = []; + for (const raw of proposed) { + const date = placeInOrder(raw, dates.at(-1), weekdays, taken); + if (prevAnchor && date <= prevAnchor.scheduled_date) { + throw new Error("Дата должна быть позже уже выполненной тренировки"); + } + if (nextAnchor && date >= nextAnchor.scheduled_date) { + throw new Error("Дата должна быть раньше уже выполненной следующей тренировки"); + } + dates.push(date); + taken.add(date); + } + segment.forEach((w, slot) => assigned.set(w.id, dates[slot])); + } + + return assigned; +} + +export function planWeekdayChange(workouts: Slot[], weekdays: number[], startFrom: string): Map { + const open = workouts.filter(isOpen).sort((a, b) => order(a) - order(b)); + const taken = new Set(workouts.filter((w) => !isOpen(w)).map((w) => w.scheduled_date)); + const assigned = new Map(); + let cursor = startFrom; + for (const item of open) { + let date = firstTrainingOnOrAfter(cursor, weekdays); + while (taken.has(date)) date = stepToTrainingDay(date, weekdays, 1); + assigned.set(item.id, date); + taken.add(date); + cursor = addDays(date, 1); + } + return assigned; +} diff --git a/frontend/src/local/schedule.ts b/frontend/src/local/schedule.ts new file mode 100644 index 0000000..8c3776e --- /dev/null +++ b/frontend/src/local/schedule.ts @@ -0,0 +1,217 @@ +import programData from "../data/program.json" with { type: "json" }; +import { benchWeight, weightStepFor } from "./weights"; +import type { StoredAccessory, StoredExercise, StoredSet, StoredWorkout } from "./types"; +import { db, nextId } from "./db"; + +type ProgramSet = { percent?: number; reps?: number; type?: string }; +type ProgramExercise = { name: string; kind: string; sets: ProgramSet[] }; +type ProgramDay = { template_day: string; label: string; exercises: ProgramExercise[] }; +type ProgramWeek = { week: number; days: ProgramDay[] }; + +const program = programData as { weeks: ProgramWeek[] }; + +export function getProgram(): { weeks: ProgramWeek[] } { + return program; +} + +export function nextDatesForWeekdays(startIso: string, weekdays: number[], count: number): string[] { + const selected = new Set(weekdays); + const dates: string[] = []; + const cursor = new Date(startIso + "T12:00:00"); + for (let i = 0; i < 400 && dates.length < count; i++) { + const jsDay = cursor.getDay(); + const monBased = jsDay === 0 ? 6 : jsDay - 1; + if (selected.has(monBased)) { + const y = cursor.getFullYear(); + const m = String(cursor.getMonth() + 1).padStart(2, "0"); + const d = String(cursor.getDate()).padStart(2, "0"); + dates.push(`${y}-${m}-${d}`); + } + cursor.setDate(cursor.getDate() + 1); + } + if (dates.length < count) throw new Error("Не удалось построить расписание"); + return dates; +} + +async function getAccessory( + cycle: number, + name: string, +): Promise { + return db.accessories.where({ cycle_number: cycle, exercise_name: name }).first(); +} + +export async function generateSchedule(opts: { + oneRmKg: number; + trainingWeekdays: number[]; + startDate: string; + cycleNumber: number; +}): Promise { + const prog = getProgram(); + const totalDays = prog.weeks.reduce((n, w) => n + w.days.length, 0); + const dates = nextDatesForWeekdays(opts.startDate, opts.trainingWeekdays, totalDays); + let dateIdx = 0; + const created: StoredWorkout[] = []; + + for (const week of prog.weeks) { + for (let dayIndex = 0; dayIndex < week.days.length; dayIndex++) { + const day = week.days[dayIndex]; + const scheduled = dates[dateIdx++]; + const workoutId = await nextId("workout"); + const exercises: StoredExercise[] = []; + + for (let order = 0; order < day.exercises.length; order++) { + const ex = day.exercises[order]; + let needsWeight = false; + let prescribed: number | null = null; + let locked = false; + + if (ex.kind === "accessory") { + const st = await getAccessory(opts.cycleNumber, ex.name); + if (st?.locked && st.weight_kg != null) { + prescribed = st.weight_kg; + locked = true; + } else if (st?.pending_weight_kg != null) { + prescribed = st.pending_weight_kg; + } else if (st?.weight_kg != null) { + prescribed = st.weight_kg; + } else { + needsWeight = true; + } + } + + const exId = await nextId("exercise"); + const sets: StoredSet[] = []; + for (let si = 0; si < ex.sets.length; si++) { + const s = ex.sets[si]; + const setId = await nextId("set"); + if (ex.kind === "bench") { + sets.push({ + id: setId, + set_index: si, + target_percent: s.percent ?? null, + target_reps: s.reps ?? null, + target_weight_kg: benchWeight(opts.oneRmKg, s.percent ?? 0), + is_failure: false, + completed: false, + actual_reps: null, + actual_weight_kg: null, + completed_at: null, + }); + } else if (ex.kind === "failure") { + sets.push({ + id: setId, + set_index: si, + target_percent: null, + target_reps: null, + target_weight_kg: null, + is_failure: true, + completed: false, + actual_reps: null, + actual_weight_kg: null, + completed_at: null, + }); + } else { + sets.push({ + id: setId, + set_index: si, + target_percent: null, + target_reps: s.reps ?? null, + target_weight_kg: prescribed, + is_failure: false, + completed: false, + actual_reps: null, + actual_weight_kg: null, + completed_at: null, + }); + } + } + + exercises.push({ + id: exId, + order_index: order, + name: ex.name, + kind: ex.kind, + prescribed_weight_kg: prescribed, + weight_locked: locked, + needs_weight_input: needsWeight, + pending_prompt: null, + sets, + }); + } + + const workout: StoredWorkout = { + id: workoutId, + cycle_number: opts.cycleNumber, + week_number: week.week, + day_index: dayIndex, + scheduled_date: scheduled, + status: "planned", + started_at: null, + paused_at: null, + completed_at: null, + current_exercise_index: 0, + current_set_index: 0, + rest_started_at: null, + exercises, + }; + await db.workouts.put(workout); + created.push(workout); + } + } + return created; +} + +export async function applyAccessoryWeightToFuture(opts: { + cycleNumber: number; + exerciseName: string; + weightKg: number; + fromDate: string; + lock: boolean; +}): Promise { + let st = await getAccessory(opts.cycleNumber, opts.exerciseName); + if (!st) { + st = { + cycle_number: opts.cycleNumber, + exercise_name: opts.exerciseName, + weight_kg: null, + locked: false, + pending_weight_kg: null, + }; + } + if (opts.lock) { + st.weight_kg = opts.weightKg; + st.locked = true; + st.pending_weight_kg = null; + } else { + st.weight_kg = opts.weightKg; + st.pending_weight_kg = opts.weightKg; + st.locked = false; + } + if (st.id != null) await db.accessories.put(st); + else await db.accessories.add(st); + + const workouts = await db.workouts.where("cycle_number").equals(opts.cycleNumber).toArray(); + for (const w of workouts) { + if (w.status === "completed" || w.scheduled_date < opts.fromDate) continue; + let changed = false; + for (const ex of w.exercises) { + if (ex.name !== opts.exerciseName || ex.kind !== "accessory") continue; + if (ex.sets.length > 0 && ex.sets.every((s) => s.completed)) continue; + ex.prescribed_weight_kg = opts.weightKg; + ex.weight_locked = opts.lock; + ex.needs_weight_input = false; + for (const s of ex.sets) { + if (!s.completed) s.target_weight_kg = opts.weightKg; + } + changed = true; + } + if (changed) await db.workouts.put(w); + } +} + +export function todayIso(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +export { weightStepFor }; diff --git a/frontend/src/local/types.ts b/frontend/src/local/types.ts new file mode 100644 index 0000000..113ab93 --- /dev/null +++ b/frontend/src/local/types.ts @@ -0,0 +1,71 @@ +export type StoredSet = { + id: number; + set_index: number; + target_percent: number | null; + target_reps: number | null; + target_weight_kg: number | null; + is_failure: boolean; + completed: boolean; + actual_reps: number | null; + actual_weight_kg: number | null; + completed_at: string | null; +}; + +export type StoredExercise = { + id: number; + order_index: number; + name: string; + kind: string; + prescribed_weight_kg: number | null; + weight_locked: boolean; + needs_weight_input: boolean; + pending_prompt: string | null; + sets: StoredSet[]; +}; + +export type StoredWorkout = { + id: number; + cycle_number: number; + week_number: number; + day_index: number; + scheduled_date: string; + status: string; + started_at: string | null; + paused_at: string | null; + completed_at: string | null; + current_exercise_index: number; + current_set_index: number; + rest_started_at: string | null; + exercises: StoredExercise[]; +}; + +export type StoredProfile = { + id: number; + one_rm_kg: number | null; + training_weekdays: number[] | null; + start_date: string | null; + onboarded: boolean; + phase: string; + rest_until: string | null; + probe_available_at: string | null; + cycle_number: number; +}; + +export type StoredAuth = { + id: number; + password_hash: string; +}; + +export type StoredAccessory = { + id?: number; + cycle_number: number; + exercise_name: string; + weight_kg: number | null; + locked: boolean; + pending_weight_kg: number | null; +}; + +export type MetaRow = { + key: string; + value: number; +}; diff --git a/frontend/src/local/weights.ts b/frontend/src/local/weights.ts new file mode 100644 index 0000000..c8c3026 --- /dev/null +++ b/frontend/src/local/weights.ts @@ -0,0 +1,21 @@ +export const PLATE_STEP_KG = 1.25; +export const ACCESSORY_STEP_KG = 0.5; +export const PAUSE_LIMIT_MINUTES = 15; + +export function weightStepFor(name: string, kind: string): number { + if (kind === "bench" || name === "Жим") return PLATE_STEP_KG; + return ACCESSORY_STEP_KG; +} + +export function roundToStep(weightKg: number, step: number): number { + if (weightKg <= 0) return 0; + return Math.round(Math.round(weightKg / step) * step * 100) / 100; +} + +export function roundExerciseWeight(weightKg: number, name: string, kind: string): number { + return roundToStep(weightKg, weightStepFor(name, kind)); +} + +export function benchWeight(oneRmKg: number, percent: number): number { + return roundToStep(oneRmKg * (percent / 100), PLATE_STEP_KG); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..b3c2e37 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..cbe0b02 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,314 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { api, formatDhms, type Profile, type WorkoutSummary, WEEKDAY_LABELS } from "../api"; +import { AppCalendar } from "../components/AppCalendar"; + +function startOfMonth(year: number, month: number) { + return new Date(year, month - 1, 1); +} + +function daysInMonth(year: number, month: number) { + return new Date(year, month, 0).getDate(); +} + +function iso(d: Date) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function weekStrip(workouts: WorkoutSummary[], todayIso: string): WorkoutSummary[] { + const today = new Date(todayIso); + const day = today.getDay(); + const mondayOffset = day === 0 ? -6 : 1 - day; + const monday = new Date(today); + monday.setDate(today.getDate() + mondayOffset); + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + const from = iso(monday); + const to = iso(sunday); + return workouts.filter((w) => w.scheduled_date >= from && w.scheduled_date <= to); +} + +export function HomePage() { + const navigate = useNavigate(); + const now = new Date(); + const [year, setYear] = useState(now.getFullYear()); + const [month, setMonth] = useState(now.getMonth() + 1); + const [workouts, setWorkouts] = useState([]); + const [allWorkouts, setAllWorkouts] = useState([]); + const [profile, setProfile] = useState(null); + const [error, setError] = useState(""); + const [restDays, setRestDays] = useState(3); + const [probeRm, setProbeRm] = useState(""); + const [probeStart, setProbeStart] = useState(() => iso(new Date())); + const [countdown, setCountdown] = useState(0); + const [drag, setDrag] = useState<{ + id: number; + from: string; + originX: number; + originY: number; + x: number; + y: number; + hover: string | null; + moved: boolean; + } | null>(null); + const dragRef = useRef(drag); + const suppressClick = useRef(false); + + const load = async () => { + try { + const [cal, upcoming] = await Promise.all([api.calendar(year, month), api.upcoming()]); + setWorkouts(cal.workouts); + setAllWorkouts(upcoming); + setProfile(cal.profile); + setCountdown(cal.profile.rest_countdown_seconds || 0); + if (cal.profile.one_rm_kg) setProbeRm(String(cal.profile.one_rm_kg)); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка загрузки"); + } + }; + + useEffect(() => { + load(); + }, [year, month]); + + useEffect(() => { + if (!profile || profile.phase !== "rest" || profile.can_probe) return; + const t = setInterval(() => setCountdown((c) => Math.max(0, c - 1)), 1000); + return () => clearInterval(t); + }, [profile?.phase, profile?.can_probe]); + + const byDate = useMemo(() => { + const map = new Map(); + for (const w of workouts) map.set(w.scheduled_date, w); + return map; + }, [workouts]); + + const todayIso = iso(new Date()); + const strip = weekStrip(allWorkouts, todayIso); + const firstWeekday = (startOfMonth(year, month).getDay() + 6) % 7; // Mon=0 + const totalDays = daysInMonth(year, month); + + const shiftMonth = (delta: number) => { + const d = new Date(year, month - 1 + delta, 1); + setYear(d.getFullYear()); + setMonth(d.getMonth() + 1); + }; + + const statusClass = (s: string) => { + if (s === "completed") return "done"; + if (s === "in_progress" || s === "paused") return "active"; + if (s === "abandoned") return "missed"; + return "planned"; + }; + + return ( +
+
+
+

Benchpress Secret

+ {profile?.one_rm_kg != null &&

1ПМ: {profile.one_rm_kg} кг · цикл {profile.cycle_number}

} +
+ + + +
+ + {profile && (profile.phase === "rest" || profile.phase === "probe" || profile.can_probe) && ( +
+ {!profile.can_probe && profile.phase === "rest" && profile.probe_available_at == null && ( +
+

Цикл завершён

+

Выберите длительность отдыха (3–5 дней)

+
+ {[3, 4, 5].map((d) => ( + + ))} +
+ +
+ )} + + {!profile.can_probe && profile.probe_available_at && ( +
+

Отдых

+

До проходки: {formatDhms(countdown)}

+
+ )} + + {profile.can_probe && ( +
+

Сделай проходку

+

Введи новый одноповторный максимум

+ +

Дата старта нового цикла

+ + +
+ )} +
+ )} + +
+

Эта неделя

+
+ {strip.length === 0 &&

Нет тренировок на этой неделе

} + {strip.map((w) => ( + + ))} +
+
+ +
+
+ +

+ {now.toLocaleString("ru-RU", { month: "long" }) && + new Date(year, month - 1, 1).toLocaleString("ru-RU", { month: "long", year: "numeric" })} +

+ +
+
+ {WEEKDAY_LABELS.map((d) => ( +
{d}
+ ))} +
+
+ {Array.from({ length: firstWeekday }).map((_, i) => ( +
+ ))} + {Array.from({ length: totalDays }).map((_, i) => { + const day = i + 1; + const key = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + const w = byDate.get(key); + const movable = !!w && w.status !== "completed" && w.status !== "abandoned"; + return ( + + ); + })} +
+ {drag?.moved && ( +
+ {Number(drag.from.slice(8))} +
+ )} +
+ + {error &&

{error}

} +
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..9f50630 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,67 @@ +import { useState, type FormEvent } from "react"; +import { useAuth } from "../auth"; + +export function LoginPage() { + const { hasPassword, setup, login } = useAuth(); + const [password, setPassword] = useState(""); + const [password2, setPassword2] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + setBusy(true); + try { + if (!hasPassword) { + if (password.length < 4) throw new Error("Минимум 4 символа"); + if (password !== password2) throw new Error("Пароли не совпадают"); + await setup(password); + } else { + await login(password); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка"); + } finally { + setBusy(false); + } + }; + + return ( +
+
+

Benchpress Secret

+

Программа жима лёжа на 5 недель

+
+
+

{hasPassword ? "Вход" : "Создайте пароль"}

+ + {!hasPassword && ( + + )} + {error &&

{error}

} + +
+
+ ); +} diff --git a/frontend/src/pages/OnboardingPage.tsx b/frontend/src/pages/OnboardingPage.tsx new file mode 100644 index 0000000..e91c11e --- /dev/null +++ b/frontend/src/pages/OnboardingPage.tsx @@ -0,0 +1,207 @@ +import { useMemo, useState, type FormEvent } from "react"; +import { api, WEEKDAY_FULL } from "../api"; +import { AppCalendar } from "../components/AppCalendar"; +import { useAuth } from "../auth"; + +function daysOk(selected: number[]): boolean { + if (selected.length !== 3) return false; + if (new Set(selected).size !== 3) return false; + const sorted = [...selected].sort((a, b) => a - b); + for (let i = 0; i < 3; i++) { + const a = sorted[i]; + const b = sorted[(i + 1) % 3]; + const gap = i < 2 ? b - a - 1 : a + 7 - b - 1; + if (gap < 1) return false; + } + return true; +} + +function roundToPlate(value: number): number { + return Math.round(Math.round(value / 1.25) * 1.25 * 100) / 100; +} + +function formatRm(value: number): string { + return String(value); +} + +export function OnboardingPage() { + const { refresh } = useAuth(); + const [step, setStep] = useState(0); + const [oneRm, setOneRm] = useState(100); + const [oneRmText, setOneRmText] = useState("100"); + const [days, setDays] = useState([]); + const [startDate, setStartDate] = useState(() => new Date().toISOString().slice(0, 10)); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const daysValid = useMemo(() => daysOk(days), [days]); + const canFinish = daysValid && oneRm > 0 && !!startDate; + + const toggleDay = (d: number) => { + setDays((prev) => { + if (prev.includes(d)) return prev.filter((x) => x !== d); + if (prev.length >= 3) return prev; + return [...prev, d]; + }); + }; + + const commitRm = (raw = oneRmText) => { + const n = Number(raw.trim().replace(",", ".")); + const rounded = !Number.isFinite(n) || n <= 0 ? oneRm : Math.max(1.25, roundToPlate(n)); + setOneRm(rounded); + setOneRmText(formatRm(rounded)); + return rounded; + }; + + const bumpRm = (delta: number) => { + const base = commitRm(); + const next = Math.max(1.25, roundToPlate(base + delta)); + setOneRm(next); + setOneRmText(formatRm(next)); + }; + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!canFinish) return; + setBusy(true); + setError(""); + try { + await api.onboarding({ + one_rm_kg: commitRm(), + training_weekdays: days, + start_date: startDate, + }); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка"); + } finally { + setBusy(false); + } + }; + + return ( +
+
+

Benchpress Secret

+

Шаг {step + 1} из 3 — настройка программы

+
+ +
+ {["1ПМ", "Дни", "Старт"].map((label, i) => ( + + ))} +
+ +
+ {step === 0 && ( +
+

Максимум на 1 повторение

+

Жим лёжа, кг. Шаг блинов 1.25

+
+ +
+ setOneRmText(e.target.value)} + onBlur={() => commitRm()} + required + /> + кг +
+ +
+ +
+ )} + + {step === 1 && ( +
+

Выберите 3 дня

+

Между соседними днями нужен минимум 1 день перерыва

+
+ {WEEKDAY_FULL.map((label, idx) => { + const on = days.includes(idx); + return ( + + ); + })} +
+

Выбрано: {days.length} / 3

+ {days.length === 3 && !daysValid && ( +

Между днями нужен минимум 1 день перерыва

+ )} +
+ + +
+
+ )} + + {step === 2 && ( +
+

Дата старта

+

С этой даты построим расписание на 5 недель

+

Первый день программы

+ +
+

+ 1ПМ: {oneRm} кг +

+

+ Дни:{" "} + {days + .slice() + .sort((a, b) => a - b) + .map((d) => WEEKDAY_FULL[d]) + .join(", ")} +

+
+ {error &&

{error}

} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..75bff3c --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,209 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { api, WEEKDAY_FULL, type Profile } from "../api"; +import { useAuth } from "../auth"; + +function daysOk(selected: number[]): boolean { + if (selected.length !== 3) return false; + if (new Set(selected).size !== 3) return false; + const sorted = [...selected].sort((a, b) => a - b); + for (let i = 0; i < 3; i++) { + const a = sorted[i]; + const b = sorted[(i + 1) % 3]; + const gap = i < 2 ? b - a - 1 : a + 7 - b - 1; + if (gap < 1) return false; + } + return true; +} + +function formatStart(iso: string): string { + const d = new Date(iso + "T12:00:00"); + return d + .toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" }) + .replace(/\s*г\.?$/, ""); +} + +export function SettingsPage() { + const { refresh } = useAuth(); + const navigate = useNavigate(); + const fileRef = useRef(null); + const [profile, setProfile] = useState(null); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [editingDays, setEditingDays] = useState(false); + const [days, setDays] = useState([]); + const [daysBusy, setDaysBusy] = useState(false); + + useEffect(() => { + api + .profile() + .then((p) => { + setProfile(p); + setDays(p.training_weekdays ?? []); + }) + .catch(() => undefined); + }, []); + + const toggleDay = (d: number) => { + setDays((prev) => { + if (prev.includes(d)) return prev.filter((x) => x !== d); + if (prev.length >= 3) return prev; + return [...prev, d]; + }); + }; + + const saveDays = async () => { + if (!daysOk(days)) { + setError("Между днями нужен минимум 1 день перерыва"); + return; + } + setDaysBusy(true); + setError(""); + try { + const next = await api.updateTrainingDays(days); + setProfile(next); + setDays(next.training_weekdays ?? days); + setEditingDays(false); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка"); + } finally { + setDaysBusy(false); + } + }; + + const exportData = async () => { + setError(""); + try { + const data = await api.export(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `benchpress-secret-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + setMessage("Экспорт скачан"); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка экспорта"); + } + }; + + const onImport = async (file: File) => { + setError(""); + try { + const text = await file.text(); + const payload = JSON.parse(text); + const res = await api.import({ version: payload.version ?? 1, data: payload.data ?? payload }); + setMessage(res.message); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка импорта"); + } + }; + + const reset = async () => { + if ( + !confirm( + "Данные будут удалены и прогресс потерян. После этого снова выберете 1ПМ, дни и дату старта. Продолжить?", + ) + ) { + return; + } + try { + await api.reset(); + await refresh(); + navigate("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка сброса"); + } + }; + + const daysLabel = + profile?.training_weekdays + ?.slice() + .sort((a, b) => a - b) + .map((d) => WEEKDAY_FULL[d]) + .join(", ") || "—"; + + return ( +
+
+

Настройки

+
+ +
+
+

+ 1ПМ: {profile?.one_rm_kg ?? "—"} кг +

+

+ Дни: {daysLabel} +

+

+ Старт: {profile?.start_date ? formatStart(profile.start_date) : "—"} +

+
+ + + {editingDays && ( +
+
+ {WEEKDAY_FULL.map((label, idx) => { + const on = days.includes(idx); + return ( + + ); + })} +
+ {days.length === 3 && !daysOk(days) && ( +

Между днями нужен минимум 1 день перерыва

+ )} + +
+ )} + + + + { + const f = e.target.files?.[0]; + if (f) onImport(f); + }} + /> +
+ + {message &&

{message}

} + {error &&

{error}

} + +
+ + +
+
+ ); +} diff --git a/frontend/src/pages/WorkoutPage.tsx b/frontend/src/pages/WorkoutPage.tsx new file mode 100644 index 0000000..fa2fc38 --- /dev/null +++ b/frontend/src/pages/WorkoutPage.tsx @@ -0,0 +1,807 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { api, formatDateRu, formatDhms, formatKg, formatRestCountdown, type Exercise, type WorkoutDetail, type WorkoutSummary } from "../api"; +import { AppCalendar } from "../components/AppCalendar"; +import { WeightStepper } from "../components/WeightStepper"; + +const ACCESSORY_STEP = 0.5; + +function AccessoryWeightEditor({ + ex, + busy, + onSave, +}: { + ex: Exercise; + busy: boolean; + onSave: (weight: number) => void; +}) { + const step = ex.weight_step_kg || ACCESSORY_STEP; + const [value, setValue] = useState(ex.prescribed_weight_kg ?? step * 20); + + useEffect(() => { + setValue(ex.prescribed_weight_kg ?? step * 20); + }, [ex.id, ex.prescribed_weight_kg, step]); + + return ( +
+ onSave(value)} + > + + + } + /> +
+ ); +} + +function roundToStep(value: number, step: number, min: number): number { + const next = Math.round(value / step) * step; + return Math.max(min, Math.round(next * 100) / 100); +} + +function EditIcon() { + return ( + + ); +} + +function AccessorySetRow({ + workoutId, + exerciseId, + setId, + weight, + reps, + targetReps, + step, + showAdjust, + onError, +}: { + workoutId: number; + exerciseId: number; + setId: number; + weight: number; + reps: number; + targetReps: number | null; + step: number; + showAdjust: boolean; + onError: (message: string) => void; +}) { + const [rowWeight, setRowWeight] = useState(weight); + const [rowReps, setRowReps] = useState(reps); + const [rowBusy, setRowBusy] = useState(false); + + const save = async (nextWeight: number, nextReps: number) => { + const prevWeight = rowWeight; + const prevReps = rowReps; + setRowWeight(nextWeight); + setRowReps(nextReps); + setRowBusy(true); + try { + await api.adjustSet(workoutId, exerciseId, setId, { + actual_weight_kg: nextWeight, + actual_reps: nextReps, + }); + } catch (err) { + setRowWeight(prevWeight); + setRowReps(prevReps); + onError(err instanceof Error ? err.message : "Ошибка"); + } finally { + setRowBusy(false); + } + }; + + return ( +
  • + + {formatKg(rowWeight)} кг × {targetReps} + + {showAdjust && ( + + + кг + + + повт. + + + )} +
  • + ); +} + +function allSetsDone(ex: Exercise): boolean { + return ex.sets.length > 0 && ex.sets.every((s) => s.completed); +} + +function doneCount(ex: Exercise): number { + return ex.sets.filter((s) => s.completed).length; +} + +export function WorkoutPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const workoutId = Number(id); + const [workout, setWorkout] = useState(null); + const [error, setError] = useState(""); + const [reps, setReps] = useState(""); + const [, setWeightValue] = useState(10); + const [adjustAmount, setAdjustAmount] = useState(0.5); + const [restSeconds, setRestSeconds] = useState(0); + const [pauseLeftSeconds, setPauseLeftSeconds] = useState(0); + const [untilWorkoutSeconds, setUntilWorkoutSeconds] = useState(0); + const [dateOpen, setDateOpen] = useState(false); + const [schedule, setSchedule] = useState([]); + const [busy, setBusy] = useState(false); + const [expandedId, setExpandedId] = useState(null); + const [editingId, setEditingId] = useState(null); + const prevExerciseId = useRef(null); + + const applyWorkout = (w: WorkoutDetail) => { + setWorkout(w); + setRestSeconds(w.rest_elapsed_seconds || 0); + if (w.status === "paused" && w.pause_expires_at) { + const left = Math.max(0, Math.floor((new Date(w.pause_expires_at).getTime() - Date.now()) / 1000)); + setPauseLeftSeconds(left); + } else { + setPauseLeftSeconds(0); + } + const ex = w.exercises[w.current_exercise_index]; + if (ex?.kind === "accessory") { + const step = ex.weight_step_kg ?? ACCESSORY_STEP; + if (ex.prescribed_weight_kg != null) { + setWeightValue(ex.prescribed_weight_kg); + } else { + setWeightValue(step * 20); // разумный старт ~10 кг при шаге 0.5 + } + setAdjustAmount(step); + } + const next = ex?.sets.find((s) => !s.completed); + if (next?.target_reps != null) setReps(String(next.target_reps)); + }; + + const load = async () => { + const w = await api.workout(workoutId); + applyWorkout(w); + const cur = w.exercises[w.current_exercise_index]; + if (cur && (w.status === "in_progress" || w.status === "paused")) { + setExpandedId(cur.id); + prevExerciseId.current = cur.id; + } else { + setExpandedId(null); + prevExerciseId.current = null; + } + }; + + const pageRef = useRef(null); + const dragRef = useRef({ x: 0, y: 0, horizontal: false, decided: false }); + const leavingRef = useRef(false); + const location = useLocation(); + const [offset, setOffset] = useState(0); + const [glide, setGlide] = useState(false); + + useEffect(() => { + setEditingId(null); + setDateOpen(false); + load().catch((err) => setError(err instanceof Error ? err.message : "Ошибка")); + api.upcoming().then(setSchedule).catch(() => undefined); + }, [workoutId]); + + useEffect(() => { + if (!workout || workout.status !== "in_progress" || !workout.rest_started_at) return; + const t = setInterval(() => setRestSeconds((s) => s + 1), 1000); + return () => clearInterval(t); + }, [workout?.status, workout?.rest_started_at, workout?.id, workout?.current_set_index]); + + useEffect(() => { + if (!workout || workout.status !== "paused" || !workout.pause_expires_at || !workout.can_resume) return; + const tick = () => { + const left = Math.max(0, Math.floor((new Date(workout.pause_expires_at!).getTime() - Date.now()) / 1000)); + setPauseLeftSeconds(left); + }; + tick(); + const t = setInterval(tick, 1000); + return () => clearInterval(t); + }, [workout?.status, workout?.pause_expires_at, workout?.can_resume, workout?.id]); + + useEffect(() => { + if (!workout || workout.status !== "planned") return; + const tick = () => { + const start = new Date(workout.scheduled_date + "T00:00:00").getTime(); + setUntilWorkoutSeconds(Math.max(0, Math.floor((start - Date.now()) / 1000))); + }; + tick(); + const t = setInterval(tick, 1000); + return () => clearInterval(t); + }, [workout?.status, workout?.scheduled_date, workout?.id]); + + // Auto-expand current exercise when it changes; collapse the previous one + useEffect(() => { + if (!workout) return; + if (workout.status !== "in_progress" && workout.status !== "paused") return; + const cur = workout.exercises[workout.current_exercise_index]; + if (!cur) return; + if (prevExerciseId.current === cur.id) return; + setExpandedId(cur.id); + prevExerciseId.current = cur.id; + }, [workout?.current_exercise_index, workout?.status, workout?.id, workout?.exercises]); + + const currentExercise = workout?.exercises[workout.current_exercise_index]; + const currentSet = useMemo(() => { + if (!workout || !currentExercise) return null; + return currentExercise.sets.find((s) => s.set_index === workout.current_set_index) || null; + }, [workout, currentExercise]); + + const liveStatus = useMemo(() => { + if (!workout) return ""; + if (workout.status === "in_progress" && workout.rest_started_at) { + return `Отдых ${formatDhms(restSeconds)}`; + } + if (workout.status === "paused" && workout.can_resume) { + return `Отдых ${formatDhms(pauseLeftSeconds)}`; + } + if (workout.status === "in_progress") { + return "Идёт тренировка"; + } + if (workout.status === "planned") { + if (workout.can_start) return "Можно начать"; + if (untilWorkoutSeconds > 0) return `Отдых ${formatDhms(untilWorkoutSeconds)}`; + return "Запланирована"; + } + if (workout.status === "completed") return "Выполнена"; + if (workout.status === "abandoned") return "Остановлена"; + if (workout.status === "paused") return "Остановлена"; + return "Запланирована"; + }, [workout, restSeconds, pauseLeftSeconds, untilWorkoutSeconds]); + + const toggleExpanded = (exerciseId: number) => { + setExpandedId((prev) => (prev === exerciseId ? null : exerciseId)); + }; + + const run = async (fn: () => Promise) => { + setBusy(true); + setError(""); + try { + applyWorkout(await fn()); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка"); + await load().catch(() => undefined); + } finally { + setBusy(false); + } + }; + + const neighbor = useMemo(() => { + const idx = schedule.findIndex((w) => w.id === workout?.id); + return { + prev: idx > 0 ? schedule[idx - 1] : null, + next: idx >= 0 && idx < schedule.length - 1 ? schedule[idx + 1] : null, + }; + }, [schedule, workout?.id]); + + const neighborRef = useRef(neighbor); + neighborRef.current = neighbor; + const dateOpenRef = useRef(dateOpen); + dateOpenRef.current = dateOpen; + + useLayoutEffect(() => { + const slide = (location.state as { slide?: "next" | "prev" } | null)?.slide; + if (!slide) { + setGlide(false); + setOffset(0); + return; + } + setGlide(false); + setOffset(slide === "next" ? window.innerWidth : -window.innerWidth); + const frame = requestAnimationFrame(() => { + setGlide(true); + setOffset(0); + }); + return () => cancelAnimationFrame(frame); + }, [workoutId, location.state]); + + useEffect(() => { + const el = pageRef.current; + if (!el) return; + + const start = (e: globalThis.TouchEvent) => { + if (dateOpenRef.current || leavingRef.current) return; + const t = e.changedTouches[0]; + dragRef.current = { x: t.clientX, y: t.clientY, horizontal: false, decided: false }; + }; + + const move = (e: globalThis.TouchEvent) => { + if (dateOpenRef.current || leavingRef.current) return; + const drag = dragRef.current; + if (!drag.x && !drag.y) return; + const t = e.changedTouches[0]; + const dx = t.clientX - drag.x; + const dy = t.clientY - drag.y; + if (!drag.decided) { + if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return; + drag.decided = true; + drag.horizontal = Math.abs(dx) > Math.abs(dy); + } + if (!drag.horizontal) return; + e.preventDefault(); + let shift = dx; + if (shift < 0 && !neighborRef.current.next) shift *= 0.22; + if (shift > 0 && !neighborRef.current.prev) shift *= 0.22; + setGlide(false); + setOffset(shift); + }; + + const end = (e: globalThis.TouchEvent) => { + const drag = dragRef.current; + dragRef.current = { x: 0, y: 0, horizontal: false, decided: false }; + if (!drag.horizontal || dateOpenRef.current || leavingRef.current) { + if (drag.horizontal) { + setGlide(true); + setOffset(0); + } + return; + } + const t = e.changedTouches[0]; + const dx = t.clientX - drag.x; + const target = dx < 0 ? neighborRef.current.next : neighborRef.current.prev; + if (Math.abs(dx) < 70 || !target) { + setGlide(true); + setOffset(0); + return; + } + leavingRef.current = true; + const dir = dx < 0 ? "next" : "prev"; + setGlide(true); + setOffset(dir === "next" ? -window.innerWidth : window.innerWidth); + window.setTimeout(() => { + leavingRef.current = false; + navigate(`/workout/${target.id}`, { state: { slide: dir } }); + }, 230); + }; + + el.addEventListener("touchstart", start, { passive: true }); + el.addEventListener("touchmove", move, { passive: false }); + el.addEventListener("touchend", end); + return () => { + el.removeEventListener("touchstart", start); + el.removeEventListener("touchmove", move); + el.removeEventListener("touchend", end); + }; + }, [workout?.id, navigate]); + + if (!workout) { + return ( +
    +

    {error || "Загрузка…"}

    +
    + ); + } + + return ( +
    +
    +
    + {workout.status === "in_progress" && ( + + )} + {workout.status === "paused" && workout.can_resume && ( + + )} +
    +
    + +
    +

    + Неделя {workout.week_number} · день {workout.day_index + 1} +

    +

    + {formatDateRu(workout.scheduled_date)} + {!(workout.status === "planned" && !workout.can_start && untilWorkoutSeconds > 0) && ` · ${liveStatus}`} +

    + {workout.status === "planned" && !workout.can_start && untilWorkoutSeconds > 0 && ( +
    +

    Отдых

    +

    до следующей тренировки {formatRestCountdown(untilWorkoutSeconds)}

    +
    + )} + {workout.status === "paused" && !workout.can_resume && ( +

    Пауза истекла — тренировка остановлена

    + )} +
    + + {workout.status === "planned" && workout.can_start && ( + + )} + + {dateOpen && workout.status !== "completed" && ( +
    node?.scrollIntoView({ block: "start" })}> +

    Выберите новую дату

    + { + setDateOpen(false); + if (isoDate === workout.scheduled_date) return; + setBusy(true); + setError(""); + api + .moveWorkout(workout.id, isoDate) + .then(() => navigate("/")) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Ошибка"); + setBusy(false); + }); + }} + /> +
    + )} + +
    +

    Упражнения

    + {workout.exercises.map((ex, index) => { + const isOpen = expandedId === ex.id; + const isCurrent = + (workout.status === "in_progress" || workout.status === "paused") && + index === workout.current_exercise_index; + const finished = allSetsDone(ex); + + const weightKnown = ex.kind === "accessory" && !ex.needs_weight_input && ex.prescribed_weight_kg != null; + const canEdit = ex.kind === "accessory" && (finished || weightKnown); + const editing = editingId === ex.id; + const step = ex.weight_step_kg || ACCESSORY_STEP; + + return ( +
    +
    + + {canEdit && ( + + )} +
    + + {isOpen && ( +
    + {ex.kind === "accessory" && + workout.status !== "completed" && + !finished && + (ex.needs_weight_input || editing) && ( + + run(async () => { + const next = await api.setWeight(workout.id, ex.id, weight); + setEditingId((current) => (current === ex.id ? null : current)); + return next; + }) + } + /> + )} + {isCurrent && + workout.status === "in_progress" && + currentSet && + !ex.pending_prompt && ( +
    +

    + Подход {currentSet.set_index + 1} / {ex.sets.length} +

    + + {ex.kind === "bench" && ( +
    +
    + {formatKg(currentSet.target_weight_kg)} + кг +
    +
    + {currentSet.target_reps} + повт. +
    +
    + )} + + {ex.kind === "failure" && ( +
    +
    + В отказ +
    +
    + )} + + {ex.kind === "accessory" && !ex.needs_weight_input && ( + <> +
    +
    + {formatKg(currentSet.target_weight_kg)} + кг +
    +
    + {currentSet.target_reps} + цель +
    +
    + + + )} + + {!ex.needs_weight_input && ( + + )} +
    + )} + + {isCurrent && workout.status === "in_progress" && ex.pending_prompt && ( +
    + {ex.pending_prompt === "confirm_easy" && ( + <> +

    Было слишком легко?

    +
    + + +
    + + )} + {ex.pending_prompt === "choose_weight_easy" && ( + <> +

    На сколько поднять вес?

    +

    + Сейчас: {formatKg(ex.prescribed_weight_kg)} кг → будет{" "} + {formatKg((ex.prescribed_weight_kg ?? 0) + adjustAmount)} кг +

    + + Шаг 0.5 кг. Новый вес не будет меньше текущего. + + + )} + {ex.pending_prompt === "choose_weight_missed" && ( + <> +

    На сколько снизить вес?

    +

    + Сейчас: {formatKg(ex.prescribed_weight_kg)} кг → будет{" "} + {formatKg(Math.max(ACCESSORY_STEP, (ex.prescribed_weight_kg ?? 0) - adjustAmount))}{" "} + кг +

    + + Шаг 0.5 кг. Новый вес будет меньше текущего. + + + )} +
    + )} + +
      + {ex.sets.map((s) => { + const showAdjust = editing && finished && s.completed && ex.kind === "accessory"; + if (showAdjust) { + return ( + + ); + } + return ( +
    • + + {s.is_failure ? ( + <>В отказ {s.completed ? "✓" : ""} + ) : ex.kind === "bench" ? ( + <> + {formatKg(s.target_weight_kg)} кг × {s.target_reps} + {s.completed ? " ✓" : ""} + + ) : ( + <> + {formatKg(s.target_weight_kg ?? ex.prescribed_weight_kg)} кг × {s.target_reps} + + )} + +
    • + ); + })} +
    +
    + )} +
    + ); + })} +
    + + {error &&

    {error}

    } +
    + {workout.status !== "completed" && ( + + )} + +
    +
    + ); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..fb12966 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; + readonly VITE_OFFLINE?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..523bfe9 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e64cf20 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + host: true, + port: 5173, + proxy: { + "/api": "http://localhost:8000", + }, + }, +}); diff --git a/Драфт.xlsx b/Драфт.xlsx new file mode 100644 index 0000000..9cd6d6d Binary files /dev/null and b/Драфт.xlsx differ