diff --git a/home/doris-barbell/app/models.py b/home/doris-barbell/app/models.py index e7bb8d3..ab78a39 100644 --- a/home/doris-barbell/app/models.py +++ b/home/doris-barbell/app/models.py @@ -61,7 +61,7 @@ class RoutineCreate(BaseModel): class WorkoutSetCreate(BaseModel): reps: int = Field(ge=1) - weight_lbs: float = Field(ge=0) + weight_lbs: float | None = Field(default=None, ge=0) rpe: float | None = Field(default=None, ge=0) rest_seconds: int | None = Field(default=None, ge=0) notes: str | None = None diff --git a/home/doris-barbell/app/routes/ui.py b/home/doris-barbell/app/routes/ui.py index ab065d5..b4b8a9b 100644 --- a/home/doris-barbell/app/routes/ui.py +++ b/home/doris-barbell/app/routes/ui.py @@ -132,7 +132,7 @@ def routine_log_sheet(request: Request, routine_id: str): context={ 'title': f"Log {routine['name']}", 'routine': routine, - 'routine_sheet': _build_routine_log_sheet(routine), + 'routine_sheet': _build_routine_log_sheet(request, routine), 'exercise_options': _exercise_options_for_routine(request, routine), 'routine_action_label': _routine_action_label(routine['name']), 'default_performed_at': _default_performed_at_value(), @@ -261,24 +261,29 @@ def _get_routine_or_404(request: Request, routine_id: str) -> dict: return routine -def _build_routine_log_sheet(routine: dict) -> list[dict]: +def _build_routine_log_sheet(request: Request, routine: dict) -> list[dict]: + equipment_by_name = _exercise_equipment_lookup(request) sheet = [] for exercise_index, exercise in enumerate(routine.get('exercises', [])): + exercise_name = exercise['exercise_name'] target_sets = max(int(exercise.get('target_sets', 1) or 1), 1) default_reps = _default_reps_value(exercise.get('target_reps')) + is_band = equipment_by_name.get(exercise_name.strip().lower()) == 'band' sheet.append( { 'exercise_index': exercise_index, - 'exercise_name': exercise['exercise_name'], + 'exercise_name': exercise_name, 'target_sets': target_sets, 'target_reps': exercise.get('target_reps'), 'default_reps': default_reps, 'notes': exercise.get('notes'), + 'is_band': is_band, 'sets': [ { 'exercise_index': exercise_index, 'index': set_index, 'default_reps': default_reps, + 'is_band': is_band, } for set_index in range(target_sets) ], @@ -305,6 +310,17 @@ def _exercise_options_for_routine(request: Request, routine: dict) -> list[str]: return ordered +def _exercise_equipment_lookup(request: Request) -> dict[str, str]: + lookup: dict[str, str] = {} + for template in request.app.state.store.list_exercise_templates(): + name = str(template.get('name', '')).strip().lower() + equipment = str(template.get('equipment', '')).strip().lower() + if not name or not equipment: + continue + lookup[name] = equipment + return lookup + + def _default_reps_value(target_reps: str | None) -> str: if target_reps is None: return '' @@ -332,11 +348,13 @@ def _routine_action_label(routine_name: str) -> str: async def _parse_structured_routine_form(request: Request, routine: dict, exercise_count: int) -> list[dict]: form = await request.form() routine_exercises = routine.get('exercises', []) + equipment_by_name = _exercise_equipment_lookup(request) exercises = [] for exercise_index in range(exercise_count): exercise_name = str(form.get(f'exercise_{exercise_index}_name', '')).strip() if not exercise_name: continue + is_band_exercise = equipment_by_name.get(exercise_name.lower()) == 'band' try: set_count = int(str(form.get(f'exercise_{exercise_index}_set_count', '0')).strip()) except ValueError as exc: @@ -350,17 +368,25 @@ async def _parse_structured_routine_form(request: Request, routine: dict, exerci reps_raw = str(form.get(f'exercise_{exercise_index}_set_{set_index}_reps', '')).strip() weight_raw = str(form.get(f'exercise_{exercise_index}_set_{set_index}_weight_lbs', '')).strip() if not reps_raw and not weight_raw: - continue - if not reps_raw and weight_raw and default_reps_raw.isdigit(): + 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 or not weight_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 = float(weight_raw) + weight_lbs = None if not weight_raw else float(weight_raw) except ValueError as exc: raise HTTPException( status_code=400, diff --git a/home/doris-barbell/app/services/store.py b/home/doris-barbell/app/services/store.py index df66e17..84ca594 100644 --- a/home/doris-barbell/app/services/store.py +++ b/home/doris-barbell/app/services/store.py @@ -289,7 +289,9 @@ class JSONStore: normalized_sets = [] for item in exercise['sets']: set_count += 1 - total_volume_lbs += item['reps'] * item['weight_lbs'] + weight_lbs = item.get('weight_lbs') + if weight_lbs is not None: + total_volume_lbs += item['reps'] * weight_lbs normalized_sets.append(dict(item)) exercises.append({ 'exercise_name': exercise['exercise_name'], @@ -340,10 +342,13 @@ class JSONStore: stats['last_performed_at'] = workout['performed_at'] for item in exercise['sets']: stats['set_count'] += 1 - stats['max_weight_lbs'] = max(stats['max_weight_lbs'], item['weight_lbs']) + weight_lbs = item.get('weight_lbs') + if weight_lbs is None: + continue + stats['max_weight_lbs'] = max(stats['max_weight_lbs'], weight_lbs) stats['best_set_estimated_1rm'] = max( stats['best_set_estimated_1rm'], - _estimated_1rm(item['weight_lbs'], item['reps']), + _estimated_1rm(weight_lbs, item['reps']), ) results = [] for item in progression.values(): diff --git a/home/doris-barbell/app/templates/base.html b/home/doris-barbell/app/templates/base.html index 72f7937..610dbb5 100644 --- a/home/doris-barbell/app/templates/base.html +++ b/home/doris-barbell/app/templates/base.html @@ -34,31 +34,37 @@ For the Greater Good -
+
+
+
+ +

Doris family directory

+
+ Casefile directory +
+

One shell, separate runtimes. Jump between training, household ops, and coursework without losing the same caseboard language.

+ +
{% block content %}{% endblock %}
diff --git a/home/doris-barbell/app/templates/routine_log.html b/home/doris-barbell/app/templates/routine_log.html index f106e9f..667640f 100644 --- a/home/doris-barbell/app/templates/routine_log.html +++ b/home/doris-barbell/app/templates/routine_log.html @@ -66,6 +66,9 @@

Target reps: {{ exercise.target_reps or 'enter manually' }}

+ {% if exercise.is_band %} +

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

+ {% endif %}
{% for set in exercise.sets %}
diff --git a/home/doris-barbell/tests/test_app.py b/home/doris-barbell/tests/test_app.py index 80d87f7..5013930 100644 --- a/home/doris-barbell/tests/test_app.py +++ b/home/doris-barbell/tests/test_app.py @@ -386,7 +386,8 @@ def test_homepage_renders_core_sections_and_forms(tmp_path: Path) -> None: assert 'Add exercise template' in body assert 'Current routine board' in body assert 'Monday: Chest' in body - assert 'Doris family directory' not in body + assert 'Doris family directory' in body + assert 'Singular front door' in body assert 'V1 focus' not in body assert 'Next likely build' not in body @@ -505,6 +506,50 @@ def test_seeded_routine_log_uses_numeric_target_reps_when_reps_left_blank(tmp_pa ] +def test_seeded_routine_log_allows_band_exercises_without_weight(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.post( + '/workouts/routine-log', + data={ + 'user_id': 'john', + 'routine_id': shoulders['id'], + 'performed_at': '2026-05-28T12:05:00', + 'routine_name': shoulders['name'], + 'notes': 'Band work without tracked weight.', + 'exercise_count': '1', + 'exercise_0_name': 'Resistance Band Punch-Out Training', + 'exercise_0_set_count': '3', + 'exercise_0_set_0_reps': '', + 'exercise_0_set_0_weight_lbs': '', + 'exercise_0_set_1_reps': '', + 'exercise_0_set_1_weight_lbs': '', + 'exercise_0_set_2_reps': '', + 'exercise_0_set_2_weight_lbs': '', + }, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert 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]['set_count'] == 3 + assert payload[0]['total_volume_lbs'] == 0 + assert payload[0]['exercises'][0]['exercise_name'] == 'Resistance Band Punch-Out Training' + assert payload[0]['exercises'][0]['sets'] == [ + {'reps': 10, 'weight_lbs': None}, + {'reps': 10, 'weight_lbs': None}, + {'reps': 10, 'weight_lbs': None}, + ] + + def test_existing_state_gets_seeded_routine_backfill(tmp_path: Path) -> None: state_path = tmp_path / 'state.json' state_path.write_text( diff --git a/home/doris-dashboard/bin/generate_dashboard.py b/home/doris-dashboard/bin/generate_dashboard.py index 60ffd03..8946d4e 100755 --- a/home/doris-dashboard/bin/generate_dashboard.py +++ b/home/doris-dashboard/bin/generate_dashboard.py @@ -2019,7 +2019,7 @@ def top_nav(active:str)->str: ('Services','services.html','services'), ('Kitchen','http://10.5.30.7:8092','kitchen'), ('Schoolhouse','https://schoolhouse.paccoco.com','schoolhouse'), - ('Barbell','http://10.5.30.6:8093','barbell'), + ('Barbell','https://gym.paccoco.com','barbell'), ('Donetick','https://donetick.paccoco.com','donetick'), ('Paperless','https://paperless.paccoco.com','paperless'), ('n8n','https://n8n.paccoco.com','n8n'), @@ -2051,7 +2051,7 @@ def render_family_directory(active:str)->str: ('Services Directory','services.html','services','Switchboard','Dense launch wall for the wider stack once you know where you need to go.'), ('Doris Kitchen','http://10.5.30.7:8092','kitchen','Pantry desk','Recipe leads, import repair, and meal-planning evidence review.'), ('Doris Schoolhouse','https://schoolhouse.paccoco.com','schoolhouse','Records desk','Assignments, recordings, D2L sync, and archive intake.'), - ('Doris Barbell','http://10.5.30.6:8093','barbell','Training desk','Body metrics, routines, workouts, and progression dossiers.'), + ('Doris Barbell','https://gym.paccoco.com','barbell','Training desk','Body metrics, routines, workouts, and progression dossiers.'), ] cards=[] active_key='dashboard' if active=='home' else active diff --git a/home/doris-kitchen/app/templates/base.html b/home/doris-kitchen/app/templates/base.html index daa3d04..53777f0 100644 --- a/home/doris-kitchen/app/templates/base.html +++ b/home/doris-kitchen/app/templates/base.html @@ -21,7 +21,7 @@ Dashboard Services Schoolhouse - Barbell + Barbell
@@ -137,7 +137,7 @@ Doris Schoolhouse Assignments, recordings, and archive intake. - + Training desk Doris Barbell Weights, routines, workouts, and progression snapshots. diff --git a/home/doris-schoolhouse/app/templates/base.html b/home/doris-schoolhouse/app/templates/base.html index fc05046..88e05e0 100644 --- a/home/doris-schoolhouse/app/templates/base.html +++ b/home/doris-schoolhouse/app/templates/base.html @@ -21,7 +21,7 @@ Dashboard Services Kitchen - Barbell + Barbell
@@ -134,7 +134,7 @@ Doris Kitchen Meal discovery, import repair, and planner triage. - + Training desk Doris Barbell Workout logs, body metrics, and progression dossiers.