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

4-Week Schedule

- {% if active_profile %} -

Schedule for: {{ active_profile.display_name }}

- {% else %} -

No profile selected -- select one

- {% endif %} -
- -{% for week in weeks %} -
-
-

Week {{ week.week_number }}

-
-
- {% for day in week.days %} -
- {{ day.workout_day.name }} -
- {{ day.date.strftime('%b %d') }} - {% if day.is_completed %} -
Done - {% endif %} - {% if day.is_today %} -
Today - {% endif %} -
- - Start - -
- {% endfor %} -
-
-{% endfor %} -{% endblock %} diff --git a/app/templates/partials/exercise_card.html b/app/templates/partials/exercise_card.html index 768ab87..d317e1b 100644 --- a/app/templates/partials/exercise_card.html +++ b/app/templates/partials/exercise_card.html @@ -7,16 +7,26 @@ {% if program %} -
-
- Week 1 -

{{ program.wk1_reps }} reps @ {{ program.wk1_weight }}

-
-
- Week 4 -

{{ 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 %} +
+ {% for step in [6, 8, 10, 12] %} +
+ {{ step }}
+ {% endfor %} + @ {{ suggestion.suggested_weight }}
+ {% else %} +

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