Compare commits

...

3 commits

123 changed files with 55240 additions and 1673 deletions

View file

@ -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)

View file

@ -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

View file

@ -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"}

View file

@ -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}
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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');
});
});

View file

@ -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>

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -35,7 +35,6 @@ ENV/
Thumbs.db
# Project specific
uv.lock
.mypy_cache
.opencode

View file

@ -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
View 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
View 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)

View 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
],
)

View file

@ -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)

View file

@ -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"])

View 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
View 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()

View file

@ -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}"
)

View 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

View 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

View 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()),
)

View 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
View 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,
)

View 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()

View 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 = {
# Подъем гантелей на бицепс стоя
"Гантели": "Подъем гантелей на бицепс стоя",
"Жим гантелей стоя": "Подъем гантелей на бицепс стоя",
"Подъем гантелей стоя": "Подъем гантелей на бицепс стоя",
"Сгибание рук с гантелями стоя": "Подъем гантелей на бицепс стоя",
"Сгибание рук с гантелями стоя ям": "Подъем гантелей на бицепс стоя",

View 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

View 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()

View 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

View file

@ -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}")

View file

@ -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("/")

View file

@ -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))

View 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

View file

@ -28,4 +28,5 @@ class ExerciseResponse(BaseModel):
class ParseResponse(BaseModel):
date: date
trainer: Optional[str] = None
exercises: Optional[List[ExerciseResponse]]

View file

@ -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)**

View file

@ -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 |

View file

@ -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()

View file

@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

99
backend/migrations/env.py Normal file
View 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()

View 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"}

View file

@ -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
View 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"

View 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
View 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
View 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()

View 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

View 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")

View file

@ -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")

View 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")

View file

@ -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

View file

@ -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
View file

@ -0,0 +1,2 @@
.git
*.md

7
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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