Fix Doris Barbell band logging and nav links
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-28 21:06:46 +00:00
parent dd4134b1ce
commit ba958f7ef6
9 changed files with 165 additions and 43 deletions

View File

@@ -61,7 +61,7 @@ class RoutineCreate(BaseModel):
class WorkoutSetCreate(BaseModel): class WorkoutSetCreate(BaseModel):
reps: int = Field(ge=1) 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) rpe: float | None = Field(default=None, ge=0)
rest_seconds: int | None = Field(default=None, ge=0) rest_seconds: int | None = Field(default=None, ge=0)
notes: str | None = None notes: str | None = None

View File

@@ -132,7 +132,7 @@ def routine_log_sheet(request: Request, routine_id: str):
context={ context={
'title': f"Log {routine['name']}", 'title': f"Log {routine['name']}",
'routine': routine, '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), 'exercise_options': _exercise_options_for_routine(request, routine),
'routine_action_label': _routine_action_label(routine['name']), 'routine_action_label': _routine_action_label(routine['name']),
'default_performed_at': _default_performed_at_value(), 'default_performed_at': _default_performed_at_value(),
@@ -261,24 +261,29 @@ def _get_routine_or_404(request: Request, routine_id: str) -> dict:
return routine 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 = [] sheet = []
for exercise_index, exercise in enumerate(routine.get('exercises', [])): 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) target_sets = max(int(exercise.get('target_sets', 1) or 1), 1)
default_reps = _default_reps_value(exercise.get('target_reps')) default_reps = _default_reps_value(exercise.get('target_reps'))
is_band = equipment_by_name.get(exercise_name.strip().lower()) == 'band'
sheet.append( sheet.append(
{ {
'exercise_index': exercise_index, 'exercise_index': exercise_index,
'exercise_name': exercise['exercise_name'], 'exercise_name': exercise_name,
'target_sets': target_sets, 'target_sets': target_sets,
'target_reps': exercise.get('target_reps'), 'target_reps': exercise.get('target_reps'),
'default_reps': default_reps, 'default_reps': default_reps,
'notes': exercise.get('notes'), 'notes': exercise.get('notes'),
'is_band': is_band,
'sets': [ 'sets': [
{ {
'exercise_index': exercise_index, 'exercise_index': exercise_index,
'index': set_index, 'index': set_index,
'default_reps': default_reps, 'default_reps': default_reps,
'is_band': is_band,
} }
for set_index in range(target_sets) for set_index in range(target_sets)
], ],
@@ -305,6 +310,17 @@ def _exercise_options_for_routine(request: Request, routine: dict) -> list[str]:
return ordered 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: def _default_reps_value(target_reps: str | None) -> str:
if target_reps is None: if target_reps is None:
return '' 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]: async def _parse_structured_routine_form(request: Request, routine: dict, exercise_count: int) -> list[dict]:
form = await request.form() form = await request.form()
routine_exercises = routine.get('exercises', []) routine_exercises = routine.get('exercises', [])
equipment_by_name = _exercise_equipment_lookup(request)
exercises = [] exercises = []
for exercise_index in range(exercise_count): for exercise_index in range(exercise_count):
exercise_name = str(form.get(f'exercise_{exercise_index}_name', '')).strip() exercise_name = str(form.get(f'exercise_{exercise_index}_name', '')).strip()
if not exercise_name: if not exercise_name:
continue continue
is_band_exercise = equipment_by_name.get(exercise_name.lower()) == 'band'
try: try:
set_count = int(str(form.get(f'exercise_{exercise_index}_set_count', '0')).strip()) set_count = int(str(form.get(f'exercise_{exercise_index}_set_count', '0')).strip())
except ValueError as exc: 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() 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() weight_raw = str(form.get(f'exercise_{exercise_index}_set_{set_index}_weight_lbs', '')).strip()
if not reps_raw and not weight_raw: if not reps_raw and not weight_raw:
continue if is_band_exercise and default_reps_raw.isdigit():
if not reps_raw and weight_raw 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 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( raise HTTPException(
status_code=400, status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} needs both reps and weight.', detail=f'Exercise {exercise_name} set {set_index + 1} needs both reps and weight.',
) )
try: try:
reps = int(reps_raw) reps = int(reps_raw)
weight_lbs = float(weight_raw) weight_lbs = None if not weight_raw else float(weight_raw)
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,

View File

@@ -289,7 +289,9 @@ class JSONStore:
normalized_sets = [] normalized_sets = []
for item in exercise['sets']: for item in exercise['sets']:
set_count += 1 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)) normalized_sets.append(dict(item))
exercises.append({ exercises.append({
'exercise_name': exercise['exercise_name'], 'exercise_name': exercise['exercise_name'],
@@ -340,10 +342,13 @@ class JSONStore:
stats['last_performed_at'] = workout['performed_at'] stats['last_performed_at'] = workout['performed_at']
for item in exercise['sets']: for item in exercise['sets']:
stats['set_count'] += 1 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'] = max(
stats['best_set_estimated_1rm'], stats['best_set_estimated_1rm'],
_estimated_1rm(item['weight_lbs'], item['reps']), _estimated_1rm(weight_lbs, item['reps']),
) )
results = [] results = []
for item in progression.values(): for item in progression.values():

View File

@@ -34,31 +34,37 @@
<span class="pill badge-hotfuzz">For the Greater Good</span> <span class="pill badge-hotfuzz">For the Greater Good</span>
</div> </div>
</div> </div>
<div class="hot-fuzz-art compact-hot-fuzz-art" aria-hidden="true"> <div class="art-stack">
<svg viewBox="0 0 320 140" class="poster-illustration compact-hero-mark" role="presentation"> <div class="hot-fuzz-art compact-hot-fuzz-art" aria-hidden="true">
<defs> <svg viewBox="0 0 320 140" class="poster-illustration compact-hero-mark" role="presentation">
<linearGradient id="barbell-sunset" x1="0%" y1="0%" x2="100%" y2="100%"> <defs>
<stop offset="0%" stop-color="#f2c14e"></stop> <linearGradient id="barbell-sunset" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="50%" stop-color="#d53600"></stop> <stop offset="0%" stop-color="#f2c14e"></stop>
<stop offset="100%" stop-color="#910f3f"></stop> <stop offset="50%" stop-color="#d53600"></stop>
</linearGradient> <stop offset="100%" stop-color="#910f3f"></stop>
</defs> </linearGradient>
<rect x="8" y="8" width="304" height="124" rx="24" class="poster-frame"></rect> </defs>
<circle cx="250" cy="45" r="34" class="poster-halo"></circle> <rect x="8" y="8" width="304" height="124" rx="24" class="poster-frame"></rect>
<path d="M34 104 L86 58 L102 74 L50 118 Z" class="poster-tape tape-left"></path> <circle cx="250" cy="45" r="34" class="poster-halo"></circle>
<path d="M218 44 L292 18 L300 38 L226 66 Z" class="poster-tape tape-right"></path> <path d="M34 104 L86 58 L102 74 L50 118 Z" class="poster-tape tape-left"></path>
<path d="M112 106 C112 82 118 64 132 52 C140 44 147 39 154 36 C161 39 168 44 176 52 C190 64 196 82 196 106 Z" class="constable-silhouette lead"></path> <path d="M218 44 L292 18 L300 38 L226 66 Z" class="poster-tape tape-right"></path>
<path d="M168 108 C170 86 178 70 192 60 C201 53 210 48 218 45 C226 48 235 53 242 60 C255 70 262 86 260 108 Z" class="constable-silhouette partner"></path> <path d="M112 106 C112 82 118 64 132 52 C140 44 147 39 154 36 C161 39 168 44 176 52 C190 64 196 82 196 106 Z" class="constable-silhouette lead"></path>
<circle cx="76" cy="44" r="20" class="swan-stamp"></circle> <path d="M168 108 C170 86 178 70 192 60 C201 53 210 48 218 45 C226 48 235 53 242 60 C255 70 262 86 260 108 Z" class="constable-silhouette partner"></path>
<path d="M71 47 C74 40 82 35 90 39 C84 39 80 43 81 48 C82 53 90 53 92 58 C85 60 77 58 73 53 L68 58 L64 55 L70 49 Z" class="swan-mark"></path> <circle cx="76" cy="44" r="20" class="swan-stamp"></circle>
<rect x="114" y="62" width="92" height="10" rx="5" class="barbell-bar"></rect> <path d="M71 47 C74 40 82 35 90 39 C84 39 80 43 81 48 C82 53 90 53 92 58 C85 60 77 58 73 53 L68 58 L64 55 L70 49 Z" class="swan-mark"></path>
<circle cx="112" cy="67" r="16" class="plate-outer"></circle> <rect x="114" y="62" width="92" height="10" rx="5" class="barbell-bar"></rect>
<circle cx="112" cy="67" r="8" class="plate-inner"></circle> <circle cx="112" cy="67" r="16" class="plate-outer"></circle>
<circle cx="208" cy="67" r="16" class="plate-outer"></circle> <circle cx="112" cy="67" r="8" class="plate-inner"></circle>
<circle cx="208" cy="67" r="8" class="plate-inner"></circle> <circle cx="208" cy="67" r="16" class="plate-outer"></circle>
<path d="M88 76 L120 68 L154 82" class="map-thread"></path> <circle cx="208" cy="67" r="8" class="plate-inner"></circle>
<text x="160" y="118" class="poster-callout">TRAINING DOSSIER</text> <path d="M88 76 L120 68 L154 82" class="map-thread"></path>
</svg> <text x="160" y="118" class="poster-callout">TRAINING DOSSIER</text>
</svg>
</div>
<figure class="photo-evidence-card hero-photo-card supporting-photo-card">
<img src="/static/images/hotfuzz-training-dossier.jpg" alt="Gym training photo with cable machine station, bench, and weight stacks." loading="eager">
<figcaption>Training still · source logged in docs</figcaption>
</figure>
</div> </div>
</div> </div>
<nav class="case-nav" aria-label="Doris Barbell sections"> <nav class="case-nav" aria-label="Doris Barbell sections">
@@ -69,6 +75,43 @@
</nav> </nav>
</header> </header>
<main class="page-shell compact-page-shell"> <main class="page-shell compact-page-shell">
<section class="family-portal evidence-board casefile-directory">
<div class="section-heading">
<div>
<p class="section-label">Singular front door</p>
<h2>Doris family directory</h2>
</div>
<span class="pill">Casefile directory</span>
</div>
<p class="muted case-legend">One shell, separate runtimes. Jump between training, household ops, and coursework without losing the same caseboard language.</p>
<div class="family-directory-grid">
<a class="family-app-card current-desk" href="https://gym.paccoco.com/">
<span class="family-app-kicker">Current desk</span>
<strong>Doris Barbell</strong>
<small>Workout logs, body metrics, and progression dossiers.</small>
</a>
<a class="family-app-card" href="http://10.5.30.7:8787/">
<span class="family-app-kicker">Operator portal</span>
<strong>Doris Dashboard</strong>
<small>Canonical front door for alerts, briefings, and launch control.</small>
</a>
<a class="family-app-card" href="http://10.5.30.7:8787/services.html">
<span class="family-app-kicker">Switchboard</span>
<strong>Services Directory</strong>
<small>Dense portal card wall for the broader stack.</small>
</a>
<a class="family-app-card" href="http://10.5.30.7:8092/">
<span class="family-app-kicker">Pantry desk</span>
<strong>Doris Kitchen</strong>
<small>Recipe triage, search warrants, and pantry planning.</small>
</a>
<a class="family-app-card" href="https://schoolhouse.paccoco.com/">
<span class="family-app-kicker">Records desk</span>
<strong>Doris Schoolhouse</strong>
<small>Assignments, recordings, and archive intake.</small>
</a>
</div>
</section>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</body> </body>

View File

@@ -66,6 +66,9 @@
</select> </select>
</label> </label>
<p class="muted">Target reps: {{ exercise.target_reps or 'enter manually' }}</p> <p class="muted">Target reps: {{ exercise.target_reps or 'enter manually' }}</p>
{% if exercise.is_band %}
<p class="muted">Resistance-band exercise: leave weight blank to log sets and default reps only.</p>
{% endif %}
<div class="grid cols-2 compact-grid"> <div class="grid cols-2 compact-grid">
{% for set in exercise.sets %} {% for set in exercise.sets %}
<div class="evidence-slab compact-slab"> <div class="evidence-slab compact-slab">

View File

@@ -386,7 +386,8 @@ def test_homepage_renders_core_sections_and_forms(tmp_path: Path) -> None:
assert 'Add exercise template' in body assert 'Add exercise template' in body
assert 'Current routine board' in body assert 'Current routine board' in body
assert 'Monday: Chest' 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 'V1 focus' not in body
assert 'Next likely build' 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: 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(

View File

@@ -2019,7 +2019,7 @@ def top_nav(active:str)->str:
('Services','services.html','services'), ('Services','services.html','services'),
('Kitchen','http://10.5.30.7:8092','kitchen'), ('Kitchen','http://10.5.30.7:8092','kitchen'),
('Schoolhouse','https://schoolhouse.paccoco.com','schoolhouse'), ('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'), ('Donetick','https://donetick.paccoco.com','donetick'),
('Paperless','https://paperless.paccoco.com','paperless'), ('Paperless','https://paperless.paccoco.com','paperless'),
('n8n','https://n8n.paccoco.com','n8n'), ('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.'), ('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 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 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=[] cards=[]
active_key='dashboard' if active=='home' else active active_key='dashboard' if active=='home' else active

View File

@@ -21,7 +21,7 @@
<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="https://schoolhouse.paccoco.com/">Schoolhouse</a> <a class="nav-link" href="https://schoolhouse.paccoco.com/">Schoolhouse</a>
<a class="nav-link" href="http://10.5.30.6:8093/">Barbell</a> <a class="nav-link" href="https://gym.paccoco.com/">Barbell</a>
</nav> </nav>
</div> </div>
<div class="casefile-title-row"> <div class="casefile-title-row">
@@ -137,7 +137,7 @@
<strong>Doris Schoolhouse</strong> <strong>Doris Schoolhouse</strong>
<small>Assignments, recordings, and archive intake.</small> <small>Assignments, recordings, and archive intake.</small>
</a> </a>
<a class="family-app-card" href="http://10.5.30.6:8093/"> <a class="family-app-card" href="https://gym.paccoco.com/">
<span class="family-app-kicker">Training desk</span> <span class="family-app-kicker">Training desk</span>
<strong>Doris Barbell</strong> <strong>Doris Barbell</strong>
<small>Weights, routines, workouts, and progression snapshots.</small> <small>Weights, routines, workouts, and progression snapshots.</small>

View File

@@ -21,7 +21,7 @@
<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>
<a class="nav-link" href="http://10.5.30.6:8093/">Barbell</a> <a class="nav-link" href="https://gym.paccoco.com/">Barbell</a>
</nav> </nav>
</div> </div>
<div class="casefile-title-row"> <div class="casefile-title-row">
@@ -134,7 +134,7 @@
<strong>Doris Kitchen</strong> <strong>Doris Kitchen</strong>
<small>Meal discovery, import repair, and planner triage.</small> <small>Meal discovery, import repair, and planner triage.</small>
</a> </a>
<a class="family-app-card" href="http://10.5.30.6:8093/"> <a class="family-app-card" href="https://gym.paccoco.com/">
<span class="family-app-kicker">Training desk</span> <span class="family-app-kicker">Training desk</span>
<strong>Doris Barbell</strong> <strong>Doris Barbell</strong>
<small>Workout logs, body metrics, and progression dossiers.</small> <small>Workout logs, body metrics, and progression dossiers.</small>