feat: add Phase 5 Progression & Analytics — smart suggestions, dashboard, schedule

Add auto-progression engine (ProgressionService) with rep increase, weight
increase, deload, and felt-easy acceleration rules. Add AnalyticsService for
user stats, exercise progress charts, and volume-by-day data. New dashboard
and schedule routes with Chart.js visualizations. Progression badges shown
inline on workout day view. Navigation updated with Dashboard and Schedule links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:26:23 -06:00
parent e35b78ae87
commit 134542b66f
19 changed files with 1209 additions and 0 deletions

102
app/routes/dashboard.py Normal file
View 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/schedule.py Normal file
View 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,
})

View File

@@ -16,6 +16,7 @@ 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
@@ -99,6 +100,15 @@ async def workout_day_detail(
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)
@@ -121,6 +131,7 @@ async def workout_day_detail(
"programs": programs,
"active_profile": active_profile,
"existing_logs": existing_logs,
"suggestions": suggestions,
"workout_day_id": workout_day_id,
"admin": admin,
})