Compare commits

...

18 Commits

Author SHA1 Message Date
2208f0492b fix: renumber sets after delete and use last-logged values for prefill
Two bugs fixed:
- Deleting a set left gaps in set numbering (1, 3, 3). Now renumbers
  remaining sets sequentially after deletion.
- Logging set 1 caused the prefill to recalculate via the progression
  engine, shifting suggested reps mid-session. Now prefills from the
  last logged set's actual values; progression suggestion is only used
  for the first set of a session.
2026-02-24 15:55:27 -06:00
7b535bef6e fix(tests): align auth tests with NotAuthenticatedError and 302 redirect
The auth dependency raises NotAuthenticatedError (not HTTPException),
and the exception handler returns a 302 redirect. Updated the unit test
to expect NotAuthenticatedError, and all route auth tests to accept 302
alongside 401/303.
2026-02-24 15:47:36 -06:00
272563060c feat: auto-populate suggested reps and weight in log form
Pre-fill the reps and weight inputs with progression engine suggestions
so users can log sets without manually retyping values each time.
Suggestions flow through the template chain on initial page load and
on all HTMX partial responses (log, edit, delete).
2026-02-24 15:46:04 -06:00
3dc0171639 updating dockerfiles for deployment vs local dev
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 10s
2026-02-24 15:37:21 -06:00
312b14e57b updating dockerfiles for deployment vs local dev 2026-02-24 15:37:16 -06:00
d8b52cf907 fix(ci): use REGISTRY_TOKEN secret for container registry auth
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 23s
The automatic GITHUB_TOKEN lacks package write permissions. Switch to
a manually configured PAT stored as REGISTRY_TOKEN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:47:09 -06:00
b18146e96c fix(ci): use act-latest container for Docker and Node.js support
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 7s
Replace manual Node.js install with catthehacker/ubuntu:act-latest
container which includes Node.js, Docker, and all build dependencies
needed for Gitea Actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:42:30 -06:00
ee45513f30 fix(ci): install Node.js 20 for actions/checkout@v4 compatibility
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 9m38s
The default apt nodejs package is too old to support static class blocks
required by checkout@v4. Use NodeSource to install Node.js 20.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:32:39 -06:00
d90c9faf23 Merge branch 'feat/package-build-workflow'
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 35s
2026-02-24 14:31:28 -06:00
093f7aa55e ci: add Gitea Actions workflow for Docker build and registry push
Adds a CI pipeline that builds the Docker image and pushes it to the
Gitea container registry on every push to master. Includes .dockerignore
to keep the build context lean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:30:19 -06:00
77d1bc4a25 chore: add uv.lock for reproducible dependency resolution
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:20:39 -06:00
89d70fcae7 Merge branch 'docs/cleanup-implementation-files' 2026-02-24 14:19:54 -06:00
53e62f694f docs: replace implementation plans with architecture and API reference docs
Remove phase implementation plans, design notes, and source spreadsheet
that are no longer needed. Add architecture.md, API_REFERENCE.md, and
database_schema.md for ongoing development and debugging reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:19:49 -06:00
fd979aa2da Merge branch 'fix/log-form-exercise-undefined' 2026-02-24 14:00:37 -06:00
215ce90404 fix: resolve template errors, orphaned sessions, and auth redirects
- Fix exercise_id undefined error in log_form.html by using scalar
  exercise_id instead of exercise.id object reference
- Clean up orphaned WorkoutSession records when all logs are deleted
- Filter empty sessions from dashboard stats (sessions, volume, streak)
- Replace broken HTTPException auth redirect with custom exception
  handler that properly returns 302 to /login

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:00:34 -06:00
134542b66f feat: add Phase 5 Progression & Analytics — smart suggestions, dashboard, schedule
Add auto-progression engine (ProgressionService) with rep increase, weight
increase, deload, and felt-easy acceleration rules. Add AnalyticsService for
user stats, exercise progress charts, and volume-by-day data. New dashboard
and schedule routes with Chart.js visualizations. Progression badges shown
inline on workout day view. Navigation updated with Dashboard and Schedule links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:26:23 -06:00
e35b78ae87 feat: add Phase 4 Logging & Tracking — inline set logging, history views
Add workout logging so users can track sets, reps, weight, and a
"felt easy?" toggle inline from the workout day view via HTMX.
Sessions auto-create on first log. History page shows past sessions
with detailed per-exercise breakdowns.

New services: WorkoutSessionService, LogService
New routes: POST /log, /log/{id}/edit, /log/{id}/delete, GET /history, /history/{id}
New templates: log_form, log_entry, session_card, log_history, session_detail
Modified: exercise_card (inline logging), nav (History link), workouts route (session context)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:12:23 -06:00
23754ea239 feat: add Phase 3 Workout UI — auth, profiles, workout viewer, exercise browser
Build the core user-facing experience with admin login (bcrypt + signed
session cookies), profile switcher, workout day viewer with warmups and
exercise cards, and HTMX-powered exercise browser with search/filter.

- AuthService with bcrypt password verification and itsdangerous session tokens
- Auth dependency redirects to /login (303) for unauthenticated requests
- NavContextMiddleware injects admin/profiles/active_profile into all templates
- Profile management (list, switch, edit) with cookie-based active profile
- Workout day viewer shows warmups + exercises + per-user programming targets
- Exercise browser with HTMX filter dropdowns (no page reloads)
- Flash message partial for success/error feedback
- 12 new tests (66 total passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:14:52 -06:00
70 changed files with 4435 additions and 140 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.gitignore
.env
.env.example
data/
docs/
tests/
alembic/
*.md
*.lock
.claude/
.gitea/
__pycache__
*.pyc
docker-compose.yaml
docker-compose.dev.yaml

12
.env.production Normal file
View File

@@ -0,0 +1,12 @@
# SneakySwole Production Environment
# Copy to .env on your production server and fill in real values.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=
APP_ENV=production
APP_HOST=0.0.0.0
APP_PORT=8000
APP_LOG_LEVEL=warning
DATABASE_URL=sqlite:///data/sneakyswole.db

View File

@@ -0,0 +1,46 @@
name: Build and Push Docker Image
on:
push:
branches:
- master
workflow_dispatch: {}
jobs:
build-and-push:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.sneakygeek.net
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: git.sneakygeek.net/sneakygeek/sneakyswole
tags: |
type=raw,value=latest
type=sha,prefix=sha-,format=short
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=git.sneakygeek.net/sneakygeek/sneakyswole:buildcache
cache-to: type=registry,ref=git.sneakygeek.net/sneakygeek/sneakyswole:buildcache,mode=max

View File

@@ -4,6 +4,8 @@ Creates and configures the FastAPI app with routes, templates,
static files, and structured logging. static files, and structured logging.
""" """
import os
import secrets
from pathlib import Path from pathlib import Path
import structlog import structlog
@@ -12,12 +14,29 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlmodel import SQLModel, Session from sqlmodel import SQLModel, Session
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request as StarletteRequest
from starlette.responses import Response
from fastapi.responses import RedirectResponse
from app.models.user import User
from app.config import get_settings from app.config import get_settings
from app.database import get_engine, get_db_session from app.database import get_engine, get_db_session
from app.logging_config import setup_logging from app.logging_config import setup_logging
from app.routes.auth import router as auth_router
from app.routes.exercises import router as exercises_router
from app.routes.history import router as history_router
from app.routes.health import router as health_router from app.routes.health import router as health_router
from app.routes.pages import router as pages_router from app.routes.pages import router as pages_router
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.seed_service import SeedService
from app.services.auth_service import AuthService
from app.services.user_service import UserService
from app.utils.auth import SESSION_COOKIE_NAME, NotAuthenticatedError
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -27,6 +46,37 @@ TEMPLATES_DIR = _BASE_DIR / "templates"
STATIC_DIR = _BASE_DIR / "static" STATIC_DIR = _BASE_DIR / "static"
class NavContextMiddleware(BaseHTTPMiddleware):
"""Injects admin, profiles, and active_profile into request.state for templates."""
async def dispatch(self, request: StarletteRequest, call_next: RequestResponseEndpoint) -> Response:
request.state.admin = None
request.state.profiles = []
request.state.active_profile = None
token = request.cookies.get(SESSION_COOKIE_NAME)
if token and hasattr(request.app.state, "engine"):
try:
with Session(request.app.state.engine) as session:
secret_key = getattr(request.app.state, "secret_key", "")
auth_service = AuthService(session, secret_key=secret_key)
user_id = auth_service.validate_session_token(token)
if user_id:
admin = session.get(User, user_id)
if admin and admin.is_admin:
request.state.admin = admin
user_service = UserService(session)
request.state.profiles = user_service.list_users(exclude_admin=True)
profile_id = request.cookies.get("active_profile_id")
if profile_id and profile_id.isdigit():
request.state.active_profile = user_service.get_user_by_id(int(profile_id))
except Exception:
pass
return await call_next(request)
def create_app() -> FastAPI: def create_app() -> FastAPI:
"""Create and configure the FastAPI application. """Create and configure the FastAPI application.
@@ -42,6 +92,11 @@ def create_app() -> FastAPI:
version="0.1.0", version="0.1.0",
) )
# Redirect unauthenticated requests to login
@app.exception_handler(NotAuthenticatedError)
async def _not_authenticated_handler(request, exc):
return RedirectResponse(url="/login", status_code=302)
# Mount static files # Mount static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@@ -49,9 +104,23 @@ def create_app() -> FastAPI:
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.state.templates = templates app.state.templates = templates
# Secret key for session signing
app.state.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
# Nav context middleware — injects admin/profiles/active_profile into request.state
app.add_middleware(NavContextMiddleware)
# Register route modules # Register route modules
app.include_router(auth_router)
app.include_router(exercises_router)
app.include_router(health_router) app.include_router(health_router)
app.include_router(history_router)
app.include_router(logging_router)
app.include_router(pages_router) app.include_router(pages_router)
app.include_router(profiles_router)
app.include_router(workouts_router)
app.include_router(dashboard_router)
app.include_router(schedule_router)
# Database setup # Database setup
engine = get_engine(settings.database_url) engine = get_engine(settings.database_url)

99
app/routes/auth.py Normal file
View File

