Refactor: split backend and frontend

This commit is contained in:
t0xa 2026-10-23 20:06:09 +03:00
parent fc192a4a0b
commit e591956858
114 changed files with 933 additions and 891 deletions

View file

@ -8,21 +8,24 @@ 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
The project is developed and run **only in Docker**: there is no local Python setup. Everything below runs inside the `fastapi-app` container (`.env` must have `POSTGRES_DATABASE_HOST=postgres`, see `.env.example`).
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`).
### Docker Development
```bash
# Start the app + PostgreSQL (applies migrations on start, hot reload for app/)
# Start backend (:8000) + frontend (:8080) + PostgreSQL (applies migrations on start, hot reload for backend/app/)
# Open http://localhost:8080
docker compose up --build
# 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 models in app/core/database/models/
# 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"
@ -57,39 +60,41 @@ docker compose exec fastapi-app mypy app/
### 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 uses PostgreSQL through async SQLAlchemy (asyncpg). ORM models live in `app/core/database/models/`, `TrainingRepository` (`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`.
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` - Standalone uvicorn runner (unused: Docker starts uvicorn from `compose.yaml`)
- `migrations/` - Alembic environment and versions
- `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,124 +0,0 @@
import calendar
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Request, Form
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.api.deps import get_training_repository
from app.core.database.repository import TrainingRepository
from app.core.metrics import ExerciseProgress, summarize
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)
def build_progress_chart(progress: ExerciseProgress) -> dict:
"""Data for the progress chart: two series over time, x is a UTC timestamp in ms."""
if progress.weighted:
series = [
("e1RM, кг", lambda metrics: metrics.e1rm),
("Тоннаж, кг", lambda metrics: metrics.volume or None),
]
else:
series = [
("Лучший подход, повторы", lambda metrics: metrics.best_reps),
("Всего повторов за тренировку", lambda metrics: metrics.total_reps),
]
chronological = list(reversed(progress.rows))
return {
"series": [
{
"label": label,
"points": [
[calendar.timegm(row.session.date.timetuple()) * 1000, round(value, 1)]
for row in chronological
if (value := metric(row.metrics)) is not None
],
}
for label, metric in series
]
}
@web_router.get("/", response_class=HTMLResponse)
async def home(
request: Request,
repository: TrainingRepository = Depends(get_training_repository),
):
"""Home page: all trainings from the database in a single table"""
return template_renderer.render(
request,
"index.html",
context={"trainings": await repository.list()},
)
@web_router.get("/exercises/", response_class=HTMLResponse)
async def exercises(
request: Request,
name: Optional[str] = None,
repository: TrainingRepository = Depends(get_training_repository),
):
"""Exercise search box and the whole history of the chosen exercise"""
query = (name or "").strip()
exercise_names = await repository.list_exercise_names()
# Exact name, but case-insensitive: what was typed may differ in case
names_by_casefold = {item.name.casefold(): item.name for item in exercise_names}
selected = names_by_casefold.get(query.casefold())
history = await repository.exercise_history(selected) if selected else []
# Nothing matched exactly: offer the names containing the typed text
suggestions = (
[item for item in exercise_names if query.casefold() in item.name.casefold()]
if query and not selected
else []
)
progress = summarize(history) if history else None
return template_renderer.render(
request,
"exercises.html",
context={
"exercise_names": exercise_names,
"query": query,
"selected": selected,
"suggestions": suggestions,
"progress": progress,
"chart": build_progress_chart(progress) if progress else None,
},
)
@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,6 +0,0 @@
from app.core.database.models.approach import ApproachModel
from app.core.database.models.base import Base
from app.core.database.models.exercise import ExerciseModel
from app.core.database.models.training import TrainingModel
__all__ = ["ApproachModel", "Base", "ExerciseModel", "TrainingModel"]

View file

