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:
@@ -5,7 +5,7 @@
|
|||||||
data/
|
data/
|
||||||
docs/
|
docs/
|
||||||
tests/
|
tests/
|
||||||
alembic/
|
alembic/__pycache__
|
||||||
*.md
|
*.md
|
||||||
*.lock
|
*.lock
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY app/ ./app/
|
COPY app/ ./app/
|
||||||
COPY config/ ./config/
|
COPY config/ ./config/
|
||||||
|
COPY alembic/ ./alembic/
|
||||||
|
COPY alembic.ini .
|
||||||
|
|
||||||
# Create data directory for SQLite
|
# Create data directory for SQLite
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|||||||
45
alembic/versions/b2c3d4e5f6g7_simplify_to_starting_weight.py
Normal file
45
alembic/versions/b2c3d4e5f6g7_simplify_to_starting_weight.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""simplify user_exercise_programs to starting_weight
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6g7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-03-13
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b2c3d4e5f6g7'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('starting_weight', sa.String(), nullable=False, server_default=''))
|
||||||
|
|
||||||
|
op.execute("UPDATE user_exercise_programs SET starting_weight = wk1_weight")
|
||||||
|
|
||||||
|
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||||
|
batch_op.drop_column('wk1_reps')
|
||||||
|
batch_op.drop_column('wk4_reps')
|
||||||
|
batch_op.drop_column('wk1_weight')
|
||||||
|
batch_op.drop_column('wk4_weight')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('wk1_reps', sa.String(), server_default=''))
|
||||||
|
batch_op.add_column(sa.Column('wk4_reps', sa.String(), server_default=''))
|
||||||
|
batch_op.add_column(sa.Column('wk1_weight', sa.String(), server_default=''))
|
||||||
|
batch_op.add_column(sa.Column('wk4_weight', sa.String(), server_default=''))
|
||||||
|
|
||||||
|
op.execute("UPDATE user_exercise_programs SET wk1_weight = starting_weight")
|
||||||
|
|
||||||
|
with op.batch_alter_table('user_exercise_programs') as batch_op:
|
||||||
|
batch_op.drop_column('starting_weight')
|
||||||
54
app/main.py
54
app/main.py
@@ -7,10 +7,12 @@ static files, and structured logging.
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
from alembic import command as alembic_command
|
||||||
|
from alembic.config import Config as AlembicConfig
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlmodel import SQLModel, Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
from starlette.requests import Request as StarletteRequest
|
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.logging import router as logging_router
|
||||||
from app.routes.workouts import router as workouts_router
|
from app.routes.workouts import router as workouts_router
|
||||||
from app.routes.dashboard import router as dashboard_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.seed_service import SeedService
|
||||||
from app.services.user_service import UserService
|
from app.services.user_service import UserService
|
||||||
from app.utils.auth import NoProfileSelectedError
|
from app.utils.auth import NoProfileSelectedError
|
||||||
@@ -64,6 +65,50 @@ class NavContextMiddleware(BaseHTTPMiddleware):
|
|||||||
return await call_next(request)
|
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:
|
def create_app() -> FastAPI:
|
||||||
"""Create and configure the FastAPI application.
|
"""Create and configure the FastAPI application.
|
||||||
|
|
||||||
@@ -103,11 +148,10 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(profiles_router)
|
app.include_router(profiles_router)
|
||||||
app.include_router(workouts_router)
|
app.include_router(workouts_router)
|
||||||
app.include_router(dashboard_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)
|
engine = get_engine(settings.database_url)
|
||||||
SQLModel.metadata.create_all(engine)
|
_run_migrations(settings.database_url)
|
||||||
app.state.engine = engine
|
app.state.engine = engine
|
||||||
|
|
||||||
# DB session dependency for routes
|
# DB session dependency for routes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""UserExerciseProgram model for per-user exercise programming.
|
"""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
|
from datetime import datetime
|
||||||
@@ -16,10 +16,7 @@ class UserExerciseProgram(SQLModel, table=True):
|
|||||||
id: Primary key, auto-incremented.
|
id: Primary key, auto-incremented.
|
||||||
user_id: FK to users table.
|
user_id: FK to users table.
|
||||||
exercise_id: FK to exercises table.
|
exercise_id: FK to exercises table.
|
||||||
wk1_reps: Week 1 target reps (string to support "30 sec" style).
|
starting_weight: Starting weight (e.g., "30 lbs", "BW").
|
||||||
wk4_reps: Week 4 target reps.
|
|
||||||
wk1_weight: Week 1 target weight (e.g., "30 lbs", "BW").
|
|
||||||
wk4_weight: Week 4 target weight.
|
|
||||||
created_at: Timestamp when the record was created.
|
created_at: Timestamp when the record was created.
|
||||||
updated_at: Timestamp of the last update.
|
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)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
user_id: int = Field(foreign_key="users.id", index=True)
|
user_id: int = Field(foreign_key="users.id", index=True)
|
||||||
exercise_id: int = Field(foreign_key="exercises.id", index=True)
|
exercise_id: int = Field(foreign_key="exercises.id", index=True)
|
||||||
wk1_reps: str = Field(default="")
|
starting_weight: str = Field(default="")
|
||||||
wk4_reps: str = Field(default="")
|
|
||||||
wk1_weight: str = Field(default="")
|
|
||||||
wk4_weight: str = Field(default="")
|
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ logger = structlog.get_logger(__name__)
|
|||||||
router = APIRouter(prefix="/log", tags=["logging"])
|
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)
|
@router.post("", response_class=HTMLResponse)
|
||||||
async def log_set(
|
async def log_set(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -38,7 +55,7 @@ async def log_set(
|
|||||||
workout_day_id = int(form.get("workout_day_id", 0))
|
workout_day_id = int(form.get("workout_day_id", 0))
|
||||||
set_number = int(form.get("set_number", 1))
|
set_number = int(form.get("set_number", 1))
|
||||||
reps = int(form.get("reps", 0))
|
reps = int(form.get("reps", 0))
|
||||||
weight = form.get("weight", "")
|
weight = _normalize_weight(form.get("weight", ""))
|
||||||
felt_easy = form.get("felt_easy") == "on"
|
felt_easy = form.get("felt_easy") == "on"
|
||||||
|
|
||||||
# Get or create today's session
|
# Get or create today's session
|
||||||
@@ -89,7 +106,7 @@ async def edit_log(
|
|||||||
log_service.update_log(
|
log_service.update_log(
|
||||||
log_id,
|
log_id,
|
||||||
reps_completed=int(form.get("reps", 0)),
|
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",
|
felt_easy=form.get("felt_easy") == "on",
|
||||||
notes=form.get("notes"),
|
notes=form.get("notes"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
"""4-week schedule calendar routes.
|
|
||||||
|
|
||||||
Displays a calendar view showing which workout day maps to which date.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import date, timedelta
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
from app.database import get_db_session
|
|
||||||
from app.models.user import User
|
|
||||||
from app.services.exercise_service import ExerciseService
|
|
||||||
from app.services.workout_session_service import WorkoutSessionService
|
|
||||||
from app.utils.auth import require_active_profile
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/schedule", tags=["schedule"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
|
||||||
async def schedule_view(
|
|
||||||
request: Request,
|
|
||||||
session: Session = Depends(get_db_session),
|
|
||||||
profile: User = Depends(require_active_profile),
|
|
||||||
):
|
|
||||||
"""Render the 4-week schedule calendar.
|
|
||||||
|
|
||||||
Shows a 4-week grid where each training day is mapped to a
|
|
||||||
calendar date. Days with completed sessions are highlighted.
|
|
||||||
"""
|
|
||||||
exercise_service = ExerciseService(session)
|
|
||||||
workout_days = exercise_service.list_workout_days()
|
|
||||||
|
|
||||||
# Build 4-week calendar starting from Monday of current week
|
|
||||||
today = date.today()
|
|
||||||
monday = today - timedelta(days=today.weekday())
|
|
||||||
|
|
||||||
weeks = []
|
|
||||||
completed_dates = set()
|
|
||||||
|
|
||||||
# Get completed sessions for highlighting
|
|
||||||
ws_service = WorkoutSessionService(session)
|
|
||||||
sessions_list = ws_service.list_sessions(
|
|
||||||
user_id=profile.id, limit=100,
|
|
||||||
)
|
|
||||||
completed_dates = {ws.date for ws in sessions_list}
|
|
||||||
|
|
||||||
# 4 workout days per week, 4 weeks
|
|
||||||
for week_num in range(4):
|
|
||||||
week_start = monday + timedelta(weeks=week_num)
|
|
||||||
week_data = {
|
|
||||||
"week_number": week_num + 1,
|
|
||||||
"days": [],
|
|
||||||
}
|
|
||||||
for day_offset, workout_day in enumerate(workout_days):
|
|
||||||
training_date = week_start + timedelta(days=day_offset)
|
|
||||||
is_today = training_date == today
|
|
||||||
is_completed = training_date in completed_dates
|
|
||||||
|
|
||||||
week_data["days"].append({
|
|
||||||
"workout_day": workout_day,
|
|
||||||
"date": training_date,
|
|
||||||
"is_today": is_today,
|
|
||||||
"is_completed": is_completed,
|
|
||||||
})
|
|
||||||
weeks.append(week_data)
|
|
||||||
|
|
||||||
templates = request.app.state.templates
|
|
||||||
return templates.TemplateResponse("pages/schedule.html", {
|
|
||||||
"request": request,
|
|
||||||
"weeks": weeks,
|
|
||||||
"active_profile": profile,
|
|
||||||
})
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
"""Auto-progression engine for workout programming.
|
"""Auto-progression engine using a rep ladder model.
|
||||||
|
|
||||||
Analyzes workout log history and applies the progression model:
|
Every exercise follows the same 6 → 8 → 10 → 12 rep ladder at current weight.
|
||||||
- +1-2 reps/week until wk4 rep target
|
At 12 reps with all sets felt easy, weight increases by 5 lbs and reps reset to 6.
|
||||||
- +5 lbs every 2 weeks once at rep target
|
Deload triggers after 4+ consecutive struggling sessions (-20% weight, reset to 6).
|
||||||
- Deload at week 5 (-20% weight, reset to wk1 reps)
|
|
||||||
- Accelerated weight increase when all sets felt easy
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -21,6 +19,12 @@ from app.models.workout_session import WorkoutSession
|
|||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
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]:
|
def _parse_weight(weight_str: str) -> Optional[float]:
|
||||||
"""Extract numeric weight from a string like '30 lbs' or 'BW'.
|
"""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"
|
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:
|
class ProgressionService:
|
||||||
"""Implements the auto-progression engine.
|
"""Implements the rep ladder auto-progression engine.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: An active SQLModel Session.
|
session: An active SQLModel Session.
|
||||||
@@ -120,14 +143,11 @@ class ProgressionService:
|
|||||||
def get_suggestion(
|
def get_suggestion(
|
||||||
self, user_id: int, exercise_id: int,
|
self, user_id: int, exercise_id: int,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Generate a progression suggestion for the next workout.
|
"""Generate a progression suggestion using the rep ladder model.
|
||||||
|
|
||||||
Analyzes recent log history against the user's program targets
|
|
||||||
and applies progression rules.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with keys: suggested_reps, suggested_weight,
|
Dict with keys: suggested_reps, suggested_weight, suggested_sets,
|
||||||
progression_type, message.
|
ladder_position, progression_type, message.
|
||||||
"""
|
"""
|
||||||
program = self._get_program(user_id, exercise_id)
|
program = self._get_program(user_id, exercise_id)
|
||||||
|
|
||||||
@@ -135,107 +155,118 @@ class ProgressionService:
|
|||||||
return {
|
return {
|
||||||
"suggested_reps": 0,
|
"suggested_reps": 0,
|
||||||
"suggested_weight": "",
|
"suggested_weight": "",
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": -1,
|
||||||
"progression_type": "no_program",
|
"progression_type": "no_program",
|
||||||
"message": "No program found for this exercise.",
|
"message": "No program found for this exercise.",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
starting_weight = program.starting_weight
|
||||||
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
|
|
||||||
|
|
||||||
recent = self._get_recent_sessions(user_id, exercise_id, limit=5)
|
recent = self._get_recent_sessions(user_id, exercise_id, limit=5)
|
||||||
|
|
||||||
|
# No history — baseline suggestion
|
||||||
if not recent:
|
if not recent:
|
||||||
return {
|
return {
|
||||||
"suggested_reps": wk1_reps,
|
"suggested_reps": REP_LADDER[0],
|
||||||
"suggested_weight": wk1_weight,
|
"suggested_weight": starting_weight,
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": 0,
|
||||||
"progression_type": "baseline",
|
"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]
|
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 = latest["weight"]
|
||||||
current_weight_num = _parse_weight(current_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)
|
# Count consecutive struggling sessions (not felt easy)
|
||||||
if consecutive_sessions >= 4:
|
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:
|
if current_weight_num is not None:
|
||||||
deload_weight = current_weight_num * 0.8
|
deload_weight = current_weight_num * DELOAD_FACTOR
|
||||||
return {
|
return {
|
||||||
"suggested_reps": wk1_reps,
|
"suggested_reps": REP_LADDER[0],
|
||||||
"suggested_weight": _format_weight(deload_weight),
|
"suggested_weight": _format_weight(deload_weight),
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": 0,
|
||||||
"progression_type": "deload",
|
"progression_type": "deload",
|
||||||
"message": (
|
"message": (
|
||||||
f"Deload week: {wk1_reps} reps @ "
|
f"Deload: {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ "
|
||||||
f"{_format_weight(deload_weight)} (-20%)."
|
f"{_format_weight(deload_weight)} (-20%)."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
# Bodyweight — can't reduce weight, just reset reps
|
||||||
return {
|
return {
|
||||||
"suggested_reps": wk1_reps,
|
"suggested_reps": REP_LADDER[0],
|
||||||
"suggested_weight": current_weight,
|
"suggested_weight": current_weight,
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": 0,
|
||||||
"progression_type": "deload",
|
"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
|
# At top of ladder (12 reps) and felt easy
|
||||||
if current_reps >= wk4_reps and latest["all_felt_easy"]:
|
if current_reps >= REP_LADDER[-1] and all_felt_easy:
|
||||||
if current_weight_num is not None:
|
if current_weight_num is not None:
|
||||||
new_weight = current_weight_num + 5
|
new_weight = current_weight_num + WEIGHT_INCREMENT
|
||||||
return {
|
return {
|
||||||
"suggested_reps": wk1_reps,
|
"suggested_reps": REP_LADDER[0],
|
||||||
"suggested_weight": _format_weight(new_weight),
|
"suggested_weight": _format_weight(new_weight),
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": 0,
|
||||||
"progression_type": "weight_increase",
|
"progression_type": "weight_increase",
|
||||||
"message": (
|
"message": (
|
||||||
f"Weight up: {wk1_reps} reps @ "
|
f"Weight up: {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ "
|
||||||
f"{_format_weight(new_weight)} (+5 lbs)."
|
f"{_format_weight(new_weight)} (+{WEIGHT_INCREMENT} lbs)."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
# Bodyweight — hold at top
|
||||||
# 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)
|
|
||||||
return {
|
return {
|
||||||
"suggested_reps": new_reps,
|
"suggested_reps": REP_LADDER[-1],
|
||||||
"suggested_weight": current_weight,
|
"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": (
|
"message": (
|
||||||
f"Reps up: {new_reps} reps @ {current_weight} "
|
f"Hold: {SETS_PER_EXERCISE}x{REP_LADDER[-1]} @ {current_weight} "
|
||||||
f"(+{increment})."
|
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 {
|
return {
|
||||||
"suggested_reps": current_reps,
|
"suggested_reps": current_reps,
|
||||||
"suggested_weight": current_weight,
|
"suggested_weight": current_weight,
|
||||||
|
"suggested_sets": SETS_PER_EXERCISE,
|
||||||
|
"ladder_position": pos,
|
||||||
"progression_type": "hold",
|
"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(
|
def record_progression(
|
||||||
|
|||||||
@@ -157,10 +157,7 @@ class SeedService:
|
|||||||
uep = UserExerciseProgram(
|
uep = UserExerciseProgram(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
exercise_id=exercise.id,
|
exercise_id=exercise.id,
|
||||||
wk1_reps=str(ex_data.get("wk1_reps", "")),
|
starting_weight=str(ex_data.get("starting_weight", "")),
|
||||||
wk4_reps=str(ex_data.get("wk4_reps", "")),
|
|
||||||
wk1_weight=str(ex_data.get("wk1_weight", "")),
|
|
||||||
wk4_weight=str(ex_data.get("wk4_weight", "")),
|
|
||||||
)
|
)
|
||||||
self._session.add(uep)
|
self._session.add(uep)
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}4-Week Schedule -- SneakySwole{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<hgroup>
|
|
||||||
<h1>4-Week Schedule</h1>
|
|
||||||
{% if active_profile %}
|
|
||||||
<p>Schedule for: <strong>{{ active_profile.display_name }}</strong></p>
|
|
||||||
{% else %}
|
|
||||||
<p>No profile selected -- <a href="/profiles">select one</a></p>
|
|
||||||
{% endif %}
|
|
||||||
</hgroup>
|
|
||||||
|
|
||||||
{% for week in weeks %}
|
|
||||||
<article>
|
|
||||||
<header>
|
|
||||||
<h3>Week {{ week.week_number }}</h3>
|
|
||||||
</header>
|
|
||||||
<div class="grid">
|
|
||||||
{% for day in week.days %}
|
|
||||||
<div style="text-align:center;
|
|
||||||
padding:1rem;
|
|
||||||
border-radius:0.5rem;
|
|
||||||
{% if day.is_today %}
|
|
||||||
border: 2px solid var(--pico-primary);
|
|
||||||
{% endif %}
|
|
||||||
{% if day.is_completed %}
|
|
||||||
background: rgba(99, 102, 241, 0.15);
|
|
||||||
{% endif %}">
|
|
||||||
<strong>{{ day.workout_day.name }}</strong>
|
|
||||||
<br>
|
|
||||||
<small>{{ day.date.strftime('%b %d') }}</small>
|
|
||||||
{% if day.is_completed %}
|
|
||||||
<br><mark>Done</mark>
|
|
||||||
{% endif %}
|
|
||||||
{% if day.is_today %}
|
|
||||||
<br><small><strong>Today</strong></small>
|
|
||||||
{% endif %}
|
|
||||||
<br>
|
|
||||||
<a href="/workouts/{{ day.workout_day.name|lower|replace(' ', '-') }}"
|
|
||||||
style="font-size:0.8rem;">
|
|
||||||
Start
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -7,16 +7,26 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if program %}
|
{% if program %}
|
||||||
<div class="grid">
|
{% set suggestion = suggestions[exercise.id] if suggestions and exercise.id in suggestions else None %}
|
||||||
<div>
|
{% set pos = suggestion.ladder_position if suggestion and suggestion.ladder_position is defined else -1 %}
|
||||||
<small>Week 1</small>
|
{% if pos >= 0 %}
|
||||||
<p>{{ program.wk1_reps }} reps @ {{ program.wk1_weight }}</p>
|
<div style="display: flex; gap: 0.25rem; align-items: center; margin-bottom: 0.75rem;">
|
||||||
</div>
|
{% for step in [6, 8, 10, 12] %}
|
||||||
<div>
|
<div style="flex: 1; text-align: center; padding: 0.35rem 0;
|
||||||
<small>Week 4</small>
|
border-radius: 0.25rem; font-size: 0.85rem; font-weight: 600;
|
||||||
<p>{{ program.wk4_reps }} reps @ {{ program.wk4_weight }}</p>
|
{% 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>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<small style="margin-left: 0.5rem; white-space: nowrap;">@ {{ suggestion.suggested_weight }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p><small>Starting weight: {{ program.starting_weight }}</small></p>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if suggestions and suggestions[exercise.id] %}
|
{% if suggestions and suggestions[exercise.id] %}
|
||||||
|
|||||||
@@ -12,8 +12,13 @@
|
|||||||
<input type="number" name="reps" placeholder="Reps"
|
<input type="number" name="reps" placeholder="Reps"
|
||||||
min="0" max="100" required
|
min="0" max="100" required
|
||||||
style="width:5rem; margin-bottom:0;">
|
style="width:5rem; margin-bottom:0;">
|
||||||
<input type="text" name="weight" placeholder="Weight (lbs)"
|
<input type="number" name="weight" placeholder="Weight (lbs)"
|
||||||
required
|
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;">
|
style="width:8rem; margin-bottom:0;">
|
||||||
<label style="display:flex; align-items:center; gap:0.3rem; margin-bottom:0; white-space:nowrap;">
|
<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;">
|
<input type="checkbox" name="felt_easy" role="switch" style="margin-bottom:0;">
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
</li>
|
</li>
|
||||||
<li><a href="/">Home</a></li>
|
<li><a href="/">Home</a></li>
|
||||||
<li><a href="/workouts">Workouts</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="/dashboard">Dashboard</a></li>
|
||||||
<li><a href="/history">History</a></li>
|
<li><a href="/history">History</a></li>
|
||||||
<li><a href="/exercises">Exercises</a></li>
|
<li><a href="/exercises">Exercises</a></li>
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
{% if suggestion and suggestion.progression_type != "no_program" %}
|
{% 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);
|
<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;
|
padding: 0.5rem 1rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
border-radius: 0 0.25rem 0.25rem 0;">
|
border-radius: 0 0.25rem 0.25rem 0;">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# SneakySwole User Programs
|
# 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.
|
# Update this file to adjust programming, then re-run the seed script.
|
||||||
|
|
||||||
programs:
|
programs:
|
||||||
@@ -11,111 +12,51 @@ programs:
|
|||||||
exercises:
|
exercises:
|
||||||
# Day 1 — Push
|
# Day 1 — Push
|
||||||
- name: "DB Chest Press (Floor)"
|
- name: "DB Chest Press (Floor)"
|
||||||
wk1_reps: 8
|
starting_weight: "30 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "30 lbs"
|
|
||||||
wk4_weight: "40 lbs"
|
|
||||||
- name: "DB Shoulder Press (Seated)"
|
- name: "DB Shoulder Press (Seated)"
|
||||||
wk1_reps: 8
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "DB Lateral Raise"
|
- name: "DB Lateral Raise"
|
||||||
wk1_reps: 10
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "15 lbs"
|
|
||||||
- name: "Push-Up (Incline if needed)"
|
- name: "Push-Up (Incline if needed)"
|
||||||
wk1_reps: 6
|
starting_weight: "BW"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "BW"
|
|
||||||
wk4_weight: "BW"
|
|
||||||
- name: "DB Tricep Overhead Ext."
|
- name: "DB Tricep Overhead Ext."
|
||||||
wk1_reps: 10
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
|
|
||||||
# Day 2 — Pull
|
# Day 2 — Pull
|
||||||
- name: "DB Bent-Over Row (Supported)"
|
- name: "DB Bent-Over Row (Supported)"
|
||||||
wk1_reps: 8
|
starting_weight: "30 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "30 lbs"
|
|
||||||
wk4_weight: "45 lbs"
|
|
||||||
- name: "DB Rear Delt Fly"
|
- name: "DB Rear Delt Fly"
|
||||||
wk1_reps: 10
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "15 lbs"
|
|
||||||
- name: "DB Hammer Curl"
|
- name: "DB Hammer Curl"
|
||||||
wk1_reps: 10
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "DB Bicep Curl"
|
- name: "DB Bicep Curl"
|
||||||
wk1_reps: 10
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "DB Shrug"
|
- name: "DB Shrug"
|
||||||
wk1_reps: 12
|
starting_weight: "35 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "35 lbs"
|
|
||||||
wk4_weight: "50 lbs"
|
|
||||||
|
|
||||||
# Day 3 — Lower
|
# Day 3 — Lower
|
||||||
- name: "Goblet Squat (DB)"
|
- name: "Goblet Squat (DB)"
|
||||||
wk1_reps: 8
|
starting_weight: "25 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "25 lbs"
|
|
||||||
wk4_weight: "40 lbs"
|
|
||||||
- name: "Romanian Deadlift (DB)"
|
- name: "Romanian Deadlift (DB)"
|
||||||
wk1_reps: 8
|
starting_weight: "25 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "25 lbs"
|
|
||||||
wk4_weight: "40 lbs"
|
|
||||||
- name: "Reverse Lunge (DB)"
|
- name: "Reverse Lunge (DB)"
|
||||||
wk1_reps: 8
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "Glute Bridge (DB on hips)"
|
- name: "Glute Bridge (DB on hips)"
|
||||||
wk1_reps: 12
|
starting_weight: "25 lbs"
|
||||||
wk4_reps: 20
|
|
||||||
wk1_weight: "25 lbs"
|
|
||||||
wk4_weight: "40 lbs"
|
|
||||||
- name: "Standing Calf Raise (DB)"
|
- name: "Standing Calf Raise (DB)"
|
||||||
wk1_reps: 15
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 25
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
|
|
||||||
# Day 4 — Full Body
|
# Day 4 — Full Body
|
||||||
- name: "DB Thruster (Squat + Press)"
|
- name: "DB Thruster (Squat + Press)"
|
||||||
wk1_reps: 6
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 10
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "DB Renegade Row"
|
- name: "DB Renegade Row"
|
||||||
wk1_reps: 6
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 10
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "DB Rev. Lunge + Curl"
|
- name: "DB Rev. Lunge + Curl"
|
||||||
wk1_reps: 6
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 10
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "Dead Bug (BW)"
|
- name: "Dead Bug (BW)"
|
||||||
wk1_reps: 6
|
starting_weight: "BW"
|
||||||
wk4_reps: 10
|
|
||||||
wk1_weight: "BW"
|
|
||||||
wk4_weight: "BW"
|
|
||||||
- name: "DB Farmer's Carry"
|
- name: "DB Farmer's Carry"
|
||||||
wk1_reps: "30 sec"
|
starting_weight: "30 lbs"
|
||||||
wk4_reps: "45 sec"
|
|
||||||
wk1_weight: "30 lbs"
|
|
||||||
wk4_weight: "45 lbs"
|
|
||||||
|
|
||||||
- user: "Daughter"
|
- user: "Daughter"
|
||||||
profile:
|
profile:
|
||||||
@@ -125,108 +66,48 @@ programs:
|
|||||||
exercises:
|
exercises:
|
||||||
# Day 1 — Push
|
# Day 1 — Push
|
||||||
- name: "DB Chest Press (Floor)"
|
- name: "DB Chest Press (Floor)"
|
||||||
wk1_reps: 10
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "DB Shoulder Press (Seated)"
|
- name: "DB Shoulder Press (Seated)"
|
||||||
wk1_reps: 10
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "DB Lateral Raise"
|
- name: "DB Lateral Raise"
|
||||||
wk1_reps: 12
|
starting_weight: "8 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "8 lbs"
|
|
||||||
wk4_weight: "12 lbs"
|
|
||||||
- name: "Push-Up (Incline if needed)"
|
- name: "Push-Up (Incline if needed)"
|
||||||
wk1_reps: 8
|
starting_weight: "BW"
|
||||||
wk4_reps: 20
|
|
||||||
wk1_weight: "BW"
|
|
||||||
wk4_weight: "BW"
|
|
||||||
- name: "DB Tricep Overhead Ext."
|
- name: "DB Tricep Overhead Ext."
|
||||||
wk1_reps: 12
|
starting_weight: "8 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "8 lbs"
|
|
||||||
wk4_weight: "15 lbs"
|
|
||||||
|
|
||||||
# Day 2 — Pull
|
# Day 2 — Pull
|
||||||
- name: "DB Bent-Over Row (Supported)"
|
- name: "DB Bent-Over Row (Supported)"
|
||||||
wk1_reps: 10
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "DB Rear Delt Fly"
|
- name: "DB Rear Delt Fly"
|
||||||
wk1_reps: 12
|
starting_weight: "8 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "8 lbs"
|
|
||||||
wk4_weight: "12 lbs"
|
|
||||||
- name: "DB Hammer Curl"
|
- name: "DB Hammer Curl"
|
||||||
wk1_reps: 12
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "DB Bicep Curl"
|
- name: "DB Bicep Curl"
|
||||||
wk1_reps: 12
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "DB Shrug"
|
- name: "DB Shrug"
|
||||||
wk1_reps: 12
|
starting_weight: "20 lbs"
|
||||||
wk4_reps: 18
|
|
||||||
wk1_weight: "20 lbs"
|
|
||||||
wk4_weight: "35 lbs"
|
|
||||||
|
|
||||||
# Day 3 — Lower
|
# Day 3 — Lower
|
||||||
- name: "Goblet Squat (DB)"
|
- name: "Goblet Squat (DB)"
|
||||||
wk1_reps: 12
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 20
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "Romanian Deadlift (DB)"
|
- name: "Romanian Deadlift (DB)"
|
||||||
wk1_reps: 10
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
- name: "Reverse Lunge (DB)"
|
- name: "Reverse Lunge (DB)"
|
||||||
wk1_reps: 10
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "Glute Bridge (DB on hips)"
|
- name: "Glute Bridge (DB on hips)"
|
||||||
wk1_reps: 15
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 25
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "30 lbs"
|
|
||||||
- name: "Standing Calf Raise (DB)"
|
- name: "Standing Calf Raise (DB)"
|
||||||
wk1_reps: 20
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: 30
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
|
|
||||||
# Day 4 — Full Body
|
# Day 4 — Full Body
|
||||||
- name: "DB Thruster (Squat + Press)"
|
- name: "DB Thruster (Squat + Press)"
|
||||||
wk1_reps: 8
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 15
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "DB Renegade Row"
|
- name: "DB Renegade Row"
|
||||||
wk1_reps: 8
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "20 lbs"
|
|
||||||
- name: "DB Rev. Lunge + Curl"
|
- name: "DB Rev. Lunge + Curl"
|
||||||
wk1_reps: 8
|
starting_weight: "10 lbs"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "10 lbs"
|
|
||||||
wk4_weight: "18 lbs"
|
|
||||||
- name: "Dead Bug (BW)"
|
- name: "Dead Bug (BW)"
|
||||||
wk1_reps: 8
|
starting_weight: "BW"
|
||||||
wk4_reps: 12
|
|
||||||
wk1_weight: "BW"
|
|
||||||
wk4_weight: "BW"
|
|
||||||
- name: "DB Farmer's Carry"
|
- name: "DB Farmer's Carry"
|
||||||
wk1_reps: "30 sec"
|
starting_weight: "15 lbs"
|
||||||
wk4_reps: "45 sec"
|
|
||||||
wk1_weight: "15 lbs"
|
|
||||||
wk4_weight: "25 lbs"
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ Replace the `/workouts` page (static list of all 4 days) with a smart "Workout N
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. Automatic Scaled Workout Reps / Sets / Weights — 6→12 Rep Ladder
|
### ~~3. Automatic Scaled Workout Reps / Sets / Weights — 6→12 Rep Ladder~~ ✅ Complete
|
||||||
**Branch:** `feature/rep-ladder-progression` | **Depends on:** #1 merged
|
**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:
|
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 ✅
|
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)
|
└──> 4. CSV Export (can be parallel with 3)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -108,10 +108,7 @@ class TestModels:
|
|||||||
program = UserExerciseProgram(
|
program = UserExerciseProgram(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
exercise_id=exercise.id,
|
exercise_id=exercise.id,
|
||||||
wk1_reps="8",
|
starting_weight="30 lbs",
|
||||||
wk4_reps="12",
|
|
||||||
wk1_weight="30 lbs",
|
|
||||||
wk4_weight="40 lbs",
|
|
||||||
)
|
)
|
||||||
session.add(program)
|
session.add(program)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the ProgressionService class."""
|
"""Tests for the ProgressionService class (rep ladder model)."""
|
||||||
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@ from app.services.progression_service import ProgressionService
|
|||||||
|
|
||||||
|
|
||||||
class TestProgressionService:
|
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."""
|
"""Create an in-memory DB with a user, exercise, and program."""
|
||||||
engine = create_engine("sqlite:///:memory:")
|
engine = create_engine("sqlite:///:memory:")
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
@@ -37,8 +37,7 @@ class TestProgressionService:
|
|||||||
|
|
||||||
program = UserExerciseProgram(
|
program = UserExerciseProgram(
|
||||||
user_id=user.id, exercise_id=exercise.id,
|
user_id=user.id, exercise_id=exercise.id,
|
||||||
wk1_reps="8", wk4_reps="12",
|
starting_weight=starting_weight,
|
||||||
wk1_weight="30 lbs", wk4_weight="40 lbs",
|
|
||||||
)
|
)
|
||||||
session.add(program)
|
session.add(program)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -47,101 +46,115 @@ class TestProgressionService:
|
|||||||
service = ProgressionService(session)
|
service = ProgressionService(session)
|
||||||
return session, service, user, day, exercise, program
|
return session, service, user, day, exercise, program
|
||||||
|
|
||||||
def test_suggest_reps_increase(self) -> None:
|
def _log_session(self, session, user, day, exercise, reps, weight, felt_easy, days_ago):
|
||||||
"""Should suggest +1-2 reps when below wk4 target."""
|
"""Helper to log a workout session with 3 sets."""
|
||||||
session, service, user, day, exercise, program = self._setup()
|
|
||||||
|
|
||||||
# Log a session where user did 8 reps (wk1 target)
|
|
||||||
ws = WorkoutSession(
|
ws = WorkoutSession(
|
||||||
user_id=user.id, workout_day_id=day.id,
|
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.add(ws)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(ws)
|
session.refresh(ws)
|
||||||
|
|
||||||
for set_num in range(1, 4):
|
for set_num in range(1, 4):
|
||||||
session.add(WorkoutLog(
|
session.add(WorkoutLog(
|
||||||
session_id=ws.id, exercise_id=exercise.id,
|
session_id=ws.id, exercise_id=exercise.id,
|
||||||
set_number=set_num, reps_completed=8,
|
set_number=set_num, reps_completed=reps,
|
||||||
weight_used="30 lbs", felt_easy=False,
|
weight_used=weight, felt_easy=felt_easy,
|
||||||
))
|
))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
def test_baseline_no_logs(self) -> None:
|
||||||
assert suggestion is not None
|
"""Should return 3x6 @ starting_weight when no logs exist."""
|
||||||
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."""
|
|
||||||
session, service, user, day, exercise, program = self._setup()
|
session, service, user, day, exercise, program = self._setup()
|
||||||
suggestion = service.get_suggestion(user.id, exercise.id)
|
suggestion = service.get_suggestion(user.id, exercise.id)
|
||||||
assert suggestion is not None
|
assert suggestion["suggested_reps"] == 6
|
||||||
assert suggestion["suggested_reps"] == 8 # wk1 default
|
assert suggestion["suggested_weight"] == "30 lbs"
|
||||||
assert suggestion["suggested_weight"] == "30 lbs" # wk1 default
|
assert suggestion["suggested_sets"] == 3
|
||||||
|
assert suggestion["ladder_position"] == 0
|
||||||
assert suggestion["progression_type"] == "baseline"
|
assert suggestion["progression_type"] == "baseline"
|
||||||
session.close()
|
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:
|
def test_record_progression(self) -> None:
|
||||||
"""record_progression should write to progress_log table."""
|
"""record_progression should write to progress_log table."""
|
||||||
session, service, user, day, exercise, program = self._setup()
|
session, service, user, day, exercise, program = self._setup()
|
||||||
@@ -152,10 +165,22 @@ class TestProgressionService:
|
|||||||
suggested_weight="30 lbs",
|
suggested_weight="30 lbs",
|
||||||
actual_reps=10,
|
actual_reps=10,
|
||||||
actual_weight="30 lbs",
|
actual_weight="30 lbs",
|
||||||
progression_type="reps_increase",
|
progression_type="climb",
|
||||||
)
|
)
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
logs = session.exec(select(ProgressLog)).all()
|
logs = session.exec(select(ProgressLog)).all()
|
||||||
assert len(logs) == 1
|
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()
|
session.close()
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
"""Tests for schedule calendar routes."""
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchedule:
|
|
||||||
"""Tests for GET /schedule."""
|
|
||||||
|
|
||||||
def test_schedule_requires_profile(self, client: TestClient) -> None:
|
|
||||||
"""GET /schedule should redirect to / without profile cookie."""
|
|
||||||
response = client.get("/schedule", follow_redirects=False)
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.headers["location"] == "/"
|
|
||||||
Reference in New Issue
Block a user