@@ -0,0 +1,99 @@
"""Authentication routes for admin login and logout.
Handles the login form, credential verification, session cookie
management, and logout.
"""
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlmodel import Session
from app.database import get_db_session
from app.services.auth_service import AuthService
from app.utils.auth import SESSION_COOKIE_NAME
logger = structlog.get_logger(__name__)
router = APIRouter(tags=["auth"])
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""Render the login form.
Args:
request: The incoming HTTP request.
Returns:
Rendered login page HTML.
"""
templates = request.app.state.templates
return templates.TemplateResponse("pages/login.html", {
"request": request,
"error": None,
})
@router.post("/login")
async def login_submit(
request: Request,
session: Session = Depends(get_db_session),
):
"""Process login form submission.
Verifies credentials and sets a session cookie on success.
Re-renders the login page with an error on failure.
Args:
request: The incoming HTTP request.
session: Database session.
Returns:
Redirect to home on success, or login page with error.
"""
form = await request.form()
username = form.get("username", "")
password = form.get("password", "")
secret_key = request.app.state.secret_key
auth_service = AuthService(session, secret_key=secret_key)
user = auth_service.authenticate(username, password)
if user is None:
templates = request.app.state.templates
return templates.TemplateResponse("pages/login.html", {
"request": request,
"error": "Invalid username or password.",
}, status_code=200)
# Create session token and set cookie — httponly and samesite for security
token = auth_service.create_session_token(user_id=user.id)
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
samesite="lax",
max_age=86400, # 24 hours
)
logger.info("login_success", username=username)
return response
@router.get("/logout")
async def logout(request: Request):
"""Clear the session cookie and redirect to login.
Args:
request: The incoming HTTP request.
Returns:
Redirect to login page.
"""
response = RedirectResponse(url="/login", status_code=303)
response.delete_cookie(key=SESSION_COOKIE_NAME)
response.delete_cookie(key="active_profile_id")
logger.info("logout")
return response

102
app/routes/dashboard.py Normal file
View File

@@ -0,0 +1,102 @@
"""Progress dashboard routes.
Displays summary statistics, volume charts, and per-exercise progress.
"""
import json
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.analytics_service import AnalyticsService
from app.services.exercise_service import ExerciseService
from app.services.progression_service import ProgressionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("", response_class=HTMLResponse)
async def dashboard(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Render the progress dashboard for the active profile.
Shows: summary stats, volume by day chart, exercise progress links.
"""
active_profile_id = get_active_profile_id(request)
active_profile = (
session.get(User, active_profile_id)
if active_profile_id
else None
)
stats = {}
volume_data = {}
if active_profile_id:
analytics = AnalyticsService(session)
stats = analytics.get_user_stats(active_profile_id)
volume_data = analytics.get_volume_by_day(active_profile_id)
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises()
templates = request.app.state.templates
return templates.TemplateResponse("pages/dashboard.html", {
"request": request,
"stats": stats,
"volume_data_json": json.dumps(volume_data),
"exercises": exercises,
"active_profile": active_profile,
"admin": admin,
})
@router.get("/exercise/{exercise_id}", response_class=HTMLResponse)
async def exercise_progress(
exercise_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Render per-exercise progress page with charts and suggestions."""
active_profile_id = get_active_profile_id(request)
active_profile = (
session.get(User, active_profile_id)
if active_profile_id
else None
)
exercise_service = ExerciseService(session)
exercise = exercise_service.get_exercise_by_id(exercise_id)
progress_data = {}
suggestion = {}
if active_profile_id:
analytics = AnalyticsService(session)
progress_data = analytics.get_exercise_progress(
active_profile_id, exercise_id,
)
progression = ProgressionService(session)
suggestion = progression.get_suggestion(
active_profile_id, exercise_id,
)
templates = request.app.state.templates
return templates.TemplateResponse("pages/exercise_progress.html", {
"request": request,
"exercise": exercise,
"progress_data_json": json.dumps(progress_data),
"suggestion": suggestion,
"active_profile": active_profile,
"admin": admin,
})

86
app/routes/exercises.py Normal file
View File

@@ -0,0 +1,86 @@
"""Exercise browser routes with HTMX search/filter support.
All filtering is done via HTMX partial responses — no JSON APIs.
"""
import structlog
from fastapi import APIRouter, Depends, Query, 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.utils.auth import get_current_admin_user
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/exercises", tags=["exercises"])
@router.get("", response_class=HTMLResponse)
async def exercise_browser(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Render the exercise browser page with all exercises.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered exercise browser page.
"""
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises()
workout_days = exercise_service.list_workout_days()
# Collect unique muscle groups for the filter dropdown
muscle_groups = sorted(set(ex.muscle_group for ex in exercises))
templates = request.app.state.templates
return templates.TemplateResponse("pages/exercise_browser.html", {
"request": request,
"exercises": exercises,
"workout_days": workout_days,
"muscle_groups": muscle_groups,
"admin": admin,
})
@router.get("/search", response_class=HTMLResponse)
async def exercise_search(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
workout_day: str = Query(default="", alias="workout_day"),
muscle_group: str = Query(default="", alias="muscle_group"),
):
"""Return filtered exercise list as an HTMX partial.
Called via hx-get from the exercise browser filter dropdowns.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
workout_day: Filter by workout day name.
muscle_group: Filter by muscle group.
Returns:
Rendered exercise list partial HTML.
"""
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises(
workout_day=workout_day or None,
muscle_group=muscle_group or None,
)
templates = request.app.state.templates
return templates.TemplateResponse("partials/exercise_list.html", {
"request": request,
"exercises": exercises,
})

113
app/routes/history.py Normal file
View File

@@ -0,0 +1,113 @@
"""Log history routes for viewing past workout sessions.
Displays a list of past sessions and detailed logs per session.
"""
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.log_service import LogService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/history", tags=["history"])
@router.get("", response_class=HTMLResponse)
async def log_history(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Display log history for the active profile.
Shows a list of past workout sessions, most recent first.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered log history page.
"""
active_profile_id = get_active_profile_id(request)
active_profile = (
session.get(User, active_profile_id)
if active_profile_id
else None
)
sessions_list = []
if active_profile_id:
ws_service = WorkoutSessionService(session)
sessions_list = ws_service.list_sessions(user_id=active_profile_id)
# Resolve workout day names for display
exercise_service = ExerciseService(session)
days_by_id = {d.id: d for d in exercise_service.list_workout_days()}
templates = request.app.state.templates
return templates.TemplateResponse("pages/log_history.html", {
"request": request,
"sessions": sessions_list,
"days_by_id": days_by_id,
"active_profile": active_profile,
"admin": admin,
})
@router.get("/{session_id}", response_class=HTMLResponse)
async def session_detail(
session_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Display detailed logs for a specific workout session.
Args:
session_id: The workout session ID.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered session detail page.
"""
ws_service = WorkoutSessionService(session)
ws = ws_service.get_session_by_id(session_id)
log_service = LogService(session)
logs = log_service.list_logs_for_session(session_id)
# Group logs by exercise
exercise_service = ExerciseService(session)
exercises_by_id = {}
logs_by_exercise = {}
for log in logs:
if log.exercise_id not in exercises_by_id:
exercises_by_id[log.exercise_id] = (
exercise_service.get_exercise_by_id(log.exercise_id)
)
logs_by_exercise.setdefault(log.exercise_id, []).append(log)
# Resolve workout day name
days_by_id = {d.id: d for d in exercise_service.list_workout_days()}
templates = request.app.state.templates
return templates.TemplateResponse("pages/session_detail.html", {
"request": request,
"workout_session": ws,
"logs_by_exercise": logs_by_exercise,
"exercises_by_id": exercises_by_id,
"days_by_id": days_by_id,
"admin": admin,
})

225
app/routes/logging.py Normal file
View File

@@ -0,0 +1,225 @@
"""Workout logging routes for inline set tracking.
Handles creating, editing, and deleting individual set logs.
All responses are HTMX partials that update in place.
"""
from datetime import date
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.log_service import LogService
from app.services.progression_service import ProgressionService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/log", tags=["logging"])
def _get_prefill_values(
logs: list,
session: Session,
profile_id: int,
exercise_id: int,
) -> tuple:
"""Get pre-fill values for the next set form.
If sets have already been logged this session, use the last logged
set's values (users typically repeat the same reps/weight across sets).
Otherwise, use the progression engine's suggestion.
Returns:
(suggested_reps, suggested_weight) tuple.
"""
if logs:
last = logs[-1]
return last.reps_completed, last.weight_used
progression = ProgressionService(session)
suggestion = progression.get_suggestion(profile_id, exercise_id)
return suggestion.get("suggested_reps"), suggestion.get("suggested_weight")
@router.post("", response_class=HTMLResponse)
async def log_set(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Log a single set for an exercise.
Creates the workout session if it doesn't exist yet (auto-create).
Returns the updated log entries partial for this exercise.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered log entries partial for this exercise.
"""
form = await request.form()
exercise_id = int(form.get("exercise_id", 0))
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", "")
felt_easy = form.get("felt_easy") == "on"
active_profile_id = get_active_profile_id(request)
if not active_profile_id:
templates = request.app.state.templates
return templates.TemplateResponse("partials/flash_message.html", {
"request": request,
"flash_error": "No profile selected. Switch profiles first.",
})
# Get or create today's session
ws_service = WorkoutSessionService(session)
ws = ws_service.get_or_create_session(
user_id=active_profile_id,
workout_day_id=workout_day_id,
session_date=date.today(),
)
# Create the log entry
log_service = LogService(session)
log_service.create_log(
session_id=ws.id,
exercise_id=exercise_id,
set_number=set_number,
reps_completed=reps,
weight_used=weight,
felt_easy=felt_easy,
)
# Return updated logs for this exercise
logs = log_service.list_logs_for_exercise(ws.id, exercise_id)
next_set = len(logs) + 1
suggested_reps, suggested_weight = _get_prefill_values(
logs, session, active_profile_id, exercise_id,
)
templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", {
"request": request,
"logs": logs,
"exercise_id": exercise_id,
"workout_day_id": workout_day_id,
"next_set": next_set,
"session_id": ws.id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
})
@router.post("/{log_id}/edit", response_class=HTMLResponse)
async def edit_log(
log_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Edit an existing log entry.
Args:
log_id: The log entry ID.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered updated log entry partial.
"""
form = await request.form()
log_service = LogService(session)
log_service.update_log(
log_id,
reps_completed=int(form.get("reps", 0)),
weight_used=form.get("weight", ""),
felt_easy=form.get("felt_easy") == "on",
notes=form.get("notes"),
)
log = log_service.get_log_by_id(log_id)
logs = log_service.list_logs_for_exercise(log.session_id, log.exercise_id)
next_set = len(logs) + 1
active_profile_id = get_active_profile_id(request)
suggested_reps, suggested_weight = None, None
if active_profile_id:
suggested_reps, suggested_weight = _get_prefill_values(
logs, session, active_profile_id, log.exercise_id,
)
templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", {
"request": request,
"logs": logs,
"exercise_id": log.exercise_id,
"workout_day_id": 0,
"next_set": next_set,
"session_id": log.session_id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
})
@router.post("/{log_id}/delete", response_class=HTMLResponse)
async def delete_log(
log_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Delete a log entry.
Args:
log_id: The log entry ID.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered updated log entries partial.
"""
log_service = LogService(session)
log = log_service.get_log_by_id(log_id)
if log:
exercise_id = log.exercise_id
session_id = log.session_id
log_service.delete_log(log_id)
logs = log_service.list_logs_for_exercise(session_id, exercise_id)
next_set = len(logs) + 1
active_profile_id = get_active_profile_id(request)
suggested_reps, suggested_weight = None, None
if active_profile_id:
suggested_reps, suggested_weight = _get_prefill_values(
logs, session, active_profile_id, exercise_id,
)
templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", {
"request": request,
"logs": logs,
"exercise_id": exercise_id,
"workout_day_id": 0,
"next_set": next_set,
"session_id": session_id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
})
return HTMLResponse("")

151
app/routes/profiles.py Normal file
View File

@@ -0,0 +1,151 @@
"""Profile management routes.
Admin can view, create, edit user profiles and switch the active profile.
"""
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.user_service import UserService
from app.utils.auth import get_current_admin_user, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/profiles", tags=["profiles"])
@router.get("", response_class=HTMLResponse)
async def list_profiles(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""List all user profiles for the admin.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered profile list page.
"""
user_service = UserService(session)
profiles = user_service.list_users(exclude_admin=True)
active_profile_id = get_active_profile_id(request)
templates = request.app.state.templates
return templates.TemplateResponse("pages/profiles.html", {
"request": request,
"profiles": profiles,
"active_profile_id": active_profile_id,
"admin": admin,
})
@router.post("/switch")
async def switch_profile(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Switch the active user profile.
Sets a cookie with the selected profile ID.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Redirect to the referring page with profile cookie set.
"""
form = await request.form()
profile_id = form.get("profile_id", "")
# Validate profile exists and is not an admin
user_service = UserService(session)
profile = user_service.get_user_by_id(int(profile_id)) if profile_id.isdigit() else None
referer = request.headers.get("referer", "/")
response = RedirectResponse(url=referer, status_code=303)
if profile and not profile.is_admin:
response.set_cookie(
key="active_profile_id",
value=str(profile.id),
httponly=True,
samesite="lax",
max_age=86400,
)
logger.info("profile_switched", profile_id=profile.id, name=profile.display_name)
else:
logger.warning("profile_switch_failed", profile_id=profile_id)
return response
@router.get("/{profile_id}/edit", response_class=HTMLResponse)
async def edit_profile_page(
profile_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Render the profile edit form.
Args:
profile_id: The profile ID to edit.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered profile edit page.
"""
user_service = UserService(session)
profile = user_service.get_user_by_id(profile_id)
templates = request.app.state.templates
return templates.TemplateResponse("pages/profile_edit.html", {
"request": request,
"profile": profile,
"admin": admin,
})
@router.post("/{profile_id}/edit")
async def update_profile(
profile_id: int,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Process profile edit form submission.
Args:
profile_id: The profile ID to update.
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Redirect to profiles page.
"""
form = await request.form()
user_service = UserService(session)
user_service.update_user(
profile_id,
display_name=form.get("display_name", ""),
height=form.get("height", ""),
weight=form.get("weight", ""),
goals=form.get("goals", ""),
)
return RedirectResponse(url="/profiles", status_code=303)

86
app/routes/schedule.py Normal file
View File

@@ -0,0 +1,86 @@
"""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 get_current_admin_user, get_active_profile_id
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),
admin: User = Depends(get_current_admin_user),
):
"""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.
"""
active_profile_id = get_active_profile_id(request)
active_profile = (
session.get(User, active_profile_id)
if active_profile_id
else None
)
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
if active_profile_id:
ws_service = WorkoutSessionService(session)
sessions_list = ws_service.list_sessions(
user_id=active_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": active_profile,
"admin": admin,
})

137
app/routes/workouts.py Normal file
View File

@@ -0,0 +1,137 @@
"""Workout day viewer routes.
Displays the warmup routine and main exercises for each workout day,
with the active profile's programming targets.
"""
from datetime import date
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session, select
from app.database import get_db_session
from app.models.user import User
from app.models.user_exercise_program import UserExerciseProgram
from app.services.exercise_service import ExerciseService
from app.services.log_service import LogService
from app.services.progression_service import ProgressionService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import get_current_admin_user, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/workouts", tags=["workouts"])
@router.get("", response_class=HTMLResponse)
async def workout_days_list(
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""List all workout days as clickable cards.
Args:
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered workout days list page.
"""
exercise_service = ExerciseService(session)
days = exercise_service.list_workout_days()
templates = request.app.state.templates
return templates.TemplateResponse("pages/workout_days.html", {
"request": request,
"days": days,
"admin": admin,
})
@router.get("/{day_name}", response_class=HTMLResponse)
async def workout_day_detail(
day_name: str,
request: Request,
session: Session = Depends(get_db_session),
admin: User = Depends(get_current_admin_user),
):
"""Display a full workout day -- warmups + exercises with form cues.
Args:
day_name: The workout day name (e.g., "push", "pull").
request: The incoming HTTP request.
session: Database session.
admin: The authenticated admin user.
Returns:
Rendered workout day detail page.
"""
exercise_service = ExerciseService(session)
# Normalize day name for DB lookup (e.g., "push" -> "Push", "full-body" -> "Full Body")
day_display = day_name.replace("-", " ").title()
warmups = exercise_service.list_warmups()
exercises = exercise_service.list_exercises(workout_day=day_display)
# Get active profile's programming if set
active_profile_id = get_active_profile_id(request)
programs = {}
active_profile = None
existing_logs = {}
if active_profile_id:
active_profile = session.get(User, active_profile_id)
if active_profile:
statement = select(UserExerciseProgram).where(
UserExerciseProgram.user_id == active_profile_id
)
for prog in session.exec(statement).all():
programs[prog.exercise_id] = prog
# Look up the workout day ID for logging forms
days = exercise_service.list_workout_days()
workout_day_id = 0
for d in days:
if d.name == day_display:
workout_day_id = d.id
break
# Get progression suggestions for each exercise
suggestions = {}
if active_profile_id:
progression = ProgressionService(session)
for exercise in exercises:
suggestions[exercise.id] = progression.get_suggestion(
active_profile_id, exercise.id,
)
# Load existing logs for today's session (if any)
if active_profile_id and workout_day_id:
ws_service = WorkoutSessionService(session)
ws = ws_service.get_or_create_session(
user_id=active_profile_id,
workout_day_id=workout_day_id,
session_date=date.today(),
)
log_service = LogService(session)
all_logs = log_service.list_logs_for_session(ws.id)
for log in all_logs:
existing_logs.setdefault(log.exercise_id, []).append(log)
templates = request.app.state.templates
return templates.TemplateResponse("pages/workout_day.html", {
"request": request,
"day_name": day_display,
"warmups": warmups,
"exercises": exercises,
"programs": programs,
"active_profile": active_profile,
"existing_logs": existing_logs,
"suggestions": suggestions,
"workout_day_id": workout_day_id,
"admin": admin,
})

View File

@@ -0,0 +1,181 @@
"""Analytics service for progress dashboard and chart data.
Aggregates workout log data into stats, trends, and chart-ready formats.
"""
import re
from datetime import date, timedelta
import structlog
from sqlmodel import Session, select
from app.models.workout_day import WorkoutDay
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
def _weight_to_float(weight_str: str) -> float:
"""Convert weight string to float for volume calculations.
Args:
weight_str: Weight like '30 lbs' or 'BW'.
Returns:
Numeric weight, or 0.0 for bodyweight.
"""
if not weight_str or weight_str.upper() == "BW":
return 0.0
match = re.search(r"(\d+(?:\.\d+)?)", weight_str)
return float(match.group(1)) if match else 0.0
class AnalyticsService:
"""Aggregates workout data for dashboards and charts.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def get_user_stats(self, user_id: int) -> dict:
"""Get summary statistics for a user.
Returns:
Dict with keys: total_sessions, total_volume, total_sets,
current_streak, last_workout_date.
"""
all_sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
).all()
# Only count sessions that still have log entries
sessions = []
total_volume = 0.0
total_sets = 0
for ws in all_sessions:
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
if not logs:
continue
sessions.append(ws)
for log_entry in logs:
total_sets += 1
weight = _weight_to_float(log_entry.weight_used)
total_volume += log_entry.reps_completed * weight
total_sessions = len(sessions)
current_streak = 0
if sessions:
week_start = date.today() - timedelta(days=date.today().weekday())
for week_offset in range(52):
week_check = week_start - timedelta(weeks=week_offset)
week_end = week_check + timedelta(days=6)
has_session = any(
week_check <= ws.date <= week_end for ws in sessions
)
if has_session:
current_streak += 1
else:
break
last_workout = sessions[0].date if sessions else None
return {
"total_sessions": total_sessions,
"total_volume": round(total_volume),
"total_sets": total_sets,
"current_streak": current_streak,
"last_workout_date": last_workout,
}
def get_exercise_progress(
self, user_id: int, exercise_id: int,
) -> dict:
"""Get chart-ready progress data for a specific exercise.
Returns:
Dict with keys: dates, reps, weights, volumes.
"""
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.asc())
).all()
dates = []
reps = []
weights = []
volumes = []
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(
WorkoutLog.session_id == ws.id,
WorkoutLog.exercise_id == exercise_id,
)
).all()
if not logs:
continue
avg_reps = sum(
log_entry.reps_completed for log_entry in logs
) / len(logs)
weight = _weight_to_float(logs[0].weight_used)
session_volume = sum(
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
for log_entry in logs
)
dates.append(ws.date.isoformat())
reps.append(round(avg_reps, 1))
weights.append(weight)
volumes.append(round(session_volume))
return {
"dates": dates,
"reps": reps,
"weights": weights,
"volumes": volumes,
}
def get_volume_by_day(self, user_id: int) -> dict:
"""Get total volume broken down by workout day.
Returns:
Dict mapping workout day name to total volume.
"""
days = self._session.exec(select(WorkoutDay)).all()
day_map = {d.id: d.name for d in days}
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
).all()
volume_by_day = {}
for ws in sessions:
day_name = day_map.get(ws.workout_day_id, "Unknown")
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
if not logs:
continue
day_volume = sum(
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
for log_entry in logs
)
volume_by_day[day_name] = (
volume_by_day.get(day_name, 0) + round(day_volume)
)
return volume_by_day