@ -1,26 +0,0 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, Float, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database.models.base import Base
if TYPE_CHECKING:
from app.core.database.models.exercise import ExerciseModel
class ApproachModel(Base):
__tablename__ = "approaches"
id: Mapped[int] = mapped_column(primary_key=True)
exercise_id: Mapped[int] = mapped_column(
ForeignKey("exercises.id", ondelete="CASCADE"), index=True
)
weight: Mapped[float] = mapped_column(Float)
reps: Mapped[int]
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
exercise: Mapped["ExerciseModel"] = relationship(back_populates="approaches")

View file

@ -1,5 +0,0 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View file

@ -1,33 +0,0 @@
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database.models.base import Base
if TYPE_CHECKING:
from app.core.database.models.approach import ApproachModel
from app.core.database.models.training import TrainingModel
class ExerciseModel(Base):
__tablename__ = "exercises"
id: Mapped[int] = mapped_column(primary_key=True)
training_id: Mapped[int] = mapped_column(
ForeignKey("trainings.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(255), index=True)
splitted_weight: Mapped[bool] = mapped_column(Boolean, server_default="false")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
training: Mapped["TrainingModel"] = relationship(back_populates="exercises")
approaches: Mapped[List["ApproachModel"]] = relationship(
back_populates="exercise",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="ApproachModel.id",
)

View file

@ -1,28 +0,0 @@
from datetime import date as date_type, datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import Date, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database.models.base import Base
if TYPE_CHECKING:
from app.core.database.models.exercise import ExerciseModel
class TrainingModel(Base):
__tablename__ = "trainings"
id: Mapped[int] = mapped_column(primary_key=True)
date: Mapped[date_type] = mapped_column(Date, index=True)
trainer: Mapped[Optional[str]] = mapped_column(String(100))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
exercises: Mapped[List["ExerciseModel"]] = relationship(
back_populates="training",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="ExerciseModel.id",
)

View file

@ -1,114 +0,0 @@
from typing import List, Sequence
from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import contains_eager, selectinload
from app.core.database.models import ApproachModel, ExerciseModel, TrainingModel
from app.core.dto.training import (
ApproachDTO,
ExerciseNameDTO,
ExerciseSessionDTO,
TrainingDTO,
)
class TrainingRepository:
"""Storage for trainings together with their exercises and approaches."""
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, trainings: Sequence[TrainingDTO]) -> List[int]:
"""Save all trainings in a single transaction, returns their ids."""
models = [self._to_model(training) for training in trainings]
self._session.add_all(models)
await self._session.commit()
return [model.id for model in models]
async def list(self) -> List[TrainingDTO]:
query = (
select(TrainingModel)
.options(
selectinload(TrainingModel.exercises).selectinload(
ExerciseModel.approaches
)
)
.order_by(TrainingModel.date.desc(), TrainingModel.id.desc())
)
result = await self._session.scalars(query)
return [TrainingDTO.model_validate(model) for model in result]
async def list_exercise_names(self) -> List[ExerciseNameDTO]:
"""Distinct exercise names with the number of times each was done."""
query = select(ExerciseModel.name, func.count(ExerciseModel.id)).group_by(
ExerciseModel.name
)
result = await self._session.execute(query)
names = [ExerciseNameDTO(name=name, count=count) for name, count 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(ExerciseModel)
.join(ExerciseModel.training)
.where(ExerciseModel.name == name)
.options(
contains_eager(ExerciseModel.training),
selectinload(ExerciseModel.approaches),
)
.order_by(TrainingModel.date.desc(), ExerciseModel.id.desc())
)
result = await self._session.scalars(query)
return [
ExerciseSessionDTO(
date=exercise.training.date,
trainer=exercise.training.trainer,
splitted_weight=exercise.splitted_weight,
approaches=[
ApproachDTO.model_validate(approach)
for approach in exercise.approaches
],
)
for exercise in result
]
async def count(self) -> int:
return await self._session.scalar(select(func.count(TrainingModel.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(TrainingModel).where(TrainingModel.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()
@staticmethod
def _to_model(training: TrainingDTO) -> TrainingModel:
return TrainingModel(
date=training.date,
trainer=training.trainer,
exercises=[
ExerciseModel(
name=exercise.name,
splitted_weight=exercise.splitted_weight,
approaches=[
ApproachModel(weight=approach.weight, reps=approach.reps)
for approach in exercise.approaches
],
)
for exercise in training.exercises
],
)

View file

@ -1,29 +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 = {
"title": "Fitness stats",
"app_name": "Fitness parser"
}
if context:
base_context.update(context)
return self.templates.TemplateResponse(request, template_name, base_context)

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,4 +0,0 @@
/*! DataTables Bootstrap 5 integration
* © SpryMedia Ltd - datatables.net/license
*/
!function(n){var o,r;"function"==typeof define&&define.amd?define(["jquery","datatables.net"],function(t){return n(t,window,document)}):"object"==typeof exports?(o=require("jquery"),r=function(t,e){e.fn.dataTable||require("datatables.net")(t,e)},"undefined"==typeof window?module.exports=function(t,e){return t=t||window,e=e||o(t),r(t,e),n(e,0,t.document)}:(r(window,o),module.exports=n(o,window,window.document))):n(jQuery,window,document)}(function(d,t,e){"use strict";var n=d.fn.dataTable;return d.extend(!0,n.defaults,{renderer:"bootstrap"}),d.extend(!0,n.ext.classes,{container:"dt-container dt-bootstrap5",search:{input:"form-control form-control-sm"},length:{select:"form-select form-select-sm"},processing:{container:"dt-processing card"},layout:{row:"row mt-2 justify-content-between",cell:"d-md-flex justify-content-between align-items-center",tableCell:"col-12",start:"dt-layout-start col-md-auto me-auto",end:"dt-layout-end col-md-auto ms-auto",full:"dt-layout-full col-md"}}),n.ext.renderer.pagingButton.bootstrap=function(t,e,n,o,r){var a=["dt-paging-button","page-item"],o=(o&&a.push("active"),r&&a.push("disabled"),d("<li>").addClass(a.join(" ")));return{display:o,clicker:d("<button>",{class:"page-link",role:"link",type:"button"}).html(n).appendTo(o)}},n.ext.renderer.pagingContainer.bootstrap=function(t,e){return d("<ul/>").addClass("pagination").append(e)},n});

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,111 +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');
});
// Bootstrap tooltips (explanations of the metrics)
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(
(element) => new bootstrap.Tooltip(element)
);
// Exercise search: 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 $exerciseInput = $('#exercise-input');
if ($exerciseInput.length) {
const exerciseNames = new Set(
$('#exercise-names option').map((_, option) => option.value).get()
);
$exerciseInput.on('input', function(event) {
const inputType = event.originalEvent && event.originalEvent.inputType;
const picked = !inputType || inputType === 'insertReplacementText';
if (picked && exerciseNames.has(this.value)) {
this.form.submit();
}
});
}
// Exercise progress chart (Chart.js): two series over time, each on its own y axis.
// Points are [UTC timestamp in ms, value]; the x axis is linear so gaps between
// trainings are proportional to the real time.
const chartDataElement = document.getElementById('progress-chart-data');
if (chartDataElement && window.Chart) {
const series = JSON.parse(chartDataElement.textContent).series;
const colors = ['#0d6efd', '#fd7e14'];
const formatDate = (timestamp, options) =>
new Date(timestamp).toLocaleDateString('ru-RU', { ...options, timeZone: 'UTC' });
const scales = {
x: {
type: 'linear',
ticks: {
maxTicksLimit: 8,
callback: (value) => formatDate(value, { month: 'short', year: 'numeric' }),
},
},
};
series.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: series.map((item, index) => ({
label: item.label,
data: item.points.map(([x, y]) => ({ x, y })),
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) => formatDate(items[0].parsed.x, {}),
},
},
},
},
});
}
// Tables with search, sorting and paging by DataTables
const tableOptions = {
pageLength: 50,
lengthMenu: [25, 50, 100, 200],
order: [], // keep the order from the server: newest training first
language: { url: document.body.dataset.datatablesLang },
};
// All trainings: date, trainer, exercise, approaches
$('#trainings-table').DataTable({
...tableOptions,
columnDefs: [{ targets: 3, orderable: false }], // approaches
});
// History of one exercise: date, trainer, approaches, max weight
$('#exercise-history-table').DataTable({
...tableOptions,
columnDefs: [{ targets: 2, orderable: false }], // approaches
});
});

