From 1227814277affca3145fc88608835e859e450c0d Mon Sep 17 00:00:00 2001 From: Fizzlepoof Date: Thu, 28 May 2026 21:32:09 +0000 Subject: [PATCH] Add Doris Barbell live workout logger --- docs/planning/doris-barbell-v1-plan.md | 3 +- home/doris-barbell/README.md | 1 + home/doris-barbell/app/routes/ui.py | 180 ++++++++++++++++++ home/doris-barbell/app/services/store.py | 86 ++++++++- .../app/templates/dashboard.html | 6 +- .../app/templates/live_workout.html | 132 +++++++++++++ home/doris-barbell/tests/test_app.py | 102 ++++++++++ 7 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 home/doris-barbell/app/templates/live_workout.html diff --git a/docs/planning/doris-barbell-v1-plan.md b/docs/planning/doris-barbell-v1-plan.md index 40ea330..d92a475 100644 --- a/docs/planning/doris-barbell-v1-plan.md +++ b/docs/planning/doris-barbell-v1-plan.md @@ -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 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. +- 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. -- 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 diff --git a/home/doris-barbell/README.md b/home/doris-barbell/README.md index 5df187b..d74c0c0 100644 --- a/home/doris-barbell/README.md +++ b/home/doris-barbell/README.md @@ -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: diff --git a/home/doris-barbell/app/routes/ui.py b/home/doris-barbell/app/routes/ui.py index b4b8a9b..3ce2f35 100644 --- a/home/doris-barbell/app/routes/ui.py +++ b/home/doris-barbell/app/routes/ui.py @@ -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', []) diff --git a/home/doris-barbell/app/services/store.py b/home/doris-barbell/app/services/store.py index 84ca594..6b2edb2 100644 --- a/home/doris-barbell/app/services/store.py +++ b/home/doris-barbell/app/services/store.py @@ -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() diff --git a/home/doris-barbell/app/templates/dashboard.html b/home/doris-barbell/app/templates/dashboard.html index cc28875..166a314 100644 --- a/home/doris-barbell/app/templates/dashboard.html +++ b/home/doris-barbell/app/templates/dashboard.html @@ -50,7 +50,11 @@
  • {{ routine.name }}

    {{ routine.exercises|length }} exercises

    -

    Open log sheet

    +

    + Open log sheet + · + {% if routine.live_session %}Resume {{ routine.name }} live log{% else %}Resume live log{% endif %} +

  • {% endfor %} diff --git a/home/doris-barbell/app/templates/live_workout.html b/home/doris-barbell/app/templates/live_workout.html new file mode 100644 index 0000000..e0876f8 --- /dev/null +++ b/home/doris-barbell/app/templates/live_workout.html @@ -0,0 +1,132 @@ +{% extends 'base.html' %} + +{% block content %} +
    +
    +
    + + One-at-a-time entry +
    +

    {{ routine.name }}

    +

    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.

    + {% if routine.notes %} +

    {{ routine.notes }}

    + {% endif %} +
    + +
    +
    + + {{ routine_sheet|length }} exercises +
    +

    Loaded exercises

    +
      + {% for exercise in routine_sheet %} +
    • + {{ exercise.exercise_name }} +

      {{ exercise.target_sets }} sets{% if exercise.target_reps %} · target reps {{ exercise.target_reps }}{% endif %}

      +
    • + {% endfor %} +
    +
    +
    + +{% if not active_session %} +
    +
    +
    + + Ready +
    +

    Start live session

    +
    + + + +
    +
    +
    +{% else %} +
    +
    +
    + + In progress +
    +

    Session in progress

    +

    Started at {{ active_session.performed_at }} · Logged exercises so far: {{ logged_exercises|length }}

    + {% if logged_exercises %} +
      + {% for exercise in logged_exercises %} +
    • + {{ exercise.exercise_name }} · {{ exercise.set_count }} sets + {% if exercise.notes %}

      {{ exercise.notes }}

      {% endif %} +
    • + {% endfor %} +
    + {% else %} +

    No exercises saved yet.

    + {% endif %} +
    + + + + +
    +
    + +
    +
    + + {{ remaining_exercises|length }} left +
    +

    Ready to log next

    + {% if remaining_exercises %} +

    Each card saves one exercise at a time, so you can log mid-workout without rewriting the whole session.

    + {% for exercise in remaining_exercises %} +
    +
    + + + + +
    + + {{ exercise.target_sets }} sets +
    +

    {{ exercise.exercise_name }}

    +

    {% if exercise.target_reps %}Target reps {{ exercise.target_reps }}{% else %}Enter reps manually{% endif %}

    + {% if exercise.is_band %} +

    Resistance-band exercise: leave weight blank to log sets and default reps only.

    + {% endif %} +
    + {% for set in exercise.sets %} +
    + Set {{ set.index + 1 }} + + +
    + {% endfor %} +
    + + +
    +
    + {% endfor %} + {% else %} +

    Everything in this routine is logged. Finish the workout to move it into history.

    + {% endif %} +
    +
    +{% endif %} +{% endblock %} diff --git a/home/doris-barbell/tests/test_app.py b/home/doris-barbell/tests/test_app.py index 5013930..079c4ff 100644 --- a/home/doris-barbell/tests/test_app.py +++ b/home/doris-barbell/tests/test_app.py @@ -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)