View File

@@ -0,0 +1,100 @@
"""Service layer for admin authentication and session management.
Handles password verification, session token creation/validation.
Uses bcrypt for password hashing and itsdangerous for session signing.
"""
from typing import Optional
import bcrypt
import structlog
from itsdangerous import BadSignature, URLSafeTimedSerializer
from sqlmodel import Session, select
from app.models.user import User
logger = structlog.get_logger(__name__)
# Session token max age: 24 hours
SESSION_MAX_AGE_SECONDS = 86400
class AuthService:
"""Handles admin authentication and session token management.
Args:
session: An active SQLModel Session.
secret_key: Secret key for signing session tokens.
"""
def __init__(self, session: Session, secret_key: str) -> None:
self._session = session
self._serializer = URLSafeTimedSerializer(secret_key)
def authenticate(self, username: str, password: str) -> Optional[User]:
"""Verify admin credentials and return the User if valid.
Only admin users can authenticate. Non-admin users are rejected.
Args:
username: The username to check.
password: The plaintext password to verify.
Returns:
The authenticated User, or None if credentials are invalid.
"""
statement = select(User).where(User.username == username)
user = self._session.exec(statement).first()
if user is None:
logger.warning("auth_failed", reason="user_not_found", username=username)
return None
if not user.is_admin:
logger.warning("auth_failed", reason="not_admin", username=username)
return None
if not user.password_hash:
logger.warning("auth_failed", reason="no_password_hash", username=username)
return None
# Verify password against bcrypt hash
if not bcrypt.checkpw(
password.encode("utf-8"),
user.password_hash.encode("utf-8"),
):
logger.warning("auth_failed", reason="wrong_password", username=username)
return None
logger.info("auth_success", username=username)
return user
def create_session_token(self, user_id: int) -> str:
"""Create a signed session token for the given user.
Args:
user_id: The authenticated user's ID.
Returns:
A signed, URL-safe token string.
"""
return self._serializer.dumps({"user_id": user_id})
def validate_session_token(self, token: str) -> Optional[int]:
"""Validate a session token and extract the user_id.
Args:
token: The session token to validate.
Returns:
The user_id if the token is valid, or None.
"""
try:
data = self._serializer.loads(token, max_age=SESSION_MAX_AGE_SECONDS)
return data.get("user_id")
except BadSignature:
logger.warning("session_invalid", reason="bad_signature")
return None
except Exception:
logger.warning("session_invalid", reason="unknown_error")
return None

227
app/services/log_service.py Normal file
View File

@@ -0,0 +1,227 @@
"""Service layer for workout log (set-level) data access.
Handles CRUD for individual set logs within workout sessions.
"""
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
class LogService:
"""Handles CRUD operations for WorkoutLog records.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def create_log(
self,
session_id: int,
exercise_id: int,
set_number: int,
reps_completed: int,
weight_used: str,
felt_easy: bool,
notes: Optional[str] = None,
) -> WorkoutLog:
"""Create a new set log entry.
Args:
session_id: FK to workout_sessions.
exercise_id: FK to exercises.
set_number: Which set (1, 2, 3...).
reps_completed: Actual reps performed.
weight_used: Weight as string (e.g., "30 lbs").
felt_easy: Whether the set felt easy.
notes: Optional notes for this set.
Returns:
The newly created WorkoutLog record.
"""
log = WorkoutLog(
session_id=session_id,
exercise_id=exercise_id,
set_number=set_number,
reps_completed=reps_completed,
weight_used=weight_used,
felt_easy=felt_easy,
notes=notes,
)
self._session.add(log)
self._session.commit()
self._session.refresh(log)
logger.info(
"log_created",
session_id=session_id,
exercise_id=exercise_id,
set=set_number,
)
return log
def list_logs_for_session(self, session_id: int) -> list[WorkoutLog]:
"""List all log entries for a workout session.
Args:
session_id: The workout session ID.
Returns:
List of WorkoutLog records ordered by exercise and set number.
"""
statement = (
select(WorkoutLog)
.where(WorkoutLog.session_id == session_id)
.order_by(WorkoutLog.exercise_id, WorkoutLog.set_number)
)
return list(self._session.exec(statement).all())
def list_logs_for_exercise(
self,
session_id: int,
exercise_id: int,
) -> list[WorkoutLog]:
"""List log entries for a specific exercise within a session.
Args:
session_id: The workout session ID.
exercise_id: The exercise ID.
Returns:
List of WorkoutLog records for this exercise, ordered by set.
"""
statement = (
select(WorkoutLog)
.where(
WorkoutLog.session_id == session_id,
WorkoutLog.exercise_id == exercise_id,
)
.order_by(WorkoutLog.set_number)
)
return list(self._session.exec(statement).all())
def get_log_by_id(self, log_id: int) -> Optional[WorkoutLog]:
"""Retrieve a log entry by primary key.
Args:
log_id: The log entry ID.
Returns:
The WorkoutLog record, or None if not found.
"""
return self._session.get(WorkoutLog, log_id)
def update_log(self, log_id: int, **kwargs) -> WorkoutLog:
"""Update fields on an existing log entry.
Args:
log_id: The log entry ID.
**kwargs: Field names and new values.
Returns:
The updated WorkoutLog record.
Raises:
ValueError: If the log is not found.
"""
log = self.get_log_by_id(log_id)
if log is None:
raise ValueError(f"WorkoutLog with id {log_id} not found")
for key, value in kwargs.items():
if hasattr(log, key):
setattr(log, key, value)
self._session.add(log)
self._session.commit()
self._session.refresh(log)
logger.info("log_updated", log_id=log_id, fields=list(kwargs.keys()))
return log
def delete_log(self, log_id: int) -> None:
"""Delete a log entry.
Removes the log, renumbers remaining sets, and cleans up the
parent session if no logs remain.
Args:
log_id: The log entry ID.
Raises:
ValueError: If the log is not found.
"""
log = self.get_log_by_id(log_id)
if log is None:
raise ValueError(f"WorkoutLog with id {log_id} not found")
session_id = log.session_id
exercise_id = log.exercise_id
self._session.delete(log)
self._session.commit()
logger.info("log_deleted", log_id=log_id)
# Renumber remaining sets so they stay sequential (1, 2, 3...)
remaining = self._session.exec(
select(WorkoutLog)
.where(
WorkoutLog.session_id == session_id,
WorkoutLog.exercise_id == exercise_id,
)
.order_by(WorkoutLog.set_number)
).all()
for i, remaining_log in enumerate(remaining, start=1):
if remaining_log.set_number != i:
remaining_log.set_number = i
self._session.add(remaining_log)
if remaining:
self._session.commit()
# Clean up orphaned session if no logs remain for ANY exercise
any_remaining = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == session_id)
).first()
if any_remaining is None:
ws = self._session.get(WorkoutSession, session_id)
if ws:
self._session.delete(ws)
self._session.commit()
logger.info("session_deleted_empty", session_id=session_id)
def get_latest_logs_for_exercise(
self,
user_id: int,
exercise_id: int,
limit: int = 10,
) -> list[WorkoutLog]:
"""Get the most recent log entries for an exercise across sessions.
Used by the progression engine (Phase 5) to determine
what the user last did for this exercise.
Args:
user_id: The user's ID.
exercise_id: The exercise ID.
limit: Maximum number of logs to return.
Returns:
List of recent WorkoutLog records, newest first.
"""
statement = (
select(WorkoutLog)
.join(WorkoutSession, WorkoutLog.session_id == WorkoutSession.id)
.where(
WorkoutSession.user_id == user_id,
WorkoutLog.exercise_id == exercise_id,
)
.order_by(WorkoutSession.date.desc(), WorkoutLog.set_number)
.limit(limit)
)
return list(self._session.exec(statement).all())

View File

@@ -0,0 +1,271 @@
"""Auto-progression engine for workout programming.
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
"""
import re
from datetime import date
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.progress_log import ProgressLog
from app.models.user_exercise_program import UserExerciseProgram
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
def _parse_weight(weight_str: str) -> Optional[float]:
"""Extract numeric weight from a string like '30 lbs' or 'BW'.
Args:
weight_str: Weight as a string.
Returns:
Numeric weight in lbs, or None for bodyweight.
"""
if not weight_str or weight_str.upper() == "BW":
return None
match = re.search(r"(\d+(?:\.\d+)?)", weight_str)
return float(match.group(1)) if match else None
def _format_weight(weight_lbs: Optional[float]) -> str:
"""Format a numeric weight back to a display string.
Args:
weight_lbs: Weight in lbs, or None for bodyweight.
Returns:
Formatted string like '35 lbs' or 'BW'.
"""
if weight_lbs is None:
return "BW"
if weight_lbs == int(weight_lbs):
return f"{int(weight_lbs)} lbs"
return f"{weight_lbs:.1f} lbs"
class ProgressionService:
"""Implements the auto-progression engine.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def _get_program(
self, user_id: int, exercise_id: int,
) -> Optional[UserExerciseProgram]:
"""Look up the user's program for a specific exercise."""
statement = select(UserExerciseProgram).where(
UserExerciseProgram.user_id == user_id,
UserExerciseProgram.exercise_id == exercise_id,
)
return self._session.exec(statement).first()
def _get_recent_sessions(
self, user_id: int, exercise_id: int, limit: int = 5,
) -> list[dict]:
"""Get recent session summaries for an exercise.
Returns a list of dicts with: date, avg_reps, weight, all_felt_easy.
"""
statement = (
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
.limit(limit * 2)
)
sessions = self._session.exec(statement).all()
results = []
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(
WorkoutLog.session_id == ws.id,
WorkoutLog.exercise_id == exercise_id,
)
).all()
if not logs:
continue
avg_reps = sum(log.reps_completed for log in logs) / len(logs)
weight = logs[0].weight_used
all_felt_easy = all(log.felt_easy for log in logs)
results.append({
"date": ws.date,
"avg_reps": avg_reps,
"weight": weight,
"all_felt_easy": all_felt_easy,
"set_count": len(logs),
})
if len(results) >= limit:
break
return results
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.
Returns:
Dict with keys: suggested_reps, suggested_weight,
progression_type, message.
"""
program = self._get_program(user_id, exercise_id)
if program is None:
return {
"suggested_reps": 0,
"suggested_weight": "",
"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
recent = self._get_recent_sessions(user_id, exercise_id, limit=5)
if not recent:
return {
"suggested_reps": wk1_reps,
"suggested_weight": wk1_weight,
"progression_type": "baseline",
"message": f"Start with {wk1_reps} reps @ {wk1_weight}.",
}
latest = recent[0]
current_reps = int(round(latest["avg_reps"]))
current_weight = latest["weight"]
current_weight_num = _parse_weight(current_weight)
consecutive_sessions = len(recent)
# Rule: Deload at week 5 (4 consecutive sessions completed)
if consecutive_sessions >= 4:
if current_weight_num is not None:
deload_weight = current_weight_num * 0.8
return {
"suggested_reps": wk1_reps,
"suggested_weight": _format_weight(deload_weight),
"progression_type": "deload",
"message": (
f"Deload week: {wk1_reps} reps @ "
f"{_format_weight(deload_weight)} (-20%)."
),
}
return {
"suggested_reps": wk1_reps,
"suggested_weight": current_weight,
"progression_type": "deload",
"message": f"Deload week: reset to {wk1_reps} reps.",
}
# Rule: Weight increase if at rep target and felt easy
if current_reps >= wk4_reps and latest["all_felt_easy"]:
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"Weight up: {wk1_reps} reps @ "
f"{_format_weight(new_weight)} (+5 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)
return {
"suggested_reps": new_reps,
"suggested_weight": current_weight,
"progression_type": "reps_increase",
"message": (
f"Reps up: {new_reps} reps @ {current_weight} "
f"(+{increment})."
),
}
# Hold: at target, waiting for biweekly weight increase
return {
"suggested_reps": current_reps,
"suggested_weight": current_weight,
"progression_type": "hold",
"message": f"Hold at {current_reps} reps @ {current_weight}.",
}
def record_progression(
self,
user_id: int,
exercise_id: int,
suggested_reps: int,
suggested_weight: str,
actual_reps: int,
actual_weight: str,
progression_type: str,
) -> ProgressLog:
"""Record a progression entry in the progress_log table."""
progress_log = ProgressLog(
user_id=user_id,
exercise_id=exercise_id,
date=date.today(),
suggested_reps=suggested_reps,
suggested_weight=suggested_weight,
actual_reps=actual_reps,
actual_weight=actual_weight,
progression_applied=progression_type,
)
self._session.add(progress_log)
self._session.commit()
self._session.refresh(progress_log)
logger.info(
"progression_recorded",
user_id=user_id,
exercise_id=exercise_id,
type=progression_type,
)
return progress_log

View File

@@ -0,0 +1,131 @@
"""Service layer for workout session management.
Handles creation, retrieval, and updates for workout sessions.
A session represents a single workout on a specific date for a user.
"""
from datetime import date
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
class WorkoutSessionService:
"""Handles CRUD operations for WorkoutSession records.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def get_or_create_session(
self,
user_id: int,
workout_day_id: int,
session_date: date,
) -> WorkoutSession:
"""Get an existing session or create a new one.
If a session already exists for this user + day + date combo,
return it. Otherwise, create a new one. This allows logging
to start automatically without an explicit "start session" step.
Args:
user_id: The user's ID.
workout_day_id: The workout day's ID.
session_date: The date of the workout.
Returns:
The existing or newly created WorkoutSession.
"""
statement = select(WorkoutSession).where(
WorkoutSession.user_id == user_id,
WorkoutSession.workout_day_id == workout_day_id,
WorkoutSession.date == session_date,
)
existing = self._session.exec(statement).first()
if existing:
return existing
ws = WorkoutSession(
user_id=user_id,
workout_day_id=workout_day_id,
date=session_date,
)
self._session.add(ws)
self._session.commit()
self._session.refresh(ws)
logger.info(
"workout_session_created",
user_id=user_id,
day_id=workout_day_id,
date=str(session_date),
)
return ws
def list_sessions(
self,
user_id: int,
limit: int = 50,
) -> list[WorkoutSession]:
"""List workout sessions for a user, most recent first.
Args:
user_id: The user's ID.
limit: Maximum number of sessions to return.
Returns:
List of WorkoutSession records, ordered by date descending.
"""
statement = (
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
.limit(limit)
)
return list(self._session.exec(statement).all())
def get_session_by_id(self, session_id: int) -> Optional[WorkoutSession]:
"""Retrieve a workout session by primary key.
Args:
session_id: The session ID.
Returns:
The WorkoutSession record, or None if not found.
"""
return self._session.get(WorkoutSession, session_id)
def update_session(self, session_id: int, **kwargs) -> WorkoutSession:
"""Update fields on an existing workout session.
Args:
session_id: The session ID.
**kwargs: Field names and new values.
Returns:
The updated WorkoutSession record.
Raises:
ValueError: If the session is not found.
"""
ws = self.get_session_by_id(session_id)
if ws is None:
raise ValueError(f"WorkoutSession with id {session_id} not found")
for key, value in kwargs.items():
if hasattr(ws, key):
setattr(ws, key, value)
self._session.add(ws)
self._session.commit()
self._session.refresh(ws)
return ws

