Merge pull request '7-Add-database-to-project' (#15) from 7-Add-database-to-project into main
Reviewed-on: #15
This commit is contained in:
commit
266aa84e87
123 changed files with 55240 additions and 1673 deletions
76
CLAUDE.md
76
CLAUDE.md
|
|
@ -8,77 +8,93 @@ F1tness Parser is a FastAPI-based web application that parses fitness training d
|
|||
- **Obsidian Notes**: Markdown tables with `# DD.MM.YYYY (trainer-session)` headers
|
||||
- **Apple Notes**: Bold markdown tables with `**DD.MM.YYYY (trainer-session)**` headers
|
||||
|
||||
The application uses a layered architecture with parsers, models, services, and API endpoints.
|
||||
The project is split into two applications in one repository:
|
||||
- `backend/` - FastAPI JSON API (parsers, models, services, PostgreSQL)
|
||||
- `frontend/` - static site on plain HTML + CSS + JS (ES modules, no build step), served by nginx, which also proxies `/api/` to the backend
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
# Start development server
|
||||
python run.py
|
||||
The project is developed and run **only in Docker**: there is no local Python setup. Everything below runs inside the `fastapi-app` container (backend code lives in `backend/`, inside the container it is `/app`) (`.env` must have `POSTGRES_DATABASE_HOST=postgres`, see `.env.example`).
|
||||
|
||||
# Start with Docker Compose (includes PostgreSQL)
|
||||
### Docker Development
|
||||
```bash
|
||||
# Start backend (:8000) + frontend (:8080) + PostgreSQL (applies migrations on start, hot reload for backend/app/)
|
||||
# Open http://localhost:8080
|
||||
docker compose up --build
|
||||
|
||||
# Run migrations manually
|
||||
python -c "from migrations.runner import MigrationRunner; from app.config import settings; import asyncio; asyncio.run(MigrationRunner(settings.get_postgres_database_url()).run_migrations())"
|
||||
# Apply migrations manually (Alembic; the app itself does NOT migrate on startup)
|
||||
docker compose exec fastapi-app alembic upgrade head
|
||||
|
||||
# Create a migration after changing tables in app/core/database/tables.py
|
||||
# (-u keeps the generated file owned by you instead of root)
|
||||
docker compose exec -u "$(id -u):$(id -g)" fastapi-app alembic revision --autogenerate -m "describe change"
|
||||
|
||||
# Import all Obsidian + Apple notes into the database
|
||||
docker compose exec fastapi-app python -m app.cli import [--replace]
|
||||
|
||||
# Stop (keeps the data) / stop and drop the database volume
|
||||
docker compose down
|
||||
docker compose down -v
|
||||
```
|
||||
Rebuild (`--build`) is needed only after changing `requirements*.txt`, `Dockerfile` or `pyproject.toml`.
|
||||
|
||||
### Testing
|
||||
Tests that need a database use a separate `<POSTGRES_DATABASE_DB>_test` database (`TEST_DATABASE_URL`, created automatically). Without it they are skipped.
|
||||
```bash
|
||||
# Run tests
|
||||
pytest
|
||||
# Run all tests
|
||||
docker compose exec fastapi-app pytest
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_core/test_obsidian_parser.py
|
||||
docker compose exec fastapi-app pytest tests/test_core/test_obsidian_parser.py
|
||||
|
||||
# Run tests with coverage
|
||||
pytest --cov=app
|
||||
# Without a running stack (starts postgres, runs once, removes the container)
|
||||
docker compose run --rm fastapi-app pytest
|
||||
```
|
||||
|
||||
### Type Checking
|
||||
```bash
|
||||
# Run type checking
|
||||
mypy app/
|
||||
docker compose exec fastapi-app mypy app/
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
**Parser Architecture**: The system uses a base parser class (`app/core/parsers/base.py`) with format-specific implementations:
|
||||
**Parser Architecture**: The system uses a base parser class (`backend/app/core/parsers/base.py`) with format-specific implementations:
|
||||
- `ObsidianNotesParser` - handles Obsidian markdown format
|
||||
- `AppleNotesParser` - handles Apple notes format
|
||||
- Each parser includes exercise name mapping for normalization
|
||||
|
||||
**Data Models**: Located in `app/core/dto/training.py`:
|
||||
**Data Models**: Located in `backend/app/core/dto/training.py`:
|
||||
- `Training` - represents a workout session
|
||||
- `Exercise` - individual exercise with multiple approaches
|
||||
- `Approach` - single set with weight and reps
|
||||
|
||||
**API Structure**:
|
||||
- `/api/v1/` - REST API endpoints
|
||||
- `/app/` - Web interface endpoints
|
||||
- Static files served from `app/static/`
|
||||
- Templates in `app/templates/`
|
||||
**API Structure** (backend, JSON only):
|
||||
- `/api/v1/trainings/`, `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/parser/parse/`, `/api/v1/health/`
|
||||
|
||||
**Frontend** (`frontend/`):
|
||||
- `index.html`, `exercises.html`, `parse.html` - pages; `js/common.js` (API + DOM helpers), `js/table.js` (search/sort/paging), one module per page
|
||||
- `vendor/` - Bootstrap CSS and Chart.js (the only third-party code)
|
||||
- `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS)
|
||||
|
||||
### Database
|
||||
|
||||
The application supports both SQLite (default) and PostgreSQL. Database migrations are handled by a custom migration runner in `migrations/runner.py` that reads SQL files from `migrations/sql/`.
|
||||
The backend uses PostgreSQL through async SQLAlchemy (asyncpg). tables are described with SQLAlchemy Core (no ORM) in `backend/app/core/database/tables.py`, `TrainingRepository` (`backend/app/core/database/repository.py`) is the data access layer, and a session is injected into endpoints with `Depends(get_session)`. Schema migrations are handled by Alembic (`alembic.ini`, `migrations/`); the URL is taken from `app/config.py`.
|
||||
|
||||
Configuration is managed through `app/config.py` using Pydantic settings with `.env` file support.
|
||||
Configuration is managed through `backend/app/config.py` using Pydantic settings with `.env` file support.
|
||||
|
||||
### Key Files
|
||||
|
||||
- `app/main.py` - FastAPI application setup with lifespan management
|
||||
- `app/config.py` - Application configuration and database settings
|
||||
- `run.py` - Development server entry point
|
||||
- `migrations/runner.py` - Custom database migration system
|
||||
- `compose.yaml` - Docker Compose setup with PostgreSQL
|
||||
- `backend/app/main.py` - FastAPI application setup with lifespan management
|
||||
- `backend/app/config.py` - Application configuration and database settings
|
||||
- `backend/run.py` - Standalone uvicorn runner (unused: Docker starts uvicorn from `compose.yaml`)
|
||||
- `backend/migrations/` - Alembic environment and versions
|
||||
- `compose.yaml` - Docker Compose setup: backend, frontend, PostgreSQL
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
Copy `.env.example` to `.env` (repo root) and configure:
|
||||
- PostgreSQL connection settings
|
||||
- Debug mode
|
||||
- PYTHONPATH (for Docker)
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -2,6 +2,17 @@
|
|||
|
||||
Приложение для парсинга текстовых файлов тренировок
|
||||
|
||||
## Структура
|
||||
- `backend/` - FastAPI, JSON API (`/api/v1/...`), PostgreSQL
|
||||
- `frontend/` - статический сайт на HTML + CSS + JS, раздаётся nginx, запросы `/api/` проксирует на бэкенд
|
||||
|
||||
## Запуск
|
||||
```bash
|
||||
docker compose up --build
|
||||
docker compose exec fastapi-app python -m app.cli import # загрузить заметки в базу
|
||||
```
|
||||
Сайт: http://localhost:8080, API и документация: http://localhost:8000/docs
|
||||
|
||||
## Поддерживаемые записи
|
||||
Работает с двумя категорями записей:
|
||||
- Obsidian notes
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
from datetime import datetime
|
||||
import logging
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.database.connection import SQLiteExecutor
|
||||
from app.core.database.models.approach import ApproachOps
|
||||
from app.core.database.models.training import TrainingOps
|
||||
from app.core.database.models.exercise import ExerciseOps
|
||||
from app.core.dto.training import TrainingDTO
|
||||
from app.core.parsers.obsidian import parse_training_data
|
||||
|
||||
|
||||
sqllite_executor = SQLiteExecutor()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/obsidian/")
|
||||
async def obsidian_trainings_list():
|
||||
return {"data": parse_training_data()}
|
||||
|
||||
|
||||
@router.get("/test/create/")
|
||||
async def create_sample_training():
|
||||
test_training = TrainingDTO(date=datetime.now().date(), trainer="Stepka")
|
||||
training_executor = TrainingOps(sqllite_executor)
|
||||
result_id = await training_executor.create(test_training)
|
||||
return {"status": "ok", "create_id": result_id}
|
||||
|
||||
|
||||
@router.get("/test/get/list/")
|
||||
async def get_trainings_list():
|
||||
training_executor = TrainingOps(sqllite_executor)
|
||||
results = await training_executor.list()
|
||||
return {"status": "ok", "create_id": results}
|
||||
|
||||
|
||||
@router.delete("/test/delete/")
|
||||
async def delete_by_ids():
|
||||
training_executor = TrainingOps(sqllite_executor)
|
||||
await training_executor.delete(list_of_ids=[4,5,6])
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/test/create/")
|
||||
async def create_full_training():
|
||||
training_executor = TrainingOps(sqllite_executor)
|
||||
exercise_executor = ExerciseOps(sqllite_executor)
|
||||
approach_executor = ApproachOps(sqllite_executor)
|
||||
sample_training_data: TrainingDTO = parse_training_data()[-1]
|
||||
logging.info(sample_training_data)
|
||||
training_id: int = await training_executor.create(sample_training_data)
|
||||
if sample_training_data.exercises:
|
||||
for exercise in sample_training_data.exercises:
|
||||
exercise.training_id = training_id
|
||||
exercise_id: int = await exercise_executor.create(exercise)
|
||||
if exercise.approaches:
|
||||
for approach in exercise.approaches:
|
||||
approach.exercise_id = exercise_id
|
||||
await approach_executor.create(approach)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import logging
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.core.parsers.obsidian import ObsidianNotesParser, parse_training_data
|
||||
from app.core.templates.templates import BaseTemplateResponse
|
||||
|
||||
web_router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
template_renderer = BaseTemplateResponse(templates)
|
||||
|
||||
|
||||
@web_router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request):
|
||||
"""Home page"""
|
||||
|
||||
return template_renderer.render(
|
||||
request,
|
||||
"index.html",
|
||||
)
|
||||
|
||||
|
||||
@web_router.get("/obsidian/list/", response_class=HTMLResponse)
|
||||
async def obsidian_list(request: Request):
|
||||
data = parse_training_data()
|
||||
return template_renderer.render(
|
||||
request=request, template_name="trainings.html", context={"trainings": data}
|
||||
)
|
||||
|
||||
|
||||
@web_router.get("/obsidian/parse", response_class=HTMLResponse)
|
||||
async def obsidian_parse_form(request: Request):
|
||||
return template_renderer.render(
|
||||
request=request, template_name="obsidian/parser.html", context={"trainings": {}}
|
||||
)
|
||||
|
||||
|
||||
@web_router.post("/obsidian/parse", response_class=HTMLResponse)
|
||||
async def obsidian_parse(request: Request, training_data: str = Form(...)):
|
||||
logging.error(training_data)
|
||||
op = ObsidianNotesParser()
|
||||
result = op.parse(data=training_data)
|
||||
logging.error(result)
|
||||
return template_renderer.render(
|
||||
request=request, template_name="trainings.html", context={"trainings": result}
|
||||
)
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
from typing import Dict, List, Any
|
||||
import aiosqlite
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class SQLiteExecutor:
|
||||
"""Executor для SQL запросов в БД SQLlite"""
|
||||
def __init__(self) -> None:
|
||||
self._db_path = settings.SQLITE_DATABASE_PATH
|
||||
|
||||
async def execute_query(
|
||||
self, query: str, params: tuple = ()
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Выполнить запрос, который не должен вносить изменения в БД.
|
||||
|
||||
:param query: SQL запрос
|
||||
:param params: Параметры, которые нужно передать вместе с запросом
|
||||
:return: Список, выдаваемый aiosqlite
|
||||
"""
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def execute_mod_query(self, query: str, params: tuple = ()) -> int:
|
||||
"""Выполнить запрос, который что-то изменяет в БД.
|
||||
|
||||
:param query: SQL запрос
|
||||
:param params: Параметры, которые нужно передать вместе с запросом
|
||||
:return: Список, выдаваемый aiosqlite
|
||||
"""
|
||||
async with aiosqlite.connect(self._db_path) as db:
|
||||
cursor = await db.execute(query, params)
|
||||
await db.commit()
|
||||
return cursor.lastrowid if cursor.lastrowid else cursor.rowcount
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
from typing import List, Dict, Any
|
||||
from app.core.database.connection import SQLiteExecutor
|
||||
from app.core.dto.training import ApproachDTO
|
||||
|
||||
|
||||
class ApproachOps:
|
||||
TABLE_NAME: str = "approaches"
|
||||
|
||||
def __init__(self, executor: SQLiteExecutor) -> None:
|
||||
self._executor = executor
|
||||
|
||||
async def create(self, approach: ApproachDTO) -> int:
|
||||
query = f"INSERT INTO {self.TABLE_NAME} (exercise_id, weight, reps) VALUES (?, ?, ?)"
|
||||
return await self._executor.execute_mod_query(
|
||||
query, (approach.exercise_id, approach.weight, approach.reps)
|
||||
)
|
||||
|
||||
async def list(self) -> List[Dict[str, Any]]:
|
||||
query = f"SELECT * FROM {self.TABLE_NAME}"
|
||||
return await self._executor.execute_query(query)
|
||||
|
||||
async def delete(self, list_of_ids: List[int]) -> None:
|
||||
placeholders: str = ",".join("?" * len(list_of_ids))
|
||||
query = f"DELETE FROM {self.TABLE_NAME} WHERE id IN ({placeholders})"
|
||||
await self._executor.execute_mod_query(query, tuple(list_of_ids))
|
||||
return None
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
from typing import List, Dict, Any
|
||||
from app.core.database.connection import SQLiteExecutor
|
||||
from app.core.dto.training import ExerciseDTO
|
||||
|
||||
|
||||
class ExerciseOps:
|
||||
TABLE_NAME: str = "exercises"
|
||||
|
||||
def __init__(self, executor: SQLiteExecutor) -> None:
|
||||
self._executor = executor
|
||||
|
||||
async def create(self, exercise: ExerciseDTO) -> int:
|
||||
query = f"INSERT INTO {self.TABLE_NAME} (training_id, name, splitted_weight) VALUES (?, ?, ?)"
|
||||
return await self._executor.execute_mod_query(
|
||||
query, (exercise.training_id, exercise.name, exercise.splitted_weight)
|
||||
)
|
||||
|
||||
async def list(self) -> List[Dict[str, Any]]:
|
||||
query = f"SELECT * FROM {self.TABLE_NAME}"
|
||||
return await self._executor.execute_query(query)
|
||||
|
||||
async def delete(self, list_of_ids: List[int]) -> None:
|
||||
placeholders: str = ",".join("?" * len(list_of_ids))
|
||||
query = f"DELETE FROM {self.TABLE_NAME} WHERE id IN ({placeholders})"
|
||||
await self._executor.execute_mod_query(query, tuple(list_of_ids))
|
||||
return None
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
from typing import List, Dict, Any
|
||||
from app.core.database.connection import SQLiteExecutor
|
||||
from app.core.dto.training import TrainingDTO
|
||||
|
||||
|
||||
class TrainingOps:
|
||||
TABLE_NAME: str = "trainings"
|
||||
|
||||
def __init__(self, executor: SQLiteExecutor) -> None:
|
||||
self._executor = executor
|
||||
|
||||
async def create(self, training: TrainingDTO) -> int:
|
||||
query = f"INSERT INTO {self.TABLE_NAME} (date, trainer) VALUES (?, ?)"
|
||||
return await self._executor.execute_mod_query(
|
||||
query, (training.date, training.trainer)
|
||||
)
|
||||
|
||||
async def list(self) -> List[Dict[str, Any]]:
|
||||
query = f"SELECT * FROM {self.TABLE_NAME}"
|
||||
return await self._executor.execute_query(query)
|
||||
|
||||
async def delete(self, list_of_ids: List[int]) -> None:
|
||||
placeholders: str = ",".join("?" * len(list_of_ids))
|
||||
query = f"DELETE FROM {self.TABLE_NAME} WHERE id IN ({placeholders})"
|
||||
await self._executor.execute_mod_query(query, tuple(list_of_ids))
|
||||
return None
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import date
|
||||
|
||||
|
||||
class ApproachDTO(BaseModel):
|
||||
id: Optional[int] = None
|
||||
exercise_id: Optional[int] = None
|
||||
weight: float
|
||||
reps: int
|
||||
|
||||
|
||||
class ExerciseDTO(BaseModel):
|
||||
id: Optional[int] = None
|
||||
training_id: Optional[int] = None
|
||||
name: str
|
||||
splitted_weight: bool = False
|
||||
approaches: Optional[List[ApproachDTO]] = None
|
||||
|
||||
|
||||
class TrainingDTO(BaseModel):
|
||||
id: Optional[int] = None
|
||||
date: date
|
||||
trainer: Optional[str] = None
|
||||
exercises: Optional[List[ExerciseDTO]] = None
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import re
|
||||
from typing import List, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.dto.training import TrainingDTO
|
||||
from app.core.parsers.apple_mapper import unique_apple_exercises_mapper
|
||||
from app.core.parsers.base import BaseNotesParser
|
||||
|
||||
|
||||
class AppleNotesParser(BaseNotesParser):
|
||||
"""Parser for Apple Notes format training data."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("complete_data/apple.md", unique_apple_exercises_mapper)
|
||||
|
||||
def filter_training_data(self, training_data: str) -> str:
|
||||
"""Filter Apple-specific training data format."""
|
||||
cleaned_text = re.sub(
|
||||
r"^\|(\s+|-*|\s-*\s)\|(\s+|-*|\s-*\s)\|(\s+|-*|\s-*\s)\|$",
|
||||
"",
|
||||
training_data,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
cleaned_text = re.sub(r"^\n", "", cleaned_text, flags=re.MULTILINE)
|
||||
lines = cleaned_text.splitlines()
|
||||
redundant_lines = [
|
||||
"| | | |",
|
||||
"|---|---|---|",
|
||||
"|**Упражнение**|**Вес**|**Подходы**|",
|
||||
]
|
||||
filtered_lines = [line for line in lines if line not in redundant_lines]
|
||||
return "\n".join(filtered_lines)
|
||||
|
||||
def parse_training_header(self, training_data_line: str) -> Tuple[bool, str, str, str]:
|
||||
"""Parse Apple Notes training header format."""
|
||||
pattern: str = (
|
||||
r"^\*\*(?P<date>\d+.\d+.\d+)\s\((?P<trainer>.+)(-(?P<year_counter>.+))?\)\*\*"
|
||||
)
|
||||
match = re.search(pattern, training_data_line)
|
||||
if match:
|
||||
date = match.group("date").strip()
|
||||
trainer = match.group("trainer").strip()
|
||||
if match.group("year_counter"):
|
||||
year_count = match.group("year_counter").strip()
|
||||
else:
|
||||
year_count = "0"
|
||||
return True, date, trainer, year_count
|
||||
return False, "", "", ""
|
||||
|
||||
def create_training_from_date(self, date_str: str) -> TrainingDTO:
|
||||
"""Create Training object from date string with fallback parsing."""
|
||||
try:
|
||||
return TrainingDTO(
|
||||
date=datetime.strptime(date_str, "%d.%m.%Y").date(), exercises=[]
|
||||
)
|
||||
except ValueError:
|
||||
return TrainingDTO(
|
||||
date=datetime.strptime(date_str, "%d.%m.%y").date(), exercises=[]
|
||||
)
|
||||
|
||||
def parse(self, data: str) -> List[TrainingDTO]:
|
||||
"""Parse Apple Notes training data from string input."""
|
||||
# Override the data file reading with direct string input
|
||||
original_method = self.read_data_file
|
||||
self.read_data_file = lambda _: data
|
||||
|
||||
try:
|
||||
trainings = self.parse_and_map_training_data()
|
||||
return trainings
|
||||
finally:
|
||||
# Restore original method
|
||||
self.read_data_file = original_method
|
||||
|
||||
|
||||
def parse_training_data() -> List[TrainingDTO]:
|
||||
"""Parse Apple Notes training data."""
|
||||
parser = AppleNotesParser()
|
||||
return parser.parse_training_data()
|
||||
|
||||
|
||||
def remap_unique_exercises(apple_trainings: List[TrainingDTO]) -> List[TrainingDTO]:
|
||||
"""Remap exercise names using Apple-specific mapping (deprecated - use parser.parse_and_map_training_data())."""
|
||||
parser = AppleNotesParser()
|
||||
return parser.apply_exercise_mapping(apple_trainings)
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import os
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
|
||||
from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
|
||||
from app.core.utils.date_refactor import parse_training_date
|
||||
|
||||
|
||||
class BaseNotesParser:
|
||||
"""Base class for parsing training data from different note formats."""
|
||||
|
||||
def __init__(self, data_file_name: str, exercise_mapper: Optional[Dict[str, str]] = None):
|
||||
self.data_file_name = data_file_name
|
||||
self.project_root = os.getcwd()
|
||||
self.exercise_mapper = exercise_mapper or {}
|
||||
|
||||
def get_data_path(self) -> str:
|
||||
return os.path.join(self.project_root, "data")
|
||||
|
||||
def get_data_file_path(self, file_name: str) -> str:
|
||||
return os.path.join(self.get_data_path(), file_name)
|
||||
|
||||
def read_data_file(self, file_name: str) -> str:
|
||||
path_to_file = self.get_data_file_path(file_name)
|
||||
with open(path_to_file, "r") as f:
|
||||
content = f.read()
|
||||
return content
|
||||
|
||||
def serialize_exercise(self, reps: str, weight: str, name: str) -> ExerciseDTO:
|
||||
"""Convert raw exercise data into Exercise object with approaches."""
|
||||
reps_list: List[int] = [int(rep) for rep in reps.split("-")]
|
||||
weight_splitted: bool = False
|
||||
weight_list: List[float] = []
|
||||
|
||||
if weight:
|
||||
weight_str_list: List[str] = [weight for weight in weight.split("-")]
|
||||
if any(split_anchor in weight_str_list[0] for split_anchor in ["x", "х"]):
|
||||
weight_splitted = True
|
||||
splitter = "x" if "x" in weight_str_list[0] else "х"
|
||||
weight_list = [
|
||||
float(xweight.split(splitter)[0]) for xweight in weight_str_list
|
||||
]
|
||||
else:
|
||||
weight_list = [float(w) for w in weight_str_list]
|
||||
|
||||
approaches = []
|
||||
if not weight:
|
||||
for rep_index in range(0, len(reps_list)):
|
||||
approach = ApproachDTO(weight=0.0, reps=reps_list[rep_index])
|
||||
approaches.append(approach)
|
||||
else:
|
||||
weight_pointer = 0
|
||||
for rep_index in range(0, len(reps_list)):
|
||||
approach = ApproachDTO(
|
||||
weight=weight_list[weight_pointer], reps=reps_list[rep_index]
|
||||
)
|
||||
if rep_index < len(weight_list) - 1:
|
||||
weight_pointer += 1
|
||||
approaches.append(approach)
|
||||
|
||||
exercise = ExerciseDTO(
|
||||
name=name, approaches=approaches, splitted_weight=weight_splitted
|
||||
)
|
||||
return exercise
|
||||
|
||||
def parse_training_exercises(self, exercise_line: str) -> ExerciseDTO:
|
||||
"""Parse exercise data from a table row."""
|
||||
stripped: List[str] = [entry.strip() for entry in exercise_line.split("|")][
|
||||
1:-1
|
||||
]
|
||||
for entry in stripped:
|
||||
if entry in ["Упражнение", "Вес", "Подходы"]:
|
||||
raise ValueError
|
||||
if stripped:
|
||||
if "---" in stripped[0]:
|
||||
raise ValueError
|
||||
if len(stripped) != 3:
|
||||
raise ValueError
|
||||
return self.serialize_exercise(
|
||||
name=stripped[0], weight=stripped[1], reps=stripped[2]
|
||||
)
|
||||
raise ValueError("No valid exercise data found")
|
||||
|
||||
def filter_training_data(self, training_data: str) -> str:
|
||||
"""Filter and clean training data. Override in subclasses for specific formats."""
|
||||
return training_data
|
||||
|
||||
def parse_training_header(
|
||||
self, training_data_line: str
|
||||
) -> Tuple[bool, str, str, str]:
|
||||
"""Parse training header. Override in subclasses for specific formats."""
|
||||
raise NotImplementedError("Subclasses must implement parse_training_header")
|
||||
|
||||
def create_training_from_date(self, date_str: str) -> TrainingDTO:
|
||||
"""Create Training object from date string using utility function."""
|
||||
return TrainingDTO(date=parse_training_date(date_str), exercises=[])
|
||||
|
||||
def parse_training_data(self) -> List[TrainingDTO]:
|
||||
"""Main parsing method. Override for specific parsing logic."""
|
||||
training_data = self.filter_training_data(
|
||||
self.read_data_file(self.data_file_name)
|
||||
)
|
||||
lines = training_data.splitlines()
|
||||
current_training = None
|
||||
trains = []
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
header_parsed, date, _, _ = self.parse_training_header(line)
|
||||
if index == len(lines) - 1:
|
||||
trains.append(current_training)
|
||||
if header_parsed:
|
||||
trains.append(current_training)
|
||||
current_training = self.create_training_from_date(date)
|
||||
continue
|
||||
try:
|
||||
exr = self.parse_training_exercises(line)
|
||||
if current_training:
|
||||
current_training.exercises.append(exr)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return [train for train in trains if train is not None]
|
||||
|
||||
def apply_exercise_mapping(self, trainings: List[TrainingDTO]) -> List[TrainingDTO]:
|
||||
"""Apply exercise name mapping to all trainings."""
|
||||
for training in trainings:
|
||||
if not training or not training.exercises:
|
||||
continue
|
||||
for exercise in training.exercises:
|
||||
if not exercise:
|
||||
continue
|
||||
mapped_name = self.exercise_mapper.get(exercise.name)
|
||||
if mapped_name is not None:
|
||||
exercise.name = mapped_name
|
||||
return trainings
|
||||
|
||||
def parse_and_map_training_data(self) -> List[TrainingDTO]:
|
||||
"""Parse training data and apply exercise mapping."""
|
||||
trainings = self.parse_training_data()
|
||||
return self.apply_exercise_mapping(trainings)
|
||||
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
from app.core.dto.training import TrainingDTO
|
||||
from app.core.parsers.obsidian_mapper import obsidian_unique_exercies_mapping
|
||||
from app.core.parsers.base import BaseNotesParser
|
||||
|
||||
|
||||
class ObsidianNotesParser(BaseNotesParser):
|
||||
"""Parser for Obsidian Notes format training data."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
data_file_name="complete_data/obsidian.md",
|
||||
exercise_mapper=obsidian_unique_exercies_mapping,
|
||||
)
|
||||
|
||||
def filter_training_data(self, training_data: str) -> str:
|
||||
"""Filter Obsidian-specific training data format."""
|
||||
cleaned_text = re.sub(r"^\s*?\n", "", training_data, flags=re.MULTILINE)
|
||||
return cleaned_text
|
||||
|
||||
def parse_training_header(
|
||||
self, training_data_line: str
|
||||
) -> Tuple[bool, str, str, str]:
|
||||
"""Parse Obsidian Notes training header format."""
|
||||
pattern: str = (
|
||||
r"#\s(?P<date>\d+.\d+.\d+)\s\((?P<trainer>.+)-(?P<year_counter>.+)\)"
|
||||
)
|
||||
match = re.search(pattern, training_data_line)
|
||||
if match:
|
||||
date = match.group("date").strip()
|
||||
trainer = match.group("trainer").strip()
|
||||
year_count = match.group("year_counter").strip()
|
||||
return True, date, trainer, year_count
|
||||
return False, "", "", ""
|
||||
|
||||
def parse(self, data: str) -> List[TrainingDTO]:
|
||||
"""Parse Obsidian training data from string input."""
|
||||
# Override the data file reading with direct string input
|
||||
original_method = self.read_data_file
|
||||
self.read_data_file = lambda _: data
|
||||
|
||||
try:
|
||||
trainings = self.parse_and_map_training_data()
|
||||
return trainings
|
||||
finally:
|
||||
# Restore original method
|
||||
self.read_data_file = original_method
|
||||
|
||||
|
||||
def parse_training_data() -> List[TrainingDTO]:
|
||||
"""Parse Obsidian Notes training data."""
|
||||
parser = ObsidianNotesParser()
|
||||
return parser.parse_training_data()
|
||||
|
||||
|
||||
def remap_unique_exercises(obsidian_trainings: List[TrainingDTO]) -> List[TrainingDTO]:
|
||||
"""Remap exercise names using Obsidian-specific mapping (deprecated - use parser.parse_and_map_training_data())."""
|
||||
parser = ObsidianNotesParser()
|
||||
return parser.apply_exercise_mapping(obsidian_trainings)
|
||||
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
|
||||
class BaseTemplateResponse:
|
||||
"""Базовый метод для рендеринга Jinja2 темплейтов.
|
||||
|
||||
Нужен в первую очередь для того, чтобы подставлять какие-то дефолтные значения (которые есть
|
||||
на всех страницах) в переменные темплейтов
|
||||
|
||||
Attributes:
|
||||
templates: Формат темплейтов, используемых в проекте
|
||||
"""
|
||||
def __init__(self, templates: Jinja2Templates):
|
||||
self.templates = templates
|
||||
|
||||
def render(
|
||||
self, request: Request, template_name: str, context: Optional[dict] = None
|
||||
):
|
||||
base_context = {
|
||||
"request": request,
|
||||
"title": "Fitness stats",
|
||||
"app_name": "Fitness parser"
|
||||
}
|
||||
|
||||
if context:
|
||||
base_context.update(context)
|
||||
|
||||
return self.templates.TemplateResponse(template_name, base_context)
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
/* Custom styles for Fitness Parser */
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
7
app/static/js/bootstrap.bundle.min.js
vendored
7
app/static/js/bootstrap.bundle.min.js
vendored
File diff suppressed because one or more lines are too long
2
app/static/js/jquery-3.7.1.min.js
vendored
2
app/static/js/jquery-3.7.1.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* Main JavaScript file for Fitness Parser
|
||||
* Uses jQuery for DOM manipulation
|
||||
*/
|
||||
|
||||
$(document).ready(function() {
|
||||
console.log('Fitness Parser JS loaded');
|
||||
|
||||
// Simple example functionality
|
||||
$('.card').on('click', function() {
|
||||
$(this).toggleClass('bg-light');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}{{ title }}{% endblock %} - Fitness Parser</title>
|
||||
|
||||
<link href="{{ url_for('static', path='/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', path='/css/main.css') }}" rel="stylesheet">
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-dark bg-primary">
|
||||
<div class="container">
|
||||
<!-- TODO: find solution to not harcode links in hrefs -->
|
||||
<a class="navbar-brand" href="/app/obsidian/list">
|
||||
<strong>{{ app_name }}</strong>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mt-4">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('static', path='/js/jquery-3.7.1.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='/js/bootstrap.bundle.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='/js/main.js') }}"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mt-5">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">Fitness parser playground</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
{% extends "base.html" %} {% block content %}
|
||||
<h1>Parse training data</h1>
|
||||
<form method="post" action="/app/obsidian/parse">
|
||||
<div class="form-group">
|
||||
<label for="training_data">Place your training here</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="training_data"
|
||||
name="training_data"
|
||||
rows="10"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Parse Training</button>
|
||||
<div>
|
||||
<button class="btn btn-secondary" onclick="fetchData()">Fetch Data</button>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</form>
|
||||
{% if result %}
|
||||
<div class="mt-3">
|
||||
<h3>Result:</h3>
|
||||
<pre>{{ result }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
async function fetchData() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/v1/trainings/obsidian/",
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
document.getElementById("result").innerHTML = JSON.stringify(
|
||||
data,
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch (error) {
|
||||
document.getElementById("result").innerHTML = "Error: " + error.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Training Sessions</h1>
|
||||
|
||||
{% if trainings %}
|
||||
<p>Total trainings: {{ trainings|length }}</p>
|
||||
|
||||
{% for training in trainings %}
|
||||
<div class="training">
|
||||
<h2>Date: {{ training.date }}</h2>
|
||||
|
||||
{% if training.exercises %}
|
||||
<p>Exercises: {{ training.exercises|length }}</p>
|
||||
|
||||
{% for exercise in training.exercises %}
|
||||
<div class="exercise">
|
||||
<h3>{{ exercise.name }}</h3>
|
||||
{% if exercise.splitted_weight %}
|
||||
<span>(Split Weight)</span>
|
||||
{% endif %}
|
||||
|
||||
<ul class="approaches">
|
||||
{% for approach in exercise.approaches %}
|
||||
<li>{{ approach.weight }}kg x {{ approach.reps }} reps</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
<p>No exercises recorded</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
<p>No training data available</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -35,7 +35,6 @@ ENV/
|
|||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
uv.lock
|
||||
.mypy_cache
|
||||
.opencode
|
||||
|
||||
|
|
@ -8,14 +8,17 @@ RUN apt-get update && apt-get install -y \
|
|||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy pyproject.toml first for better caching
|
||||
COPY pyproject.toml ./
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt requirements-dev.txt ./
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -e .
|
||||
# Install Python dependencies (the project is run only in Docker, so dev tools
|
||||
# like pytest and mypy are part of the image)
|
||||
RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||
|
||||
# Copy application code
|
||||
COPY pyproject.toml alembic.ini ./
|
||||
COPY ./app ./app
|
||||
COPY ./migrations ./migrations
|
||||
COPY ./data ./data
|
||||
COPY ./tests ./tests
|
||||
|
||||
|
|
@ -26,4 +29,4 @@ ENV PYTHONPATH=/app
|
|||
EXPOSE 8000
|
||||
|
||||
# Command will be overridden by docker-compose
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
149
backend/alembic.ini
Normal file
149
backend/alembic.ini
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/migrations
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
11
backend/app/api/deps.py
Normal file
11
backend/app/api/deps.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.connection import get_session
|
||||
from app.core.database.repository import TrainingRepository
|
||||
|
||||
|
||||
def get_training_repository(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TrainingRepository:
|
||||
return TrainingRepository(session)
|
||||
71
backend/app/api/v1/exercises.py
Normal file
71
backend/app/api/v1/exercises.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.api.deps import get_training_repository
|
||||
from app.core.database.repository import TrainingRepository
|
||||
from app.core.dto.training import ExerciseNameDTO
|
||||
from app.core.metrics import Record, summarize
|
||||
from app.schemas.exercises import (
|
||||
ExerciseProgressResponse,
|
||||
RecordResponse,
|
||||
SessionMetricsResponse,
|
||||
SessionRowResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def record_response(record: Optional[Record]) -> Optional[RecordResponse]:
|
||||
return RecordResponse(value=record.value, date=record.date) if record else None
|
||||
|
||||
|
||||
@router.get("/", response_model=List[ExerciseNameDTO])
|
||||
async def list_exercise_names(
|
||||
repository: TrainingRepository = Depends(get_training_repository),
|
||||
):
|
||||
"""Distinct exercise names with the number of times each was done."""
|
||||
return await repository.list_exercise_names()
|
||||
|
||||
|
||||
@router.get("/progress/", response_model=ExerciseProgressResponse)
|
||||
async def exercise_progress(
|
||||
name: str,
|
||||
repository: TrainingRepository = Depends(get_training_repository),
|
||||
):
|
||||
"""The whole history of the exercise with progress metrics.
|
||||
|
||||
The name is matched exactly, but case-insensitively: what was typed may differ in case.
|
||||
"""
|
||||
names_by_casefold = {
|
||||
item.name.casefold(): item.name
|
||||
for item in await repository.list_exercise_names()
|
||||
}
|
||||
exact_name = names_by_casefold.get(name.strip().casefold())
|
||||
if exact_name is None:
|
||||
raise HTTPException(status_code=404, detail="Exercise not found")
|
||||
|
||||
progress = summarize(await repository.exercise_history(exact_name))
|
||||
return ExerciseProgressResponse(
|
||||
name=exact_name,
|
||||
weighted=progress.weighted,
|
||||
max_weight=progress.max_weight,
|
||||
best_primary=record_response(progress.best_primary),
|
||||
best_secondary=record_response(progress.best_secondary),
|
||||
first_primary=progress.first_primary,
|
||||
last_primary=progress.last_primary,
|
||||
primary_change=progress.primary_change,
|
||||
primary_change_percent=progress.primary_change_percent,
|
||||
rows=[
|
||||
SessionRowResponse(
|
||||
date=row.session.date,
|
||||
trainer=row.session.trainer,
|
||||
splitted_weight=row.session.splitted_weight,
|
||||
approaches=row.session.approaches,
|
||||
metrics=SessionMetricsResponse.model_validate(
|
||||
row.metrics, from_attributes=True
|
||||
),
|
||||
)
|
||||
for row in progress.rows
|
||||
],
|
||||
)
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
from fastapi import APIRouter, HTTPException
|
||||
from typing import List
|
||||
from app.schemas.parser import ParseRequest, ParseResponse
|
||||
from app.core.services.parser_service import ParserService
|
||||
|
||||
|
|
@ -7,7 +6,7 @@ router = APIRouter()
|
|||
parser_service = ParserService()
|
||||
|
||||
|
||||
@router.post("/parse/", response_model=List[ParseResponse])
|
||||
@router.post("/parse/", response_model=list[ParseResponse])
|
||||
async def parse_training_data(request: ParseRequest):
|
||||
try:
|
||||
result = await parser_service.parse_data(request)
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
from fastapi import APIRouter
|
||||
from app.api.v1.endpoints import parser, health, trainings
|
||||
from app.api.v1 import exercises, parser, health, trainings
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
api_router.include_router(parser.router, prefix="/parser", tags=["parser"])
|
||||
api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"])
|
||||
api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"])
|
||||
34
backend/app/api/v1/trainings.py
Normal file
34
backend/app/api/v1/trainings.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.api.deps import get_training_repository
|
||||
from app.core.database.repository import TrainingRepository
|
||||
from app.core.dto.training import TrainingDTO
|
||||
from app.core.parsers.obsidian import parse_training_data
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/obsidian/")
|
||||
async def obsidian_trainings_list():
|
||||
return {"data": parse_training_data()}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TrainingDTO])
|
||||
async def get_trainings_list(
|
||||
repository: TrainingRepository = Depends(get_training_repository),
|
||||
):
|
||||
return await repository.list()
|
||||
|
||||
|
||||
@router.delete("/{training_id}/")
|
||||
async def delete_training(
|
||||
training_id: int,
|
||||
repository: TrainingRepository = Depends(get_training_repository),
|
||||
):
|
||||
deleted = await repository.delete([training_id])
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Training not found")
|
||||
return {"status": "ok"}
|
||||
64
backend/app/cli.py
Normal file
64
backend/app/cli.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Command line helpers.
|
||||
|
||||
python -m app.cli import [--replace]
|
||||
|
||||
Imports all trainings from the Obsidian and Apple notes into the database.
|
||||
The schema must already exist (`alembic upgrade head`).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
from app.core.database.connection import create_engine, create_session_factory
|
||||
from app.core.database.repository import TrainingRepository
|
||||
from app.core.parsers.apple import AppleNotesParser
|
||||
from app.core.parsers.obsidian import ObsidianNotesParser
|
||||
|
||||
|
||||
async def import_notes(replace: bool) -> None:
|
||||
parsers = [ObsidianNotesParser(), AppleNotesParser()]
|
||||
trainings = []
|
||||
for parser in parsers:
|
||||
parsed = parser.parse_file()
|
||||
print(
|
||||
f"{type(parser).__name__}: {len(parsed)} trainings, "
|
||||
f"{len(parser.errors)} skipped lines"
|
||||
)
|
||||
trainings.extend(parsed)
|
||||
|
||||
engine = create_engine(settings.get_postgres_database_url())
|
||||
try:
|
||||
async with create_session_factory(engine)() as session:
|
||||
repository = TrainingRepository(session)
|
||||
existing = await repository.count()
|
||||
if existing and not replace:
|
||||
raise SystemExit(
|
||||
f"Database already has {existing} trainings, "
|
||||
"use --replace to overwrite them"
|
||||
)
|
||||
if replace:
|
||||
await repository.delete_all()
|
||||
await repository.save_many(trainings)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
print(f"Imported {len(trainings)} trainings")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
arg_parser = argparse.ArgumentParser(prog="app.cli")
|
||||
subcommands = arg_parser.add_subparsers(dest="command", required=True)
|
||||
import_parser = subcommands.add_parser("import", help="import notes into the DB")
|
||||
import_parser.add_argument(
|
||||
"--replace", action="store_true", help="delete existing trainings first"
|
||||
)
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
if args.command == "import":
|
||||
asyncio.run(import_notes(replace=args.replace))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Base app settings
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
app_name: str = "F1tness Parser API"
|
||||
version: str = "0.0.1"
|
||||
debug: bool = False
|
||||
|
||||
# SQLite database settings
|
||||
SQLITE_DATABASE_PATH: str = "fitness.db"
|
||||
|
||||
# Postgres database settings
|
||||
POSTGRES_DATABASE_URL: Optional[str] = None
|
||||
POSTGRES_DATABASE_HOST: str = "localhost"
|
||||
|
|
@ -20,16 +19,15 @@ class Settings(BaseSettings):
|
|||
POSTGRES_DATABASE_USER: str = "postgres"
|
||||
POSTGRES_DATABASE_PASSWORD: str = "password"
|
||||
|
||||
def get_postgres_database_url(self, async_driver: bool = True) -> str:
|
||||
"""Method for receiving relational database URL"""
|
||||
def get_postgres_database_url(self) -> str:
|
||||
"""SQLAlchemy async URL. POSTGRES_DATABASE_URL, if set, takes precedence."""
|
||||
|
||||
# If POSTGRES_DATABASE_URL is somehow set in env file - return it
|
||||
if self.POSTGRES_DATABASE_URL:
|
||||
return self.POSTGRES_DATABASE_URL
|
||||
|
||||
driver = "postgresql+asyncpg" if async_driver else "postgresql"
|
||||
return (
|
||||
f"{driver}://{self.POSTGRES_DATABASE_USER}:{self.POSTGRES_DATABASE_PASSWORD}"
|
||||
f"postgresql+asyncpg://{quote(self.POSTGRES_DATABASE_USER)}"
|
||||
f":{quote(self.POSTGRES_DATABASE_PASSWORD)}"
|
||||
f"@{self.POSTGRES_DATABASE_HOST}"
|
||||
f":{self.POSTGRES_DATABASE_PORT}/{self.POSTGRES_DATABASE_DB}"
|
||||
)
|
||||
24
backend/app/core/database/connection.py
Normal file
24
backend/app/core/database/connection.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from typing import AsyncIterator
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
|
||||
def create_engine(database_url: str) -> AsyncEngine:
|
||||
return create_async_engine(database_url)
|
||||
|
||||
|
||||
def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||
# expire_on_commit=False keeps ids/attributes readable after commit
|
||||
return async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
|
||||
"""FastAPI dependency: one session per request, factory is set in the app lifespan."""
|
||||
async with request.app.state.session_factory() as session:
|
||||
yield session
|
||||
169
backend/app/core/database/repository.py
Normal file
169
backend/app/core/database/repository.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional, Sequence
|
||||
|
||||
from sqlalchemy import delete, func, insert, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.tables import approaches, exercises, trainings
|
||||
from app.core.dto.training import (
|
||||
ApproachDTO,
|
||||
ExerciseDTO,
|
||||
ExerciseNameDTO,
|
||||
ExerciseSessionDTO,
|
||||
TrainingDTO,
|
||||
)
|
||||
|
||||
|
||||
class TrainingRepository:
|
||||
"""Storage for trainings together with their exercises and approaches (SQLAlchemy Core)."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def save(self, training: TrainingDTO) -> int:
|
||||
return (await self.save_many([training]))[0]
|
||||
|
||||
async def save_many(self, items: Sequence[TrainingDTO]) -> List[int]:
|
||||
"""Save all trainings in a single transaction, returns their ids."""
|
||||
if not items:
|
||||
return []
|
||||
training_ids = await self._insert_returning_ids(
|
||||
trainings,
|
||||
[{"date": item.date, "trainer": item.trainer} for item in items],
|
||||
)
|
||||
|
||||
exercise_rows = []
|
||||
exercise_dtos = []
|
||||
for training_id, item in zip(training_ids, items):
|
||||
for exercise in item.exercises:
|
||||
exercise_rows.append(
|
||||
{
|
||||
"training_id": training_id,
|
||||
"name": exercise.name,
|
||||
"splitted_weight": exercise.splitted_weight,
|
||||
}
|
||||
)
|
||||
exercise_dtos.append(exercise)
|
||||
if exercise_rows:
|
||||
exercise_ids = await self._insert_returning_ids(exercises, exercise_rows)
|
||||
approach_rows = [
|
||||
{"exercise_id": exercise_id, "weight": a.weight, "reps": a.reps}
|
||||
for exercise_id, exercise in zip(exercise_ids, exercise_dtos)
|
||||
for a in exercise.approaches
|
||||
]
|
||||
if approach_rows:
|
||||
await self._session.execute(insert(approaches), approach_rows)
|
||||
|
||||
await self._session.commit()
|
||||
return training_ids
|
||||
|
||||
async def list(self) -> List[TrainingDTO]:
|
||||
training_rows = (
|
||||
await self._session.execute(
|
||||
select(trainings).order_by(trainings.c.date.desc(), trainings.c.id.desc())
|
||||
)
|
||||
).all()
|
||||
approaches_by_exercise = await self._approaches_by_exercise()
|
||||
exercises_by_training: Dict[int, List[ExerciseDTO]] = defaultdict(list)
|
||||
exercise_rows = await self._session.execute(
|
||||
select(exercises).order_by(exercises.c.id)
|
||||
)
|
||||
for row in exercise_rows:
|
||||
exercises_by_training[row.training_id].append(
|
||||
ExerciseDTO(
|
||||
id=row.id,
|
||||
training_id=row.training_id,
|
||||
name=row.name,
|
||||
splitted_weight=row.splitted_weight,
|
||||
approaches=approaches_by_exercise[row.id],
|
||||
)
|
||||
)
|
||||
return [
|
||||
TrainingDTO(
|
||||
id=row.id,
|
||||
date=row.date,
|
||||
trainer=row.trainer,
|
||||
exercises=exercises_by_training[row.id],
|
||||
)
|
||||
for row in training_rows
|
||||
]
|
||||
|
||||
async def list_exercise_names(self) -> List[ExerciseNameDTO]:
|
||||
"""Distinct exercise names with the number of times each was done."""
|
||||
query = select(
|
||||
exercises.c.name, func.count(exercises.c.id).label("times")
|
||||
).group_by(exercises.c.name)
|
||||
result = await self._session.execute(query)
|
||||
names = [ExerciseNameDTO(name=row.name, count=row.times) for row in result]
|
||||
# Sorted here, not in SQL: the order must not depend on DB collation
|
||||
return sorted(names, key=lambda item: item.name.casefold())
|
||||
|
||||
async def exercise_history(self, name: str) -> List[ExerciseSessionDTO]:
|
||||
"""Every occurrence of the exercise, newest first."""
|
||||
query = (
|
||||
select(
|
||||
exercises.c.id,
|
||||
exercises.c.splitted_weight,
|
||||
trainings.c.date,
|
||||
trainings.c.trainer,
|
||||
)
|
||||
.join(trainings, trainings.c.id == exercises.c.training_id)
|
||||
.where(exercises.c.name == name)
|
||||
.order_by(trainings.c.date.desc(), exercises.c.id.desc())
|
||||
)
|
||||
rows = (await self._session.execute(query)).all()
|
||||
approaches_by_exercise = await self._approaches_by_exercise(
|
||||
[row.id for row in rows]
|
||||
)
|
||||
return [
|
||||
ExerciseSessionDTO(
|
||||
date=row.date,
|
||||
trainer=row.trainer,
|
||||
splitted_weight=row.splitted_weight,
|
||||
approaches=approaches_by_exercise[row.id],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def count(self) -> int:
|
||||
return await self._session.scalar(select(func.count(trainings.c.id))) or 0
|
||||
|
||||
async def delete(self, list_of_ids: List[int]) -> int:
|
||||
"""Delete trainings by ids (exercises and approaches go with them)."""
|
||||
result = await self._session.execute(
|
||||
delete(trainings).where(trainings.c.id.in_(list_of_ids))
|
||||
)
|
||||
await self._session.commit()
|
||||
return result.rowcount # type: ignore[attr-defined]
|
||||
|
||||
async def delete_all(self) -> None:
|
||||
await self._session.execute(
|
||||
text("TRUNCATE trainings RESTART IDENTITY CASCADE")
|
||||
)
|
||||
await self._session.commit()
|
||||
|
||||
async def _insert_returning_ids(self, table, rows: List[dict]) -> List[int]:
|
||||
"""Bulk insert, ids come back in the order of `rows`."""
|
||||
result = await self._session.execute(
|
||||
insert(table).returning(table.c.id, sort_by_parameter_order=True), rows
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
async def _approaches_by_exercise(
|
||||
self, exercise_ids: Optional[Sequence[int]] = None
|
||||
) -> Dict[int, List[ApproachDTO]]:
|
||||
"""Approaches grouped by exercise id (all of them when no ids are given)."""
|
||||
query = select(approaches).order_by(approaches.c.id)
|
||||
if exercise_ids is not None:
|
||||
query = query.where(approaches.c.exercise_id.in_(exercise_ids))
|
||||
grouped: Dict[int, List[ApproachDTO]] = defaultdict(list)
|
||||
for row in await self._session.execute(query):
|
||||
grouped[row.exercise_id].append(
|
||||
ApproachDTO(
|
||||
id=row.id,
|
||||
exercise_id=row.exercise_id,
|
||||
weight=row.weight,
|
||||
reps=row.reps,
|
||||
)
|
||||
)
|
||||
return grouped
|
||||
54
backend/app/core/database/tables.py
Normal file
54
backend/app/core/database/tables.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
MetaData,
|
||||
String,
|
||||
Table,
|
||||
func,
|
||||
)
|
||||
|
||||
metadata = MetaData()
|
||||
|
||||
trainings = Table(
|
||||
"trainings",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("date", Date, nullable=False, index=True),
|
||||
Column("trainer", String(100)),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
exercises = Table(
|
||||
"exercises",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column(
|
||||
"training_id",
|
||||
ForeignKey("trainings.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
Column("name", String(255), nullable=False, index=True),
|
||||
Column("splitted_weight", Boolean, nullable=False, server_default="false"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
approaches = Table(
|
||||
"approaches",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column(
|
||||
"exercise_id",
|
||||
ForeignKey("exercises.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
Column("weight", Float, nullable=False),
|
||||
Column("reps", Integer, nullable=False),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
45
backend/app/core/dto/training.py
Normal file
45
backend/app/core/dto/training.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import date
|
||||
|
||||
|
||||
class DTOBase(BaseModel):
|
||||
# Allows TrainingDTO.model_validate(<ORM model>)
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ApproachDTO(DTOBase):
|
||||
id: int | None = None
|
||||
exercise_id: int | None = None
|
||||
weight: float
|
||||
reps: int
|
||||
|
||||
|
||||
class ExerciseDTO(DTOBase):
|
||||
id: int | None = None
|
||||
training_id: int | None = None
|
||||
name: str
|
||||
splitted_weight: bool = False
|
||||
approaches: list[ApproachDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TrainingDTO(DTOBase):
|
||||
id: int | None = None
|
||||
date: date
|
||||
trainer: str | None = None
|
||||
exercises: list[ExerciseDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExerciseNameDTO(DTOBase):
|
||||
"""Distinct exercise name and how many times it was done."""
|
||||
|
||||
name: str
|
||||
count: int
|
||||
|
||||
|
||||
class ExerciseSessionDTO(DTOBase):
|
||||
"""One occurrence of an exercise in the history of that exercise."""
|
||||
|
||||
date: date
|
||||
trainer: str | None = None
|
||||
splitted_weight: bool = False
|
||||
approaches: list[ApproachDTO] = Field(default_factory=list)
|
||||
120
backend/app/core/metrics.py
Normal file
120
backend/app/core/metrics.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Progress metrics of an exercise computed from its history.
|
||||
|
||||
Weighted exercises are tracked by the estimated one-rep max (e1RM) and by the
|
||||
volume load; exercises done without extra weight (pull-ups, dips) by the best
|
||||
set and the total number of reps.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
from app.core.dto.training import ApproachDTO, ExerciseSessionDTO
|
||||
|
||||
|
||||
def estimate_1rm(weight: float, reps: int) -> float:
|
||||
"""Epley formula: weight * (1 + reps / 30). A single rep is the weight itself."""
|
||||
return weight if reps <= 1 else weight * (1 + reps / 30)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionMetrics:
|
||||
max_weight: float
|
||||
e1rm: Optional[float] # best set by e1RM, None when there are no weighted sets
|
||||
volume: float # sum of weight * reps over weighted sets
|
||||
total_reps: int # all sets, with or without weight
|
||||
best_reps: int # reps of the best set
|
||||
|
||||
|
||||
def session_metrics(approaches: Sequence[ApproachDTO]) -> SessionMetrics:
|
||||
# Sets without weight (warm-ups, bodyweight) don't count for e1RM and volume
|
||||
weighted_sets = [approach for approach in approaches if approach.weight > 0]
|
||||
return SessionMetrics(
|
||||
max_weight=max((approach.weight for approach in approaches), default=0.0),
|
||||
e1rm=max(
|
||||
(estimate_1rm(approach.weight, approach.reps) for approach in weighted_sets),
|
||||
default=None,
|
||||
),
|
||||
volume=sum(approach.weight * approach.reps for approach in weighted_sets),
|
||||
total_reps=sum(approach.reps for approach in approaches),
|
||||
best_reps=max((approach.reps for approach in approaches), default=0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionRow:
|
||||
session: ExerciseSessionDTO
|
||||
metrics: SessionMetrics
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Record:
|
||||
value: float
|
||||
date: date
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExerciseProgress:
|
||||
# True if any session has weighted sets: then e1RM and volume are the main
|
||||
# metrics, otherwise reps of the best set and total reps
|
||||
weighted: bool
|
||||
rows: List[SessionRow] # same order as the history: newest first
|
||||
max_weight: float
|
||||
best_primary: Optional[Record] # e1RM / best set reps
|
||||
best_secondary: Optional[Record] # volume / total reps of a session
|
||||
first_primary: Optional[float] # in the oldest session that has the metric
|
||||
last_primary: Optional[float] # in the newest session that has the metric
|
||||
|
||||
@property
|
||||
def primary_change(self) -> Optional[float]:
|
||||
"""Absolute change of the main metric between the first and the last session."""
|
||||
if self.first_primary is None or self.last_primary is None:
|
||||
return None
|
||||
return self.last_primary - self.first_primary
|
||||
|
||||
@property
|
||||
def primary_change_percent(self) -> Optional[float]:
|
||||
change = self.primary_change
|
||||
if change is None or not self.first_primary:
|
||||
return None
|
||||
return change / self.first_primary * 100
|
||||
|
||||
|
||||
def summarize(history: Sequence[ExerciseSessionDTO]) -> ExerciseProgress:
|
||||
"""Progress of an exercise; history is newest first (as the repository returns it)."""
|
||||
rows = [SessionRow(item, session_metrics(item.approaches)) for item in history]
|
||||
weighted = any(row.metrics.e1rm is not None for row in rows)
|
||||
|
||||
def primary(row: SessionRow) -> Optional[float]:
|
||||
return row.metrics.e1rm if weighted else float(row.metrics.best_reps)
|
||||
|
||||
def secondary(row: SessionRow) -> float:
|
||||
return row.metrics.volume if weighted else float(row.metrics.total_reps)
|
||||
|
||||
best_primary: Optional[Record] = None
|
||||
best_secondary: Optional[Record] = None
|
||||
first_primary: Optional[float] = None
|
||||
last_primary: Optional[float] = None
|
||||
|
||||
# Oldest first: a record is kept on the date it was first reached
|
||||
for row in reversed(rows):
|
||||
value = primary(row)
|
||||
if value is not None:
|
||||
if first_primary is None:
|
||||
first_primary = value
|
||||
last_primary = value
|
||||
if best_primary is None or value > best_primary.value:
|
||||
best_primary = Record(value, row.session.date)
|
||||
extra = secondary(row)
|
||||
if best_secondary is None or extra > best_secondary.value:
|
||||
best_secondary = Record(extra, row.session.date)
|
||||
|
||||
return ExerciseProgress(
|
||||
weighted=weighted,
|
||||
rows=rows,
|
||||
max_weight=max((row.metrics.max_weight for row in rows), default=0.0),
|
||||
best_primary=best_primary,
|
||||
best_secondary=best_secondary,
|
||||
first_primary=first_primary,
|
||||
last_primary=last_primary,
|
||||
)
|
||||
52
backend/app/core/parsers/apple.py
Normal file
52
backend/app/core/parsers/apple.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from app.core.dto.training import TrainingDTO
|
||||
from app.core.parsers.apple_mapper import unique_apple_exercises_mapper
|
||||
from app.core.parsers.base import BaseNotesParser, TrainingHeader
|
||||
|
||||
|
||||
class AppleNotesParser(BaseNotesParser):
|
||||
"""Parser for Apple Notes format training data."""
|
||||
|
||||
HEADER_PATTERN = re.compile(
|
||||
r"^\*\*(?P<date>\d+.\d+.\d+)\s\((?P<trainer>.+?)(?:-(?P<year_counter>[^)]+))?\)\*\*"
|
||||
)
|
||||
REDUNDANT_LINES = [
|
||||
"| | | |",
|
||||
"|---|---|---|",
|
||||
"|**Упражнение**|**Вес**|**Подходы**|",
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("complete_data/apple.md", unique_apple_exercises_mapper)
|
||||
|
||||
def filter_training_data(self, training_data: str) -> str:
|
||||
"""Filter Apple-specific training data format."""
|
||||
cleaned_text = re.sub(
|
||||
r"^\|(\s+|-*|\s-*\s)\|(\s+|-*|\s-*\s)\|(\s+|-*|\s-*\s)\|$",
|
||||
"",
|
||||
training_data,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
cleaned_text = re.sub(r"^\n", "", cleaned_text, flags=re.MULTILINE)
|
||||
lines = cleaned_text.splitlines()
|
||||
filtered_lines = [line for line in lines if line not in self.REDUNDANT_LINES]
|
||||
return "\n".join(filtered_lines)
|
||||
|
||||
def parse_training_header(self, training_data_line: str) -> Optional[TrainingHeader]:
|
||||
"""Parse Apple Notes training header format."""
|
||||
match = self.HEADER_PATTERN.search(training_data_line)
|
||||
if not match:
|
||||
return None
|
||||
year_counter = match.group("year_counter")
|
||||
return TrainingHeader(
|
||||
date=match.group("date").strip(),
|
||||
trainer=match.group("trainer").strip(),
|
||||
year_counter=year_counter.strip() if year_counter else None,
|
||||
)
|
||||
|
||||
|
||||
def parse_training_data() -> List[TrainingDTO]:
|
||||
"""Parse Apple Notes training data."""
|
||||
return AppleNotesParser().parse_file()
|
||||
|
|
@ -179,6 +179,7 @@ unique_apple_exercises_mapper = {
|
|||
"Жим от груди сидя в тренажере (около трицепс машины)": "Жим от груди сидя в тренажере (рычаги)",
|
||||
"Жим от груди сидя в тренажёре": "Жим от груди сидя в тренажере (рычаги)",
|
||||
"Жим от груди сидя в тренажёре (блинчики)": "Жим от груди сидя в тренажере (рычаги)",
|
||||
"Жим от груди сидя в тренажере (блинчики)": "Жим от груди сидя в тренажере (рычаги)",
|
||||
"Силовой тренажер": "Жим от груди сидя в тренажере (рычаги)",
|
||||
|
||||
# Жим от плеч вверх в тренажере (рычаги)
|
||||
|
|
@ -239,6 +240,7 @@ unique_apple_exercises_mapper = {
|
|||
"Разгибание рук стоя с канатом": "Отжимание на трицепс в кроссовере",
|
||||
"Трацепс": "Отжимание на трицепс в кроссовере",
|
||||
"Трицепс вниз у стойки": "Отжимание на трицепс в кроссовере",
|
||||
"Отжимания на трицепс в кроссовере": "Отжимание на трицепс в кроссовере",
|
||||
|
||||
# Подтягивания
|
||||
"Подтягивание на заднюю дельту в петлях": "Подтягивания",
|
||||
|
|
@ -253,6 +255,7 @@ unique_apple_exercises_mapper = {
|
|||
# Подъем гантелей на бицепс стоя
|
||||
"Гантели": "Подъем гантелей на бицепс стоя",
|
||||
"Жим гантелей стоя": "Подъем гантелей на бицепс стоя",
|
||||
"Подъем гантелей стоя": "Подъем гантелей на бицепс стоя",
|
||||
"Сгибание рук с гантелями стоя": "Подъем гантелей на бицепс стоя",
|
||||
"Сгибание рук с гантелями стоя ям": "Подъем гантелей на бицепс стоя",
|
||||
|
||||
139
backend/app/core/parsers/base.py
Normal file
139
backend/app/core/parsers/base.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import logging
|
||||
import os
|
||||
from typing import Dict, List, NamedTuple, Optional
|
||||
|
||||
from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
|
||||
from app.core.utils.date_refactor import parse_training_date
|
||||
|
||||
TABLE_HEADER_CELLS = {"Упражнение", "Вес", "Подходы"}
|
||||
|
||||
|
||||
class ParseError(ValueError):
|
||||
"""Raised when a line looks like training data but can't be parsed."""
|
||||
|
||||
|
||||
class TrainingHeader(NamedTuple):
|
||||
date: str
|
||||
trainer: str
|
||||
year_counter: Optional[str] = None
|
||||
|
||||
|
||||
class BaseNotesParser:
|
||||
"""Base class for parsing training data from different note formats."""
|
||||
|
||||
def __init__(self, data_file_name: str, exercise_mapper: Optional[Dict[str, str]] = None):
|
||||
self.data_file_name = data_file_name
|
||||
self.project_root = os.getcwd()
|
||||
self.exercise_mapper = exercise_mapper or {}
|
||||
# Lines skipped by the last parse() call
|
||||
self.errors: List[str] = []
|
||||
|
||||
def get_data_path(self) -> str:
|
||||
return os.path.join(self.project_root, "data")
|
||||
|
||||
def get_data_file_path(self, file_name: str) -> str:
|
||||
return os.path.join(self.get_data_path(), file_name)
|
||||
|
||||
def read_data_file(self, file_name: str) -> str:
|
||||
path_to_file = self.get_data_file_path(file_name)
|
||||
with open(path_to_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return content
|
||||
|
||||
def serialize_exercise(self, reps: str, weight: str, name: str) -> ExerciseDTO:
|
||||
"""Convert raw exercise data into Exercise object with approaches."""
|
||||
try:
|
||||
reps_list: List[int] = [int(rep) for rep in reps.split("-")]
|
||||
# "15х2" (cyrillic х) and "15x2" mean the same: 15 kg per hand
|
||||
weight_tokens: List[str] = weight.replace("х", "x").split("-") if weight else []
|
||||
# "0-12х2-16х2": the first approach may have no weight, so check all of them
|
||||
weight_splitted: bool = any("x" in token for token in weight_tokens)
|
||||
weight_list: List[float] = [float(w.split("x")[0]) for w in weight_tokens]
|
||||
except ValueError as e:
|
||||
raise ParseError(f"name={name!r}, weight={weight!r}, reps={reps!r}") from e
|
||||
|
||||
# Last weight is reused for the remaining approaches
|
||||
approaches = [
|
||||
ApproachDTO(
|
||||
weight=weight_list[min(index, len(weight_list) - 1)] if weight_list else 0.0,
|
||||
reps=rep,
|
||||
)
|
||||
for index, rep in enumerate(reps_list)
|
||||
]
|
||||
return ExerciseDTO(
|
||||
name=name, approaches=approaches, splitted_weight=weight_splitted
|
||||
)
|
||||
|
||||
def parse_exercise_row(self, exercise_line: str) -> Optional[ExerciseDTO]:
|
||||
"""Parse exercise data from a table row.
|
||||
|
||||
Returns None for lines that are not exercise rows (headings, separators, text)
|
||||
and raises ParseError for rows that are exercises but are malformed.
|
||||
"""
|
||||
if "|" not in exercise_line:
|
||||
return None
|
||||
cells: List[str] = [entry.strip() for entry in exercise_line.split("|")][1:-1]
|
||||
if not cells or "---" in cells[0] or TABLE_HEADER_CELLS & set(cells):
|
||||
return None
|
||||
if len(cells) != 3:
|
||||
raise ParseError(f"expected 3 columns, got {len(cells)}")
|
||||
name, weight, reps = cells
|
||||
return self.serialize_exercise(name=name, weight=weight, reps=reps)
|
||||
|
||||
def filter_training_data(self, training_data: str) -> str:
|
||||
"""Filter and clean training data. Override in subclasses for specific formats."""
|
||||
return training_data
|
||||
|
||||
def parse_training_header(self, training_data_line: str) -> Optional[TrainingHeader]:
|
||||
"""Parse training header. Override in subclasses for specific formats."""
|
||||
raise NotImplementedError("Subclasses must implement parse_training_header")
|
||||
|
||||
def parse(self, data: str, map_names: bool = True) -> List[TrainingDTO]:
|
||||
"""Parse trainings from a string with notes."""
|
||||
self.errors = []
|
||||
lines = self.filter_training_data(data).splitlines()
|
||||
trainings: List[TrainingDTO] = []
|
||||
current_training: Optional[TrainingDTO] = None
|
||||
|
||||
for line_number, line in enumerate(lines, start=1):
|
||||
header = self.parse_training_header(line)
|
||||
if header:
|
||||
try:
|
||||
current_training = TrainingDTO(
|
||||
date=parse_training_date(header.date), trainer=header.trainer
|
||||
)
|
||||
except ValueError as e:
|
||||
# Exercises below belong to an unknown training, skip them
|
||||
current_training = None
|
||||
self._report_error(line_number, line, e)
|
||||
continue
|
||||
trainings.append(current_training)
|
||||
continue
|
||||
|
||||
if current_training is None:
|
||||
continue
|
||||
try:
|
||||
exercise = self.parse_exercise_row(line)
|
||||
except ParseError as e:
|
||||
self._report_error(line_number, line, e)
|
||||
continue
|
||||
if exercise:
|
||||
current_training.exercises.append(exercise)
|
||||
|
||||
return self.apply_exercise_mapping(trainings) if map_names else trainings
|
||||
|
||||
def parse_file(self, map_names: bool = True) -> List[TrainingDTO]:
|
||||
"""Parse trainings from the parser's data file."""
|
||||
return self.parse(self.read_data_file(self.data_file_name), map_names=map_names)
|
||||
|
||||
def _report_error(self, line_number: int, line: str, error: Exception) -> None:
|
||||
message = f"line {line_number}: {error} ({line.strip()!r})"
|
||||
self.errors.append(message)
|
||||
logging.warning(f"{type(self).__name__} skipped {message}")
|
||||
|
||||
def apply_exercise_mapping(self, trainings: List[TrainingDTO]) -> List[TrainingDTO]:
|
||||
"""Apply exercise name mapping to all trainings."""
|
||||
for training in trainings:
|
||||
for exercise in training.exercises or []:
|
||||
exercise.name = self.exercise_mapper.get(exercise.name, exercise.name)
|
||||
return trainings
|
||||
41
backend/app/core/parsers/obsidian.py
Normal file
41
backend/app/core/parsers/obsidian.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from app.core.dto.training import TrainingDTO
|
||||
from app.core.parsers.obsidian_mapper import obsidian_unique_exercies_mapping
|
||||
from app.core.parsers.base import BaseNotesParser, TrainingHeader
|
||||
|
||||
|
||||
class ObsidianNotesParser(BaseNotesParser):
|
||||
"""Parser for Obsidian Notes format training data."""
|
||||
|
||||
HEADER_PATTERN = re.compile(
|
||||
r"#\s(?P<date>\d+.\d+.\d+)\s\((?P<trainer>.+)-(?P<year_counter>.+)\)"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
data_file_name="complete_data/obsidian.md",
|
||||
exercise_mapper=obsidian_unique_exercies_mapping,
|
||||
)
|
||||
|
||||
def filter_training_data(self, training_data: str) -> str:
|
||||
"""Filter Obsidian-specific training data format."""
|
||||
cleaned_text = re.sub(r"^\s*?\n", "", training_data, flags=re.MULTILINE)
|
||||
return cleaned_text
|
||||
|
||||
def parse_training_header(self, training_data_line: str) -> Optional[TrainingHeader]:
|
||||
"""Parse Obsidian Notes training header format."""
|
||||
match = self.HEADER_PATTERN.search(training_data_line)
|
||||
if not match:
|
||||
return None
|
||||
return TrainingHeader(
|
||||
date=match.group("date").strip(),
|
||||
trainer=match.group("trainer").strip(),
|
||||
year_counter=match.group("year_counter").strip(),
|
||||
)
|
||||
|
||||
|
||||
def parse_training_data() -> List[TrainingDTO]:
|
||||
"""Parse Obsidian Notes training data."""
|
||||
return ObsidianNotesParser().parse_file()
|
||||
|
|
@ -12,8 +12,8 @@ def parse_old_data() -> List[TrainingDTO]:
|
|||
obsidian_parser = ObsidianNotesParser()
|
||||
apple_parser = AppleNotesParser()
|
||||
|
||||
o_trainings = obsidian_parser.parse_and_map_training_data()
|
||||
a_trainings = apple_parser.parse_and_map_training_data()
|
||||
o_trainings = obsidian_parser.parse_file()
|
||||
a_trainings = apple_parser.parse_file()
|
||||
|
||||
return o_trainings + a_trainings
|
||||
|
||||
|
|
@ -1,21 +1,23 @@
|
|||
import re
|
||||
from datetime import date
|
||||
|
||||
DATE_PATTERNS = [
|
||||
r"^(?P<day>[0-9]{2})\.(?P<month>[0-9]{2})\.(?P<year>[0-9]{4})",
|
||||
r"^(?P<day>[0-9]{2})\.(?P<month>[0-9]{2})\.(?P<year>[0-9]{2})(?![0-9])",
|
||||
r"^(?P<year>[0-9]{4})\-(?P<month>[0-9]{2})\-(?P<day>[0-9]{2})",
|
||||
r"^(?P<year>[0-9]{4})\.(?P<month>[0-9]{2})\.(?P<day>[0-9]{2})",
|
||||
]
|
||||
|
||||
|
||||
def parse_training_date(raw_date: str) -> date:
|
||||
patterns = [
|
||||
r"^(?P<day>[0-9]{2})\.(?P<month>[0-9]{2})\.(?P<year>[0-9]{4})*",
|
||||
r"^(?P<year>[0-9]{4})\-(?P<month>[0-9]{2})\-(?P<day>[0-9]{2})*",
|
||||
r"^(?P<year>[0-9]{4})\.(?P<month>[0-9]{2})\.(?P<day>[0-9]{2})*",
|
||||
r"^(?P<day>[0-9]{2})\.(?P<month>[0-9]{2})\.(?P<year>[0-9]{4})*",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
for pattern in DATE_PATTERNS:
|
||||
match = re.match(pattern, raw_date)
|
||||
if match:
|
||||
day = int(match.group("day"))
|
||||
month = int(match.group("month"))
|
||||
year = int(match.group("year"))
|
||||
if year < 100:
|
||||
year += 2000
|
||||
return date(year, month, day)
|
||||
|
||||
raise ValueError(f"Не удалось найти дату в строке: {raw_date}")
|
||||
|
|
@ -2,13 +2,10 @@ import logging
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.api.v1.api import api_router
|
||||
from app.api.v1.web import web_router
|
||||
from app.api.v1.router import api_router
|
||||
from app.config import settings
|
||||
from migrations.runner import MigrationRunner
|
||||
from app.core.database.connection import create_engine, create_session_factory
|
||||
|
||||
# TODO: Replace level with settings value
|
||||
logging.basicConfig(
|
||||
|
|
@ -18,10 +15,11 @@ logging.basicConfig(
|
|||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logging.info(f"\n\n{settings.get_postgres_database_url()}")
|
||||
runner = MigrationRunner(settings.get_postgres_database_url())
|
||||
await runner.run_migrations()
|
||||
# Schema is managed by Alembic: run `alembic upgrade head` before starting the app
|
||||
engine = create_engine(settings.get_postgres_database_url())
|
||||
app.state.session_factory = create_session_factory(engine)
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# TODO: (#ToLearn) Почитать про lifespan в FastAPI приложениях
|
||||
|
|
@ -32,13 +30,8 @@ app = FastAPI(
|
|||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
# Include routers
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
app.include_router(web_router, prefix="/app")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
|
@ -5,10 +5,10 @@ from pprint import pprint
|
|||
|
||||
|
||||
om = ObsidianNotesParser()
|
||||
o_mapped_trainings = om.parse_training_data()
|
||||
o_mapped_trainings = om.parse_file()
|
||||
|
||||
am = AppleNotesParser()
|
||||
a_mapped_trainings = am.parse_training_data()
|
||||
a_mapped_trainings = am.parse_file()
|
||||
|
||||
combined_list = o_mapped_trainings + a_mapped_trainings
|
||||
print(len(combined_list))
|
||||
40
backend/app/schemas/exercises.py
Normal file
40
backend/app/schemas/exercises.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.dto.training import ApproachDTO
|
||||
|
||||
|
||||
class SessionMetricsResponse(BaseModel):
|
||||
max_weight: float
|
||||
e1rm: Optional[float]
|
||||
volume: float
|
||||
total_reps: int
|
||||
best_reps: int
|
||||
|
||||
|
||||
class SessionRowResponse(BaseModel):
|
||||
date: date
|
||||
trainer: Optional[str]
|
||||
splitted_weight: bool
|
||||
approaches: List[ApproachDTO]
|
||||
metrics: SessionMetricsResponse
|
||||
|
||||
|
||||
class RecordResponse(BaseModel):
|
||||
value: float
|
||||
date: date
|
||||
|
||||
|
||||
class ExerciseProgressResponse(BaseModel):
|
||||
name: str
|
||||
weighted: bool
|
||||
max_weight: float
|
||||
best_primary: Optional[RecordResponse]
|
||||
best_secondary: Optional[RecordResponse]
|
||||
first_primary: Optional[float]
|
||||
last_primary: Optional[float]
|
||||
primary_change: Optional[float]
|
||||
primary_change_percent: Optional[float]
|
||||
rows: List[SessionRowResponse] # newest first
|
||||
|
|
@ -28,4 +28,5 @@ class ExerciseResponse(BaseModel):
|
|||
|
||||
class ParseResponse(BaseModel):
|
||||
date: date
|
||||
trainer: Optional[str] = None
|
||||
exercises: Optional[List[ExerciseResponse]]
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
|---|---|---|
|
||||
|Подтягивания|Синяя резинка|7-5-4-4|
|
||||
|Тяга горизонтального блока|45|12-12-12-12|
|
||||
|Тяга вертикального блока обратным хватом|49.8х2-45|10-10-9|
|
||||
|Тяга вертикального блока обратным хватом|49.8-45|10-10-9|
|
||||
|Отжимания|+5|4-5-4|
|
||||
|Подъем ног в висе||10-10-8|
|
||||
|
||||
|
|
@ -304,7 +304,7 @@
|
|||
|Жим от груди лежа (гантели)|16x2-18x2|12-8-10|
|
||||
|Сведение рук пек дек|37|12-12-12-12|
|
||||
|Подъем штанги стоя|20|12-11-9-9|
|
||||
|Молоты стоя|20-20-18|10-9-12|
|
||||
|Молоты стоя|20х2-20х2-18х2|10-9-12|
|
||||
|
||||
**01.12.2023 (с56)**
|
||||
|
||||
|
|
@ -402,7 +402,7 @@
|
|||
|---|---|---|
|
||||
|Гравитрон|57-47|12-9-7-6|
|
||||
|Гребная тяга с упором в грудь|37|12-12-12|
|
||||
|Тяга гантели в наклоне|18|12-12-12-12|
|
||||
|Тяга гантели в наклоне|18х2|12-12-12-12|
|
||||
|Подъем штанги стоя|20|13-12-12|
|
||||
|Жим от плеч вверх (гантели)|20х2|7-7-12|
|
||||
|
||||
|
|
@ -463,7 +463,7 @@
|
|||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|Болгарские выпады|8x2-10x2|12-12-12|
|
||||
|Румынская тяга|32|12-12-12|
|
||||
|Румынская тяга|16х2|12-12-12|
|
||||
|Дельт-машина |47-57-47|12-10-12-12|
|
||||
|Дельт-машина|27|12-12-12-12|
|
||||
|Жим от плеч вверх (гантели)|14x2|12-12-11-10|
|
||||
|
|
@ -526,7 +526,7 @@
|
|||
|---|---|---|
|
||||
|Подтягивания|0-0-47-47|4-3-9-8|
|
||||
|Тяга штанги в наклоне|20-40|11-12-12-12|
|
||||
|Тяга одной рукой в кроссовере на колене|41|12-12-12|
|
||||
|Тяга одной рукой в кроссовере на колене|20.5х2|12-12-12|
|
||||
|Жим от груди сидя в тренажере (блинчики)|20x2|12-12-12|
|
||||
|Сведение рук пек дек|47-37|6-11-10|
|
||||
|
||||
|
|
@ -673,7 +673,7 @@
|
|||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|Подтягивания|0-0-0-47|5-4-3-11|
|
||||
|Тяга гантелей к поясу лежа на животе на скамье 45°|28-36|12-12-12-12|
|
||||
|Тяга гантелей к поясу лежа на животе на скамье 45°|14х2-18х2|12-12-12-12|
|
||||
|Гребная тяга с упором в грудь|37-47|12-11-10|
|
||||
|Жим от груди сидя в тренажере (блинчики)|20x2-25x2|12-12-12|
|
||||
|Сведение рук пек дек|47-37|11-9-12-11|
|
||||
|
|
@ -683,7 +683,7 @@
|
|||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|Разгибание ног сидя|47-67-77|12-12-12-12|
|
||||
|Румынская тяга|44|12-12-12|
|
||||
|Румынская тяга|22х2|12-12-12|
|
||||
|Жим от плеч вверх (гантели)|14x2-16x2|12-12-12-10|
|
||||
|Дельт-машина|47|12-12-12|
|
||||
|Разведение рук в наклоне|8х2|12-12-12|
|
||||
|
|
@ -866,7 +866,7 @@
|
|||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|Разгибание ног сидя|58-77-86|12-12-12-10-10|
|
||||
|Румынская тяга|40|12-12-12-12|
|
||||
|Румынская тяга|20х2|12-12-12-12|
|
||||
|Махи гантелей в стороны стоя|8х8-10х10|12-12-12|
|
||||
|Разведение рук пек дек|27|12-12-10|
|
||||
|Жим от плеч вверх (гантели)|16х2|12-12|
|
||||
|
|
@ -878,7 +878,7 @@
|
|||
|Подтягивания||8-6-4|
|
||||
|Тяга горизонтального блока|58.6-58.6-54|12-12-12-12|
|
||||
|Тяга вертикального блока обратным хватом|49.8|12-12-12|
|
||||
|Жим от груди сидя в тренажере (блинчики)|67-87|12-12-10-10|
|
||||
|Жим от груди сидя в тренажере (блинчики)|33.5х2-43.5х2|12-12-10-10|
|
||||
|Брусья|47|9-9-9|
|
||||
|
||||
**10.07.2024 (с108-54)**
|
||||
|
|
@ -1019,7 +1019,7 @@
|
|||
|---|---|---|
|
||||
|Гравитрон|47-37|12-9-8|
|
||||
|Тяга горизонтального блока|48.9-54.4|12-12-12-12|
|
||||
|Тяга гантели в наклоне|22-22-24|12-12-12|
|
||||
|Тяга гантели в наклоне|22х2-22х2-24х2|12-12-12|
|
||||
|Жим от груди сидя в тренажере (кирпичики)|77|12-12-10-8|
|
||||
|Баттерфляй|57-47|10-12-12|
|
||||
|
||||
|
|
@ -1062,7 +1062,7 @@
|
|||
|Гравитрон|47|12-12-9-10|
|
||||
|Гребная тяга с упором в грудь|47|12-13-11|
|
||||
|Тяга гантели в наклоне|20 на каждую руку|12-12-12|
|
||||
|Жим от груди сидя в тренажере (блинчики)|30-50|14-12-12-12|
|
||||
|Жим от груди сидя в тренажере (блинчики)|15х2-25х2|14-12-12-12|
|
||||
|Сведение рук пек дек|37|12-12-12|
|
||||
|
||||
**08.05.2024 (solo-35)**
|
||||
|
|
@ -1,3 +1,792 @@
|
|||
# 2026-10-21 (solo-107)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------------------------- | -------- | ----------- |
|
||||
| Подтягивания | 78.5 | 12-10-8-8 |
|
||||
| Тяга Т-грифа | 40-45 | 12-8-8-8 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 11-10-9-6 |
|
||||
| Разгибание ног сидя | 100 | 12-12-10-12 |
|
||||
| Разведение ног сидя в тренажёре (кирпичики) | 70-75-80 | 12-12-10 |
|
||||
|
||||
# 2026-10-19 (solo-106)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ------ | ----------- |
|
||||
| Подъем штанги стоя | 35 | 10-9-9-8 |
|
||||
| Отжимания на трицепс в кроссовере | 55-60 | 12-12-10-10 |
|
||||
| Разведение рук с гантелей из-за головы | 27.5 | 10-9-8-9 |
|
||||
| Жим от плеч вверх (гантели) | 22.5х2 | 8-8-9-8 |
|
||||
| Отведение рук пек дек (1 рука) | 22х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-10-17 (solo-105)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------------------- | ------ | --------- |
|
||||
| Подтягивания | 78.9 | 11-10-7-7 |
|
||||
| Тяга вертикального блока узким хватом | 55 | 8-9-8-9 |
|
||||
| Жим от груди лежа (гантели) | 27.5х2 | 10-8-7-7 |
|
||||
| Сведение рук пек дек | 60 | 10-9-9-9 |
|
||||
| Поворот гири на лямке | 12.5х2 | 10-11-11 |
|
||||
|
||||
# 2026-10-15 (solo-104)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ----------- | ----------- |
|
||||
| Подъем гантелей стоя | 20х2 | 12-11-10-10 |
|
||||
| Молоты стоя | 20х2 | 11-11-10-10 |
|
||||
| Разведение рук с гантелей из-за головы | 27.5 | 8-10-9-10 |
|
||||
| Жим от плеч вверх (гантели) | 20х2-22.5х2 | 12-10-7-8 |
|
||||
| Махи гантелей в стороны стоя | 15х2 | 10-10-9-10 |
|
||||
|
||||
# 2026-10-12 (solo-103)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | ------ | --------- |
|
||||
| Подтягивания | 79.5 | 11-10-7-7 |
|
||||
| Тяга горизонтального блока | 55-65 | 12-8-8-9 |
|
||||
| Жим от груди лежа (гантели) | 27.5х2 | 10-8-8-7 |
|
||||
| Жим от груди сидя в тренажере (кирпичики) | 70-80 | 12-7-8-8 |
|
||||
| Пресс на скамье | | 12-12-12 |
|
||||
|
||||
# 2026-10-10 (solo-102)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ------ | ----------- |
|
||||
| Бицепс в тренажере | 25 | 10-8-8-7 |
|
||||
| Подъем гантелей стоя | 17.5х2 | 12-12-10-10 |
|
||||
| Французский жим лежа (штанга) | 30 | 10-10-7-8 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 17.5х2 | 8-7-8-7 |
|
||||
| Поворот гири на лямке | 12х2 | 10-10-9-9 |
|
||||
|
||||
# 2026-10-07 (solo-101)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ------------------- | ----------- |
|
||||
| Подтягивания | 79 | 10-10-8-8 |
|
||||
| Гребная тяга с упором в грудь (1 рука) | 15х2-20х2-25х2-30х2 | 12-12-12-12 |
|
||||
| Жим от груди лежа (гантели) | 27.5х2 | 10-8-7-6 |
|
||||
| Разгибание ног сидя | 90-100 | 12-12-11-11 |
|
||||
| Икроножные сидя | 40-50 | 12-12-12 |
|
||||
|
||||
# 2026-10-05 (solo-100)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ---------- | --------- |
|
||||
| Бицепс в тренажере | 25 | 10-8-8-7 |
|
||||
| Французский жим лежа (штанга) | 30 | 10-10-9-7 |
|
||||
| Разведение рук с гантелей из-за головы | 25-25-27.5 | 11-12-8-7 |
|
||||
| Жим от плеч вверх (гантели) | 22.5x2 | 8-8-8-7 |
|
||||
| Поворот гири на лямке | 10х2-12х2 | 12-10-7-8 |
|
||||
# 2026-10-03 (solo-99)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ----------- | ----------- |
|
||||
| Подтягивания | 79.3 | 10-8-7-7 |
|
||||
| Тяга гантели в наклоне | 27.5х2-30х2 | 12-9-10-10 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 10-10-7-7 |
|
||||
| Сведение рук пек дек | 55 | 10-10-10-10 |
|
||||
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-10-01 (solo-98)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ----------- | ----------- |
|
||||
| Подъем гантелей стоя | 17.5x2-20x2 | 12-10-10-10 |
|
||||
| Французский жим лежа (штанга) | 30 | 9-10-8-7 |
|
||||
| Разведение рук с гантелей из-за головы | 22.5 | 10-10-10-10 |
|
||||
| Жим от плеч вверх (гантели) | 20х2-22.5х2 | 12-8-6-7 |
|
||||
| Махи гантелей в стороны стоя | 12.5х2-15х2 | 12-11-8 |
|
||||
|
||||
# 2026-09-28 (solo-97)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------------------ | ----- | ----------- |
|
||||
| Подтягивания | 79.3 | 9-8-8-6 |
|
||||
| Тяга вертикального блока (кирпичики) | 80-90 | 12-9-10-9 |
|
||||
| Жим от груди лежа (гантели) | 25х2 | 10-9-8-7 |
|
||||
| Сведение рук пек дек | 55 | 11-9-8-9 |
|
||||
| Пресс на скамье | | 12-12-12-12 |
|
||||
|
||||
# 2026-09-19 (solo-96)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ------ | ----------- |
|
||||
| Бицепс в тренажере | 25 | 11-8-7-7 |
|
||||
| Молоты стоя | 17.5х2 | 11-11-12-10 |
|
||||
| Разведение рук с гантелей из-за головы | 25 | 8-9-10-11 |
|
||||
| Жим от плеч вверх (гантели) | 20х2 | 12-10-9-10 |
|
||||
| Махи гантелей в стороны стоя | 15х2 | 10-11-10-11 |
|
||||
|
||||
# 2026-09-14 (solo-95)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ------------------ | ---------- |
|
||||
| Подтягивания | 80 | 10-10-8-7 |
|
||||
| Тяга Т-грифа | 40-45 | 12-10-9-8 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 12-10-8-8 |
|
||||
| Жим от груди лежа (гантели) | 27.5х2-27.5х2-25х2 | 6-6-7-7 |
|
||||
| Поворот гири на лямке | 10х2-12х2-10х2 | 12-7-12-12 |
|
||||
|
||||
# 2026-09-12 (solo-94)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| --------------------------------- | ----- | ----------- |
|
||||
| Бицепс в тренажере | 25 | 11-9-7-7 |
|
||||
| Отведение рук пек дек | 35-40 | 12-12-10-11 |
|
||||
| Французский жим лежа (штанга) | 30 | 8-8-8-8 |
|
||||
| Отжимания на трицепс в кроссовере | 50 | 12-12-12-12 |
|
||||
| Пресс на скамье | | 14-12-12-12 |
|
||||
|
||||
# 2026-09-09 (solo-93)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------------------ | --------- | ----------- |
|
||||
| Подтягивания | 79.4 | 11-9-7-7 |
|
||||
| Тяга вертикального блока (кирпичики) | 50-55 | 12-10-10-10 |
|
||||
| Жим от груди лежа (гантели) | 27.5х2 | 10-8-8-7 |
|
||||
| Отжимания на брусьях | 79.4 | 8-7-7-7 |
|
||||
| Махи рукой в кроссовере | 20х2-21х2 | 12-10-11-11 |
|
||||
|
||||
# 2026-09-07 (solo-92)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ---- | ----------- |
|
||||
| Подъем штанги стоя | 35 | 11-9-8-8 |
|
||||
| Французский жим лежа (штанга) | 30 | 12-10-8-6 |
|
||||
| Разведение рук с гантелей из-за головы | 25 | 10-10-8-8 |
|
||||
| Жим от плеч вверх (гантели) | 20х2 | 11-11-8-11 |
|
||||
| Пресс на скамье | | 12-12-12-12 |
|
||||
|
||||
# 2026-09-05 (solo-91)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | ----- | ----------- |
|
||||
| Подтягивания | 79.9 | 12-8-6-6 |
|
||||
| Тяга вертикального блока узким хватом | 50 | 12-11-9-10 |
|
||||
| Сведение рук пек дек | 55 | 12-12-12-12 |
|
||||
| Жим от груди сидя в тренажере (кирпичики) | 60-65 | 12-10-11-12 |
|
||||
| Жим от плеч вверх в тренажере (кирпичики) | 35-40 | 12-10-10-10 |
|
||||
|
||||
# 2026-08-20 (solo-90)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ------ | ----------- |
|
||||
| Бицепс в тренажере | 25 | 12-10-10-9 |
|
||||
| Молоты стоя | 17.5х2 | 12-12-12-12 |
|
||||
| Французский жим лежа (штанга) | 30 | 10-10-8-5 |
|
||||
| Разведение рук с гантелей из-за головы | 22.5 | 12-12-12-12 |
|
||||
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-08-17 (solo-89)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------------------- | ------ | ---------- |
|
||||
| Подтягивания | 78.3 | 12-10-8-7 |
|
||||
| Тяга вертикального блока узким хватом | 50-55 | 12-10-9-9 |
|
||||
| Жим от груди лежа (гантели) | 27.5x2 | 10-8-8-6 |
|
||||
| Сведение рук пек дек | 60 | 10-10-10-9 |
|
||||
| Махи рукой в кроссовере | 20х2 | 12-12-12 |
|
||||
|
||||
# 2026-08-15 (solo-88)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------------------ | ----------- | ----------- |
|
||||
| Подъем штанги стоя | 35-35-30 | 9-7-12-9 |
|
||||
| Подъем гантелей стоя обратным хватом | 15х2-17.5х2 | 12-12-12-12 |
|
||||
| Французский жим лежа (штанга) | 30 | 10-10-9-7 |
|
||||
| Жим от плеч вверх (гантели) | 22.5х2-20х2 | 8-10-10-10 |
|
||||
| Махи гантелей в стороны стоя | 15х2 | 12-11-11-11 |
|
||||
|
||||
# 2026-08-13 (solo-87)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ------ | ----------- |
|
||||
| Подтягивания | 77.6 | 12-8-8-7 |
|
||||
| Тяга Т-грифа | 45 | 10-9-10-9 |
|
||||
| Жим от груди лежа (гантели) | 27.5х2 | 10-8-7-8 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 8-9-8-8 |
|
||||
| Пресс на скамье | | 12-12-12-12 |
|
||||
|
||||
# 2026-08-10 (solo-86)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ------ | ----------- |
|
||||
| Подъем гантелей стоя | 20х2 | 12-12-10-10 |
|
||||
| Французский жим лежа (штанга) | 30 | 10-9-8-7 |
|
||||
| Разведение рук с гантелей из-за головы | 25 | 12-12-12-12 |
|
||||
| Жим от плеч вверх (гантели) | 22.5х2 | 10-9-7-7 |
|
||||
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-08-08 (solo-85)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ----- | ----------- |
|
||||
| Подтягивания | 78.1 | 12-10-7-7 |
|
||||
| Тяга вертикального блока узким хватом | 50-55 | 12-10-11-10 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 12-9-8-7 |
|
||||
| Сведение рук пек дек | 60 | 10-11-10-9 |
|
||||
| Пресс на скамье | | 12-12-12-12 |
|
||||
|
||||
# 2026-08-06 (solo-84)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ----------- | ----------- |
|
||||
| Подъем гантелей стоя | 20х2 | 12-12-10-10 |
|
||||
| Французский жим лежа (штанга) | 30 | 10-10-10-8 |
|
||||
| Разведение рук с гантелей из-за головы | 25 | 9-10-10-12 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 15х2-17.5х2 | 12-10-7-9 |
|
||||
| Махи рукой в кроссовере | 20х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-08-03 (solo-83)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | ----------- | ---------- |
|
||||
| Подтягивания | 78.9 | 12-12-9-7 |
|
||||
| Тяга Т-грифа | 45 | 10-9-10-10 |
|
||||
| Жим от груди лежа (гантели) | 27.5х2 | 10-9-8-7 |
|
||||
| Сведение рук пек дек | 65 | 9-9-8-8 |
|
||||
| Жим от плеч вверх в тренажере (кирпичики) | 40-45-45-40 | 12-9-6 |
|
||||
|
||||
# 2026-08-01 (solo-82)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | -------- | ----------- |
|
||||
| Подъем гантелей стоя | 20х2 | 12-10-10-10 |
|
||||
| Бицепс в тренажере | 25-25-20 | 10-7-10-10 |
|
||||
| Отжимания на трицепс в кроссовере | 65-60 | 10-12-11-10 |
|
||||
| Разведение рук с гантелей из-за головы | 25 | 8-10-10-8 |
|
||||
| Поворот гири на лямке | 10х2 | 12-12-12 |
|
||||
|
||||
# 2026-07-29 (solo-81)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ---- | ---------- |
|
||||
| Подтягивания | 78.6 | 11-10-9-8 |
|
||||
| Тяга вертикального блока узким хватом | 60 | 10-10-9-7 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 12-9-8-7 |
|
||||
| Сведение рук пек дек | 60 | 10-9-10-10 |
|
||||
| Пресс на скамье | | 12-12-12 |
|
||||
|
||||
# 2026-07-24 (solo-80)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ----------- | ----------- |
|
||||
| Бицепс в тренажере | 25 | 12-10-8-8 |
|
||||
| Молоты стоя | 15х2-17.5х2 | 12-12-12-12 |
|
||||
| Французский жим лежа (штанга) | 30 | 12-10-8-8 |
|
||||
| Разведение рук с гантелей из-за головы | 25 | 8-10-10-10 |
|
||||
| Поворот гири на лямке | 10х2 | 12-12-12 |
|
||||
|
||||
# 2026-07-22 (solo-79)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | -------- | ----------- |
|
||||
| Подтягивания | 78 | 12-10-9-8 |
|
||||
| Тяга Т-грифа | 40 | 11-12-11-9 |
|
||||
| Жим от груди сидя в тренажере (кирпичики) | 60-70-75 | 15-12-10-9 |
|
||||
| Жим от груди лежа (гантели) | 25х2 | 9-8-9-8 |
|
||||
| Пресс на скамье | | 12-12-12-12 |
|
||||
|
||||
# 2026-07-20 (solo-78)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ---- | ----------- |
|
||||
| Подъем штанги стоя | 35 | 10-10-8-8 |
|
||||
| Французский жим лежа (штанга) | 30 | 12-10-10-8 |
|
||||
| Разведение рук с гантелей из-за головы | 25 | 10-12-11-10 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 12-12-9-10 |
|
||||
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-07-18 (solo-77)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------------------------- | -------- | ----------- |
|
||||
| Подтягивания | 78 | 11-9-8-7 |
|
||||
| Тяга горизонтального блока | 50-60-65 | 12-12-9-9 |
|
||||
| Разведение рук пек дек | 35 | 12-12-9-10 |
|
||||
| Сгибание ног лежа | 45-50 | 12-12-10-8 |
|
||||
| Разведение ног сидя в тренажёре (кирпичики) | 85 | 12-12-12-12 |
|
||||
|
||||
# 2026-07-16 (solo-76)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | ----------- | ----------- |
|
||||
| Подъем гантелей стоя | 17.5х2-20х2 | 12-11-10-10 |
|
||||
| Отжимания на трицепс в кроссовере | 50-55 | 12-12-12-12 |
|
||||
| Бицепс в тренажере | 20-25 | 12-10-8-7 |
|
||||
| Жим от плеч вверх в тренажере (кирпичики) | 40-45 | 12-12-9-8 |
|
||||
| Махи гантелей в стороны стоя | 12.5х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-07-13 (solo-75)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | --------- | ---------- |
|
||||
| Подтягивания | 78.1 | 12-10-8-7 |
|
||||
| Тяга вертикального блока узким хватом | 50-55 | 12-10-10-8 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 25х2-30х2 | 12-10-8-6 |
|
||||
| Брусья | 78.1 | 10-8-10-8 |
|
||||
| Пресс на скамье | | 12-12-12 |
|
||||
|
||||
# 2026-07-11 (solo-74)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------------------------- | -------- | ----------- |
|
||||
| Подъем штанги стоя | 35 | 10-9-8-7 |
|
||||
| Французский жим лежа (штанга) | 30 | 10-10-9-7 |
|
||||
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
|
||||
| Разведение ног сидя в тренажёре (кирпичики) | 80-80-85 | 12-12-12-12 |
|
||||
| Икроножные сидя | 40-50-55 | 12-12-12-12 |
|
||||
|
||||
# 2026-07-09 (solo-73)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | ------ | ----------- |
|
||||
| Подтягивания | 78.1 | 12-9-8-7 |
|
||||
| Тяга Т-грифа | 40 | 10-10-10-10 |
|
||||
| Жим от груди сидя в тренажере (кирпичики) | 70-80 | 12-12-8-10 |
|
||||
| Жим от груди лежа (гантели) | 22.5х2 | 10-10-10-10 |
|
||||
| Пресс на скамье | | 12-12-12-12 |
|
||||
|
||||
# 2026-07-06 (solo-72)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | ------- | ----------- |
|
||||
| Разгибание ног сидя | 100-110 | 12-10-12-10 |
|
||||
| Гиперэкстензия | 15 | 12-12-9-9 |
|
||||
| Подъем гантелей стоя | 17.5х2 | 12-12-10-10 |
|
||||
| Французский жим лежа (штанга) | 30 | 10-9-8-7 |
|
||||
| Жим от плеч вверх в тренажере (кирпичики) | 50-45 | 7-9 |
|
||||
|
||||
# 2026-07-04 (solo-71)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | ----- | ----------- |
|
||||
| Подтягивания | 78.4 | 10-8-7-6 |
|
||||
| Гребная тяга с упором в грудь | 80-85 | 12-10-10-10 |
|
||||
| Жим от груди сидя в тренажере (кирпичики) | 70-75 | 12-10-10-7 |
|
||||
| Сведение рук пек дек | 55 | 12-10-10-10 |
|
||||
| Пресс на скамье | | 14-12-12 |
|
||||
|
||||
# 2026-07-02 (solo-70)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ---- | ----------- |
|
||||
| Бицепс в тренажере | 25 | 12-8-8-7 |
|
||||
| Французский жим лежа (штанга) | 30 | 10-9-8-8 |
|
||||
| Разведение рук с гантелей из-за головы | 25 | 9-10-10-8 |
|
||||
| Жим от плеч вверх (гантели) | 20х2 | 12-10-10-8 |
|
||||
| Поворот гири на лямке | 10х2 | 12-12-11-11 |
|
||||
|
||||
# 2026-06-29 (solo-69)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ---- | ----------- |
|
||||
| Подтягивания | 78.1 | 12-11-9-8 |
|
||||
| Тяга вертикального блока узким хватом | 50 | 12-10-11-10 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 25х2 | 12-12-10-9 |
|
||||
| Сведение рук пек дек | 55 | 11-11-10-11 |
|
||||
| Пресс на скамье | | 12-12-12-12 |
|
||||
|
||||
# 2026-06-26 (solo-68)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------- | ------ | ----------- |
|
||||
| Подъем гантелей стоя | 17.5х2 | 12-12-12-12 |
|
||||
| Молоты стоя | 17.5х2 | 12-12-12-12 |
|
||||
| Французский жим лежа (штанга) | 30 | 9-9-7-7 |
|
||||
| Махи гантелей в стороны стоя | 15х2 | 12-11-11-10 |
|
||||
| Поворот гири на лямке | 10х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-06-24 (solo-67)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ----- | ----------- |
|
||||
| Подтягивания | 77.5 | 12-10-8-7 |
|
||||
| Тяга горизонтального блока | 55-65 | 12-9-9-9 |
|
||||
| Сведение рук пек дек | 50-55 | 12-12-12-10 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 25х2 | 9-10-8-10 |
|
||||
| Пресс на скамье | | 12-12-12-12 |
|
||||
|
||||
# 2026-06-22 (solo-66)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ------ | ----------- |
|
||||
| Подъем штанги стоя | 30-35 | 12-7-8-7 |
|
||||
| Бицепс в тренажере | 25-20 | 10-10-10-9 |
|
||||
| Французский жим лежа (штанга) | 30 | 11-10-8-6 |
|
||||
| Разведение рук с гантелей из-за головы | 25 | 10-10-9-8 |
|
||||
| Махи гантелей в стороны стоя | 12.5х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-06-22 (solo-66)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ------ | ----------- |
|
||||
| Подъем штанги стоя | 30-35 | 12-7-8-7 |
|
||||
| Бицепс в тренажере | 25-20 | 10-10-10-9 |
|
||||
| Французский жим лежа (штанга) | 30 | 11-10-8-6 |
|
||||
| Разведение рук с гантелей из-за головы | 25 | 10-10-9-8 |
|
||||
| Махи гантелей в стороны стоя | 12.5х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-06-20 (solo-65)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------------------------- | ----- | ----------- |
|
||||
| Подтягивания | 78 | 12-10-8-7 |
|
||||
| Тяга Т-грифа | 30-40 | 12-10-10-10 |
|
||||
| Сгибание ног лежа | 45-50 | 12-11-10-7 |
|
||||
| Разведение ног сидя в тренажёре (кирпичики) | 50-80 | 12-12-12-12 |
|
||||
| Икроножные сидя | 40-45 | 12-12-12-12 |
|
||||
|
||||
# 2026-06-17 (solo-64)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | -------- | ----------- |
|
||||
| Подъем штанги стоя | 30-35-30 | 12-7-11-9 |
|
||||
| Разведение рук с гантелей из-за головы | 22.5-25 | 12-12-12-12 |
|
||||
| Отжимания на трицепс в кроссовере | 55 | 12-12-11-10 |
|
||||
| Махи рукой в кроссовере | 20х2 | 12-12-12-12 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 12-11-6 |
|
||||
|
||||
# 2026-06-15 (solo-63)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | -------- | ----------- |
|
||||
| Подтягивания | 78.5 | 13-9-8-7 |
|
||||
| Тяга вертикального блока узким хватом | 50-60-55 | 12-8-9-9 |
|
||||
| Жим от груди сидя в тренажере (кирпичики) | 65-75-80 | 12-12-11-8 |
|
||||
| Гиперэкстензия | 15 | 12-12-12-10 |
|
||||
| Разгибание ног сидя | 90-100 | 12-12-9-10 |
|
||||
|
||||
# 2026-06-11 (solo-62)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | ---- | ----------- |
|
||||
| Подтягивания обратным хватом | 78 | 10-9-8-6 |
|
||||
| Бицепс в тренажере | 25 | 7-8-7-7 |
|
||||
| Французский жим лежа (штанга) | 30 | 11-9-8-9 |
|
||||
| Махи рукой в кроссовере | 20х2 | 12-12-12-12 |
|
||||
| Жим от плеч вверх в тренажере (кирпичики) | 40 | 12-11-11-7 |
|
||||
|
||||
# 2026-06-08 (solo-61)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | -------- | ----------- |
|
||||
| Подтягивания | 78.1 | 13-11-9-6 |
|
||||
| Гребная тяга с упором в грудь | 60-70-75 | 15-12-12-12 |
|
||||
| Жим от груди сидя в тренажере (кирпичики) | 70-75 | 12-12-10-8 |
|
||||
| Сведение рук пек дек | 60 | 8-8-8-9 |
|
||||
| Подъем ног в висе | 78.1 | 10-10-7 |
|
||||
|
||||
# 2026-06-06 (solo-60)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ----------- | --------- |
|
||||
| Подъем штанги стоя | 30-35 | 12-7-7-7 |
|
||||
| Молоты стоя | 17.5х2-20х2 | 12-9-9-10 |
|
||||
| Разведение рук с гантелей из-за головы | 25-27.5 | 12-8-8-7 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 12-10-9-8 |
|
||||
| Махи гантелей в стороны стоя | 12.5x2-15x2 | 12-11-9 |
|
||||
|
||||
# 2026-06-04 (solo-59)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------ | ----------- | ----------- |
|
||||
| Подтягивания | 77.9 | 11-8-8-6 |
|
||||
| Тяга вертикального блока | 50-55 | 12-11-11-11 |
|
||||
| Тяга Т-грифа | 35-40 | 12-10-11-11 |
|
||||
| Сгибание ног лежа | 50 | 12-12-11-9 |
|
||||
| Икроножные сидя | 40-45-45-50 | 12-11-12-12 |
|
||||
|
||||
# 2026-06-01 (solo-58)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | -------- | ----------- |
|
||||
| Подтягивания обратным хватом | 78.3 | 10-9-6-6 |
|
||||
| Бицепс в тренажере | 25-25-20 | 9-7-11-10 |
|
||||
| Разведение рук с гантелей из-за головы | 22.5 | 10-10-10-10 |
|
||||
| Брусья (гравитрон) | 40-30 | 12-11-10-9 |
|
||||
| Поворот гири на лямке | 10х2 | 12-12-11 |
|
||||
|
||||
# 2026-05-30 (solo-57)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ----------- | --------- |
|
||||
| Подтягивания | 78.2 | 11-5-5-6 |
|
||||
| Тяга горизонтального блока | 60-70-65-70 | 12-7-10-7 |
|
||||
| Отведение рук пек дек | 30-35 | 12-9-10-9 |
|
||||
| Сведение рук пек дек | 65-70-65 | 12-7-11-7 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 20х2 | 12-12-12 |
|
||||
|
||||
# 2026-05-28 (solo-56)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | -------- | ----------- |
|
||||
| Подъем штанги стоя | 30-35-30 | 12-6-12-9 |
|
||||
| Французский жим лежа (штанга) | 30 | 8-8-7-7 |
|
||||
| Разведение рук с гантелей из-за головы | 20-22.5 | 11-9-9-8 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 10-10-9-9 |
|
||||
| Махи рукой в кроссовере | 20х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-05-25 (solo-55)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------- | -------- | ----------- |
|
||||
| Подтягивания | 77.8 | 11-8-7-7 |
|
||||
| Тяга Т-грифа | 40-45 | 12-9-9-10 |
|
||||
| Икроножные сидя | 30-40-45 | 12-12-12-12 |
|
||||
| Разгибание ног сидя | 90-100 | 12-12-11-12 |
|
||||
| Пресс на скамье | | 13-12 |
|
||||
|
||||
# 2026-05-23 (solo-54)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | -------- | ----------- |
|
||||
| Бицепс в тренажере | 25 | 10-8-8-8 |
|
||||
| Подъем гантелей стоя обратным хватом | 17.5х2 | 12-12-12-12 |
|
||||
| Французский жим лежа (штанга) | 30-30-25 | 8-8-11-8 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 10-8-9-7 |
|
||||
| Поворот гири на лямке | 10х2 | 12-10-10-11 |
|
||||
|
||||
# 2026-05-21 (solo-53)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | -------------- | ----------- |
|
||||
| Подтягивания | 77.8 | 11-9-6-7 |
|
||||
| Тяга вертикального блока узким хватом | 55 | 11-10-9-7 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 30х2-30х2-35х2 | 10-10-7-6 |
|
||||
| Сведение рук пек дек | 55 | 11-11-10-10 |
|
||||
| Махи рукой в кроссовере | 22х2-21х2 | 10-10-10-11 |
|
||||
|
||||
# 2026-05-18 (solo-52)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ------ | ----------- |
|
||||
| Подъем штанги стоя | 35-30 | 10-12-10-10 |
|
||||
| Молоты стоя | 17.5х2 | 12-11-12-12 |
|
||||
| Французский жим лежа (штанга) | 30 | 10-10-8-7 |
|
||||
| Разведение рук с гантелей из-за головы | 22.5 | 9-10-9-8 |
|
||||
| Пресс на скамье | 79.1 | 13-12-12-12 |
|
||||
|
||||
# 2026-05-16 (solo-51)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ----------- | ----------- |
|
||||
| Подтягивания | 78 | 12-8-8-6 |
|
||||
| Тяга Т-грифа | 40-45 | 12-9-8-8 |
|
||||
| Жим от груди лежа (гантели) | 25х2-27.5х2 | 12-8-8-7 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 30х2 | 8-7-7-7 |
|
||||
| Махи рукой в кроссовере | 20х2 | 12-12-11-12 |
|
||||
|
||||
# 2026-05-14 (solo-50)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ----------- | ----------- |
|
||||
| Подъем гантелей стоя | 17.5х2-20х2 | 12-12-9-10 |
|
||||
| Отжимания на трицепс в кроссовере | 65-65-60 | 10-10-12-10 |
|
||||
| Разведение рук с гантелей из-за головы | 22.5 | 10-10-9-8 |
|
||||
| Жим от плеч вверх (гантели) | 22.5х2 | 7-8-8-7 |
|
||||
| Поворот гири на лямке | 10х2 | 12-11-10-11 |
|
||||
|
||||
# 2026-05-11 (solo-49)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------- | ----- | ----------- |
|
||||
| Подтягивания | 77.6 | 10-9-7-7 |
|
||||
| Тяга горизонтального блока | 60-65 | 12-10-10-10 |
|
||||
| Брусья | 77.6 | 10-10-9-6 |
|
||||
| Сведение рук пек дек | 65 | 9-7-7-6 |
|
||||
| Пресс на скамье | | 12-12-12-12 |
|
||||
|
||||
# 2026-05-09 (solo-48)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ------------------ | ----------- |
|
||||
| Подъем гантелей стоя | 17.5х2-17.5х2-20х2 | 12-12-8-8 |
|
||||
| Бицепс в тренажере | 25-20 | 8-10-10-10 |
|
||||
| Французский жим лежа (штанга) | 30 | 9-9-7-7 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 17.5х2 | 7-7-7-7 |
|
||||
| Махи рукой в кроссовере | 20х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-05-07 (solo-47)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | ------- | ---------- |
|
||||
| Подтягивания | 78 | 12-10-7-6 |
|
||||
| Тяга вертикального блока узким хватом | 50-55 | 12-8-8-8 |
|
||||
| Приседания | 30-32.5 | 12-9-8-8 |
|
||||
| Икроножные сидя | 45 | 10-13-9-12 |
|
||||
| Жим от груди сидя в тренажере (кирпичики) | 70-80 | 12-11-10 |
|
||||
|
||||
# 2026-05-04 (solo-46)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ------- | ---------- |
|
||||
| Подъем штанги стоя | 35 | 12-10-8-8 |
|
||||
| Подъем гантелей стоя | 17.5х2 | 10-10-10-9 |
|
||||
| Разведение рук с гантелей из-за головы | 22.5-25 | 12-11-11-8 |
|
||||
| Жим от плеч вверх (гантели) | 22.5х2 | 9-10-8-8 |
|
||||
| Махи гантелей в стороны стоя | 15х2 | 12-11-11 |
|
||||
|
||||
# 2026-05-02 (solo-46)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | --------------- | ---------- |
|
||||
| Подтягивания | 77.4 | 10-8-6-7 |
|
||||
| Тяга Т-грифа | 35-45 | 12-7-8-8 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 15х2-25х2-35х2 | 15-12-6-6 |
|
||||
| Сведение рук пек дек | 60 | 10-9-9-9 |
|
||||
| Гакк-приседания | 0-5х2-10х2-15х2 | 12-12-12-9 |
|
||||
|
||||
# 2026-04-30 (solo-45)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ----------- | ----------- |
|
||||
| Подтягивания обратным хватом | 77.6 | 9-7-7-7 |
|
||||
| Бицепс в тренажере | 25 | 8-7-8-7 |
|
||||
| Французский жим лежа (штанга) | 30 | 9-8-7-6 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 15х2-17.5х2 | 11-8-8-6 |
|
||||
| Поворот гири на лямке | 8х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-04-27 (solo-44)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------- | -------- | ---------- |
|
||||
| Подтягивания | 78.1 | 12-9-7-6 |
|
||||
| Тяга горизонтального блока | 65-70-65 | 12-7-11-9 |
|
||||
| Сгибание ног сидя | 50-60 | 12-12-10-9 |
|
||||
| Икроножные сидя | 35-40-45 | 12-12-10-9 |
|
||||
| Махи рукой в кроссовере | 20х2 | 12-12-12 |
|
||||
|
||||
# 2026-04-25 (solo-43)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------- | ------------------ | ----------- |
|
||||
| Подъем штанги стоя | 30 | 12-12-11-12 |
|
||||
| Молоты стоя | 17.5х2-20х2-17.5x2 | 12-8-12-12 |
|
||||
| Французский жим лежа (штанга) | 30-23-28 | 12-12-8-7 |
|
||||
| Гиперэкстензия | 15 | 12-12-10-9 |
|
||||
| Пресс на скамье | | 12-12-12-12 |
|
||||
|
||||
# 2026-04-23 (solo-42)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------- | ----------- | ----------- |
|
||||
| Разгибание ног сидя | 90-100-110 | 12-12-10-11 |
|
||||
| Жим ногами | 100-110-120 | 12-12-9-10 |
|
||||
| Сгибание ног лежа | 40-55 | 12-11-9-8 |
|
||||
| Икроножные сидя | 20-25-30-35 | 12-12-12-12 |
|
||||
| Пресс на скамье | | 12-12-12-12 |
|
||||
|
||||
# 2026-04-20 (solo-41)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | ----------- | ----------- |
|
||||
| Подтягивания | 78.0 | 10-9-5-7 |
|
||||
| Тяга Т-грифа | 30-40-45-40 | 12-11-8-12 |
|
||||
| Жим от груди лежа (гантели) | 25х2-30х2 | 12-7-7-5 |
|
||||
| Жим от груди сидя в тренажере (кирпичики) | 75 | 9-9-9-8 |
|
||||
| Пресс на скамье | 78.0 | 12-12-12-12 |
|
||||
|
||||
# 2026-04-18 (solo-40)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | --------- | ----------- |
|
||||
| Подтягивания обратным хватом | 78.3 | 9-8-7-6 |
|
||||
| Подъем гантелей стоя | 20х2 | 8-8-7-6 |
|
||||
| Французский жим лежа (штанга) | 30 | 10-8-7-7 |
|
||||
| Разведение рук с гантелей из-за головы | 22.5 | 10-7-9-9 |
|
||||
| Махи рукой в кроссовере | 20х2-22х2 | 12-10-10-12 |
|
||||
|
||||
# 2026-04-16 (solo-39)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------------------- | -------------- | --------- |
|
||||
| Подтягивания | 77.7 | 12-7-8-8 |
|
||||
| Тяга вертикального блока (блинчики) | 20х2-25х2 | 12-7-7-7 |
|
||||
| Сведение рук пек дек | 70 | 10-10-9-7 |
|
||||
| Жим от груди сидя в тренажере (кирпичики) | 70-75 | 12-8-9-9 |
|
||||
| Махи рукой в кроссовере | 17х2-18х2-19х2 | 12-12-11 |
|
||||
|
||||
# 2026-04-13 (solo-38)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ----------- | --------- |
|
||||
| Подтягивания обратным хватом | 78.7 | 10-8-7-6 |
|
||||
| Бицепс в тренажере | 25-20 | 8-10-9-8 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 17.5х2 | 11-10-7-8 |
|
||||
| Французский жим лежа (гантели) | 15х2 | 7-6-5-6 |
|
||||
| Махи гантелей в стороны стоя | 17.5х2-15х2 | 9-10-10-9 |
|
||||
|
||||
# 2026-04-11 (solo-37)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| --------------------------- | ----------- | ----------- |
|
||||
| Подтягивания | 77.8 | 13-9-7-6 |
|
||||
| Тяга гантели в наклоне | 32.5х2-35х2 | 12-12-12-9 |
|
||||
| Жим от груди лежа (гантели) | 25х2 | 11-10-8-7 |
|
||||
| Сведение рук пек дек | 65 | 7-7-7-7 |
|
||||
| Пресс на скамье | | 13-12-12-12 |
|
||||
|
||||
# 2026-04-09 (solo-36)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ----------------------- | ----------- |
|
||||
| Подъем гантелей стоя | 20х2-22.5x2-22.5х2-20х2 | 12-7-7-7 |
|
||||
| Французский жим лежа (штанга) | 30-30-25 | 7-6-10-10 |
|
||||
| Разведение рук с гантелей из-за головы | 22.5 | 10-10-10-12 |
|
||||
| Жим от плеч вверх (гантели) | 20х2-22.5х2 | 12-10-9-7 |
|
||||
| Махи гантелей в стороны стоя | 15х2-17.5x2 | 12-8-9-10 |
|
||||
|
||||
# 2026-04-06 (solo-35)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| --------------------------- | ----------- | --------- |
|
||||
| Подтягивания | 78.1 | 12-10-8-7 |
|
||||
| Тяга вертикального блока | 50-55 | 12-9-9-10 |
|
||||
| Жим от груди лежа (гантели) | 25х2-27.5х2 | 12-10-8-7 |
|
||||
| Сведение рук пек дек | 65 | 10-9-10-8 |
|
||||
| Брусья | 78.1 | 9-7-8-6 |
|
||||
|
||||
# 2026-04-04 (solo-34)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | ---------------- | ----------- |
|
||||
| Бицепс в тренажере | 30-25 | 7-8-8-6 |
|
||||
| Молоты стоя | 20х2-20х2-17.5х2 | 12-10-12-12 |
|
||||
| Французский жим лежа (штанга) | 30 | 8-8-7-7 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 15х2 | 11-11-8-9 |
|
||||
| Махи гантелей в стороны стоя | 15х2 | 10-12-12-12 |
|
||||
|
||||
# 2026-04-02 (solo-33)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------------- | --------- | ----------- |
|
||||
| Подтягивания | 78.1 | 12-8-8-6 |
|
||||
| Тяга Т-грифа | 30-40 | 12-11-11-10 |
|
||||
| Жим от груди сидя в тренажере (блинчики) | 25х2-30х2 | 12-11-9-8 |
|
||||
| Брусья | 78.1 | 9-7-8-8 |
|
||||
| Махи рукой в кроссовере | 17х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-03-30 (solo-32)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------------------ | ----------- | ----------- |
|
||||
| Подъем штанги стоя | 35 | 12-12-12-9 |
|
||||
| Французский жим лежа (штанга) | 30 | 11-9-8-6 |
|
||||
| Подъем гантелей стоя обратным хватом | 17.5х2-20х2 | 12-12-12-9 |
|
||||
| Жим от плеч вверх (гантели) в наклоне | 20х2-22.5х2 | 12-10-8-8 |
|
||||
| Махи гантелей в стороны стоя | 15х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-03-26 (solo-31)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------- | ---------------- | ----------- |
|
||||
| Подтягивания | | 13-8-7-6 |
|
||||
| Тяга гантели в наклоне | 25х2-30х2-32.5х2 | 12-12-12-10 |
|
||||
| Жим лежа (штанга) | 50-70 | 12-6-5-5 |
|
||||
| Сведение рук пек дек | 60-65 | 12-8-9-8 |
|
||||
| Махи руки в сторону в кроссовере | 15х2-16х2 | 12-12-12-12 |
|
||||
|
||||
# 2026-03-23 (solo-30)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
|
|
@ -1322,7 +2111,7 @@
|
|||
| Гребная тяга с упором в грудь | 75 | 12-12-12-12 |
|
||||
| Тяга Т-грифа | 32.5-35 | 12-12-12-12 |
|
||||
| Жим от груди лежа (гантели) | 22.5х2 | 12-12-12-10 |
|
||||
| махи рукой в кроссовере | 10x2 | 12-12-12-12 |
|
||||
| Махи рукой в кроссовере | 10x2 | 12-12-12-12 |
|
||||
|
||||
# 2025-04-26 (solo-41)
|
||||
|
||||
|
|
@ -1459,7 +2248,7 @@
|
|||
| ----------------------------- | -------- | ----------- |
|
||||
| Подтягивания | | 10-7-6-5 |
|
||||
| Гребная тяга с упором в грудь | 55-65-70 | 12-12-12-12 |
|
||||
| Тяга штанги в наклоне | 15х2 | 12-12-12-12 |
|
||||
| Тяга штанги в наклоне | 30 | 12-12-12-12 |
|
||||
| Махи гантелей в стороны стоя| 10х2 | 12-12-12-12 |
|
||||
| Жим от плеч вверх (гантели)| 20х2 | 10-10-8-8 |
|
||||
|
||||
|
|
@ -1498,7 +2287,7 @@
|
|||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------- | --------- | ----------- |
|
||||
| Подтягивания | | 10-7-6 |
|
||||
| Тяга штанги в наклоне | 10х2-15х2 | 12-12-12-12 |
|
||||
| Тяга штанги в наклоне | 20-30 | 12-12-12-12 |
|
||||
| Тяга гантели в наклоне | 25х2 | 12-12-12-12 |
|
||||
| Разведение рук пек дек | 30 | 12-12-10-8 |
|
||||
| Жим от плеч вверх в тренажере (кирпичики)| 35 | 12-12-11-9 |
|
||||
|
|
@ -1612,7 +2401,7 @@
|
|||
| ---------------------- | ----------- | ----------- |
|
||||
| Разгибание ног сидя | 75-75-90 | 12-16-12-12 |
|
||||
| Жим ногами | 100-120-140 | 12-12-12-12 |
|
||||
| Икроножные стоя | 25-40-45 | 12-12-12-12 |
|
||||
| Икроножные стоя | 12.5х2-20х2-22.5х2 | 12-12-12-12 |
|
||||
| Жим от плеч вверх (гантели) | 20х2 | 9-12-10-8 |
|
||||
| Разведение рук пек дек | 35 | 12-12-12 |
|
||||
|
||||
|
|
@ -1692,7 +2481,7 @@
|
|||
| --------------------------------------- | --------- | -------- |
|
||||
| Болгарские выпады | 12х2-20х2 | 8-6-6 |
|
||||
| Сгибание ног сидя | 77-86 | 12-12-12 |
|
||||
| Икроножные стоя | 34 | 11-10-12 |
|
||||
| Икроножные стоя | 17х2 | 11-10-12 |
|
||||
| Жим от плеч вверх в тренажере (блинчики) | 13x2-17x2 | 12-12-12 |
|
||||
| Дельт-машина | 47 | 12-12-12 |
|
||||
| Разведение рук пек дек | 37 | 12-9 |
|
||||
|
|
@ -1816,7 +2605,7 @@
|
|||
| Разгибание ног сидя | 49-81-90 | 12-12-12 |
|
||||
| Жим ногами | 75-125-150 | 12-12-10 |
|
||||
| Гиперэкстензия | 10-15 | 12-12-12 |
|
||||
| Махи рукой в кроссовере | 13.9 | 12-12-12 |
|
||||
| Махи рукой в кроссовере | 13.9х2 | 12-12-12 |
|
||||
| Дельт-машина | 47 | 12-11-12 |
|
||||
| Разведение рук пек дек | 37 | 9-9-8 |
|
||||
|
||||
|
|
@ -1960,7 +2749,7 @@
|
|||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------- | --------- | -------- |
|
||||
| Подтягивания | | 10-7-5 |
|
||||
| Тяга гантели в наклоне | 24-26 | 12-12-12 |
|
||||
| Тяга гантели в наклоне | 24х2-26х2 | 12-12-12 |
|
||||
| Жим от груди лежа (гантели) | 20х2-22х2 | 12-12-9 |
|
||||
| Тяга горизонтального блока | 47-47-57 | 12-12-12 |
|
||||
| Тяга вертикального блока | 47 | 12-12-12 |
|
||||
|
|
@ -2024,7 +2813,7 @@
|
|||
| ----------------------------------------- | ----------- | ---------- |
|
||||
| Подтягивания | | 9-7-6 |
|
||||
| Тяга горизонтального блока | 57 | 12-12-12 |
|
||||
| Тяга гантели в наклоне | 26 | 12-12-12 |
|
||||
| Тяга гантели в наклоне | 26х2 | 12-12-12 |
|
||||
| Жим от груди сидя в тренажере (блинчики)| 20x2-30x2-30x2-20x2 | 12-11-9-10 |
|
||||
| Махи гантелей в стороны стоя| 9х2 | 12-12-12 |
|
||||
| Трицепс машина | 77-87 | 12-12-9 |
|
||||
|
|
@ -2088,7 +2877,7 @@
|
|||
| ---------------------------- | -------------- | --------- |
|
||||
| Подтягивания | | 10-6-6 |
|
||||
| Гребная тяга с упором в грудь | 47-51 | 12-12-12 |
|
||||
| Тяга гантели в наклоне | 26-28 | 12-12-12 |
|
||||
| Тяга гантели в наклоне | 26х2-28х2 | 12-12-12 |
|
||||
| Жим от груди лежа (гантели) | 18х2-24х2-22х2 | 12-10-7-8 |
|
||||
| Сведение рук пек дек| 37 | 12-12-12 |
|
||||
| Брусья | 37 | 12-9-9 |
|
||||
|
|
@ -2098,7 +2887,7 @@
|
|||
| Упражнение | Вес | Подходы |
|
||||
| ----------------------------- | -------- | ----------- |
|
||||
| Разгибание ног сидя | 62-82-90 | 12-12-11-10 |
|
||||
| Румынская тяга | 40 | 12-12-12 |
|
||||
| Румынская тяга | 20х2 | 12-12-12 |
|
||||
| Икроножные сидя | 27 | 16-15-14 |
|
||||
| Разведение рук пек дек | 47 | 12-12-12 |
|
||||
| Жим от плеч вверх (гантели) | 18х2 | 11-12-12 |
|
||||
|
|
@ -2119,7 +2908,7 @@
|
|||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------ | ---------- | ----------- |
|
||||
| Разгибание ног сидя | 59-77-81.5 | 12-12-12-12 |
|
||||
| Румынская тяга | 40 | 12-12-12 |
|
||||
| Румынская тяга | 20х2 | 12-12-12 |
|
||||
| Икроножные сидя | 27.2 | 12-12-12 |
|
||||
| Жим от плеч вверх (гантели) | 16x2-18x2 | 12-12-12 |
|
||||
| Дельт-машина | 47 | 12-12-10 |
|
||||
|
|
@ -22,10 +22,6 @@ def dump_trainings_to_json(trainings: List[TrainingDTO], filename="trainings.jso
|
|||
def main():
|
||||
# Parse Obsidian notes
|
||||
obsidian_trainings = odp()
|
||||
for training in obsidian_trainings:
|
||||
for exercise in training.exercises:
|
||||
print(exercise.name)
|
||||
|
||||
# Parse Apple notes
|
||||
apple_trainings = adp()
|
||||
|
||||
1
backend/migrations/README
Normal file
1
backend/migrations/README
Normal file
|
|
@ -0,0 +1 @@
|
|||
Generic single-database configuration with an async dbapi.
|
||||
99
backend/migrations/env.py
Normal file
99
backend/migrations/env.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.config import settings
|
||||
from app.core.database.tables import metadata
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# The URL comes from app settings (.env), not from alembic.ini.
|
||||
# "%" is escaped because the value goes through configparser interpolation.
|
||||
config.set_main_option(
|
||||
"sqlalchemy.url", settings.get_postgres_database_url().replace("%", "%%")
|
||||
)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata, compare_type=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
backend/migrations/script.py.mako
Normal file
28
backend/migrations/script.py.mako
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"""initial schema
|
||||
|
||||
Revision ID: 08ba8bee6328
|
||||
Revises:
|
||||
Create Date: 2026-10-22 22:48:54.728389
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '08ba8bee6328'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('trainings',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('date', sa.Date(), nullable=False),
|
||||
sa.Column('trainer', sa.String(length=100), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_trainings_date'), 'trainings', ['date'], unique=False)
|
||||
op.create_table('exercises',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('training_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('splitted_weight', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['training_id'], ['trainings.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_exercises_name'), 'exercises', ['name'], unique=False)
|
||||
op.create_index(op.f('ix_exercises_training_id'), 'exercises', ['training_id'], unique=False)
|
||||
op.create_table('approaches',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('exercise_id', sa.Integer(), nullable=False),
|
||||
sa.Column('weight', sa.Float(), nullable=False),
|
||||
sa.Column('reps', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['exercise_id'], ['exercises.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_approaches_exercise_id'), 'approaches', ['exercise_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_approaches_exercise_id'), table_name='approaches')
|
||||
op.drop_table('approaches')
|
||||
op.drop_index(op.f('ix_exercises_training_id'), table_name='exercises')
|
||||
op.drop_index(op.f('ix_exercises_name'), table_name='exercises')
|
||||
op.drop_table('exercises')
|
||||
op.drop_index(op.f('ix_trainings_date'), table_name='trainings')
|
||||
op.drop_table('trainings')
|
||||
# ### end Alembic commands ###
|
||||
15
backend/pyproject.toml
Normal file
15
backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[project]
|
||||
name = "f1tness-parser"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
# Dependencies live in requirements.txt / requirements-dev.txt
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
[tools.pyright]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
7
backend/requirements-dev.txt
Normal file
7
backend/requirements-dev.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-r requirements.txt
|
||||
|
||||
pytest==8.4.1
|
||||
pytest-asyncio==1.4.0
|
||||
mypy==1.17.1
|
||||
types-grpcio==1.0.0.20250703
|
||||
types-protobuf==6.30.2.20250822
|
||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
pydantic==2.11.7
|
||||
pydantic-settings==2.10.1
|
||||
python-dotenv==1.1.1
|
||||
sqlalchemy[asyncio]==2.1.6
|
||||
asyncpg==0.32.0
|
||||
alembic==1.20.1
|
||||
67
backend/tests/conftest.py
Normal file
67
backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Shared fixtures for tests that need a real PostgreSQL.
|
||||
|
||||
In docker compose TEST_DATABASE_URL is already set (database `<name>_test`).
|
||||
Elsewhere set it to a throwaway database whose name ends with `_test`; it is
|
||||
created if missing and its tables are emptied by every test that uses `session`.
|
||||
Without TEST_DATABASE_URL those tests are skipped.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
from app.core.database.connection import create_engine, create_session_factory
|
||||
from app.core.database.repository import TrainingRepository
|
||||
|
||||
TEST_DATABASE_URL = os.environ.get("TEST_DATABASE_URL")
|
||||
|
||||
|
||||
async def ensure_test_database(url: str) -> None:
|
||||
"""Create the test database if it doesn't exist yet."""
|
||||
target = make_url(url)
|
||||
name = target.database or ""
|
||||
if not name.endswith("_test"):
|
||||
pytest.fail(f"Refusing to run: database {name!r} must end with '_test'")
|
||||
|
||||
admin_engine = create_async_engine(
|
||||
target.set(database="postgres"), isolation_level="AUTOCOMMIT"
|
||||
)
|
||||
try:
|
||||
async with admin_engine.connect() as conn:
|
||||
exists = await conn.scalar(
|
||||
text("SELECT 1 FROM pg_database WHERE datname = :name"), {"name": name}
|
||||
)
|
||||
if not exists:
|
||||
quoted = name.replace('"', '""')
|
||||
await conn.execute(text(f'CREATE DATABASE "{quoted}"'))
|
||||
finally:
|
||||
await admin_engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def alembic_config(monkeypatch) -> Config:
|
||||
if not TEST_DATABASE_URL:
|
||||
pytest.skip("TEST_DATABASE_URL is not set")
|
||||
await ensure_test_database(TEST_DATABASE_URL)
|
||||
# migrations/env.py takes the URL from settings
|
||||
monkeypatch.setattr(settings, "POSTGRES_DATABASE_URL", TEST_DATABASE_URL)
|
||||
return Config("alembic.ini")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session(alembic_config):
|
||||
"""Session on a migrated and emptied test database."""
|
||||
# env.py runs its own event loop, so alembic goes to a thread
|
||||
await asyncio.to_thread(command.upgrade, alembic_config, "head")
|
||||
engine = create_engine(TEST_DATABASE_URL)
|
||||
async with create_session_factory(engine)() as session:
|
||||
await TrainingRepository(session).delete_all()
|
||||
yield session
|
||||
await engine.dispose()
|
||||
116
backend/tests/test_core/test_metrics.py
Normal file
116
backend/tests/test_core/test_metrics.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.dto.training import ApproachDTO, ExerciseSessionDTO
|
||||
from app.core.metrics import estimate_1rm, session_metrics, summarize
|
||||
|
||||
|
||||
def make_session(day: int, *sets: tuple, month: int = 1) -> ExerciseSessionDTO:
|
||||
return ExerciseSessionDTO(
|
||||
date=date(2025, month, day),
|
||||
approaches=[ApproachDTO(weight=weight, reps=reps) for weight, reps in sets],
|
||||
)
|
||||
|
||||
|
||||
def test_estimate_1rm_uses_epley_formula():
|
||||
assert estimate_1rm(140, 12) == pytest.approx(196)
|
||||
assert estimate_1rm(100, 10) == pytest.approx(133.333, rel=1e-4)
|
||||
|
||||
|
||||
def test_single_rep_is_the_weight_itself():
|
||||
assert estimate_1rm(100, 1) == 100
|
||||
|
||||
|
||||
def test_session_metrics_of_weighted_sets():
|
||||
metrics = session_metrics(make_session(1, (100, 12), (110, 10)).approaches)
|
||||
|
||||
assert metrics.max_weight == 110
|
||||
assert metrics.e1rm == pytest.approx(max(140.0, 110 * (1 + 10 / 30)))
|
||||
assert metrics.volume == 100 * 12 + 110 * 10
|
||||
assert metrics.total_reps == 22
|
||||
assert metrics.best_reps == 12
|
||||
|
||||
|
||||
def test_sets_without_weight_count_only_as_reps():
|
||||
metrics = session_metrics(make_session(1, (0, 7), (47, 12)).approaches)
|
||||
|
||||
assert metrics.e1rm == pytest.approx(estimate_1rm(47, 12))
|
||||
assert metrics.volume == 47 * 12
|
||||
assert metrics.total_reps == 19
|
||||
|
||||
|
||||
def test_session_without_weight_has_no_e1rm():
|
||||
metrics = session_metrics(make_session(1, (0, 11), (0, 8)).approaches)
|
||||
|
||||
assert metrics.e1rm is None
|
||||
assert metrics.volume == 0
|
||||
assert metrics.total_reps == 19
|
||||
assert metrics.best_reps == 11
|
||||
|
||||
|
||||
def test_weighted_progress_records_and_change():
|
||||
# newest first, like the repository returns
|
||||
history = [
|
||||
make_session(20, (100, 12)), # e1RM 140
|
||||
make_session(10, (110, 12), (100, 12)), # e1RM 154 (record)
|
||||
make_session(1, (80, 10)), # e1RM 106.67
|
||||
]
|
||||
|
||||
progress = summarize(history)
|
||||
|
||||
assert progress.weighted is True
|
||||
assert progress.max_weight == 110
|
||||
assert progress.best_primary.value == pytest.approx(154)
|
||||
assert progress.best_primary.date == date(2025, 1, 10)
|
||||
assert progress.best_secondary.value == 110 * 12 + 100 * 12
|
||||
assert progress.first_primary == pytest.approx(80 * (1 + 10 / 30))
|
||||
assert progress.last_primary == pytest.approx(140)
|
||||
assert progress.primary_change == pytest.approx(140 - 80 * (1 + 10 / 30))
|
||||
assert progress.primary_change_percent == pytest.approx(
|
||||
(140 - 80 * (1 + 10 / 30)) / (80 * (1 + 10 / 30)) * 100
|
||||
)
|
||||
|
||||
|
||||
def test_record_keeps_the_first_date_it_was_reached():
|
||||
history = [make_session(20, (100, 12)), make_session(10, (100, 12))]
|
||||
|
||||
assert summarize(history).best_primary.date == date(2025, 1, 10)
|
||||
|
||||
|
||||
def test_exercise_without_weight_is_tracked_by_reps():
|
||||
history = [
|
||||
make_session(20, (0, 8), (0, 7)), # best 8, total 15
|
||||
make_session(10, (0, 10), (0, 5)), # best 10, total 15
|
||||
make_session(1, (0, 6)), # best 6, total 6
|
||||
]
|
||||
|
||||
progress = summarize(history)
|
||||
|
||||
assert progress.weighted is False
|
||||
assert progress.max_weight == 0
|
||||
assert progress.best_primary.value == 10
|
||||
assert progress.best_primary.date == date(2025, 1, 10)
|
||||
assert progress.best_secondary.value == 15
|
||||
assert progress.best_secondary.date == date(2025, 1, 10)
|
||||
assert (progress.first_primary, progress.last_primary) == (6, 8)
|
||||
assert progress.primary_change == 2
|
||||
|
||||
|
||||
def test_weighted_exercise_ignores_sessions_without_weight_for_primary_metric():
|
||||
history = [
|
||||
make_session(20, (0, 12)), # no e1RM
|
||||
make_session(10, (100, 10)),
|
||||
]
|
||||
|
||||
progress = summarize(history)
|
||||
|
||||
assert progress.weighted is True
|
||||
assert progress.last_primary == pytest.approx(estimate_1rm(100, 10))
|
||||
assert progress.primary_change == 0
|
||||
|
||||
|
||||
def test_percent_change_is_undefined_when_the_first_value_is_zero():
|
||||
progress = summarize([make_session(1, (0, 0))])
|
||||
|
||||
assert progress.primary_change_percent is None
|
||||
124
backend/tests/test_core/test_notes_parsing.py
Normal file
124
backend/tests/test_core/test_notes_parsing.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.parsers.apple import AppleNotesParser
|
||||
from app.core.parsers.obsidian import ObsidianNotesParser
|
||||
from app.core.utils.date_refactor import parse_training_date
|
||||
|
||||
OBSIDIAN_NOTES = """\
|
||||
# 07.01.2025 (solo-2)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| --------------------------------------- | --------------------- | ----------- |
|
||||
| Разгибание ног сидя | 77-86 | 12-12-12-12 |
|
||||
| Подтягивания | красная резинка | 9-7-5 |
|
||||
|
||||
# 04.01.2025 (c153-1)
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| --- | --- | --- |
|
||||
| Сгибание ног лежа | 47 | 12-12-11-10 |
|
||||
"""
|
||||
|
||||
APPLE_NOTES = """\
|
||||
**21.08.2024 (с123-69)**
|
||||
|
||||
| | | |
|
||||
|---|---|---|
|
||||
|**Упражнение**|**Вес**|**Подходы**|
|
||||
|Жим ногами|100|12-12-12|
|
||||
|
||||
**22.08.2024 (соло)**
|
||||
|
||||
|Подтягивания||9-7-5|
|
||||
"""
|
||||
|
||||
|
||||
def test_obsidian_parses_every_training_and_trainer():
|
||||
parser = ObsidianNotesParser()
|
||||
trainings = parser.parse(OBSIDIAN_NOTES, map_names=False)
|
||||
|
||||
assert [t.date for t in trainings] == [date(2025, 1, 7), date(2025, 1, 4)]
|
||||
assert [t.trainer for t in trainings] == ["solo", "c153"]
|
||||
assert [len(t.exercises) for t in trainings] == [1, 1]
|
||||
|
||||
|
||||
def test_malformed_rows_are_reported_not_silently_dropped():
|
||||
parser = ObsidianNotesParser()
|
||||
parser.parse(OBSIDIAN_NOTES, map_names=False)
|
||||
|
||||
assert len(parser.errors) == 1
|
||||
assert "красная резинка" in parser.errors[0]
|
||||
|
||||
|
||||
def test_errors_are_reset_between_parses():
|
||||
parser = ObsidianNotesParser()
|
||||
parser.parse(OBSIDIAN_NOTES, map_names=False)
|
||||
parser.parse("# 07.01.2025 (solo-2)\n", map_names=False)
|
||||
|
||||
assert parser.errors == []
|
||||
|
||||
|
||||
def test_last_line_header_keeps_all_trainings():
|
||||
parser = ObsidianNotesParser()
|
||||
text = OBSIDIAN_NOTES + "\n# 01.01.2025 (solo-3)"
|
||||
trainings = parser.parse(text, map_names=False)
|
||||
|
||||
assert [t.date for t in trainings] == [
|
||||
date(2025, 1, 7),
|
||||
date(2025, 1, 4),
|
||||
date(2025, 1, 1),
|
||||
]
|
||||
assert trainings[-1].exercises == []
|
||||
|
||||
|
||||
def test_bad_date_skips_whole_training_block():
|
||||
parser = ObsidianNotesParser()
|
||||
text = "# 31.02.2025 (solo-1)\n| Жим | 10 | 12-12 |\n" + OBSIDIAN_NOTES
|
||||
trainings = parser.parse(text, map_names=False)
|
||||
|
||||
assert [t.date for t in trainings] == [date(2025, 1, 7), date(2025, 1, 4)]
|
||||
# The bad date itself plus the malformed row inside OBSIDIAN_NOTES;
|
||||
# the exercise under the bad header is skipped without an extra error
|
||||
assert len(parser.errors) == 2
|
||||
assert "day is out of range" in parser.errors[0]
|
||||
|
||||
|
||||
def test_exercise_mapping_is_applied_by_default():
|
||||
parser = ObsidianNotesParser()
|
||||
parser.exercise_mapper = {"Разгибание ног сидя": "Разгибание ног"}
|
||||
|
||||
mapped = parser.parse(OBSIDIAN_NOTES)
|
||||
unmapped = parser.parse(OBSIDIAN_NOTES, map_names=False)
|
||||
|
||||
assert mapped[0].exercises[0].name == "Разгибание ног"
|
||||
assert unmapped[0].exercises[0].name == "Разгибание ног сидя"
|
||||
|
||||
|
||||
def test_apple_parses_headers_with_and_without_counter():
|
||||
parser = AppleNotesParser()
|
||||
trainings = parser.parse(APPLE_NOTES, map_names=False)
|
||||
|
||||
assert [t.date for t in trainings] == [date(2024, 8, 21), date(2024, 8, 22)]
|
||||
assert [t.trainer for t in trainings] == ["с123", "соло"]
|
||||
assert [len(t.exercises) for t in trainings] == [1, 1]
|
||||
assert trainings[1].exercises[0].approaches[0].weight == 0.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
("21.08.2024", date(2024, 8, 21)),
|
||||
("21.08.24", date(2024, 8, 21)),
|
||||
("2024-08-21", date(2024, 8, 21)),
|
||||
("2024.08.21", date(2024, 8, 21)),
|
||||
],
|
||||
)
|
||||
def test_parse_training_date_formats(raw, expected):
|
||||
assert parse_training_date(raw) == expected
|
||||
|
||||
|
||||
def test_parse_training_date_rejects_garbage():
|
||||
with pytest.raises(ValueError):
|
||||
parse_training_date("yesterday")
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
from obsidian.notes_parser import serialize_exercise
|
||||
from obsidian.py_models import Approach, Exercise
|
||||
from app.core.dto.training import ApproachDTO as Approach, ExerciseDTO as Exercise
|
||||
from app.core.parsers.base import ParseError
|
||||
from app.core.parsers.obsidian import ObsidianNotesParser
|
||||
import pytest
|
||||
|
||||
serialize_exercise = ObsidianNotesParser().serialize_exercise
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reps, weight, name, expected",
|
||||
|
|
@ -65,8 +68,51 @@ import pytest
|
|||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
# Cyrillic and latin "x" mixed in a single value
|
||||
"12-12-12",
|
||||
"10х2-12x2",
|
||||
"test_exercise",
|
||||
Exercise(
|
||||
name="test_exercise",
|
||||
splitted_weight=True,
|
||||
approaches=[
|
||||
Approach(weight=10, reps=12),
|
||||
Approach(weight=12, reps=12),
|
||||
Approach(weight=12, reps=12),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
# First approach without weight, the rest per hand
|
||||
"8-8-7",
|
||||
"0-12х2-16х2",
|
||||
"test_exercise",
|
||||
Exercise(
|
||||
name="test_exercise",
|
||||
splitted_weight=True,
|
||||
approaches=[
|
||||
Approach(weight=0, reps=8),
|
||||
Approach(weight=12, reps=8),
|
||||
Approach(weight=16, reps=7),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_approach_parsing(reps, weight, name, expected):
|
||||
exercise = serialize_exercise(reps, weight, name)
|
||||
assert expected == exercise
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reps, weight",
|
||||
[
|
||||
("12-12-", "10х2"),
|
||||
("11--11-7", ""),
|
||||
("12-12", "красная резинка"),
|
||||
],
|
||||
)
|
||||
def test_malformed_exercise_raises_parse_error(reps, weight):
|
||||
with pytest.raises(ParseError):
|
||||
serialize_exercise(reps, weight, "test_exercise")
|
||||
150
backend/tests/test_core/test_repository.py
Normal file
150
backend/tests/test_core/test_repository.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""Repository and migration tests against a real PostgreSQL.
|
||||
|
||||
The `session` / `alembic_config` fixtures (tests/conftest.py) skip these tests
|
||||
when TEST_DATABASE_URL is not set.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
|
||||
from app.core.database.tables import approaches, exercises
|
||||
from app.core.database.repository import TrainingRepository
|
||||
from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
|
||||
from app.core.parsers.apple import AppleNotesParser
|
||||
from app.core.parsers.obsidian import ObsidianNotesParser
|
||||
|
||||
|
||||
def make_training(day: int = 1, trainer: str = "solo") -> TrainingDTO:
|
||||
return TrainingDTO(
|
||||
date=date(2025, 1, day),
|
||||
trainer=trainer,
|
||||
exercises=[
|
||||
ExerciseDTO(
|
||||
name="Жим ногами",
|
||||
approaches=[
|
||||
ApproachDTO(weight=100, reps=12),
|
||||
ApproachDTO(weight=110, reps=10),
|
||||
],
|
||||
),
|
||||
ExerciseDTO(
|
||||
name="Жим гантелей лежа",
|
||||
splitted_weight=True,
|
||||
approaches=[ApproachDTO(weight=20, reps=12)],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_save_and_list_roundtrip(session):
|
||||
repository = TrainingRepository(session)
|
||||
|
||||
training_id = await repository.save(make_training())
|
||||
trainings = await repository.list()
|
||||
|
||||
assert len(trainings) == 1
|
||||
saved = trainings[0]
|
||||
assert saved.id == training_id
|
||||
assert (saved.date, saved.trainer) == (date(2025, 1, 1), "solo")
|
||||
assert [e.name for e in saved.exercises] == ["Жим ногами", "Жим гантелей лежа"]
|
||||
assert saved.exercises[1].splitted_weight is True
|
||||
assert [(a.weight, a.reps) for a in saved.exercises[0].approaches] == [
|
||||
(100, 12),
|
||||
(110, 10),
|
||||
]
|
||||
|
||||
|
||||
async def test_list_is_ordered_by_date_desc(session):
|
||||
repository = TrainingRepository(session)
|
||||
await repository.save_many([make_training(day=1), make_training(day=3)])
|
||||
|
||||
assert [t.date.day for t in await repository.list()] == [3, 1]
|
||||
|
||||
|
||||
async def test_exercise_names_are_distinct_counted_and_sorted(session):
|
||||
repository = TrainingRepository(session)
|
||||
await repository.save_many([make_training(day=1), make_training(day=3)])
|
||||
|
||||
names = await repository.list_exercise_names()
|
||||
|
||||
assert [(item.name, item.count) for item in names] == [
|
||||
("Жим гантелей лежа", 2),
|
||||
("Жим ногами", 2),
|
||||
]
|
||||
|
||||
|
||||
async def test_exercise_history_is_newest_first(session):
|
||||
repository = TrainingRepository(session)
|
||||
await repository.save_many(
|
||||
[make_training(day=1), make_training(day=3, trainer="c153")]
|
||||
)
|
||||
|
||||
history = await repository.exercise_history("Жим ногами")
|
||||
|
||||
assert [item.date.day for item in history] == [3, 1]
|
||||
assert [item.trainer for item in history] == ["c153", "solo"]
|
||||
assert [(a.weight, a.reps) for a in history[0].approaches] == [(100, 12), (110, 10)]
|
||||
assert history[0].splitted_weight is False
|
||||
|
||||
|
||||
async def test_exercise_history_keeps_splitted_weight_and_ignores_other_exercises(
|
||||
session,
|
||||
):
|
||||
repository = TrainingRepository(session)
|
||||
await repository.save(make_training())
|
||||
|
||||
history = await repository.exercise_history("Жим гантелей лежа")
|
||||
|
||||
assert len(history) == 1
|
||||
assert history[0].splitted_weight is True
|
||||
assert [a.weight for a in history[0].approaches] == [20]
|
||||
assert await repository.exercise_history("Несуществующее") == []
|
||||
|
||||
|
||||
async def test_delete_cascades_to_exercises_and_approaches(session):
|
||||
repository = TrainingRepository(session)
|
||||
keep_id, delete_id = await repository.save_many(
|
||||
[make_training(day=1), make_training(day=2)]
|
||||
)
|
||||
|
||||
assert await repository.delete([delete_id]) == 1
|
||||
|
||||
assert [t.id for t in await repository.list()] == [keep_id]
|
||||
assert await session.scalar(select(func.count(exercises.c.id))) == 2
|
||||
assert await session.scalar(select(func.count(approaches.c.id))) == 3
|
||||
|
||||
|
||||
async def test_save_many_is_atomic(session):
|
||||
repository = TrainingRepository(session)
|
||||
broken = make_training(day=2, trainer="x" * 101) # trainer is VARCHAR(100)
|
||||
|
||||
with pytest.raises(DBAPIError):
|
||||
await repository.save_many([make_training(day=1), broken])
|
||||
await session.rollback()
|
||||
|
||||
assert await repository.count() == 0
|
||||
|
||||
|
||||
async def test_all_notes_can_be_saved(session):
|
||||
trainings = ObsidianNotesParser().parse_file() + AppleNotesParser().parse_file()
|
||||
repository = TrainingRepository(session)
|
||||
|
||||
ids = await repository.save_many(trainings)
|
||||
|
||||
assert len(ids) == len(trainings)
|
||||
assert await repository.count() == len(trainings)
|
||||
|
||||
|
||||
async def test_migrations_match_models(alembic_config):
|
||||
await asyncio.to_thread(command.upgrade, alembic_config, "head")
|
||||
# check() raises if autogenerate would produce a new migration
|
||||
await asyncio.to_thread(command.check, alembic_config)
|
||||
|
||||
|
||||
async def test_downgrade_and_upgrade(alembic_config):
|
||||
await asyncio.to_thread(command.downgrade, alembic_config, "base")
|
||||
await asyncio.to_thread(command.upgrade, alembic_config, "head")
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
# Development overrides for hot reload
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
fastapi-app:
|
||||
environment:
|
||||
- DEBUG=true
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/app --log-level debug
|
||||
command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/app --log-level debug"
|
||||
develop:
|
||||
watch:
|
||||
- action: sync
|
||||
path: ./app
|
||||
path: ./backend/app
|
||||
target: /app/app
|
||||
- action: rebuild
|
||||
path: ./pyproject.toml
|
||||
path: ./backend/requirements.txt
|
||||
- action: rebuild
|
||||
path: ./backend/requirements-dev.txt
|
||||
|
|
|
|||
34
compose.yaml
34
compose.yaml
|
|
@ -1,25 +1,41 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
fastapi-app:
|
||||
build:
|
||||
context: .
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./app:/app/app:ro
|
||||
- ./data:/app/data:ro
|
||||
- ./tests:/app/tests:ro
|
||||
- ./migrations:/app/migrations:ro
|
||||
- ./backend/app:/app/app:ro
|
||||
- ./backend/data:/app/data:ro
|
||||
- ./backend/tests:/app/tests:ro
|
||||
# Writable: `alembic revision --autogenerate` creates files in migrations/versions
|
||||
- ./backend/migrations:/app/migrations
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PYTHONPATH=${PYTHONPATH}
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/app
|
||||
- PYTHONPATH=${PYTHONPATH:-/app}
|
||||
# Tests wipe their tables, so they use a separate database (created on demand)
|
||||
- TEST_DATABASE_URL=postgresql+asyncpg://${POSTGRES_DATABASE_USER}:${POSTGRES_DATABASE_PASSWORD}@postgres:5432/${POSTGRES_DATABASE_DB}_test
|
||||
command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/app"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
# Static files are served as they are: edits show up on page reload
|
||||
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
|
||||
- ./frontend/exercises.html:/usr/share/nginx/html/exercises.html:ro
|
||||
- ./frontend/parse.html:/usr/share/nginx/html/parse.html:ro
|
||||
- ./frontend/css:/usr/share/nginx/html/css:ro
|
||||
- ./frontend/js:/usr/share/nginx/html/js:ro
|
||||
- ./frontend/vendor:/usr/share/nginx/html/vendor:ro
|
||||
depends_on:
|
||||
- fastapi-app
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env_file:
|
||||
|
|
|
|||
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.git
|
||||
*.md
|
||||
7
frontend/Dockerfile
Normal file
7
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY index.html exercises.html parse.html /usr/share/nginx/html/
|
||||
COPY css /usr/share/nginx/html/css
|
||||
COPY js /usr/share/nginx/html/js
|
||||
COPY vendor /usr/share/nginx/html/vendor
|
||||
74
frontend/css/main.css
Normal file
74
frontend/css/main.css
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/* Custom styles for Fitness Parser */
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Sidebar navigation */
|
||||
.sidebar .nav-link:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.sidebar {
|
||||
flex: 0 0 15rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lets wide tables scroll inside the content instead of stretching the page */
|
||||
.content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Text with a tooltip */
|
||||
.has-tip {
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted currentColor;
|
||||
}
|
||||
|
||||
/* Trainings table */
|
||||
td.date {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* DataTables right-aligns columns it detects as dates; dates read better on the left.
|
||||
Same selector as in dataTables.bootstrap5.min.css, this file is loaded after it. */
|
||||
table.dataTable th.dt-type-date,
|
||||
table.dataTable td.dt-type-date {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.approach {
|
||||
display: inline-block;
|
||||
margin: 0 0.35rem 0.15rem 0;
|
||||
padding: 0.05rem 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background: #eef2f7;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
43
frontend/exercises.html
Normal file
43
frontend/exercises.html
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Упражнения - Fitness Parser</title>
|
||||
|
||||
<link href="vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="vendor/dataTables.bootstrap5.min.css" rel="stylesheet">
|
||||
<link href="css/main.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="d-flex flex-column flex-md-row">
|
||||
<!-- Sidebar navigation: on narrow screens it turns into a strip on top -->
|
||||
<aside class="sidebar bg-dark p-3">
|
||||
<a class="d-block text-white text-decoration-none fs-5 fw-bold mb-md-4 me-3" href="index.html">Fitness parser</a>
|
||||
<ul class="nav nav-pills flex-row flex-md-column gap-1 mt-2 mt-md-0">
|
||||
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white active" href="exercises.html">Упражнения</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="flex-grow-1 p-4 content" id="content">
|
||||
<h1 class="h3 mb-3">История упражнения</h1>
|
||||
<form method="get" action="exercises.html" class="mb-4">
|
||||
<label for="exercise-input" class="form-label">Упражнение</label>
|
||||
<div class="input-group">
|
||||
<input type="search" id="exercise-input" name="name" class="form-control" list="exercise-names"
|
||||
placeholder="Начните вводить название…" autocomplete="off">
|
||||
<button type="submit" class="btn btn-primary">Показать</button>
|
||||
</div>
|
||||
<datalist id="exercise-names"></datalist>
|
||||
</form>
|
||||
<div id="exercise"></div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="vendor/chart.umd.js"></script>
|
||||
<script src="vendor/dataTables.min.js"></script>
|
||||
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
||||
<script type="module" src="js/exercises.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
frontend/index.html
Normal file
34
frontend/index.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Тренировки - Fitness Parser</title>
|
||||
|
||||
<link href="vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="vendor/dataTables.bootstrap5.min.css" rel="stylesheet">
|
||||
<link href="css/main.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="d-flex flex-column flex-md-row">
|
||||
<!-- Sidebar navigation: on narrow screens it turns into a strip on top -->
|
||||
<aside class="sidebar bg-dark p-3">
|
||||
<a class="d-block text-white text-decoration-none fs-5 fw-bold mb-md-4 me-3" href="index.html">Fitness parser</a>
|
||||
<ul class="nav nav-pills flex-row flex-md-column gap-1 mt-2 mt-md-0">
|
||||
<li class="nav-item"><a class="nav-link text-white active" href="index.html">Тренировки</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="flex-grow-1 p-4 content" id="content">
|
||||
<h1 class="h3 mb-3">Все упражнения</h1>
|
||||
<div id="trainings"><div class="text-muted">Загрузка…</div></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="vendor/dataTables.min.js"></script>
|
||||
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
||||
<script type="module" src="js/trainings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
62
frontend/js/common.js
Normal file
62
frontend/js/common.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Shared helpers: API access, DOM building, formatting
|
||||
|
||||
const API = '/api/v1';
|
||||
|
||||
export async function api(path, options) {
|
||||
const response = await fetch(API + path, options);
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP ${response.status}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/** h('div', {class: 'x'}, 'text', childNode) -> element; strings become text nodes */
|
||||
export function h(tag, attrs = {}, ...children) {
|
||||
const element = document.createElement(tag);
|
||||
for (const [name, value] of Object.entries(attrs)) {
|
||||
if (value !== null && value !== undefined && value !== false) {
|
||||
element.setAttribute(name, value === true ? '' : value);
|
||||
}
|
||||
}
|
||||
element.append(...children.filter((child) => child !== null && child !== undefined));
|
||||
return element;
|
||||
}
|
||||
|
||||
export function alert(kind, ...children) {
|
||||
return h('div', { class: `alert alert-${kind}` }, ...children);
|
||||
}
|
||||
|
||||
/** "2026-10-21" -> "21.10.2026" (no Date: a timezone must not shift the day) */
|
||||
export function formatDate(iso) {
|
||||
const [year, month, day] = iso.split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
/** Rounded to one decimal without trailing zeros: 22.5 -> "22.5", 20 -> "20" */
|
||||
export function num(value) {
|
||||
return String(Math.round(value * 10) / 10);
|
||||
}
|
||||
|
||||
/** Same with an explicit sign: "+12", "-3.5", "+0" */
|
||||
export function signed(value) {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return (rounded > 0 ? '+' : '') + rounded;
|
||||
}
|
||||
|
||||
/** Approaches as "weight × reps" chips */
|
||||
export function approachChips(approaches) {
|
||||
return approaches.map((approach) =>
|
||||
h('span', { class: 'approach' },
|
||||
`${approach.weight ? num(approach.weight) + ' кг' : 'без веса'} × ${approach.reps}`)
|
||||
);
|
||||
}
|
||||
|
||||
export function perArmBadge() {
|
||||
return h('span', { class: 'badge text-bg-light border', title: 'Вес указан на каждую руку (гантель)' }, 'на руку');
|
||||
}
|
||||
|
||||
export function tip(text, hint) {
|
||||
return h('span', { class: 'has-tip', title: hint }, text);
|
||||
}
|
||||
187
frontend/js/exercises.js
Normal file
187
frontend/js/exercises.js
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, tip } from './common.js';
|
||||
import { createTable } from './table.js';
|
||||
|
||||
const HINTS = {
|
||||
recordE1rm: 'e1RM — расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Рекорд — наибольшее значение за всю историю.',
|
||||
lastE1rm: 'e1RM лучшего подхода последней тренировки. Ниже — изменение относительно первой тренировки с этим упражнением.',
|
||||
recordVolume: 'Тоннаж — суммарная работа за тренировку: сумма вес × повторы по всем подходам с весом. Рекорд — тренировка с наибольшим тоннажем.',
|
||||
maxWeight: 'Самый тяжёлый вес в одном подходе за всю историю, без учёта повторов.',
|
||||
bestReps: 'Наибольшее число повторов в одном подходе за всю историю.',
|
||||
lastBestReps: 'Наибольшее число повторов в одном подходе последней тренировки. Ниже — изменение относительно первой тренировки.',
|
||||
recordTotalReps: 'Наибольшая сумма повторов по всем подходам за одну тренировку.',
|
||||
e1rm: 'Расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Подходы без веса не учитываются.',
|
||||
volume: 'Тоннаж: сумма вес × повторы по подходам с весом за тренировку.',
|
||||
};
|
||||
|
||||
const input = document.getElementById('exercise-input');
|
||||
const container = document.getElementById('exercise');
|
||||
const query = (new URLSearchParams(location.search).get('name') || '').trim();
|
||||
|
||||
let names = [];
|
||||
try {
|
||||
names = await api('/exercises/');
|
||||
} catch (error) {
|
||||
container.replaceChildren(alert('danger', `Не удалось загрузить упражнения: ${error.message}`));
|
||||
}
|
||||
|
||||
document.getElementById('exercise-names').replaceChildren(...names.map((item) => h('option', { value: item.name })));
|
||||
input.value = query;
|
||||
if (!query) {
|
||||
input.focus();
|
||||
}
|
||||
|
||||
// Open the history right after a suggestion is picked from the list. Typing, pasting and
|
||||
// deleting have their own inputType, so a half-typed name that equals a shorter exercise
|
||||
// name doesn't submit early; Enter/button always work.
|
||||
const knownNames = new Set(names.map((item) => item.name));
|
||||
input.addEventListener('input', (event) => {
|
||||
const picked = !event.inputType || event.inputType === 'insertReplacementText';
|
||||
if (picked && knownNames.has(input.value)) {
|
||||
input.form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
if (query) {
|
||||
await showProgress();
|
||||
} else if (!names.length && !container.children.length) {
|
||||
container.replaceChildren(alert('info',
|
||||
'В базе пока нет тренировок. Загрузи их из заметок командой:',
|
||||
h('br'), h('code', {}, 'docker compose exec fastapi-app python -m app.cli import')));
|
||||
}
|
||||
|
||||
async function showProgress() {
|
||||
let progress;
|
||||
try {
|
||||
progress = await api(`/exercises/progress/?name=${encodeURIComponent(query)}`);
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
container.replaceChildren(alert('danger', `Не удалось загрузить историю: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
// Nothing matched exactly: offer the names containing the typed text
|
||||
const suggestions = names.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()));
|
||||
container.replaceChildren(
|
||||
alert('warning', `Упражнение «${query}» не найдено. ${suggestions.length ? 'Возможно, вы искали:' : ''}`),
|
||||
...(suggestions.length ? [h('div', { class: 'list-group' }, ...suggestions.map((item) =>
|
||||
h('a', { class: 'list-group-item list-group-item-action d-flex justify-content-between align-items-center', href: `exercises.html?name=${encodeURIComponent(item.name)}` },
|
||||
item.name, h('span', { class: 'badge text-bg-secondary rounded-pill' }, `${item.count}`))))] : []));
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = progress.name;
|
||||
container.replaceChildren(...progressView(progress));
|
||||
if (progress.rows.length) {
|
||||
drawChart(progress);
|
||||
}
|
||||
}
|
||||
|
||||
function progressView(progress) {
|
||||
const { rows, weighted } = progress;
|
||||
const first = rows[rows.length - 1];
|
||||
const last = rows[0];
|
||||
|
||||
const change = (unit) => h('span', {},
|
||||
h('span', { class: progress.primary_change > 0 ? 'text-success' : progress.primary_change < 0 ? 'text-danger' : 'text-muted' },
|
||||
`${signed(progress.primary_change)} ${unit}`,
|
||||
progress.primary_change_percent !== null ? ` (${progress.primary_change_percent >= 0 ? '+' : ''}${progress.primary_change_percent.toFixed(1)}%)` : ''),
|
||||
' к первой тренировке');
|
||||
|
||||
const card = (title, value, note, hint) => h('div', { class: 'col' },
|
||||
h('div', { class: 'card h-100 mb-0' }, h('div', { class: 'card-body' },
|
||||
h('div', { class: 'text-muted small' }, tip(title, hint)),
|
||||
h('div', { class: 'fs-4 fw-semibold' }, value),
|
||||
h('div', { class: 'text-muted small' }, note))));
|
||||
|
||||
const cards = weighted ? [
|
||||
card('Рекорд e1RM', `${num(progress.best_primary.value)} кг`, formatDate(progress.best_primary.date), HINTS.recordE1rm),
|
||||
card('Последний e1RM', `${num(progress.last_primary)} кг`, rows.length > 1 ? change('кг') : '', HINTS.lastE1rm),
|
||||
card('Рекорд тоннажа', `${num(progress.best_secondary.value)} кг`, formatDate(progress.best_secondary.date), HINTS.recordVolume),
|
||||
card('Максимальный вес', `${num(progress.max_weight)} кг`, '', HINTS.maxWeight),
|
||||
] : [
|
||||
card('Лучший подход', `${num(progress.best_primary.value)} повт.`, formatDate(progress.best_primary.date), HINTS.bestReps),
|
||||
card('Лучший подход в последний раз', `${num(progress.last_primary)} повт.`, rows.length > 1 ? change('повт.') : '', HINTS.lastBestReps),
|
||||
card('Рекорд суммы повторов', num(progress.best_secondary.value), formatDate(progress.best_secondary.date), HINTS.recordTotalReps),
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ title: 'Дата', className: 'text-nowrap date', sort: (row) => row.date, cell: (row) => formatDate(row.date) },
|
||||
{ title: 'Тренер', className: 'text-nowrap', sort: (row) => row.trainer || '', cell: (row) => row.trainer || '' },
|
||||
{ title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...approachChips(row.approaches), row.splitted_weight ? perArmBadge() : null) },
|
||||
...(weighted ? [
|
||||
{ title: 'Макс. вес', className: 'text-nowrap text-end', sort: (row) => row.metrics.max_weight, cell: (row) => row.metrics.max_weight ? `${num(row.metrics.max_weight)} кг` : '—' },
|
||||
{ title: tip('e1RM', HINTS.e1rm), className: 'text-nowrap text-end', sort: (row) => row.metrics.e1rm || 0, cell: (row) => row.metrics.e1rm ? `${num(row.metrics.e1rm)} кг` : '—' },
|
||||
{ title: tip('Тоннаж', HINTS.volume), className: 'text-nowrap text-end', sort: (row) => row.metrics.volume, cell: (row) => row.metrics.volume ? `${num(row.metrics.volume)} кг` : '—' },
|
||||
] : [
|
||||
{ title: 'Лучший подход', className: 'text-nowrap text-end', sort: (row) => row.metrics.best_reps, cell: (row) => `${row.metrics.best_reps}` },
|
||||
{ title: 'Всего повторов', className: 'text-nowrap text-end', sort: (row) => row.metrics.total_reps, cell: (row) => `${row.metrics.total_reps}` },
|
||||
]),
|
||||
];
|
||||
|
||||
return [
|
||||
h('h2', { class: 'h4' }, progress.name),
|
||||
h('p', { class: 'text-muted' },
|
||||
'Тренировок: ', h('strong', {}, `${rows.length}`),
|
||||
', первая: ', h('strong', {}, formatDate(first.date)),
|
||||
', последняя: ', h('strong', {}, formatDate(last.date))),
|
||||
h('div', { class: 'row row-cols-1 row-cols-sm-2 row-cols-xl-4 g-3 mb-4' }, ...cards),
|
||||
h('div', { class: 'card mb-2' }, h('div', { class: 'card-body' },
|
||||
h('div', { style: 'height: 320px' }, h('canvas', { id: 'progress-chart' })))),
|
||||
h('p', { class: 'text-muted small mb-4' }, weighted
|
||||
? 'e1RM — расчётный максимум на один повтор по формуле Эпли: вес × (1 + повторы / 30), берётся лучший подход тренировки; подходы без веса не учитываются. Тоннаж — сумма вес × повторы по подходам с весом.'
|
||||
: 'Упражнение выполняется без дополнительного веса, поэтому прогресс считается по повторам.'),
|
||||
createTable({ id: 'exercise-history-table', columns, rows }),
|
||||
];
|
||||
}
|
||||
|
||||
/** Two series over time, each on its own y axis. The x axis is linear so gaps between
|
||||
* trainings are proportional to the real time. */
|
||||
function drawChart(progress) {
|
||||
const series = progress.weighted
|
||||
? [['e1RM, кг', (row) => row.metrics.e1rm], ['Тоннаж, кг', (row) => row.metrics.volume || null]]
|
||||
: [['Лучший подход, повторы', (row) => row.metrics.best_reps], ['Всего повторов за тренировку', (row) => row.metrics.total_reps]];
|
||||
const chronological = [...progress.rows].reverse();
|
||||
const datasets = series.map(([label, metric]) => ({
|
||||
label,
|
||||
points: chronological
|
||||
.map((row) => ({ x: Date.parse(row.date), y: metric(row) })) // "YYYY-MM-DD" parses as UTC
|
||||
.filter((point) => point.y !== null && point.y !== undefined)
|
||||
.map((point) => ({ x: point.x, y: Math.round(point.y * 10) / 10 })),
|
||||
}));
|
||||
|
||||
const colors = ['#0d6efd', '#fd7e14'];
|
||||
const formatTick = (timestamp, options) =>
|
||||
new Date(timestamp).toLocaleDateString('ru-RU', { ...options, timeZone: 'UTC' });
|
||||
|
||||
const scales = {
|
||||
x: { type: 'linear', ticks: { maxTicksLimit: 8, callback: (value) => formatTick(value, { month: 'short', year: 'numeric' }) } },
|
||||
};
|
||||
datasets.forEach((item, index) => {
|
||||
scales['y' + index] = {
|
||||
position: index === 0 ? 'left' : 'right',
|
||||
grid: { drawOnChartArea: index === 0 },
|
||||
title: { display: true, text: item.label, color: colors[index] },
|
||||
};
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('progress-chart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: datasets.map((item, index) => ({
|
||||
label: item.label,
|
||||
data: item.points,
|
||||
yAxisID: 'y' + index,
|
||||
borderColor: colors[index],
|
||||
backgroundColor: colors[index],
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
tension: 0.2,
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'nearest', axis: 'x', intersect: false },
|
||||
scales,
|
||||
plugins: { tooltip: { callbacks: { title: (items) => formatTick(items[0].parsed.x, {}) } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
23
frontend/js/parse.js
Normal file
23
frontend/js/parse.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { alert, api } from './common.js';
|
||||
import { trainingsView } from './trainings-view.js';
|
||||
|
||||
const form = document.getElementById('parse-form');
|
||||
const result = document.getElementById('result');
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
result.replaceChildren(alert('info', 'Разбираю…'));
|
||||
try {
|
||||
const trainings = await api('/parser/parse/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
data: document.getElementById('training-data').value,
|
||||
format: document.getElementById('format').value,
|
||||
}),
|
||||
});
|
||||
result.replaceChildren(trainings.length ? trainingsView(trainings) : alert('warning', 'Тренировки не найдены'));
|
||||
} catch (error) {
|
||||
result.replaceChildren(alert('danger', `Не удалось разобрать заметку: ${error.message}`));
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue