feat: add Phase 4 Logging & Tracking — inline set logging, history views

Add workout logging so users can track sets, reps, weight, and a
"felt easy?" toggle inline from the workout day view via HTMX.
Sessions auto-create on first log. History page shows past sessions
with detailed per-exercise breakdowns.

New services: WorkoutSessionService, LogService
New routes: POST /log, /log/{id}/edit, /log/{id}/delete, GET /history, /history/{id}
New templates: log_form, log_entry, session_card, log_history, session_detail
Modified: exercise_card (inline logging), nav (History link), workouts route (session context)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:12:23 -06:00
parent 23754ea239
commit e35b78ae87
17 changed files with 1071 additions and 1 deletions

113
app/routes/history.py Normal file
View 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,
})

177
app/routes/logging.py Normal file
View File

@@ -0,0 +1,177 @@
"""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.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"])
@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
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,
})
@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
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,
})
@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
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,
})
return HTMLResponse("")

View File

@@ -4,6 +4,8 @@ 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
@@ -13,6 +15,8 @@ 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.workout_session_service import WorkoutSessionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
logger = structlog.get_logger(__name__)
@@ -77,6 +81,7 @@ async def workout_day_detail(
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:
@@ -86,6 +91,27 @@ async def workout_day_detail(
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
# 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,
@@ -94,5 +120,7 @@ async def workout_day_detail(
"exercises": exercises,
"programs": programs,
"active_profile": active_profile,
"existing_logs": existing_logs,
"workout_day_id": workout_day_id,
"admin": admin,
})