Add structured routine logging sheets to Doris Barbell
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
86
home/doris-barbell/app/templates/routine_log.html
Normal file
86
home/doris-barbell/app/templates/routine_log.html
Normal 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 %}
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user