doris-barbell: seed john split and routine board
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled

This commit is contained in:
Fizzlepoof
2026-05-24 02:59:14 +00:00
parent 9e0f66c2b6
commit a4c5ee7285
5 changed files with 397 additions and 56 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -171,28 +171,35 @@
</article>
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Library snapshot</span><span class="pill">Scaffold</span></div>
<h2>Template and routine scaffold</h2>
<p class="muted">You can start building the library now, even before the real split lands tomorrow.</p>
<div class="case-legend"><span class="section-label">Current split</span><span class="pill">Routine board</span></div>
<h2>Current routine board</h2>
<p class="muted">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.</p>
<ul class="list case-list">
<li>Exercise templates: {{ summary.counts.exercise_templates }}</li>
<li>Routines: {{ summary.counts.routines }}</li>
</ul>
{% if summary.exercise_templates %}
<p><strong>Templates</strong></p>
<ul class="list">
{% for template in summary.exercise_templates %}
<li>{{ template.name }}{% if template.equipment %} · {{ template.equipment }}{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
{% if summary.routines %}
<p><strong>Routines</strong></p>
<ul class="list">
{% for routine in summary.routines %}
<li>{{ routine.name }} · {{ routine.exercises|length }} exercises</li>
{% endfor %}
</ul>
<div class="history-block">
<h3>Loaded routine days</h3>
<ul class="list">
{% for routine in summary.routines %}
<li>
<strong>{{ routine.name }}</strong>
<p class="muted">{{ routine.exercises|length }} exercises{% if routine.notes %} · {{ routine.notes }}{% endif %}</p>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if summary.exercise_templates %}
<div class="history-block">
<h3>Template snapshot</h3>
<ul class="list">
{% for template in summary.exercise_templates %}
<li>{{ template.name }}{% if template.equipment %} · {{ template.equipment }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</article>
</section>

View File

@@ -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'