Add structured routine logging sheets to Doris Barbell
Some checks failed
secret-guardrails / gitleaks (push) Has been cancelled
secret-guardrails / artifact-secret-scan (push) Has been cancelled

This commit is contained in:
Fizzlepoof
2026-05-28 17:51:14 +00:00
parent 24c6a29273
commit 8a61169ab9
6 changed files with 296 additions and 2 deletions

View File

@@ -121,6 +121,47 @@ def quick_log_workout(
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER) 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: def _dashboard_context(request: Request) -> dict:
store = request.app.state.store store = request.app.state.store
summary = store.dashboard_summary() summary = store.dashboard_summary()
@@ -208,3 +249,95 @@ def _parse_workout_exercises(raw_lines: str) -> list[dict]:
if not exercises: if not exercises:
raise HTTPException(status_code=400, detail='At least one workout exercise is required.') raise HTTPException(status_code=400, detail='At least one workout exercise is required.')
return exercises 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

View File

@@ -267,6 +267,13 @@ class JSONStore:
state = self._read() state = self._read()
return sorted(state['routines'], key=lambda item: item['name'].lower()) 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]: 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'])

View File

@@ -17,7 +17,7 @@
<strong>Doris Constabulary</strong> <strong>Doris Constabulary</strong>
</div> </div>
<nav class="nav-links doris-family-nav" aria-label="Doris family navigation"> <nav class="nav-links doris-family-nav" aria-label="Doris family navigation">
<a class="nav-link {{ 'active' if request.url.path == '/' else '' }}" href="http://10.5.30.6:8093/">Barbell</a> <a class="nav-link {{ 'active' if request.url.path == '/' else '' }}" href="https://gym.paccoco.com/">Barbell</a>
<a class="nav-link" href="http://10.5.30.7:8787/">Dashboard</a> <a class="nav-link" href="http://10.5.30.7:8787/">Dashboard</a>
<a class="nav-link" href="http://10.5.30.7:8787/services.html">Services</a> <a class="nav-link" href="http://10.5.30.7:8787/services.html">Services</a>
<a class="nav-link" href="http://10.5.30.7:8092/">Kitchen</a> <a class="nav-link" href="http://10.5.30.7:8092/">Kitchen</a>
@@ -118,7 +118,7 @@
</div> </div>
<p class="muted case-legend">One shell, separate runtimes. Training reports live here, but the rest of the Doris family is one jump away.</p> <p class="muted case-legend">One shell, separate runtimes. Training reports live here, but the rest of the Doris family is one jump away.</p>
<div class="family-directory-grid"> <div class="family-directory-grid">
<a class="family-app-card current-desk" href="http://10.5.30.6:8093/"> <a class="family-app-card current-desk" href="https://gym.paccoco.com/">
<span class="family-app-kicker">Current desk</span> <span class="family-app-kicker">Current desk</span>
<strong>Doris Barbell</strong> <strong>Doris Barbell</strong>
<small>Body metrics, routines, workouts, and progression dossiers.</small> <small>Body metrics, routines, workouts, and progression dossiers.</small>

View File

