Add Doris Barbell live workout logger
This commit is contained in:
@@ -54,6 +54,7 @@ The current scaffold already supports:
|
||||
- routine creation/listing on top of John's imported base split
|
||||
- structured workout logging with nested exercises and sets
|
||||
- dedicated routine log sheets with dropdown exercise selection plus set-by-set reps and weight entry
|
||||
- live workout pages that let John start a session and save one exercise at a time mid-workout
|
||||
- history endpoints for weight, measurements, and workouts
|
||||
- exercise progression summaries with max weight and estimated 1RM per exercise
|
||||
- mobile-friendly homepage with HTML forms for:
|
||||
|
||||
@@ -140,6 +140,86 @@ def routine_log_sheet(request: Request, routine_id: str):
|
||||
)
|
||||
|
||||
|
||||
@router.get('/routines/{routine_id}/live')
|
||||
def live_workout_logger(request: Request, routine_id: str):
|
||||
routine = _get_routine_or_404(request, routine_id)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name='live_workout.html',
|
||||
context=_live_workout_context(request, routine),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/routines/{routine_id}/live/start', status_code=303)
|
||||
def start_live_workout_session(
|
||||
request: Request,
|
||||
routine_id: str,
|
||||
user_id: str = Form(...),
|
||||
performed_at: str = Form(...),
|
||||
):
|
||||
routine = _get_routine_or_404(request, routine_id)
|
||||
if routine['user_id'] != user_id:
|
||||
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
|
||||
request.app.state.store.start_active_workout_session(
|
||||
{
|
||||
'routine_id': routine['id'],
|
||||
'routine_name': routine['name'],
|
||||
'user_id': user_id,
|
||||
'performed_at': performed_at,
|
||||
}
|
||||
)
|
||||
return RedirectResponse(url=f"/routines/{routine_id}/live", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post('/routines/{routine_id}/live/add-exercise', status_code=303)
|
||||
async def add_live_workout_exercise(
|
||||
request: Request,
|
||||
routine_id: str,
|
||||
user_id: str = Form(...),
|
||||
performed_at: str = Form(...),
|
||||
exercise_name: str = Form(...),
|
||||
set_count: int = Form(...),
|
||||
):
|
||||
routine = _get_routine_or_404(request, routine_id)
|
||||
if routine['user_id'] != user_id:
|
||||
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
|
||||
active_session = request.app.state.store.get_active_workout_session(routine_id, user_id)
|
||||
if active_session is None:
|
||||
request.app.state.store.start_active_workout_session(
|
||||
{
|
||||
'routine_id': routine['id'],
|
||||
'routine_name': routine['name'],
|
||||
'user_id': user_id,
|
||||
'performed_at': performed_at,
|
||||
}
|
||||
)
|
||||
exercise = await _parse_live_workout_exercise_form(request, routine, exercise_name, set_count)
|
||||
request.app.state.store.add_active_workout_exercise(routine_id, user_id, exercise)
|
||||
return RedirectResponse(url=f"/routines/{routine_id}/live", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post('/routines/{routine_id}/live/finish', status_code=303)
|
||||
def finish_live_workout_session(
|
||||
request: Request,
|
||||
routine_id: str,
|
||||
user_id: str = Form(...),
|
||||
performed_at: str = Form(...),
|
||||
notes: str = Form(''),
|
||||
):
|
||||
routine = _get_routine_or_404(request, routine_id)
|
||||
if routine['user_id'] != user_id:
|
||||
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
|
||||
active_session = request.app.state.store.get_active_workout_session(routine_id, user_id)
|
||||
if active_session is None:
|
||||
raise HTTPException(status_code=400, detail='No live workout session in progress.')
|
||||
if active_session['performed_at'] != performed_at:
|
||||
raise HTTPException(status_code=400, detail='Performed-at timestamp does not match the active session.')
|
||||
if not active_session.get('exercises'):
|
||||
raise HTTPException(status_code=400, detail='Log at least one exercise before finishing the session.')
|
||||
request.app.state.store.finish_active_workout_session(routine_id, user_id, notes.strip() or None)
|
||||
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post('/workouts/routine-log', status_code=303)
|
||||
async def log_seeded_routine_workout(
|
||||
request: Request,
|
||||
@@ -168,6 +248,11 @@ async def log_seeded_routine_workout(
|
||||
def _dashboard_context(request: Request) -> dict:
|
||||
store = request.app.state.store
|
||||
summary = store.dashboard_summary()
|
||||
routines_with_live_state = []
|
||||
for routine in summary['routines']:
|
||||
active_session = store.get_active_workout_session(routine['id'], routine['user_id'])
|
||||
routines_with_live_state.append({**routine, 'live_session': active_session})
|
||||
summary['routines'] = routines_with_live_state
|
||||
return {
|
||||
'title': 'Doris Barbell',
|
||||
'summary': summary,
|
||||
@@ -178,6 +263,49 @@ def _dashboard_context(request: Request) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _live_workout_context(request: Request, routine: dict) -> dict:
|
||||
active_session = request.app.state.store.get_active_workout_session(routine['id'], routine['user_id'])
|
||||
routine_sheet = _build_routine_log_sheet(request, routine)
|
||||
remaining_exercises = _remaining_live_exercises(routine_sheet, active_session)
|
||||
logged_exercises = []
|
||||
if active_session is not None:
|
||||
for exercise in active_session.get('exercises', []):
|
||||
logged_exercises.append(
|
||||
{
|
||||
'exercise_name': exercise['exercise_name'],
|
||||
'set_count': len(exercise.get('sets', [])),
|
||||
'notes': exercise.get('notes'),
|
||||
}
|
||||
)
|
||||
return {
|
||||
'title': f"Live log · {routine['name']}",
|
||||
'routine': routine,
|
||||
'routine_sheet': routine_sheet,
|
||||
'active_session': active_session,
|
||||
'logged_exercises': logged_exercises,
|
||||
'remaining_exercises': remaining_exercises,
|
||||
'default_performed_at': active_session['performed_at'] if active_session else _default_performed_at_value(),
|
||||
'routine_action_label': _routine_action_label(routine['name']),
|
||||
}
|
||||
|
||||
|
||||
def _remaining_live_exercises(routine_sheet: list[dict], active_session: dict | None) -> list[dict]:
|
||||
completed_counts: dict[str, int] = {}
|
||||
if active_session is not None:
|
||||
for exercise in active_session.get('exercises', []):
|
||||
normalized_name = exercise['exercise_name'].strip().lower()
|
||||
completed_counts[normalized_name] = completed_counts.get(normalized_name, 0) + 1
|
||||
remaining = []
|
||||
for exercise in routine_sheet:
|
||||
normalized_name = exercise['exercise_name'].strip().lower()
|
||||
completed_count = completed_counts.get(normalized_name, 0)
|
||||
if completed_count > 0:
|
||||
completed_counts[normalized_name] = completed_count - 1
|
||||
continue
|
||||
remaining.append(exercise)
|
||||
return remaining
|
||||
|
||||
|
||||
def _optional_float(value: str) -> float | None:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
@@ -345,6 +473,58 @@ def _routine_action_label(routine_name: str) -> str:
|
||||
return day_aliases.get(label, label)
|
||||
|
||||
|
||||
async def _parse_live_workout_exercise_form(
|
||||
request: Request,
|
||||
routine: dict,
|
||||
exercise_name: str,
|
||||
set_count: int,
|
||||
) -> dict:
|
||||
form = await request.form()
|
||||
sheet = _build_routine_log_sheet(request, routine)
|
||||
matching_exercise = next(
|
||||
(exercise for exercise in sheet if exercise['exercise_name'].strip().lower() == exercise_name.strip().lower()),
|
||||
None,
|
||||
)
|
||||
if matching_exercise is None:
|
||||
raise HTTPException(status_code=400, detail=f'Unknown routine exercise: {exercise_name}')
|
||||
default_reps_raw = matching_exercise.get('default_reps', '')
|
||||
is_band_exercise = bool(matching_exercise.get('is_band'))
|
||||
sets = []
|
||||
for set_index in range(set_count):
|
||||
reps_raw = str(form.get(f'set_{set_index}_reps', '')).strip()
|
||||
weight_raw = str(form.get(f'set_{set_index}_weight_lbs', '')).strip()
|
||||
if not reps_raw and not weight_raw:
|
||||
if is_band_exercise and default_reps_raw.isdigit():
|
||||
reps_raw = default_reps_raw
|
||||
else:
|
||||
continue
|
||||
if not reps_raw and default_reps_raw.isdigit():
|
||||
reps_raw = default_reps_raw
|
||||
if not reps_raw:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Exercise {exercise_name} set {set_index + 1} needs reps.',
|
||||
)
|
||||
if not weight_raw and not is_band_exercise:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Exercise {exercise_name} set {set_index + 1} needs both reps and weight.',
|
||||
)
|
||||
try:
|
||||
reps = int(reps_raw)
|
||||
weight_lbs = None if not weight_raw else float(weight_raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Exercise {exercise_name} set {set_index + 1} has invalid reps or weight.',
|
||||
) from exc
|
||||
sets.append({'reps': reps, 'weight_lbs': weight_lbs})
|
||||
if not sets:
|
||||
raise HTTPException(status_code=400, detail=f'Log at least one set for {exercise_name}.')
|
||||
exercise_notes = str(form.get('exercise_notes', '')).strip() or None
|
||||
return {'exercise_name': matching_exercise['exercise_name'], 'notes': exercise_notes, 'sets': sets}
|
||||
|
||||
|
||||
async def _parse_structured_routine_form(request: Request, routine: dict, exercise_count: int) -> list[dict]:
|
||||
form = await request.form()
|
||||
routine_exercises = routine.get('exercises', [])
|
||||
|
||||
@@ -155,6 +155,7 @@ DEFAULT_STATE = {
|
||||
'weight_entries': [],
|
||||
'measurement_entries': [],
|
||||
'workout_sessions': [],
|
||||
'active_workout_sessions': [],
|
||||
}
|
||||
|
||||
LEGACY_TEMPLATE_ALIASES = {
|
||||
@@ -176,7 +177,11 @@ class JSONStore:
|
||||
self._write(seeded_state)
|
||||
|
||||
def _read(self) -> dict[str, Any]:
|
||||
return json.loads(self.state_path.read_text())
|
||||
payload = json.loads(self.state_path.read_text())
|
||||
seeded_payload = _ensure_seeded_library(payload)
|
||||
if seeded_payload != payload:
|
||||
self._write(seeded_payload)
|
||||
return seeded_payload
|
||||
|
||||
def _write(self, payload: dict[str, Any]) -> None:
|
||||
self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
||||
@@ -278,6 +283,84 @@ class JSONStore:
|
||||
return routine
|
||||
return None
|
||||
|
||||
def get_active_workout_session(self, routine_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, user_id)
|
||||
for session in state['active_workout_sessions']:
|
||||
if session['routine_id'] == routine_id and session['user_id'] == user_id:
|
||||
return session
|
||||
return None
|
||||
|
||||
def start_active_workout_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, payload['user_id'])
|
||||
state['active_workout_sessions'] = [
|
||||
session
|
||||
for session in state['active_workout_sessions']
|
||||
if not (
|
||||
session['routine_id'] == payload['routine_id'] and session['user_id'] == payload['user_id']
|
||||
)
|
||||
]
|
||||
saved = {
|
||||
'id': uuid4().hex,
|
||||
'routine_id': payload['routine_id'],
|
||||
'routine_name': payload['routine_name'],
|
||||
'user_id': payload['user_id'],
|
||||
'performed_at': payload['performed_at'],
|
||||
'notes': payload.get('notes'),
|
||||
'exercises': [],
|
||||
}
|
||||
state['active_workout_sessions'].append(saved)
|
||||
self._write(state)
|
||||
return saved
|
||||
|
||||
def add_active_workout_exercise(self, routine_id: str, user_id: str, exercise: dict[str, Any]) -> dict[str, Any]:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, user_id)
|
||||
for session in state['active_workout_sessions']:
|
||||
if session['routine_id'] == routine_id and session['user_id'] == user_id:
|
||||
session['exercises'].append(
|
||||
{
|
||||
'exercise_name': exercise['exercise_name'],
|
||||
'notes': exercise.get('notes'),
|
||||
'sets': [dict(item) for item in exercise['sets']],
|
||||
}
|
||||
)
|
||||
self._write(state)
|
||||
return session
|
||||
raise KeyError((routine_id, user_id))
|
||||
|
||||
def finish_active_workout_session(self, routine_id: str, user_id: str, notes: str | None = None) -> dict[str, Any]:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, user_id)
|
||||
for session in state['active_workout_sessions']:
|
||||
if session['routine_id'] != routine_id or session['user_id'] != user_id:
|
||||
continue
|
||||
workout_payload = {
|
||||
'user_id': user_id,
|
||||
'performed_at': session['performed_at'],
|
||||
'routine_name': session['routine_name'],
|
||||
'notes': notes,
|
||||
'exercises': [
|
||||
{
|
||||
'exercise_name': exercise['exercise_name'],
|
||||
'notes': exercise.get('notes'),
|
||||
'sets': [dict(item) for item in exercise['sets']],
|
||||
}
|
||||
for exercise in session['exercises']
|
||||
],
|
||||
}
|
||||
saved_workout = self.add_workout(workout_payload)
|
||||
refreshed_state = self._read()
|
||||
refreshed_state['active_workout_sessions'] = [
|
||||
active_session
|
||||
for active_session in refreshed_state['active_workout_sessions']
|
||||
if active_session.get('id') != session['id']
|
||||
]
|
||||
self._write(refreshed_state)
|
||||
return saved_workout
|
||||
raise KeyError((routine_id, user_id))
|
||||
|
||||
def add_workout(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
state = self._read()
|
||||
self._user_or_raise(state, payload['user_id'])
|
||||
@@ -430,6 +513,7 @@ def _ensure_seeded_library(state: dict[str, Any]) -> dict[str, Any]:
|
||||
payload.setdefault('weight_entries', [])
|
||||
payload.setdefault('measurement_entries', [])
|
||||
payload.setdefault('workout_sessions', [])
|
||||
payload.setdefault('active_workout_sessions', [])
|
||||
|
||||
for template in payload['exercise_templates']:
|
||||
legacy_name = template.get('name', '').strip().lower()
|
||||
|
||||
@@ -50,7 +50,11 @@
|
||||
<li>
|
||||
<strong>{{ routine.name }}</strong>
|
||||
<p class="muted">{{ routine.exercises|length }} exercises</p>
|
||||
<p><a class="nav-link" href="/routines/{{ routine.id }}/log">Open log sheet</a></p>
|
||||
<p>
|
||||
<a class="nav-link" href="/routines/{{ routine.id }}/log">Open log sheet</a>
|
||||
·
|
||||
<a class="nav-link" href="/routines/{{ routine.id }}/live">{% if routine.live_session %}Resume {{ routine.name }} live log{% else %}Resume live log{% endif %}</a>
|
||||
</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
132
home/doris-barbell/app/templates/live_workout.html
Normal file
132
home/doris-barbell/app/templates/live_workout.html
Normal file
@@ -0,0 +1,132 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<section class="grid cols-2 dossier-grid">
|
||||
<article class="card evidence-board">
|
||||
<div class="case-legend">
|
||||
<span class="section-label">Live workout logger</span>
|
||||
<span class="pill">One-at-a-time entry</span>
|
||||
</div>
|
||||
<h2>{{ routine.name }}</h2>
|
||||
<p class="muted">Log one exercise at a time while you work through the session. Save each movement as you finish it, then close out the workout when you're done.</p>
|
||||
{% if routine.notes %}
|
||||
<p class="muted">{{ routine.notes }}</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="card evidence-board">
|
||||
<div class="case-legend">
|
||||
<span class="section-label">Routine field sheet</span>
|
||||
<span class="pill">{{ routine_sheet|length }} exercises</span>
|
||||
</div>
|
||||
<h2>Loaded exercises</h2>
|
||||
<ul class="list case-list">
|
||||
{% for exercise in routine_sheet %}
|
||||
<li>
|
||||
<strong>{{ exercise.exercise_name }}</strong>
|
||||
<p class="muted">{{ exercise.target_sets }} sets{% if exercise.target_reps %} · target reps {{ exercise.target_reps }}{% endif %}</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{% if not active_session %}
|
||||
<section class="grid dossier-grid">
|
||||
<article class="card evidence-board">
|
||||
<div class="case-legend">
|
||||
<span class="section-label">Session control</span>
|
||||
<span class="pill">Ready</span>
|
||||
</div>
|
||||
<h2>Start live session</h2>
|
||||
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/start">
|
||||
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
|
||||
<label>Performed at
|
||||
<input type="datetime-local" name="performed_at" value="{{ default_performed_at }}" required>
|
||||
</label>
|
||||
<button type="submit">Start {{ routine.name }} session</button>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="grid cols-2 dossier-grid">
|
||||
<article class="card evidence-board">
|
||||
<div class="case-legend">
|
||||
<span class="section-label">Session control</span>
|
||||
<span class="pill">In progress</span>
|
||||
</div>
|
||||
<h2>Session in progress</h2>
|
||||
<p class="muted">Started at {{ active_session.performed_at }} · Logged exercises so far: {{ logged_exercises|length }}</p>
|
||||
{% if logged_exercises %}
|
||||
<ul class="list case-list">
|
||||
{% for exercise in logged_exercises %}
|
||||
<li>
|
||||
<strong>{{ exercise.exercise_name }} · {{ exercise.set_count }} sets</strong>
|
||||
{% if exercise.notes %}<p class="muted">{{ exercise.notes }}</p>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No exercises saved yet.</p>
|
||||
{% endif %}
|
||||
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/finish">
|
||||
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
|
||||
<input type="hidden" name="performed_at" value="{{ active_session.performed_at }}">
|
||||
<label>Session notes
|
||||
<textarea name="notes" rows="2" placeholder="Energy, pain, machine swaps, or anything worth remembering."></textarea>
|
||||
</label>
|
||||
<button type="submit">Finish {{ routine_action_label }} workout</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="card evidence-board">
|
||||
<div class="case-legend">
|
||||
<span class="section-label">Remaining queue</span>
|
||||
<span class="pill">{{ remaining_exercises|length }} left</span>
|
||||
</div>
|
||||
<h2>Ready to log next</h2>
|
||||
{% if remaining_exercises %}
|
||||
<p class="muted">Each card saves one exercise at a time, so you can log mid-workout without rewriting the whole session.</p>
|
||||
{% for exercise in remaining_exercises %}
|
||||
<section class="evidence-slab">
|
||||
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/add-exercise">
|
||||
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
|
||||
<input type="hidden" name="performed_at" value="{{ active_session.performed_at }}">
|
||||
<input type="hidden" name="exercise_name" value="{{ exercise.exercise_name }}">
|
||||
<input type="hidden" name="set_count" value="{{ exercise.target_sets }}">
|
||||
<div class="case-legend">
|
||||
<span class="section-label">Exercise</span>
|
||||
<span class="pill">{{ exercise.target_sets }} sets</span>
|
||||
</div>
|
||||
<h3>{{ exercise.exercise_name }}</h3>
|
||||
<p class="muted">{% if exercise.target_reps %}Target reps {{ exercise.target_reps }}{% else %}Enter reps manually{% endif %}</p>
|
||||
{% if exercise.is_band %}
|
||||
<p class="muted">Resistance-band exercise: leave weight blank to log sets and default reps only.</p>
|
||||
{% endif %}
|
||||
<div class="grid cols-2 compact-grid">
|
||||
{% for set in exercise.sets %}
|
||||
<div class="evidence-slab compact-slab">
|
||||
<strong>Set {{ set.index + 1 }}</strong>
|
||||
<label>Reps
|
||||
<input type="number" name="set_{{ set.index }}_reps" min="1" step="1" placeholder="{{ set.default_reps }}">
|
||||
</label>
|
||||
<label>Weight (lb)
|
||||
<input type="number" name="set_{{ set.index }}_weight_lbs" min="0" step="0.1">
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<label>Exercise notes
|
||||
<textarea name="exercise_notes" rows="2" placeholder="Technique note, pain flag, or machine change."></textarea>
|
||||
</label>
|
||||
<button type="submit">Save {{ exercise.exercise_name }}</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="muted">Everything in this routine is logged. Finish the workout to move it into history.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -415,6 +415,108 @@ def test_seeded_routine_can_render_prefilled_log_sheet(tmp_path: Path) -> None:
|
||||
assert re.search(r'name="performed_at" value="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}"', body)
|
||||
|
||||
|
||||
def test_seeded_routine_can_render_live_workout_page(tmp_path: Path) -> None:
|
||||
client = make_client(tmp_path)
|
||||
|
||||
summary = client.get('/api/dashboard/summary').json()
|
||||
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
|
||||
|
||||
response = client.get(f"/routines/{shoulders['id']}/live")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert 'Live workout logger' in body
|
||||
assert 'Log one exercise at a time' in body
|
||||
assert 'Start Thursday: Shoulders session' in body
|
||||
assert 'Resume live log' in client.get('/').text
|
||||
|
||||
|
||||
def test_live_workout_page_backfills_older_state_without_active_session_key(tmp_path: Path) -> None:
|
||||
client = make_client(tmp_path)
|
||||
|
||||
legacy_state = json.loads((tmp_path / 'state.json').read_text())
|
||||
legacy_state.pop('active_workout_sessions', None)
|
||||
(tmp_path / 'state.json').write_text(json.dumps(legacy_state))
|
||||
|
||||
response = client.get('/routines/seed-routine-04/live')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'Start Thursday: Shoulders session' in response.text
|
||||
|
||||
|
||||
def test_live_workout_session_can_log_exercises_one_at_a_time_and_finish(tmp_path: Path) -> None:
|
||||
client = make_client(tmp_path)
|
||||
|
||||
summary = client.get('/api/dashboard/summary').json()
|
||||
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
|
||||
|
||||
start_response = client.post(
|
||||
f"/routines/{shoulders['id']}/live/start",
|
||||
data={
|
||||
'user_id': 'john',
|
||||
'performed_at': '2026-05-28T12:15:00',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
assert start_response.status_code == 200
|
||||
start_body = start_response.text
|
||||
assert 'Session in progress' in start_body
|
||||
assert 'Logged exercises so far: 0' in start_body
|
||||
assert 'name="exercise_name"' in start_body
|
||||
assert 'name="set_0_reps"' in start_body
|
||||
assert 'name="set_0_weight_lbs"' in start_body
|
||||
|
||||
add_response = client.post(
|
||||
f"/routines/{shoulders['id']}/live/add-exercise",
|
||||
data={
|
||||
'user_id': 'john',
|
||||
'performed_at': '2026-05-28T12:15:00',
|
||||
'exercise_name': 'Shoulder Press',
|
||||
'set_count': '3',
|
||||
'set_0_reps': '10',
|
||||
'set_0_weight_lbs': '70',
|
||||
'set_1_reps': '10',
|
||||
'set_1_weight_lbs': '80',
|
||||
'set_2_reps': '8',
|
||||
'set_2_weight_lbs': '90',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
assert add_response.status_code == 200
|
||||
add_body = add_response.text
|
||||
assert 'Logged exercises so far: 1' in add_body
|
||||
assert 'Shoulder Press · 3 sets' in add_body
|
||||
assert 'Upright Row' in add_body
|
||||
|
||||
finish_response = client.post(
|
||||
f"/routines/{shoulders['id']}/live/finish",
|
||||
data={
|
||||
'user_id': 'john',
|
||||
'performed_at': '2026-05-28T12:15:00',
|
||||
'notes': 'Logged between sets.',
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert finish_response.status_code == 303
|
||||
assert finish_response.headers['location'] == '/'
|
||||
|
||||
workout_history = client.get('/api/history/john/workouts')
|
||||
assert workout_history.status_code == 200
|
||||
payload = workout_history.json()
|
||||
assert payload[0]['routine_name'] == 'Thursday: Shoulders'
|
||||
assert payload[0]['exercise_count'] == 1
|
||||
assert payload[0]['set_count'] == 3
|
||||
assert payload[0]['notes'] == 'Logged between sets.'
|
||||
assert payload[0]['exercises'][0]['exercise_name'] == 'Shoulder Press'
|
||||
assert payload[0]['exercises'][0]['sets'][2] == {'reps': 8, 'weight_lbs': 90.0}
|
||||
|
||||
dashboard = client.get('/').text
|
||||
assert 'Resume Thursday: Shoulders live log' not in dashboard
|
||||
|
||||
|
||||
def test_seeded_routine_log_submission_creates_structured_workout(tmp_path: Path) -> None:
|
||||
client = make_client(tmp_path)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user