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:
2026-03-13 13:57:02 -05:00
parent 69b3357800
commit 52e48f8ed4
19 changed files with 410 additions and 494 deletions

View File

@@ -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

View File

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

View File

@@ -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"),
)

View File

@@ -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,
})

View File

@@ -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(

View File

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

View File

@@ -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 %}

View File

@@ -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] %}

View File

@@ -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;">

View File

@@ -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>

View File

@@ -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;">