Add Doris Barbell live workout logger
This commit is contained in:
@@ -48,8 +48,9 @@ Tech stack: FastAPI, Jinja2, JSON state store for V1 bootstrap, Docker Compose o
|
|||||||
- Homepage now renders John's imported five-day split as the default routine board.
|
- Homepage now renders John's imported five-day split as the default routine board.
|
||||||
- Homepage now includes mobile-friendly HTML forms for quick weight entry, body measurements, exercise templates, routine drafts, and quick workout logging.
|
- Homepage now includes mobile-friendly HTML forms for quick weight entry, body measurements, exercise templates, routine drafts, and quick workout logging.
|
||||||
- Routine log sheets now support dropdown exercise selection with set-by-set rep and weight entry for seeded program days.
|
- Routine log sheets now support dropdown exercise selection with set-by-set rep and weight entry for seeded program days.
|
||||||
|
- Live workout pages now let John start a session and save one exercise at a time mid-workout before finishing the routine into history.
|
||||||
- Homepage now includes John-focused recent history snapshots so the app is useful before charts exist.
|
- Homepage now includes John-focused recent history snapshots so the app is useful before charts exist.
|
||||||
- Tests currently cover health, seeded users, BMI calculation, imperial measurement logging, exercise template creation, routine creation, history ordering, structured workout logging, progression summaries, homepage render, routine log flows, and form-post redirects.
|
- Tests currently cover health, seeded users, BMI calculation, imperial measurement logging, exercise template creation, routine creation, history ordering, structured workout logging, live workout session flows, progression summaries, homepage render, routine log flows, and form-post redirects.
|
||||||
|
|
||||||
## Next build slices
|
## Next build slices
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ The current scaffold already supports:
|
|||||||
- routine creation/listing on top of John's imported base split
|
- routine creation/listing on top of John's imported base split
|
||||||
- structured workout logging with nested exercises and sets
|
- structured workout logging with nested exercises and sets
|
||||||
- dedicated routine log sheets with dropdown exercise selection plus set-by-set reps and weight entry
|
- 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
|
- history endpoints for weight, measurements, and workouts
|
||||||
- exercise progression summaries with max weight and estimated 1RM per exercise
|
- exercise progression summaries with max weight and estimated 1RM per exercise
|
||||||
- mobile-friendly homepage with HTML forms for:
|
- 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)
|
@router.post('/workouts/routine-log', status_code=303)
|
||||||
async def log_seeded_routine_workout(
|
async def log_seeded_routine_workout(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -168,6 +248,11 @@ async def log_seeded_routine_workout(
|
|||||||
def _dashboard_context(request: Request) -> dict:
|
def _dashboard_context(request: Request) -> dict:
|
||||||
store = request.app.state.store
|
store = request.app.state.store
|
||||||
summary = store.dashboard_summary()
|
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 {
|
return {
|
||||||
'title': 'Doris Barbell',
|
'title': 'Doris Barbell',
|
||||||
'summary': summary,
|
'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:
|
def _optional_float(value: str) -> float | None:
|
||||||
cleaned = value.strip()
|
cleaned = value.strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
@@ -345,6 +473,58 @@ def _routine_action_label(routine_name: str) -> str:
|
|||||||
return day_aliases.get(label, label)
|
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]:
|
async def _parse_structured_routine_form(request: Request, routine: dict, exercise_count: int) -> list[dict]:
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
routine_exercises = routine.get('exercises', [])
|
routine_exercises = routine.get('exercises', [])
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ DEFAULT_STATE = {
|
|||||||
'weight_entries': [],
|
'weight_entries': [],
|
||||||
'measurement_entries': [],
|
'measurement_entries': [],
|
||||||
'workout_sessions': [],
|
'workout_sessions': [],
|
||||||
|
'active_workout_sessions': [],
|
||||||
}
|
}
|
||||||
|
|
||||||
LEGACY_TEMPLATE_ALIASES = {
|
LEGACY_TEMPLATE_ALIASES = {
|
||||||
@@ -176,7 +177,11 @@ class JSONStore:
|
|||||||
self._write(seeded_state)
|
self._write(seeded_state)
|
||||||
|
|
||||||
def _read(self) -> dict[str, Any]:
|
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:
|
def _write(self, payload: dict[str, Any]) -> None:
|
||||||
self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
@@ -278,6 +283,84 @@ class JSONStore:
|
|||||||
return routine
|
return routine
|
||||||
return None
|
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]:
|
def add_workout(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
state = self._read()
|
state = self._read()
|
||||||
self._user_or_raise(state, payload['user_id'])
|
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('weight_entries', [])
|
||||||
payload.setdefault('measurement_entries', [])
|
payload.setdefault('measurement_entries', [])
|
||||||
payload.setdefault('workout_sessions', [])
|
payload.setdefault('workout_sessions', [])
|
||||||
|
payload.setdefault('active_workout_sessions', [])
|
||||||
|
|
||||||
for template in payload['exercise_templates']:
|
for template in payload['exercise_templates']:
|
||||||
legacy_name = template.get('name', '').strip().lower()
|
legacy_name = template.get('name', '').strip().lower()
|
||||||
|
|||||||
@@ -50,7 +50,11 @@
|
|||||||
<li>
|
<li>
|
||||||
<strong>{{ routine.name }}</strong>
|
<strong>{{ routine.name }}</strong>
|
||||||
<p class="muted">{{ routine.exercises|length }} exercises</p>
|
<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>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</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)
|
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:
|
def test_seeded_routine_log_submission_creates_structured_workout(tmp_path: Path) -> None:
|
||||||
client = make_client(tmp_path)
|
client = make_client(tmp_path)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user