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:
@@ -30,6 +30,8 @@ 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.auth_service import AuthService
|
||||
from app.services.user_service import UserService
|
||||
@@ -111,6 +113,8 @@ def create_app() -> FastAPI:
|
||||
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
|
||||
engine = get_engine(settings.database_url)
|
||||
|
||||
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/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,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
173
app/services/analytics_service.py
Normal file
173
app/services/analytics_service.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""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.
|
||||
"""
|
||||
sessions = self._session.exec(
|
||||
select(WorkoutSession)
|
||||
.where(WorkoutSession.user_id == user_id)
|
||||
.order_by(WorkoutSession.date.desc())
|
||||
).all()
|
||||
total_sessions = len(sessions)
|
||||
|
||||
total_volume = 0.0
|
||||
total_sets = 0
|
||||
for ws in sessions:
|
||||
logs = self._session.exec(
|
||||
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
|
||||
).all()
|
||||
for log_entry in logs:
|
||||
total_sets += 1
|
||||
weight = _weight_to_float(log_entry.weight_used)
|
||||
total_volume += log_entry.reps_completed * weight
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
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
|
||||
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 %}
|
||||
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 %}
|
||||
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 %}
|
||||
@@ -19,6 +19,11 @@
|
||||
</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>
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
</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>
|
||||
|
||||
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 %}
|
||||
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>
|
||||
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()
|
||||
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, 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, 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, 303)
|
||||
Reference in New Issue
Block a user