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

View 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()

View 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)

View 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()

View 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)