View File

@@ -27,14 +27,16 @@
</ul> </ul>
<ul> <ul>
{% block nav_items %} {% block nav_items %}
<!-- Phase 3 adds: profile switcher, login/logout --> {% include "partials/nav.html" ignore missing %}
{% endblock %} {% endblock %}
</ul> </ul>
</nav> </nav>
</header> </header>
<main class="container"> <main class="container">
{% block flash %}{% endblock %} {% block flash %}
{% include "partials/flash_message.html" ignore missing %}
{% endblock %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Dashboard -- SneakySwole{% endblock %}
{% block head_extra %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<hgroup>
<h1>Progress Dashboard</h1>
{% if active_profile %}
<p>{{ active_profile.display_name }}'s training overview</p>
{% else %}
<p>No profile selected -- <a href="/profiles">select one</a></p>
{% endif %}
</hgroup>
{% if stats %}
<!-- Summary Stats -->
<div class="grid">
{% include "partials/stats_card.html" %}
</div>
<!-- Volume by Day Chart -->
<article>
<header><h3>Volume by Workout Day</h3></header>
{% include "partials/volume_chart.html" %}
</article>
<!-- Exercise Progress Links -->
<article>
<header><h3>Per-Exercise Progress</h3></header>
<ul>
{% for exercise in exercises %}
<li>
<a href="/dashboard/exercise/{{ exercise.id }}">
{{ exercise.name }}
</a>
<small> -- {{ exercise.workout_day }} Day</small>
</li>
{% endfor %}
</ul>
</article>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Exercise Library — SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>Exercise Library</h1>
<p>Browse all exercises with form cues and programming details.</p>
</hgroup>
<!-- HTMX-powered filters -->
<div class="grid">
<select name="workout_day"
hx-get="/exercises/search"
hx-target="#exercise-results"
hx-include="[name='muscle_group']">
<option value="">All Days</option>
{% for day in workout_days %}
<option value="{{ day.name }}">{{ day.name }}</option>
{% endfor %}
</select>
<select name="muscle_group"
hx-get="/exercises/search"
hx-target="#exercise-results"
hx-include="[name='workout_day']">
<option value="">All Muscle Groups</option>
{% for mg in muscle_groups %}
<option value="{{ mg }}">{{ mg }}</option>
{% endfor %}
</select>
</div>
<!-- Exercise results (replaced by HTMX on filter change) -->
<div id="exercise-results">
{% include "partials/exercise_list.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}{{ exercise.name }} Progress -- SneakySwole{% endblock %}
{% block head_extra %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<hgroup>
<h1>{{ exercise.name }}</h1>
<p>{{ exercise.muscle_group }} | {{ exercise.workout_day }} Day</p>
</hgroup>
<!-- Progression Suggestion -->
{% if suggestion %}
<article>
<header><h3>Next Workout Suggestion</h3></header>
<p>{{ suggestion.message }}</p>
<div class="grid">
<div>
<small>Suggested Reps</small>
<p style="font-size:1.5rem; font-weight:700;">
{{ suggestion.suggested_reps }}
</p>
</div>
<div>
<small>Suggested Weight</small>
<p style="font-size:1.5rem; font-weight:700;">
{{ suggestion.suggested_weight }}
</p>
</div>
<div>
<small>Progression Type</small>
<p><mark>{{ suggestion.progression_type }}</mark></p>
</div>
</div>
</article>
{% endif %}
<!-- Progress Chart -->
<article>
<header><h3>Rep and Weight Trends</h3></header>
{% include "partials/progress_chart.html" %}
</article>
<a href="/dashboard" role="button" class="outline">Back to Dashboard</a>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Workout History -- SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>Workout History</h1>
{% if active_profile %}
<p>History for: <strong>{{ active_profile.display_name }}</strong></p>
{% else %}
<p>No profile selected -- <a href="/profiles">select one</a></p>
{% endif %}
</hgroup>
{% if sessions %}
{% for ws in sessions %}
{% set day = days_by_id.get(ws.workout_day_id) %}
{% include "partials/session_card.html" %}
{% endfor %}
{% else %}
<p>No workout sessions recorded yet.</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}SneakySwole — Login{% endblock %}
{% block content %}
<article>
<header>
<h2>Admin Login</h2>
</header>
{% if error %}
<div class="flash-error" role="alert">{{ error }}</div>
{% endif %}
<form method="POST" action="/login">
<label for="username">Username</label>
<input type="text" id="username" name="username"
placeholder="Enter username" required autofocus>
<label for="password">Password</label>
<input type="password" id="password" name="password"
placeholder="Enter password" required>
<button type="submit">Login</button>
</form>
</article>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Edit Profile — {{ profile.display_name }}{% endblock %}
{% block content %}
<article>
<header>
<h2>Edit Profile: {{ profile.display_name }}</h2>
</header>
<form method="POST" action="/profiles/{{ profile.id }}/edit">
<label for="display_name">Display Name</label>
<input type="text" id="display_name" name="display_name"
value="{{ profile.display_name }}" required>
<label for="height">Height</label>
<input type="text" id="height" name="height"
value="{{ profile.height or '' }}" placeholder="e.g., 6'0&quot;">
<label for="weight">Weight</label>
<input type="text" id="weight" name="weight"
value="{{ profile.weight or '' }}" placeholder="e.g., 260 lbs">
<label for="goals">Goals</label>
<textarea id="goals" name="goals"
placeholder="Training goals...">{{ profile.goals or '' }}</textarea>
<div class="grid">
<a href="/profiles" role="button" class="outline secondary">Cancel</a>
<button type="submit">Save Changes</button>
</div>
</form>
</article>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}User Profiles — SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>User Profiles</h1>
<p>Manage training profiles.</p>
</hgroup>
<div class="grid">
{% for profile in profiles %}
{% include "partials/profile_card.html" %}
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}4-Week Schedule -- SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>4-Week Schedule</h1>
{% if active_profile %}
<p>Schedule for: <strong>{{ active_profile.display_name }}</strong></p>
{% else %}
<p>No profile selected -- <a href="/profiles">select one</a></p>
{% endif %}
</hgroup>
{% for week in weeks %}
<article>
<header>
<h3>Week {{ week.week_number }}</h3>
</header>
<div class="grid">
{% for day in week.days %}
<div style="text-align:center;
padding:1rem;
border-radius:0.5rem;
{% if day.is_today %}
border: 2px solid var(--pico-primary);
{% endif %}
{% if day.is_completed %}
background: rgba(99, 102, 241, 0.15);
{% endif %}">
<strong>{{ day.workout_day.name }}</strong>
<br>
<small>{{ day.date.strftime('%b %d') }}</small>
{% if day.is_completed %}
<br><mark>Done</mark>
{% endif %}
{% if day.is_today %}
<br><small><strong>Today</strong></small>
{% endif %}
<br>
<a href="/workouts/{{ day.workout_day.name|lower|replace(' ', '-') }}"
style="font-size:0.8rem;">
Start
</a>
</div>
{% endfor %}
</div>
</article>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Session Detail -- SneakySwole{% endblock %}
{% block content %}
<hgroup>
{% set day = days_by_id.get(workout_session.workout_day_id) %}
<h1>
{{ day.name if day else "Workout" }}
-- {{ workout_session.date.strftime('%B %d, %Y') }}
</h1>
{% if workout_session.notes %}
<p>{{ workout_session.notes }}</p>
{% endif %}
</hgroup>
{% for exercise_id, logs in logs_by_exercise.items() %}
{% set exercise = exercises_by_id[exercise_id] %}
<article>
<header>
<h3>{{ exercise.name }}</h3>
</header>
<table>
<thead>
<tr>
<th>Set</th>
<th>Reps</th>
<th>Weight</th>
<th>Easy?</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.set_number }}</td>
<td>{{ log.reps_completed }}</td>
<td>{{ log.weight_used }}</td>
<td>{{ "Yes" if log.felt_easy else "No" }}</td>
<td>{{ log.notes or "" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</article>
{% else %}
<p>No exercises logged in this session.</p>
{% endfor %}
<a href="/history" role="button" class="outline">Back to History</a>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}{{ day_name }} Day — SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>{{ day_name }} Day</h1>
{% if active_profile %}
<p>Training as: <strong>{{ active_profile.display_name }}</strong></p>
{% else %}
<p>No profile selected — <a href="/profiles">select one</a></p>
{% endif %}
</hgroup>
<!-- Warmup Section -->
<section>
<h2>Warmup</h2>
{% include "partials/warmup_list.html" %}
</section>
<!-- Exercises Section -->
<section>
<h2>Exercises</h2>
{% for exercise in exercises %}
{% set program = programs.get(exercise.id) %}
{% include "partials/exercise_card.html" %}
{% endfor %}
</section>
<a href="/workouts" role="button" class="outline">Back to All Days</a>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Workout Days — SneakySwole{% endblock %}
{% block content %}
<h1>Workout Days</h1>
<div class="grid">
{% for day in days %}
<article>
<header>
<h3>Day {{ day.day_number }}: {{ day.name }}</h3>
</header>
<p>{{ day.description }}</p>
<footer>
<a href="/workouts/{{ day.name|lower|replace(' ', '-') }}"
role="button">View Workout</a>
</footer>
</article>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,51 @@
<article>
<header>
<hgroup>
<h3>{{ exercise.name }}</h3>
<p>{{ exercise.muscle_group }} | {{ exercise.sets }} sets | Tempo: {{ exercise.tempo }}</p>
</hgroup>
</header>
{% if program %}
<div class="grid">
<div>
<small>Week 1</small>
<p>{{ program.wk1_reps }} reps @ {{ program.wk1_weight }}</p>
</div>
<div>
<small>Week 4</small>
<p>{{ program.wk4_reps }} reps @ {{ program.wk4_weight }}</p>
</div>
</div>
{% endif %}
{% if suggestions and suggestions[exercise.id] %}
{% set suggestion = suggestions[exercise.id] %}
{% include "partials/progression_badge.html" %}
{% endif %}
<details>
<summary>Form Cues</summary>
<p>{{ exercise.form_cues }}</p>
</details>
<!-- Inline logging (Phase 4) -->
{% if active_profile %}
<div id="logs-exercise-{{ exercise.id }}">
{% if suggestions and suggestions[exercise.id] %}
{% set suggested_reps = suggestions[exercise.id].suggested_reps %}
{% set suggested_weight = suggestions[exercise.id].suggested_weight %}
{% endif %}
{% if existing_logs and existing_logs[exercise.id] %}
{% set logs = existing_logs[exercise.id] %}
{% set exercise_id = exercise.id %}
{% set next_set = logs|length + 1 %}
{% include "partials/log_entry.html" %}
{% else %}
{% set exercise_id = exercise.id %}
{% set next_set = 1 %}
{% include "partials/log_form.html" %}
{% endif %}
</div>
{% endif %}
</article>

View File

@@ -0,0 +1,16 @@
{% for exercise in exercises %}
<article>
<header>
<hgroup>
<h3>{{ exercise.name }}</h3>
<p>{{ exercise.muscle_group }} | {{ exercise.workout_day }} Day | {{ exercise.sets }} sets | Tempo: {{ exercise.tempo }}</p>
</hgroup>
</header>
<details>
<summary>Form Cues</summary>
<p>{{ exercise.form_cues }}</p>
</details>
</article>
{% else %}
<p>No exercises found matching your filters.</p>
{% endfor %}

View File

@@ -0,0 +1,6 @@
{% if flash_success %}
<div class="flash-success" role="status">{{ flash_success }}</div>
{% endif %}
{% if flash_error %}
<div class="flash-error" role="alert">{{ flash_error }}</div>
{% endif %}

View File

@@ -0,0 +1,39 @@
<!-- Displays logged sets for a single exercise within a session -->
{% if logs %}
<table>
<thead>
<tr>
<th>Set</th>
<th>Reps</th>
<th>Weight</th>
<th>Easy?</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.set_number }}</td>
<td>{{ log.reps_completed }}</td>
<td>{{ log.weight_used }}</td>
<td>{{ "Yes" if log.felt_easy else "No" }}</td>
<td>
<form hx-post="/log/{{ log.id }}/delete"
hx-target="#logs-exercise-{{ exercise_id }}"
hx-swap="innerHTML"
hx-confirm="Delete this set?"
style="display:inline; margin:0;">
<button type="submit" class="outline secondary"
style="padding:0.2rem 0.5rem; font-size:0.8rem;">
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<!-- Next set form -->
{% include "partials/log_form.html" %}

View File

@@ -0,0 +1,26 @@
<!-- Inline logging form, included inside each exercise_card.html -->
<form hx-post="/log"
hx-target="#logs-exercise-{{ exercise_id }}"
hx-swap="innerHTML"
style="margin-bottom:0;">
<input type="hidden" name="exercise_id" value="{{ exercise_id }}">
<input type="hidden" name="workout_day_id" value="{{ workout_day_id }}">
<input type="hidden" name="set_number" value="{{ next_set|default(1) }}">
<div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
<small style="white-space:nowrap; opacity:0.7;">Set {{ next_set|default(1) }}</small>
<input type="number" name="reps" placeholder="Reps"
min="0" max="100" required
{% if suggested_reps %}value="{{ suggested_reps }}"{% endif %}
style="width:5rem; margin-bottom:0;">
<input type="text" name="weight" placeholder="Weight (lbs)"
required
{% if suggested_weight %}value="{{ suggested_weight }}"{% endif %}
style="width:8rem; margin-bottom:0;">
<label style="display:flex; align-items:center; gap:0.3rem; margin-bottom:0; white-space:nowrap;">
<input type="checkbox" name="felt_easy" role="switch" style="margin-bottom:0;">
Easy?
</label>
<button type="submit" style="margin-bottom:0; width:auto; white-space:nowrap;">Log Set</button>
</div>
</form>

View File

