diff --git a/home/doris-barbell/app/routes/ui.py b/home/doris-barbell/app/routes/ui.py index b11e35f..07b5699 100644 --- a/home/doris-barbell/app/routes/ui.py +++ b/home/doris-barbell/app/routes/ui.py @@ -121,6 +121,47 @@ def quick_log_workout( return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER) +@router.get('/routines/{routine_id}/log') +def routine_log_sheet(request: Request, routine_id: str): + routine = _get_routine_or_404(request, routine_id) + return templates.TemplateResponse( + request=request, + name='routine_log.html', + context={ + 'title': f"Log {routine['name']}", + 'routine': routine, + 'routine_sheet': _build_routine_log_sheet(routine), + 'routine_action_label': _routine_action_label(routine['name']), + 'default_performed_at': '', + }, + ) + + +@router.post('/workouts/routine-log', status_code=303) +async def log_seeded_routine_workout( + request: Request, + user_id: str = Form(...), + routine_id: str = Form(...), + performed_at: str = Form(...), + routine_name: str = Form(...), + exercise_count: int = 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.') + + payload = { + 'user_id': user_id, + 'performed_at': performed_at, + 'routine_name': routine_name.strip(), + 'notes': notes.strip() or None, + 'exercises': await _parse_structured_routine_form(request, exercise_count), + } + request.app.state.store.add_workout(payload) + return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER) + + def _dashboard_context(request: Request) -> dict: store = request.app.state.store summary = store.dashboard_summary() @@ -208,3 +249,95 @@ def _parse_workout_exercises(raw_lines: str) -> list[dict]: if not exercises: raise HTTPException(status_code=400, detail='At least one workout exercise is required.') return exercises + + +def _get_routine_or_404(request: Request, routine_id: str) -> dict: + routine = request.app.state.store.get_routine(routine_id) + if routine is None: + raise HTTPException(status_code=404, detail='Unknown routine') + return routine + + +def _build_routine_log_sheet(routine: dict) -> list[dict]: + sheet = [] + for exercise_index, exercise in enumerate(routine.get('exercises', [])): + target_sets = max(int(exercise.get('target_sets', 1) or 1), 1) + default_reps = _default_reps_value(exercise.get('target_reps')) + sheet.append( + { + 'exercise_index': exercise_index, + 'exercise_name': exercise['exercise_name'], + 'target_sets': target_sets, + 'target_reps': exercise.get('target_reps'), + 'notes': exercise.get('notes'), + 'sets': [ + { + 'exercise_index': exercise_index, + 'index': set_index, + 'default_reps': default_reps, + } + for set_index in range(target_sets) + ], + } + ) + return sheet + + +def _default_reps_value(target_reps: str | None) -> str: + if target_reps is None: + return '' + cleaned = str(target_reps).strip() + return cleaned if cleaned.isdigit() else '' + + +def _routine_action_label(routine_name: str) -> str: + if ': ' in routine_name: + label = routine_name.split(': ', 1)[1].strip().lower() + else: + label = routine_name.strip().lower() + day_aliases = { + 'shoulders': 'shoulder day', + 'arms': 'arm day', + 'legs': 'leg day', + } + return day_aliases.get(label, label) + + +async def _parse_structured_routine_form(request: Request, exercise_count: int) -> list[dict]: + form = await request.form() + exercises = [] + for exercise_index in range(exercise_count): + exercise_name = str(form.get(f'exercise_{exercise_index}_name', '')).strip() + if not exercise_name: + continue + try: + set_count = int(str(form.get(f'exercise_{exercise_index}_set_count', '0')).strip()) + except ValueError as exc: + raise HTTPException(status_code=400, detail='Invalid set count in routine log form.') from exc + sets = [] + for set_index in range(set_count): + 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 or not weight_raw: + 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) + 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: + continue + exercise_notes = str(form.get(f'exercise_{exercise_index}_notes', '')).strip() or None + exercises.append({'exercise_name': exercise_name, 'notes': exercise_notes, 'sets': sets}) + if not exercises: + raise HTTPException(status_code=400, detail='Log at least one completed exercise set.') + return exercises diff --git a/home/doris-barbell/app/services/store.py b/home/doris-barbell/app/services/store.py index bbcbd13..7c5dfa6 100644 --- a/home/doris-barbell/app/services/store.py +++ b/home/doris-barbell/app/services/store.py @@ -267,6 +267,13 @@ class JSONStore: state = self._read() return sorted(state['routines'], key=lambda item: item['name'].lower()) + def get_routine(self, routine_id: str) -> dict[str, Any] | None: + state = self._read() + for routine in state['routines']: + if routine.get('id') == routine_id: + return routine + return None + def add_workout(self, payload: dict[str, Any]) -> dict[str, Any]: state = self._read() self._user_or_raise(state, payload['user_id']) diff --git a/home/doris-barbell/app/templates/base.html b/home/doris-barbell/app/templates/base.html index 2a9c54e..d80277e 100644 --- a/home/doris-barbell/app/templates/base.html +++ b/home/doris-barbell/app/templates/base.html @@ -17,7 +17,7 @@ Doris Constabulary