344 lines
12 KiB
Python
344 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Form, HTTPException, Request, status
|
|
from fastapi.responses import RedirectResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
router = APIRouter()
|
|
templates = Jinja2Templates(directory='app/templates')
|
|
|
|
|
|
@router.get('/')
|
|
def home(request: Request):
|
|
return templates.TemplateResponse(
|
|
request=request,
|
|
name='dashboard.html',
|
|
context=_dashboard_context(request),
|
|
)
|
|
|
|
|
|
@router.post('/log/weight', status_code=303)
|
|
def log_weight(
|
|
request: Request,
|
|
user_id: str = Form(...),
|
|
recorded_at: str = Form(...),
|
|
weight_lbs: float = Form(...),
|
|
body_fat_percent: str = Form(''),
|
|
notes: str = Form(''),
|
|
):
|
|
payload = {
|
|
'recorded_at': recorded_at,
|
|
'weight_lbs': weight_lbs,
|
|
'body_fat_percent': _optional_float(body_fat_percent),
|
|
'notes': notes.strip() or None,
|
|
}
|
|
request.app.state.store.add_weight_entry(user_id, payload)
|
|
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
|
@router.post('/log/measurements', status_code=303)
|
|
def log_measurements(
|
|
request: Request,
|
|
user_id: str = Form(...),
|
|
recorded_at: str = Form(...),
|
|
waist_inches: str = Form(''),
|
|
hip_inches: str = Form(''),
|
|
chest_inches: str = Form(''),
|
|
neck_inches: str = Form(''),
|
|
notes: str = Form(''),
|
|
):
|
|
payload = {
|
|
'recorded_at': recorded_at,
|
|
'waist_inches': _optional_float(waist_inches),
|
|
'hip_inches': _optional_float(hip_inches),
|
|
'chest_inches': _optional_float(chest_inches),
|
|
'neck_inches': _optional_float(neck_inches),
|
|
'notes': notes.strip() or None,
|
|
}
|
|
if not any(
|
|
payload[key] is not None
|
|
for key in ('waist_inches', 'hip_inches', 'chest_inches', 'neck_inches')
|
|
):
|
|
raise HTTPException(status_code=400, detail='At least one body measurement is required.')
|
|
request.app.state.store.add_measurement_entry(user_id, payload)
|
|
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
|
@router.post('/templates', status_code=303)
|
|
def create_template(
|
|
request: Request,
|
|
name: str = Form(...),
|
|
primary_muscle: str = Form(''),
|
|
equipment: str = Form(''),
|
|
notes: str = Form(''),
|
|
):
|
|
request.app.state.store.add_exercise_template(
|
|
{
|
|
'name': name.strip(),
|
|
'primary_muscle': primary_muscle.strip() or None,
|
|
'equipment': equipment.strip() or None,
|
|
'notes': notes.strip() or None,
|
|
}
|
|
)
|
|
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
|
@router.post('/routines', status_code=303)
|
|
def create_routine(
|
|
request: Request,
|
|
user_id: str = Form(...),
|
|
name: str = Form(...),
|
|
exercise_lines: str = Form(...),
|
|
notes: str = Form(''),
|
|
):
|
|
payload = {
|
|
'user_id': user_id,
|
|
'name': name.strip(),
|
|
'notes': notes.strip() or None,
|
|
'exercises': _parse_routine_exercises(exercise_lines),
|
|
}
|
|
request.app.state.store.add_routine(payload)
|
|
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
|
@router.post('/workouts/quick-log', status_code=303)
|
|
def quick_log_workout(
|
|
request: Request,
|
|
user_id: str = Form(...),
|
|
performed_at: str = Form(...),
|
|
routine_name: str = Form(...),
|
|
exercise_lines: str = Form(...),
|
|
notes: str = Form(''),
|
|
):
|
|
payload = {
|
|
'user_id': user_id,
|
|
'performed_at': performed_at,
|
|
'routine_name': routine_name.strip(),
|
|
'notes': notes.strip() or None,
|
|
'exercises': _parse_workout_exercises(exercise_lines),
|
|
}
|
|
request.app.state.store.add_workout(payload)
|
|
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()
|
|
return {
|
|
'title': 'Doris Barbell',
|
|
'summary': summary,
|
|
'john_weight_history': store.weight_history('john')[:5],
|
|
'john_measurement_history': store.measurement_history('john')[:5],
|
|
'john_workout_history': store.workout_history('john')[:5],
|
|
'john_progression': store.exercise_progression('john')[:5],
|
|
}
|
|
|
|
|
|
def _optional_float(value: str) -> float | None:
|
|
cleaned = value.strip()
|
|
if not cleaned:
|
|
return None
|
|
try:
|
|
return float(cleaned)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=f'Invalid numeric value: {value}') from exc
|
|
|
|
|
|
def _parse_routine_exercises(raw_lines: str) -> list[dict]:
|
|
exercises = []
|
|
for line in raw_lines.splitlines():
|
|
cleaned = line.strip()
|
|
if not cleaned:
|
|
continue
|
|
parts = [part.strip() for part in cleaned.split('|')]
|
|
if len(parts) < 3:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail='Routine lines must use exercise|sets|reps|optional notes format.',
|
|
)
|
|
exercise_name, target_sets_raw, target_reps = parts[:3]
|
|
try:
|
|
target_sets = int(target_sets_raw)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=f'Invalid target sets: {target_sets_raw}') from exc
|
|
exercises.append(
|
|
{
|
|
'exercise_name': exercise_name,
|
|
'target_sets': target_sets,
|
|
'target_reps': target_reps,
|
|
'notes': parts[3] if len(parts) > 3 and parts[3] else None,
|
|
}
|
|
)
|
|
if not exercises:
|
|
raise HTTPException(status_code=400, detail='At least one routine exercise is required.')
|
|
return exercises
|
|
|
|
|
|
def _parse_workout_exercises(raw_lines: str) -> list[dict]:
|
|
exercises = []
|
|
for line in raw_lines.splitlines():
|
|
cleaned = line.strip()
|
|
if not cleaned:
|
|
continue
|
|
parts = [part.strip() for part in cleaned.split('|')]
|
|
if len(parts) < 2:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail='Workout lines must use exercise|reps@weight, reps@weight|optional notes format.',
|
|
)
|
|
exercise_name = parts[0]
|
|
notes = parts[2] if len(parts) > 2 and parts[2] else None
|
|
sets = []
|
|
for set_chunk in parts[1].split(','):
|
|
token = set_chunk.strip()
|
|
if not token:
|
|
continue
|
|
if '@' not in token:
|
|
raise HTTPException(status_code=400, detail=f'Invalid set token: {token}')
|
|
reps_raw, weight_raw = [value.strip() for value in token.split('@', 1)]
|
|
try:
|
|
reps = int(reps_raw)
|
|
weight_lbs = float(weight_raw)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=f'Invalid set token: {token}') from exc
|
|
sets.append({'reps': reps, 'weight_lbs': weight_lbs})
|
|
if not sets:
|
|
raise HTTPException(status_code=400, detail='Each workout exercise needs at least one set.')
|
|
exercises.append({'exercise_name': exercise_name, 'notes': notes, 'sets': sets})
|
|
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
|