diff --git a/docs/planning/doris-barbell-v1-plan.md b/docs/planning/doris-barbell-v1-plan.md index 03460e4..7ea1c7b 100644 --- a/docs/planning/doris-barbell-v1-plan.md +++ b/docs/planning/doris-barbell-v1-plan.md @@ -22,7 +22,7 @@ Tech stack: FastAPI, Jinja2, JSON state store for V1 bootstrap, Docker Compose o - Progress photos: out for V1. - Muscle-size tracking: not a priority. - Current height assumption for seed data: 68.5 inches unless John corrects it. -- Exercise catalog: John will send the real exercise list later; V1 should support templates/routines once that lands. +- Exercise catalog: John's current five-day split has now been imported as the default exercise/routine library with a default prescription of 3 sets x 10 reps; working weights are still pending. ## Current scaffold already started @@ -45,6 +45,7 @@ Tech stack: FastAPI, Jinja2, JSON state store for V1 bootstrap, Docker Compose o - `GET /api/history/{user_id}/workouts` - `GET /api/progression/{user_id}/exercises` - Homepage now renders current metrics, recent workouts, exercise bests, and placeholder template/routine scaffolding. +- 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 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, and form-post redirects. @@ -86,7 +87,7 @@ Tech stack: FastAPI, Jinja2, JSON state store for V1 bootstrap, Docker Compose o ## Biggest open product questions still waiting on John -1. Exact exercise/routine list. +1. Working weights for the imported split. 2. Preferred auth mode for V1. 3. Which extra non-photo metrics matter beyond weight, BMI, body fat, waist, hips, chest, and neck. 4. Whether 68.5 inches should be treated as the current real height, or whether he wants 69 inches used instead. diff --git a/home/doris-barbell/README.md b/home/doris-barbell/README.md index 55b0000..5b38b59 100644 --- a/home/doris-barbell/README.md +++ b/home/doris-barbell/README.md @@ -41,11 +41,17 @@ Unless John overrides them later, V1 is being built with these defaults: The current scaffold already supports: - seeded users for `john` and stub `manndra` +- seeded exercise templates and John's current five-day split: + - Monday: Chest + - Tuesday: Back + - Wednesday: Arms + - Thursday: Shoulders + - Friday: Legs - profile updates - weight logging with BMI calculation - imperial body measurements for waist, hips, chest, and neck - exercise template creation/listing -- routine creation/listing, even before the final real workout split is known +- routine creation/listing on top of John's imported base split - structured workout logging with nested exercises and sets - history endpoints for weight, measurements, and workouts - exercise progression summaries with max weight and estimated 1RM per exercise @@ -58,6 +64,7 @@ The current scaffold already supports: - basic dashboard summary plus John-facing history snapshots John is currently seeded at a default height assumption of `68.5` inches until he says otherwise. +His current imported split is stored with a default prescription of `3` sets and `10` reps for each listed movement, while working weights remain `TBD` until he fills them in. ## Current API surface diff --git a/home/doris-barbell/app/services/store.py b/home/doris-barbell/app/services/store.py index 1abb549..bbcbd13 100644 --- a/home/doris-barbell/app/services/store.py +++ b/home/doris-barbell/app/services/store.py @@ -6,29 +6,152 @@ from pathlib import Path from typing import Any from uuid import uuid4 -DEFAULT_STATE = { - 'users': { - 'john': { - 'id': 'john', - 'display_name': 'John', - 'is_stub': False, - 'measurement_system': 'imperial', - 'height_inches': 68.5, - 'goal_weight_lbs': None, - 'notes': 'Seeded with John default height assumption from current intake.', - }, - 'manndra': { - 'id': 'manndra', - 'display_name': 'Manndra', - 'is_stub': True, - 'measurement_system': 'imperial', - 'height_inches': None, - 'goal_weight_lbs': None, - 'notes': 'Stubbed for future shared use.', - }, +DEFAULT_USERS = { + 'john': { + 'id': 'john', + 'display_name': 'John', + 'is_stub': False, + 'measurement_system': 'imperial', + 'height_inches': 68.5, + 'goal_weight_lbs': None, + 'notes': 'Seeded with John default height assumption from current intake.', }, - 'exercise_templates': [], - 'routines': [], + 'manndra': { + 'id': 'manndra', + 'display_name': 'Manndra', + 'is_stub': True, + 'measurement_system': 'imperial', + 'height_inches': None, + 'goal_weight_lbs': None, + 'notes': 'Stubbed for future shared use.', + }, +} + +SEEDED_EXERCISE_TEMPLATES = [ + {'name': 'Bench Press', 'primary_muscle': 'chest', 'equipment': 'barbell'}, + {'name': 'Incline Bench Press', 'primary_muscle': 'upper chest', 'equipment': 'barbell'}, + {'name': 'Decline Bench Press', 'primary_muscle': 'lower chest', 'equipment': 'barbell'}, + {'name': 'Butterflies', 'primary_muscle': 'chest', 'equipment': 'machine'}, + {'name': 'Lat Pulldowns', 'primary_muscle': 'back', 'equipment': 'cable'}, + {'name': 'Bent-Over Supported Rows', 'primary_muscle': 'back', 'equipment': 'machine'}, + {'name': 'Landmine Rows', 'primary_muscle': 'back', 'equipment': 'barbell'}, + {'name': 'Seated Machine Rows', 'primary_muscle': 'back', 'equipment': 'machine'}, + {'name': 'Resistance Band Ab Holds', 'primary_muscle': 'abs', 'equipment': 'band'}, + {'name': 'Lat Pulldown Drop Set', 'primary_muscle': 'back', 'equipment': 'cable'}, + {'name': 'Bicep Curls', 'primary_muscle': 'biceps', 'equipment': 'dumbbells'}, + {'name': 'Front Tricep Extensions', 'primary_muscle': 'triceps', 'equipment': 'cable'}, + {'name': 'Overhead Tricep Extensions', 'primary_muscle': 'triceps', 'equipment': 'cable'}, + {'name': 'Incline Barbell Curls', 'primary_muscle': 'biceps', 'equipment': 'barbell'}, + {'name': 'Isolation Bicep Curls', 'primary_muscle': 'biceps', 'equipment': 'machine'}, + {'name': '7-7-7 Barbell Curl', 'primary_muscle': 'biceps', 'equipment': 'barbell'}, + {'name': 'Suitcase Carry', 'primary_muscle': 'abs', 'equipment': 'dumbbell'}, + {'name': 'Shoulder Press', 'primary_muscle': 'shoulders', 'equipment': 'machine'}, + {'name': 'Arnold Press', 'primary_muscle': 'shoulders', 'equipment': 'dumbbells'}, + {'name': 'Side Lateral Raise', 'primary_muscle': 'shoulders', 'equipment': 'dumbbells'}, + {'name': 'Front Lateral Raise', 'primary_muscle': 'shoulders', 'equipment': 'dumbbells'}, + {'name': 'Upright Row', 'primary_muscle': 'shoulders', 'equipment': 'barbell'}, + {'name': 'Shoulder Shrugs', 'primary_muscle': 'traps', 'equipment': 'dumbbells'}, + {'name': '7-7-7 Shoulder Press', 'primary_muscle': 'shoulders', 'equipment': 'machine'}, + {'name': 'Resistance Band Punch-Out Holds', 'primary_muscle': 'abs', 'equipment': 'band'}, + {'name': 'Squats', 'primary_muscle': 'legs', 'equipment': 'barbell'}, + {'name': 'Leg Extensions', 'primary_muscle': 'quads', 'equipment': 'machine'}, + {'name': 'Step Ups', 'primary_muscle': 'legs', 'equipment': 'bodyweight'}, + {'name': 'Deadlifts', 'primary_muscle': 'posterior chain', 'equipment': 'barbell'}, + {'name': 'Calf Raises', 'primary_muscle': 'calves', 'equipment': 'machine'}, + {'name': 'Resistance Band Crunches', 'primary_muscle': 'abs', 'equipment': 'band'}, +] + +SEEDED_ROUTINES = [ + { + 'user_id': 'john', + 'name': 'Monday: Chest', + 'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD.", + 'exercises': [ + {'exercise_name': 'Bench Press', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Incline Bench Press', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Decline Bench Press', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Butterflies', 'target_sets': 3, 'target_reps': '10'}, + ], + }, + { + 'user_id': 'john', + 'name': 'Tuesday: Back', + 'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD.", + 'exercises': [ + {'exercise_name': 'Lat Pulldowns', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Bent-Over Supported Rows', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Landmine Rows', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Seated Machine Rows', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Resistance Band Ab Holds', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Lat Pulldown Drop Set', 'target_sets': 3, 'target_reps': '10'}, + ], + }, + { + 'user_id': 'john', + 'name': 'Wednesday: Arms', + 'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD. Includes Bis and Tris focus plus suitcase carries for abs.", + 'exercises': [ + {'exercise_name': 'Bicep Curls', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Front Tricep Extensions', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Overhead Tricep Extensions', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Incline Barbell Curls', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Isolation Bicep Curls', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': '7-7-7 Barbell Curl', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Suitcase Carry', 'target_sets': 3, 'target_reps': '10'}, + ], + }, + { + 'user_id': 'john', + 'name': 'Thursday: Shoulders', + 'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD. Includes resistance band punch-out ab holds.", + 'exercises': [ + {'exercise_name': 'Shoulder Press', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Arnold Press', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Side Lateral Raise', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Front Lateral Raise', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Upright Row', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Shoulder Shrugs', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': '7-7-7 Shoulder Press', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Resistance Band Punch-Out Holds', 'target_sets': 3, 'target_reps': '10'}, + ], + }, + { + 'user_id': 'john', + 'name': 'Friday: Legs', + 'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD. Includes resistance band crunches for abs.", + 'exercises': [ + {'exercise_name': 'Squats', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Leg Extensions', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Step Ups', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Deadlifts', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Calf Raises', 'target_sets': 3, 'target_reps': '10'}, + {'exercise_name': 'Resistance Band Crunches', 'target_sets': 3, 'target_reps': '10'}, + ], + }, +] + +DEFAULT_STATE = { + 'users': DEFAULT_USERS, + 'exercise_templates': [ + { + 'id': f'seed-template-{index + 1:02d}', + 'name': template['name'], + 'primary_muscle': template.get('primary_muscle'), + 'equipment': template.get('equipment'), + 'notes': template.get('notes'), + } + for index, template in enumerate(SEEDED_EXERCISE_TEMPLATES) + ], + 'routines': [ + { + 'id': f'seed-routine-{index + 1:02d}', + 'user_id': routine['user_id'], + 'name': routine['name'], + 'notes': routine.get('notes'), + 'exercises': [dict(exercise) for exercise in routine['exercises']], + } + for index, routine in enumerate(SEEDED_ROUTINES) + ], 'weight_entries': [], 'measurement_entries': [], 'workout_sessions': [], @@ -42,6 +165,11 @@ class JSONStore: self.state_dir.mkdir(parents=True, exist_ok=True) if not self.state_path.exists(): self._write(deepcopy(DEFAULT_STATE)) + return + state = self._read() + seeded_state = _ensure_seeded_library(state) + if seeded_state != state: + self._write(seeded_state) def _read(self) -> dict[str, Any]: return json.loads(self.state_path.read_text()) @@ -243,11 +371,13 @@ class JSONStore: key=lambda item: item['performed_at'], reverse=True, )[:5] + recent_templates = list(reversed(state['exercise_templates'][-5:])) + routine_board = _routine_board(state['routines']) return { 'users': users, 'recent_workouts': recent_workouts, - 'exercise_templates': self.list_exercise_templates()[:5], - 'routines': self.list_routines()[:5], + 'exercise_templates': recent_templates, + 'routines': routine_board, 'counts': { 'exercise_templates': len(state['exercise_templates']), 'measurement_entries': len(state['measurement_entries']), @@ -276,6 +406,76 @@ def _history_for_user(entries: list[dict[str, Any]], user_id: str, sort_key: str return sorted(matches, key=lambda item: item[sort_key], reverse=True) +def _ensure_seeded_library(state: dict[str, Any]) -> dict[str, Any]: + payload = deepcopy(state) + payload.setdefault('users', deepcopy(DEFAULT_USERS)) + payload.setdefault('exercise_templates', []) + payload.setdefault('routines', []) + payload.setdefault('weight_entries', []) + payload.setdefault('measurement_entries', []) + payload.setdefault('workout_sessions', []) + + existing_template_names = { + item.get('name', '').strip().lower() + for item in payload['exercise_templates'] + if item.get('name') + } + for index, template in enumerate(SEEDED_EXERCISE_TEMPLATES, start=1): + normalized_name = template['name'].strip().lower() + if normalized_name in existing_template_names: + continue + payload['exercise_templates'].append( + { + 'id': f'seed-template-{index:02d}', + 'name': template['name'], + 'primary_muscle': template.get('primary_muscle'), + 'equipment': template.get('equipment'), + 'notes': template.get('notes'), + } + ) + existing_template_names.add(normalized_name) + + existing_routines = { + item.get('name', '').strip().lower(): item + for item in payload['routines'] + if item.get('name') + } + for index, routine in enumerate(SEEDED_ROUTINES, start=1): + normalized_name = routine['name'].strip().lower() + existing_routine = existing_routines.get(normalized_name) + seeded_exercises = [dict(exercise) for exercise in routine['exercises']] + if existing_routine is not None: + existing_routine['user_id'] = routine['user_id'] + existing_routine['notes'] = routine.get('notes') + existing_routine['exercises'] = seeded_exercises + continue + new_routine = { + 'id': f'seed-routine-{index:02d}', + 'user_id': routine['user_id'], + 'name': routine['name'], + 'notes': routine.get('notes'), + 'exercises': seeded_exercises, + } + payload['routines'].append(new_routine) + existing_routines[normalized_name] = new_routine + + return payload + + +def _routine_board(routines: list[dict[str, Any]]) -> list[dict[str, Any]]: + by_name = { + routine.get('name', '').strip().lower(): routine + for routine in routines + if routine.get('name') + } + ordered = [] + for seeded_routine in SEEDED_ROUTINES: + matched = by_name.get(seeded_routine['name'].strip().lower()) + if matched is not None: + ordered.append(matched) + return ordered + + def _trim_measurement_entry(entry: dict[str, Any] | None) -> dict[str, Any] | None: if entry is None: return None diff --git a/home/doris-barbell/app/templates/dashboard.html b/home/doris-barbell/app/templates/dashboard.html index 445825f..21aed48 100644 --- a/home/doris-barbell/app/templates/dashboard.html +++ b/home/doris-barbell/app/templates/dashboard.html @@ -171,28 +171,35 @@
-
Scaffold
-

Template and routine scaffold

-

You can start building the library now, even before the real split lands tomorrow.

+
Routine board
+

Current routine board

+

John's current five-day split is now loaded as the default routine library with a default prescription of 3 sets of 10 reps. Weights are still TBD.

- {% if summary.exercise_templates %} -

Templates

- - {% endif %} {% if summary.routines %} -

Routines

- +
+

Loaded routine days

+ +
+ {% endif %} + {% if summary.exercise_templates %} +
+

Template snapshot

+ +
{% endif %}
diff --git a/home/doris-barbell/tests/test_app.py b/home/doris-barbell/tests/test_app.py index 57c2d9f..17980d0 100644 --- a/home/doris-barbell/tests/test_app.py +++ b/home/doris-barbell/tests/test_app.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from fastapi.testclient import TestClient @@ -30,14 +31,37 @@ def test_dashboard_summary_seeds_john_and_manndra(tmp_path: Path) -> None: assert user_ids == ['john', 'manndra'] assert payload['counts'] == { - 'exercise_templates': 0, + 'exercise_templates': 31, 'measurement_entries': 0, - 'routines': 0, + 'routines': 5, 'weight_entries': 0, 'workout_sessions': 0, } john = next(user for user in payload['users'] if user['id'] == 'john') assert john['measurement_system'] == 'imperial' + routine_names = [routine['name'] for routine in payload['routines']] + assert routine_names == [ + 'Monday: Chest', + 'Tuesday: Back', + 'Wednesday: Arms', + 'Thursday: Shoulders', + 'Friday: Legs', + ] + assert payload['routines'][0]['exercises'][0] == { + 'exercise_name': 'Bench Press', + 'target_sets': 3, + 'target_reps': '10', + } + assert '3 sets of 10 reps' in payload['routines'][0]['notes'] + assert 'Weights are still TBD' in payload['routines'][0]['notes'] + template_names = [template['name'] for template in payload['exercise_templates']] + assert template_names == [ + 'Resistance Band Crunches', + 'Calf Raises', + 'Deadlifts', + 'Step Ups', + 'Leg Extensions', + ] def test_updating_profile_and_logging_weight_computes_bmi(tmp_path: Path) -> None: @@ -132,11 +156,14 @@ def test_can_create_and_list_exercise_templates(tmp_path: Path) -> None: list_response = client.get('/api/exercises/templates') assert list_response.status_code == 200 payload = list_response.json() - assert len(payload) == 1 - assert payload[0]['name'] == 'Bench Press' + assert len(payload) == 32 + assert any( + item['name'] == 'Bench Press' and item['notes'] == 'Flat bench barbell press.' + for item in payload + ) summary = client.get('/api/dashboard/summary').json() - assert summary['counts']['exercise_templates'] == 1 + assert summary['counts']['exercise_templates'] == 32 def test_can_create_routine_before_real_program_arrives(tmp_path: Path) -> None: @@ -181,11 +208,11 @@ def test_can_create_routine_before_real_program_arrives(tmp_path: Path) -> None: list_response = client.get('/api/routines') assert list_response.status_code == 200 payload = list_response.json() - assert len(payload) == 1 - assert payload[0]['name'] == 'Push Day Placeholder' + assert len(payload) == 6 + assert any(routine['name'] == 'Push Day Placeholder' for routine in payload) summary = client.get('/api/dashboard/summary').json() - assert summary['counts']['routines'] == 1 + assert summary['counts']['routines'] == 6 def test_history_endpoints_return_newest_first(tmp_path: Path) -> None: @@ -343,7 +370,106 @@ def test_homepage_renders_core_sections_and_forms(tmp_path: Path) -> None: assert 'Current body metrics' in body assert 'Log weight' in body assert 'Add exercise template' in body - assert 'Build routine draft' in body + assert 'Current routine board' in body + assert 'Monday: Chest' in body + + +def test_existing_state_gets_seeded_routine_backfill(tmp_path: Path) -> None: + state_path = tmp_path / 'state.json' + state_path.write_text( + json.dumps( + { + 'users': { + 'john': { + 'id': 'john', + 'display_name': 'John', + 'is_stub': False, + 'measurement_system': 'imperial', + 'height_inches': 68.5, + 'goal_weight_lbs': None, + 'notes': 'Legacy state', + }, + 'manndra': { + 'id': 'manndra', + 'display_name': 'Manndra', + 'is_stub': True, + 'measurement_system': 'imperial', + 'height_inches': None, + 'goal_weight_lbs': None, + 'notes': 'Legacy stub', + }, + }, + 'exercise_templates': [], + 'routines': [], + 'weight_entries': [], + 'measurement_entries': [], + 'workout_sessions': [], + } + ) + ) + + client = make_client(tmp_path) + payload = client.get('/api/dashboard/summary').json() + + assert payload['counts']['exercise_templates'] == 31 + assert payload['counts']['routines'] == 5 + assert payload['routines'][0]['name'] == 'Monday: Chest' + assert payload['routines'][0]['exercises'][0]['target_sets'] == 3 + assert payload['routines'][0]['exercises'][0]['target_reps'] == '10' + + +def test_existing_seeded_routines_upgrade_prescriptions_to_three_by_ten(tmp_path: Path) -> None: + state_path = tmp_path / 'state.json' + state_path.write_text( + json.dumps( + { + 'users': { + 'john': { + 'id': 'john', + 'display_name': 'John', + 'is_stub': False, + 'measurement_system': 'imperial', + 'height_inches': 68.5, + 'goal_weight_lbs': None, + 'notes': 'Legacy state', + }, + 'manndra': { + 'id': 'manndra', + 'display_name': 'Manndra', + 'is_stub': True, + 'measurement_system': 'imperial', + 'height_inches': None, + 'goal_weight_lbs': None, + 'notes': 'Legacy stub', + }, + }, + 'exercise_templates': [], + 'routines': [ + { + 'id': 'seed-routine-01', + 'user_id': 'john', + 'name': 'Monday: Chest', + 'notes': 'Imported from John\'s current routine. Set and rep targets were not provided yet.', + 'exercises': [ + {'exercise_name': 'Bench Press', 'target_sets': 1, 'target_reps': 'TBD'}, + {'exercise_name': 'Incline Bench Press', 'target_sets': 1, 'target_reps': 'TBD'}, + ], + } + ], + 'weight_entries': [], + 'measurement_entries': [], + 'workout_sessions': [], + } + ) + ) + + client = make_client(tmp_path) + payload = client.get('/api/dashboard/summary').json() + + monday = next(routine for routine in payload['routines'] if routine['name'] == 'Monday: Chest') + assert monday['exercises'][0]['target_sets'] == 3 + assert monday['exercises'][0]['target_reps'] == '10' + assert 'Weights are still TBD' in monday['notes'] def test_weight_form_submission_redirects_and_updates_dashboard(tmp_path: Path) -> None: @@ -404,8 +530,8 @@ def test_template_and_routine_form_submissions_redirect_and_render_entries(tmp_p page = client.get('/') assert page.status_code == 200 assert 'Leg Press' in page.text - assert 'Leg Day Draft' in page.text routines = client.get('/api/routines').json() - assert routines[0]['exercises'][0]['exercise_name'] == 'Leg Press' - assert routines[0]['exercises'][1]['notes'] == 'Slow negative' + saved_routine = next(routine for routine in routines if routine['name'] == 'Leg Day Draft') + assert saved_routine['exercises'][0]['exercise_name'] == 'Leg Press' + assert saved_routine['exercises'][1]['notes'] == 'Slow negative'