@@ -186,6 +186,7 @@
<li> <li>
<strong>{{ routine.name }}</strong> <strong>{{ routine.name }}</strong>
<p class="muted">{{ routine.exercises|length }} exercises{% if routine.notes %} · {{ routine.notes }}{% endif %}</p> <p class="muted">{{ routine.exercises|length }} exercises{% if routine.notes %} · {{ routine.notes }}{% endif %}</p>
<p><a class="nav-link" href="/routines/{{ routine.id }}/log">Open log sheet</a></p>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -0,0 +1,86 @@
{% 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">Routine field sheet</span>
<span class="pill">Live training log</span>
</div>
<h2>{{ routine.name }}</h2>
<p class="muted">Fill in today's completed reps and weight for each set. Leave any unfinished exercise blank and it will be skipped.</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">Current prescription</span>
<span class="pill">Shoulder day</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 %}{% if exercise.notes %} · {{ exercise.notes }}{% endif %}</p>
</li>
{% endfor %}
</ul>
</article>
</section>
<section class="grid dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Workout report</span>
<span class="pill">Structured entry</span>
</div>
<h2>Log this session</h2>
<form class="stack-form intake-docket" method="post" action="/workouts/routine-log">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<input type="hidden" name="routine_id" value="{{ routine.id }}">
<input type="hidden" name="routine_name" value="{{ routine.name }}">
<input type="hidden" name="exercise_count" value="{{ routine_sheet|length }}">
<label>Performed at
<input type="datetime-local" name="performed_at" value="{{ default_performed_at }}" required>
</label>
<label>Session notes
<textarea name="notes" rows="2" placeholder="Pain, wins, machine availability, energy, anything worth remembering."></textarea>
</label>
{% for exercise in routine_sheet %}
<section class="evidence-slab">
<input type="hidden" name="exercise_{{ exercise.exercise_index }}_name" value="{{ exercise.exercise_name }}">
<input type="hidden" name="exercise_{{ exercise.exercise_index }}_set_count" value="{{ exercise.target_sets }}">
<div class="case-legend">
<span class="section-label">Exercise {{ loop.index }}</span>
<span class="pill">{{ exercise.target_sets }} sets</span>
</div>
<h3>{{ exercise.exercise_name }}</h3>
<p class="muted">Target reps: {{ exercise.target_reps or 'enter manually' }}</p>
<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="exercise_{{ set.exercise_index }}_set_{{ set.index }}_reps" min="1" step="1" value="{{ set.default_reps }}">
</label>
<label>Weight (lb)
<input type="number" name="exercise_{{ set.exercise_index }}_set_{{ set.index }}_weight_lbs" min="0" step="0.1">
</label>
</div>
{% endfor %}
</div>
<label>Exercise notes
<textarea name="exercise_{{ exercise.exercise_index }}_notes" rows="2" placeholder="Technique note, pain flag, or machine change."></textarea>
</label>
</section>
{% endfor %}
<button type="submit">Save {{ routine_action_label }} workout</button>
</form>
</article>
</section>
{% endblock %}

View File

@@ -374,6 +374,73 @@ def test_homepage_renders_core_sections_and_forms(tmp_path: Path) -> None:
assert 'Monday: Chest' in body assert 'Monday: Chest' in body
def test_seeded_routine_can_render_prefilled_log_sheet(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']}/log")
assert response.status_code == 200
body = response.text
assert 'Thursday: Shoulders' in body
assert 'Shoulder Press' in body
assert 'Save shoulder day workout' in body
assert 'name="exercise_0_set_0_reps"' in body
assert 'name="exercise_0_set_0_weight_lbs"' in body
assert 'value="10"' in body
def test_seeded_routine_log_submission_creates_structured_workout(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:00:00',
'routine_name': shoulders['name'],
'notes': 'Shoulder day at the gym.',
'exercise_count': '2',
'exercise_0_name': 'Shoulder Press',
'exercise_0_set_count': '3',
'exercise_0_set_0_reps': '10',
'exercise_0_set_0_weight_lbs': '70',
'exercise_0_set_1_reps': '10',
'exercise_0_set_1_weight_lbs': '80',
'exercise_0_set_2_reps': '8',
'exercise_0_set_2_weight_lbs': '90',
'exercise_1_name': 'Arnold Press',
'exercise_1_set_count': '3',
'exercise_1_set_0_reps': '10',
'exercise_1_set_0_weight_lbs': '35',
'exercise_1_set_1_reps': '10',
'exercise_1_set_1_weight_lbs': '35',
'exercise_1_set_2_reps': '8',
'exercise_1_set_2_weight_lbs': '40',
},
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]['exercise_count'] == 2
assert payload[0]['set_count'] == 6
assert payload[0]['total_volume_lbs'] == 3240
assert payload[0]['exercises'][0]['exercise_name'] == 'Shoulder Press'
assert payload[0]['exercises'][0]['sets'][2] == {'reps': 8, 'weight_lbs': 90.0}
def test_existing_state_gets_seeded_routine_backfill(tmp_path: Path) -> None: def test_existing_state_gets_seeded_routine_backfill(tmp_path: Path) -> None:
state_path = tmp_path / 'state.json' state_path = tmp_path / 'state.json'
state_path.write_text( state_path.write_text(