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