Compare commits
18 Commits
1f47103480
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
| 2208f0492b | |||
| 7b535bef6e | |||
| 272563060c | |||
| 3dc0171639 | |||
| 312b14e57b | |||
| d8b52cf907 | |||
| b18146e96c | |||
| ee45513f30 | |||
| d90c9faf23 | |||
| 093f7aa55e | |||
| 77d1bc4a25 | |||
| 89d70fcae7 | |||
| 53e62f694f | |||
| fd979aa2da | |||
| 215ce90404 | |||
| 134542b66f | |||
| e35b78ae87 | |||
| 23754ea239 |
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
data/
|
||||||
|
docs/
|
||||||
|
tests/
|
||||||
|
alembic/
|
||||||
|
*.md
|
||||||
|
*.lock
|
||||||
|
.claude/
|
||||||
|
.gitea/
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
docker-compose.yaml
|
||||||
|
docker-compose.dev.yaml
|
||||||
12
.env.production
Normal file
12
.env.production
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# SneakySwole Production Environment
|
||||||
|
# Copy to .env on your production server and fill in real values.
|
||||||
|
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
APP_ENV=production
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=8000
|
||||||
|
APP_LOG_LEVEL=warning
|
||||||
|
|
||||||
|
DATABASE_URL=sqlite:///data/sneakyswole.db
|
||||||
46
.gitea/workflows/build-package.yaml
Normal file
46
.gitea/workflows/build-package.yaml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.sneakygeek.net
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.sneakygeek.net/sneakygeek/sneakyswole
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha,prefix=sha-,format=short
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.sneakygeek.net/sneakygeek/sneakyswole:buildcache
|
||||||
|
cache-to: type=registry,ref=git.sneakygeek.net/sneakygeek/sneakyswole:buildcache,mode=max
|
||||||
69
app/main.py
69
app/main.py
@@ -4,6 +4,8 @@ Creates and configures the FastAPI app with routes, templates,
|
|||||||
static files, and structured logging.
|
static files, and structured logging.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
@@ -12,12 +14,29 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlmodel import SQLModel, Session
|
from sqlmodel import SQLModel, Session
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
from starlette.requests import Request as StarletteRequest
|
||||||
|
from starlette.responses import Response
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import get_engine, get_db_session
|
from app.database import get_engine, get_db_session
|
||||||
from app.logging_config import setup_logging
|
from app.logging_config import setup_logging
|
||||||
|
from app.routes.auth import router as auth_router
|
||||||
|
from app.routes.exercises import router as exercises_router
|
||||||
|
from app.routes.history import router as history_router
|
||||||
from app.routes.health import router as health_router
|
from app.routes.health import router as health_router
|
||||||
from app.routes.pages import router as pages_router
|
from app.routes.pages import router as pages_router
|
||||||
|
from app.routes.profiles import router as profiles_router
|
||||||
|
from app.routes.logging import router as logging_router
|
||||||
|
from app.routes.workouts import router as workouts_router
|
||||||
|
from app.routes.dashboard import router as dashboard_router
|
||||||
|
from app.routes.schedule import router as schedule_router
|
||||||
from app.services.seed_service import SeedService
|
from app.services.seed_service import SeedService
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.services.user_service import UserService
|
||||||
|
from app.utils.auth import SESSION_COOKIE_NAME, NotAuthenticatedError
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -27,6 +46,37 @@ TEMPLATES_DIR = _BASE_DIR / "templates"
|
|||||||
STATIC_DIR = _BASE_DIR / "static"
|
STATIC_DIR = _BASE_DIR / "static"
|
||||||
|
|
||||||
|
|
||||||
|
class NavContextMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Injects admin, profiles, and active_profile into request.state for templates."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: StarletteRequest, call_next: RequestResponseEndpoint) -> Response:
|
||||||
|
request.state.admin = None
|
||||||
|
request.state.profiles = []
|
||||||
|
request.state.active_profile = None
|
||||||
|
|
||||||
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if token and hasattr(request.app.state, "engine"):
|
||||||
|
try:
|
||||||
|
with Session(request.app.state.engine) as session:
|
||||||
|
secret_key = getattr(request.app.state, "secret_key", "")
|
||||||
|
auth_service = AuthService(session, secret_key=secret_key)
|
||||||
|
user_id = auth_service.validate_session_token(token)
|
||||||
|
if user_id:
|
||||||
|
admin = session.get(User, user_id)
|
||||||
|
if admin and admin.is_admin:
|
||||||
|
request.state.admin = admin
|
||||||
|
user_service = UserService(session)
|
||||||
|
request.state.profiles = user_service.list_users(exclude_admin=True)
|
||||||
|
|
||||||
|
profile_id = request.cookies.get("active_profile_id")
|
||||||
|
if profile_id and profile_id.isdigit():
|
||||||
|
request.state.active_profile = user_service.get_user_by_id(int(profile_id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Create and configure the FastAPI application.
|
"""Create and configure the FastAPI application.
|
||||||
|
|
||||||
@@ -42,6 +92,11 @@ def create_app() -> FastAPI:
|
|||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Redirect unauthenticated requests to login
|
||||||
|
@app.exception_handler(NotAuthenticatedError)
|
||||||
|
async def _not_authenticated_handler(request, exc):
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
# Mount static files
|
# Mount static files
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
@@ -49,9 +104,23 @@ def create_app() -> FastAPI:
|
|||||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
app.state.templates = templates
|
app.state.templates = templates
|
||||||
|
|
||||||
|
# Secret key for session signing
|
||||||
|
app.state.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
|
||||||
|
|
||||||
|
# Nav context middleware — injects admin/profiles/active_profile into request.state
|
||||||
|
app.add_middleware(NavContextMiddleware)
|
||||||
|
|
||||||
# Register route modules
|
# Register route modules
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(exercises_router)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
app.include_router(history_router)
|
||||||
|
app.include_router(logging_router)
|
||||||
app.include_router(pages_router)
|
app.include_router(pages_router)
|
||||||
|
app.include_router(profiles_router)
|
||||||
|
app.include_router(workouts_router)
|
||||||
|
app.include_router(dashboard_router)
|
||||||
|
app.include_router(schedule_router)
|
||||||
|
|
||||||
# Database setup
|
# Database setup
|
||||||
engine = get_engine(settings.database_url)
|
engine = get_engine(settings.database_url)
|
||||||
|
|||||||
99
app/routes/auth.py
Normal file
99
app/routes/auth.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Authentication routes for admin login and logout.
|
||||||
|
|
||||||
|
Handles the login form, credential verification, session cookie
|
||||||
|
management, and logout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.database import get_db_session
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.utils.auth import SESSION_COOKIE_NAME
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request):
|
||||||
|
"""Render the login form.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered login page HTML.
|
||||||
|
"""
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/login.html", {
|
||||||
|
"request": request,
|
||||||
|
"error": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login_submit(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Process login form submission.
|
||||||
|
|
||||||
|
Verifies credentials and sets a session cookie on success.
|
||||||
|
Re-renders the login page with an error on failure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redirect to home on success, or login page with error.
|
||||||
|
"""
|
||||||
|
form = await request.form()
|
||||||
|
username = form.get("username", "")
|
||||||
|
password = form.get("password", "")
|
||||||
|
|
||||||
|
secret_key = request.app.state.secret_key
|
||||||
|
auth_service = AuthService(session, secret_key=secret_key)
|
||||||
|
user = auth_service.authenticate(username, password)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/login.html", {
|
||||||
|
"request": request,
|
||||||
|
"error": "Invalid username or password.",
|
||||||
|
}, status_code=200)
|
||||||
|
|
||||||
|
# Create session token and set cookie — httponly and samesite for security
|
||||||
|
token = auth_service.create_session_token(user_id=user.id)
|
||||||
|
response = RedirectResponse(url="/", status_code=303)
|
||||||
|
response.set_cookie(
|
||||||
|
key=SESSION_COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400, # 24 hours
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("login_success", username=username)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
"""Clear the session cookie and redirect to login.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redirect to login page.
|
||||||
|
"""
|
||||||
|
response = RedirectResponse(url="/login", status_code=303)
|
||||||
|
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
||||||
|
response.delete_cookie(key="active_profile_id")
|
||||||
|
logger.info("logout")
|
||||||
|
return response
|
||||||
102
app/routes/dashboard.py
Normal file
102
app/routes/dashboard.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Progress dashboard routes.
|
||||||
|
|
||||||
|
Displays summary statistics, volume charts, and per-exercise progress.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.database import get_db_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.analytics_service import AnalyticsService
|
||||||
|
from app.services.exercise_service import ExerciseService
|
||||||
|
from app.services.progression_service import ProgressionService
|
||||||
|
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def dashboard(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Render the progress dashboard for the active profile.
|
||||||
|
|
||||||
|
Shows: summary stats, volume by day chart, exercise progress links.
|
||||||
|
"""
|
||||||
|
active_profile_id = get_active_profile_id(request)
|
||||||
|
active_profile = (
|
||||||
|
session.get(User, active_profile_id)
|
||||||
|
if active_profile_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
volume_data = {}
|
||||||
|
if active_profile_id:
|
||||||
|
analytics = AnalyticsService(session)
|
||||||
|
stats = analytics.get_user_stats(active_profile_id)
|
||||||
|
volume_data = analytics.get_volume_by_day(active_profile_id)
|
||||||
|
|
||||||
|
exercise_service = ExerciseService(session)
|
||||||
|
exercises = exercise_service.list_exercises()
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/dashboard.html", {
|
||||||
|
"request": request,
|
||||||
|
"stats": stats,
|
||||||
|
"volume_data_json": json.dumps(volume_data),
|
||||||
|
"exercises": exercises,
|
||||||
|
"active_profile": active_profile,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/exercise/{exercise_id}", response_class=HTMLResponse)
|
||||||
|
async def exercise_progress(
|
||||||
|
exercise_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Render per-exercise progress page with charts and suggestions."""
|
||||||
|
active_profile_id = get_active_profile_id(request)
|
||||||
|
active_profile = (
|
||||||
|
session.get(User, active_profile_id)
|
||||||
|
if active_profile_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
exercise_service = ExerciseService(session)
|
||||||
|
exercise = exercise_service.get_exercise_by_id(exercise_id)
|
||||||
|
|
||||||
|
progress_data = {}
|
||||||
|
suggestion = {}
|
||||||
|
if active_profile_id:
|
||||||
|
analytics = AnalyticsService(session)
|
||||||
|
progress_data = analytics.get_exercise_progress(
|
||||||
|
active_profile_id, exercise_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
progression = ProgressionService(session)
|
||||||
|
suggestion = progression.get_suggestion(
|
||||||
|
active_profile_id, exercise_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/exercise_progress.html", {
|
||||||
|
"request": request,
|
||||||
|
"exercise": exercise,
|
||||||
|
"progress_data_json": json.dumps(progress_data),
|
||||||
|
"suggestion": suggestion,
|
||||||
|
"active_profile": active_profile,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
86
app/routes/exercises.py
Normal file
86
app/routes/exercises.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Exercise browser routes with HTMX search/filter support.
|
||||||
|
|
||||||
|
All filtering is done via HTMX partial responses — no JSON APIs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.database import get_db_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.exercise_service import ExerciseService
|
||||||
|
from app.utils.auth import get_current_admin_user
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/exercises", tags=["exercises"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def exercise_browser(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Render the exercise browser page with all exercises.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered exercise browser page.
|
||||||
|
"""
|
||||||
|
exercise_service = ExerciseService(session)
|
||||||
|
exercises = exercise_service.list_exercises()
|
||||||
|
workout_days = exercise_service.list_workout_days()
|
||||||
|
|
||||||
|
# Collect unique muscle groups for the filter dropdown
|
||||||
|
muscle_groups = sorted(set(ex.muscle_group for ex in exercises))
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/exercise_browser.html", {
|
||||||
|
"request": request,
|
||||||
|
"exercises": exercises,
|
||||||
|
"workout_days": workout_days,
|
||||||
|
"muscle_groups": muscle_groups,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_class=HTMLResponse)
|
||||||
|
async def exercise_search(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
workout_day: str = Query(default="", alias="workout_day"),
|
||||||
|
muscle_group: str = Query(default="", alias="muscle_group"),
|
||||||
|
):
|
||||||
|
"""Return filtered exercise list as an HTMX partial.
|
||||||
|
|
||||||
|
Called via hx-get from the exercise browser filter dropdowns.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
workout_day: Filter by workout day name.
|
||||||
|
muscle_group: Filter by muscle group.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered exercise list partial HTML.
|
||||||
|
"""
|
||||||
|
exercise_service = ExerciseService(session)
|
||||||
|
exercises = exercise_service.list_exercises(
|
||||||
|
workout_day=workout_day or None,
|
||||||
|
muscle_group=muscle_group or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("partials/exercise_list.html", {
|
||||||
|
"request": request,
|
||||||
|
"exercises": exercises,
|
||||||
|
})
|
||||||
113
app/routes/history.py
Normal file
113
app/routes/history.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""Log history routes for viewing past workout sessions.
|
||||||
|
|
||||||
|
Displays a list of past sessions and detailed logs per session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.database import get_db_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.exercise_service import ExerciseService
|
||||||
|
from app.services.log_service import LogService
|
||||||
|
from app.services.workout_session_service import WorkoutSessionService
|
||||||
|
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/history", tags=["history"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def log_history(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Display log history for the active profile.
|
||||||
|
|
||||||
|
Shows a list of past workout sessions, most recent first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered log history page.
|
||||||
|
"""
|
||||||
|
active_profile_id = get_active_profile_id(request)
|
||||||
|
active_profile = (
|
||||||
|
session.get(User, active_profile_id)
|
||||||
|
if active_profile_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
sessions_list = []
|
||||||
|
if active_profile_id:
|
||||||
|
ws_service = WorkoutSessionService(session)
|
||||||
|
sessions_list = ws_service.list_sessions(user_id=active_profile_id)
|
||||||
|
|
||||||
|
# Resolve workout day names for display
|
||||||
|
exercise_service = ExerciseService(session)
|
||||||
|
days_by_id = {d.id: d for d in exercise_service.list_workout_days()}
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/log_history.html", {
|
||||||
|
"request": request,
|
||||||
|
"sessions": sessions_list,
|
||||||
|
"days_by_id": days_by_id,
|
||||||
|
"active_profile": active_profile,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}", response_class=HTMLResponse)
|
||||||
|
async def session_detail(
|
||||||
|
session_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Display detailed logs for a specific workout session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The workout session ID.
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered session detail page.
|
||||||
|
"""
|
||||||
|
ws_service = WorkoutSessionService(session)
|
||||||
|
ws = ws_service.get_session_by_id(session_id)
|
||||||
|
|
||||||
|
log_service = LogService(session)
|
||||||
|
logs = log_service.list_logs_for_session(session_id)
|
||||||
|
|
||||||
|
# Group logs by exercise
|
||||||
|
exercise_service = ExerciseService(session)
|
||||||
|
exercises_by_id = {}
|
||||||
|
logs_by_exercise = {}
|
||||||
|
for log in logs:
|
||||||
|
if log.exercise_id not in exercises_by_id:
|
||||||
|
exercises_by_id[log.exercise_id] = (
|
||||||
|
exercise_service.get_exercise_by_id(log.exercise_id)
|
||||||
|
)
|
||||||
|
logs_by_exercise.setdefault(log.exercise_id, []).append(log)
|
||||||
|
|
||||||
|
# Resolve workout day name
|
||||||
|
days_by_id = {d.id: d for d in exercise_service.list_workout_days()}
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/session_detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"workout_session": ws,
|
||||||
|
"logs_by_exercise": logs_by_exercise,
|
||||||
|
"exercises_by_id": exercises_by_id,
|
||||||
|
"days_by_id": days_by_id,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
225
app/routes/logging.py
Normal file
225
app/routes/logging.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Workout logging routes for inline set tracking.
|
||||||
|
|
||||||
|
Handles creating, editing, and deleting individual set logs.
|
||||||
|
All responses are HTMX partials that update in place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.database import get_db_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.log_service import LogService
|
||||||
|
from app.services.progression_service import ProgressionService
|
||||||
|
from app.services.workout_session_service import WorkoutSessionService
|
||||||
|
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/log", tags=["logging"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_prefill_values(
|
||||||
|
logs: list,
|
||||||
|
session: Session,
|
||||||
|
profile_id: int,
|
||||||
|
exercise_id: int,
|
||||||
|
) -> tuple:
|
||||||
|
"""Get pre-fill values for the next set form.
|
||||||
|
|
||||||
|
If sets have already been logged this session, use the last logged
|
||||||
|
set's values (users typically repeat the same reps/weight across sets).
|
||||||
|
Otherwise, use the progression engine's suggestion.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(suggested_reps, suggested_weight) tuple.
|
||||||
|
"""
|
||||||
|
if logs:
|
||||||
|
last = logs[-1]
|
||||||
|
return last.reps_completed, last.weight_used
|
||||||
|
|
||||||
|
progression = ProgressionService(session)
|
||||||
|
suggestion = progression.get_suggestion(profile_id, exercise_id)
|
||||||
|
return suggestion.get("suggested_reps"), suggestion.get("suggested_weight")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_class=HTMLResponse)
|
||||||
|
async def log_set(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Log a single set for an exercise.
|
||||||
|
|
||||||
|
Creates the workout session if it doesn't exist yet (auto-create).
|
||||||
|
Returns the updated log entries partial for this exercise.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered log entries partial for this exercise.
|
||||||
|
"""
|
||||||
|
form = await request.form()
|
||||||
|
exercise_id = int(form.get("exercise_id", 0))
|
||||||
|
workout_day_id = int(form.get("workout_day_id", 0))
|
||||||
|
set_number = int(form.get("set_number", 1))
|
||||||
|
reps = int(form.get("reps", 0))
|
||||||
|
weight = form.get("weight", "")
|
||||||
|
felt_easy = form.get("felt_easy") == "on"
|
||||||
|
|
||||||
|
active_profile_id = get_active_profile_id(request)
|
||||||
|
if not active_profile_id:
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("partials/flash_message.html", {
|
||||||
|
"request": request,
|
||||||
|
"flash_error": "No profile selected. Switch profiles first.",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get or create today's session
|
||||||
|
ws_service = WorkoutSessionService(session)
|
||||||
|
ws = ws_service.get_or_create_session(
|
||||||
|
user_id=active_profile_id,
|
||||||
|
workout_day_id=workout_day_id,
|
||||||
|
session_date=date.today(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the log entry
|
||||||
|
log_service = LogService(session)
|
||||||
|
log_service.create_log(
|
||||||
|
session_id=ws.id,
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
set_number=set_number,
|
||||||
|
reps_completed=reps,
|
||||||
|
weight_used=weight,
|
||||||
|
felt_easy=felt_easy,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return updated logs for this exercise
|
||||||
|
logs = log_service.list_logs_for_exercise(ws.id, exercise_id)
|
||||||
|
next_set = len(logs) + 1
|
||||||
|
suggested_reps, suggested_weight = _get_prefill_values(
|
||||||
|
logs, session, active_profile_id, exercise_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("partials/log_entry.html", {
|
||||||
|
"request": request,
|
||||||
|
"logs": logs,
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"workout_day_id": workout_day_id,
|
||||||
|
"next_set": next_set,
|
||||||
|
"session_id": ws.id,
|
||||||
|
"suggested_reps": suggested_reps,
|
||||||
|
"suggested_weight": suggested_weight,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{log_id}/edit", response_class=HTMLResponse)
|
||||||
|
async def edit_log(
|
||||||
|
log_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Edit an existing log entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_id: The log entry ID.
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered updated log entry partial.
|
||||||
|
"""
|
||||||
|
form = await request.form()
|
||||||
|
log_service = LogService(session)
|
||||||
|
|
||||||
|
log_service.update_log(
|
||||||
|
log_id,
|
||||||
|
reps_completed=int(form.get("reps", 0)),
|
||||||
|
weight_used=form.get("weight", ""),
|
||||||
|
felt_easy=form.get("felt_easy") == "on",
|
||||||
|
notes=form.get("notes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
log = log_service.get_log_by_id(log_id)
|
||||||
|
logs = log_service.list_logs_for_exercise(log.session_id, log.exercise_id)
|
||||||
|
next_set = len(logs) + 1
|
||||||
|
|
||||||
|
active_profile_id = get_active_profile_id(request)
|
||||||
|
suggested_reps, suggested_weight = None, None
|
||||||
|
if active_profile_id:
|
||||||
|
suggested_reps, suggested_weight = _get_prefill_values(
|
||||||
|
logs, session, active_profile_id, log.exercise_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("partials/log_entry.html", {
|
||||||
|
"request": request,
|
||||||
|
"logs": logs,
|
||||||
|
"exercise_id": log.exercise_id,
|
||||||
|
"workout_day_id": 0,
|
||||||
|
"next_set": next_set,
|
||||||
|
"session_id": log.session_id,
|
||||||
|
"suggested_reps": suggested_reps,
|
||||||
|
"suggested_weight": suggested_weight,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{log_id}/delete", response_class=HTMLResponse)
|
||||||
|
async def delete_log(
|
||||||
|
log_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Delete a log entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_id: The log entry ID.
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered updated log entries partial.
|
||||||
|
"""
|
||||||
|
log_service = LogService(session)
|
||||||
|
log = log_service.get_log_by_id(log_id)
|
||||||
|
|
||||||
|
if log:
|
||||||
|
exercise_id = log.exercise_id
|
||||||
|
session_id = log.session_id
|
||||||
|
log_service.delete_log(log_id)
|
||||||
|
|
||||||
|
logs = log_service.list_logs_for_exercise(session_id, exercise_id)
|
||||||
|
next_set = len(logs) + 1
|
||||||
|
|
||||||
|
active_profile_id = get_active_profile_id(request)
|
||||||
|
suggested_reps, suggested_weight = None, None
|
||||||
|
if active_profile_id:
|
||||||
|
suggested_reps, suggested_weight = _get_prefill_values(
|
||||||
|
logs, session, active_profile_id, exercise_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("partials/log_entry.html", {
|
||||||
|
"request": request,
|
||||||
|
"logs": logs,
|
||||||
|
"exercise_id": exercise_id,
|
||||||
|
"workout_day_id": 0,
|
||||||
|
"next_set": next_set,
|
||||||
|
"session_id": session_id,
|
||||||
|
"suggested_reps": suggested_reps,
|
||||||
|
"suggested_weight": suggested_weight,
|
||||||
|
})
|
||||||
|
|
||||||
|
return HTMLResponse("")
|
||||||
151
app/routes/profiles.py
Normal file
151
app/routes/profiles.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Profile management routes.
|
||||||
|
|
||||||
|
Admin can view, create, edit user profiles and switch the active profile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.database import get_db_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.user_service import UserService
|
||||||
|
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/profiles", tags=["profiles"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def list_profiles(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""List all user profiles for the admin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered profile list page.
|
||||||
|
"""
|
||||||
|
user_service = UserService(session)
|
||||||
|
profiles = user_service.list_users(exclude_admin=True)
|
||||||
|
active_profile_id = get_active_profile_id(request)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/profiles.html", {
|
||||||
|
"request": request,
|
||||||
|
"profiles": profiles,
|
||||||
|
"active_profile_id": active_profile_id,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/switch")
|
||||||
|
async def switch_profile(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Switch the active user profile.
|
||||||
|
|
||||||
|
Sets a cookie with the selected profile ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redirect to the referring page with profile cookie set.
|
||||||
|
"""
|
||||||
|
form = await request.form()
|
||||||
|
profile_id = form.get("profile_id", "")
|
||||||
|
|
||||||
|
# Validate profile exists and is not an admin
|
||||||
|
user_service = UserService(session)
|
||||||
|
profile = user_service.get_user_by_id(int(profile_id)) if profile_id.isdigit() else None
|
||||||
|
|
||||||
|
referer = request.headers.get("referer", "/")
|
||||||
|
response = RedirectResponse(url=referer, status_code=303)
|
||||||
|
|
||||||
|
if profile and not profile.is_admin:
|
||||||
|
response.set_cookie(
|
||||||
|
key="active_profile_id",
|
||||||
|
value=str(profile.id),
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400,
|
||||||
|
)
|
||||||
|
logger.info("profile_switched", profile_id=profile.id, name=profile.display_name)
|
||||||
|
else:
|
||||||
|
logger.warning("profile_switch_failed", profile_id=profile_id)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{profile_id}/edit", response_class=HTMLResponse)
|
||||||
|
async def edit_profile_page(
|
||||||
|
profile_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Render the profile edit form.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: The profile ID to edit.
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered profile edit page.
|
||||||
|
"""
|
||||||
|
user_service = UserService(session)
|
||||||
|
profile = user_service.get_user_by_id(profile_id)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/profile_edit.html", {
|
||||||
|
"request": request,
|
||||||
|
"profile": profile,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{profile_id}/edit")
|
||||||
|
async def update_profile(
|
||||||
|
profile_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Process profile edit form submission.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: The profile ID to update.
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redirect to profiles page.
|
||||||
|
"""
|
||||||
|
form = await request.form()
|
||||||
|
user_service = UserService(session)
|
||||||
|
|
||||||
|
user_service.update_user(
|
||||||
|
profile_id,
|
||||||
|
display_name=form.get("display_name", ""),
|
||||||
|
height=form.get("height", ""),
|
||||||
|
weight=form.get("weight", ""),
|
||||||
|
goals=form.get("goals", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(url="/profiles", status_code=303)
|
||||||
86
app/routes/schedule.py
Normal file
86
app/routes/schedule.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""4-week schedule calendar routes.
|
||||||
|
|
||||||
|
Displays a calendar view showing which workout day maps to which date.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.database import get_db_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.exercise_service import ExerciseService
|
||||||
|
from app.services.workout_session_service import WorkoutSessionService
|
||||||
|
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/schedule", tags=["schedule"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def schedule_view(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Render the 4-week schedule calendar.
|
||||||
|
|
||||||
|
Shows a 4-week grid where each training day is mapped to a
|
||||||
|
calendar date. Days with completed sessions are highlighted.
|
||||||
|
"""
|
||||||
|
active_profile_id = get_active_profile_id(request)
|
||||||
|
active_profile = (
|
||||||
|
session.get(User, active_profile_id)
|
||||||
|
if active_profile_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
exercise_service = ExerciseService(session)
|
||||||
|
workout_days = exercise_service.list_workout_days()
|
||||||
|
|
||||||
|
# Build 4-week calendar starting from Monday of current week
|
||||||
|
today = date.today()
|
||||||
|
monday = today - timedelta(days=today.weekday())
|
||||||
|
|
||||||
|
weeks = []
|
||||||
|
completed_dates = set()
|
||||||
|
|
||||||
|
# Get completed sessions for highlighting
|
||||||
|
if active_profile_id:
|
||||||
|
ws_service = WorkoutSessionService(session)
|
||||||
|
sessions_list = ws_service.list_sessions(
|
||||||
|
user_id=active_profile_id, limit=100,
|
||||||
|
)
|
||||||
|
completed_dates = {ws.date for ws in sessions_list}
|
||||||
|
|
||||||
|
# 4 workout days per week, 4 weeks
|
||||||
|
for week_num in range(4):
|
||||||
|
week_start = monday + timedelta(weeks=week_num)
|
||||||
|
week_data = {
|
||||||
|
"week_number": week_num + 1,
|
||||||
|
"days": [],
|
||||||
|
}
|
||||||
|
for day_offset, workout_day in enumerate(workout_days):
|
||||||
|
training_date = week_start + timedelta(days=day_offset)
|
||||||
|
is_today = training_date == today
|
||||||
|
is_completed = training_date in completed_dates
|
||||||
|
|
||||||
|
week_data["days"].append({
|
||||||
|
"workout_day": workout_day,
|
||||||
|
"date": training_date,
|
||||||
|
"is_today": is_today,
|
||||||
|
"is_completed": is_completed,
|
||||||
|
})
|
||||||
|
weeks.append(week_data)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/schedule.html", {
|
||||||
|
"request": request,
|
||||||
|
"weeks": weeks,
|
||||||
|
"active_profile": active_profile,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
137
app/routes/workouts.py
Normal file
137
app/routes/workouts.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""Workout day viewer routes.
|
||||||
|
|
||||||
|
Displays the warmup routine and main exercises for each workout day,
|
||||||
|
with the active profile's programming targets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.database import get_db_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.user_exercise_program import UserExerciseProgram
|
||||||
|
from app.services.exercise_service import ExerciseService
|
||||||
|
from app.services.log_service import LogService
|
||||||
|
from app.services.progression_service import ProgressionService
|
||||||
|
from app.services.workout_session_service import WorkoutSessionService
|
||||||
|
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/workouts", tags=["workouts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def workout_days_list(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""List all workout days as clickable cards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered workout days list page.
|
||||||
|
"""
|
||||||
|
exercise_service = ExerciseService(session)
|
||||||
|
days = exercise_service.list_workout_days()
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/workout_days.html", {
|
||||||
|
"request": request,
|
||||||
|
"days": days,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{day_name}", response_class=HTMLResponse)
|
||||||
|
async def workout_day_detail(
|
||||||
|
day_name: str,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
admin: User = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""Display a full workout day -- warmups + exercises with form cues.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
day_name: The workout day name (e.g., "push", "pull").
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session.
|
||||||
|
admin: The authenticated admin user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered workout day detail page.
|
||||||
|
"""
|
||||||
|
exercise_service = ExerciseService(session)
|
||||||
|
|
||||||
|
# Normalize day name for DB lookup (e.g., "push" -> "Push", "full-body" -> "Full Body")
|
||||||
|
day_display = day_name.replace("-", " ").title()
|
||||||
|
|
||||||
|
warmups = exercise_service.list_warmups()
|
||||||
|
exercises = exercise_service.list_exercises(workout_day=day_display)
|
||||||
|
|
||||||
|
# Get active profile's programming if set
|
||||||
|
active_profile_id = get_active_profile_id(request)
|
||||||
|
programs = {}
|
||||||
|
active_profile = None
|
||||||
|
existing_logs = {}
|
||||||
|
if active_profile_id:
|
||||||
|
active_profile = session.get(User, active_profile_id)
|
||||||
|
if active_profile:
|
||||||
|
statement = select(UserExerciseProgram).where(
|
||||||
|
UserExerciseProgram.user_id == active_profile_id
|
||||||
|
)
|
||||||
|
for prog in session.exec(statement).all():
|
||||||
|
programs[prog.exercise_id] = prog
|
||||||
|
|
||||||
|
# Look up the workout day ID for logging forms
|
||||||
|
days = exercise_service.list_workout_days()
|
||||||
|
workout_day_id = 0
|
||||||
|
for d in days:
|
||||||
|
if d.name == day_display:
|
||||||
|
workout_day_id = d.id
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get progression suggestions for each exercise
|
||||||
|
suggestions = {}
|
||||||
|
if active_profile_id:
|
||||||
|
progression = ProgressionService(session)
|
||||||
|
for exercise in exercises:
|
||||||
|
suggestions[exercise.id] = progression.get_suggestion(
|
||||||
|
active_profile_id, exercise.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load existing logs for today's session (if any)
|
||||||
|
if active_profile_id and workout_day_id:
|
||||||
|
ws_service = WorkoutSessionService(session)
|
||||||
|
ws = ws_service.get_or_create_session(
|
||||||
|
user_id=active_profile_id,
|
||||||
|
workout_day_id=workout_day_id,
|
||||||
|
session_date=date.today(),
|
||||||
|
)
|
||||||
|
log_service = LogService(session)
|
||||||
|
all_logs = log_service.list_logs_for_session(ws.id)
|
||||||
|
for log in all_logs:
|
||||||
|
existing_logs.setdefault(log.exercise_id, []).append(log)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse("pages/workout_day.html", {
|
||||||
|
"request": request,
|
||||||
|
"day_name": day_display,
|
||||||
|
"warmups": warmups,
|
||||||
|
"exercises": exercises,
|
||||||
|
"programs": programs,
|
||||||
|
"active_profile": active_profile,
|
||||||
|
"existing_logs": existing_logs,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"workout_day_id": workout_day_id,
|
||||||
|
"admin": admin,
|
||||||
|
})
|
||||||
181
app/services/analytics_service.py
Normal file
181
app/services/analytics_service.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""Analytics service for progress dashboard and chart data.
|
||||||
|
|
||||||
|
Aggregates workout log data into stats, trends, and chart-ready formats.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.workout_day import WorkoutDay
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _weight_to_float(weight_str: str) -> float:
|
||||||
|
"""Convert weight string to float for volume calculations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weight_str: Weight like '30 lbs' or 'BW'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Numeric weight, or 0.0 for bodyweight.
|
||||||
|
"""
|
||||||
|
if not weight_str or weight_str.upper() == "BW":
|
||||||
|
return 0.0
|
||||||
|
match = re.search(r"(\d+(?:\.\d+)?)", weight_str)
|
||||||
|
return float(match.group(1)) if match else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsService:
|
||||||
|
"""Aggregates workout data for dashboards and charts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active SQLModel Session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def get_user_stats(self, user_id: int) -> dict:
|
||||||
|
"""Get summary statistics for a user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: total_sessions, total_volume, total_sets,
|
||||||
|
current_streak, last_workout_date.
|
||||||
|
"""
|
||||||
|
all_sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
.order_by(WorkoutSession.date.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Only count sessions that still have log entries
|
||||||
|
sessions = []
|
||||||
|
total_volume = 0.0
|
||||||
|
total_sets = 0
|
||||||
|
for ws in all_sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
|
||||||
|
).all()
|
||||||
|
if not logs:
|
||||||
|
continue
|
||||||
|
sessions.append(ws)
|
||||||
|
for log_entry in logs:
|
||||||
|
total_sets += 1
|
||||||
|
weight = _weight_to_float(log_entry.weight_used)
|
||||||
|
total_volume += log_entry.reps_completed * weight
|
||||||
|
total_sessions = len(sessions)
|
||||||
|
|
||||||
|
current_streak = 0
|
||||||
|
if sessions:
|
||||||
|
week_start = date.today() - timedelta(days=date.today().weekday())
|
||||||
|
for week_offset in range(52):
|
||||||
|
week_check = week_start - timedelta(weeks=week_offset)
|
||||||
|
week_end = week_check + timedelta(days=6)
|
||||||
|
has_session = any(
|
||||||
|
week_check <= ws.date <= week_end for ws in sessions
|
||||||
|
)
|
||||||
|
if has_session:
|
||||||
|
current_streak += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
last_workout = sessions[0].date if sessions else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_sessions": total_sessions,
|
||||||
|
"total_volume": round(total_volume),
|
||||||
|
"total_sets": total_sets,
|
||||||
|
"current_streak": current_streak,
|
||||||
|
"last_workout_date": last_workout,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_exercise_progress(
|
||||||
|
self, user_id: int, exercise_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Get chart-ready progress data for a specific exercise.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: dates, reps, weights, volumes.
|
||||||
|
"""
|
||||||
|
sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
.order_by(WorkoutSession.date.asc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
dates = []
|
||||||
|
reps = []
|
||||||
|
weights = []
|
||||||
|
volumes = []
|
||||||
|
|
||||||
|
for ws in sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(
|
||||||
|
WorkoutLog.session_id == ws.id,
|
||||||
|
WorkoutLog.exercise_id == exercise_id,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
avg_reps = sum(
|
||||||
|
log_entry.reps_completed for log_entry in logs
|
||||||
|
) / len(logs)
|
||||||
|
weight = _weight_to_float(logs[0].weight_used)
|
||||||
|
session_volume = sum(
|
||||||
|
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
|
||||||
|
for log_entry in logs
|
||||||
|
)
|
||||||
|
|
||||||
|
dates.append(ws.date.isoformat())
|
||||||
|
reps.append(round(avg_reps, 1))
|
||||||
|
weights.append(weight)
|
||||||
|
volumes.append(round(session_volume))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dates": dates,
|
||||||
|
"reps": reps,
|
||||||
|
"weights": weights,
|
||||||
|
"volumes": volumes,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_volume_by_day(self, user_id: int) -> dict:
|
||||||
|
"""Get total volume broken down by workout day.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping workout day name to total volume.
|
||||||
|
"""
|
||||||
|
days = self._session.exec(select(WorkoutDay)).all()
|
||||||
|
day_map = {d.id: d.name for d in days}
|
||||||
|
|
||||||
|
sessions = self._session.exec(
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
volume_by_day = {}
|
||||||
|
for ws in sessions:
|
||||||
|
day_name = day_map.get(ws.workout_day_id, "Unknown")
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
day_volume = sum(
|
||||||
|
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
|
||||||
|
for log_entry in logs
|
||||||
|
)
|
||||||
|
volume_by_day[day_name] = (
|
||||||
|
volume_by_day.get(day_name, 0) + round(day_volume)
|
||||||
|
)
|
||||||
|
|
||||||
|
return volume_by_day
|
||||||
100
app/services/auth_service.py
Normal file
100
app/services/auth_service.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Service layer for admin authentication and session management.
|
||||||
|
|
||||||
|
Handles password verification, session token creation/validation.
|
||||||
|
Uses bcrypt for password hashing and itsdangerous for session signing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import structlog
|
||||||
|
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
# Session token max age: 24 hours
|
||||||
|
SESSION_MAX_AGE_SECONDS = 86400
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""Handles admin authentication and session token management.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active SQLModel Session.
|
||||||
|
secret_key: Secret key for signing session tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session, secret_key: str) -> None:
|
||||||
|
self._session = session
|
||||||
|
self._serializer = URLSafeTimedSerializer(secret_key)
|
||||||
|
|
||||||
|
def authenticate(self, username: str, password: str) -> Optional[User]:
|
||||||
|
"""Verify admin credentials and return the User if valid.
|
||||||
|
|
||||||
|
Only admin users can authenticate. Non-admin users are rejected.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: The username to check.
|
||||||
|
password: The plaintext password to verify.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The authenticated User, or None if credentials are invalid.
|
||||||
|
"""
|
||||||
|
statement = select(User).where(User.username == username)
|
||||||
|
user = self._session.exec(statement).first()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
logger.warning("auth_failed", reason="user_not_found", username=username)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not user.is_admin:
|
||||||
|
logger.warning("auth_failed", reason="not_admin", username=username)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not user.password_hash:
|
||||||
|
logger.warning("auth_failed", reason="no_password_hash", username=username)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify password against bcrypt hash
|
||||||
|
if not bcrypt.checkpw(
|
||||||
|
password.encode("utf-8"),
|
||||||
|
user.password_hash.encode("utf-8"),
|
||||||
|
):
|
||||||
|
logger.warning("auth_failed", reason="wrong_password", username=username)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info("auth_success", username=username)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_session_token(self, user_id: int) -> str:
|
||||||
|
"""Create a signed session token for the given user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The authenticated user's ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A signed, URL-safe token string.
|
||||||
|
"""
|
||||||
|
return self._serializer.dumps({"user_id": user_id})
|
||||||
|
|
||||||
|
def validate_session_token(self, token: str) -> Optional[int]:
|
||||||
|
"""Validate a session token and extract the user_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The session token to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The user_id if the token is valid, or None.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = self._serializer.loads(token, max_age=SESSION_MAX_AGE_SECONDS)
|
||||||
|
return data.get("user_id")
|
||||||
|
except BadSignature:
|
||||||
|
logger.warning("session_invalid", reason="bad_signature")
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
logger.warning("session_invalid", reason="unknown_error")
|
||||||
|
return None
|
||||||
227
app/services/log_service.py
Normal file
227
app/services/log_service.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""Service layer for workout log (set-level) data access.
|
||||||
|
|
||||||
|
Handles CRUD for individual set logs within workout sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LogService:
|
||||||
|
"""Handles CRUD operations for WorkoutLog records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active SQLModel Session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def create_log(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
exercise_id: int,
|
||||||
|
set_number: int,
|
||||||
|
reps_completed: int,
|
||||||
|
weight_used: str,
|
||||||
|
felt_easy: bool,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
) -> WorkoutLog:
|
||||||
|
"""Create a new set log entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: FK to workout_sessions.
|
||||||
|
exercise_id: FK to exercises.
|
||||||
|
set_number: Which set (1, 2, 3...).
|
||||||
|
reps_completed: Actual reps performed.
|
||||||
|
weight_used: Weight as string (e.g., "30 lbs").
|
||||||
|
felt_easy: Whether the set felt easy.
|
||||||
|
notes: Optional notes for this set.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created WorkoutLog record.
|
||||||
|
"""
|
||||||
|
log = WorkoutLog(
|
||||||
|
session_id=session_id,
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
set_number=set_number,
|
||||||
|
reps_completed=reps_completed,
|
||||||
|
weight_used=weight_used,
|
||||||
|
felt_easy=felt_easy,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
self._session.add(log)
|
||||||
|
self._session.commit()
|
||||||
|
self._session.refresh(log)
|
||||||
|
logger.info(
|
||||||
|
"log_created",
|
||||||
|
session_id=session_id,
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
set=set_number,
|
||||||
|
)
|
||||||
|
return log
|
||||||
|
|
||||||
|
def list_logs_for_session(self, session_id: int) -> list[WorkoutLog]:
|
||||||
|
"""List all log entries for a workout session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The workout session ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WorkoutLog records ordered by exercise and set number.
|
||||||
|
"""
|
||||||
|
statement = (
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(WorkoutLog.session_id == session_id)
|
||||||
|
.order_by(WorkoutLog.exercise_id, WorkoutLog.set_number)
|
||||||
|
)
|
||||||
|
return list(self._session.exec(statement).all())
|
||||||
|
|
||||||
|
def list_logs_for_exercise(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
exercise_id: int,
|
||||||
|
) -> list[WorkoutLog]:
|
||||||
|
"""List log entries for a specific exercise within a session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The workout session ID.
|
||||||
|
exercise_id: The exercise ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WorkoutLog records for this exercise, ordered by set.
|
||||||
|
"""
|
||||||
|
statement = (
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(
|
||||||
|
WorkoutLog.session_id == session_id,
|
||||||
|
WorkoutLog.exercise_id == exercise_id,
|
||||||
|
)
|
||||||
|
.order_by(WorkoutLog.set_number)
|
||||||
|
)
|
||||||
|
return list(self._session.exec(statement).all())
|
||||||
|
|
||||||
|
def get_log_by_id(self, log_id: int) -> Optional[WorkoutLog]:
|
||||||
|
"""Retrieve a log entry by primary key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_id: The log entry ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The WorkoutLog record, or None if not found.
|
||||||
|
"""
|
||||||
|
return self._session.get(WorkoutLog, log_id)
|
||||||
|
|
||||||
|
def update_log(self, log_id: int, **kwargs) -> WorkoutLog:
|
||||||
|
"""Update fields on an existing log entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_id: The log entry ID.
|
||||||
|
**kwargs: Field names and new values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated WorkoutLog record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the log is not found.
|
||||||
|
"""
|
||||||
|
log = self.get_log_by_id(log_id)
|
||||||
|
if log is None:
|
||||||
|
raise ValueError(f"WorkoutLog with id {log_id} not found")
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(log, key):
|
||||||
|
setattr(log, key, value)
|
||||||
|
|
||||||
|
self._session.add(log)
|
||||||
|
self._session.commit()
|
||||||
|
self._session.refresh(log)
|
||||||
|
logger.info("log_updated", log_id=log_id, fields=list(kwargs.keys()))
|
||||||
|
return log
|
||||||
|
|
||||||
|
def delete_log(self, log_id: int) -> None:
|
||||||
|
"""Delete a log entry.
|
||||||
|
|
||||||
|
Removes the log, renumbers remaining sets, and cleans up the
|
||||||
|
parent session if no logs remain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_id: The log entry ID.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the log is not found.
|
||||||
|
"""
|
||||||
|
log = self.get_log_by_id(log_id)
|
||||||
|
if log is None:
|
||||||
|
raise ValueError(f"WorkoutLog with id {log_id} not found")
|
||||||
|
|
||||||
|
session_id = log.session_id
|
||||||
|
exercise_id = log.exercise_id
|
||||||
|
self._session.delete(log)
|
||||||
|
self._session.commit()
|
||||||
|
logger.info("log_deleted", log_id=log_id)
|
||||||
|
|
||||||
|
# Renumber remaining sets so they stay sequential (1, 2, 3...)
|
||||||
|
remaining = self._session.exec(
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(
|
||||||
|
WorkoutLog.session_id == session_id,
|
||||||
|
WorkoutLog.exercise_id == exercise_id,
|
||||||
|
)
|
||||||
|
.order_by(WorkoutLog.set_number)
|
||||||
|
).all()
|
||||||
|
for i, remaining_log in enumerate(remaining, start=1):
|
||||||
|
if remaining_log.set_number != i:
|
||||||
|
remaining_log.set_number = i
|
||||||
|
self._session.add(remaining_log)
|
||||||
|
if remaining:
|
||||||
|
self._session.commit()
|
||||||
|
|
||||||
|
# Clean up orphaned session if no logs remain for ANY exercise
|
||||||
|
any_remaining = self._session.exec(
|
||||||
|
select(WorkoutLog).where(WorkoutLog.session_id == session_id)
|
||||||
|
).first()
|
||||||
|
if any_remaining is None:
|
||||||
|
ws = self._session.get(WorkoutSession, session_id)
|
||||||
|
if ws:
|
||||||
|
self._session.delete(ws)
|
||||||
|
self._session.commit()
|
||||||
|
logger.info("session_deleted_empty", session_id=session_id)
|
||||||
|
|
||||||
|
def get_latest_logs_for_exercise(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
exercise_id: int,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[WorkoutLog]:
|
||||||
|
"""Get the most recent log entries for an exercise across sessions.
|
||||||
|
|
||||||
|
Used by the progression engine (Phase 5) to determine
|
||||||
|
what the user last did for this exercise.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user's ID.
|
||||||
|
exercise_id: The exercise ID.
|
||||||
|
limit: Maximum number of logs to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of recent WorkoutLog records, newest first.
|
||||||
|
"""
|
||||||
|
statement = (
|
||||||
|
select(WorkoutLog)
|
||||||
|
.join(WorkoutSession, WorkoutLog.session_id == WorkoutSession.id)
|
||||||
|
.where(
|
||||||
|
WorkoutSession.user_id == user_id,
|
||||||
|
WorkoutLog.exercise_id == exercise_id,
|
||||||
|
)
|
||||||
|
.order_by(WorkoutSession.date.desc(), WorkoutLog.set_number)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(self._session.exec(statement).all())
|
||||||
271
app/services/progression_service.py
Normal file
271
app/services/progression_service.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"""Auto-progression engine for workout programming.
|
||||||
|
|
||||||
|
Analyzes workout log history and applies the progression model:
|
||||||
|
- +1-2 reps/week until wk4 rep target
|
||||||
|
- +5 lbs every 2 weeks once at rep target
|
||||||
|
- Deload at week 5 (-20% weight, reset to wk1 reps)
|
||||||
|
- Accelerated weight increase when all sets felt easy
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.progress_log import ProgressLog
|
||||||
|
from app.models.user_exercise_program import UserExerciseProgram
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_weight(weight_str: str) -> Optional[float]:
|
||||||
|
"""Extract numeric weight from a string like '30 lbs' or 'BW'.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weight_str: Weight as a string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Numeric weight in lbs, or None for bodyweight.
|
||||||
|
"""
|
||||||
|
if not weight_str or weight_str.upper() == "BW":
|
||||||
|
return None
|
||||||
|
match = re.search(r"(\d+(?:\.\d+)?)", weight_str)
|
||||||
|
return float(match.group(1)) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_weight(weight_lbs: Optional[float]) -> str:
|
||||||
|
"""Format a numeric weight back to a display string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weight_lbs: Weight in lbs, or None for bodyweight.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string like '35 lbs' or 'BW'.
|
||||||
|
"""
|
||||||
|
if weight_lbs is None:
|
||||||
|
return "BW"
|
||||||
|
if weight_lbs == int(weight_lbs):
|
||||||
|
return f"{int(weight_lbs)} lbs"
|
||||||
|
return f"{weight_lbs:.1f} lbs"
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressionService:
|
||||||
|
"""Implements the auto-progression engine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active SQLModel Session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def _get_program(
|
||||||
|
self, user_id: int, exercise_id: int,
|
||||||
|
) -> Optional[UserExerciseProgram]:
|
||||||
|
"""Look up the user's program for a specific exercise."""
|
||||||
|
statement = select(UserExerciseProgram).where(
|
||||||
|
UserExerciseProgram.user_id == user_id,
|
||||||
|
UserExerciseProgram.exercise_id == exercise_id,
|
||||||
|
)
|
||||||
|
return self._session.exec(statement).first()
|
||||||
|
|
||||||
|
def _get_recent_sessions(
|
||||||
|
self, user_id: int, exercise_id: int, limit: int = 5,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get recent session summaries for an exercise.
|
||||||
|
|
||||||
|
Returns a list of dicts with: date, avg_reps, weight, all_felt_easy.
|
||||||
|
"""
|
||||||
|
statement = (
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
.order_by(WorkoutSession.date.desc())
|
||||||
|
.limit(limit * 2)
|
||||||
|
)
|
||||||
|
sessions = self._session.exec(statement).all()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for ws in sessions:
|
||||||
|
logs = self._session.exec(
|
||||||
|
select(WorkoutLog).where(
|
||||||
|
WorkoutLog.session_id == ws.id,
|
||||||
|
WorkoutLog.exercise_id == exercise_id,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
avg_reps = sum(log.reps_completed for log in logs) / len(logs)
|
||||||
|
weight = logs[0].weight_used
|
||||||
|
all_felt_easy = all(log.felt_easy for log in logs)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"date": ws.date,
|
||||||
|
"avg_reps": avg_reps,
|
||||||
|
"weight": weight,
|
||||||
|
"all_felt_easy": all_felt_easy,
|
||||||
|
"set_count": len(logs),
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_suggestion(
|
||||||
|
self, user_id: int, exercise_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Generate a progression suggestion for the next workout.
|
||||||
|
|
||||||
|
Analyzes recent log history against the user's program targets
|
||||||
|
and applies progression rules.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: suggested_reps, suggested_weight,
|
||||||
|
progression_type, message.
|
||||||
|
"""
|
||||||
|
program = self._get_program(user_id, exercise_id)
|
||||||
|
|
||||||
|
if program is None:
|
||||||
|
return {
|
||||||
|
"suggested_reps": 0,
|
||||||
|
"suggested_weight": "",
|
||||||
|
"progression_type": "no_program",
|
||||||
|
"message": "No program found for this exercise.",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
wk1_reps = int(program.wk1_reps)
|
||||||
|
wk4_reps = int(program.wk4_reps)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
wk1_reps = 0
|
||||||
|
wk4_reps = 0
|
||||||
|
|
||||||
|
wk1_weight = program.wk1_weight
|
||||||
|
|
||||||
|
recent = self._get_recent_sessions(user_id, exercise_id, limit=5)
|
||||||
|
|
||||||
|
if not recent:
|
||||||
|
return {
|
||||||
|
"suggested_reps": wk1_reps,
|
||||||
|
"suggested_weight": wk1_weight,
|
||||||
|
"progression_type": "baseline",
|
||||||
|
"message": f"Start with {wk1_reps} reps @ {wk1_weight}.",
|
||||||
|
}
|
||||||
|
|
||||||
|
latest = recent[0]
|
||||||
|
current_reps = int(round(latest["avg_reps"]))
|
||||||
|
current_weight = latest["weight"]
|
||||||
|
current_weight_num = _parse_weight(current_weight)
|
||||||
|
consecutive_sessions = len(recent)
|
||||||
|
|
||||||
|
# Rule: Deload at week 5 (4 consecutive sessions completed)
|
||||||
|
if consecutive_sessions >= 4:
|
||||||
|
if current_weight_num is not None:
|
||||||
|
deload_weight = current_weight_num * 0.8
|
||||||
|
return {
|
||||||
|
"suggested_reps": wk1_reps,
|
||||||
|
"suggested_weight": _format_weight(deload_weight),
|
||||||
|
"progression_type": "deload",
|
||||||
|
"message": (
|
||||||
|
f"Deload week: {wk1_reps} reps @ "
|
||||||
|
f"{_format_weight(deload_weight)} (-20%)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"suggested_reps": wk1_reps,
|
||||||
|
"suggested_weight": current_weight,
|
||||||
|
"progression_type": "deload",
|
||||||
|
"message": f"Deload week: reset to {wk1_reps} reps.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rule: Weight increase if at rep target and felt easy
|
||||||
|
if current_reps >= wk4_reps and latest["all_felt_easy"]:
|
||||||
|
if current_weight_num is not None:
|
||||||
|
new_weight = current_weight_num + 5
|
||||||
|
return {
|
||||||
|
"suggested_reps": wk1_reps,
|
||||||
|
"suggested_weight": _format_weight(new_weight),
|
||||||
|
"progression_type": "weight_increase",
|
||||||
|
"message": (
|
||||||
|
f"Weight up: {wk1_reps} reps @ "
|
||||||
|
f"{_format_weight(new_weight)} (+5 lbs)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rule: Weight increase after 2 weeks at rep target
|
||||||
|
if (
|
||||||
|
current_reps >= wk4_reps
|
||||||
|
and len(recent) >= 2
|
||||||
|
and int(round(recent[1]["avg_reps"])) >= wk4_reps
|
||||||
|
):
|
||||||
|
if current_weight_num is not None:
|
||||||
|
new_weight = current_weight_num + 5
|
||||||
|
return {
|
||||||
|
"suggested_reps": wk1_reps,
|
||||||
|
"suggested_weight": _format_weight(new_weight),
|
||||||
|
"progression_type": "weight_increase",
|
||||||
|
"message": (
|
||||||
|
f"2 weeks at target: {wk1_reps} reps @ "
|
||||||
|
f"{_format_weight(new_weight)} (+5 lbs)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rule: Rep increase (+1-2 reps)
|
||||||
|
if current_reps < wk4_reps:
|
||||||
|
increment = 2 if latest["all_felt_easy"] else 1
|
||||||
|
new_reps = min(current_reps + increment, wk4_reps)
|
||||||
|
return {
|
||||||
|
"suggested_reps": new_reps,
|
||||||
|
"suggested_weight": current_weight,
|
||||||
|
"progression_type": "reps_increase",
|
||||||
|
"message": (
|
||||||
|
f"Reps up: {new_reps} reps @ {current_weight} "
|
||||||
|
f"(+{increment})."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hold: at target, waiting for biweekly weight increase
|
||||||
|
return {
|
||||||
|
"suggested_reps": current_reps,
|
||||||
|
"suggested_weight": current_weight,
|
||||||
|
"progression_type": "hold",
|
||||||
|
"message": f"Hold at {current_reps} reps @ {current_weight}.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def record_progression(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
exercise_id: int,
|
||||||
|
suggested_reps: int,
|
||||||
|
suggested_weight: str,
|
||||||
|
actual_reps: int,
|
||||||
|
actual_weight: str,
|
||||||
|
progression_type: str,
|
||||||
|
) -> ProgressLog:
|
||||||
|
"""Record a progression entry in the progress_log table."""
|
||||||
|
progress_log = ProgressLog(
|
||||||
|
user_id=user_id,
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
date=date.today(),
|
||||||
|
suggested_reps=suggested_reps,
|
||||||
|
suggested_weight=suggested_weight,
|
||||||
|
actual_reps=actual_reps,
|
||||||
|
actual_weight=actual_weight,
|
||||||
|
progression_applied=progression_type,
|
||||||
|
)
|
||||||
|
self._session.add(progress_log)
|
||||||
|
self._session.commit()
|
||||||
|
self._session.refresh(progress_log)
|
||||||
|
logger.info(
|
||||||
|
"progression_recorded",
|
||||||
|
user_id=user_id,
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
type=progression_type,
|
||||||
|
)
|
||||||
|
return progress_log
|
||||||
131
app/services/workout_session_service.py
Normal file
131
app/services/workout_session_service.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Service layer for workout session management.
|
||||||
|
|
||||||
|
Handles creation, retrieval, and updates for workout sessions.
|
||||||
|
A session represents a single workout on a specific date for a user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkoutSessionService:
|
||||||
|
"""Handles CRUD operations for WorkoutSession records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active SQLModel Session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def get_or_create_session(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
workout_day_id: int,
|
||||||
|
session_date: date,
|
||||||
|
) -> WorkoutSession:
|
||||||
|
"""Get an existing session or create a new one.
|
||||||
|
|
||||||
|
If a session already exists for this user + day + date combo,
|
||||||
|
return it. Otherwise, create a new one. This allows logging
|
||||||
|
to start automatically without an explicit "start session" step.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user's ID.
|
||||||
|
workout_day_id: The workout day's ID.
|
||||||
|
session_date: The date of the workout.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The existing or newly created WorkoutSession.
|
||||||
|
"""
|
||||||
|
statement = select(WorkoutSession).where(
|
||||||
|
WorkoutSession.user_id == user_id,
|
||||||
|
WorkoutSession.workout_day_id == workout_day_id,
|
||||||
|
WorkoutSession.date == session_date,
|
||||||
|
)
|
||||||
|
existing = self._session.exec(statement).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
ws = WorkoutSession(
|
||||||
|
user_id=user_id,
|
||||||
|
workout_day_id=workout_day_id,
|
||||||
|
date=session_date,
|
||||||
|
)
|
||||||
|
self._session.add(ws)
|
||||||
|
self._session.commit()
|
||||||
|
self._session.refresh(ws)
|
||||||
|
logger.info(
|
||||||
|
"workout_session_created",
|
||||||
|
user_id=user_id,
|
||||||
|
day_id=workout_day_id,
|
||||||
|
date=str(session_date),
|
||||||
|
)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
def list_sessions(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[WorkoutSession]:
|
||||||
|
"""List workout sessions for a user, most recent first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user's ID.
|
||||||
|
limit: Maximum number of sessions to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WorkoutSession records, ordered by date descending.
|
||||||
|
"""
|
||||||
|
statement = (
|
||||||
|
select(WorkoutSession)
|
||||||
|
.where(WorkoutSession.user_id == user_id)
|
||||||
|
.order_by(WorkoutSession.date.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(self._session.exec(statement).all())
|
||||||
|
|
||||||
|
def get_session_by_id(self, session_id: int) -> Optional[WorkoutSession]:
|
||||||
|
"""Retrieve a workout session by primary key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The session ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The WorkoutSession record, or None if not found.
|
||||||
|
"""
|
||||||
|
return self._session.get(WorkoutSession, session_id)
|
||||||
|
|
||||||
|
def update_session(self, session_id: int, **kwargs) -> WorkoutSession:
|
||||||
|
"""Update fields on an existing workout session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The session ID.
|
||||||
|
**kwargs: Field names and new values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated WorkoutSession record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the session is not found.
|
||||||
|
"""
|
||||||
|
ws = self.get_session_by_id(session_id)
|
||||||
|
if ws is None:
|
||||||
|
raise ValueError(f"WorkoutSession with id {session_id} not found")
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(ws, key):
|
||||||
|
setattr(ws, key, value)
|
||||||
|
|
||||||
|
self._session.add(ws)
|
||||||
|
self._session.commit()
|
||||||
|
self._session.refresh(ws)
|
||||||
|
return ws
|
||||||
@@ -27,14 +27,16 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<ul>
|
<ul>
|
||||||
{% block nav_items %}
|
{% block nav_items %}
|
||||||
<!-- Phase 3 adds: profile switcher, login/logout -->
|
{% include "partials/nav.html" ignore missing %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{% block flash %}{% endblock %}
|
{% block flash %}
|
||||||
|
{% include "partials/flash_message.html" ignore missing %}
|
||||||
|
{% endblock %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
46
app/templates/pages/dashboard.html
Normal file
46
app/templates/pages/dashboard.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard -- SneakySwole{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
<h1>Progress Dashboard</h1>
|
||||||
|
{% if active_profile %}
|
||||||
|
<p>{{ active_profile.display_name }}'s training overview</p>
|
||||||
|
{% else %}
|
||||||
|
<p>No profile selected -- <a href="/profiles">select one</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
{% if stats %}
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="grid">
|
||||||
|
{% include "partials/stats_card.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Volume by Day Chart -->
|
||||||
|
<article>
|
||||||
|
<header><h3>Volume by Workout Day</h3></header>
|
||||||
|
{% include "partials/volume_chart.html" %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Exercise Progress Links -->
|
||||||
|
<article>
|
||||||
|
<header><h3>Per-Exercise Progress</h3></header>
|
||||||
|
<ul>
|
||||||
|
{% for exercise in exercises %}
|
||||||
|
<li>
|
||||||
|
<a href="/dashboard/exercise/{{ exercise.id }}">
|
||||||
|
{{ exercise.name }}
|
||||||
|
</a>
|
||||||
|
<small> -- {{ exercise.workout_day }} Day</small>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
38
app/templates/pages/exercise_browser.html
Normal file
38
app/templates/pages/exercise_browser.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Exercise Library — SneakySwole{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
<h1>Exercise Library</h1>
|
||||||
|
<p>Browse all exercises with form cues and programming details.</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<!-- HTMX-powered filters -->
|
||||||
|
<div class="grid">
|
||||||
|
<select name="workout_day"
|
||||||
|
hx-get="/exercises/search"
|
||||||
|
hx-target="#exercise-results"
|
||||||
|
hx-include="[name='muscle_group']">
|
||||||
|
<option value="">All Days</option>
|
||||||
|
{% for day in workout_days %}
|
||||||
|
<option value="{{ day.name }}">{{ day.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select name="muscle_group"
|
||||||
|
hx-get="/exercises/search"
|
||||||
|
hx-target="#exercise-results"
|
||||||
|
hx-include="[name='workout_day']">
|
||||||
|
<option value="">All Muscle Groups</option>
|
||||||
|
{% for mg in muscle_groups %}
|
||||||
|
<option value="{{ mg }}">{{ mg }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Exercise results (replaced by HTMX on filter change) -->
|
||||||
|
<div id="exercise-results">
|
||||||
|
{% include "partials/exercise_list.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
48
app/templates/pages/exercise_progress.html
Normal file
48
app/templates/pages/exercise_progress.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ exercise.name }} Progress -- SneakySwole{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
<h1>{{ exercise.name }}</h1>
|
||||||
|
<p>{{ exercise.muscle_group }} | {{ exercise.workout_day }} Day</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<!-- Progression Suggestion -->
|
||||||
|
{% if suggestion %}
|
||||||
|
<article>
|
||||||
|
<header><h3>Next Workout Suggestion</h3></header>
|
||||||
|
<p>{{ suggestion.message }}</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<small>Suggested Reps</small>
|
||||||
|
<p style="font-size:1.5rem; font-weight:700;">
|
||||||
|
{{ suggestion.suggested_reps }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small>Suggested Weight</small>
|
||||||
|
<p style="font-size:1.5rem; font-weight:700;">
|
||||||
|
{{ suggestion.suggested_weight }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small>Progression Type</small>
|
||||||
|
<p><mark>{{ suggestion.progression_type }}</mark></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Progress Chart -->
|
||||||
|
<article>
|
||||||
|
<header><h3>Rep and Weight Trends</h3></header>
|
||||||
|
{% include "partials/progress_chart.html" %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<a href="/dashboard" role="button" class="outline">Back to Dashboard</a>
|
||||||
|
{% endblock %}
|
||||||
23
app/templates/pages/log_history.html
Normal file
23
app/templates/pages/log_history.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Workout History -- SneakySwole{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
<h1>Workout History</h1>
|
||||||
|
{% if active_profile %}
|
||||||
|
<p>History for: <strong>{{ active_profile.display_name }}</strong></p>
|
||||||
|
{% else %}
|
||||||
|
<p>No profile selected -- <a href="/profiles">select one</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
{% if sessions %}
|
||||||
|
{% for ws in sessions %}
|
||||||
|
{% set day = days_by_id.get(ws.workout_day_id) %}
|
||||||
|
{% include "partials/session_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p>No workout sessions recorded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
27
app/templates/pages/login.html
Normal file
27
app/templates/pages/login.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}SneakySwole — Login{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h2>Admin Login</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="flash-error" role="alert">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username"
|
||||||
|
placeholder="Enter username" required autofocus>
|
||||||
|
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password"
|
||||||
|
placeholder="Enter password" required>
|
||||||
|
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
34
app/templates/pages/profile_edit.html
Normal file
34
app/templates/pages/profile_edit.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Edit Profile — {{ profile.display_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h2>Edit Profile: {{ profile.display_name }}</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form method="POST" action="/profiles/{{ profile.id }}/edit">
|
||||||
|
<label for="display_name">Display Name</label>
|
||||||
|
<input type="text" id="display_name" name="display_name"
|
||||||
|
value="{{ profile.display_name }}" required>
|
||||||
|
|
||||||
|
<label for="height">Height</label>
|
||||||
|
<input type="text" id="height" name="height"
|
||||||
|
value="{{ profile.height or '' }}" placeholder="e.g., 6'0"">
|
||||||
|
|
||||||
|
<label for="weight">Weight</label>
|
||||||
|
<input type="text" id="weight" name="weight"
|
||||||
|
value="{{ profile.weight or '' }}" placeholder="e.g., 260 lbs">
|
||||||
|
|
||||||
|
<label for="goals">Goals</label>
|
||||||
|
<textarea id="goals" name="goals"
|
||||||
|
placeholder="Training goals...">{{ profile.goals or '' }}</textarea>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<a href="/profiles" role="button" class="outline secondary">Cancel</a>
|
||||||
|
<button type="submit">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
16
app/templates/pages/profiles.html
Normal file
16
app/templates/pages/profiles.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}User Profiles — SneakySwole{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
<h1>User Profiles</h1>
|
||||||
|
<p>Manage training profiles.</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
{% for profile in profiles %}
|
||||||
|
{% include "partials/profile_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
50
app/templates/pages/schedule.html
Normal file
50
app/templates/pages/schedule.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}4-Week Schedule -- SneakySwole{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
<h1>4-Week Schedule</h1>
|
||||||
|
{% if active_profile %}
|
||||||
|
<p>Schedule for: <strong>{{ active_profile.display_name }}</strong></p>
|
||||||
|
{% else %}
|
||||||
|
<p>No profile selected -- <a href="/profiles">select one</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
{% for week in weeks %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h3>Week {{ week.week_number }}</h3>
|
||||||
|
</header>
|
||||||
|
<div class="grid">
|
||||||
|
{% for day in week.days %}
|
||||||
|
<div style="text-align:center;
|
||||||
|
padding:1rem;
|
||||||
|
border-radius:0.5rem;
|
||||||
|
{% if day.is_today %}
|
||||||
|
border: 2px solid var(--pico-primary);
|
||||||
|
{% endif %}
|
||||||
|
{% if day.is_completed %}
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
{% endif %}">
|
||||||
|
<strong>{{ day.workout_day.name }}</strong>
|
||||||
|
<br>
|
||||||
|
<small>{{ day.date.strftime('%b %d') }}</small>
|
||||||
|
{% if day.is_completed %}
|
||||||
|
<br><mark>Done</mark>
|
||||||
|
{% endif %}
|
||||||
|
{% if day.is_today %}
|
||||||
|
<br><small><strong>Today</strong></small>
|
||||||
|
{% endif %}
|
||||||
|
<br>
|
||||||
|
<a href="/workouts/{{ day.workout_day.name|lower|replace(' ', '-') }}"
|
||||||
|
style="font-size:0.8rem;">
|
||||||
|
Start
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
51
app/templates/pages/session_detail.html
Normal file
51
app/templates/pages/session_detail.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Session Detail -- SneakySwole{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
{% set day = days_by_id.get(workout_session.workout_day_id) %}
|
||||||
|
<h1>
|
||||||
|
{{ day.name if day else "Workout" }}
|
||||||
|
-- {{ workout_session.date.strftime('%B %d, %Y') }}
|
||||||
|
</h1>
|
||||||
|
{% if workout_session.notes %}
|
||||||
|
<p>{{ workout_session.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
{% for exercise_id, logs in logs_by_exercise.items() %}
|
||||||
|
{% set exercise = exercises_by_id[exercise_id] %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h3>{{ exercise.name }}</h3>
|
||||||
|
</header>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Set</th>
|
||||||
|
<th>Reps</th>
|
||||||
|
<th>Weight</th>
|
||||||
|
<th>Easy?</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log.set_number }}</td>
|
||||||
|
<td>{{ log.reps_completed }}</td>
|
||||||
|
<td>{{ log.weight_used }}</td>
|
||||||
|
<td>{{ "Yes" if log.felt_easy else "No" }}</td>
|
||||||
|
<td>{{ log.notes or "" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<p>No exercises logged in this session.</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<a href="/history" role="button" class="outline">Back to History</a>
|
||||||
|
{% endblock %}
|
||||||
31
app/templates/pages/workout_day.html
Normal file
31
app/templates/pages/workout_day.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ day_name }} Day — SneakySwole{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<hgroup>
|
||||||
|
<h1>{{ day_name }} Day</h1>
|
||||||
|
{% if active_profile %}
|
||||||
|
<p>Training as: <strong>{{ active_profile.display_name }}</strong></p>
|
||||||
|
{% else %}
|
||||||
|
<p>No profile selected — <a href="/profiles">select one</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<!-- Warmup Section -->
|
||||||
|
<section>
|
||||||
|
<h2>Warmup</h2>
|
||||||
|
{% include "partials/warmup_list.html" %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Exercises Section -->
|
||||||
|
<section>
|
||||||
|
<h2>Exercises</h2>
|
||||||
|
{% for exercise in exercises %}
|
||||||
|
{% set program = programs.get(exercise.id) %}
|
||||||
|
{% include "partials/exercise_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<a href="/workouts" role="button" class="outline">Back to All Days</a>
|
||||||
|
{% endblock %}
|
||||||
22
app/templates/pages/workout_days.html
Normal file
22
app/templates/pages/workout_days.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Workout Days — SneakySwole{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Workout Days</h1>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
{% for day in days %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h3>Day {{ day.day_number }}: {{ day.name }}</h3>
|
||||||
|
</header>
|
||||||
|
<p>{{ day.description }}</p>
|
||||||
|
<footer>
|
||||||
|
<a href="/workouts/{{ day.name|lower|replace(' ', '-') }}"
|
||||||
|
role="button">View Workout</a>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
51
app/templates/partials/exercise_card.html
Normal file
51
app/templates/partials/exercise_card.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h3>{{ exercise.name }}</h3>
|
||||||
|
<p>{{ exercise.muscle_group }} | {{ exercise.sets }} sets | Tempo: {{ exercise.tempo }}</p>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if program %}
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<small>Week 1</small>
|
||||||
|
<p>{{ program.wk1_reps }} reps @ {{ program.wk1_weight }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small>Week 4</small>
|
||||||
|
<p>{{ program.wk4_reps }} reps @ {{ program.wk4_weight }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if suggestions and suggestions[exercise.id] %}
|
||||||
|
{% set suggestion = suggestions[exercise.id] %}
|
||||||
|
{% include "partials/progression_badge.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Form Cues</summary>
|
||||||
|
<p>{{ exercise.form_cues }}</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Inline logging (Phase 4) -->
|
||||||
|
{% if active_profile %}
|
||||||
|
<div id="logs-exercise-{{ exercise.id }}">
|
||||||
|
{% if suggestions and suggestions[exercise.id] %}
|
||||||
|
{% set suggested_reps = suggestions[exercise.id].suggested_reps %}
|
||||||
|
{% set suggested_weight = suggestions[exercise.id].suggested_weight %}
|
||||||
|
{% endif %}
|
||||||
|
{% if existing_logs and existing_logs[exercise.id] %}
|
||||||
|
{% set logs = existing_logs[exercise.id] %}
|
||||||
|
{% set exercise_id = exercise.id %}
|
||||||
|
{% set next_set = logs|length + 1 %}
|
||||||
|
{% include "partials/log_entry.html" %}
|
||||||
|
{% else %}
|
||||||
|
{% set exercise_id = exercise.id %}
|
||||||
|
{% set next_set = 1 %}
|
||||||
|
{% include "partials/log_form.html" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
16
app/templates/partials/exercise_list.html
Normal file
16
app/templates/partials/exercise_list.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{% for exercise in exercises %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h3>{{ exercise.name }}</h3>
|
||||||
|
<p>{{ exercise.muscle_group }} | {{ exercise.workout_day }} Day | {{ exercise.sets }} sets | Tempo: {{ exercise.tempo }}</p>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
<details>
|
||||||
|
<summary>Form Cues</summary>
|
||||||
|
<p>{{ exercise.form_cues }}</p>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<p>No exercises found matching your filters.</p>
|
||||||
|
{% endfor %}
|
||||||
6
app/templates/partials/flash_message.html
Normal file
6
app/templates/partials/flash_message.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{% if flash_success %}
|
||||||
|
<div class="flash-success" role="status">{{ flash_success }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if flash_error %}
|
||||||
|
<div class="flash-error" role="alert">{{ flash_error }}</div>
|
||||||
|
{% endif %}
|
||||||
39
app/templates/partials/log_entry.html
Normal file
39
app/templates/partials/log_entry.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!-- Displays logged sets for a single exercise within a session -->
|
||||||
|
{% if logs %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Set</th>
|
||||||
|
<th>Reps</th>
|
||||||
|
<th>Weight</th>
|
||||||
|
<th>Easy?</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log.set_number }}</td>
|
||||||
|
<td>{{ log.reps_completed }}</td>
|
||||||
|
<td>{{ log.weight_used }}</td>
|
||||||
|
<td>{{ "Yes" if log.felt_easy else "No" }}</td>
|
||||||
|
<td>
|
||||||
|
<form hx-post="/log/{{ log.id }}/delete"
|
||||||
|
hx-target="#logs-exercise-{{ exercise_id }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Delete this set?"
|
||||||
|
style="display:inline; margin:0;">
|
||||||
|
<button type="submit" class="outline secondary"
|
||||||
|
style="padding:0.2rem 0.5rem; font-size:0.8rem;">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Next set form -->
|
||||||
|
{% include "partials/log_form.html" %}
|
||||||
26
app/templates/partials/log_form.html
Normal file
26
app/templates/partials/log_form.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!-- Inline logging form, included inside each exercise_card.html -->
|
||||||
|
<form hx-post="/log"
|
||||||
|
hx-target="#logs-exercise-{{ exercise_id }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
style="margin-bottom:0;">
|
||||||
|
<input type="hidden" name="exercise_id" value="{{ exercise_id }}">
|
||||||
|
<input type="hidden" name="workout_day_id" value="{{ workout_day_id }}">
|
||||||
|
<input type="hidden" name="set_number" value="{{ next_set|default(1) }}">
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
|
||||||
|
<small style="white-space:nowrap; opacity:0.7;">Set {{ next_set|default(1) }}</small>
|
||||||
|
<input type="number" name="reps" placeholder="Reps"
|
||||||
|
min="0" max="100" required
|
||||||
|
{% if suggested_reps %}value="{{ suggested_reps }}"{% endif %}
|
||||||
|
style="width:5rem; margin-bottom:0;">
|
||||||
|
<input type="text" name="weight" placeholder="Weight (lbs)"
|
||||||
|
required
|
||||||
|
{% if suggested_weight %}value="{{ suggested_weight }}"{% endif %}
|
||||||
|
style="width:8rem; margin-bottom:0;">
|
||||||
|
<label style="display:flex; align-items:center; gap:0.3rem; margin-bottom:0; white-space:nowrap;">
|
||||||
|
<input type="checkbox" name="felt_easy" role="switch" style="margin-bottom:0;">
|
||||||
|
Easy?
|
||||||
|
</label>
|
||||||
|
<button type="submit" style="margin-bottom:0; width:auto; white-space:nowrap;">Log Set</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
38
app/templates/partials/nav.html
Normal file
38
app/templates/partials/nav.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% set admin = request.state.admin %}
|
||||||
|
{% set profiles = request.state.profiles %}
|
||||||
|
{% set active_profile = request.state.active_profile %}
|
||||||
|
{% if admin %}
|
||||||
|
<li>
|
||||||
|
<details class="dropdown">
|
||||||
|
<summary>
|
||||||
|
{% if active_profile %}
|
||||||
|
{{ active_profile.display_name }}
|
||||||
|
{% else %}
|
||||||
|
Select Profile
|
||||||
|
{% endif %}
|
||||||
|
</summary>
|
||||||
|
<ul dir="rtl">
|
||||||
|
{% for profile in profiles %}
|
||||||
|
<li>
|
||||||
|
<form method="POST" action="/profiles/switch" style="margin:0;">
|
||||||
|
<input type="hidden" name="profile_id" value="{{ profile.id }}">
|
||||||
|
<button type="submit" class="outline secondary"
|
||||||
|
style="width:100%; text-align:left; border:none;">
|
||||||
|
{{ profile.display_name }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
<li><a href="/workouts">Workouts</a></li>
|
||||||
|
<li><a href="/schedule">Schedule</a></li>
|
||||||
|
<li><a href="/dashboard">Dashboard</a></li>
|
||||||
|
<li><a href="/history">History</a></li>
|
||||||
|
<li><a href="/exercises">Exercises</a></li>
|
||||||
|
<li><a href="/profiles">Profiles</a></li>
|
||||||
|
<li><a href="/logout">Logout</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="/login">Login</a></li>
|
||||||
|
{% endif %}
|
||||||
12
app/templates/partials/profile_card.html
Normal file
12
app/templates/partials/profile_card.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h3>{{ profile.display_name }}</h3>
|
||||||
|
<p>{{ profile.height }} | {{ profile.weight }}</p>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
<p>{{ profile.goals or "No goals set" }}</p>
|
||||||
|
<footer>
|
||||||
|
<a href="/profiles/{{ profile.id }}/edit" role="button" class="outline">Edit</a>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
56
app/templates/partials/progress_chart.html
Normal file
56
app/templates/partials/progress_chart.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<canvas id="progress-chart" style="max-height:300px;"></canvas>
|
||||||
|
<p id="progress-chart-empty" style="display:none;">No data yet.</p>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var data = {{ progress_data_json|safe }};
|
||||||
|
|
||||||
|
if (!data.dates || data.dates.length === 0) {
|
||||||
|
document.getElementById('progress-chart').style.display = 'none';
|
||||||
|
document.getElementById('progress-chart-empty').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
new Chart(document.getElementById('progress-chart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.dates,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Avg Reps',
|
||||||
|
data: data.reps,
|
||||||
|
borderColor: 'rgba(99, 102, 241, 1)',
|
||||||
|
backgroundColor: 'rgba(99, 102, 241, 0.2)',
|
||||||
|
yAxisID: 'y-reps',
|
||||||
|
tension: 0.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Weight (lbs)',
|
||||||
|
data: data.weights,
|
||||||
|
borderColor: 'rgba(244, 63, 94, 1)',
|
||||||
|
backgroundColor: 'rgba(244, 63, 94, 0.2)',
|
||||||
|
yAxisID: 'y-weight',
|
||||||
|
tension: 0.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
scales: {
|
||||||
|
'y-reps': {
|
||||||
|
type: 'linear', display: true, position: 'left',
|
||||||
|
title: { display: true, text: 'Reps', color: '#ccc' },
|
||||||
|
ticks: { color: '#ccc' }
|
||||||
|
},
|
||||||
|
'y-weight': {
|
||||||
|
type: 'linear', display: true, position: 'right',
|
||||||
|
title: { display: true, text: 'Weight (lbs)', color: '#ccc' },
|
||||||
|
ticks: { color: '#ccc' },
|
||||||
|
grid: { drawOnChartArea: false }
|
||||||
|
},
|
||||||
|
x: { ticks: { color: '#ccc' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
11
app/templates/partials/progression_badge.html
Normal file
11
app/templates/partials/progression_badge.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% if suggestion and suggestion.progression_type != "no_program" %}
|
||||||
|
<div style="background: rgba(99, 102, 241, 0.1);
|
||||||
|
border-left: 3px solid var(--pico-primary);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 0 0.25rem 0.25rem 0;">
|
||||||
|
<small>
|
||||||
|
<strong>Suggestion:</strong> {{ suggestion.message }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
16
app/templates/partials/session_card.html
Normal file
16
app/templates/partials/session_card.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h3>{{ day.name if day else "Unknown" }} Day</h3>
|
||||||
|
<p>{{ ws.date.strftime('%A, %B %d, %Y') }}</p>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
{% if ws.notes %}
|
||||||
|
<p>{{ ws.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<footer>
|
||||||
|
<a href="/history/{{ ws.id }}" role="button" class="outline">
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
24
app/templates/partials/stats_card.html
Normal file
24
app/templates/partials/stats_card.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<article>
|
||||||
|
<header><h4>Sessions</h4></header>
|
||||||
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
|
{{ stats.total_sessions }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<header><h4>Total Volume</h4></header>
|
||||||
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
|
{{ "{:,}".format(stats.total_volume) }} lbs
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<header><h4>Total Sets</h4></header>
|
||||||
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
|
{{ stats.total_sets }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<header><h4>Streak</h4></header>
|
||||||
|
<p style="font-size:2rem; font-weight:700;">
|
||||||
|
{{ stats.current_streak }} week{{ "s" if stats.current_streak != 1 }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
30
app/templates/partials/volume_chart.html
Normal file
30
app/templates/partials/volume_chart.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<canvas id="volume-chart" style="max-height:300px;"></canvas>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var data = {{ volume_data_json|safe }};
|
||||||
|
var labels = Object.keys(data);
|
||||||
|
var values = Object.values(data);
|
||||||
|
|
||||||
|
new Chart(document.getElementById('volume-chart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Total Volume (lbs)',
|
||||||
|
data: values,
|
||||||
|
backgroundColor: 'rgba(99, 102, 241, 0.7)',
|
||||||
|
borderColor: 'rgba(99, 102, 241, 1)',
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true, ticks: { color: '#ccc' } },
|
||||||
|
x: { ticks: { color: '#ccc' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
23
app/templates/partials/warmup_list.html
Normal file
23
app/templates/partials/warmup_list.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Exercise</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Reps</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for warmup in warmups %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<details>
|
||||||
|
<summary>{{ warmup.name }}</summary>
|
||||||
|
<p>{{ warmup.form_cues }}</p>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
<td>{{ warmup.type }}</td>
|
||||||
|
<td>{{ warmup.reps }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
87
app/utils/auth.py
Normal file
87
app/utils/auth.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Authentication dependencies for FastAPI route protection.
|
||||||
|
|
||||||
|
Provides dependency functions that verify the admin session cookie
|
||||||
|
and return the authenticated User, or redirect to /login.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.database import get_db_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NotAuthenticatedError(Exception):
|
||||||
|
"""Raised when a request lacks valid authentication."""
|
||||||
|
|
||||||
|
# Cookie name for the admin session
|
||||||
|
SESSION_COOKIE_NAME = "session"
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_admin_user(request: Request, session: Session = Depends(get_db_session)) -> User:
|
||||||
|
"""FastAPI dependency that extracts and validates the admin session.
|
||||||
|
|
||||||
|
Reads the session cookie, validates the token, and returns the
|
||||||
|
authenticated admin User. Redirects to /login (303) if not authenticated.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
session: Database session (injected by FastAPI).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The authenticated admin User.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RedirectResponse: 303 redirect to /login if no valid admin session.
|
||||||
|
"""
|
||||||
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
raise _login_redirect()
|
||||||
|
|
||||||
|
secret_key = getattr(request.app.state, "secret_key", "")
|
||||||
|
auth_service = AuthService(session, secret_key=secret_key)
|
||||||
|
user_id = auth_service.validate_session_token(token)
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
raise _login_redirect()
|
||||||
|
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None or not user.is_admin:
|
||||||
|
raise _login_redirect()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_profile_id(request: Request) -> Optional[int]:
|
||||||
|
"""Extract the active profile ID from the session cookie.
|
||||||
|
|
||||||
|
The admin selects which user profile to view/log as. This is stored
|
||||||
|
in a separate cookie called 'active_profile_id'.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The active profile user ID, or None if not set.
|
||||||
|
"""
|
||||||
|
profile_id = request.cookies.get("active_profile_id")
|
||||||
|
if profile_id and profile_id.isdigit():
|
||||||
|
return int(profile_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _login_redirect():
|
||||||
|
"""Create a redirect exception to the login page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A NotAuthenticatedError handled by a registered exception handler
|
||||||
|
in main.py that sends a 302 redirect to /login.
|
||||||
|
"""
|
||||||
|
return NotAuthenticatedError()
|
||||||
29
docker-compose.dev.yaml
Normal file
29
docker-compose.dev.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: sneakyswole-dev
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-8000}:8000"
|
||||||
|
volumes:
|
||||||
|
- sneakyswole-data:/app/data
|
||||||
|
- ./app:/app/app
|
||||||
|
- ./config:/app/config
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=development
|
||||||
|
- APP_LOG_LEVEL=debug
|
||||||
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sneakyswole-data:
|
||||||
|
driver: local
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
image: git.sneakygeek.net/sneakygeek/sneakyswole:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: sneakyswole
|
container_name: sneakyswole
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "${APP_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- sneakyswole-data:/app/data
|
- sneakyswole-data:/app/data
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
278
docs/API_REFERENCE.md
Normal file
278
docs/API_REFERENCE.md
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
All endpoints return HTML (full pages or HTMX partials) unless noted otherwise. Protected routes require a valid admin session cookie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### `GET /login`
|
||||||
|
|
||||||
|
Render the login form.
|
||||||
|
|
||||||
|
- **Auth:** None
|
||||||
|
- **Response:** Full page (`pages/login.html`)
|
||||||
|
|
||||||
|
### `POST /login`
|
||||||
|
|
||||||
|
Authenticate admin credentials and start a session.
|
||||||
|
|
||||||
|
- **Auth:** None
|
||||||
|
- **Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
- **Form fields:**
|
||||||
|
- `username` (string) — admin username
|
||||||
|
- `password` (string) — admin password
|
||||||
|
- **Success:** 303 redirect to `/`, sets `session` cookie (httponly, samesite=lax, 24h TTL)
|
||||||
|
- **Failure:** 200 with login page re-rendered and error message
|
||||||
|
|
||||||
|
### `GET /logout`
|
||||||
|
|
||||||
|
End the admin session.
|
||||||
|
|
||||||
|
- **Auth:** None (clears cookies regardless)
|
||||||
|
- **Response:** 303 redirect to `/login`, deletes `session` and `active_profile_id` cookies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Application health check for monitoring and readiness probes.
|
||||||
|
|
||||||
|
- **Auth:** None
|
||||||
|
- **Response:** JSON
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "sneakyswole",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
### `GET /`
|
||||||
|
|
||||||
|
Home page.
|
||||||
|
|
||||||
|
- **Auth:** None
|
||||||
|
- **Response:** Full page (`pages/home.html`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
All profile routes require admin authentication.
|
||||||
|
|
||||||
|
### `GET /profiles`
|
||||||
|
|
||||||
|
List all user profiles (excludes admin).
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Response:** Full page (`pages/profiles.html`)
|
||||||
|
- **Template context:** `profiles`, `active_profile_id`, `admin`
|
||||||
|
|
||||||
|
### `POST /profiles/switch`
|
||||||
|
|
||||||
|
Switch the active user profile.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
- **Form fields:**
|
||||||
|
- `profile_id` (string) — ID of the profile to activate
|
||||||
|
- **Response:** 303 redirect to referring page, sets `active_profile_id` cookie
|
||||||
|
|
||||||
|
### `GET /profiles/{profile_id}/edit`
|
||||||
|
|
||||||
|
Render the profile edit form.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Path params:** `profile_id` (int)
|
||||||
|
- **Response:** Full page (`pages/profile_edit.html`)
|
||||||
|
|
||||||
|
### `POST /profiles/{profile_id}/edit`
|
||||||
|
|
||||||
|
Update a user profile.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Path params:** `profile_id` (int)
|
||||||
|
- **Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
- **Form fields:**
|
||||||
|
- `display_name` (string)
|
||||||
|
- `height` (string)
|
||||||
|
- `weight` (string)
|
||||||
|
- `goals` (string)
|
||||||
|
- **Response:** 303 redirect to `/profiles`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workouts
|
||||||
|
|
||||||
|
All workout routes require admin authentication.
|
||||||
|
|
||||||
|
### `GET /workouts`
|
||||||
|
|
||||||
|
List all workout days as clickable cards.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Response:** Full page (`pages/workout_days.html`)
|
||||||
|
- **Template context:** `days`, `admin`
|
||||||
|
|
||||||
|
### `GET /workouts/{day_name}`
|
||||||
|
|
||||||
|
Display a full workout day with warmups, exercises, programming targets, and inline logging.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Path params:** `day_name` (string) — URL-friendly name, e.g., "push", "pull", "lower", "full-body"
|
||||||
|
- **Response:** Full page (`pages/workout_day.html`)
|
||||||
|
- **Template context:** `day_name`, `warmups`, `exercises`, `programs`, `active_profile`, `existing_logs`, `suggestions`, `workout_day_id`, `admin`
|
||||||
|
- **Notes:** Day name is normalized (e.g., "full-body" → "Full Body"). If an active profile is set, includes programming targets, progression suggestions, and today's existing logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercises
|
||||||
|
|
||||||
|
All exercise routes require admin authentication.
|
||||||
|
|
||||||
|
### `GET /exercises`
|
||||||
|
|
||||||
|
Render the exercise browser with filter dropdowns.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Response:** Full page (`pages/exercise_browser.html`)
|
||||||
|
- **Template context:** `exercises`, `workout_days`, `muscle_groups`, `admin`
|
||||||
|
|
||||||
|
### `GET /exercises/search`
|
||||||
|
|
||||||
|
HTMX partial — return filtered exercise list.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Query params:**
|
||||||
|
- `workout_day` (string, optional) — filter by workout day name
|
||||||
|
- `muscle_group` (string, optional) — filter by muscle group
|
||||||
|
- **Response:** HTMX partial (`partials/exercise_list.html`)
|
||||||
|
- **Usage:** Called via `hx-get` from exercise browser filter dropdowns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workout Logging
|
||||||
|
|
||||||
|
All logging routes require admin authentication. Responses are HTMX partials that update inline.
|
||||||
|
|
||||||
|
### `POST /log`
|
||||||
|
|
||||||
|
Log a single set for an exercise. Auto-creates today's workout session if needed.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
- **Form fields:**
|
||||||
|
- `exercise_id` (int)
|
||||||
|
- `workout_day_id` (int)
|
||||||
|
- `set_number` (int, default=1)
|
||||||
|
- `reps` (int)
|
||||||
|
- `weight` (string) — e.g., "30 lbs", "BW"
|
||||||
|
- `felt_easy` (checkbox, "on" = true)
|
||||||
|
- **Response:** HTMX partial (`partials/log_entry.html`) with updated logs for this exercise
|
||||||
|
- **Error:** If no active profile selected, returns `partials/flash_message.html` with error
|
||||||
|
|
||||||
|
### `POST /log/{log_id}/edit`
|
||||||
|
|
||||||
|
Edit an existing log entry.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Path params:** `log_id` (int)
|
||||||
|
- **Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
- **Form fields:**
|
||||||
|
- `reps` (int)
|
||||||
|
- `weight` (string)
|
||||||
|
- `felt_easy` (checkbox)
|
||||||
|
- `notes` (string, optional)
|
||||||
|
- **Response:** HTMX partial (`partials/log_entry.html`) with updated logs
|
||||||
|
|
||||||
|
### `POST /log/{log_id}/delete`
|
||||||
|
|
||||||
|
Delete a log entry.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Path params:** `log_id` (int)
|
||||||
|
- **Response:** HTMX partial (`partials/log_entry.html`) with remaining logs, or empty HTML if log not found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
All history routes require admin authentication.
|
||||||
|
|
||||||
|
### `GET /history`
|
||||||
|
|
||||||
|
Display log history for the active profile — list of past sessions, most recent first.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Response:** Full page (`pages/log_history.html`)
|
||||||
|
- **Template context:** `sessions`, `days_by_id`, `active_profile`, `admin`
|
||||||
|
- **Notes:** Requires active profile to show sessions
|
||||||
|
|
||||||
|
### `GET /history/{session_id}`
|
||||||
|
|
||||||
|
Display detailed logs for a specific workout session, grouped by exercise.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Path params:** `session_id` (int)
|
||||||
|
- **Response:** Full page (`pages/session_detail.html`)
|
||||||
|
- **Template context:** `workout_session`, `logs_by_exercise`, `exercises_by_id`, `days_by_id`, `admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
All dashboard routes require admin authentication.
|
||||||
|
|
||||||
|
### `GET /dashboard`
|
||||||
|
|
||||||
|
Render the progress dashboard with summary stats, volume chart, and exercise links.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Response:** Full page (`pages/dashboard.html`)
|
||||||
|
- **Template context:** `stats`, `volume_data_json`, `exercises`, `active_profile`, `admin`
|
||||||
|
- **Notes:** Stats and volume data require an active profile. Chart.js renders client-side charts from JSON embedded in the template.
|
||||||
|
|
||||||
|
### `GET /dashboard/exercise/{exercise_id}`
|
||||||
|
|
||||||
|
Per-exercise progress page with charts and progression suggestions.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Path params:** `exercise_id` (int)
|
||||||
|
- **Response:** Full page (`pages/exercise_progress.html`)
|
||||||
|
- **Template context:** `exercise`, `progress_data_json`, `suggestion`, `active_profile`, `admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schedule
|
||||||
|
|
||||||
|
### `GET /schedule`
|
||||||
|
|
||||||
|
4-week calendar view showing which workout day maps to which date.
|
||||||
|
|
||||||
|
- **Auth:** Admin session
|
||||||
|
- **Response:** Full page (`pages/schedule.html`)
|
||||||
|
- **Template context:** `weeks`, `active_profile`, `admin`
|
||||||
|
- **Notes:** Calendar starts from Monday of the current week. Days with completed sessions are highlighted. Requires active profile for session completion data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication Details
|
||||||
|
|
||||||
|
### Cookies
|
||||||
|
|
||||||
|
| Cookie | Purpose | Flags | TTL |
|
||||||
|
|--------|---------|-------|-----|
|
||||||
|
| `session` | Admin session token (itsdangerous signed) | httponly, samesite=lax | 24 hours |
|
||||||
|
| `active_profile_id` | Currently selected user profile ID | httponly, samesite=lax | 24 hours |
|
||||||
|
|
||||||
|
### Auth Failure Behavior
|
||||||
|
|
||||||
|
- Missing or invalid session cookie → 302 redirect to `/login`
|
||||||
|
- Handled via `NotAuthenticatedError` exception + registered handler in `app/main.py`
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Documentation Folder
|
# Documentation
|
||||||
|
|
||||||
This folder contains business planning, architecture decisions, and documentation.
|
This folder contains architecture, API reference, and development documentation.
|
||||||
|
|
||||||
**No application code belongs here.**
|
**No application code belongs here.**
|
||||||
|
|
||||||
@@ -8,11 +8,12 @@ This folder contains business planning, architecture decisions, and documentatio
|
|||||||
|
|
||||||
| Document | Purpose |
|
| Document | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `code_guidelines.md` | Code creation guidelines and Git strategy |
|
| `architecture.md` | App architecture, layers, patterns, request lifecycle |
|
||||||
| `security.md` | Python focused security code suggestions |
|
| `API_REFERENCE.md` | All 23 endpoints with params, auth, and response details |
|
||||||
| `roadmap.md` | 5-phase development roadmap |
|
| `database_schema.md` | All 8 tables, columns, relationships, migrations |
|
||||||
| `plans/` | Design documents and implementation plans |
|
| `code_guidelines.md` | Coding standards and Git strategy |
|
||||||
|
| `security.md` | Python-focused security guidance |
|
||||||
|
| `roadmap.md` | Feature status and future plans |
|
||||||
|
|
||||||
## Guidelines
|
## Guidelines
|
||||||
|
|
||||||
|
|||||||
197
docs/architecture.md
Normal file
197
docs/architecture.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
SneakySwole is a workout tracking and programming app built with FastAPI, HTMX, and SQLite. It follows a layered architecture with strict separation of concerns: routes handle HTTP, services handle business logic and data access, and models define the schema.
|
||||||
|
|
||||||
|
All user interactions return HTML (full pages or HTMX partials) — there are no JSON APIs except the health check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Notes |
|
||||||
|
|-------|-----------|-------|
|
||||||
|
| Web framework | FastAPI + Uvicorn | Async, Python 3.12 |
|
||||||
|
| Frontend | Jinja2 + HTMX + Pico CSS | Dark theme, no JS framework |
|
||||||
|
| Database | SQLite3 + SQLModel ORM | Single file at `data/sneakyswole.db` |
|
||||||
|
| Migrations | Alembic | Schema versioning, auto-generated DDL |
|
||||||
|
| Auth | bcrypt + itsdangerous | Hashed passwords, signed session cookies |
|
||||||
|
| Logging | structlog | Structured JSON logging |
|
||||||
|
| Config | pydantic-settings | Typed `.env` loader with validation |
|
||||||
|
| Container | Docker + docker-compose | Single service, port 8000, named volume |
|
||||||
|
| Testing | pytest | 30+ test modules |
|
||||||
|
| Dependencies | uv + requirements.txt | Pinned versions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Application Layers
|
||||||
|
|
||||||
|
### 1. Routes (`app/routes/`)
|
||||||
|
|
||||||
|
HTTP handlers that parse requests, call services, and return rendered templates. Each file covers one domain.
|
||||||
|
|
||||||
|
| File | Prefix | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `auth.py` | `/login`, `/logout` | Login form, credential verification, session cookies |
|
||||||
|
| `pages.py` | `/` | Home page |
|
||||||
|
| `profiles.py` | `/profiles` | Profile list, switch, edit |
|
||||||
|
| `workouts.py` | `/workouts` | Workout day list and detail viewer |
|
||||||
|
| `exercises.py` | `/exercises` | Exercise browser with HTMX search |
|
||||||
|
| `logging.py` | `/log` | Inline set logging (create/edit/delete) |
|
||||||
|
| `history.py` | `/history` | Past session list and detail |
|
||||||
|
| `dashboard.py` | `/dashboard` | Progress stats, volume charts, per-exercise progress |
|
||||||
|
| `schedule.py` | `/schedule` | 4-week calendar view |
|
||||||
|
| `health.py` | `/health` | JSON health check |
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Routes never query the database directly — all data access goes through services
|
||||||
|
- All routes (except `/login`, `/logout`, `/health`) require admin authentication via `@Depends(get_current_admin_user)`
|
||||||
|
- Routes return `TemplateResponse` (HTML), not JSON
|
||||||
|
|
||||||
|
### 2. Services (`app/services/`)
|
||||||
|
|
||||||
|
Business logic and all database access. Each service is instantiated with a SQLModel `Session`.
|
||||||
|
|
||||||
|
| Service | File | Responsibility |
|
||||||
|
|---------|------|---------------|
|
||||||
|
| `AuthService` | `auth_service.py` | bcrypt password verification, session token creation/validation (itsdangerous) |
|
||||||
|
| `UserService` | `user_service.py` | User CRUD, list profiles, update stats |
|
||||||
|
| `ExerciseService` | `exercise_service.py` | Exercise queries (by day, muscle group), warmup listing, workout day listing |
|
||||||
|
| `LogService` | `log_service.py` | Workout log CRUD (create/read/update/delete sets) |
|
||||||
|
| `WorkoutSessionService` | `workout_session_service.py` | Session management (get_or_create, list, lookup) |
|
||||||
|
| `ProgressionService` | `progression_service.py` | Auto-progression suggestions (+reps/+weight/deload) |
|
||||||
|
| `AnalyticsService` | `analytics_service.py` | User stats, volume by day, per-exercise progress data |
|
||||||
|
| `SeedService` | `seed_service.py` | YAML-driven database initialization from `config/` files |
|
||||||
|
|
||||||
|
### 3. Models (`app/models/`)
|
||||||
|
|
||||||
|
SQLModel ORM classes defining 8 database tables. See `docs/database_schema.md` for full details.
|
||||||
|
|
||||||
|
### 4. Utils (`app/utils/`)
|
||||||
|
|
||||||
|
Shared utilities — currently just auth dependencies:
|
||||||
|
|
||||||
|
- **`auth.py`** — `get_current_admin_user()` (FastAPI dependency), `get_active_profile_id()`, `NotAuthenticatedError`, `SESSION_COOKIE_NAME`
|
||||||
|
|
||||||
|
### 5. Templates (`app/templates/`)
|
||||||
|
|
||||||
|
Jinja2 templates split into full pages and reusable HTMX partials.
|
||||||
|
|
||||||
|
- **`base.html`** — Master layout with Pico CSS dark theme, HTMX script, nav bar
|
||||||
|
- **`pages/`** — 12 full-page templates (login, home, dashboard, workout_day, etc.)
|
||||||
|
- **`partials/`** — 13 HTMX fragment templates (exercise_card, log_form, nav, stats_card, etc.)
|
||||||
|
|
||||||
|
### 6. Configuration
|
||||||
|
|
||||||
|
- **`app/config.py`** — Typed `Settings` class (pydantic-settings), singleton via `@lru_cache`
|
||||||
|
- **`.env`** — Runtime secrets (gitignored), referenced by docker-compose
|
||||||
|
- **`.env.example`** — Template documenting required variables
|
||||||
|
- **`config/exercises.yaml`** — Exercise library (name, muscle group, sets, tempo, form cues)
|
||||||
|
- **`config/user_programs.yaml`** — Per-user programming targets (week 1/4 reps and weights)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. Admin submits credentials via `POST /login`
|
||||||
|
2. `AuthService.authenticate()` verifies bcrypt hash
|
||||||
|
3. On success, `AuthService.create_session_token()` creates a signed token (itsdangerous)
|
||||||
|
4. Token stored in httponly cookie (`session`), samesite=lax, 24h TTL
|
||||||
|
5. `get_current_admin_user()` dependency validates token on every protected route
|
||||||
|
6. Invalid/missing token raises `NotAuthenticatedError` → 302 redirect to `/login`
|
||||||
|
|
||||||
|
### Profile Switching
|
||||||
|
|
||||||
|
- Admin selects active profile via `POST /profiles/switch`
|
||||||
|
- Profile ID stored in separate httponly cookie (`active_profile_id`)
|
||||||
|
- `get_active_profile_id()` extracts it from the request
|
||||||
|
- Workout logging happens under the active profile
|
||||||
|
|
||||||
|
### NavContextMiddleware
|
||||||
|
|
||||||
|
Starlette middleware (`app/main.py:NavContextMiddleware`) runs on every request:
|
||||||
|
1. Reads session cookie and validates token
|
||||||
|
2. If valid admin: loads `admin`, `profiles` list, and `active_profile` into `request.state`
|
||||||
|
3. Templates read from `request.state` to render the nav bar (profile switcher, etc.)
|
||||||
|
|
||||||
|
### HTMX Partial Pattern
|
||||||
|
|
||||||
|
All dynamic updates use HTMX with HTML fragment responses:
|
||||||
|
- Filter dropdowns trigger `hx-get` to `/exercises/search` → returns `partials/exercise_list.html`
|
||||||
|
- Log form submits `hx-post` to `/log` → returns `partials/log_entry.html`
|
||||||
|
- No JSON APIs, no fetch calls, no vanilla JS
|
||||||
|
|
||||||
|
### Database Startup
|
||||||
|
|
||||||
|
1. `create_app()` creates SQLModel engine and calls `SQLModel.metadata.create_all()`
|
||||||
|
2. `@app.on_event("startup")` triggers `SeedService.seed_all()`
|
||||||
|
3. Seed service reads `config/exercises.yaml` + `config/user_programs.yaml`
|
||||||
|
4. Inserts exercises, warmups, workout days, user profiles, and programming targets
|
||||||
|
5. Creates admin user from `.env` credentials (bcrypt-hashed)
|
||||||
|
6. Skips seeding if data already exists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Request
|
||||||
|
↓
|
||||||
|
NavContextMiddleware (injects admin/profiles/active_profile into request.state)
|
||||||
|
↓
|
||||||
|
FastAPI Router (matches route)
|
||||||
|
↓
|
||||||
|
Auth Dependency (get_current_admin_user — validates session cookie)
|
||||||
|
↓
|
||||||
|
Route Handler (parses request, calls service(s))
|
||||||
|
↓
|
||||||
|
Service Layer (business logic, DB queries via SQLModel Session)
|
||||||
|
↓
|
||||||
|
Jinja2 TemplateResponse (renders full page or HTMX partial)
|
||||||
|
↓
|
||||||
|
Client Response (HTML)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Setup
|
||||||
|
|
||||||
|
- **`Dockerfile`** — Slim Python 3.12 base, copies app + config, installs deps, runs Uvicorn
|
||||||
|
- **`docker-compose.yaml`** — Single `app` service, port 8000, named volume for `data/`, `.env` file
|
||||||
|
- Static files and templates are baked into the image
|
||||||
|
- SQLite DB persists via Docker volume mount at `data/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
SneakySwole/
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── config.py # Typed settings (pydantic-settings)
|
||||||
|
│ ├── database.py # Engine factory + session dependency
|
||||||
|
│ ├── logging_config.py # structlog setup
|
||||||
|
│ ├── main.py # App factory, middleware, router registration
|
||||||
|
│ ├── models/ # SQLModel ORM (8 tables)
|
||||||
|
│ ├── routes/ # FastAPI handlers (10 modules)
|
||||||
|
│ ├── services/ # Business logic + DB access (8 services)
|
||||||
|
│ ├── static/css/ # Pico CSS overrides
|
||||||
|
│ ├── templates/ # Jinja2 (pages/ + partials/)
|
||||||
|
│ └── utils/ # Auth dependencies
|
||||||
|
├── config/ # YAML seed data
|
||||||
|
│ ├── exercises.yaml
|
||||||
|
│ └── user_programs.yaml
|
||||||
|
├── alembic/ # Database migrations
|
||||||
|
├── tests/ # pytest test suite
|
||||||
|
├── data/ # SQLite DB (gitignored, Docker volume)
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yaml
|
||||||
|
├── pyproject.toml
|
||||||
|
├── requirements.txt
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
202
docs/database_schema.md
Normal file
202
docs/database_schema.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# Database Schema
|
||||||
|
|
||||||
|
SQLite database at `data/sneakyswole.db`, managed by SQLModel ORM with Alembic migrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### `users`
|
||||||
|
|
||||||
|
User profiles (admin and regular). Admin has login credentials; regular profiles are managed by the admin.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|------------|-------------|
|
||||||
|
| `id` | INTEGER | PK, auto-increment | |
|
||||||
|
| `username` | VARCHAR | UNIQUE, indexed | Login identifier |
|
||||||
|
| `password_hash` | VARCHAR | default="" | bcrypt hash (admin only) |
|
||||||
|
| `display_name` | VARCHAR | default="" | Name shown in UI |
|
||||||
|
| `height` | VARCHAR | nullable | e.g., "6'0\"" |
|
||||||
|
| `weight` | VARCHAR | nullable | e.g., "260 lbs" |
|
||||||
|
| `goals` | VARCHAR | nullable | Free-text training goals |
|
||||||
|
| `is_admin` | BOOLEAN | default=False | Admin privileges flag |
|
||||||
|
| `created_at` | DATETIME | auto | Record creation time |
|
||||||
|
| `updated_at` | DATETIME | auto | Last update time |
|
||||||
|
|
||||||
|
**Model:** `app/models/user.py:User`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `exercises`
|
||||||
|
|
||||||
|
Exercise library catalog. Each exercise belongs to a workout day.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|------------|-------------|
|
||||||
|
| `id` | INTEGER | PK, auto-increment | |
|
||||||
|
| `name` | VARCHAR | indexed | e.g., "DB Chest Press (Floor)" |
|
||||||
|
| `muscle_group` | VARCHAR | default="" | e.g., "Chest", "Shoulders" |
|
||||||
|
| `workout_day` | VARCHAR | indexed | "Push", "Pull", "Lower", "Full Body" |
|
||||||
|
| `sets` | INTEGER | default=3 | Default number of sets |
|
||||||
|
| `tempo` | VARCHAR | default="" | e.g., "3-1-2" (eccentric-pause-concentric) |
|
||||||
|
| `form_cues` | VARCHAR | default="" | Detailed form instructions |
|
||||||
|
| `created_at` | DATETIME | auto | Record creation time |
|
||||||
|
|
||||||
|
**Model:** `app/models/exercise.py:Exercise`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `warmups`
|
||||||
|
|
||||||
|
Standardized warmup routine displayed before every workout.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|------------|-------------|
|
||||||
|
| `id` | INTEGER | PK, auto-increment | |
|
||||||
|
| `name` | VARCHAR | indexed | e.g., "Cat / Cow" |
|
||||||
|
| `type` | VARCHAR | default="" | Category: "Thoracic Mob", "Hip Mobility", etc. |
|
||||||
|
| `reps` | VARCHAR | default="" | e.g., "8 reps", "8 each side" |
|
||||||
|
| `form_cues` | VARCHAR | default="" | Detailed form instructions |
|
||||||
|
| `sort_order` | INTEGER | default=0 | Display order in warmup sequence |
|
||||||
|
| `created_at` | DATETIME | auto | Record creation time |
|
||||||
|
|
||||||
|
**Model:** `app/models/warmup.py:Warmup`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `workout_days`
|
||||||
|
|
||||||
|
The 4-day training split definition.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|------------|-------------|
|
||||||
|
| `id` | INTEGER | PK, auto-increment | |
|
||||||
|
| `name` | VARCHAR | UNIQUE, indexed | "Push", "Pull", "Lower", "Full Body" |
|
||||||
|
| `day_number` | INTEGER | UNIQUE | Order in rotation (1-4) |
|
||||||
|
| `description` | VARCHAR | default="" | Brief focus description |
|
||||||
|
|
||||||
|
**Model:** `app/models/workout_day.py:WorkoutDay`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `user_exercise_programs`
|
||||||
|
|
||||||
|
Per-user programming targets linking users to exercises with week 1/4 goals.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|------------|-------------|
|
||||||
|
| `id` | INTEGER | PK, auto-increment | |
|
||||||
|
| `user_id` | INTEGER | FK → `users.id`, indexed | Profile this applies to |
|
||||||
|
| `exercise_id` | INTEGER | FK → `exercises.id`, indexed | Exercise being programmed |
|
||||||
|
| `wk1_reps` | VARCHAR | default="" | Week 1 target reps (e.g., "10", "30 sec") |
|
||||||
|
| `wk4_reps` | VARCHAR | default="" | Week 4 target reps |
|
||||||
|
| `wk1_weight` | VARCHAR | default="" | Week 1 target weight (e.g., "30 lbs", "BW") |
|
||||||
|
| `wk4_weight` | VARCHAR | default="" | Week 4 target weight |
|
||||||
|
| `created_at` | DATETIME | auto | Record creation time |
|
||||||
|
| `updated_at` | DATETIME | auto | Last update time |
|
||||||
|
|
||||||
|
**Model:** `app/models/user_exercise_program.py:UserExerciseProgram`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `workout_sessions`
|
||||||
|
|
||||||
|
A completed workout session — ties a user to a workout day on a date.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|------------|-------------|
|
||||||
|
| `id` | INTEGER | PK, auto-increment | |
|
||||||
|
| `user_id` | INTEGER | FK → `users.id`, indexed | Who did the workout |
|
||||||
|
| `workout_day_id` | INTEGER | FK → `workout_days.id` | Which day was trained |
|
||||||
|
| `date` | DATE | default=today | Date the workout was performed |
|
||||||
|
| `notes` | VARCHAR | nullable | Free-text session notes |
|
||||||
|
| `created_at` | DATETIME | auto | Record creation time |
|
||||||
|
|
||||||
|
**Model:** `app/models/workout_session.py:WorkoutSession`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `workout_logs`
|
||||||
|
|
||||||
|
Individual set logs within a session. Each row = one set of one exercise.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|------------|-------------|
|
||||||
|
| `id` | INTEGER | PK, auto-increment | |
|
||||||
|
| `session_id` | INTEGER | FK → `workout_sessions.id`, indexed | Parent session |
|
||||||
|
| `exercise_id` | INTEGER | FK → `exercises.id` | Which exercise |
|
||||||
|
| `set_number` | INTEGER | default=1 | Set number (1, 2, 3...) |
|
||||||
|
| `reps_completed` | INTEGER | default=0 | Actual reps performed |
|
||||||
|
| `weight_used` | VARCHAR | default="" | Weight used (e.g., "30 lbs", "BW") |
|
||||||
|
| `felt_easy` | BOOLEAN | default=False | Progression signal |
|
||||||
|
| `notes` | VARCHAR | nullable | Per-set notes |
|
||||||
|
| `created_at` | DATETIME | auto | Record creation time |
|
||||||
|
|
||||||
|
**Model:** `app/models/workout_log.py:WorkoutLog`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `progress_log`
|
||||||
|
|
||||||
|
Progression tracking — what the engine suggested vs what the user actually did.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|------------|-------------|
|
||||||
|
| `id` | INTEGER | PK, auto-increment | |
|
||||||
|
| `user_id` | INTEGER | FK → `users.id`, indexed | Profile being tracked |
|
||||||
|
| `exercise_id` | INTEGER | FK → `exercises.id` | Exercise being tracked |
|
||||||
|
| `date` | DATE | default=today | Date of progression entry |
|
||||||
|
| `suggested_reps` | INTEGER | nullable | Engine recommendation |
|
||||||
|
| `suggested_weight` | VARCHAR | nullable | Engine recommendation |
|
||||||
|
| `actual_reps` | INTEGER | nullable | What user actually did |
|
||||||
|
| `actual_weight` | VARCHAR | nullable | What user actually used |
|
||||||
|
| `progression_applied` | VARCHAR | nullable | Type: "reps_increase", "weight_increase", "deload" |
|
||||||
|
| `created_at` | DATETIME | auto | Record creation time |
|
||||||
|
|
||||||
|
**Model:** `app/models/progress_log.py:ProgressLog`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
users
|
||||||
|
├── user_exercise_programs (1:N via user_id)
|
||||||
|
├── workout_sessions (1:N via user_id)
|
||||||
|
└── progress_log (1:N via user_id)
|
||||||
|
|
||||||
|
exercises
|
||||||
|
├── user_exercise_programs (1:N via exercise_id)
|
||||||
|
├── workout_logs (1:N via exercise_id)
|
||||||
|
└── progress_log (1:N via exercise_id)
|
||||||
|
|
||||||
|
workout_days
|
||||||
|
└── workout_sessions (1:N via workout_day_id)
|
||||||
|
|
||||||
|
workout_sessions
|
||||||
|
└── workout_logs (1:N via session_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Managed by Alembic. Config at `alembic.ini`, migration scripts at `alembic/versions/`.
|
||||||
|
|
||||||
|
- **Initial migration:** `1855836abf6c_initial_schema_8_tables.py` — creates all 8 tables
|
||||||
|
|
||||||
|
To create a new migration:
|
||||||
|
```bash
|
||||||
|
alembic revision --autogenerate -m "description"
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seeding
|
||||||
|
|
||||||
|
On first startup, `SeedService.seed_all()` reads:
|
||||||
|
- `config/exercises.yaml` — exercise catalog + warmups + workout days
|
||||||
|
- `config/user_programs.yaml` — per-user week 1/4 targets
|
||||||
|
|
||||||
|
Admin user is created from `ADMIN_USERNAME` / `ADMIN_PASSWORD` env vars with bcrypt hash. Seeding is skipped if data already exists.
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Design: Roadmap, Exercise Data, and Auth Model
|
|
||||||
|
|
||||||
**Date:** 2026-02-23
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document captures the design decisions for the SneakySwole roadmap structure, exercise data management, and authentication model.
|
|
||||||
|
|
||||||
## Roadmap Structure
|
|
||||||
|
|
||||||
Five phases, each producing a testable milestone:
|
|
||||||
|
|
||||||
1. **Scaffold & Infrastructure** — project structure, Docker, base template, logging
|
|
||||||
2. **Data Layer & Seeding** — SQLite schema, Alembic migrations, YAML-based exercise/program seeding
|
|
||||||
3. **Workout UI** — admin auth, profile management, workout viewer, exercise browser
|
|
||||||
4. **Logging & Tracking** — per-exercise logging, session tracking, log history
|
|
||||||
5. **Progression & Analytics** — auto-progression, schedule view, progress dashboard
|
|
||||||
|
|
||||||
Full details in `docs/roadmap.md`.
|
|
||||||
|
|
||||||
## Exercise Data Design
|
|
||||||
|
|
||||||
### Separated YAML Files
|
|
||||||
|
|
||||||
Exercise data is split into two config files for clean separation of concerns:
|
|
||||||
|
|
||||||
- **`config/exercises.yaml`** — Universal exercise library
|
|
||||||
- Exercise name, muscle group, workout day, sets, tempo, form cues
|
|
||||||
- Warmup exercises (name, type, reps/time, form cues)
|
|
||||||
- Rarely changes; defines the available exercise catalog
|
|
||||||
|
|
||||||
- **`config/user_programs.yaml`** — Per-user programming
|
|
||||||
- References exercises by name
|
|
||||||
- Week 1 and week 4 reps and weights per user per exercise
|
|
||||||
- Changes when users adjust their programming or new users are added
|
|
||||||
|
|
||||||
### Seeding Flow
|
|
||||||
|
|
||||||
1. On first run (empty DB), the seed script reads both YAML files
|
|
||||||
2. Exercises and warmups are inserted into their respective tables
|
|
||||||
3. User profiles are created (admin from `.env`, others from user_programs.yaml)
|
|
||||||
4. User-specific programming (reps/weights) is linked to exercises and users
|
|
||||||
5. Subsequent runs skip seeding if data already exists
|
|
||||||
|
|
||||||
### Why YAML Over Spreadsheet
|
|
||||||
|
|
||||||
- Human-readable and version-controllable
|
|
||||||
- Easy to update without special tools
|
|
||||||
- Can be validated with schema checks
|
|
||||||
- Spreadsheet remains source of truth for initial data extraction only
|
|
||||||
|
|
||||||
## Authentication Model
|
|
||||||
|
|
||||||
### Phase 1: Simple Admin Auth
|
|
||||||
|
|
||||||
- Single admin user with credentials stored in `.env` (`ADMIN_USERNAME`, `ADMIN_PASSWORD`)
|
|
||||||
- On first run, admin user is created in DB with bcrypt-hashed password
|
|
||||||
- Admin logs in via a simple login form (session-based auth)
|
|
||||||
- Admin creates user profiles through the UI (no login for profiles)
|
|
||||||
- Profile switcher in navigation allows admin to select the active profile
|
|
||||||
- All workout logging happens under the selected profile
|
|
||||||
|
|
||||||
### Security Considerations
|
|
||||||
|
|
||||||
- Passwords hashed with bcrypt (never stored plaintext)
|
|
||||||
- `.env` file is gitignored and never committed
|
|
||||||
- `.env.example` documents required variables without real values
|
|
||||||
- Session tokens with reasonable expiry
|
|
||||||
- Admin-only routes protected by auth middleware
|
|
||||||
|
|
||||||
### Future Upgrade Path
|
|
||||||
|
|
||||||
- Phase 1 auth is designed to be replaceable
|
|
||||||
- If multi-user login is needed later, add login credentials to the user profiles table
|
|
||||||
- Profile switcher becomes unnecessary when each user logs in independently
|
|
||||||
@@ -1,59 +1,30 @@
|
|||||||
# SneakySwole Roadmap
|
# SneakySwole Roadmap
|
||||||
|
|
||||||
## Phase 1: Scaffold & Infrastructure
|
## Completed
|
||||||
|
|
||||||
Set up the project foundation — everything needed before writing features.
|
### Phase 1: Scaffold & Infrastructure
|
||||||
|
FastAPI project structure, Dockerfile + docker-compose.yaml, Pico CSS dark theme base template, `.env` config, structlog logging, health check endpoint.
|
||||||
|
|
||||||
- FastAPI project structure (`app/` with routes, services, models, templates, static, utils)
|
### Phase 2: Data Layer & Seeding
|
||||||
- Dockerfile + docker-compose.yaml (SQLite volume, port 8000, `.env` support)
|
8-table SQLite schema via SQLModel + Alembic migrations. YAML-driven seed script (`config/exercises.yaml`, `config/user_programs.yaml`). Service layer for all DB access. Admin user auto-created from `.env` with bcrypt.
|
||||||
- Pico CSS dark theme base template (Jinja2 with `data-theme="dark"`)
|
|
||||||
- `.env` / `.env.example` with admin credentials (`ADMIN_USERNAME`, `ADMIN_PASSWORD`)
|
|
||||||
- `uv` for dependency management, `requirements.txt` with pinned versions
|
|
||||||
- Structlog logging setup
|
|
||||||
- Basic health check route (`/health`)
|
|
||||||
|
|
||||||
## Phase 2: Data Layer & Seeding
|
### Phase 3: Workout UI
|
||||||
|
Admin login (bcrypt + signed session cookies), profile switcher, workout day viewer with warmups and exercise cards, HTMX-powered exercise browser with search/filter. NavContextMiddleware for automatic template context.
|
||||||
|
|
||||||
Build the database schema and seed it from YAML config files.
|
### Phase 4: Logging & Tracking
|
||||||
|
Inline set logging from the workout day view via HTMX. Auto-created workout sessions. Log history with per-session detail view. Edit and delete log entries.
|
||||||
|
|
||||||
- SQLite schema via Alembic migrations
|
### Phase 5: Progression & Analytics
|
||||||
- Tables: users, exercises, warmups, workout_days, user_exercise_programs, sets, progress_log
|
Auto-progression engine (+reps/+weight/deload rules), 4-week schedule calendar, progress dashboard with Chart.js charts, per-exercise progress pages with suggestions.
|
||||||
- `config/exercises.yaml` — exercise library (name, muscle group, workout day, sets, tempo, form cues)
|
|
||||||
- `config/user_programs.yaml` — per-user week 1/4 reps and weights for each exercise
|
|
||||||
- Seed script that loads YAML into DB on first run
|
|
||||||
- Admin user auto-created from `.env` credentials on startup (password hashed with bcrypt)
|
|
||||||
- Two initial user profiles seeded: Phillip and Daughter
|
|
||||||
- Service layer for all DB access (no direct queries from routes)
|
|
||||||
|
|
||||||
## Phase 3: Workout UI
|
---
|
||||||
|
|
||||||
The core user-facing experience — viewing workouts and managing profiles.
|
## Future Ideas
|
||||||
|
|
||||||
- Admin login (simple session-based auth, password hashed)
|
- Multi-user login (replace profile switcher with individual logins)
|
||||||
- Profile switcher in nav (admin selects active user profile)
|
- REST API for mobile clients
|
||||||
- Admin can create and edit user profiles (name, weight, height, goals)
|
- Exercise video/image attachments
|
||||||
- Workout day viewer — warmup routine + main exercises with full form cues
|
- Custom workout program builder
|
||||||
- Exercise library browser (search/filter by muscle group, workout day)
|
- Export/import workout data (CSV/JSON)
|
||||||
- All interactions via HTMX partials (no JSON APIs, no vanilla JS)
|
- Notifications/reminders
|
||||||
|
- Social features (sharing workouts)
|
||||||
## Phase 4: Logging & Tracking
|
|
||||||
|
|
||||||
Enable workout logging so users can track what they actually did.
|
|
||||||
|
|
||||||
- Per-exercise logging: sets completed, reps, weight used, "felt easy?" toggle
|
|
||||||
- Workout session model (date, user profile, workout day)
|
|
||||||
- HTMX inline logging — log directly from the workout day view
|
|
||||||
- Log history view per user profile
|
|
||||||
- Edit/delete past log entries
|
|
||||||
|
|
||||||
## Phase 5: Progression & Analytics
|
|
||||||
|
|
||||||
Smart suggestions and visual progress tracking.
|
|
||||||
|
|
||||||
- Auto-progression engine based on log history
|
|
||||||
- +1-2 reps/week, +5 lbs every 2 weeks
|
|
||||||
- Deload detection at week 5 (-20% weight)
|
|
||||||
- 4-week schedule view (calendar-style, shows which day maps to which date)
|
|
||||||
- Progress dashboard per user profile
|
|
||||||
- Per-exercise progress history and trends
|
|
||||||
- Summary stats (total volume, streak tracking)
|
|
||||||
|
|||||||
@@ -6,4 +6,7 @@ requires-python = ">=3.12"
|
|||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=75.0"]
|
requires = ["setuptools>=75.0"]
|
||||||
build-backend = "setuptools.backends._legacy:_Backend"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app*"]
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ jinja2>=3.1.0,<4.0.0
|
|||||||
structlog>=24.0.0,<25.0.0
|
structlog>=24.0.0,<25.0.0
|
||||||
python-dotenv>=1.0.0,<2.0.0
|
python-dotenv>=1.0.0,<2.0.0
|
||||||
bcrypt>=4.2.0,<5.0.0
|
bcrypt>=4.2.0,<5.0.0
|
||||||
|
itsdangerous>=2.2.0,<3.0.0
|
||||||
|
python-multipart>=0.0.20,<1.0.0
|
||||||
pyyaml>=6.0.0,<7.0.0
|
pyyaml>=6.0.0,<7.0.0
|
||||||
sqlmodel>=0.0.22,<1.0.0
|
sqlmodel>=0.0.22,<1.0.0
|
||||||
alembic>=1.14.0,<2.0.0
|
alembic>=1.14.0,<2.0.0
|
||||||
|
|||||||
2
run_dev_docker.sh
Executable file
2
run_dev_docker.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
docker compose -f docker-compose.dev.yaml up --build "$@"
|
||||||
96
tests/test_analytics_service.py
Normal file
96
tests/test_analytics_service.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Tests for the AnalyticsService class."""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, Session, create_engine
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.exercise import Exercise
|
||||||
|
from app.models.workout_day import WorkoutDay
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
|
from app.services.analytics_service import AnalyticsService
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyticsService:
|
||||||
|
"""Tests for analytics data aggregation."""
|
||||||
|
|
||||||
|
def _setup(self):
|
||||||
|
"""Create DB with sessions and logs for analytics."""
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
|
||||||
|
user = User(username="phil", password_hash="h", display_name="Phillip")
|
||||||
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
|
exercise = Exercise(
|
||||||
|
name="DB Chest Press", muscle_group="Chest",
|
||||||
|
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
|
||||||
|
)
|
||||||
|
session.add_all([user, day, exercise])
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
session.refresh(day)
|
||||||
|
session.refresh(exercise)
|
||||||
|
|
||||||
|
# Create 3 sessions over 3 weeks
|
||||||
|
for week in range(3):
|
||||||
|
ws = WorkoutSession(
|
||||||
|
user_id=user.id, workout_day_id=day.id,
|
||||||
|
date=date.today() - timedelta(days=(2 - week) * 7),
|
||||||
|
)
|
||||||
|
session.add(ws)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ws)
|
||||||
|
|
||||||
|
for set_num in range(1, 4):
|
||||||
|
session.add(WorkoutLog(
|
||||||
|
session_id=ws.id, exercise_id=exercise.id,
|
||||||
|
set_number=set_num,
|
||||||
|
reps_completed=8 + week,
|
||||||
|
weight_used="30 lbs",
|
||||||
|
felt_easy=(week == 2),
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
service = AnalyticsService(session)
|
||||||
|
return session, service, user, exercise
|
||||||
|
|
||||||
|
def test_get_total_sessions(self) -> None:
|
||||||
|
"""Should return the total number of sessions for a user."""
|
||||||
|
session, service, user, exercise = self._setup()
|
||||||
|
stats = service.get_user_stats(user.id)
|
||||||
|
assert stats["total_sessions"] == 3
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_get_total_volume(self) -> None:
|
||||||
|
"""Should calculate total volume (sets x reps x weight)."""
|
||||||
|
session, service, user, exercise = self._setup()
|
||||||
|
stats = service.get_user_stats(user.id)
|
||||||
|
assert stats["total_volume"] > 0
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_get_workout_streak(self) -> None:
|
||||||
|
"""Should calculate consecutive workout weeks."""
|
||||||
|
session, service, user, exercise = self._setup()
|
||||||
|
stats = service.get_user_stats(user.id)
|
||||||
|
assert stats["current_streak"] >= 1
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_get_exercise_progress_data(self) -> None:
|
||||||
|
"""Should return chart-ready data for an exercise."""
|
||||||
|
session, service, user, exercise = self._setup()
|
||||||
|
data = service.get_exercise_progress(user.id, exercise.id)
|
||||||
|
assert "dates" in data
|
||||||
|
assert "reps" in data
|
||||||
|
assert "weights" in data
|
||||||
|
assert len(data["dates"]) == 3
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_get_volume_by_day(self) -> None:
|
||||||
|
"""Should return volume per workout day for charts."""
|
||||||
|
session, service, user, exercise = self._setup()
|
||||||
|
data = service.get_volume_by_day(user.id)
|
||||||
|
assert "Push" in data
|
||||||
|
assert data["Push"] > 0
|
||||||
|
session.close()
|
||||||
57
tests/test_auth_dependency.py
Normal file
57
tests/test_auth_dependency.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Tests for the auth dependency (require_admin)."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.utils.auth import (
|
||||||
|
NotAuthenticatedError,
|
||||||
|
get_current_admin_user,
|
||||||
|
get_active_profile_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthDependency:
|
||||||
|
"""Tests for the require_admin dependency."""
|
||||||
|
|
||||||
|
def test_redirects_when_no_session_cookie(self) -> None:
|
||||||
|
"""Should raise NotAuthenticatedError when no session cookie is present."""
|
||||||
|
request = MagicMock()
|
||||||
|
request.cookies = {}
|
||||||
|
|
||||||
|
with pytest.raises(NotAuthenticatedError):
|
||||||
|
get_current_admin_user(request=request, session=MagicMock())
|
||||||
|
|
||||||
|
def test_redirects_when_invalid_token(self) -> None:
|
||||||
|
"""Should raise NotAuthenticatedError when session cookie has invalid token."""
|
||||||
|
request = MagicMock()
|
||||||
|
request.cookies = {"session": "invalid-token"}
|
||||||
|
request.app.state.secret_key = "test-secret"
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get.return_value = None
|
||||||
|
|
||||||
|
with pytest.raises(NotAuthenticatedError):
|
||||||
|
get_current_admin_user(request=request, session=mock_session)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetActiveProfileId:
|
||||||
|
"""Tests for the get_active_profile_id dependency."""
|
||||||
|
|
||||||
|
def test_returns_profile_id_from_cookie(self) -> None:
|
||||||
|
"""Should return the integer profile ID from cookie."""
|
||||||
|
request = MagicMock()
|
||||||
|
request.cookies = {"active_profile_id": "5"}
|
||||||
|
assert get_active_profile_id(request) == 5
|
||||||
|
|
||||||
|
def test_returns_none_when_no_cookie(self) -> None:
|
||||||
|
"""Should return None when no active_profile_id cookie is set."""
|
||||||
|
request = MagicMock()
|
||||||
|
request.cookies = {}
|
||||||
|
assert get_active_profile_id(request) is None
|
||||||
|
|
||||||
|
def test_returns_none_for_non_numeric(self) -> None:
|
||||||
|
"""Should return None for non-numeric cookie values."""
|
||||||
|
request = MagicMock()
|
||||||
|
request.cookies = {"active_profile_id": "abc"}
|
||||||
|
assert get_active_profile_id(request) is None
|
||||||
43
tests/test_auth_routes.py
Normal file
43
tests/test_auth_routes.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Tests for login/logout routes."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginPage:
|
||||||
|
"""Tests for GET /login."""
|
||||||
|
|
||||||
|
def test_login_page_returns_200(self, client: TestClient) -> None:
|
||||||
|
"""GET /login should render the login form."""
|
||||||
|
response = client.get("/login")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Login" in response.text
|
||||||
|
|
||||||
|
def test_login_page_has_form(self, client: TestClient) -> None:
|
||||||
|
"""GET /login should contain a login form with username and password."""
|
||||||
|
response = client.get("/login")
|
||||||
|
assert 'name="username"' in response.text
|
||||||
|
assert 'name="password"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginPost:
|
||||||
|
"""Tests for POST /login."""
|
||||||
|
|
||||||
|
def test_login_invalid_credentials_shows_error(self, client: TestClient) -> None:
|
||||||
|
"""POST /login with wrong credentials should show error message."""
|
||||||
|
response = client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": "wrong", "password": "wrong"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
# Should re-render login page with error or redirect back
|
||||||
|
assert response.status_code in (200, 303)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogout:
|
||||||
|
"""Tests for GET /logout."""
|
||||||
|
|
||||||
|
def test_logout_redirects_to_login(self, client: TestClient) -> None:
|
||||||
|
"""GET /logout should clear session and redirect to login."""
|
||||||
|
response = client.get("/logout", follow_redirects=False)
|
||||||
|
assert response.status_code in (303, 307)
|
||||||
|
assert "/login" in response.headers.get("location", "")
|
||||||
93
tests/test_auth_service.py
Normal file
93
tests/test_auth_service.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Tests for the AuthService class."""
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from sqlmodel import SQLModel, Session, create_engine
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthService:
|
||||||
|
"""Tests for admin authentication logic."""
|
||||||
|
|
||||||
|
def _setup(self):
|
||||||
|
"""Create an in-memory DB with an admin user."""
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
|
||||||
|
# Create admin user with known password
|
||||||
|
pw_hash = bcrypt.hashpw(b"adminpass", bcrypt.gensalt()).decode("utf-8")
|
||||||
|
admin = User(
|
||||||
|
username="admin",
|
||||||
|
password_hash=pw_hash,
|
||||||
|
display_name="Admin",
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
session.add(admin)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
service = AuthService(session, secret_key="test-secret-key")
|
||||||
|
return session, service
|
||||||
|
|
||||||
|
def test_authenticate_valid_credentials(self) -> None:
|
||||||
|
"""authenticate should return the User for valid credentials."""
|
||||||
|
session, service = self._setup()
|
||||||
|
user = service.authenticate("admin", "adminpass")
|
||||||
|
assert user is not None
|
||||||
|
assert user.username == "admin"
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_authenticate_wrong_password(self) -> None:
|
||||||
|
"""authenticate should return None for wrong password."""
|
||||||
|
session, service = self._setup()
|
||||||
|
user = service.authenticate("admin", "wrongpass")
|
||||||
|
assert user is None
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_authenticate_unknown_user(self) -> None:
|
||||||
|
"""authenticate should return None for unknown username."""
|
||||||
|
session, service = self._setup()
|
||||||
|
user = service.authenticate("nobody", "anything")
|
||||||
|
assert user is None
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_authenticate_non_admin(self) -> None:
|
||||||
|
"""authenticate should return None for non-admin users."""
|
||||||
|
session, service = self._setup()
|
||||||
|
pw_hash = bcrypt.hashpw(b"userpass", bcrypt.gensalt()).decode("utf-8")
|
||||||
|
non_admin = User(
|
||||||
|
username="regular",
|
||||||
|
password_hash=pw_hash,
|
||||||
|
display_name="Regular",
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
session.add(non_admin)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
user = service.authenticate("regular", "userpass")
|
||||||
|
assert user is None
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_create_session_token(self) -> None:
|
||||||
|
"""create_session_token should return a non-empty signed string."""
|
||||||
|
session, service = self._setup()
|
||||||
|
token = service.create_session_token(user_id=1)
|
||||||
|
assert isinstance(token, str)
|
||||||
|
assert len(token) > 0
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_validate_session_token(self) -> None:
|
||||||
|
"""validate_session_token should return the user_id from a valid token."""
|
||||||
|
session, service = self._setup()
|
||||||
|
token = service.create_session_token(user_id=42)
|
||||||
|
user_id = service.validate_session_token(token)
|
||||||
|
assert user_id == 42
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_validate_session_token_invalid(self) -> None:
|
||||||
|
"""validate_session_token should return None for tampered tokens."""
|
||||||
|
session, service = self._setup()
|
||||||
|
user_id = service.validate_session_token("fake-token-value")
|
||||||
|
assert user_id is None
|
||||||
|
session.close()
|
||||||
21
tests/test_dashboard_routes.py
Normal file
21
tests/test_dashboard_routes.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Tests for dashboard routes."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboard:
|
||||||
|
"""Tests for GET /dashboard."""
|
||||||
|
|
||||||
|
def test_dashboard_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /dashboard should require admin login."""
|
||||||
|
response = client.get("/dashboard", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExerciseProgress:
|
||||||
|
"""Tests for GET /dashboard/exercise/<id>."""
|
||||||
|
|
||||||
|
def test_exercise_progress_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /dashboard/exercise/1 should require admin login."""
|
||||||
|
response = client.get("/dashboard/exercise/1", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
24
tests/test_exercise_routes.py
Normal file
24
tests/test_exercise_routes.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Tests for exercise browser routes."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestExerciseBrowser:
|
||||||
|
"""Tests for GET /exercises."""
|
||||||
|
|
||||||
|
def test_exercise_browser_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /exercises should require admin login."""
|
||||||
|
response = client.get("/exercises", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExerciseSearch:
|
||||||
|
"""Tests for HTMX exercise search."""
|
||||||
|
|
||||||
|
def test_exercise_search_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /exercises/search should require admin login."""
|
||||||
|
response = client.get(
|
||||||
|
"/exercises/search?workout_day=Push",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
21
tests/test_history_routes.py
Normal file
21
tests/test_history_routes.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Tests for log history routes."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogHistory:
|
||||||
|
"""Tests for GET /history."""
|
||||||
|
|
||||||
|
def test_history_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /history should require admin login."""
|
||||||
|
response = client.get("/history", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionDetail:
|
||||||
|
"""Tests for GET /history/<session_id>."""
|
||||||
|
|
||||||
|
def test_session_detail_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /history/1 should require admin login."""
|
||||||
|
response = client.get("/history/1", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
107
tests/test_log_service.py
Normal file
107
tests/test_log_service.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Tests for the LogService class."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, Session, create_engine
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.exercise import Exercise
|
||||||
|
from app.models.workout_day import WorkoutDay
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
|
from app.services.log_service import LogService
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogService:
|
||||||
|
"""Tests for workout log CRUD operations."""
|
||||||
|
|
||||||
|
def _setup(self):
|
||||||
|
"""Create an in-memory DB with prerequisite data."""
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
|
||||||
|
user = User(username="phil", password_hash="h", display_name="Phillip")
|
||||||
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
|
exercise = Exercise(
|
||||||
|
name="DB Chest Press", muscle_group="Chest",
|
||||||
|
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
|
||||||
|
)
|
||||||
|
session.add_all([user, day, exercise])
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
session.refresh(day)
|
||||||
|
session.refresh(exercise)
|
||||||
|
|
||||||
|
ws = WorkoutSession(
|
||||||
|
user_id=user.id, workout_day_id=day.id, date=date.today(),
|
||||||
|
)
|
||||||
|
session.add(ws)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ws)
|
||||||
|
|
||||||
|
service = LogService(session)
|
||||||
|
return session, service, ws, exercise
|
||||||
|
|
||||||
|
def test_create_log_entry(self) -> None:
|
||||||
|
"""create_log should insert a new log entry."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
log = service.create_log(
|
||||||
|
session_id=ws.id,
|
||||||
|
exercise_id=exercise.id,
|
||||||
|
set_number=1,
|
||||||
|
reps_completed=8,
|
||||||
|
weight_used="30 lbs",
|
||||||
|
felt_easy=False,
|
||||||
|
)
|
||||||
|
assert log.id is not None
|
||||||
|
assert log.reps_completed == 8
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_list_logs_for_session(self) -> None:
|
||||||
|
"""list_logs_for_session should return all logs for a session."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
|
||||||
|
service.create_log(ws.id, exercise.id, 2, 8, "30 lbs", False)
|
||||||
|
service.create_log(ws.id, exercise.id, 3, 7, "30 lbs", True)
|
||||||
|
logs = service.list_logs_for_session(ws.id)
|
||||||
|
assert len(logs) == 3
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_list_logs_for_exercise_in_session(self) -> None:
|
||||||
|
"""list_logs_for_exercise should filter by exercise within a session."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
|
||||||
|
logs = service.list_logs_for_exercise(ws.id, exercise.id)
|
||||||
|
assert len(logs) == 1
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_update_log(self) -> None:
|
||||||
|
"""update_log should modify an existing log entry."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
log = service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
|
||||||
|
updated = service.update_log(log.id, reps_completed=10, felt_easy=True)
|
||||||
|
assert updated.reps_completed == 10
|
||||||
|
assert updated.felt_easy is True
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_delete_log(self) -> None:
|
||||||
|
"""delete_log should remove a log entry."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
log = service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
|
||||||
|
service.delete_log(log.id)
|
||||||
|
logs = service.list_logs_for_session(ws.id)
|
||||||
|
assert len(logs) == 0
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_get_latest_logs_for_exercise(self) -> None:
|
||||||
|
"""get_latest_logs should return the most recent logs for an exercise."""
|
||||||
|
session, service, ws, exercise = self._setup()
|
||||||
|
service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
|
||||||
|
service.create_log(ws.id, exercise.id, 2, 8, "30 lbs", True)
|
||||||
|
logs = service.get_latest_logs_for_exercise(
|
||||||
|
user_id=ws.user_id,
|
||||||
|
exercise_id=exercise.id,
|
||||||
|
)
|
||||||
|
assert len(logs) >= 1
|
||||||
|
session.close()
|
||||||
44
tests/test_logging_routes.py
Normal file
44
tests/test_logging_routes.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Tests for workout logging routes."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogSet:
|
||||||
|
"""Tests for POST /log."""
|
||||||
|
|
||||||
|
def test_log_set_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""POST /log should require admin login."""
|
||||||
|
response = client.post(
|
||||||
|
"/log",
|
||||||
|
data={
|
||||||
|
"exercise_id": "1",
|
||||||
|
"workout_day_id": "1",
|
||||||
|
"set_number": "1",
|
||||||
|
"reps": "8",
|
||||||
|
"weight": "30 lbs",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogEdit:
|
||||||
|
"""Tests for POST /log/<id>/edit."""
|
||||||
|
|
||||||
|
def test_edit_log_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""POST /log/1/edit should require admin login."""
|
||||||
|
response = client.post(
|
||||||
|
"/log/1/edit",
|
||||||
|
data={"reps": "10", "weight": "35 lbs"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogDelete:
|
||||||
|
"""Tests for POST /log/<id>/delete."""
|
||||||
|
|
||||||
|
def test_delete_log_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""POST /log/1/delete should require admin login."""
|
||||||
|
response = client.post("/log/1/delete", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
26
tests/test_profile_routes.py
Normal file
26
tests/test_profile_routes.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Tests for profile management routes."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileSwitcher:
|
||||||
|
"""Tests for POST /profiles/switch."""
|
||||||
|
|
||||||
|
def test_switch_profile_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""POST /profiles/switch should require admin login."""
|
||||||
|
response = client.post(
|
||||||
|
"/profiles/switch",
|
||||||
|
data={"profile_id": "1"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
# Should redirect to login or return 401
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileList:
|
||||||
|
"""Tests for GET /profiles."""
|
||||||
|
|
||||||
|
def test_profiles_page_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /profiles should require admin login."""
|
||||||
|
response = client.get("/profiles", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
161
tests/test_progression_service.py
Normal file
161
tests/test_progression_service.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Tests for the ProgressionService class."""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, Session, create_engine
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.exercise import Exercise
|
||||||
|
from app.models.workout_day import WorkoutDay
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
from app.models.workout_log import WorkoutLog
|
||||||
|
from app.models.user_exercise_program import UserExerciseProgram
|
||||||
|
from app.models.progress_log import ProgressLog
|
||||||
|
from app.services.progression_service import ProgressionService
|
||||||
|
|
||||||
|
|
||||||
|
class TestProgressionService:
|
||||||
|
"""Tests for the auto-progression engine."""
|
||||||
|
|
||||||
|
def _setup(self):
|
||||||
|
"""Create an in-memory DB with a user, exercise, and program."""
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
|
||||||
|
user = User(username="phil", password_hash="h", display_name="Phillip")
|
||||||
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
|
exercise = Exercise(
|
||||||
|
name="DB Chest Press", muscle_group="Chest",
|
||||||
|
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
|
||||||
|
)
|
||||||
|
session.add_all([user, day, exercise])
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
session.refresh(day)
|
||||||
|
session.refresh(exercise)
|
||||||
|
|
||||||
|
program = UserExerciseProgram(
|
||||||
|
user_id=user.id, exercise_id=exercise.id,
|
||||||
|
wk1_reps="8", wk4_reps="12",
|
||||||
|
wk1_weight="30 lbs", wk4_weight="40 lbs",
|
||||||
|
)
|
||||||
|
session.add(program)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(program)
|
||||||
|
|
||||||
|
service = ProgressionService(session)
|
||||||
|
return session, service, user, day, exercise, program
|
||||||
|
|
||||||
|
def test_suggest_reps_increase(self) -> None:
|
||||||
|
"""Should suggest +1-2 reps when below wk4 target."""
|
||||||
|
session, service, user, day, exercise, program = self._setup()
|
||||||
|
|
||||||
|
# Log a session where user did 8 reps (wk1 target)
|
||||||
|
ws = WorkoutSession(
|
||||||
|
user_id=user.id, workout_day_id=day.id,
|
||||||
|
date=date.today() - timedelta(days=7),
|
||||||
|
)
|
||||||
|
session.add(ws)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ws)
|
||||||
|
|
||||||
|
for set_num in range(1, 4):
|
||||||
|
session.add(WorkoutLog(
|
||||||
|
session_id=ws.id, exercise_id=exercise.id,
|
||||||
|
set_number=set_num, reps_completed=8,
|
||||||
|
weight_used="30 lbs", felt_easy=False,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
assert suggestion is not None
|
||||||
|
assert suggestion["suggested_reps"] >= 9 # +1-2 reps
|
||||||
|
assert suggestion["suggested_weight"] == "30 lbs" # same weight
|
||||||
|
assert suggestion["progression_type"] == "reps_increase"
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_suggest_weight_increase(self) -> None:
|
||||||
|
"""Should suggest +5 lbs when at wk4 rep target and felt easy."""
|
||||||
|
session, service, user, day, exercise, program = self._setup()
|
||||||
|
|
||||||
|
# Log two sessions at max reps, all felt easy
|
||||||
|
for week_offset in [14, 7]:
|
||||||
|
ws = WorkoutSession(
|
||||||
|
user_id=user.id, workout_day_id=day.id,
|
||||||
|
date=date.today() - timedelta(days=week_offset),
|
||||||
|
)
|
||||||
|
session.add(ws)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ws)
|
||||||
|
|
||||||
|
for set_num in range(1, 4):
|
||||||
|
session.add(WorkoutLog(
|
||||||
|
session_id=ws.id, exercise_id=exercise.id,
|
||||||
|
set_number=set_num, reps_completed=12,
|
||||||
|
weight_used="30 lbs", felt_easy=True,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
assert suggestion is not None
|
||||||
|
assert suggestion["suggested_weight"] == "35 lbs" # +5 lbs
|
||||||
|
assert suggestion["progression_type"] == "weight_increase"
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_suggest_deload(self) -> None:
|
||||||
|
"""Should suggest deload after 4 weeks of progression."""
|
||||||
|
session, service, user, day, exercise, program = self._setup()
|
||||||
|
|
||||||
|
# Log 4 weeks of sessions (simulate week 5 trigger)
|
||||||
|
for week in range(4):
|
||||||
|
ws = WorkoutSession(
|
||||||
|
user_id=user.id, workout_day_id=day.id,
|
||||||
|
date=date.today() - timedelta(days=(4 - week) * 7),
|
||||||
|
)
|
||||||
|
session.add(ws)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ws)
|
||||||
|
|
||||||
|
for set_num in range(1, 4):
|
||||||
|
session.add(WorkoutLog(
|
||||||
|
session_id=ws.id, exercise_id=exercise.id,
|
||||||
|
set_number=set_num, reps_completed=12,
|
||||||
|
weight_used="40 lbs", felt_easy=False,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
assert suggestion is not None
|
||||||
|
# After 4 consecutive weeks, week 5 should be deload
|
||||||
|
if suggestion["progression_type"] == "deload":
|
||||||
|
assert "32" in suggestion["suggested_weight"] # -20% of 40
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_no_suggestion_without_logs(self) -> None:
|
||||||
|
"""Should return program defaults when no logs exist."""
|
||||||
|
session, service, user, day, exercise, program = self._setup()
|
||||||
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
|
assert suggestion is not None
|
||||||
|
assert suggestion["suggested_reps"] == 8 # wk1 default
|
||||||
|
assert suggestion["suggested_weight"] == "30 lbs" # wk1 default
|
||||||
|
assert suggestion["progression_type"] == "baseline"
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_record_progression(self) -> None:
|
||||||
|
"""record_progression should write to progress_log table."""
|
||||||
|
session, service, user, day, exercise, program = self._setup()
|
||||||
|
service.record_progression(
|
||||||
|
user_id=user.id,
|
||||||
|
exercise_id=exercise.id,
|
||||||
|
suggested_reps=10,
|
||||||
|
suggested_weight="30 lbs",
|
||||||
|
actual_reps=10,
|
||||||
|
actual_weight="30 lbs",
|
||||||
|
progression_type="reps_increase",
|
||||||
|
)
|
||||||
|
from sqlmodel import select
|
||||||
|
logs = session.exec(select(ProgressLog)).all()
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0].progression_applied == "reps_increase"
|
||||||
|
session.close()
|
||||||
12
tests/test_schedule_routes.py
Normal file
12
tests/test_schedule_routes.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""Tests for schedule calendar routes."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchedule:
|
||||||
|
"""Tests for GET /schedule."""
|
||||||
|
|
||||||
|
def test_schedule_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /schedule should require admin login."""
|
||||||
|
response = client.get("/schedule", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
17
tests/test_workout_routes.py
Normal file
17
tests/test_workout_routes.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Tests for workout day viewer routes."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkoutDayViewer:
|
||||||
|
"""Tests for GET /workouts/<day_name>."""
|
||||||
|
|
||||||
|
def test_workout_day_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /workouts/push should require admin login."""
|
||||||
|
response = client.get("/workouts/push", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
|
|
||||||
|
def test_workout_days_list_requires_auth(self, client: TestClient) -> None:
|
||||||
|
"""GET /workouts should require admin login."""
|
||||||
|
response = client.get("/workouts", follow_redirects=False)
|
||||||
|
assert response.status_code in (401, 302, 303)
|
||||||
83
tests/test_workout_session_service.py
Normal file
83
tests/test_workout_session_service.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Tests for the WorkoutSessionService class."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, Session, create_engine
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.workout_day import WorkoutDay
|
||||||
|
from app.models.workout_session import WorkoutSession
|
||||||
|
from app.services.workout_session_service import WorkoutSessionService
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkoutSessionService:
|
||||||
|
"""Tests for workout session CRUD operations."""
|
||||||
|
|
||||||
|
def _setup(self):
|
||||||
|
"""Create an in-memory DB with prerequisite data."""
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
|
||||||
|
user = User(username="phil", password_hash="h", display_name="Phillip")
|
||||||
|
day = WorkoutDay(name="Push", day_number=1, description="Push day")
|
||||||
|
session.add_all([user, day])
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
session.refresh(day)
|
||||||
|
|
||||||
|
service = WorkoutSessionService(session)
|
||||||
|
return session, service, user, day
|
||||||
|
|
||||||
|
def test_get_or_create_session_creates_new(self) -> None:
|
||||||
|
"""get_or_create_session should create a new session if none exists."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
ws = service.get_or_create_session(
|
||||||
|
user_id=user.id,
|
||||||
|
workout_day_id=day.id,
|
||||||
|
session_date=date.today(),
|
||||||
|
)
|
||||||
|
assert ws.id is not None
|
||||||
|
assert ws.user_id == user.id
|
||||||
|
assert ws.workout_day_id == day.id
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_get_or_create_session_returns_existing(self) -> None:
|
||||||
|
"""get_or_create_session should return existing session for same day."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
ws1 = service.get_or_create_session(user.id, day.id, date.today())
|
||||||
|
ws2 = service.get_or_create_session(user.id, day.id, date.today())
|
||||||
|
assert ws1.id == ws2.id
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_list_sessions_for_user(self) -> None:
|
||||||
|
"""list_sessions should return all sessions for a user."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
service.get_or_create_session(user.id, day.id, date.today())
|
||||||
|
sessions = service.list_sessions(user_id=user.id)
|
||||||
|
assert len(sessions) == 1
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_list_sessions_empty(self) -> None:
|
||||||
|
"""list_sessions should return empty list for user with no sessions."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
sessions = service.list_sessions(user_id=user.id)
|
||||||
|
assert len(sessions) == 0
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_get_session_by_id(self) -> None:
|
||||||
|
"""get_session_by_id should return the correct session."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
ws = service.get_or_create_session(user.id, day.id, date.today())
|
||||||
|
found = service.get_session_by_id(ws.id)
|
||||||
|
assert found is not None
|
||||||
|
assert found.id == ws.id
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_update_session_notes(self) -> None:
|
||||||
|
"""update_session should allow updating notes."""
|
||||||
|
session, service, user, day = self._setup()
|
||||||
|
ws = service.get_or_create_session(user.id, day.id, date.today())
|
||||||
|
updated = service.update_session(ws.id, notes="Great workout!")
|
||||||
|
assert updated.notes == "Great workout!"
|
||||||
|
session.close()
|
||||||
8
uv.lock
generated
Normal file
8
uv.lock
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sneakyswole"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
Binary file not shown.
Reference in New Issue
Block a user