Week {{ week.week_number }}
-- {{ day.date.strftime('%b %d') }} - {% if day.is_completed %} -
Done - {% endif %} - {% if day.is_today %} -
Today - {% endif %} -
- - Start - -
diff --git a/.dockerignore b/.dockerignore
index 45d3a93..8f94b6c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,7 +5,7 @@
data/
docs/
tests/
-alembic/
+alembic/__pycache__
*.md
*.lock
.claude/
diff --git a/Dockerfile b/Dockerfile
index 76ef53a..449f1cd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/alembic/versions/b2c3d4e5f6g7_simplify_to_starting_weight.py b/alembic/versions/b2c3d4e5f6g7_simplify_to_starting_weight.py
new file mode 100644
index 0000000..485e9b8
--- /dev/null
+++ b/alembic/versions/b2c3d4e5f6g7_simplify_to_starting_weight.py
@@ -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')
diff --git a/app/main.py b/app/main.py
index ae63b2b..cf4bdc6 100644
--- a/app/main.py
+++ b/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
diff --git a/app/models/user_exercise_program.py b/app/models/user_exercise_program.py
index eb46dd5..ee661ef 100644
--- a/app/models/user_exercise_program.py
+++ b/app/models/user_exercise_program.py
@@ -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)
diff --git a/app/routes/logging.py b/app/routes/logging.py
index ad9367b..0d8c938 100644
--- a/app/routes/logging.py
+++ b/app/routes/logging.py
@@ -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"),
)
diff --git a/app/routes/schedule.py b/app/routes/schedule.py
deleted file mode 100644
index 1744562..0000000
--- a/app/routes/schedule.py
+++ /dev/null
@@ -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,
- })
diff --git a/app/services/progression_service.py b/app/services/progression_service.py
index dd0e992..c449bdb 100644
--- a/app/services/progression_service.py
+++ b/app/services/progression_service.py
@@ -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(
diff --git a/app/services/seed_service.py b/app/services/seed_service.py
index 0f4efca..d03fd2c 100644
--- a/app/services/seed_service.py
+++ b/app/services/seed_service.py
@@ -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)
diff --git a/app/templates/pages/schedule.html b/app/templates/pages/schedule.html
deleted file mode 100644
index de48da5..0000000
--- a/app/templates/pages/schedule.html
+++ /dev/null
@@ -1,50 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}4-Week Schedule -- SneakySwole{% endblock %}
-
-{% block content %}
-
- Schedule for: {{ active_profile.display_name }} No profile selected -- select one4-Week Schedule
- {% if active_profile %}
- Week {{ week.week_number }}
-
- {{ day.date.strftime('%b %d') }}
- {% if day.is_completed %}
-
Done
- {% endif %}
- {% if day.is_today %}
-
Today
- {% endif %}
-
-
- Start
-
-
{{ program.wk1_reps }} reps @ {{ program.wk1_weight }}
-{{ program.wk4_reps }} reps @ {{ program.wk4_weight }}
+ {% 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 %} +Starting weight: {{ program.starting_weight }}
+ {% endif %} {% endif %} {% if suggestions and suggestions[exercise.id] %} diff --git a/app/templates/partials/log_form.html b/app/templates/partials/log_form.html index c34613d..4f402dd 100644 --- a/app/templates/partials/log_form.html +++ b/app/templates/partials/log_form.html @@ -12,8 +12,13 @@ -