Merge branch 'feature/rep-ladder-progression'
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
data/
|
||||
docs/
|
||||
tests/
|
||||
alembic/
|
||||
alembic/__pycache__
|
||||
*.md
|
||||
*.lock
|
||||
.claude/
|
||||
|
||||
@@ -14,6 +14,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
COPY config/ ./config/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY alembic.ini .
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
45
alembic/versions/b2c3d4e5f6g7_simplify_to_starting_weight.py
Normal file
45
alembic/versions/b2c3d4e5f6g7_simplify_to_starting_weight.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""simplify user_exercise_programs to starting_weight
|
||||
|
||||
Revision ID: b2c3d4e5f6g7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-03-13
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b2c3d4e5f6g7'
|
||||
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||
batch_op.add_column(sa.Column('starting_weight', sa.String(), nullable=False, server_default=''))
|
||||
|
||||
op.execute("UPDATE user_exercise_programs SET starting_weight = wk1_weight")
|
||||
|
||||
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||
batch_op.drop_column('wk1_reps')
|
||||
batch_op.drop_column('wk4_reps')
|
||||
batch_op.drop_column('wk1_weight')
|
||||
batch_op.drop_column('wk4_weight')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||
batch_op.add_column(sa.Column('wk1_reps', sa.String(), server_default=''))
|
||||
batch_op.add_column(sa.Column('wk4_reps', sa.String(), server_default=''))
|
||||
batch_op.add_column(sa.Column('wk1_weight', sa.String(), server_default=''))
|
||||
batch_op.add_column(sa.Column('wk4_weight', sa.String(), server_default=''))
|
||||
|
||||
op.execute("UPDATE user_exercise_programs SET wk1_weight = starting_weight")
|
||||
|
||||
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||
batch_op.drop_column('starting_weight')
|
||||
54
app/main.py
54
app/main.py
@@ -7,10 +7,12 @@ static files, and structured logging.
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from alembic import command as alembic_command
|
||||
from alembic.config import Config as AlembicConfig
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import SQLModel, Session
|
||||
from sqlmodel import Session
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
@@ -29,7 +31,6 @@ 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.user_service import UserService
|
||||
from app.utils.auth import NoProfileSelectedError
|
||||
@@ -64,6 +65,50 @@ class NavContextMiddleware(BaseHTTPMiddleware):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _run_migrations(database_url: str) -> None:
|
||||
"""Run Alembic migrations to bring the DB schema up to date.
|
||||
|
||||
On a fresh DB this creates all tables via the initial migration.
|
||||
On an existing DB created by create_all (no alembic_version table),
|
||||
stamps the last known pre-migration revision, then upgrades.
|
||||
"""
|
||||
from sqlalchemy import create_engine as sa_create_engine, inspect
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
alembic_cfg = AlembicConfig(str(project_root / "alembic.ini"))
|
||||
alembic_cfg.set_main_option("script_location", str(project_root / "alembic"))
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
engine = sa_create_engine(database_url)
|
||||
inspector = inspect(engine)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
if existing_tables and "alembic_version" not in existing_tables:
|
||||
# DB was created by create_all — stamp it at the last revision
|
||||
# that matches the current schema so migrations run from there.
|
||||
# Check which schema state we're in by looking at columns.
|
||||
columns = {c["name"] for c in inspector.get_columns("user_exercise_programs")}
|
||||
if "wk1_reps" in columns:
|
||||
# Old schema: stamp at remove-auth migration (before rep ladder)
|
||||
alembic_command.stamp(alembic_cfg, "a1b2c3d4e5f6")
|
||||
logger.info("alembic_stamped", revision="a1b2c3d4e5f6", reason="legacy_db_old_schema")
|
||||
elif "starting_weight" in columns:
|
||||
# New schema already: stamp at head
|
||||
alembic_command.stamp(alembic_cfg, "head")
|
||||
logger.info("alembic_stamped", revision="head", reason="legacy_db_new_schema")
|
||||
else:
|
||||
# Unknown state — stamp at initial and let migrations sort it out
|
||||
alembic_command.stamp(alembic_cfg, "1855836abf6c")
|
||||
logger.info("alembic_stamped", revision="1855836abf6c", reason="legacy_db_unknown")
|
||||
elif not existing_tables:
|
||||
# Fresh DB — create alembic_version table so upgrade starts from scratch
|
||||
logger.info("fresh_database_detected")
|
||||
|
||||
engine.dispose()
|
||||
alembic_command.upgrade(alembic_cfg, "head")
|
||||
logger.info("migrations_applied")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application.
|
||||
|
||||
@@ -103,11 +148,10 @@ def create_app() -> FastAPI:
|
||||
app.include_router(profiles_router)
|
||||
app.include_router(workouts_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(schedule_router)
|
||||
|
||||
# Database setup
|
||||
# Database setup — run Alembic migrations instead of create_all
|
||||
engine = get_engine(settings.database_url)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
_run_migrations(settings.database_url)
|
||||
app.state.engine = engine
|
||||
|
||||
# DB session dependency for routes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""UserExerciseProgram model for per-user exercise programming.
|
||||
|
||||
Links a user to an exercise with week 1 and week 4 rep/weight targets.
|
||||
Links a user to an exercise with a starting weight for the rep ladder.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -16,10 +16,7 @@ class UserExerciseProgram(SQLModel, table=True):
|
||||
id: Primary key, auto-incremented.
|
||||
user_id: FK to users table.
|
||||
exercise_id: FK to exercises table.
|
||||
wk1_reps: Week 1 target reps (string to support "30 sec" style).
|
||||
wk4_reps: Week 4 target reps.
|
||||
wk1_weight: Week 1 target weight (e.g., "30 lbs", "BW").
|
||||
wk4_weight: Week 4 target weight.
|
||||
starting_weight: Starting weight (e.g., "30 lbs", "BW").
|
||||
created_at: Timestamp when the record was created.
|
||||
updated_at: Timestamp of the last update.
|
||||
"""
|
||||
@@ -29,9 +26,6 @@ class UserExerciseProgram(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="users.id", index=True)
|
||||
exercise_id: int = Field(foreign_key="exercises.id", index=True)
|
||||
wk1_reps: str = Field(default="")
|
||||
wk4_reps: str = Field(default="")
|
||||
wk1_weight: str = Field(default="")
|
||||
wk4_weight: str = Field(default="")
|
||||
starting_weight: str = Field(default="")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
@@ -22,6 +22,23 @@ logger = structlog.get_logger(__name__)
|
||||
router = APIRouter(prefix="/log", tags=["logging"])
|
||||
|
||||
|
||||
def _normalize_weight(raw: str) -> str:
|
||||
"""Convert numeric weight input to display format.
|
||||
|
||||
'0' or '' → 'BW', bare number → '{n} lbs', already formatted → pass through.
|
||||
"""
|
||||
raw = raw.strip()
|
||||
if not raw or raw == "0":
|
||||
return "BW"
|
||||
try:
|
||||
num = float(raw)
|
||||
if num == int(num):
|
||||
return f"{int(num)} lbs"
|
||||
return f"{num} lbs"
|
||||
except ValueError:
|
||||
return raw
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
async def log_set(
|
||||
request: Request,
|
||||
@@ -38,7 +55,7 @@ async def log_set(
|
||||
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", "")
|
||||
weight = _normalize_weight(form.get("weight", ""))
|
||||
felt_easy = form.get("felt_easy") == "on"
|
||||
|
||||
# Get or create today's session
|
||||
@@ -89,7 +106,7 @@ async def edit_log(
|
||||
log_service.update_log(
|
||||
log_id,
|
||||
reps_completed=int(form.get("reps", 0)),
|
||||
weight_used=form.get("weight", ""),
|
||||
weight_used=_normalize_weight(form.get("weight", "")),
|
||||
felt_easy=form.get("felt_easy") == "on",
|
||||
notes=form.get("notes"),
|
||||
)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
"""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 require_active_profile
|
||||
|
||||
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),
|
||||
profile: User = Depends(require_active_profile),
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
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
|
||||
ws_service = WorkoutSessionService(session)
|
||||
sessions_list = ws_service.list_sessions(
|
||||
user_id=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": profile,
|
||||
})
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Auto-progression engine for workout programming.
|
||||
"""Auto-progression engine using a rep ladder model.
|
||||
|
||||
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
|
||||
Every exercise follows the same 6 → 8 → 10 → 12 rep ladder at current weight.
|
||||
At 12 reps with all sets felt easy, weight increases by 5 lbs and reps reset to 6.
|
||||
Deload triggers after 4+ consecutive struggling sessions (-20% weight, reset to 6).
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -21,6 +19,12 @@ from app.models.workout_session import WorkoutSession
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
REP_LADDER = [6, 8, 10, 12]
|
||||
SETS_PER_EXERCISE = 3
|
||||
WEIGHT_INCREMENT = 5
|
||||
DELOAD_FACTOR = 0.8
|
||||
STRUGGLE_THRESHOLD = 4
|
||||
|
||||
|
||||
def _parse_weight(weight_str: str) -> Optional[float]:
|
||||
"""Extract numeric weight from a string like '30 lbs' or 'BW'.
|
||||
@@ -53,8 +57,27 @@ def _format_weight(weight_lbs: Optional[float]) -> str:
|
||||
return f"{weight_lbs:.1f} lbs"
|
||||
|
||||
|
||||
def _snap_to_ladder(reps: int) -> int:
|
||||
"""Clamp reps into the ladder range [6, 12]."""
|
||||
return max(REP_LADDER[0], min(reps, REP_LADDER[-1]))
|
||||
|
||||
|
||||
def _ladder_position(reps: int) -> int:
|
||||
"""Return the index (0-3) of reps in REP_LADDER, or -1 if outside."""
|
||||
snapped = _snap_to_ladder(reps)
|
||||
try:
|
||||
return REP_LADDER.index(snapped)
|
||||
except ValueError:
|
||||
# reps is in range but not on a ladder step (e.g. 7, 9, 11)
|
||||
# find the highest step at or below current reps
|
||||
for i in range(len(REP_LADDER) - 1, -1, -1):
|
||||
if REP_LADDER[i] <= snapped:
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
class ProgressionService:
|
||||
"""Implements the auto-progression engine.
|
||||
"""Implements the rep ladder auto-progression engine.
|
||||
|
||||
Args:
|
||||
session: An active SQLModel Session.
|
||||
@@ -120,14 +143,11 @@ class ProgressionService:
|
||||
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.
|
||||
"""Generate a progression suggestion using the rep ladder model.
|
||||
|
||||
Returns:
|
||||
Dict with keys: suggested_reps, suggested_weight,
|
||||
progression_type, message.
|
||||
Dict with keys: suggested_reps, suggested_weight, suggested_sets,
|
||||
ladder_position, progression_type, message.
|
||||
"""
|
||||
program = self._get_program(user_id, exercise_id)
|
||||
|
||||
@@ -135,107 +155,118 @@ class ProgressionService:
|
||||
return {
|
||||
"suggested_reps": 0,
|
||||
"suggested_weight": "",
|
||||
"suggested_sets": SETS_PER_EXERCISE,
|
||||
"ladder_position": -1,
|
||||
"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
|
||||
|
||||
starting_weight = program.starting_weight
|
||||
recent = self._get_recent_sessions(user_id, exercise_id, limit=5)
|
||||
|
||||
# No history — baseline suggestion
|
||||
if not recent:
|
||||
return {
|
||||
"suggested_reps": wk1_reps,
|
||||
"suggested_weight": wk1_weight,
|
||||
"suggested_reps": REP_LADDER[0],
|
||||
"suggested_weight": starting_weight,
|
||||
"suggested_sets": SETS_PER_EXERCISE,
|
||||
"ladder_position": 0,
|
||||
"progression_type": "baseline",
|
||||
"message": f"Start with {wk1_reps} reps @ {wk1_weight}.",
|
||||
"message": f"Start with {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ {starting_weight}.",
|
||||
}
|
||||
|
||||
latest = recent[0]
|
||||
current_reps = int(round(latest["avg_reps"]))
|
||||
current_reps = _snap_to_ladder(int(round(latest["avg_reps"])))
|
||||
current_weight = latest["weight"]
|
||||
current_weight_num = _parse_weight(current_weight)
|
||||
consecutive_sessions = len(recent)
|
||||
all_felt_easy = latest["all_felt_easy"]
|
||||
|
||||
# Rule: Deload at week 5 (4 consecutive sessions completed)
|
||||
if consecutive_sessions >= 4:
|
||||
# Count consecutive struggling sessions (not felt easy)
|
||||
struggle_count = 0
|
||||
for s in recent:
|
||||
if not s["all_felt_easy"]:
|
||||
struggle_count += 1
|
||||
else:
|
||||
break
|
||||
|
||||
# Deload: 4+ consecutive struggling sessions
|
||||
if struggle_count >= STRUGGLE_THRESHOLD:
|
||||
if current_weight_num is not None:
|
||||
deload_weight = current_weight_num * 0.8
|
||||
deload_weight = current_weight_num * DELOAD_FACTOR
|
||||
return {
|
||||
"suggested_reps": wk1_reps,
|
||||
"suggested_reps": REP_LADDER[0],
|
||||
"suggested_weight": _format_weight(deload_weight),
|
||||
"suggested_sets": SETS_PER_EXERCISE,
|
||||
"ladder_position": 0,
|
||||
"progression_type": "deload",
|
||||
"message": (
|
||||
f"Deload week: {wk1_reps} reps @ "
|
||||
f"Deload: {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ "
|
||||
f"{_format_weight(deload_weight)} (-20%)."
|
||||
),
|
||||
}
|
||||
# Bodyweight — can't reduce weight, just reset reps
|
||||
return {
|
||||
"suggested_reps": wk1_reps,
|
||||
"suggested_reps": REP_LADDER[0],
|
||||
"suggested_weight": current_weight,
|
||||
"suggested_sets": SETS_PER_EXERCISE,
|
||||
"ladder_position": 0,
|
||||
"progression_type": "deload",
|
||||
"message": f"Deload week: reset to {wk1_reps} reps.",
|
||||
"message": f"Deload: reset to {SETS_PER_EXERCISE}x{REP_LADDER[0]}.",
|
||||
}
|
||||
|
||||
# Rule: Weight increase if at rep target and felt easy
|
||||
if current_reps >= wk4_reps and latest["all_felt_easy"]:
|
||||
# At top of ladder (12 reps) and felt easy
|
||||
if current_reps >= REP_LADDER[-1] and all_felt_easy:
|
||||
if current_weight_num is not None:
|
||||
new_weight = current_weight_num + 5
|
||||
new_weight = current_weight_num + WEIGHT_INCREMENT
|
||||
return {
|
||||
"suggested_reps": wk1_reps,
|
||||
"suggested_reps": REP_LADDER[0],
|
||||
"suggested_weight": _format_weight(new_weight),
|
||||
"suggested_sets": SETS_PER_EXERCISE,
|
||||
"ladder_position": 0,
|
||||
"progression_type": "weight_increase",
|
||||
"message": (
|
||||
f"Weight up: {wk1_reps} reps @ "
|
||||
f"{_format_weight(new_weight)} (+5 lbs)."
|
||||
f"Weight up: {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ "
|
||||
f"{_format_weight(new_weight)} (+{WEIGHT_INCREMENT} 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)
|
||||
# Bodyweight — hold at top
|
||||
return {
|
||||
"suggested_reps": new_reps,
|
||||
"suggested_reps": REP_LADDER[-1],
|
||||
"suggested_weight": current_weight,
|
||||
"progression_type": "reps_increase",
|
||||
"suggested_sets": SETS_PER_EXERCISE,
|
||||
"ladder_position": len(REP_LADDER) - 1,
|
||||
"progression_type": "hold_at_top",
|
||||
"message": (
|
||||
f"Reps up: {new_reps} reps @ {current_weight} "
|
||||
f"(+{increment})."
|
||||
f"Hold: {SETS_PER_EXERCISE}x{REP_LADDER[-1]} @ {current_weight} "
|
||||
f"(bodyweight max)."
|
||||
),
|
||||
}
|
||||
|
||||
# Hold: at target, waiting for biweekly weight increase
|
||||
# Below top and felt easy — climb to next ladder step
|
||||
if all_felt_easy:
|
||||
pos = _ladder_position(current_reps)
|
||||
next_pos = min(pos + 1, len(REP_LADDER) - 1)
|
||||
next_reps = REP_LADDER[next_pos]
|
||||
return {
|
||||
"suggested_reps": next_reps,
|
||||
"suggested_weight": current_weight,
|
||||
"suggested_sets": SETS_PER_EXERCISE,
|
||||
"ladder_position": next_pos,
|
||||
"progression_type": "climb",
|
||||
"message": (
|
||||
f"Climb: {SETS_PER_EXERCISE}x{next_reps} @ {current_weight}."
|
||||
),
|
||||
}
|
||||
|
||||
# Not all felt easy — hold at current
|
||||
pos = _ladder_position(current_reps)
|
||||
return {
|
||||
"suggested_reps": current_reps,
|
||||
"suggested_weight": current_weight,
|
||||
"suggested_sets": SETS_PER_EXERCISE,
|
||||
"ladder_position": pos,
|
||||
"progression_type": "hold",
|
||||
"message": f"Hold at {current_reps} reps @ {current_weight}.",
|
||||
"message": f"Hold: {SETS_PER_EXERCISE}x{current_reps} @ {current_weight}.",
|
||||
}
|
||||
|
||||
def record_progression(
|
||||
|
||||
@@ -157,10 +157,7 @@ class SeedService:
|
||||
uep = UserExerciseProgram(
|
||||
user_id=user.id,
|
||||
exercise_id=exercise.id,
|
||||
wk1_reps=str(ex_data.get("wk1_reps", "")),
|
||||
wk4_reps=str(ex_data.get("wk4_reps", "")),
|
||||
wk1_weight=str(ex_data.get("wk1_weight", "")),
|
||||
wk4_weight=str(ex_data.get("wk4_weight", "")),
|
||||
starting_weight=str(ex_data.get("starting_weight", "")),
|
||||
)
|
||||
self._session.add(uep)
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -7,16 +7,26 @@
|
||||
</header>
|
||||
|
||||
{% if program %}
|
||||
<div class="grid">
|
||||
<div>
|
||||
<small>Week 1</small>
|
||||
<p>{{ program.wk1_reps }} reps @ {{ program.wk1_weight }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<small>Week 4</small>
|
||||
<p>{{ program.wk4_reps }} reps @ {{ program.wk4_weight }}</p>
|
||||
{% set suggestion = suggestions[exercise.id] if suggestions and exercise.id in suggestions else None %}
|
||||
{% set pos = suggestion.ladder_position if suggestion and suggestion.ladder_position is defined else -1 %}
|
||||
{% if pos >= 0 %}
|
||||
<div style="display: flex; gap: 0.25rem; align-items: center; margin-bottom: 0.75rem;">
|
||||
{% for step in [6, 8, 10, 12] %}
|
||||
<div style="flex: 1; text-align: center; padding: 0.35rem 0;
|
||||
border-radius: 0.25rem; font-size: 0.85rem; font-weight: 600;
|
||||
{% if loop.index0 <= pos %}
|
||||
background: var(--pico-primary); color: var(--pico-primary-inverse);
|
||||
{% else %}
|
||||
background: var(--pico-muted-border-color); color: var(--pico-muted-color);
|
||||
{% endif %}">
|
||||
{{ step }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<small style="margin-left: 0.5rem; white-space: nowrap;">@ {{ suggestion.suggested_weight }}</small>
|
||||
</div>
|
||||
{% else %}
|
||||
<p><small>Starting weight: {{ program.starting_weight }}</small></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if suggestions and suggestions[exercise.id] %}
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
<input type="number" name="reps" placeholder="Reps"
|
||||
min="0" max="100" required
|
||||
style="width:5rem; margin-bottom:0;">
|
||||
<input type="text" name="weight" placeholder="Weight (lbs)"
|
||||
required
|
||||
<input type="number" name="weight" placeholder="Weight (lbs)"
|
||||
min="0" max="999" step="0.5" required
|
||||
{% if suggested_weight and suggested_weight != "BW" %}
|
||||
value="{{ suggested_weight|replace(' lbs', '') }}"
|
||||
{% elif suggested_weight == "BW" %}
|
||||
value="0"
|
||||
{% endif %}
|
||||
style="width:8rem; margin-bottom:0;">
|
||||
<label style="display:flex; align-items:center; gap:0.3rem; margin-bottom:0; white-space:nowrap;">
|
||||
<input type="checkbox" name="felt_easy" role="switch" style="margin-bottom:0;">
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
</li>
|
||||
<li><a href="/">Home</a></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>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
{% if suggestion and suggestion.progression_type != "no_program" %}
|
||||
{% set badge_colors = {
|
||||
"deload": "var(--pico-del-color)",
|
||||
"weight_increase": "var(--pico-ins-color)",
|
||||
"climb": "var(--pico-primary)",
|
||||
"hold": "var(--pico-muted-color)",
|
||||
"hold_at_top": "var(--pico-muted-color)",
|
||||
"baseline": "var(--pico-primary)",
|
||||
} %}
|
||||
{% set border_color = badge_colors.get(suggestion.progression_type, "var(--pico-primary)") %}
|
||||
<div style="background: rgba(99, 102, 241, 0.1);
|
||||
border-left: 3px solid var(--pico-primary);
|
||||
border-left: 3px solid {{ border_color }};
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0 0.25rem 0.25rem 0;">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# SneakySwole User Programs
|
||||
# Per-user exercise programming with week 1 and week 4 targets.
|
||||
# Per-user exercise programming with starting weights for rep ladder progression.
|
||||
# Rep ladder: 6 → 8 → 10 → 12 reps at current weight, then +5 lbs and reset.
|
||||
# Update this file to adjust programming, then re-run the seed script.
|
||||
|
||||
programs:
|
||||
@@ -11,111 +12,51 @@ programs:
|
||||
exercises:
|
||||
# Day 1 — Push
|
||||
- name: "DB Chest Press (Floor)"
|
||||
wk1_reps: 8
|
||||
wk4_reps: 12
|
||||
wk1_weight: "30 lbs"
|
||||
wk4_weight: "40 lbs"
|
||||
starting_weight: "30 lbs"
|
||||
- name: "DB Shoulder Press (Seated)"
|
||||
wk1_reps: 8
|
||||
wk4_reps: 12
|
||||
wk1_weight: "20 lbs"
|
||||
wk4_weight: "30 lbs"
|
||||
starting_weight: "20 lbs"
|
||||
- name: "DB Lateral Raise"
|
||||
wk1_reps: 10
|
||||
wk4_reps: 15
|
||||
wk1_weight: "10 lbs"
|
||||
wk4_weight: "15 lbs"
|
||||
starting_weight: "10 lbs"
|
||||
- name: "Push-Up (Incline if needed)"
|
||||
wk1_reps: 6
|
||||
wk4_reps: 15
|
||||
wk1_weight: "BW"
|
||||
wk4_weight: "BW"
|
||||
starting_weight: "BW"
|
||||
- name: "DB Tricep Overhead Ext."
|
||||
wk1_reps: 10
|
||||
wk4_reps: 15
|
||||
wk1_weight: "15 lbs"
|
||||
wk4_weight: "25 lbs"
|
||||
starting_weight: "15 lbs"
|
||||
|
||||
# Day 2 — Pull
|
||||
- name: "DB Bent-Over Row (Supported)"
|
||||
wk1_reps: 8
|
||||
wk4_reps: 12
|
||||
wk1_weight: "30 lbs"
|
||||
wk4_weight: "45 lbs"
|
||||
starting_weight: "30 lbs"
|
||||
- name: "DB Rear Delt Fly"
|
||||
wk1_reps: 10
|
||||
wk4_reps: 15
|
||||
wk1_weight: "10 lbs"
|
||||
wk4_weight: "15 lbs"
|
||||
starting_weight: "10 lbs"
|
||||
- name: "DB Hammer Curl"
|
||||
wk1_reps: 10
|
||||
wk4_reps: 15
|
||||
wk1_weight: "20 lbs"
|
||||
wk4_weight: "30 lbs"
|
||||
starting_weight: "20 lbs"
|
||||
- name: "DB Bicep Curl"
|
||||
wk1_reps: 10
|
||||
wk4_reps: 15
|
||||
wk1_weight: "20 lbs"
|
||||
wk4_weight: "30 lbs"
|
||||
starting_weight: "20 lbs"
|
||||
- name: "DB Shrug"
|
||||
wk1_reps: 12
|
||||
wk4_reps: 15
|
||||
wk1_weight: "35 lbs"
|
||||
wk4_weight: "50 lbs"
|
||||
starting_weight: "35 lbs"
|
||||
|
||||
# Day 3 — Lower
|
||||
- name: "Goblet Squat (DB)"
|
||||
wk1_reps: 8
|
||||
wk4_reps: 15
|
||||
wk1_weight: "25 lbs"
|
||||
wk4_weight: "40 lbs"
|
||||
starting_weight: "25 lbs"
|
||||
- name: "Romanian Deadlift (DB)"
|
||||
wk1_reps: 8
|
||||
wk4_reps: 12
|
||||
wk1_weight: "25 lbs"
|
||||
wk4_weight: "40 lbs"
|
||||
starting_weight: "25 lbs"
|
||||
- name: "Reverse Lunge (DB)"
|
||||
wk1_reps: 8
|
||||
wk4_reps: 12
|
||||
wk1_weight: "15 lbs"
|
||||
wk4_weight: "25 lbs"
|
||||
starting_weight: "15 lbs"
|
||||
- name: "Glute Bridge (DB on hips)"
|
||||
wk1_reps: 12
|
||||
wk4_reps: 20
|
||||
wk1_weight: "25 lbs"
|
||||
wk4_weight: "40 lbs"
|
||||
starting_weight: "25 lbs"
|
||||
- name: "Standing Calf Raise (DB)"
|
||||
wk1_reps: 15
|
||||
wk4_reps: 25
|
||||
wk1_weight: "20 lbs"
|
||||
wk4_weight: "30 lbs"
|
||||
starting_weight: "20 lbs"
|
||||
|
||||
# Day 4 — Full Body
|
||||
- name: "DB Thruster (Squat + Press)"
|
||||
wk1_reps: 6
|
||||
wk4_reps: 10
|
||||
wk1_weight: "20 lbs"
|
||||
wk4_weight: "30 lbs"
|
||||
starting_weight: "20 lbs"
|
||||
- name: "DB Renegade Row"
|
||||
wk1_reps: 6
|
||||
wk4_reps: 10
|
||||
wk1_weight: "20 lbs"
|
||||
wk4_weight: "30 lbs"
|
||||
starting_weight: "20 lbs"
|
||||
- name: "DB Rev. Lunge + Curl"
|
||||
wk1_reps: 6
|
||||
wk4_reps: 10
|
||||
wk1_weight: "15 lbs"
|
||||
wk4_weight: "25 lbs"
|
||||
starting_weight: "15 lbs"
|
||||
- name: "Dead Bug (BW)"
|
||||
wk1_reps: 6
|
||||
wk4_reps: 10
|
||||
wk1_weight: "BW"
|
||||
wk4_weight: "BW"
|
||||
starting_weight: "BW"
|
||||
- name: "DB Farmer's Carry"
|
||||
wk1_reps: "30 sec"
|
||||
wk4_reps: "45 sec"
|
||||
wk1_weight: "30 lbs"
|
||||
wk4_weight: "45 lbs"
|
||||
starting_weight: "30 lbs"
|
||||
|
||||
- user: "Daughter"
|
||||
profile:
|
||||
@@ -125,108 +66,48 @@ programs:
|
||||
exercises:
|
||||
# Day 1 — Push
|
||||
- name: "DB Chest Press (Floor)"
|
||||
wk1_reps: 10
|
||||
wk4_reps: 15
|
||||
wk1_weight: "15 lbs"
|
||||
wk4_weight: "25 lbs"
|
||||
starting_weight: "15 lbs"
|
||||
- name: "DB Shoulder Press (Seated)"
|
||||
wk1_reps: 10
|
||||
wk4_reps: 15
|
||||
wk1_weight: "10 lbs"
|
||||
wk4_weight: "20 lbs"
|
||||
starting_weight: "10 lbs"
|
||||
- name: "DB Lateral Raise"
|
||||
wk1_reps: 12
|
||||
wk4_reps: 18
|
||||
wk1_weight: "8 lbs"
|
||||
wk4_weight: "12 lbs"
|
||||
starting_weight: "8 lbs"
|
||||
- name: "Push-Up (Incline if needed)"
|
||||
wk1_reps: 8
|
||||
wk4_reps: 20
|
||||
wk1_weight: "BW"
|
||||
wk4_weight: "BW"
|
||||
starting_weight: "BW"
|
||||
- name: "DB Tricep Overhead Ext."
|
||||
wk1_reps: 12
|
||||
wk4_reps: 18
|
||||
wk1_weight: "8 lbs"
|
||||
wk4_weight: "15 lbs"
|
||||
starting_weight: "8 lbs"
|
||||
|
||||
# Day 2 — Pull
|
||||
- name: "DB Bent-Over Row (Supported)"
|
||||
wk1_reps: 10
|
||||
wk4_reps: 15
|
||||
wk1_weight: "15 lbs"
|
||||
wk4_weight: "25 lbs"
|
||||
starting_weight: "15 lbs"
|
||||
- name: "DB Rear Delt Fly"
|
||||
wk1_reps: 12
|
||||
wk4_reps: 18
|
||||
wk1_weight: "8 lbs"
|
||||
wk4_weight: "12 lbs"
|
||||
starting_weight: "8 lbs"
|
||||
- name: "DB Hammer Curl"
|
||||
wk1_reps: 12
|
||||
wk4_reps: 18
|
||||
wk1_weight: "10 lbs"
|
||||
wk4_weight: "20 lbs"
|
||||
starting_weight: "10 lbs"
|
||||
- name: "DB Bicep Curl"
|
||||
wk1_reps: 12
|
||||
wk4_reps: 18
|
||||
wk1_weight: "10 lbs"
|
||||
wk4_weight: "20 lbs"
|
||||
starting_weight: "10 lbs"
|
||||
- name: "DB Shrug"
|
||||
wk1_reps: 12
|
||||
wk4_reps: 18
|
||||
wk1_weight: "20 lbs"
|
||||
wk4_weight: "35 lbs"
|
||||
starting_weight: "20 lbs"
|
||||
|
||||
# Day 3 — Lower
|
||||
- name: "Goblet Squat (DB)"
|
||||
wk1_reps: 12
|
||||
wk4_reps: 20
|
||||
wk1_weight: "15 lbs"
|
||||
wk4_weight: "25 lbs"
|
||||
starting_weight: "15 lbs"
|
||||
- name: "Romanian Deadlift (DB)"
|
||||
wk1_reps: 10
|
||||
wk4_reps: 15
|
||||
wk1_weight: "15 lbs"
|
||||
wk4_weight: "25 lbs"
|
||||
starting_weight: "15 lbs"
|
||||
- name: "Reverse Lunge (DB)"
|
||||
wk1_reps: 10
|
||||
wk4_reps: 15
|
||||
wk1_weight: "10 lbs"
|
||||
wk4_weight: "20 lbs"
|
||||
starting_weight: "10 lbs"
|
||||
- name: "Glute Bridge (DB on hips)"
|
||||
wk1_reps: 15
|
||||
wk4_reps: 25
|
||||
wk1_weight: "15 lbs"
|
||||
wk4_weight: "30 lbs"
|
||||
starting_weight: "15 lbs"
|
||||
- name: "Standing Calf Raise (DB)"
|
||||
wk1_reps: 20
|
||||
wk4_reps: 30
|
||||
wk1_weight: "15 lbs"
|
||||
wk4_weight: "25 lbs"
|
||||
starting_weight: "15 lbs"
|
||||
|
||||
# Day 4 — Full Body
|
||||
- name: "DB Thruster (Squat + Press)"
|
||||
wk1_reps: 8
|
||||
wk4_reps: 15
|
||||
wk1_weight: "10 lbs"
|
||||
wk4_weight: "20 lbs"
|
||||
starting_weight: "10 lbs"
|
||||
- name: "DB Renegade Row"
|
||||
wk1_reps: 8
|
||||
wk4_reps: 12
|
||||
wk1_weight: "10 lbs"
|
||||
wk4_weight: "20 lbs"
|
||||
starting_weight: "10 lbs"
|
||||
- name: "DB Rev. Lunge + Curl"
|
||||
wk1_reps: 8
|
||||
wk4_reps: 12
|
||||
wk1_weight: "10 lbs"
|
||||
wk4_weight: "18 lbs"
|
||||
starting_weight: "10 lbs"
|
||||
- name: "Dead Bug (BW)"
|
||||
wk1_reps: 8
|
||||
wk4_reps: 12
|
||||
wk1_weight: "BW"
|
||||
wk4_weight: "BW"
|
||||
starting_weight: "BW"
|
||||
- name: "DB Farmer's Carry"
|
||||
wk1_reps: "30 sec"
|
||||
wk4_reps: "45 sec"
|
||||
wk1_weight: "15 lbs"
|
||||
wk4_weight: "25 lbs"
|
||||
starting_weight: "15 lbs"
|
||||
|
||||
@@ -55,7 +55,7 @@ Replace the `/workouts` page (static list of all 4 days) with a smart "Workout N
|
||||
|
||||
---
|
||||
|
||||
### 3. Automatic Scaled Workout Reps / Sets / Weights — 6→12 Rep Ladder
|
||||
### ~~3. Automatic Scaled Workout Reps / Sets / Weights — 6→12 Rep Ladder~~ ✅ Complete
|
||||
**Branch:** `feature/rep-ladder-progression` | **Depends on:** #1 merged
|
||||
|
||||
Replace the current wk1/wk4 target system with a fixed, universal rep ladder progression. Every exercise follows the same pattern:
|
||||
@@ -116,7 +116,7 @@ Add a CSV export section to the dashboard page with a date range picker (default
|
||||
|
||||
```
|
||||
1. Remove Auth ✅ ──┬──> 2. Workout Now ✅
|
||||
├──> 3. Rep Ladder Progression (can be parallel with 4)
|
||||
├──> 3. Rep Ladder Progression ✅
|
||||
└──> 4. CSV Export (can be parallel with 3)
|
||||
```
|
||||
|
||||
|
||||
@@ -108,10 +108,7 @@ class TestModels:
|
||||
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="30 lbs",
|
||||
)
|
||||
session.add(program)
|
||||
session.commit()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Tests for schedule calendar routes."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestSchedule:
|
||||
"""Tests for GET /schedule."""
|
||||
|
||||
def test_schedule_requires_profile(self, client: TestClient) -> None:
|
||||
"""GET /schedule should redirect to / without profile cookie."""
|
||||
response = client.get("/schedule", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/"
|
||||
Reference in New Issue
Block a user