@@ -0,0 +1,38 @@
{% set admin = request.state.admin %}
{% set profiles = request.state.profiles %}
{% set active_profile = request.state.active_profile %}
{% if admin %}
<li>
<details class="dropdown">
<summary>
{% if active_profile %}
{{ active_profile.display_name }}
{% else %}
Select Profile
{% endif %}
</summary>
<ul dir="rtl">
{% for profile in profiles %}
<li>
<form method="POST" action="/profiles/switch" style="margin:0;">
<input type="hidden" name="profile_id" value="{{ profile.id }}">
<button type="submit" class="outline secondary"
style="width:100%; text-align:left; border:none;">
{{ profile.display_name }}
</button>
</form>
</li>
{% endfor %}
</ul>
</details>
</li>
<li><a href="/workouts">Workouts</a></li>
<li><a href="/schedule">Schedule</a></li>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/history">History</a></li>
<li><a href="/exercises">Exercises</a></li>
<li><a href="/profiles">Profiles</a></li>
<li><a href="/logout">Logout</a></li>
{% else %}
<li><a href="/login">Login</a></li>
{% endif %}

View File

@@ -0,0 +1,12 @@
<article>
<header>
<hgroup>
<h3>{{ profile.display_name }}</h3>
<p>{{ profile.height }} | {{ profile.weight }}</p>
</hgroup>
</header>
<p>{{ profile.goals or "No goals set" }}</p>
<footer>
<a href="/profiles/{{ profile.id }}/edit" role="button" class="outline">Edit</a>
</footer>
</article>

View File

@@ -0,0 +1,56 @@
<canvas id="progress-chart" style="max-height:300px;"></canvas>
<p id="progress-chart-empty" style="display:none;">No data yet.</p>
<script>
(function() {
var data = {{ progress_data_json|safe }};
if (!data.dates || data.dates.length === 0) {
document.getElementById('progress-chart').style.display = 'none';
document.getElementById('progress-chart-empty').style.display = 'block';
return;
}
new Chart(document.getElementById('progress-chart'), {
type: 'line',
data: {
labels: data.dates,
datasets: [
{
label: 'Avg Reps',
data: data.reps,
borderColor: 'rgba(99, 102, 241, 1)',
backgroundColor: 'rgba(99, 102, 241, 0.2)',
yAxisID: 'y-reps',
tension: 0.3
},
{
label: 'Weight (lbs)',
data: data.weights,
borderColor: 'rgba(244, 63, 94, 1)',
backgroundColor: 'rgba(244, 63, 94, 0.2)',
yAxisID: 'y-weight',
tension: 0.3
}
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
'y-reps': {
type: 'linear', display: true, position: 'left',
title: { display: true, text: 'Reps', color: '#ccc' },
ticks: { color: '#ccc' }
},
'y-weight': {
type: 'linear', display: true, position: 'right',
title: { display: true, text: 'Weight (lbs)', color: '#ccc' },
ticks: { color: '#ccc' },
grid: { drawOnChartArea: false }
},
x: { ticks: { color: '#ccc' } }
}
}
});
})();
</script>

View File

@@ -0,0 +1,11 @@
{% if suggestion and suggestion.progression_type != "no_program" %}
<div style="background: rgba(99, 102, 241, 0.1);
border-left: 3px solid var(--pico-primary);
padding: 0.5rem 1rem;
margin-bottom: 0.5rem;
border-radius: 0 0.25rem 0.25rem 0;">
<small>
<strong>Suggestion:</strong> {{ suggestion.message }}
</small>
</div>
{% endif %}

View File

@@ -0,0 +1,16 @@
<article>
<header>
<hgroup>
<h3>{{ day.name if day else "Unknown" }} Day</h3>
<p>{{ ws.date.strftime('%A, %B %d, %Y') }}</p>
</hgroup>
</header>
{% if ws.notes %}
<p>{{ ws.notes }}</p>
{% endif %}
<footer>
<a href="/history/{{ ws.id }}" role="button" class="outline">
View Details
</a>
</footer>
</article>

View File

@@ -0,0 +1,24 @@
<article>
<header><h4>Sessions</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.total_sessions }}
</p>
</article>
<article>
<header><h4>Total Volume</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ "{:,}".format(stats.total_volume) }} lbs
</p>
</article>
<article>
<header><h4>Total Sets</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.total_sets }}
</p>
</article>
<article>
<header><h4>Streak</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.current_streak }} week{{ "s" if stats.current_streak != 1 }}
</p>
</article>

View File

@@ -0,0 +1,30 @@
<canvas id="volume-chart" style="max-height:300px;"></canvas>
<script>
(function() {
var data = {{ volume_data_json|safe }};
var labels = Object.keys(data);
var values = Object.values(data);
new Chart(document.getElementById('volume-chart'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Total Volume (lbs)',
data: values,
backgroundColor: 'rgba(99, 102, 241, 0.7)',
borderColor: 'rgba(99, 102, 241, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { color: '#ccc' } },
x: { ticks: { color: '#ccc' } }
}
}
});
})();
</script>

View File

@@ -0,0 +1,23 @@
<table>
<thead>
<tr>
<th>Exercise</th>
<th>Type</th>
<th>Reps</th>
</tr>
</thead>
<tbody>
{% for warmup in warmups %}
<tr>
<td>
<details>
<summary>{{ warmup.name }}</summary>
<p>{{ warmup.form_cues }}</p>
</details>
</td>
<td>{{ warmup.type }}</td>
<td>{{ warmup.reps }}</td>
</tr>
{% endfor %}
</tbody>
</table>

87
app/utils/auth.py Normal file
View File

@@ -0,0 +1,87 @@
"""Authentication dependencies for FastAPI route protection.
Provides dependency functions that verify the admin session cookie
and return the authenticated User, or redirect to /login.
"""
from typing import Optional
import structlog
from fastapi import Depends, Request
from fastapi.responses import RedirectResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.auth_service import AuthService
logger = structlog.get_logger(__name__)
class NotAuthenticatedError(Exception):
"""Raised when a request lacks valid authentication."""
# Cookie name for the admin session
SESSION_COOKIE_NAME = "session"
def get_current_admin_user(request: Request, session: Session = Depends(get_db_session)) -> User:
"""FastAPI dependency that extracts and validates the admin session.
Reads the session cookie, validates the token, and returns the
authenticated admin User. Redirects to /login (303) if not authenticated.
Args:
request: The incoming HTTP request.
session: Database session (injected by FastAPI).
Returns:
The authenticated admin User.
Raises:
RedirectResponse: 303 redirect to /login if no valid admin session.
"""
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
raise _login_redirect()
secret_key = getattr(request.app.state, "secret_key", "")
auth_service = AuthService(session, secret_key=secret_key)
user_id = auth_service.validate_session_token(token)
if user_id is None:
raise _login_redirect()
user = session.get(User, user_id)
if user is None or not user.is_admin:
raise _login_redirect()
return user
def get_active_profile_id(request: Request) -> Optional[int]:
"""Extract the active profile ID from the session cookie.
The admin selects which user profile to view/log as. This is stored
in a separate cookie called 'active_profile_id'.
Args:
request: The incoming HTTP request.
Returns:
The active profile user ID, or None if not set.
"""
profile_id = request.cookies.get("active_profile_id")
if profile_id and profile_id.isdigit():
return int(profile_id)
return None
def _login_redirect():
"""Create a redirect exception to the login page.
Returns:
A NotAuthenticatedError handled by a registered exception handler
in main.py that sends a 302 redirect to /login.
"""
return NotAuthenticatedError()

29
docker-compose.dev.yaml Normal file
View File

@@ -0,0 +1,29 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: sneakyswole-dev
ports:
- "${APP_PORT:-8000}:8000"
volumes:
- sneakyswole-data:/app/data
- ./app:/app/app
- ./config:/app/config
env_file:
- .env
environment:
- APP_ENV=development
- APP_LOG_LEVEL=debug
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
sneakyswole-data:
driver: local

View File

@@ -1,11 +1,9 @@
services: services:
app: app:
build: image: git.sneakygeek.net/sneakygeek/sneakyswole:latest
context: .
dockerfile: Dockerfile
container_name: sneakyswole container_name: sneakyswole
ports: ports:
- "8000:8000" - "${APP_PORT:-8000}:8000"
volumes: volumes:
- sneakyswole-data:/app/data - sneakyswole-data:/app/data
env_file: env_file:

278
docs/API_REFERENCE.md Normal file
View File

@@ -0,0 +1,278 @@
# API Reference
All endpoints return HTML (full pages or HTMX partials) unless noted otherwise. Protected routes require a valid admin session cookie.
---
## Authentication
### `GET /login`
Render the login form.
- **Auth:** None
- **Response:** Full page (`pages/login.html`)
### `POST /login`
Authenticate admin credentials and start a session.
- **Auth:** None
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `username` (string) — admin username
- `password` (string) — admin password
- **Success:** 303 redirect to `/`, sets `session` cookie (httponly, samesite=lax, 24h TTL)
- **Failure:** 200 with login page re-rendered and error message
### `GET /logout`
End the admin session.
- **Auth:** None (clears cookies regardless)
- **Response:** 303 redirect to `/login`, deletes `session` and `active_profile_id` cookies
---
## Health
### `GET /health`
Application health check for monitoring and readiness probes.
- **Auth:** None
- **Response:** JSON
```json
{
"app": "sneakyswole",
"version": "0.1.0",
"status": "ok"
}
```
---
## Pages
### `GET /`
Home page.
- **Auth:** None
- **Response:** Full page (`pages/home.html`)
---
## Profiles
All profile routes require admin authentication.
### `GET /profiles`
List all user profiles (excludes admin).
- **Auth:** Admin session
- **Response:** Full page (`pages/profiles.html`)
- **Template context:** `profiles`, `active_profile_id`, `admin`
### `POST /profiles/switch`
Switch the active user profile.
- **Auth:** Admin session
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `profile_id` (string) — ID of the profile to activate
- **Response:** 303 redirect to referring page, sets `active_profile_id` cookie
### `GET /profiles/{profile_id}/edit`
Render the profile edit form.
- **Auth:** Admin session
- **Path params:** `profile_id` (int)
- **Response:** Full page (`pages/profile_edit.html`)
### `POST /profiles/{profile_id}/edit`
Update a user profile.
- **Auth:** Admin session
- **Path params:** `profile_id` (int)
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `display_name` (string)
- `height` (string)
- `weight` (string)
- `goals` (string)
- **Response:** 303 redirect to `/profiles`
---
## Workouts
All workout routes require admin authentication.
### `GET /workouts`
List all workout days as clickable cards.
- **Auth:** Admin session
- **Response:** Full page (`pages/workout_days.html`)
- **Template context:** `days`, `admin`
### `GET /workouts/{day_name}`
Display a full workout day with warmups, exercises, programming targets, and inline logging.
- **Auth:** Admin session
- **Path params:** `day_name` (string) — URL-friendly name, e.g., "push", "pull", "lower", "full-body"
- **Response:** Full page (`pages/workout_day.html`)
- **Template context:** `day_name`, `warmups`, `exercises`, `programs`, `active_profile`, `existing_logs`, `suggestions`, `workout_day_id`, `admin`
- **Notes:** Day name is normalized (e.g., "full-body" → "Full Body"). If an active profile is set, includes programming targets, progression suggestions, and today's existing logs.
---
## Exercises
All exercise routes require admin authentication.
### `GET /exercises`
Render the exercise browser with filter dropdowns.
- **Auth:** Admin session
- **Response:** Full page (`pages/exercise_browser.html`)
- **Template context:** `exercises`, `workout_days`, `muscle_groups`, `admin`
### `GET /exercises/search`
HTMX partial — return filtered exercise list.
- **Auth:** Admin session
- **Query params:**
- `workout_day` (string, optional) — filter by workout day name
- `muscle_group` (string, optional) — filter by muscle group
- **Response:** HTMX partial (`partials/exercise_list.html`)
- **Usage:** Called via `hx-get` from exercise browser filter dropdowns
---
## Workout Logging
All logging routes require admin authentication. Responses are HTMX partials that update inline.
### `POST /log`
Log a single set for an exercise. Auto-creates today's workout session if needed.
- **Auth:** Admin session
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `exercise_id` (int)
- `workout_day_id` (int)
- `set_number` (int, default=1)
- `reps` (int)
- `weight` (string) — e.g., "30 lbs", "BW"
- `felt_easy` (checkbox, "on" = true)
- **Response:** HTMX partial (`partials/log_entry.html`) with updated logs for this exercise
- **Error:** If no active profile selected, returns `partials/flash_message.html` with error
### `POST /log/{log_id}/edit`
Edit an existing log entry.
- **Auth:** Admin session
- **Path params:** `log_id` (int)
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `reps` (int)
- `weight` (string)
- `felt_easy` (checkbox)
- `notes` (string, optional)
- **Response:** HTMX partial (`partials/log_entry.html`) with updated logs
### `POST /log/{log_id}/delete`
Delete a log entry.
- **Auth:** Admin session
- **Path params:** `log_id` (int)
- **Response:** HTMX partial (`partials/log_entry.html`) with remaining logs, or empty HTML if log not found
---
## History
All history routes require admin authentication.
### `GET /history`
Display log history for the active profile — list of past sessions, most recent first.
- **Auth:** Admin session
- **Response:** Full page (`pages/log_history.html`)
- **Template context:** `sessions`, `days_by_id`, `active_profile`, `admin`
- **Notes:** Requires active profile to show sessions
### `GET /history/{session_id}`
Display detailed logs for a specific workout session, grouped by exercise.
- **Auth:** Admin session
- **Path params:** `session_id` (int)
- **Response:** Full page (`pages/session_detail.html`)
- **Template context:** `workout_session`, `logs_by_exercise`, `exercises_by_id`, `days_by_id`, `admin`
---
## Dashboard
All dashboard routes require admin authentication.
### `GET /dashboard`
Render the progress dashboard with summary stats, volume chart, and exercise links.
- **Auth:** Admin session
- **Response:** Full page (`pages/dashboard.html`)
- **Template context:** `stats`, `volume_data_json`, `exercises`, `active_profile`, `admin`
- **Notes:** Stats and volume data require an active profile. Chart.js renders client-side charts from JSON embedded in the template.
### `GET /dashboard/exercise/{exercise_id}`
Per-exercise progress page with charts and progression suggestions.
- **Auth:** Admin session
- **Path params:** `exercise_id` (int)
- **Response:** Full page (`pages/exercise_progress.html`)
- **Template context:** `exercise`, `progress_data_json`, `suggestion`, `active_profile`, `admin`
---
## Schedule
### `GET /schedule`
4-week calendar view showing which workout day maps to which date.
- **Auth:** Admin session
- **Response:** Full page (`pages/schedule.html`)
- **Template context:** `weeks`, `active_profile`, `admin`
- **Notes:** Calendar starts from Monday of the current week. Days with completed sessions are highlighted. Requires active profile for session completion data.
---
## Authentication Details
### Cookies
| Cookie | Purpose | Flags | TTL |
|--------|---------|-------|-----|
| `session` | Admin session token (itsdangerous signed) | httponly, samesite=lax | 24 hours |
| `active_profile_id` | Currently selected user profile ID | httponly, samesite=lax | 24 hours |
### Auth Failure Behavior
- Missing or invalid session cookie → 302 redirect to `/login`
- Handled via `NotAuthenticatedError` exception + registered handler in `app/main.py`

View File

@@ -1,6 +1,6 @@
# Documentation Folder # Documentation
This folder contains business planning, architecture decisions, and documentation. This folder contains architecture, API reference, and development documentation.
**No application code belongs here.** **No application code belongs here.**
@@ -8,15 +8,16 @@ This folder contains business planning, architecture decisions, and documentatio
| Document | Purpose | | Document | Purpose |
|----------|---------| |----------|---------|
| `code_guidelines.md` | Code creation guidelines and Git strategy | | `architecture.md` | App architecture, layers, patterns, request lifecycle |
| `security.md` | Python focused security code suggestions | | `API_REFERENCE.md` | All 23 endpoints with params, auth, and response details |
| `roadmap.md` | 5-phase development roadmap | | `database_schema.md` | All 8 tables, columns, relationships, migrations |
| `plans/` | Design documents and implementation plans | | `code_guidelines.md` | Coding standards and Git strategy |
| `security.md` | Python-focused security guidance |
| `roadmap.md` | Feature status and future plans |
## Guidelines ## Guidelines
- Keep documents focused and concise - Keep documents focused and concise
- Update docs when architecture decisions change - Update docs when architecture decisions change
- Use markdown tables for structured information - Use markdown tables for structured information
- Include links to external documentation where relevant - Include links to external documentation where relevant

197
docs/architecture.md Normal file
View File

@@ -0,0 +1,197 @@
# Architecture
## Overview
SneakySwole is a workout tracking and programming app built with FastAPI, HTMX, and SQLite. It follows a layered architecture with strict separation of concerns: routes handle HTTP, services handle business logic and data access, and models define the schema.
All user interactions return HTML (full pages or HTMX partials) — there are no JSON APIs except the health check.
---
## Technology Stack
| Layer | Technology | Notes |
|-------|-----------|-------|
| Web framework | FastAPI + Uvicorn | Async, Python 3.12 |
| Frontend | Jinja2 + HTMX + Pico CSS | Dark theme, no JS framework |
| Database | SQLite3 + SQLModel ORM | Single file at `data/sneakyswole.db` |
| Migrations | Alembic | Schema versioning, auto-generated DDL |
| Auth | bcrypt + itsdangerous | Hashed passwords, signed session cookies |
| Logging | structlog | Structured JSON logging |
| Config | pydantic-settings | Typed `.env` loader with validation |
| Container | Docker + docker-compose | Single service, port 8000, named volume |
| Testing | pytest | 30+ test modules |
| Dependencies | uv + requirements.txt | Pinned versions |
---
## Application Layers
### 1. Routes (`app/routes/`)
HTTP handlers that parse requests, call services, and return rendered templates. Each file covers one domain.
| File | Prefix | Purpose |
|------|--------|---------|
| `auth.py` | `/login`, `/logout` | Login form, credential verification, session cookies |
| `pages.py` | `/` | Home page |
| `profiles.py` | `/profiles` | Profile list, switch, edit |
| `workouts.py` | `/workouts` | Workout day list and detail viewer |
| `exercises.py` | `/exercises` | Exercise browser with HTMX search |
| `logging.py` | `/log` | Inline set logging (create/edit/delete) |
| `history.py` | `/history` | Past session list and detail |
| `dashboard.py` | `/dashboard` | Progress stats, volume charts, per-exercise progress |
| `schedule.py` | `/schedule` | 4-week calendar view |
| `health.py` | `/health` | JSON health check |
**Rules:**
- Routes never query the database directly — all data access goes through services
- All routes (except `/login`, `/logout`, `/health`) require admin authentication via `@Depends(get_current_admin_user)`
- Routes return `TemplateResponse` (HTML), not JSON
### 2. Services (`app/services/`)
Business logic and all database access. Each service is instantiated with a SQLModel `Session`.
| Service | File | Responsibility |
|---------|------|---------------|
| `AuthService` | `auth_service.py` | bcrypt password verification, session token creation/validation (itsdangerous) |
| `UserService` | `user_service.py` | User CRUD, list profiles, update stats |
| `ExerciseService` | `exercise_service.py` | Exercise queries (by day, muscle group), warmup listing, workout day listing |
| `LogService` | `log_service.py` | Workout log CRUD (create/read/update/delete sets) |
| `WorkoutSessionService` | `workout_session_service.py` | Session management (get_or_create, list, lookup) |
| `ProgressionService` | `progression_service.py` | Auto-progression suggestions (+reps/+weight/deload) |
| `AnalyticsService` | `analytics_service.py` | User stats, volume by day, per-exercise progress data |
| `SeedService` | `seed_service.py` | YAML-driven database initialization from `config/` files |
### 3. Models (`app/models/`)
SQLModel ORM classes defining 8 database tables. See `docs/database_schema.md` for full details.
### 4. Utils (`app/utils/`)
Shared utilities — currently just auth dependencies:
- **`auth.py`** — `get_current_admin_user()` (FastAPI dependency), `get_active_profile_id()`, `NotAuthenticatedError`, `SESSION_COOKIE_NAME`
### 5. Templates (`app/templates/`)
Jinja2 templates split into full pages and reusable HTMX partials.
- **`base.html`** — Master layout with Pico CSS dark theme, HTMX script, nav bar
- **`pages/`** — 12 full-page templates (login, home, dashboard, workout_day, etc.)
- **`partials/`** — 13 HTMX fragment templates (exercise_card, log_form, nav, stats_card, etc.)
### 6. Configuration
- **`app/config.py`** — Typed `Settings` class (pydantic-settings), singleton via `@lru_cache`
- **`.env`** — Runtime secrets (gitignored), referenced by docker-compose
- **`.env.example`** — Template documenting required variables
- **`config/exercises.yaml`** — Exercise library (name, muscle group, sets, tempo, form cues)
- **`config/user_programs.yaml`** — Per-user programming targets (week 1/4 reps and weights)
---
## Key Patterns
### Authentication Flow
1. Admin submits credentials via `POST /login`
2. `AuthService.authenticate()` verifies bcrypt hash
3. On success, `AuthService.create_session_token()` creates a signed token (itsdangerous)
4. Token stored in httponly cookie (`session`), samesite=lax, 24h TTL
5. `get_current_admin_user()` dependency validates token on every protected route
6. Invalid/missing token raises `NotAuthenticatedError` → 302 redirect to `/login`
### Profile Switching
- Admin selects active profile via `POST /profiles/switch`
- Profile ID stored in separate httponly cookie (`active_profile_id`)
- `get_active_profile_id()` extracts it from the request
- Workout logging happens under the active profile
### NavContextMiddleware
Starlette middleware (`app/main.py:NavContextMiddleware`) runs on every request:
1. Reads session cookie and validates token
2. If valid admin: loads `admin`, `profiles` list, and `active_profile` into `request.state`
3. Templates read from `request.state` to render the nav bar (profile switcher, etc.)
### HTMX Partial Pattern
All dynamic updates use HTMX with HTML fragment responses:
- Filter dropdowns trigger `hx-get` to `/exercises/search` → returns `partials/exercise_list.html`
- Log form submits `hx-post` to `/log` → returns `partials/log_entry.html`
- No JSON APIs, no fetch calls, no vanilla JS
### Database Startup
1. `create_app()` creates SQLModel engine and calls `SQLModel.metadata.create_all()`
2. `@app.on_event("startup")` triggers `SeedService.seed_all()`
3. Seed service reads `config/exercises.yaml` + `config/user_programs.yaml`
4. Inserts exercises, warmups, workout days, user profiles, and programming targets
5. Creates admin user from `.env` credentials (bcrypt-hashed)
6. Skips seeding if data already exists
---
## Request Lifecycle
```
Client Request
NavContextMiddleware (injects admin/profiles/active_profile into request.state)
FastAPI Router (matches route)
Auth Dependency (get_current_admin_user — validates session cookie)
Route Handler (parses request, calls service(s))
Service Layer (business logic, DB queries via SQLModel Session)
Jinja2 TemplateResponse (renders full page or HTMX partial)
Client Response (HTML)
```
---
## Docker Setup
- **`Dockerfile`** — Slim Python 3.12 base, copies app + config, installs deps, runs Uvicorn
- **`docker-compose.yaml`** — Single `app` service, port 8000, named volume for `data/`, `.env` file
- Static files and templates are baked into the image
- SQLite DB persists via Docker volume mount at `data/`
---
## Directory Structure
```
SneakySwole/
├── app/
│ ├── __init__.py
│ ├── config.py # Typed settings (pydantic-settings)
│ ├── database.py # Engine factory + session dependency
│ ├── logging_config.py # structlog setup
│ ├── main.py # App factory, middleware, router registration
│ ├── models/ # SQLModel ORM (8 tables)
│ ├── routes/ # FastAPI handlers (10 modules)
│ ├── services/ # Business logic + DB access (8 services)
│ ├── static/css/ # Pico CSS overrides
│ ├── templates/ # Jinja2 (pages/ + partials/)
│ └── utils/ # Auth dependencies
├── config/ # YAML seed data
│ ├── exercises.yaml
│ └── user_programs.yaml
├── alembic/ # Database migrations
├── tests/ # pytest test suite
├── data/ # SQLite DB (gitignored, Docker volume)
├── docs/ # Documentation
├── Dockerfile
├── docker-compose.yaml
├── pyproject.toml
├── requirements.txt
└── .env.example
```

202
docs/database_schema.md Normal file
View File

@@ -0,0 +1,202 @@
# Database Schema
SQLite database at `data/sneakyswole.db`, managed by SQLModel ORM with Alembic migrations.
---
## Tables
### `users`
User profiles (admin and regular). Admin has login credentials; regular profiles are managed by the admin.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `username` | VARCHAR | UNIQUE, indexed | Login identifier |
| `password_hash` | VARCHAR | default="" | bcrypt hash (admin only) |
| `display_name` | VARCHAR | default="" | Name shown in UI |
| `height` | VARCHAR | nullable | e.g., "6'0\"" |
| `weight` | VARCHAR | nullable | e.g., "260 lbs" |
| `goals` | VARCHAR | nullable | Free-text training goals |
| `is_admin` | BOOLEAN | default=False | Admin privileges flag |
| `created_at` | DATETIME | auto | Record creation time |
| `updated_at` | DATETIME | auto | Last update time |
**Model:** `app/models/user.py:User`
---
### `exercises`
Exercise library catalog. Each exercise belongs to a workout day.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `name` | VARCHAR | indexed | e.g., "DB Chest Press (Floor)" |
| `muscle_group` | VARCHAR | default="" | e.g., "Chest", "Shoulders" |
| `workout_day` | VARCHAR | indexed | "Push", "Pull", "Lower", "Full Body" |
| `sets` | INTEGER | default=3 | Default number of sets |
| `tempo` | VARCHAR | default="" | e.g., "3-1-2" (eccentric-pause-concentric) |
| `form_cues` | VARCHAR | default="" | Detailed form instructions |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/exercise.py:Exercise`
---
### `warmups`
Standardized warmup routine displayed before every workout.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `name` | VARCHAR | indexed | e.g., "Cat / Cow" |
| `type` | VARCHAR | default="" | Category: "Thoracic Mob", "Hip Mobility", etc. |
| `reps` | VARCHAR | default="" | e.g., "8 reps", "8 each side" |
| `form_cues` | VARCHAR | default="" | Detailed form instructions |
| `sort_order` | INTEGER | default=0 | Display order in warmup sequence |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/warmup.py:Warmup`
---
### `workout_days`
The 4-day training split definition.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `name` | VARCHAR | UNIQUE, indexed | "Push", "Pull", "Lower", "Full Body" |
| `day_number` | INTEGER | UNIQUE | Order in rotation (1-4) |
| `description` | VARCHAR | default="" | Brief focus description |
**Model:** `app/models/workout_day.py:WorkoutDay`
---
### `user_exercise_programs`
Per-user programming targets linking users to exercises with week 1/4 goals.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `user_id` | INTEGER | FK → `users.id`, indexed | Profile this applies to |
| `exercise_id` | INTEGER | FK → `exercises.id`, indexed | Exercise being programmed |
| `wk1_reps` | VARCHAR | default="" | Week 1 target reps (e.g., "10", "30 sec") |
| `wk4_reps` | VARCHAR | default="" | Week 4 target reps |
| `wk1_weight` | VARCHAR | default="" | Week 1 target weight (e.g., "30 lbs", "BW") |
| `wk4_weight` | VARCHAR | default="" | Week 4 target weight |
| `created_at` | DATETIME | auto | Record creation time |
| `updated_at` | DATETIME | auto | Last update time |
**Model:** `app/models/user_exercise_program.py:UserExerciseProgram`
---
### `workout_sessions`
A completed workout session — ties a user to a workout day on a date.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `user_id` | INTEGER | FK → `users.id`, indexed | Who did the workout |
| `workout_day_id` | INTEGER | FK → `workout_days.id` | Which day was trained |
| `date` | DATE | default=today | Date the workout was performed |
| `notes` | VARCHAR | nullable | Free-text session notes |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/workout_session.py:WorkoutSession`
---
### `workout_logs`
Individual set logs within a session. Each row = one set of one exercise.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `session_id` | INTEGER | FK → `workout_sessions.id`, indexed | Parent session |
| `exercise_id` | INTEGER | FK → `exercises.id` | Which exercise |
| `set_number` | INTEGER | default=1 | Set number (1, 2, 3...) |
| `reps_completed` | INTEGER | default=0 | Actual reps performed |
| `weight_used` | VARCHAR | default="" | Weight used (e.g., "30 lbs", "BW") |
| `felt_easy` | BOOLEAN | default=False | Progression signal |
| `notes` | VARCHAR | nullable | Per-set notes |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/workout_log.py:WorkoutLog`
---
### `progress_log`
Progression tracking — what the engine suggested vs what the user actually did.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `user_id` | INTEGER | FK → `users.id`, indexed | Profile being tracked |
| `exercise_id` | INTEGER | FK → `exercises.id` | Exercise being tracked |
| `date` | DATE | default=today | Date of progression entry |
| `suggested_reps` | INTEGER | nullable | Engine recommendation |
| `suggested_weight` | VARCHAR | nullable | Engine recommendation |
| `actual_reps` | INTEGER | nullable | What user actually did |
| `actual_weight` | VARCHAR | nullable | What user actually used |
| `progression_applied` | VARCHAR | nullable | Type: "reps_increase", "weight_increase", "deload" |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/progress_log.py:ProgressLog`
---
## Relationships
```
users
├── user_exercise_programs (1:N via user_id)
├── workout_sessions (1:N via user_id)
└── progress_log (1:N via user_id)
exercises
├── user_exercise_programs (1:N via exercise_id)
├── workout_logs (1:N via exercise_id)
└── progress_log (1:N via exercise_id)
workout_days
└── workout_sessions (1:N via workout_day_id)
workout_sessions
└── workout_logs (1:N via session_id)
```
---
## Migrations
Managed by Alembic. Config at `alembic.ini`, migration scripts at `alembic/versions/`.
- **Initial migration:** `1855836abf6c_initial_schema_8_tables.py` — creates all 8 tables
To create a new migration:
```bash
alembic revision --autogenerate -m "description"
alembic upgrade head
```
---
## Seeding
On first startup, `SeedService.seed_all()` reads:
- `config/exercises.yaml` — exercise catalog + warmups + workout days
- `config/user_programs.yaml` — per-user week 1/4 targets
Admin user is created from `ADMIN_USERNAME` / `ADMIN_PASSWORD` env vars with bcrypt hash. Seeding is skipped if data already exists.

View File

@@ -1,76 +0,0 @@
# Design: Roadmap, Exercise Data, and Auth Model
**Date:** 2026-02-23
**Status:** Approved
## Overview
This document captures the design decisions for the SneakySwole roadmap structure, exercise data management, and authentication model.
## Roadmap Structure
Five phases, each producing a testable milestone:
1. **Scaffold & Infrastructure** — project structure, Docker, base template, logging
2. **Data Layer & Seeding** — SQLite schema, Alembic migrations, YAML-based exercise/program seeding
3. **Workout UI** — admin auth, profile management, workout viewer, exercise browser
4. **Logging & Tracking** — per-exercise logging, session tracking, log history
5. **Progression & Analytics** — auto-progression, schedule view, progress dashboard
Full details in `docs/roadmap.md`.
## Exercise Data Design
### Separated YAML Files
Exercise data is split into two config files for clean separation of concerns:
- **`config/exercises.yaml`** — Universal exercise library
- Exercise name, muscle group, workout day, sets, tempo, form cues
- Warmup exercises (name, type, reps/time, form cues)
- Rarely changes; defines the available exercise catalog
- **`config/user_programs.yaml`** — Per-user programming
- References exercises by name
- Week 1 and week 4 reps and weights per user per exercise
- Changes when users adjust their programming or new users are added
### Seeding Flow
1. On first run (empty DB), the seed script reads both YAML files
2. Exercises and warmups are inserted into their respective tables
3. User profiles are created (admin from `.env`, others from user_programs.yaml)
4. User-specific programming (reps/weights) is linked to exercises and users
5. Subsequent runs skip seeding if data already exists
### Why YAML Over Spreadsheet
- Human-readable and version-controllable
- Easy to update without special tools
- Can be validated with schema checks
- Spreadsheet remains source of truth for initial data extraction only
## Authentication Model
### Phase 1: Simple Admin Auth
- Single admin user with credentials stored in `.env` (`ADMIN_USERNAME`, `ADMIN_PASSWORD`)
- On first run, admin user is created in DB with bcrypt-hashed password
- Admin logs in via a simple login form (session-based auth)
- Admin creates user profiles through the UI (no login for profiles)
- Profile switcher in navigation allows admin to select the active profile
- All workout logging happens under the selected profile
### Security Considerations
- Passwords hashed with bcrypt (never stored plaintext)
- `.env` file is gitignored and never committed
- `.env.example` documents required variables without real values
- Session tokens with reasonable expiry
- Admin-only routes protected by auth middleware
### Future Upgrade Path
- Phase 1 auth is designed to be replaceable
- If multi-user login is needed later, add login credentials to the user profiles table
- Profile switcher becomes unnecessary when each user logs in independently

View File

@@ -1,59 +1,30 @@
# SneakySwole Roadmap # SneakySwole Roadmap
## Phase 1: Scaffold & Infrastructure ## Completed
Set up the project foundation — everything needed before writing features. ### Phase 1: Scaffold & Infrastructure
FastAPI project structure, Dockerfile + docker-compose.yaml, Pico CSS dark theme base template, `.env` config, structlog logging, health check endpoint.
- FastAPI project structure (`app/` with routes, services, models, templates, static, utils) ### Phase 2: Data Layer & Seeding
- Dockerfile + docker-compose.yaml (SQLite volume, port 8000, `.env` support) 8-table SQLite schema via SQLModel + Alembic migrations. YAML-driven seed script (`config/exercises.yaml`, `config/user_programs.yaml`). Service layer for all DB access. Admin user auto-created from `.env` with bcrypt.
- Pico CSS dark theme base template (Jinja2 with `data-theme="dark"`)
- `.env` / `.env.example` with admin credentials (`ADMIN_USERNAME`, `ADMIN_PASSWORD`)
- `uv` for dependency management, `requirements.txt` with pinned versions
- Structlog logging setup
- Basic health check route (`/health`)
## Phase 2: Data Layer & Seeding ### Phase 3: Workout UI
Admin login (bcrypt + signed session cookies), profile switcher, workout day viewer with warmups and exercise cards, HTMX-powered exercise browser with search/filter. NavContextMiddleware for automatic template context.
Build the database schema and seed it from YAML config files. ### Phase 4: Logging & Tracking
Inline set logging from the workout day view via HTMX. Auto-created workout sessions. Log history with per-session detail view. Edit and delete log entries.
- SQLite schema via Alembic migrations ### Phase 5: Progression & Analytics
- Tables: users, exercises, warmups, workout_days, user_exercise_programs, sets, progress_log Auto-progression engine (+reps/+weight/deload rules), 4-week schedule calendar, progress dashboard with Chart.js charts, per-exercise progress pages with suggestions.
- `config/exercises.yaml` — exercise library (name, muscle group, workout day, sets, tempo, form cues)
- `config/user_programs.yaml` — per-user week 1/4 reps and weights for each exercise
- Seed script that loads YAML into DB on first run
- Admin user auto-created from `.env` credentials on startup (password hashed with bcrypt)
- Two initial user profiles seeded: Phillip and Daughter
- Service layer for all DB access (no direct queries from routes)
## Phase 3: Workout UI ---
The core user-facing experience — viewing workouts and managing profiles. ## Future Ideas
- Admin login (simple session-based auth, password hashed) - Multi-user login (replace profile switcher with individual logins)
- Profile switcher in nav (admin selects active user profile) - REST API for mobile clients
- Admin can create and edit user profiles (name, weight, height, goals) - Exercise video/image attachments
- Workout day viewer — warmup routine + main exercises with full form cues - Custom workout program builder
- Exercise library browser (search/filter by muscle group, workout day) - Export/import workout data (CSV/JSON)
- All interactions via HTMX partials (no JSON APIs, no vanilla JS) - Notifications/reminders
- Social features (sharing workouts)
## Phase 4: Logging & Tracking
Enable workout logging so users can track what they actually did.
- Per-exercise logging: sets completed, reps, weight used, "felt easy?" toggle
- Workout session model (date, user profile, workout day)
- HTMX inline logging — log directly from the workout day view
- Log history view per user profile
- Edit/delete past log entries
## Phase 5: Progression & Analytics
Smart suggestions and visual progress tracking.
- Auto-progression engine based on log history
- +1-2 reps/week, +5 lbs every 2 weeks
- Deload detection at week 5 (-20% weight)
- 4-week schedule view (calendar-style, shows which day maps to which date)
- Progress dashboard per user profile
- Per-exercise progress history and trends
- Summary stats (total volume, streak tracking)

View File

@@ -6,4 +6,7 @@ requires-python = ">=3.12"
[build-system] [build-system]
requires = ["setuptools>=75.0"] requires = ["setuptools>=75.0"]
build-backend = "setuptools.backends._legacy:_Backend" build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]

View File

@@ -4,6 +4,8 @@ jinja2>=3.1.0,<4.0.0
structlog>=24.0.0,<25.0.0 structlog>=24.0.0,<25.0.0
python-dotenv>=1.0.0,<2.0.0 python-dotenv>=1.0.0,<2.0.0
bcrypt>=4.2.0,<5.0.0 bcrypt>=4.2.0,<5.0.0
itsdangerous>=2.2.0,<3.0.0
python-multipart>=0.0.20,<1.0.0
pyyaml>=6.0.0,<7.0.0 pyyaml>=6.0.0,<7.0.0
sqlmodel>=0.0.22,<1.0.0 sqlmodel>=0.0.22,<1.0.0
alembic>=1.14.0,<2.0.0 alembic>=1.14.0,<2.0.0

2
run_dev_docker.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
docker compose -f docker-compose.dev.yaml up --build "$@"

View File

@@ -0,0 +1,96 @@
"""Tests for the AnalyticsService class."""
from datetime import date, timedelta
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.models.exercise import Exercise
from app.models.workout_day import WorkoutDay
from app.models.workout_session import WorkoutSession
from app.models.workout_log import WorkoutLog
from app.services.analytics_service import AnalyticsService
class TestAnalyticsService:
"""Tests for analytics data aggregation."""
def _setup(self):
"""Create DB with sessions and logs for analytics."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
user = User(username="phil", password_hash="h", display_name="Phillip")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
exercise = Exercise(
name="DB Chest Press", muscle_group="Chest",
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
)
session.add_all([user, day, exercise])
session.commit()
session.refresh(user)
session.refresh(day)
session.refresh(exercise)
# Create 3 sessions over 3 weeks
for week in range(3):
ws = WorkoutSession(
user_id=user.id, workout_day_id=day.id,
date=date.today() - timedelta(days=(2 - 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=8 + week,
weight_used="30 lbs",
felt_easy=(week == 2),
))
session.commit()
service = AnalyticsService(session)
return session, service, user, exercise
def test_get_total_sessions(self) -> None:
"""Should return the total number of sessions for a user."""
session, service, user, exercise = self._setup()
stats = service.get_user_stats(user.id)
assert stats["total_sessions"] == 3
session.close()
def test_get_total_volume(self) -> None:
"""Should calculate total volume (sets x reps x weight)."""
session, service, user, exercise = self._setup()
stats = service.get_user_stats(user.id)
assert stats["total_volume"] > 0
session.close()
def test_get_workout_streak(self) -> None:
"""Should calculate consecutive workout weeks."""
session, service, user, exercise = self._setup()
stats = service.get_user_stats(user.id)
assert stats["current_streak"] >= 1
session.close()
def test_get_exercise_progress_data(self) -> None:
"""Should return chart-ready data for an exercise."""
session, service, user, exercise = self._setup()
data = service.get_exercise_progress(user.id, exercise.id)
assert "dates" in data
assert "reps" in data
assert "weights" in data
assert len(data["dates"]) == 3
session.close()
def test_get_volume_by_day(self) -> None:
"""Should return volume per workout day for charts."""
session, service, user, exercise = self._setup()
data = service.get_volume_by_day(user.id)
assert "Push" in data
assert data["Push"] > 0
session.close()

View File

@@ -0,0 +1,57 @@
"""Tests for the auth dependency (require_admin)."""
from unittest.mock import MagicMock
import pytest
from app.utils.auth import (
NotAuthenticatedError,
get_current_admin_user,
get_active_profile_id,
)
class TestAuthDependency:
"""Tests for the require_admin dependency."""
def test_redirects_when_no_session_cookie(self) -> None:
"""Should raise NotAuthenticatedError when no session cookie is present."""
request = MagicMock()
request.cookies = {}
with pytest.raises(NotAuthenticatedError):
get_current_admin_user(request=request, session=MagicMock())
def test_redirects_when_invalid_token(self) -> None:
"""Should raise NotAuthenticatedError when session cookie has invalid token."""
request = MagicMock()
request.cookies = {"session": "invalid-token"}
request.app.state.secret_key = "test-secret"
mock_session = MagicMock()
mock_session.get.return_value = None
with pytest.raises(NotAuthenticatedError):
get_current_admin_user(request=request, session=mock_session)
class TestGetActiveProfileId:
"""Tests for the get_active_profile_id dependency."""
def test_returns_profile_id_from_cookie(self) -> None:
"""Should return the integer profile ID from cookie."""
request = MagicMock()
request.cookies = {"active_profile_id": "5"}
assert get_active_profile_id(request) == 5
def test_returns_none_when_no_cookie(self) -> None:
"""Should return None when no active_profile_id cookie is set."""
request = MagicMock()
request.cookies = {}
assert get_active_profile_id(request) is None
def test_returns_none_for_non_numeric(self) -> None:
"""Should return None for non-numeric cookie values."""
request = MagicMock()
request.cookies = {"active_profile_id": "abc"}
assert get_active_profile_id(request) is None

43
tests/test_auth_routes.py Normal file
View File

@@ -0,0 +1,43 @@
"""Tests for login/logout routes."""
from fastapi.testclient import TestClient
class TestLoginPage:
"""Tests for GET /login."""
def test_login_page_returns_200(self, client: TestClient) -> None:
"""GET /login should render the login form."""
response = client.get("/login")
assert response.status_code == 200
assert "Login" in response.text
def test_login_page_has_form(self, client: TestClient) -> None:
"""GET /login should contain a login form with username and password."""
response = client.get("/login")
assert 'name="username"' in response.text
assert 'name="password"' in response.text
class TestLoginPost:
"""Tests for POST /login."""
def test_login_invalid_credentials_shows_error(self, client: TestClient) -> None:
"""POST /login with wrong credentials should show error message."""
response = client.post(
"/login",
data={"username": "wrong", "password": "wrong"},
follow_redirects=False,
)
# Should re-render login page with error or redirect back
assert response.status_code in (200, 303)
class TestLogout:
"""Tests for GET /logout."""
def test_logout_redirects_to_login(self, client: TestClient) -> None:
"""GET /logout should clear session and redirect to login."""
response = client.get("/logout", follow_redirects=False)
assert response.status_code in (303, 307)
assert "/login" in response.headers.get("location", "")

View File

@@ -0,0 +1,93 @@
"""Tests for the AuthService class."""
import bcrypt
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.services.auth_service import AuthService
class TestAuthService:
"""Tests for admin authentication logic."""
def _setup(self):
"""Create an in-memory DB with an admin user."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
# Create admin user with known password
pw_hash = bcrypt.hashpw(b"adminpass", bcrypt.gensalt()).decode("utf-8")
admin = User(
username="admin",
password_hash=pw_hash,
display_name="Admin",
is_admin=True,
)
session.add(admin)
session.commit()
service = AuthService(session, secret_key="test-secret-key")
return session, service
def test_authenticate_valid_credentials(self) -> None:
"""authenticate should return the User for valid credentials."""
session, service = self._setup()
user = service.authenticate("admin", "adminpass")
assert user is not None
assert user.username == "admin"
session.close()
def test_authenticate_wrong_password(self) -> None:
"""authenticate should return None for wrong password."""
session, service = self._setup()
user = service.authenticate("admin", "wrongpass")
assert user is None
session.close()
def test_authenticate_unknown_user(self) -> None:
"""authenticate should return None for unknown username."""
session, service = self._setup()
user = service.authenticate("nobody", "anything")
assert user is None
session.close()
def test_authenticate_non_admin(self) -> None:
"""authenticate should return None for non-admin users."""
session, service = self._setup()
pw_hash = bcrypt.hashpw(b"userpass", bcrypt.gensalt()).decode("utf-8")
non_admin = User(
username="regular",
password_hash=pw_hash,
display_name="Regular",
is_admin=False,
)
session.add(non_admin)
session.commit()
user = service.authenticate("regular", "userpass")
assert user is None
session.close()
def test_create_session_token(self) -> None:
"""create_session_token should return a non-empty signed string."""
session, service = self._setup()
token = service.create_session_token(user_id=1)
assert isinstance(token, str)
assert len(token) > 0
session.close()
def test_validate_session_token(self) -> None:
"""validate_session_token should return the user_id from a valid token."""
session, service = self._setup()
token = service.create_session_token(user_id=42)
user_id = service.validate_session_token(token)
assert user_id == 42
session.close()
def test_validate_session_token_invalid(self) -> None:
"""validate_session_token should return None for tampered tokens."""
session, service = self._setup()
user_id = service.validate_session_token("fake-token-value")
assert user_id is None
session.close()

View File

@@ -0,0 +1,21 @@
"""Tests for dashboard routes."""
from fastapi.testclient import TestClient
class TestDashboard:
"""Tests for GET /dashboard."""
def test_dashboard_requires_auth(self, client: TestClient) -> None:
"""GET /dashboard should require admin login."""
response = client.get("/dashboard", follow_redirects=False)
assert response.status_code in (401, 302, 303)
class TestExerciseProgress:
"""Tests for GET /dashboard/exercise/<id>."""
def test_exercise_progress_requires_auth(self, client: TestClient) -> None:
"""GET /dashboard/exercise/1 should require admin login."""
response = client.get("/dashboard/exercise/1", follow_redirects=False)
assert response.status_code in (401, 302, 303)

View File

@@ -0,0 +1,24 @@
"""Tests for exercise browser routes."""
from fastapi.testclient import TestClient
class TestExerciseBrowser:
"""Tests for GET /exercises."""
def test_exercise_browser_requires_auth(self, client: TestClient) -> None:
"""GET /exercises should require admin login."""
response = client.get("/exercises", follow_redirects=False)
assert response.status_code in (401, 302, 303)
class TestExerciseSearch:
"""Tests for HTMX exercise search."""
def test_exercise_search_requires_auth(self, client: TestClient) -> None:
"""GET /exercises/search should require admin login."""
response = client.get(
"/exercises/search?workout_day=Push",
follow_redirects=False,
)
assert response.status_code in (401, 302, 303)

View File

@@ -0,0 +1,21 @@
"""Tests for log history routes."""
from fastapi.testclient import TestClient
class TestLogHistory:
"""Tests for GET /history."""
def test_history_requires_auth(self, client: TestClient) -> None:
"""GET /history should require admin login."""
response = client.get("/history", follow_redirects=False)
assert response.status_code in (401, 302, 303)
class TestSessionDetail:
"""Tests for GET /history/<session_id>."""
def test_session_detail_requires_auth(self, client: TestClient) -> None:
"""GET /history/1 should require admin login."""
response = client.get("/history/1", follow_redirects=False)
assert response.status_code in (401, 302, 303)

107
tests/test_log_service.py Normal file
View File

@@ -0,0 +1,107 @@
"""Tests for the LogService class."""
from datetime import date
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.models.exercise import Exercise
from app.models.workout_day import WorkoutDay
from app.models.workout_session import WorkoutSession
from app.models.workout_log import WorkoutLog
from app.services.log_service import LogService
class TestLogService:
"""Tests for workout log CRUD operations."""
def _setup(self):
"""Create an in-memory DB with prerequisite data."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
user = User(username="phil", password_hash="h", display_name="Phillip")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
exercise = Exercise(
name="DB Chest Press", muscle_group="Chest",
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
)
session.add_all([user, day, exercise])
session.commit()
session.refresh(user)
session.refresh(day)
session.refresh(exercise)
ws = WorkoutSession(
user_id=user.id, workout_day_id=day.id, date=date.today(),
)
session.add(ws)
session.commit()
session.refresh(ws)
service = LogService(session)
return session, service, ws, exercise
def test_create_log_entry(self) -> None:
"""create_log should insert a new log entry."""
session, service, ws, exercise = self._setup()
log = service.create_log(
session_id=ws.id,
exercise_id=exercise.id,
set_number=1,
reps_completed=8,
weight_used="30 lbs",
felt_easy=False,
)
assert log.id is not None
assert log.reps_completed == 8
session.close()
def test_list_logs_for_session(self) -> None:
"""list_logs_for_session should return all logs for a session."""
session, service, ws, exercise = self._setup()
service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
service.create_log(ws.id, exercise.id, 2, 8, "30 lbs", False)
service.create_log(ws.id, exercise.id, 3, 7, "30 lbs", True)
logs = service.list_logs_for_session(ws.id)
assert len(logs) == 3
session.close()
def test_list_logs_for_exercise_in_session(self) -> None:
"""list_logs_for_exercise should filter by exercise within a session."""
session, service, ws, exercise = self._setup()
service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
logs = service.list_logs_for_exercise(ws.id, exercise.id)
assert len(logs) == 1
session.close()
def test_update_log(self) -> None:
"""update_log should modify an existing log entry."""
session, service, ws, exercise = self._setup()
log = service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
updated = service.update_log(log.id, reps_completed=10, felt_easy=True)
assert updated.reps_completed == 10
assert updated.felt_easy is True
session.close()
def test_delete_log(self) -> None:
"""delete_log should remove a log entry."""
session, service, ws, exercise = self._setup()
log = service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
service.delete_log(log.id)
logs = service.list_logs_for_session(ws.id)
assert len(logs) == 0
session.close()
def test_get_latest_logs_for_exercise(self) -> None:
"""get_latest_logs should return the most recent logs for an exercise."""
session, service, ws, exercise = self._setup()
service.create_log(ws.id, exercise.id, 1, 8, "30 lbs", False)
service.create_log(ws.id, exercise.id, 2, 8, "30 lbs", True)
logs = service.get_latest_logs_for_exercise(
user_id=ws.user_id,
exercise_id=exercise.id,
)
assert len(logs) >= 1
session.close()

View File

@@ -0,0 +1,44 @@
"""Tests for workout logging routes."""
from fastapi.testclient import TestClient
class TestLogSet:
"""Tests for POST /log."""
def test_log_set_requires_auth(self, client: TestClient) -> None:
"""POST /log should require admin login."""
response = client.post(
"/log",
data={
"exercise_id": "1",
"workout_day_id": "1",
"set_number": "1",
"reps": "8",
"weight": "30 lbs",
},
follow_redirects=False,
)
assert response.status_code in (401, 302, 303)
class TestLogEdit:
"""Tests for POST /log/<id>/edit."""
def test_edit_log_requires_auth(self, client: TestClient) -> None:
"""POST /log/1/edit should require admin login."""
response = client.post(
"/log/1/edit",
data={"reps": "10", "weight": "35 lbs"},
follow_redirects=False,
)
assert response.status_code in (401, 302, 303)
class TestLogDelete:
"""Tests for POST /log/<id>/delete."""
def test_delete_log_requires_auth(self, client: TestClient) -> None:
"""POST /log/1/delete should require admin login."""
response = client.post("/log/1/delete", follow_redirects=False)
assert response.status_code in (401, 302, 303)

View File

@@ -0,0 +1,26 @@
"""Tests for profile management routes."""
from fastapi.testclient import TestClient
class TestProfileSwitcher:
"""Tests for POST /profiles/switch."""
def test_switch_profile_requires_auth(self, client: TestClient) -> None:
"""POST /profiles/switch should require admin login."""
response = client.post(
"/profiles/switch",
data={"profile_id": "1"},
follow_redirects=False,
)
# Should redirect to login or return 401
assert response.status_code in (401, 302, 303)
class TestProfileList:
"""Tests for GET /profiles."""
def test_profiles_page_requires_auth(self, client: TestClient) -> None:
"""GET /profiles should require admin login."""
response = client.get("/profiles", follow_redirects=False)
assert response.status_code in (401, 302, 303)

View File

@@ -0,0 +1,161 @@
"""Tests for the ProgressionService class."""
from datetime import date, timedelta
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.models.exercise import Exercise
from app.models.workout_day import WorkoutDay
from app.models.workout_session import WorkoutSession
from app.models.workout_log import WorkoutLog
from app.models.user_exercise_program import UserExerciseProgram
from app.models.progress_log import ProgressLog
from app.services.progression_service import ProgressionService
class TestProgressionService:
"""Tests for the auto-progression engine."""
def _setup(self):
"""Create an in-memory DB with a user, exercise, and program."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
user = User(username="phil", password_hash="h", display_name="Phillip")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
exercise = Exercise(
name="DB Chest Press", muscle_group="Chest",
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
)
session.add_all([user, day, exercise])
session.commit()
session.refresh(user)
session.refresh(day)
session.refresh(exercise)
program = UserExerciseProgram(
user_id=user.id, exercise_id=exercise.id,
wk1_reps="8", wk4_reps="12",
wk1_weight="30 lbs", wk4_weight="40 lbs",
)
session.add(program)
session.commit()
session.refresh(program)
service = ProgressionService(session)
return session, service, user, day, exercise, program
def test_suggest_reps_increase(self) -> None:
"""Should suggest +1-2 reps when below wk4 target."""
session, service, user, day, exercise, program = self._setup()
# Log a session where user did 8 reps (wk1 target)
ws = WorkoutSession(
user_id=user.id, workout_day_id=day.id,
date=date.today() - timedelta(days=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=8,
weight_used="30 lbs", felt_easy=False,
))
session.commit()
suggestion = service.get_suggestion(user.id, exercise.id)
assert suggestion is not None
assert suggestion["suggested_reps"] >= 9 # +1-2 reps
assert suggestion["suggested_weight"] == "30 lbs" # same weight
assert suggestion["progression_type"] == "reps_increase"
session.close()
def test_suggest_weight_increase(self) -> None:
"""Should suggest +5 lbs when at wk4 rep target and felt easy."""
session, service, user, day, exercise, program = self._setup()
# Log two sessions at max reps, all felt easy
for week_offset in [14, 7]:
ws = WorkoutSession(
user_id=user.id, workout_day_id=day.id,
date=date.today() - timedelta(days=week_offset),
)
session.add(ws)
session.commit()
session.refresh(ws)
for set_num in range(1, 4):
session.add(WorkoutLog(
session_id=ws.id, exercise_id=exercise.id,
set_number=set_num, reps_completed=12,
weight_used="30 lbs", felt_easy=True,
))
session.commit()
suggestion = service.get_suggestion(user.id, exercise.id)
assert suggestion is not None
assert suggestion["suggested_weight"] == "35 lbs" # +5 lbs
assert suggestion["progression_type"] == "weight_increase"
session.close()
def test_suggest_deload(self) -> None:
"""Should suggest deload after 4 weeks of progression."""
session, service, user, day, exercise, program = self._setup()
# Log 4 weeks of sessions (simulate week 5 trigger)
for week in range(4):
ws = WorkoutSession(
user_id=user.id, workout_day_id=day.id,
date=date.today() - timedelta(days=(4 - week) * 7),
)
session.add(ws)
session.commit()
session.refresh(ws)
for set_num in range(1, 4):
session.add(WorkoutLog(
session_id=ws.id, exercise_id=exercise.id,
set_number=set_num, reps_completed=12,
weight_used="40 lbs", felt_easy=False,
))
session.commit()
suggestion = service.get_suggestion(user.id, exercise.id)
assert suggestion is not None
# After 4 consecutive weeks, week 5 should be deload
if suggestion["progression_type"] == "deload":
assert "32" in suggestion["suggested_weight"] # -20% of 40
session.close()
def test_no_suggestion_without_logs(self) -> None:
"""Should return program defaults when no logs exist."""
session, service, user, day, exercise, program = self._setup()
suggestion = service.get_suggestion(user.id, exercise.id)
assert suggestion is not None
assert suggestion["suggested_reps"] == 8 # wk1 default
assert suggestion["suggested_weight"] == "30 lbs" # wk1 default
assert suggestion["progression_type"] == "baseline"
session.close()
def test_record_progression(self) -> None:
"""record_progression should write to progress_log table."""
session, service, user, day, exercise, program = self._setup()
service.record_progression(
user_id=user.id,
exercise_id=exercise.id,
suggested_reps=10,
suggested_weight="30 lbs",
actual_reps=10,
actual_weight="30 lbs",
progression_type="reps_increase",
)
from sqlmodel import select
logs = session.exec(select(ProgressLog)).all()
assert len(logs) == 1
assert logs[0].progression_applied == "reps_increase"
session.close()

View File

@@ -0,0 +1,12 @@
"""Tests for schedule calendar routes."""
from fastapi.testclient import TestClient
class TestSchedule:
"""Tests for GET /schedule."""
def test_schedule_requires_auth(self, client: TestClient) -> None:
"""GET /schedule should require admin login."""
response = client.get("/schedule", follow_redirects=False)
assert response.status_code in (401, 302, 303)

View File

@@ -0,0 +1,17 @@
"""Tests for workout day viewer routes."""
from fastapi.testclient import TestClient
class TestWorkoutDayViewer:
"""Tests for GET /workouts/<day_name>."""
def test_workout_day_requires_auth(self, client: TestClient) -> None:
"""GET /workouts/push should require admin login."""
response = client.get("/workouts/push", follow_redirects=False)
assert response.status_code in (401, 302, 303)
def test_workout_days_list_requires_auth(self, client: TestClient) -> None:
"""GET /workouts should require admin login."""
response = client.get("/workouts", follow_redirects=False)
assert response.status_code in (401, 302, 303)

View File

@@ -0,0 +1,83 @@
"""Tests for the WorkoutSessionService class."""
from datetime import date
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.models.workout_day import WorkoutDay
from app.models.workout_session import WorkoutSession
from app.services.workout_session_service import WorkoutSessionService
class TestWorkoutSessionService:
"""Tests for workout session CRUD operations."""
def _setup(self):
"""Create an in-memory DB with prerequisite data."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
user = User(username="phil", password_hash="h", display_name="Phillip")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
session.add_all([user, day])
session.commit()
session.refresh(user)
session.refresh(day)
service = WorkoutSessionService(session)
return session, service, user, day
def test_get_or_create_session_creates_new(self) -> None:
"""get_or_create_session should create a new session if none exists."""
session, service, user, day = self._setup()
ws = service.get_or_create_session(
user_id=user.id,
workout_day_id=day.id,
session_date=date.today(),
)
assert ws.id is not None
assert ws.user_id == user.id
assert ws.workout_day_id == day.id
session.close()
def test_get_or_create_session_returns_existing(self) -> None:
"""get_or_create_session should return existing session for same day."""
session, service, user, day = self._setup()
ws1 = service.get_or_create_session(user.id, day.id, date.today())
ws2 = service.get_or_create_session(user.id, day.id, date.today())
assert ws1.id == ws2.id
session.close()
def test_list_sessions_for_user(self) -> None:
"""list_sessions should return all sessions for a user."""
session, service, user, day = self._setup()
service.get_or_create_session(user.id, day.id, date.today())
sessions = service.list_sessions(user_id=user.id)
assert len(sessions) == 1
session.close()
def test_list_sessions_empty(self) -> None:
"""list_sessions should return empty list for user with no sessions."""
session, service, user, day = self._setup()
sessions = service.list_sessions(user_id=user.id)
assert len(sessions) == 0
session.close()
def test_get_session_by_id(self) -> None:
"""get_session_by_id should return the correct session."""
session, service, user, day = self._setup()
ws = service.get_or_create_session(user.id, day.id, date.today())
found = service.get_session_by_id(ws.id)
assert found is not None
assert found.id == ws.id
session.close()
def test_update_session_notes(self) -> None:
"""update_session should allow updating notes."""
session, service, user, day = self._setup()
ws = service.get_or_create_session(user.id, day.id, date.today())
updated = service.update_session(ws.id, notes="Great workout!")
assert updated.notes == "Great workout!"
session.close()

8
uv.lock generated Normal file
View File

@@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "sneakyswole"
version = "0.1.0"
source = { editable = "." }

Binary file not shown.