View file

@ -1,54 +0,0 @@
{# Approaches as "weight × reps" chips #}
{% macro approach_chips(approaches) %}{% for approach in approaches %}<span class="approach">{% if approach.weight %}{{ '%g'|format(approach.weight) }} кг{% else %}без веса{% endif %} × {{ approach.reps }}</span>{% endfor %}{% endmacro %}
{# One table for all trainings: a row per exercise. Search, sorting and paging come from DataTables (main.js) #}
{% macro trainings_table(trainings) %}
{% set ns = namespace(exercises=0) %}
{% for training in trainings %}{% set ns.exercises = ns.exercises + training.exercises|length %}{% endfor %}
<p class="text-muted">
Тренировок: <strong>{{ trainings|length }}</strong>,
упражнений: <strong>{{ ns.exercises }}</strong>
</p>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="trainings-table">
<thead>
<tr>
<th scope="col">Дата</th>
<th scope="col">Тренер</th>
<th scope="col">Упражнение</th>
<th scope="col">Подходы (вес × повторения)</th>
</tr>
</thead>
<tbody>
{% for training in trainings %}
{% for exercise in training.exercises %}
<tr>
{# data-order: ISO date, so sorting by date is chronological #}
<td class="text-nowrap date" data-order="{{ training.date.isoformat() }}">{{ training.date.strftime('%d.%m.%Y') }}</td>
<td class="text-nowrap">{{ training.trainer or '' }}</td>
<td>
{{ exercise.name }}
{% if exercise.splitted_weight %}
<span class="badge text-bg-light border" title="Вес указан на каждую руку (гантель)">на руку</span>
{% endif %}
</td>
<td>
{{ approach_chips(exercise.approaches) }}
</td>
</tr>
{% else %}
{# DataTables needs the same number of cells in every row (no colspan) #}
<tr>
<td class="text-nowrap date" data-order="{{ training.date.isoformat() }}">{{ training.date.strftime('%d.%m.%Y') }}</td>
<td class="text-nowrap">{{ training.trainer or '' }}</td>
<td class="text-muted fst-italic">Упражнения не записаны</td>
<td></td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
{% endmacro %}

View file

@ -1,44 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/main.css') }}" rel="stylesheet">
{% block extra_head %}{% endblock %}
</head>
<body data-datatables-lang="{{ url_for('static', path='/js/i18n/datatables-ru.json') }}">
<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="{{ url_for('home') }}">
{{ app_name }}
</a>
<ul class="nav nav-pills flex-row flex-md-column gap-1 mt-2 mt-md-0">
{% for route, label in [('home', 'Тренировки'), ('obsidian_parse_form', 'Разобрать заметку'), ('exercises', 'Упражнения')] %}
<li class="nav-item">
<a class="nav-link text-white{% if request.url.path == url_for(route).path %} active{% endif %}" href="{{ url_for(route) }}">{{ label }}</a>
</li>
{% endfor %}
</ul>
</aside>
<!-- Main Content -->
<main class="flex-grow-1 p-4 content">
{% block content %}{% endblock %}
</main>
</div>
<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/dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.bootstrap5.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/main.js') }}"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View file

@ -1,162 +0,0 @@
{% extends "base.html" %}
{% from "_trainings_table.html" import approach_chips %}
{% block content %}
{% macro num(value) %}{{ '%g'|format(value|round(1)) }}{% endmacro %}
{# "+12 кг (+6.5%)" colored by direction; unit is "кг" or "повт." #}
{% macro change(value, percent, unit) %}
<span class="{% if value > 0 %}text-success{% elif value < 0 %}text-danger{% else %}text-muted{% endif %}">
{{ '%+g'|format(value|round(1)) }} {{ unit }}{% if percent is not none %} ({{ '%+.1f'|format(percent) }}%){% endif %}
</span> к первой тренировке
{% endmacro %}
{# Text with a dotted underline and a tooltip explaining it #}
{% macro tip(text, hint) %}<span class="has-tip" data-bs-toggle="tooltip" title="{{ hint }}">{{ text }}</span>{% endmacro %}
{% macro stat_card(title, value, note, hint) %}
<div class="col">
<div class="card h-100 mb-0">
<div class="card-body">
<div class="text-muted small">{{ tip(title, hint) }}</div>
<div class="fs-4 fw-semibold">{{ value }}</div>
<div class="text-muted small">{{ note }}</div>
</div>
</div>
</div>
{% endmacro %}
{% set hints = {
'record_e1rm': 'e1RM — расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Рекорд — наибольшее значение за всю историю.',
'last_e1rm': 'e1RM лучшего подхода последней тренировки. Ниже — изменение относительно первой тренировки с этим упражнением.',
'record_volume': 'Тоннаж — суммарная работа за тренировку: сумма вес × повторы по всем подходам с весом. Рекорд — тренировка с наибольшим тоннажем.',
'max_weight': 'Самый тяжёлый вес в одном подходе за всю историю, без учёта повторов.',
'best_reps': 'Наибольшее число повторов в одном подходе за всю историю.',
'last_best_reps': 'Наибольшее число повторов в одном подходе последней тренировки. Ниже — изменение относительно первой тренировки.',
'record_total_reps': 'Наибольшая сумма повторов по всем подходам за одну тренировку.',
'e1rm': 'Расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Подходы без веса не учитываются.',
'volume': 'Тоннаж: сумма вес × повторы по подходам с весом за тренировку.',
} %}
<h1 class="h3 mb-3">История упражнения</h1>
<form method="get" action="{{ url_for('exercises') }}" 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"
value="{{ selected or query }}" placeholder="Начните вводить название…"{% if not query %} autofocus{% endif %}>
<button type="submit" class="btn btn-primary">Показать</button>
</div>
<datalist id="exercise-names">
{% for item in exercise_names %}<option value="{{ item.name }}"></option>{% endfor %}
</datalist>
</form>
{% if selected %}
{% set rows = progress.rows %}
<h2 class="h4">{{ selected }}</h2>
<p class="text-muted">
Тренировок: <strong>{{ rows|length }}</strong>,
первая: <strong>{{ rows[-1].session.date.strftime('%d.%m.%Y') }}</strong>,
последняя: <strong>{{ rows[0].session.date.strftime('%d.%m.%Y') }}</strong>
</p>
<div class="row row-cols-1 row-cols-sm-2 row-cols-xl-4 g-3 mb-4">
{% if progress.weighted %}
{{ stat_card('Рекорд e1RM', num(progress.best_primary.value) ~ ' кг', progress.best_primary.date.strftime('%d.%m.%Y'), hints.record_e1rm) }}
{{ stat_card('Последний e1RM', num(progress.last_primary) ~ ' кг',
change(progress.primary_change, progress.primary_change_percent, 'кг') if rows|length > 1 else '',
hints.last_e1rm) }}
{{ stat_card('Рекорд тоннажа', num(progress.best_secondary.value) ~ ' кг', progress.best_secondary.date.strftime('%d.%m.%Y'), hints.record_volume) }}
{{ stat_card('Максимальный вес', num(progress.max_weight) ~ ' кг', '', hints.max_weight) }}
{% else %}
{{ stat_card('Лучший подход', num(progress.best_primary.value) ~ ' повт.', progress.best_primary.date.strftime('%d.%m.%Y'), hints.best_reps) }}
{{ stat_card('Лучший подход в последний раз', num(progress.last_primary) ~ ' повт.',
change(progress.primary_change, progress.primary_change_percent, 'повт.') if rows|length > 1 else '',
hints.last_best_reps) }}
{{ stat_card('Рекорд суммы повторов', num(progress.best_secondary.value), progress.best_secondary.date.strftime('%d.%m.%Y'), hints.record_total_reps) }}
{% endif %}
</div>
<div class="card mb-2">
<div class="card-body">
<div style="height: 320px"><canvas id="progress-chart"></canvas></div>
</div>
</div>
<script type="application/json" id="progress-chart-data">{{ chart|tojson }}</script>
<p class="text-muted small mb-4">
{% if progress.weighted %}
e1RM — расчётный максимум на один повтор по формуле Эпли: вес × (1 + повторы / 30), берётся лучший подход
тренировки; подходы без веса не учитываются. Тоннаж — сумма вес × повторы по подходам с весом.
{% else %}
Упражнение выполняется без дополнительного веса, поэтому прогресс считается по повторам.
{% endif %}
</p>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="exercise-history-table">
<thead>
<tr>
<th scope="col">Дата</th>
<th scope="col">Тренер</th>
<th scope="col">Подходы (вес × повторения)</th>
{% if progress.weighted %}
<th scope="col" class="text-end">Макс. вес</th>
<th scope="col" class="text-end">{{ tip('e1RM', hints.e1rm) }}</th>
<th scope="col" class="text-end">{{ tip('Тоннаж', hints.volume) }}</th>
{% else %}
<th scope="col" class="text-end">Лучший подход</th>
<th scope="col" class="text-end">Всего повторов</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for row in rows %}
{% set item = row.session %}
{% set m = row.metrics %}
<tr>
{# data-order: ISO date and plain numbers, so sorting is chronological/numeric #}
<td class="text-nowrap date" data-order="{{ item.date.isoformat() }}">{{ item.date.strftime('%d.%m.%Y') }}</td>
<td class="text-nowrap">{{ item.trainer or '' }}</td>
<td>
{{ approach_chips(item.approaches) }}
{% if item.splitted_weight %}
<span class="badge text-bg-light border" title="Вес указан на каждую руку (гантель)">на руку</span>
{% endif %}
</td>
{% if progress.weighted %}
<td class="text-nowrap text-end" data-order="{{ m.max_weight }}">{% if m.max_weight %}{{ num(m.max_weight) }} кг{% else %}—{% endif %}</td>
<td class="text-nowrap text-end" data-order="{{ m.e1rm or 0 }}">{% if m.e1rm %}{{ num(m.e1rm) }} кг{% else %}—{% endif %}</td>
<td class="text-nowrap text-end" data-order="{{ m.volume }}">{% if m.volume %}{{ num(m.volume) }} кг{% else %}—{% endif %}</td>
{% else %}
<td class="text-nowrap text-end" data-order="{{ m.best_reps }}">{{ m.best_reps }}</td>
<td class="text-nowrap text-end" data-order="{{ m.total_reps }}">{{ m.total_reps }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif query %}
<div class="alert alert-warning">
Упражнение «{{ query }}» не найдено.
{% if suggestions %}Возможно, вы искали:{% endif %}
</div>
{% if suggestions %}
<div class="list-group">
{% for item in suggestions %}
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
href="{{ url_for('exercises').include_query_params(name=item.name) }}">
{{ item.name }}
<span class="badge text-bg-secondary rounded-pill">{{ item.count }}</span>
</a>
{% endfor %}
</div>
{% endif %}
{% elif not exercise_names %}
<div class="alert alert-info">
В базе пока нет тренировок. Загрузи их из заметок командой:
<br><code>docker compose exec fastapi-app python -m app.cli import</code>
</div>
{% endif %}
{% endblock %}
{% block extra_scripts %}
{% if chart %}<script src="{{ url_for('static', path='/js/chart.umd.js') }}"></script>{% endif %}
{% endblock %}

View file

@ -1,15 +0,0 @@
{% extends "base.html" %}
{% from "_trainings_table.html" import trainings_table %}
{% block content %}
<h1 class="h3 mb-3">Все тренировки</h1>
{% if trainings %}
{{ trainings_table(trainings) }}
{% else %}
<div class="alert alert-info">
В базе пока нет тренировок. Загрузи их из заметок командой:
<br><code>docker compose exec fastapi-app python -m app.cli import</code>
</div>
{% endif %}
{% 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,12 +0,0 @@
{% extends "base.html" %}
{% from "_trainings_table.html" import trainings_table %}
{% block content %}
<h1 class="h3 mb-3">Результат разбора</h1>
{% if trainings %}
{{ trainings_table(trainings) }}
{% else %}
<p>Тренировки не найдены</p>
{% endif %}
{% endblock %}

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

@ -1,4 +1,3 @@
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
from datetime import date
@ -9,25 +8,25 @@ class DTOBase(BaseModel):
class ApproachDTO(DTOBase):
id: Optional[int] = None
exercise_id: Optional[int] = None
id: int | None = None
exercise_id: int | None = None
weight: float
reps: int
class ExerciseDTO(DTOBase):
id: Optional[int] = None
training_id: Optional[int] = None
id: int | None = None
training_id: int | None = None
name: str
splitted_weight: bool = False
approaches: List[ApproachDTO] = Field(default_factory=list)
approaches: list[ApproachDTO] = Field(default_factory=list)
class TrainingDTO(DTOBase):
id: Optional[int] = None
id: int | None = None
date: date
trainer: Optional[str] = None
exercises: List[ExerciseDTO] = Field(default_factory=list)
trainer: str | None = None
exercises: list[ExerciseDTO] = Field(default_factory=list)
class ExerciseNameDTO(DTOBase):
@ -41,6 +40,6 @@ class ExerciseSessionDTO(DTOBase):
"""One occurrence of an exercise in the history of that exercise."""
date: date
trainer: Optional[str] = None
trainer: str | None = None
splitted_weight: bool = False
approaches: List[ApproachDTO] = Field(default_factory=list)
approaches: list[ApproachDTO] = Field(default_factory=list)

View file

@ -2,11 +2,8 @@ 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 app.core.database.connection import create_engine, create_session_factory
@ -33,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

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

@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import settings
from app.core.database.models import Base
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.
@ -27,7 +27,7 @@ if config.config_file_name is not None:
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
target_metadata = metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:

View file

@ -3,8 +3,6 @@ uvicorn[standard]==0.35.0
pydantic==2.11.7
pydantic-settings==2.10.1
python-dotenv==1.1.1
jinja2==3.1.6
python-multipart==0.0.20
sqlalchemy[asyncio]==2.1.6
asyncpg==0.32.0
alembic==1.20.1

View file

@ -12,7 +12,7 @@ from alembic import command
from sqlalchemy import func, select
from sqlalchemy.exc import DBAPIError
from app.core.database.models import ApproachModel, ExerciseModel
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
@ -114,8 +114,8 @@ async def test_delete_cascades_to_exercises_and_approaches(session):
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(ExerciseModel.id))) == 2
assert await session.scalar(select(func.count(ApproachModel.id))) == 3
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):

View file

@ -7,9 +7,9 @@ services:
develop:
watch:
- action: sync
path: ./app
path: ./backend/app
target: /app/app
- action: rebuild
path: ./requirements.txt
path: ./backend/requirements.txt
- action: rebuild
path: ./requirements-dev.txt
path: ./backend/requirements-dev.txt

View file

@ -1,16 +1,16 @@
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
- ./backend/app:/app/app:ro
- ./backend/data:/app/data:ro
- ./backend/tests:/app/tests:ro
# Writable: `alembic revision --autogenerate` creates files in migrations/versions
- ./migrations:/app/migrations
- ./backend/migrations:/app/migrations
env_file:
- .env
environment:
@ -21,6 +21,21 @@ services:
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

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