Refactor: split backend and frontend
This commit is contained in:
parent
fc192a4a0b
commit
e591956858
114 changed files with 933 additions and 891 deletions
43
CLAUDE.md
43
CLAUDE.md
|
|
@ -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
|
- **Obsidian Notes**: Markdown tables with `# DD.MM.YYYY (trainer-session)` headers
|
||||||
- **Apple Notes**: Bold 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
|
## 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
|
### Docker Development
|
||||||
```bash
|
```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
|
docker compose up --build
|
||||||
|
|
||||||
# Apply migrations manually (Alembic; the app itself does NOT migrate on startup)
|
# Apply migrations manually (Alembic; the app itself does NOT migrate on startup)
|
||||||
docker compose exec fastapi-app alembic upgrade head
|
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)
|
# (-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"
|
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
|
### 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
|
- `ObsidianNotesParser` - handles Obsidian markdown format
|
||||||
- `AppleNotesParser` - handles Apple notes format
|
- `AppleNotesParser` - handles Apple notes format
|
||||||
- Each parser includes exercise name mapping for normalization
|
- 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
|
- `Training` - represents a workout session
|
||||||
- `Exercise` - individual exercise with multiple approaches
|
- `Exercise` - individual exercise with multiple approaches
|
||||||
- `Approach` - single set with weight and reps
|
- `Approach` - single set with weight and reps
|
||||||
|
|
||||||
**API Structure**:
|
**API Structure** (backend, JSON only):
|
||||||
- `/api/v1/` - REST API endpoints
|
- `/api/v1/trainings/`, `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/parser/parse/`, `/api/v1/health/`
|
||||||
- `/app/` - Web interface endpoints
|
|
||||||
- Static files served from `app/static/`
|
**Frontend** (`frontend/`):
|
||||||
- Templates in `app/templates/`
|
- `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
|
### 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
|
### Key Files
|
||||||
|
|
||||||
- `app/main.py` - FastAPI application setup with lifespan management
|
- `backend/app/main.py` - FastAPI application setup with lifespan management
|
||||||
- `app/config.py` - Application configuration and database settings
|
- `backend/app/config.py` - Application configuration and database settings
|
||||||
- `run.py` - Standalone uvicorn runner (unused: Docker starts uvicorn from `compose.yaml`)
|
- `backend/run.py` - Standalone uvicorn runner (unused: Docker starts uvicorn from `compose.yaml`)
|
||||||
- `migrations/` - Alembic environment and versions
|
- `backend/migrations/` - Alembic environment and versions
|
||||||
- `compose.yaml` - Docker Compose setup with PostgreSQL
|
- `compose.yaml` - Docker Compose setup: backend, frontend, PostgreSQL
|
||||||
|
|
||||||
## Environment Setup
|
## Environment Setup
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and configure:
|
Copy `.env.example` to `.env` (repo root) and configure:
|
||||||
- PostgreSQL connection settings
|
- PostgreSQL connection settings
|
||||||
- Debug mode
|
- Debug mode
|
||||||
- PYTHONPATH (for Docker)
|
- PYTHONPATH (for Docker)
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -2,6 +2,17 @@
|
||||||
|
|
||||||
Приложение для парсинга текстовых файлов тренировок
|
Приложение для парсинга текстовых файлов тренировок
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
- `backend/` - FastAPI, JSON API (`/api/v1/...`), PostgreSQL
|
||||||
|
- `frontend/` - статический сайт на HTML + CSS + JS, раздаётся nginx, запросы `/api/` проксирует на бэкенд
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
docker compose exec fastapi-app python -m app.cli import # загрузить заметки в базу
|
||||||
|
```
|
||||||
|
Сайт: http://localhost:8080, API и документация: http://localhost:8000/docs
|
||||||
|
|
||||||
## Поддерживаемые записи
|
## Поддерживаемые записи
|
||||||
Работает с двумя категорями записей:
|
Работает с двумя категорями записей:
|
||||||
- Obsidian notes
|
- Obsidian notes
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
)
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
@ -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",
|
|
||||||
)
|
|
||||||
|
|
@ -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",
|
|
||||||
)
|
|
||||||
|
|
@ -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
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
@ -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)
|
|
||||||
5
app/static/css/dataTables.bootstrap5.min.css
vendored
5
app/static/css/dataTables.bootstrap5.min.css
vendored
File diff suppressed because one or more lines are too long
7
app/static/js/bootstrap.bundle.min.js
vendored
7
app/static/js/bootstrap.bundle.min.js
vendored
File diff suppressed because one or more lines are too long
4
app/static/js/dataTables.bootstrap5.min.js
vendored
4
app/static/js/dataTables.bootstrap5.min.js
vendored
|
|
@ -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});
|
|
||||||
4
app/static/js/dataTables.min.js
vendored
4
app/static/js/dataTables.min.js
vendored
File diff suppressed because one or more lines are too long
2
app/static/js/jquery-3.7.1.min.js
vendored
2
app/static/js/jquery-3.7.1.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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 %}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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 %}
|
|
||||||
|
|
@ -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 %}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
{% extends "base.html" %} {% block content %}
|
|
||||||
<h1>Parse training data</h1>
|
|
||||||
<form method="post" action="/app/obsidian/parse">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="training_data">Place your training here</label>
|
|
||||||
<textarea
|
|
||||||
class="form-control"
|
|
||||||
id="training_data"
|
|
||||||
name="training_data"
|
|
||||||
rows="10"
|
|
||||||
required
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary">Parse Training</button>
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-secondary" onclick="fetchData()">Fetch Data</button>
|
|
||||||
<div id="result"></div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% if result %}
|
|
||||||
<div class="mt-3">
|
|
||||||
<h3>Result:</h3>
|
|
||||||
<pre>{{ result }}</pre>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/v1/trainings/obsidian/",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
document.getElementById("result").innerHTML = JSON.stringify(
|
|
||||||
data,
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
document.getElementById("result").innerHTML = "Error: " + error.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,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 %}
|
|
||||||
71
backend/app/api/v1/exercises.py
Normal file
71
backend/app/api/v1/exercises.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from app.api.deps import get_training_repository
|
||||||
|
from app.core.database.repository import TrainingRepository
|
||||||
|
from app.core.dto.training import ExerciseNameDTO
|
||||||
|
from app.core.metrics import Record, summarize
|
||||||
|
from app.schemas.exercises import (
|
||||||
|
ExerciseProgressResponse,
|
||||||
|
RecordResponse,
|
||||||
|
SessionMetricsResponse,
|
||||||
|
SessionRowResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def record_response(record: Optional[Record]) -> Optional[RecordResponse]:
|
||||||
|
return RecordResponse(value=record.value, date=record.date) if record else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[ExerciseNameDTO])
|
||||||
|
async def list_exercise_names(
|
||||||
|
repository: TrainingRepository = Depends(get_training_repository),
|
||||||
|
):
|
||||||
|
"""Distinct exercise names with the number of times each was done."""
|
||||||
|
return await repository.list_exercise_names()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/progress/", response_model=ExerciseProgressResponse)
|
||||||
|
async def exercise_progress(
|
||||||
|
name: str,
|
||||||
|
repository: TrainingRepository = Depends(get_training_repository),
|
||||||
|
):
|
||||||
|
"""The whole history of the exercise with progress metrics.
|
||||||
|
|
||||||
|
The name is matched exactly, but case-insensitively: what was typed may differ in case.
|
||||||
|
"""
|
||||||
|
names_by_casefold = {
|
||||||
|
item.name.casefold(): item.name
|
||||||
|
for item in await repository.list_exercise_names()
|
||||||
|
}
|
||||||
|
exact_name = names_by_casefold.get(name.strip().casefold())
|
||||||
|
if exact_name is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Exercise not found")
|
||||||
|
|
||||||
|
progress = summarize(await repository.exercise_history(exact_name))
|
||||||
|
return ExerciseProgressResponse(
|
||||||
|
name=exact_name,
|
||||||
|
weighted=progress.weighted,
|
||||||
|
max_weight=progress.max_weight,
|
||||||
|
best_primary=record_response(progress.best_primary),
|
||||||
|
best_secondary=record_response(progress.best_secondary),
|
||||||
|
first_primary=progress.first_primary,
|
||||||
|
last_primary=progress.last_primary,
|
||||||
|
primary_change=progress.primary_change,
|
||||||
|
primary_change_percent=progress.primary_change_percent,
|
||||||
|
rows=[
|
||||||
|
SessionRowResponse(
|
||||||
|
date=row.session.date,
|
||||||
|
trainer=row.session.trainer,
|
||||||
|
splitted_weight=row.session.splitted_weight,
|
||||||
|
approaches=row.session.approaches,
|
||||||
|
metrics=SessionMetricsResponse.model_validate(
|
||||||
|
row.metrics, from_attributes=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for row in progress.rows
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from typing import List
|
|
||||||
from app.schemas.parser import ParseRequest, ParseResponse
|
from app.schemas.parser import ParseRequest, ParseResponse
|
||||||
from app.core.services.parser_service import ParserService
|
from app.core.services.parser_service import ParserService
|
||||||
|
|
||||||
|
|
@ -7,7 +6,7 @@ router = APIRouter()
|
||||||
parser_service = ParserService()
|
parser_service = ParserService()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/parse/", response_model=List[ParseResponse])
|
@router.post("/parse/", response_model=list[ParseResponse])
|
||||||
async def parse_training_data(request: ParseRequest):
|
async def parse_training_data(request: ParseRequest):
|
||||||
try:
|
try:
|
||||||
result = await parser_service.parse_data(request)
|
result = await parser_service.parse_data(request)
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
from fastapi import APIRouter
|
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 = APIRouter()
|
||||||
|
|
||||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
api_router.include_router(parser.router, prefix="/parser", tags=["parser"])
|
api_router.include_router(parser.router, prefix="/parser", tags=["parser"])
|
||||||
api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"])
|
api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"])
|
||||||
|
api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"])
|
||||||
169
backend/app/core/database/repository.py
Normal file
169
backend/app/core/database/repository.py
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Dict, List, Optional, Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, insert, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database.tables import approaches, exercises, trainings
|
||||||
|
from app.core.dto.training import (
|
||||||
|
ApproachDTO,
|
||||||
|
ExerciseDTO,
|
||||||
|
ExerciseNameDTO,
|
||||||
|
ExerciseSessionDTO,
|
||||||
|
TrainingDTO,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrainingRepository:
|
||||||
|
"""Storage for trainings together with their exercises and approaches (SQLAlchemy Core)."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def save(self, training: TrainingDTO) -> int:
|
||||||
|
return (await self.save_many([training]))[0]
|
||||||
|
|
||||||
|
async def save_many(self, items: Sequence[TrainingDTO]) -> List[int]:
|
||||||
|
"""Save all trainings in a single transaction, returns their ids."""
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
training_ids = await self._insert_returning_ids(
|
||||||
|
trainings,
|
||||||
|
[{"date": item.date, "trainer": item.trainer} for item in items],
|
||||||
|
)
|
||||||
|
|
||||||
|
exercise_rows = []
|
||||||
|
exercise_dtos = []
|
||||||
|
for training_id, item in zip(training_ids, items):
|
||||||
|
for exercise in item.exercises:
|
||||||
|
exercise_rows.append(
|
||||||
|
{
|
||||||
|
"training_id": training_id,
|
||||||
|
"name": exercise.name,
|
||||||
|
"splitted_weight": exercise.splitted_weight,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exercise_dtos.append(exercise)
|
||||||
|
if exercise_rows:
|
||||||
|
exercise_ids = await self._insert_returning_ids(exercises, exercise_rows)
|
||||||
|
approach_rows = [
|
||||||
|
{"exercise_id": exercise_id, "weight": a.weight, "reps": a.reps}
|
||||||
|
for exercise_id, exercise in zip(exercise_ids, exercise_dtos)
|
||||||
|
for a in exercise.approaches
|
||||||
|
]
|
||||||
|
if approach_rows:
|
||||||
|
await self._session.execute(insert(approaches), approach_rows)
|
||||||
|
|
||||||
|
await self._session.commit()
|
||||||
|
return training_ids
|
||||||
|
|
||||||
|
async def list(self) -> List[TrainingDTO]:
|
||||||
|
training_rows = (
|
||||||
|
await self._session.execute(
|
||||||
|
select(trainings).order_by(trainings.c.date.desc(), trainings.c.id.desc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
approaches_by_exercise = await self._approaches_by_exercise()
|
||||||
|
exercises_by_training: Dict[int, List[ExerciseDTO]] = defaultdict(list)
|
||||||
|
exercise_rows = await self._session.execute(
|
||||||
|
select(exercises).order_by(exercises.c.id)
|
||||||
|
)
|
||||||
|
for row in exercise_rows:
|
||||||
|
exercises_by_training[row.training_id].append(
|
||||||
|
ExerciseDTO(
|
||||||
|
id=row.id,
|
||||||
|
training_id=row.training_id,
|
||||||
|
name=row.name,
|
||||||
|
splitted_weight=row.splitted_weight,
|
||||||
|
approaches=approaches_by_exercise[row.id],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
TrainingDTO(
|
||||||
|
id=row.id,
|
||||||
|
date=row.date,
|
||||||
|
trainer=row.trainer,
|
||||||
|
exercises=exercises_by_training[row.id],
|
||||||
|
)
|
||||||
|
for row in training_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def list_exercise_names(self) -> List[ExerciseNameDTO]:
|
||||||
|
"""Distinct exercise names with the number of times each was done."""
|
||||||
|
query = select(
|
||||||
|
exercises.c.name, func.count(exercises.c.id).label("times")
|
||||||
|
).group_by(exercises.c.name)
|
||||||
|
result = await self._session.execute(query)
|
||||||
|
names = [ExerciseNameDTO(name=row.name, count=row.times) for row in result]
|
||||||
|
# Sorted here, not in SQL: the order must not depend on DB collation
|
||||||
|
return sorted(names, key=lambda item: item.name.casefold())
|
||||||
|
|
||||||
|
async def exercise_history(self, name: str) -> List[ExerciseSessionDTO]:
|
||||||
|
"""Every occurrence of the exercise, newest first."""
|
||||||
|
query = (
|
||||||
|
select(
|
||||||
|
exercises.c.id,
|
||||||
|
exercises.c.splitted_weight,
|
||||||
|
trainings.c.date,
|
||||||
|
trainings.c.trainer,
|
||||||
|
)
|
||||||
|
.join(trainings, trainings.c.id == exercises.c.training_id)
|
||||||
|
.where(exercises.c.name == name)
|
||||||
|
.order_by(trainings.c.date.desc(), exercises.c.id.desc())
|
||||||
|
)
|
||||||
|
rows = (await self._session.execute(query)).all()
|
||||||
|
approaches_by_exercise = await self._approaches_by_exercise(
|
||||||
|
[row.id for row in rows]
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
ExerciseSessionDTO(
|
||||||
|
date=row.date,
|
||||||
|
trainer=row.trainer,
|
||||||
|
splitted_weight=row.splitted_weight,
|
||||||
|
approaches=approaches_by_exercise[row.id],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def count(self) -> int:
|
||||||
|
return await self._session.scalar(select(func.count(trainings.c.id))) or 0
|
||||||
|
|
||||||
|
async def delete(self, list_of_ids: List[int]) -> int:
|
||||||
|
"""Delete trainings by ids (exercises and approaches go with them)."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
delete(trainings).where(trainings.c.id.in_(list_of_ids))
|
||||||
|
)
|
||||||
|
await self._session.commit()
|
||||||
|
return result.rowcount # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
async def delete_all(self) -> None:
|
||||||
|
await self._session.execute(
|
||||||
|
text("TRUNCATE trainings RESTART IDENTITY CASCADE")
|
||||||
|
)
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
|
async def _insert_returning_ids(self, table, rows: List[dict]) -> List[int]:
|
||||||
|
"""Bulk insert, ids come back in the order of `rows`."""
|
||||||
|
result = await self._session.execute(
|
||||||
|
insert(table).returning(table.c.id, sort_by_parameter_order=True), rows
|
||||||
|
)
|
||||||
|
return list(result.scalars())
|
||||||
|
|
||||||
|
async def _approaches_by_exercise(
|
||||||
|
self, exercise_ids: Optional[Sequence[int]] = None
|
||||||
|
) -> Dict[int, List[ApproachDTO]]:
|
||||||
|
"""Approaches grouped by exercise id (all of them when no ids are given)."""
|
||||||
|
query = select(approaches).order_by(approaches.c.id)
|
||||||
|
if exercise_ids is not None:
|
||||||
|
query = query.where(approaches.c.exercise_id.in_(exercise_ids))
|
||||||
|
grouped: Dict[int, List[ApproachDTO]] = defaultdict(list)
|
||||||
|
for row in await self._session.execute(query):
|
||||||
|
grouped[row.exercise_id].append(
|
||||||
|
ApproachDTO(
|
||||||
|
id=row.id,
|
||||||
|
exercise_id=row.exercise_id,
|
||||||
|
weight=row.weight,
|
||||||
|
reps=row.reps,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return grouped
|
||||||
54
backend/app/core/database/tables.py
Normal file
54
backend/app/core/database/tables.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
MetaData,
|
||||||
|
String,
|
||||||
|
Table,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = MetaData()
|
||||||
|
|
||||||
|
trainings = Table(
|
||||||
|
"trainings",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("date", Date, nullable=False, index=True),
|
||||||
|
Column("trainer", String(100)),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
exercises = Table(
|
||||||
|
"exercises",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column(
|
||||||
|
"training_id",
|
||||||
|
ForeignKey("trainings.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
Column("name", String(255), nullable=False, index=True),
|
||||||
|
Column("splitted_weight", Boolean, nullable=False, server_default="false"),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
approaches = Table(
|
||||||
|
"approaches",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column(
|
||||||
|
"exercise_id",
|
||||||
|
ForeignKey("exercises.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
Column("weight", Float, nullable=False),
|
||||||
|
Column("reps", Integer, nullable=False),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
from typing import List, Optional
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
|
@ -9,25 +8,25 @@ class DTOBase(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ApproachDTO(DTOBase):
|
class ApproachDTO(DTOBase):
|
||||||
id: Optional[int] = None
|
id: int | None = None
|
||||||
exercise_id: Optional[int] = None
|
exercise_id: int | None = None
|
||||||
weight: float
|
weight: float
|
||||||
reps: int
|
reps: int
|
||||||
|
|
||||||
|
|
||||||
class ExerciseDTO(DTOBase):
|
class ExerciseDTO(DTOBase):
|
||||||
id: Optional[int] = None
|
id: int | None = None
|
||||||
training_id: Optional[int] = None
|
training_id: int | None = None
|
||||||
name: str
|
name: str
|
||||||
splitted_weight: bool = False
|
splitted_weight: bool = False
|
||||||
approaches: List[ApproachDTO] = Field(default_factory=list)
|
approaches: list[ApproachDTO] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class TrainingDTO(DTOBase):
|
class TrainingDTO(DTOBase):
|
||||||
id: Optional[int] = None
|
id: int | None = None
|
||||||
date: date
|
date: date
|
||||||
trainer: Optional[str] = None
|
trainer: str | None = None
|
||||||
exercises: List[ExerciseDTO] = Field(default_factory=list)
|
exercises: list[ExerciseDTO] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ExerciseNameDTO(DTOBase):
|
class ExerciseNameDTO(DTOBase):
|
||||||
|
|
@ -41,6 +40,6 @@ class ExerciseSessionDTO(DTOBase):
|
||||||
"""One occurrence of an exercise in the history of that exercise."""
|
"""One occurrence of an exercise in the history of that exercise."""
|
||||||
|
|
||||||
date: date
|
date: date
|
||||||
trainer: Optional[str] = None
|
trainer: str | None = None
|
||||||
splitted_weight: bool = False
|
splitted_weight: bool = False
|
||||||
approaches: List[ApproachDTO] = Field(default_factory=list)
|
approaches: list[ApproachDTO] = Field(default_factory=list)
|
||||||
|
|
@ -2,11 +2,8 @@ import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
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.router import api_router
|
||||||
from app.api.v1.web import web_router
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.database.connection import create_engine, create_session_factory
|
from app.core.database.connection import create_engine, create_session_factory
|
||||||
|
|
||||||
|
|
@ -33,13 +30,8 @@ app = FastAPI(
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
app.include_router(web_router, prefix="/app")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
40
backend/app/schemas/exercises.py
Normal file
40
backend/app/schemas/exercises.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
from datetime import date
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.dto.training import ApproachDTO
|
||||||
|
|
||||||
|
|
||||||
|
class SessionMetricsResponse(BaseModel):
|
||||||
|
max_weight: float
|
||||||
|
e1rm: Optional[float]
|
||||||
|
volume: float
|
||||||
|
total_reps: int
|
||||||
|
best_reps: int
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRowResponse(BaseModel):
|
||||||
|
date: date
|
||||||
|
trainer: Optional[str]
|
||||||
|
splitted_weight: bool
|
||||||
|
approaches: List[ApproachDTO]
|
||||||
|
metrics: SessionMetricsResponse
|
||||||
|
|
||||||
|
|
||||||
|
class RecordResponse(BaseModel):
|
||||||
|
value: float
|
||||||
|
date: date
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseProgressResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
weighted: bool
|
||||||
|
max_weight: float
|
||||||
|
best_primary: Optional[RecordResponse]
|
||||||
|
best_secondary: Optional[RecordResponse]
|
||||||
|
first_primary: Optional[float]
|
||||||
|
last_primary: Optional[float]
|
||||||
|
primary_change: Optional[float]
|
||||||
|
primary_change_percent: Optional[float]
|
||||||
|
rows: List[SessionRowResponse] # newest first
|
||||||
|
|
@ -28,4 +28,5 @@ class ExerciseResponse(BaseModel):
|
||||||
|
|
||||||
class ParseResponse(BaseModel):
|
class ParseResponse(BaseModel):
|
||||||
date: date
|
date: date
|
||||||
|
trainer: Optional[str] = None
|
||||||
exercises: Optional[List[ExerciseResponse]]
|
exercises: Optional[List[ExerciseResponse]]
|
||||||
|
|
@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
|
||||||
from app.config import settings
|
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
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# 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
|
# add your model's MetaData object here
|
||||||
# for 'autogenerate' support
|
# for 'autogenerate' support
|
||||||
target_metadata = Base.metadata
|
target_metadata = metadata
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
# other values from the config, defined by the needs of env.py,
|
||||||
# can be acquired:
|
# can be acquired:
|
||||||
|
|
@ -3,8 +3,6 @@ uvicorn[standard]==0.35.0
|
||||||
pydantic==2.11.7
|
pydantic==2.11.7
|
||||||
pydantic-settings==2.10.1
|
pydantic-settings==2.10.1
|
||||||
python-dotenv==1.1.1
|
python-dotenv==1.1.1
|
||||||
jinja2==3.1.6
|
|
||||||
python-multipart==0.0.20
|
|
||||||
sqlalchemy[asyncio]==2.1.6
|
sqlalchemy[asyncio]==2.1.6
|
||||||
asyncpg==0.32.0
|
asyncpg==0.32.0
|
||||||
alembic==1.20.1
|
alembic==1.20.1
|
||||||
|
|
@ -12,7 +12,7 @@ from alembic import command
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.exc import DBAPIError
|
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.database.repository import TrainingRepository
|
||||||
from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
|
from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
|
||||||
from app.core.parsers.apple import AppleNotesParser
|
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 await repository.delete([delete_id]) == 1
|
||||||
|
|
||||||
assert [t.id for t in await repository.list()] == [keep_id]
|
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(exercises.c.id))) == 2
|
||||||
assert await session.scalar(select(func.count(ApproachModel.id))) == 3
|
assert await session.scalar(select(func.count(approaches.c.id))) == 3
|
||||||
|
|
||||||
|
|
||||||
async def test_save_many_is_atomic(session):
|
async def test_save_many_is_atomic(session):
|
||||||
|
|
@ -7,9 +7,9 @@ services:
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- action: sync
|
- action: sync
|
||||||
path: ./app
|
path: ./backend/app
|
||||||
target: /app/app
|
target: /app/app
|
||||||
- action: rebuild
|
- action: rebuild
|
||||||
path: ./requirements.txt
|
path: ./backend/requirements.txt
|
||||||
- action: rebuild
|
- action: rebuild
|
||||||
path: ./requirements-dev.txt
|
path: ./backend/requirements-dev.txt
|
||||||
|
|
|
||||||
25
compose.yaml
25
compose.yaml
|
|
@ -1,16 +1,16 @@
|
||||||
services:
|
services:
|
||||||
fastapi-app:
|
fastapi-app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app/app:ro
|
- ./backend/app:/app/app:ro
|
||||||
- ./data:/app/data:ro
|
- ./backend/data:/app/data:ro
|
||||||
- ./tests:/app/tests:ro
|
- ./backend/tests:/app/tests:ro
|
||||||
# Writable: `alembic revision --autogenerate` creates files in migrations/versions
|
# Writable: `alembic revision --autogenerate` creates files in migrations/versions
|
||||||
- ./migrations:/app/migrations
|
- ./backend/migrations:/app/migrations
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -21,6 +21,21 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
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:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
env_file:
|
env_file:
|
||||||
|
|
|
||||||
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
7
frontend/Dockerfile
Normal file
7
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY index.html exercises.html parse.html /usr/share/nginx/html/
|
||||||
|
COPY css /usr/share/nginx/html/css
|
||||||
|
COPY js /usr/share/nginx/html/js
|
||||||
|
COPY vendor /usr/share/nginx/html/vendor
|
||||||
43
frontend/exercises.html
Normal file
43
frontend/exercises.html
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Упражнения - Fitness Parser</title>
|
||||||
|
|
||||||
|
<link href="vendor/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="vendor/dataTables.bootstrap5.min.css" rel="stylesheet">
|
||||||
|
<link href="css/main.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="d-flex flex-column flex-md-row">
|
||||||
|
<!-- Sidebar navigation: on narrow screens it turns into a strip on top -->
|
||||||
|
<aside class="sidebar bg-dark p-3">
|
||||||
|
<a class="d-block text-white text-decoration-none fs-5 fw-bold mb-md-4 me-3" href="index.html">Fitness parser</a>
|
||||||
|
<ul class="nav nav-pills flex-row flex-md-column gap-1 mt-2 mt-md-0">
|
||||||
|
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-white active" href="exercises.html">Упражнения</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="flex-grow-1 p-4 content" id="content">
|
||||||
|
<h1 class="h3 mb-3">История упражнения</h1>
|
||||||
|
<form method="get" action="exercises.html" class="mb-4">
|
||||||
|
<label for="exercise-input" class="form-label">Упражнение</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="search" id="exercise-input" name="name" class="form-control" list="exercise-names"
|
||||||
|
placeholder="Начните вводить название…" autocomplete="off">
|
||||||
|
<button type="submit" class="btn btn-primary">Показать</button>
|
||||||
|
</div>
|
||||||
|
<datalist id="exercise-names"></datalist>
|
||||||
|
</form>
|
||||||
|
<div id="exercise"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="vendor/chart.umd.js"></script>
|
||||||
|
<script src="vendor/dataTables.min.js"></script>
|
||||||
|
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
||||||
|
<script type="module" src="js/exercises.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
frontend/index.html
Normal file
34
frontend/index.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Тренировки - Fitness Parser</title>
|
||||||
|
|
||||||
|
<link href="vendor/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="vendor/dataTables.bootstrap5.min.css" rel="stylesheet">
|
||||||
|
<link href="css/main.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="d-flex flex-column flex-md-row">
|
||||||
|
<!-- Sidebar navigation: on narrow screens it turns into a strip on top -->
|
||||||
|
<aside class="sidebar bg-dark p-3">
|
||||||
|
<a class="d-block text-white text-decoration-none fs-5 fw-bold mb-md-4 me-3" href="index.html">Fitness parser</a>
|
||||||
|
<ul class="nav nav-pills flex-row flex-md-column gap-1 mt-2 mt-md-0">
|
||||||
|
<li class="nav-item"><a class="nav-link text-white active" href="index.html">Тренировки</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="flex-grow-1 p-4 content" id="content">
|
||||||
|
<h1 class="h3 mb-3">Все упражнения</h1>
|
||||||
|
<div id="trainings"><div class="text-muted">Загрузка…</div></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="vendor/dataTables.min.js"></script>
|
||||||
|
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
||||||
|
<script type="module" src="js/trainings.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
62
frontend/js/common.js
Normal file
62
frontend/js/common.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// Shared helpers: API access, DOM building, formatting
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
export async function api(path, options) {
|
||||||
|
const response = await fetch(API + path, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(`HTTP ${response.status}`);
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** h('div', {class: 'x'}, 'text', childNode) -> element; strings become text nodes */
|
||||||
|
export function h(tag, attrs = {}, ...children) {
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
for (const [name, value] of Object.entries(attrs)) {
|
||||||
|
if (value !== null && value !== undefined && value !== false) {
|
||||||
|
element.setAttribute(name, value === true ? '' : value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.append(...children.filter((child) => child !== null && child !== undefined));
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alert(kind, ...children) {
|
||||||
|
return h('div', { class: `alert alert-${kind}` }, ...children);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2026-10-21" -> "21.10.2026" (no Date: a timezone must not shift the day) */
|
||||||
|
export function formatDate(iso) {
|
||||||
|
const [year, month, day] = iso.split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rounded to one decimal without trailing zeros: 22.5 -> "22.5", 20 -> "20" */
|
||||||
|
export function num(value) {
|
||||||
|
return String(Math.round(value * 10) / 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same with an explicit sign: "+12", "-3.5", "+0" */
|
||||||
|
export function signed(value) {
|
||||||
|
const rounded = Math.round(value * 10) / 10;
|
||||||
|
return (rounded > 0 ? '+' : '') + rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approaches as "weight × reps" chips */
|
||||||
|
export function approachChips(approaches) {
|
||||||
|
return approaches.map((approach) =>
|
||||||
|
h('span', { class: 'approach' },
|
||||||
|
`${approach.weight ? num(approach.weight) + ' кг' : 'без веса'} × ${approach.reps}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function perArmBadge() {
|
||||||
|
return h('span', { class: 'badge text-bg-light border', title: 'Вес указан на каждую руку (гантель)' }, 'на руку');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tip(text, hint) {
|
||||||
|
return h('span', { class: 'has-tip', title: hint }, text);
|
||||||
|
}
|
||||||
187
frontend/js/exercises.js
Normal file
187
frontend/js/exercises.js
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, tip } from './common.js';
|
||||||
|
import { createTable } from './table.js';
|
||||||
|
|
||||||
|
const HINTS = {
|
||||||
|
recordE1rm: 'e1RM — расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Рекорд — наибольшее значение за всю историю.',
|
||||||
|
lastE1rm: 'e1RM лучшего подхода последней тренировки. Ниже — изменение относительно первой тренировки с этим упражнением.',
|
||||||
|
recordVolume: 'Тоннаж — суммарная работа за тренировку: сумма вес × повторы по всем подходам с весом. Рекорд — тренировка с наибольшим тоннажем.',
|
||||||
|
maxWeight: 'Самый тяжёлый вес в одном подходе за всю историю, без учёта повторов.',
|
||||||
|
bestReps: 'Наибольшее число повторов в одном подходе за всю историю.',
|
||||||
|
lastBestReps: 'Наибольшее число повторов в одном подходе последней тренировки. Ниже — изменение относительно первой тренировки.',
|
||||||
|
recordTotalReps: 'Наибольшая сумма повторов по всем подходам за одну тренировку.',
|
||||||
|
e1rm: 'Расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Подходы без веса не учитываются.',
|
||||||
|
volume: 'Тоннаж: сумма вес × повторы по подходам с весом за тренировку.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = document.getElementById('exercise-input');
|
||||||
|
const container = document.getElementById('exercise');
|
||||||
|
const query = (new URLSearchParams(location.search).get('name') || '').trim();
|
||||||
|
|
||||||
|
let names = [];
|
||||||
|
try {
|
||||||
|
names = await api('/exercises/');
|
||||||
|
} catch (error) {
|
||||||
|
container.replaceChildren(alert('danger', `Не удалось загрузить упражнения: ${error.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('exercise-names').replaceChildren(...names.map((item) => h('option', { value: item.name })));
|
||||||
|
input.value = query;
|
||||||
|
if (!query) {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the history right after a suggestion is picked from the list. Typing, pasting and
|
||||||
|
// deleting have their own inputType, so a half-typed name that equals a shorter exercise
|
||||||
|
// name doesn't submit early; Enter/button always work.
|
||||||
|
const knownNames = new Set(names.map((item) => item.name));
|
||||||
|
input.addEventListener('input', (event) => {
|
||||||
|
const picked = !event.inputType || event.inputType === 'insertReplacementText';
|
||||||
|
if (picked && knownNames.has(input.value)) {
|
||||||
|
input.form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
await showProgress();
|
||||||
|
} else if (!names.length && !container.children.length) {
|
||||||
|
container.replaceChildren(alert('info',
|
||||||
|
'В базе пока нет тренировок. Загрузи их из заметок командой:',
|
||||||
|
h('br'), h('code', {}, 'docker compose exec fastapi-app python -m app.cli import')));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showProgress() {
|
||||||
|
let progress;
|
||||||
|
try {
|
||||||
|
progress = await api(`/exercises/progress/?name=${encodeURIComponent(query)}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 404) {
|
||||||
|
container.replaceChildren(alert('danger', `Не удалось загрузить историю: ${error.message}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Nothing matched exactly: offer the names containing the typed text
|
||||||
|
const suggestions = names.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
container.replaceChildren(
|
||||||
|
alert('warning', `Упражнение «${query}» не найдено. ${suggestions.length ? 'Возможно, вы искали:' : ''}`),
|
||||||
|
...(suggestions.length ? [h('div', { class: 'list-group' }, ...suggestions.map((item) =>
|
||||||
|
h('a', { class: 'list-group-item list-group-item-action d-flex justify-content-between align-items-center', href: `exercises.html?name=${encodeURIComponent(item.name)}` },
|
||||||
|
item.name, h('span', { class: 'badge text-bg-secondary rounded-pill' }, `${item.count}`))))] : []));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.value = progress.name;
|
||||||
|
container.replaceChildren(...progressView(progress));
|
||||||
|
if (progress.rows.length) {
|
||||||
|
drawChart(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressView(progress) {
|
||||||
|
const { rows, weighted } = progress;
|
||||||
|
const first = rows[rows.length - 1];
|
||||||
|
const last = rows[0];
|
||||||
|
|
||||||
|
const change = (unit) => h('span', {},
|
||||||
|
h('span', { class: progress.primary_change > 0 ? 'text-success' : progress.primary_change < 0 ? 'text-danger' : 'text-muted' },
|
||||||
|
`${signed(progress.primary_change)} ${unit}`,
|
||||||
|
progress.primary_change_percent !== null ? ` (${progress.primary_change_percent >= 0 ? '+' : ''}${progress.primary_change_percent.toFixed(1)}%)` : ''),
|
||||||
|
' к первой тренировке');
|
||||||
|
|
||||||
|
const card = (title, value, note, hint) => h('div', { class: 'col' },
|
||||||
|
h('div', { class: 'card h-100 mb-0' }, h('div', { class: 'card-body' },
|
||||||
|
h('div', { class: 'text-muted small' }, tip(title, hint)),
|
||||||
|
h('div', { class: 'fs-4 fw-semibold' }, value),
|
||||||
|
h('div', { class: 'text-muted small' }, note))));
|
||||||
|
|
||||||
|
const cards = weighted ? [
|
||||||
|
card('Рекорд e1RM', `${num(progress.best_primary.value)} кг`, formatDate(progress.best_primary.date), HINTS.recordE1rm),
|
||||||
|
card('Последний e1RM', `${num(progress.last_primary)} кг`, rows.length > 1 ? change('кг') : '', HINTS.lastE1rm),
|
||||||
|
card('Рекорд тоннажа', `${num(progress.best_secondary.value)} кг`, formatDate(progress.best_secondary.date), HINTS.recordVolume),
|
||||||
|
card('Максимальный вес', `${num(progress.max_weight)} кг`, '', HINTS.maxWeight),
|
||||||
|
] : [
|
||||||
|
card('Лучший подход', `${num(progress.best_primary.value)} повт.`, formatDate(progress.best_primary.date), HINTS.bestReps),
|
||||||
|
card('Лучший подход в последний раз', `${num(progress.last_primary)} повт.`, rows.length > 1 ? change('повт.') : '', HINTS.lastBestReps),
|
||||||
|
card('Рекорд суммы повторов', num(progress.best_secondary.value), formatDate(progress.best_secondary.date), HINTS.recordTotalReps),
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'Дата', className: 'text-nowrap date', sort: (row) => row.date, cell: (row) => formatDate(row.date) },
|
||||||
|
{ title: 'Тренер', className: 'text-nowrap', sort: (row) => row.trainer || '', cell: (row) => row.trainer || '' },
|
||||||
|
{ title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...approachChips(row.approaches), row.splitted_weight ? perArmBadge() : null) },
|
||||||
|
...(weighted ? [
|
||||||
|
{ title: 'Макс. вес', className: 'text-nowrap text-end', sort: (row) => row.metrics.max_weight, cell: (row) => row.metrics.max_weight ? `${num(row.metrics.max_weight)} кг` : '—' },
|
||||||
|
{ title: tip('e1RM', HINTS.e1rm), className: 'text-nowrap text-end', sort: (row) => row.metrics.e1rm || 0, cell: (row) => row.metrics.e1rm ? `${num(row.metrics.e1rm)} кг` : '—' },
|
||||||
|
{ title: tip('Тоннаж', HINTS.volume), className: 'text-nowrap text-end', sort: (row) => row.metrics.volume, cell: (row) => row.metrics.volume ? `${num(row.metrics.volume)} кг` : '—' },
|
||||||
|
] : [
|
||||||
|
{ title: 'Лучший подход', className: 'text-nowrap text-end', sort: (row) => row.metrics.best_reps, cell: (row) => `${row.metrics.best_reps}` },
|
||||||
|
{ title: 'Всего повторов', className: 'text-nowrap text-end', sort: (row) => row.metrics.total_reps, cell: (row) => `${row.metrics.total_reps}` },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
h('h2', { class: 'h4' }, progress.name),
|
||||||
|
h('p', { class: 'text-muted' },
|
||||||
|
'Тренировок: ', h('strong', {}, `${rows.length}`),
|
||||||
|
', первая: ', h('strong', {}, formatDate(first.date)),
|
||||||
|
', последняя: ', h('strong', {}, formatDate(last.date))),
|
||||||
|
h('div', { class: 'row row-cols-1 row-cols-sm-2 row-cols-xl-4 g-3 mb-4' }, ...cards),
|
||||||
|
h('div', { class: 'card mb-2' }, h('div', { class: 'card-body' },
|
||||||
|
h('div', { style: 'height: 320px' }, h('canvas', { id: 'progress-chart' })))),
|
||||||
|
h('p', { class: 'text-muted small mb-4' }, weighted
|
||||||
|
? 'e1RM — расчётный максимум на один повтор по формуле Эпли: вес × (1 + повторы / 30), берётся лучший подход тренировки; подходы без веса не учитываются. Тоннаж — сумма вес × повторы по подходам с весом.'
|
||||||
|
: 'Упражнение выполняется без дополнительного веса, поэтому прогресс считается по повторам.'),
|
||||||
|
createTable({ id: 'exercise-history-table', columns, rows }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two series over time, each on its own y axis. The x axis is linear so gaps between
|
||||||
|
* trainings are proportional to the real time. */
|
||||||
|
function drawChart(progress) {
|
||||||
|
const series = progress.weighted
|
||||||
|
? [['e1RM, кг', (row) => row.metrics.e1rm], ['Тоннаж, кг', (row) => row.metrics.volume || null]]
|
||||||
|
: [['Лучший подход, повторы', (row) => row.metrics.best_reps], ['Всего повторов за тренировку', (row) => row.metrics.total_reps]];
|
||||||
|
const chronological = [...progress.rows].reverse();
|
||||||
|
const datasets = series.map(([label, metric]) => ({
|
||||||
|
label,
|
||||||
|
points: chronological
|
||||||
|
.map((row) => ({ x: Date.parse(row.date), y: metric(row) })) // "YYYY-MM-DD" parses as UTC
|
||||||
|
.filter((point) => point.y !== null && point.y !== undefined)
|
||||||
|
.map((point) => ({ x: point.x, y: Math.round(point.y * 10) / 10 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const colors = ['#0d6efd', '#fd7e14'];
|
||||||
|
const formatTick = (timestamp, options) =>
|
||||||
|
new Date(timestamp).toLocaleDateString('ru-RU', { ...options, timeZone: 'UTC' });
|
||||||
|
|
||||||
|
const scales = {
|
||||||
|
x: { type: 'linear', ticks: { maxTicksLimit: 8, callback: (value) => formatTick(value, { month: 'short', year: 'numeric' }) } },
|
||||||
|
};
|
||||||
|
datasets.forEach((item, index) => {
|
||||||
|
scales['y' + index] = {
|
||||||
|
position: index === 0 ? 'left' : 'right',
|
||||||
|
grid: { drawOnChartArea: index === 0 },
|
||||||
|
title: { display: true, text: item.label, color: colors[index] },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('progress-chart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: datasets.map((item, index) => ({
|
||||||
|
label: item.label,
|
||||||
|
data: item.points,
|
||||||
|
yAxisID: 'y' + index,
|
||||||
|
borderColor: colors[index],
|
||||||
|
backgroundColor: colors[index],
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 3,
|
||||||
|
tension: 0.2,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'nearest', axis: 'x', intersect: false },
|
||||||
|
scales,
|
||||||
|
plugins: { tooltip: { callbacks: { title: (items) => formatTick(items[0].parsed.x, {}) } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
23
frontend/js/parse.js
Normal file
23
frontend/js/parse.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { alert, api } from './common.js';
|
||||||
|
import { trainingsView } from './trainings-view.js';
|
||||||
|
|
||||||
|
const form = document.getElementById('parse-form');
|
||||||
|
const result = document.getElementById('result');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
result.replaceChildren(alert('info', 'Разбираю…'));
|
||||||
|
try {
|
||||||
|
const trainings = await api('/parser/parse/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: document.getElementById('training-data').value,
|
||||||
|
format: document.getElementById('format').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
result.replaceChildren(trainings.length ? trainingsView(trainings) : alert('warning', 'Тренировки не найдены'));
|
||||||
|
} catch (error) {
|
||||||
|
result.replaceChildren(alert('danger', `Не удалось разобрать заметку: ${error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue