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>
This commit is contained in:
@@ -24,9 +24,11 @@ from app.database import get_engine, get_db_session
|
||||
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.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.services.seed_service import SeedService
|
||||
from app.services.auth_service import AuthService
|
||||
@@ -104,6 +106,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(auth_router)
|
||||
app.include_router(exercises_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(history_router)
|
||||
app.include_router(logging_router)
|
||||
app.include_router(pages_router)
|
||||
app.include_router(profiles_router)
|
||||
app.include_router(workouts_router)
|
||||
|
||||
113
app/routes/history.py
Normal file
113
app/routes/history.py
Normal 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,
|
||||
})
|
||||
177
app/routes/logging.py
Normal file
177
app/routes/logging.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""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.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"])
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
return HTMLResponse("")
|
||||
@@ -4,6 +4,8 @@ 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
|
||||
@@ -13,6 +15,8 @@ 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.workout_session_service import WorkoutSessionService
|
||||
from app.utils.auth import get_current_admin_user, get_active_profile_id
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -77,6 +81,7 @@ async def workout_day_detail(
|
||||
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:
|
||||
@@ -86,6 +91,27 @@ async def workout_day_detail(
|
||||
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
|
||||
|
||||
# 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,
|
||||
@@ -94,5 +120,7 @@ async def workout_day_detail(
|
||||
"exercises": exercises,
|
||||
"programs": programs,
|
||||
"active_profile": active_profile,
|
||||
"existing_logs": existing_logs,
|
||||
"workout_day_id": workout_day_id,
|
||||
"admin": admin,
|
||||
})
|
||||
|
||||
195
app/services/log_service.py
Normal file
195
app/services/log_service.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""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.
|
||||
|
||||
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")
|
||||
|
||||
self._session.delete(log)
|
||||
self._session.commit()
|
||||
logger.info("log_deleted", log_id=log_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())
|
||||
131
app/services/workout_session_service.py
Normal file
131
app/services/workout_session_service.py
Normal 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
|
||||
23
app/templates/pages/log_history.html
Normal file
23
app/templates/pages/log_history.html
Normal 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 %}
|
||||
51
app/templates/pages/session_detail.html
Normal file
51
app/templates/pages/session_detail.html
Normal 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 %}
|
||||
@@ -24,5 +24,18 @@
|
||||
<p>{{ exercise.form_cues }}</p>
|
||||
</details>
|
||||
|
||||
<!-- Phase 4 adds: inline logging form here -->
|
||||
<!-- Inline logging (Phase 4) -->
|
||||
{% if active_profile %}
|
||||
<div id="logs-exercise-{{ exercise.id }}">
|
||||
{% 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 next_set = 1 %}
|
||||
{% include "partials/log_form.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
39
app/templates/partials/log_entry.html
Normal file
39
app/templates/partials/log_entry.html
Normal 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" %}
|
||||
24
app/templates/partials/log_form.html
Normal file
24
app/templates/partials/log_form.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- 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
|
||||
style="width:5rem; margin-bottom:0;">
|
||||
<input type="text" name="weight" placeholder="Weight (lbs)"
|
||||
required
|
||||
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>
|
||||
@@ -27,6 +27,7 @@
|
||||
</details>
|
||||
</li>
|
||||
<li><a href="/workouts">Workouts</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>
|
||||
|
||||
16
app/templates/partials/session_card.html
Normal file
16
app/templates/partials/session_card.html
Normal 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>
|
||||
21
tests/test_history_routes.py
Normal file
21
tests/test_history_routes.py
Normal 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, 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, 303)
|
||||
107
tests/test_log_service.py
Normal file
107
tests/test_log_service.py
Normal 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()
|
||||
44
tests/test_logging_routes.py
Normal file
44
tests/test_logging_routes.py
Normal 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, 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, 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, 303)
|
||||
83
tests/test_workout_session_service.py
Normal file
83
tests/test_workout_session_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user