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

@@ -5,7 +5,7 @@
data/
docs/
tests/
alembic/
alembic/__pycache__
*.md
*.lock
.claude/

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"] == "/"