feat: replace wk1/wk4 targets with 6→8→10→12 rep ladder progression
Simplifies the progression model to a universal rep ladder: every exercise follows 6→8→10→12 reps at current weight, then +5 lbs and reset to 6. Replaces per-user wk1/wk4 rep and weight targets with a single starting_weight field. - Add Alembic migration to drop wk1_reps/wk4_reps/wk1_weight/wk4_weight, add starting_weight (migrated from wk1_weight) - Run Alembic migrations on app startup instead of create_all, with auto-detection and stamping for legacy databases - Include alembic/ and alembic.ini in Docker image - Rewrite progression_service.get_suggestion() with ladder logic: climb, hold, weight_increase, hold_at_top, deload - Replace wk1/wk4 grid in exercise cards with rep ladder progress bar - Add color-coded progression badges by type - Change weight log input from text to number with pre-filled suggestion - Normalize weight input in routes (0→BW, bare number→N lbs) - Remove schedule page (route, template, nav link, tests) - Simplify user_programs.yaml from 4 fields to 1 per exercise - Update all tests for new schema and progression logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Tests for the ProgressionService class."""
|
||||
"""Tests for the ProgressionService class (rep ladder model)."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
@@ -15,9 +15,9 @@ from app.services.progression_service import ProgressionService
|
||||
|
||||
|
||||
class TestProgressionService:
|
||||
"""Tests for the auto-progression engine."""
|
||||
"""Tests for the rep ladder auto-progression engine."""
|
||||
|
||||
def _setup(self):
|
||||
def _setup(self, starting_weight="30 lbs"):
|
||||
"""Create an in-memory DB with a user, exercise, and program."""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
@@ -37,8 +37,7 @@ class TestProgressionService:
|
||||
|
||||
program = UserExerciseProgram(
|
||||
user_id=user.id, exercise_id=exercise.id,
|
||||
wk1_reps="8", wk4_reps="12",
|
||||
wk1_weight="30 lbs", wk4_weight="40 lbs",
|
||||
starting_weight=starting_weight,
|
||||
)
|
||||
session.add(program)
|
||||
session.commit()
|
||||
@@ -47,101 +46,115 @@ class TestProgressionService:
|
||||
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)
|
||||
def _log_session(self, session, user, day, exercise, reps, weight, felt_easy, days_ago):
|
||||
"""Helper to log a workout session with 3 sets."""
|
||||
ws = WorkoutSession(
|
||||
user_id=user.id, workout_day_id=day.id,
|
||||
date=date.today() - timedelta(days=7),
|
||||
date=date.today() - timedelta(days=days_ago),
|
||||
)
|
||||
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,
|
||||
set_number=set_num, reps_completed=reps,
|
||||
weight_used=weight, felt_easy=felt_easy,
|
||||
))
|
||||
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."""
|
||||
def test_baseline_no_logs(self) -> None:
|
||||
"""Should return 3x6 @ starting_weight 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["suggested_reps"] == 6
|
||||
assert suggestion["suggested_weight"] == "30 lbs"
|
||||
assert suggestion["suggested_sets"] == 3
|
||||
assert suggestion["ladder_position"] == 0
|
||||
assert suggestion["progression_type"] == "baseline"
|
||||
session.close()
|
||||
|
||||
def test_climb_when_felt_easy(self) -> None:
|
||||
"""Should climb from 6 to 8 reps when all sets felt easy."""
|
||||
session, service, user, day, exercise, program = self._setup()
|
||||
self._log_session(session, user, day, exercise, reps=6, weight="30 lbs", felt_easy=True, days_ago=7)
|
||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||
assert suggestion["suggested_reps"] == 8
|
||||
assert suggestion["suggested_weight"] == "30 lbs"
|
||||
assert suggestion["progression_type"] == "climb"
|
||||
assert suggestion["ladder_position"] == 1
|
||||
session.close()
|
||||
|
||||
def test_hold_when_not_felt_easy(self) -> None:
|
||||
"""Should hold at current reps when not all sets felt easy."""
|
||||
session, service, user, day, exercise, program = self._setup()
|
||||
self._log_session(session, user, day, exercise, reps=8, weight="30 lbs", felt_easy=False, days_ago=7)
|
||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||
assert suggestion["suggested_reps"] == 8
|
||||
assert suggestion["suggested_weight"] == "30 lbs"
|
||||
assert suggestion["progression_type"] == "hold"
|
||||
session.close()
|
||||
|
||||
def test_weight_increase_at_12_felt_easy(self) -> None:
|
||||
"""Should suggest +5 lbs when at 12 reps and all felt easy."""
|
||||
session, service, user, day, exercise, program = self._setup()
|
||||
self._log_session(session, user, day, exercise, reps=12, weight="30 lbs", felt_easy=True, days_ago=7)
|
||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||
assert suggestion["suggested_reps"] == 6
|
||||
assert suggestion["suggested_weight"] == "35 lbs"
|
||||
assert suggestion["progression_type"] == "weight_increase"
|
||||
assert suggestion["ladder_position"] == 0
|
||||
session.close()
|
||||
|
||||
def test_deload_after_4_struggling_sessions(self) -> None:
|
||||
"""Should deload after 4 consecutive struggling sessions."""
|
||||
session, service, user, day, exercise, program = self._setup()
|
||||
for week in range(4):
|
||||
self._log_session(session, user, day, exercise, reps=8, weight="40 lbs", felt_easy=False, days_ago=(4 - week) * 7)
|
||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||
assert suggestion["progression_type"] == "deload"
|
||||
assert suggestion["suggested_reps"] == 6
|
||||
assert "32" in suggestion["suggested_weight"] # 40 * 0.8 = 32
|
||||
session.close()
|
||||
|
||||
def test_bodyweight_hold_at_top(self) -> None:
|
||||
"""Bodyweight exercises should hold at 12 reps, no weight increase."""
|
||||
session, service, user, day, exercise, program = self._setup(starting_weight="BW")
|
||||
self._log_session(session, user, day, exercise, reps=12, weight="BW", felt_easy=True, days_ago=7)
|
||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||
assert suggestion["suggested_reps"] == 12
|
||||
assert suggestion["suggested_weight"] == "BW"
|
||||
assert suggestion["progression_type"] == "hold_at_top"
|
||||
session.close()
|
||||
|
||||
def test_bodyweight_climb(self) -> None:
|
||||
"""Bodyweight exercises should climb the ladder normally."""
|
||||
session, service, user, day, exercise, program = self._setup(starting_weight="BW")
|
||||
self._log_session(session, user, day, exercise, reps=8, weight="BW", felt_easy=True, days_ago=7)
|
||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||
assert suggestion["suggested_reps"] == 10
|
||||
assert suggestion["progression_type"] == "climb"
|
||||
session.close()
|
||||
|
||||
def test_no_program(self) -> None:
|
||||
"""Should return no_program when no program exists."""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
session = Session(engine)
|
||||
user = User(username="u", display_name="U")
|
||||
exercise = Exercise(
|
||||
name="Ex", muscle_group="Test",
|
||||
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
|
||||
)
|
||||
session.add_all([user, exercise])
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
session.refresh(exercise)
|
||||
service = ProgressionService(session)
|
||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||
assert suggestion["progression_type"] == "no_program"
|
||||
session.close()
|
||||
|
||||
def test_record_progression(self) -> None:
|
||||
"""record_progression should write to progress_log table."""
|
||||
session, service, user, day, exercise, program = self._setup()
|
||||
@@ -152,10 +165,22 @@ class TestProgressionService:
|
||||
suggested_weight="30 lbs",
|
||||
actual_reps=10,
|
||||
actual_weight="30 lbs",
|
||||
progression_type="reps_increase",
|
||||
progression_type="climb",
|
||||
)
|
||||
from sqlmodel import select
|
||||
logs = session.exec(select(ProgressLog)).all()
|
||||
assert len(logs) == 1
|
||||
assert logs[0].progression_applied == "reps_increase"
|
||||
assert logs[0].progression_applied == "climb"
|
||||
session.close()
|
||||
|
||||
def test_full_ladder_climb(self) -> None:
|
||||
"""Should climb 6 -> 8 -> 10 -> 12 across sessions."""
|
||||
session, service, user, day, exercise, program = self._setup()
|
||||
expected_climbs = [(6, 8), (8, 10), (10, 12)]
|
||||
for i, (current, expected_next) in enumerate(expected_climbs):
|
||||
self._log_session(session, user, day, exercise, reps=current, weight="30 lbs", felt_easy=True, days_ago=(len(expected_climbs) - i) * 7)
|
||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||
# Latest session is 10 reps felt easy -> should suggest 12
|
||||
assert suggestion["suggested_reps"] == 12
|
||||
assert suggestion["progression_type"] == "climb"
|
||||
session.close()
|
||||
|
||||
Reference in New Issue
Block a user