feat: add Doris Barbell v1 scaffold

This commit is contained in:
Fizzlepoof
2026-05-21 23:32:10 +00:00
parent d5cfdbe8b5
commit 9dc5a9a8a3
21 changed files with 2175 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
TZ=America/Chicago
BARBELL_HOST=0.0.0.0
BARBELL_PORT=8093
BARBELL_STATE_DIR=/data/state
BARBELL_SHARED_PASSWORD=

6
home/doris-barbell/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.env
.venv/
__pycache__/
.pytest_cache/
/data/
*.pyc

View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8093
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8093"]

View File

@@ -0,0 +1,106 @@
# Doris Barbell
Private-first exercise tracking app for John, with a clean path to add Manndra later.
## Purpose
Doris Barbell gives John one place to track:
- workout sessions
- structured exercise templates and routines
- sets with reps, weight, notes, rest, and RPE
- body weight and BMI over time
- broader body metrics in imperial units
- progress trends and simple PR tracking
## Runtime shape
- **Repo source:** `home/doris-barbell`
- **Expected live PD runtime:** `/mnt/docker-ssd/docker/compose/doris-barbell`
- **Expected first exposure:** private-first on PD, with public routing optional later
- **Published app port:** `8093`
Do not run production from an agent workspace.
## V1 defaults in flight
Unless John overrides them later, V1 is being built with these defaults:
- mobile-friendly but usable on desktop
- private-first deployment on PD
- John is the active user
- Manndra is stubbed as a second household member for future expansion
- JSON-backed state first for speed of delivery
- imperial measurement system only
- no progress photos
- no muscle-size tracking focus
- structured workout logging first, richer planning/reporting layered in after
## Current scaffold
The current scaffold already supports:
- seeded users for `john` and stub `manndra`
- 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
- 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
- mobile-friendly homepage with HTML forms for:
- weight logging
- body-measurement logging
- exercise template creation
- routine draft creation
- quick workout logging
- 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.
## Current API surface
- `GET /api/health`
- `GET /api/dashboard/summary`
- `PUT /api/profile/{user_id}`
- `POST /api/profile/{user_id}/weight`
- `GET /api/history/{user_id}/weights`
- `POST /api/profile/{user_id}/measurements`
- `GET /api/history/{user_id}/measurements`
- `POST /api/exercises/templates`
- `GET /api/exercises/templates`
- `POST /api/routines`
- `GET /api/routines`
- `POST /api/workouts`
- `GET /api/history/{user_id}/workouts`
- `GET /api/progression/{user_id}/exercises`
- `POST /log/weight`
- `POST /log/measurements`
- `POST /templates`
- `POST /routines`
- `POST /workouts/quick-log`
## Local run
```bash
cd /home/fizzlepoof/repos/truenas-stacks/home/doris-barbell
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8093
```
Or:
```bash
cd /home/fizzlepoof/repos/truenas-stacks/home/doris-barbell
bin/run-local.sh
```
## Verification targets
```bash
curl -fsS http://127.0.0.1:8093/api/health
curl -fsS http://127.0.0.1:8093/api/dashboard/summary
```

View File

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(slots=True)
class Settings:
app_name: str = 'Doris Barbell'
default_state_dir: Path = Path(os.environ.get('BARBELL_STATE_DIR', '/tmp/doris-barbell-state'))

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.config import Settings
from app.routes.api import router as api_router
from app.routes.ui import router as ui_router
from app.services.store import JSONStore
BASE_DIR = Path(__file__).resolve().parent
def create_app(state_dir: Path | str | None = None) -> FastAPI:
settings = Settings()
app = FastAPI(title=settings.app_name)
resolved_state_dir = Path(state_dir) if state_dir is not None else settings.default_state_dir
app.state.store = JSONStore(resolved_state_dir)
app.mount('/static', StaticFiles(directory=BASE_DIR / 'static'), name='static')
app.include_router(ui_router)
app.include_router(api_router)
return app
app = create_app()

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
class ProfileUpdate(BaseModel):
display_name: str | None = None
height_inches: float | None = Field(default=None, gt=0)
goal_weight_lbs: float | None = Field(default=None, gt=0)
measurement_system: Literal['imperial'] | None = 'imperial'
notes: str | None = None
class WeightEntryCreate(BaseModel):
recorded_at: str
weight_lbs: float = Field(gt=0)
body_fat_percent: float | None = Field(default=None, ge=0, le=100)
notes: str | None = None
class MeasurementEntryCreate(BaseModel):
recorded_at: str
waist_inches: float | None = Field(default=None, gt=0)
hip_inches: float | None = Field(default=None, gt=0)
chest_inches: float | None = Field(default=None, gt=0)
neck_inches: float | None = Field(default=None, gt=0)
notes: str | None = None
@model_validator(mode='after')
def validate_at_least_one_measurement(self) -> 'MeasurementEntryCreate':
if not any(
value is not None
for value in [self.waist_inches, self.hip_inches, self.chest_inches, self.neck_inches]
):
raise ValueError('At least one body measurement is required.')
return self
class ExerciseTemplateCreate(BaseModel):
name: str
primary_muscle: str | None = None
equipment: str | None = None
notes: str | None = None
class RoutineExerciseCreate(BaseModel):
exercise_name: str
target_sets: int = Field(ge=1)
target_reps: str
notes: str | None = None
class RoutineCreate(BaseModel):
user_id: str
name: str
notes: str | None = None
exercises: list[RoutineExerciseCreate]
class WorkoutSetCreate(BaseModel):
reps: int = Field(ge=1)
weight_lbs: float = Field(ge=0)
rpe: float | None = Field(default=None, ge=0)
rest_seconds: int | None = Field(default=None, ge=0)
notes: str | None = None
class WorkoutExerciseCreate(BaseModel):
exercise_name: str
notes: str | None = None
sets: list[WorkoutSetCreate]
class WorkoutCreate(BaseModel):
user_id: str
performed_at: str
routine_name: str
notes: str | None = None
exercises: list[WorkoutExerciseCreate]
JSONDict = dict[str, Any]

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from app.models import (
ExerciseTemplateCreate,
MeasurementEntryCreate,
ProfileUpdate,
RoutineCreate,
WeightEntryCreate,
WorkoutCreate,
)
router = APIRouter(prefix='/api')
@router.get('/health')
def health() -> dict[str, str]:
return {'status': 'ok'}
@router.get('/dashboard/summary')
def dashboard_summary(request: Request) -> dict:
return request.app.state.store.dashboard_summary()
@router.put('/profile/{user_id}')
def update_profile(user_id: str, payload: ProfileUpdate, request: Request) -> dict:
try:
return request.app.state.store.update_profile(user_id, payload.model_dump())
except KeyError as exc:
raise HTTPException(status_code=404, detail='Unknown user') from exc
@router.post('/profile/{user_id}/weight', status_code=201)
def add_weight_entry(user_id: str, payload: WeightEntryCreate, request: Request) -> dict:
try:
return request.app.state.store.add_weight_entry(user_id, payload.model_dump())
except KeyError as exc:
raise HTTPException(status_code=404, detail='Unknown user') from exc
@router.get('/history/{user_id}/weights')
def weight_history(user_id: str, request: Request) -> list[dict]:
try:
return request.app.state.store.weight_history(user_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail='Unknown user') from exc
@router.post('/profile/{user_id}/measurements', status_code=201)
def add_measurement_entry(user_id: str, payload: MeasurementEntryCreate, request: Request) -> dict:
try:
return request.app.state.store.add_measurement_entry(user_id, payload.model_dump())
except KeyError as exc:
raise HTTPException(status_code=404, detail='Unknown user') from exc
@router.get('/history/{user_id}/measurements')
def measurement_history(user_id: str, request: Request) -> list[dict]:
try:
return request.app.state.store.measurement_history(user_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail='Unknown user') from exc
@router.post('/exercises/templates', status_code=201)
def create_exercise_template(payload: ExerciseTemplateCreate, request: Request) -> dict:
return request.app.state.store.add_exercise_template(payload.model_dump())
@router.get('/exercises/templates')
def list_exercise_templates(request: Request) -> list[dict]:
return request.app.state.store.list_exercise_templates()
@router.post('/routines', status_code=201)
def create_routine(payload: RoutineCreate, request: Request) -> dict:
try:
return request.app.state.store.add_routine(payload.model_dump())
except KeyError as exc:
raise HTTPException(status_code=404, detail='Unknown user') from exc
@router.get('/routines')
def list_routines(request: Request) -> list[dict]:
return request.app.state.store.list_routines()
@router.post('/workouts', status_code=201)
def add_workout(payload: WorkoutCreate, request: Request) -> dict:
try:
return request.app.state.store.add_workout(payload.model_dump())
except KeyError as exc:
raise HTTPException(status_code=404, detail='Unknown user') from exc
@router.get('/history/{user_id}/workouts')
def workout_history(user_id: str, request: Request) -> list[dict]:
try:
return request.app.state.store.workout_history(user_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail='Unknown user') from exc
@router.get('/progression/{user_id}/exercises')
def exercise_progression(user_id: str, request: Request) -> list[dict]:
try:
return request.app.state.store.exercise_progression(user_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail='Unknown user') from exc

View File

@@ -0,0 +1,210 @@
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)
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

View File

@@ -0,0 +1,302 @@
from __future__ import annotations
import json
from copy import deepcopy
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.',
},
},
'exercise_templates': [],
'routines': [],
'weight_entries': [],
'measurement_entries': [],
'workout_sessions': [],
}
class JSONStore:
def __init__(self, state_dir: Path) -> None:
self.state_dir = Path(state_dir)
self.state_path = self.state_dir / 'state.json'
self.state_dir.mkdir(parents=True, exist_ok=True)
if not self.state_path.exists():
self._write(deepcopy(DEFAULT_STATE))
def _read(self) -> dict[str, Any]:
return json.loads(self.state_path.read_text())
def _write(self, payload: dict[str, Any]) -> None:
self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True))
def update_profile(self, user_id: str, changes: dict[str, Any]) -> dict[str, Any]:
state = self._read()
user = state['users'].get(user_id)
if user is None:
raise KeyError(user_id)
for key, value in changes.items():
if value is not None:
user[key] = value
state['users'][user_id] = user
self._write(state)
return user
def add_weight_entry(self, user_id: str, entry: dict[str, Any]) -> dict[str, Any]:
state = self._read()
user = self._user_or_raise(state, user_id)
saved = {
'id': uuid4().hex,
'user_id': user_id,
'recorded_at': entry['recorded_at'],
'weight_lbs': entry['weight_lbs'],
'body_fat_percent': entry.get('body_fat_percent'),
'notes': entry.get('notes'),
'bmi': _calculate_bmi(entry['weight_lbs'], user.get('height_inches')),
}
state['weight_entries'].append(saved)
self._write(state)
return saved
def weight_history(self, user_id: str) -> list[dict[str, Any]]:
state = self._read()
self._user_or_raise(state, user_id)
return _history_for_user(state['weight_entries'], user_id, 'recorded_at')
def add_measurement_entry(self, user_id: str, entry: dict[str, Any]) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, user_id)
saved = {
'id': uuid4().hex,
'user_id': user_id,
'recorded_at': entry['recorded_at'],
'waist_inches': entry.get('waist_inches'),
'hip_inches': entry.get('hip_inches'),
'chest_inches': entry.get('chest_inches'),
'neck_inches': entry.get('neck_inches'),
'notes': entry.get('notes'),
}
state['measurement_entries'].append(saved)
self._write(state)
return saved
def measurement_history(self, user_id: str) -> list[dict[str, Any]]:
state = self._read()
self._user_or_raise(state, user_id)
return _history_for_user(state['measurement_entries'], user_id, 'recorded_at')
def add_exercise_template(self, payload: dict[str, Any]) -> dict[str, Any]:
state = self._read()
saved = {
'id': uuid4().hex,
'name': payload['name'],
'primary_muscle': payload.get('primary_muscle'),
'equipment': payload.get('equipment'),
'notes': payload.get('notes'),
}
state['exercise_templates'].append(saved)
self._write(state)
return saved
def list_exercise_templates(self) -> list[dict[str, Any]]:
state = self._read()
return sorted(state['exercise_templates'], key=lambda item: item['name'].lower())
def add_routine(self, payload: dict[str, Any]) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, payload['user_id'])
saved = {
'id': uuid4().hex,
'user_id': payload['user_id'],
'name': payload['name'],
'notes': payload.get('notes'),
'exercises': [dict(exercise) for exercise in payload['exercises']],
}
state['routines'].append(saved)
self._write(state)
return saved
def list_routines(self) -> list[dict[str, Any]]:
state = self._read()
return sorted(state['routines'], key=lambda item: item['name'].lower())
def add_workout(self, payload: dict[str, Any]) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, payload['user_id'])
set_count = 0
total_volume_lbs = 0.0
exercises: list[dict[str, Any]] = []
for exercise in payload['exercises']:
normalized_sets = []
for item in exercise['sets']:
set_count += 1
total_volume_lbs += item['reps'] * item['weight_lbs']
normalized_sets.append(dict(item))
exercises.append({
'exercise_name': exercise['exercise_name'],
'notes': exercise.get('notes'),
'sets': normalized_sets,
})
saved = {
'id': uuid4().hex,
'user_id': payload['user_id'],
'performed_at': payload['performed_at'],
'routine_name': payload['routine_name'],
'notes': payload.get('notes'),
'exercise_count': len(exercises),
'set_count': set_count,
'total_volume_lbs': int(total_volume_lbs) if total_volume_lbs.is_integer() else total_volume_lbs,
'exercises': exercises,
}
state['workout_sessions'].append(saved)
self._write(state)
return saved
def workout_history(self, user_id: str) -> list[dict[str, Any]]:
state = self._read()
self._user_or_raise(state, user_id)
return _history_for_user(state['workout_sessions'], user_id, 'performed_at')
def exercise_progression(self, user_id: str) -> list[dict[str, Any]]:
state = self._read()
self._user_or_raise(state, user_id)
progression: dict[str, dict[str, Any]] = {}
for workout in _history_for_user(state['workout_sessions'], user_id, 'performed_at'):
for exercise in workout['exercises']:
name = exercise['exercise_name']
stats = progression.setdefault(
name,
{
'exercise_name': name,
'sessions': 0,
'set_count': 0,
'max_weight_lbs': 0.0,
'best_set_estimated_1rm': 0.0,
'last_performed_at': workout['performed_at'],
},
)
stats['sessions'] += 1
if workout['performed_at'] > stats['last_performed_at']:
stats['last_performed_at'] = workout['performed_at']
for item in exercise['sets']:
stats['set_count'] += 1
stats['max_weight_lbs'] = max(stats['max_weight_lbs'], item['weight_lbs'])
stats['best_set_estimated_1rm'] = max(
stats['best_set_estimated_1rm'],
_estimated_1rm(item['weight_lbs'], item['reps']),
)
results = []
for item in progression.values():
results.append(
{
**item,
'max_weight_lbs': _normalize_number(item['max_weight_lbs']),
'best_set_estimated_1rm': _normalize_number(item['best_set_estimated_1rm']),
}
)
return sorted(results, key=lambda item: item['exercise_name'].lower())
def dashboard_summary(self) -> dict[str, Any]:
state = self._read()
users = []
weight_entries = state['weight_entries']
measurement_entries = state['measurement_entries']
for user in state['users'].values():
latest_weight = _latest_entry_for_user(weight_entries, user['id'])
latest_measurements = _latest_entry_for_user(measurement_entries, user['id'])
users.append({
'id': user['id'],
'display_name': user['display_name'],
'is_stub': user['is_stub'],
'measurement_system': user.get('measurement_system', 'imperial'),
'height_inches': user['height_inches'],
'goal_weight_lbs': user['goal_weight_lbs'],
'current_weight_lbs': latest_weight['weight_lbs'] if latest_weight else None,
'current_bmi': latest_weight['bmi'] if latest_weight else None,
'last_weight_recorded_at': latest_weight['recorded_at'] if latest_weight else None,
'latest_measurements': _trim_measurement_entry(latest_measurements),
})
recent_workouts = sorted(
state['workout_sessions'],
key=lambda item: item['performed_at'],
reverse=True,
)[:5]
return {
'users': users,
'recent_workouts': recent_workouts,
'exercise_templates': self.list_exercise_templates()[:5],
'routines': self.list_routines()[:5],
'counts': {
'exercise_templates': len(state['exercise_templates']),
'measurement_entries': len(state['measurement_entries']),
'routines': len(state['routines']),
'weight_entries': len(state['weight_entries']),
'workout_sessions': len(state['workout_sessions']),
},
}
def _user_or_raise(self, state: dict[str, Any], user_id: str) -> dict[str, Any]:
user = state['users'].get(user_id)
if user is None:
raise KeyError(user_id)
return user
def _latest_entry_for_user(entries: list[dict[str, Any]], user_id: str) -> dict[str, Any] | None:
matches = [entry for entry in entries if entry['user_id'] == user_id]
if not matches:
return None
return max(matches, key=lambda item: item['recorded_at'])
def _history_for_user(entries: list[dict[str, Any]], user_id: str, sort_key: str) -> list[dict[str, Any]]:
matches = [entry for entry in entries if entry['user_id'] == user_id]
return sorted(matches, key=lambda item: item[sort_key], reverse=True)
def _trim_measurement_entry(entry: dict[str, Any] | None) -> dict[str, Any] | None:
if entry is None:
return None
return {
'recorded_at': entry['recorded_at'],
'waist_inches': entry['waist_inches'],
'hip_inches': entry['hip_inches'],
'chest_inches': entry['chest_inches'],
'neck_inches': entry['neck_inches'],
}
def _normalize_number(value: float) -> int | float:
return int(value) if float(value).is_integer() else round(value, 1)
def _estimated_1rm(weight_lbs: float, reps: int) -> float:
return weight_lbs * (1 + reps / 30)
def _calculate_bmi(weight_lbs: float, height_inches: float | None) -> float | None:
if not height_inches:
return None
return 703 * weight_lbs / (height_inches * height_inches)

View File

@@ -0,0 +1,266 @@
:root {
color-scheme: dark;
--hf-night: #090b10;
--hf-panel: rgba(17, 21, 29, 0.94);
--hf-panel-raise: rgba(25, 31, 43, 0.96);
--hf-paper: #f2efdd;
--hf-muted: #cbbda7;
--hf-line: rgba(242, 239, 221, 0.12);
--hf-shadow: 0 20px 48px rgba(0, 0, 0, 0.34);
--hf-red: #d53600;
--hf-red-deep: #910f3f;
--hf-amber: #f2c14e;
--hf-blue: #7b8fb2;
--bg: #090b10;
--panel: var(--hf-panel);
--panel-border: var(--hf-line);
--text: var(--hf-paper);
--muted: var(--hf-muted);
--accent: var(--hf-red);
--accent-2: var(--hf-amber);
--field: rgba(255, 255, 255, 0.05);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background:
radial-gradient(circle at top left, rgba(213, 54, 0, 0.22), transparent 22rem),
radial-gradient(circle at top right, rgba(123, 143, 178, 0.16), transparent 20rem),
linear-gradient(180deg, #07090d 0%, #0d1017 52%, #131824 100%);
color: var(--text);
}
.incident-tape {
height: 14px;
background: repeating-linear-gradient(135deg, var(--hf-amber) 0 18px, #111 18px 36px);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.32);
}
.page-header,
.page-shell {
width: min(1100px, calc(100% - 2rem));
margin: 0 auto;
}
.casefile-header {
display: grid;
gap: 18px;
padding: 1.25rem 0 1rem;
}
.brand-row,
.casefile-title-row {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: center;
flex-wrap: wrap;
}
.nav-brand { display: grid; gap: 3px; }
.nav-kicker,
.eyebrow {
margin: 0 0 0.45rem;
color: var(--hf-amber);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.75rem;
}
.nav-links { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.nav-link,
.pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.55rem 0.85rem;
border: 1px solid var(--panel-border);
text-decoration: none;
background: rgba(255,255,255,.04);
color: var(--text);
font-size: .88rem;
}
.nav-link:hover { border-color: rgba(242, 193, 78, 0.58); background: rgba(242, 193, 78, 0.12); }
.brand-badges { display: flex; flex-wrap: wrap; gap: 8px; }
.hot-fuzz-art {
position: relative;
flex: 0 1 320px;
min-width: min(100%, 280px);
}
.poster-illustration {
width: 100%;
height: auto;
display: block;
filter: drop-shadow(0 24px 30px rgba(0, 0, 0, 0.35));
}
.poster-frame { fill: rgba(10, 12, 18, 0.88); stroke: rgba(242, 239, 221, 0.2); stroke-width: 2; }
.poster-halo { fill: url(#barbell-sunset); opacity: 0.9; }
.poster-tape { fill: rgba(242, 193, 78, 0.88); stroke: rgba(17, 17, 17, 0.8); stroke-width: 2; }
.constable-silhouette { fill: rgba(8, 10, 15, 0.92); stroke: rgba(242, 239, 221, 0.12); stroke-width: 1.5; }
.constable-silhouette.partner { fill: rgba(24, 30, 42, 0.94); }
.swan-stamp { fill: rgba(123, 143, 178, 0.2); stroke: rgba(242, 239, 221, 0.42); stroke-width: 2; }
.swan-mark { fill: rgba(242, 239, 221, 0.94); }
.poster-callout { fill: rgba(242, 239, 221, 0.82); font-size: 18px; font-weight: 800; letter-spacing: 0.24em; text-anchor: middle; }
.recipe-card,
.folder-body,
.folder-tab,
.barbell-bar,
.plate-outer,
.plate-inner { fill: rgba(242, 239, 221, 0.9); }
.plate-inner { fill: rgba(10, 12, 18, 0.72); }
.film-grain,
.cinematic-glow {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: 28px;
}
.film-grain {
background: repeating-linear-gradient(0deg, rgba(255,255,255,0.025) 0 2px, transparent 2px 5px), repeating-linear-gradient(90deg, rgba(255,255,255,0.018) 0 1px, transparent 1px 4px);
mix-blend-mode: soft-light;
opacity: 0.35;
}
.cinematic-glow {
background: radial-gradient(circle at 16% 18%, rgba(123, 143, 178, 0.22), transparent 24%), radial-gradient(circle at 84% 24%, rgba(213, 54, 0, 0.24), transparent 26%), linear-gradient(115deg, rgba(242, 193, 78, 0.05), transparent 40%, rgba(145, 15, 63, 0.1) 78%, transparent 100%);
animation: emergencyPulse 8s ease-in-out infinite alternate;
}
.app-casefile { position: relative; overflow: hidden; isolation: isolate; }
.casefile-stamp {
display: inline-flex;
align-items: center;
margin: 0 0 0.85rem;
padding: 0.35rem 0.7rem;
border: 1px solid rgba(242, 193, 78, 0.45);
border-radius: 999px;
color: #ffe5b0;
background: rgba(242, 193, 78, 0.1);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.72rem;
}
.app-casefile-barbell .casefile-stamp { box-shadow: 0 0 0 1px rgba(213, 54, 0, 0.12), 0 0 28px rgba(145, 15, 63, 0.2); }
@keyframes emergencyPulse {
0% { opacity: 0.72; transform: scale(1); }
50% { opacity: 0.92; transform: scale(1.015); }
100% { opacity: 0.78; transform: scale(1.03); }
}
.badge-hotfuzz { background: linear-gradient(135deg, var(--hf-red), var(--hf-red-deep)); color: #fff4ea; border-color: transparent; }
h1 { margin: 0; font-size: clamp(2.4rem, 5vw, 4rem); line-height: .94; letter-spacing: -.04em; }
h2 { margin-bottom: 0.75rem; }
h3 { margin-bottom: 0.5rem; }
.lede,
.muted,
.hint { color: var(--muted); }
.grid { display: grid; gap: 1rem; }
.grid.cols-2 { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
.compact-grid { gap: 0.75rem; }
.card {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 20px;
padding: 1rem;
box-shadow: var(--hf-shadow);
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, var(--hf-red), var(--hf-amber), var(--hf-blue));
}
.card h2,
.card h3,
.card p { margin-top: 0; }
.metric-value { font-size: 2rem; font-weight: 700; }
.list { list-style: none; padding: 0; margin: 0; }
.list li + li { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid rgba(255, 255, 255, 0.08); }
.badge { display: inline-block; padding: 0.2rem 0.55rem; border-radius: 999px; background: rgba(242, 193, 78, 0.18); color: var(--hf-amber); font-size: 0.8rem; }
.stack-form { display: grid; gap: 0.75rem; }
.stack-form label { display: grid; gap: 0.35rem; font-size: 0.95rem; }
input, select, textarea, button {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--field);
color: var(--text);
padding: 0.8rem 0.9rem;
font: inherit;
}
textarea { resize: vertical; }
button {
background: linear-gradient(135deg, var(--hf-red) 0%, var(--hf-red-deep) 100%);
color: #fff4ea;
border: none;
font-weight: 700;
}
button:hover { filter: brightness(1.05); cursor: pointer; }
button:focus, input:focus, select:focus, textarea:focus { outline: 2px solid rgba(242, 193, 78, 0.45); outline-offset: 1px; }
.history-block + .history-block { margin-top: 1.25rem; }
.hint { margin: -0.35rem 0 0; font-size: 0.9rem; }
.evidence-board,
.evidence-card,
.evidence-slab,
.intake-docket { position: relative; }
.dossier-grid { align-items: stretch; }
.case-legend {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.case-legend::after {
content: 'FOR THE GREATER GOOD';
font-size: 0.67rem;
letter-spacing: 0.16em;
color: rgba(242, 193, 78, 0.7);
}
.evidence-board::after,
.evidence-card::after {
content: '';
position: absolute;
inset: 14px;
border: 1px dashed rgba(242, 193, 78, 0.09);
border-radius: 16px;
pointer-events: none;
}
.case-list li { padding-left: 18px; position: relative; }
.case-list li::before { content: '•'; position: absolute; left: 0; color: var(--hf-amber); }
.intake-docket {
background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015));
border: 1px solid rgba(242, 239, 221, 0.06);
border-radius: 18px;
padding: 14px;
}
.evidence-slab {
background: rgba(255, 255, 255, 0.035);
border: 1px solid var(--panel-border);
border-radius: 16px;
padding: 14px;
}
@media (max-width: 700px) {
.page-header,
.page-shell {
width: min(100% - 1rem, 1100px);
}
.brand-row,
.casefile-title-row {
align-items: flex-start;
}
.hot-fuzz-art {
flex-basis: 100%;
min-width: 0;
}
}

View File

@@ -0,0 +1,69 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or 'Doris Barbell' }}</title>
<link rel="stylesheet" href="/static/app.css?v=4">
</head>
<body class="doris-hot-fuzz app-barbell">
<div class="incident-tape" aria-hidden="true"></div>
<header class="page-header casefile-header app-casefile app-casefile-barbell">
<div class="film-grain" aria-hidden="true"></div>
<div class="cinematic-glow" aria-hidden="true"></div>
<div class="brand-row">
<div class="nav-brand">
<span class="nav-kicker">N.W.A. Case File</span>
<strong>Doris Constabulary</strong>
</div>
<nav class="nav-links" aria-label="Doris Barbell navigation">
<a class="nav-link" href="http://10.5.1.16:8787/">Dashboard</a>
<a class="nav-link" href="http://10.5.1.16:8092/">Kitchen</a>
<a class="nav-link" href="https://schoolhouse.paccoco.com">Schoolhouse</a>
<a class="nav-link" href="/">Barbell</a>
</nav>
</div>
<div class="casefile-title-row">
<div>
<p class="eyebrow">Station training desk</p>
<div class="casefile-stamp barbell-evidence-tag">Training Dossier</div>
<h1>Doris Barbell</h1>
<p class="lede">Reps, weight, BMI, and body metrics now look like a proper performance dossier instead of generic gym CRUD.</p>
<div class="brand-badges" aria-label="Theme badges">
<span class="pill badge-hotfuzz">For the Greater Good</span>
<span class="pill">Evidence locker: reps</span>
<span class="pill">Case type: Training Dossier</span>
</div>
</div>
<div class="hot-fuzz-art" aria-hidden="true">
<svg viewBox="0 0 320 220" class="poster-illustration" role="presentation">
<defs>
<linearGradient id="barbell-sunset" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f2c14e"></stop>
<stop offset="50%" stop-color="#d53600"></stop>
<stop offset="100%" stop-color="#910f3f"></stop>
</linearGradient>
</defs>
<rect x="8" y="8" width="304" height="204" rx="24" class="poster-frame"></rect>
<circle cx="228" cy="72" r="58" class="poster-halo"></circle>
<path d="M18 170 L112 100 L134 124 L40 198 Z" class="poster-tape tape-left"></path>
<path d="M202 82 L302 32 L314 60 L214 112 Z" class="poster-tape tape-right"></path>
<path d="M112 180 C110 150 116 126 132 110 C140 100 148 94 154 90 C160 94 168 100 176 110 C192 126 198 150 196 180 Z" class="constable-silhouette lead"></path>
<path d="M174 184 C172 154 178 132 194 118 C202 110 210 104 218 100 C226 104 234 110 242 118 C258 132 264 154 262 184 Z" class="constable-silhouette partner"></path>
<circle cx="84" cy="60" r="28" class="swan-stamp"></circle>
<path d="M76 64 C80 54 91 48 101 53 C93 52 88 57 89 63 C90 69 101 68 103 76 C94 78 83 76 78 69 L71 76 L66 71 L74 64 Z" class="swan-mark"></path>
<rect x="122" y="110" width="86" height="8" rx="4" class="barbell-bar"></rect>
<circle cx="120" cy="114" r="16" class="plate-outer"></circle>
<circle cx="120" cy="114" r="8" class="plate-inner"></circle>
<circle cx="210" cy="114" r="16" class="plate-outer"></circle>
<circle cx="210" cy="114" r="8" class="plate-inner"></circle>
<text x="160" y="204" class="poster-callout">TRAINING DOSSIER</text>
</svg>
</div>
</div>
</header>
<main class="page-shell">
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,290 @@
{% extends 'base.html' %}
{% block content %}
<section class="grid cols-2 evidence-board dossier-grid">
<article class="card evidence-card">
<div class="case-legend">
<span class="section-label">Training floor board</span>
<span class="pill">Training Dossier</span>
</div>
<h2>Current body metrics</h2>
<div class="grid dossier-grid">
{% for user in summary.users %}
<section class="evidence-slab">
<h3>{{ user.display_name }} {% if user.is_stub %}<span class="badge">stub</span>{% endif %}</h3>
<p class="metric-value">{{ user.current_weight_lbs if user.current_weight_lbs is not none else '—' }}{% if user.current_weight_lbs is not none %} lb{% endif %}</p>
<p class="muted">BMI: {{ '%.1f'|format(user.current_bmi) if user.current_bmi is not none else 'Not set yet' }}</p>
<p class="muted">System: {{ user.measurement_system }}</p>
{% if user.latest_measurements %}
<p class="muted">
Waist {{ user.latest_measurements.waist_inches or '—' }} in ·
Hips {{ user.latest_measurements.hip_inches or '—' }} in ·
Chest {{ user.latest_measurements.chest_inches or '—' }} in ·
Neck {{ user.latest_measurements.neck_inches or '—' }} in
</p>
{% else %}
<p class="muted">No body measurements logged yet.</p>
{% endif %}
</section>
{% endfor %}
</div>
</article>
<article class="card evidence-card">
<div class="case-legend">
<span class="section-label">Case priorities</span>
<span class="pill">Operator notes</span>
</div>
<h2>V1 focus</h2>
<ul class="list case-list">
<li>Structured workout logging with routines and exercises.</li>
<li>Body-weight, BMI, waist/hip/chest/neck history in imperial units.</li>
<li>Recent workouts, volume trends, and simple PR tracking.</li>
</ul>
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Intake docket</span><span class="pill">Weight log</span></div>
<h2>Log weight</h2>
<form class="stack-form intake-docket" method="post" action="/log/weight">
<label>User
<select name="user_id">
{% for user in summary.users %}
<option value="{{ user.id }}" {% if user.id == 'john' %}selected{% endif %}>{{ user.display_name }}</option>
{% endfor %}
</select>
</label>
<label>Recorded at
<input type="datetime-local" name="recorded_at" required>
</label>
<div class="grid cols-2 compact-grid">
<label>Weight (lb)
<input type="number" name="weight_lbs" min="0" step="0.1" required>
</label>
<label>Body fat %
<input type="number" name="body_fat_percent" min="0" max="100" step="0.1">
</label>
</div>
<label>Notes
<textarea name="notes" rows="2" placeholder="Morning weigh-in, post-workout, etc."></textarea>
</label>
<button type="submit">Save weight entry</button>
</form>
</article>
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Intake docket</span><span class="pill">Measurements</span></div>
<h2>Log body measurements</h2>
<form class="stack-form intake-docket" method="post" action="/log/measurements">
<input type="hidden" name="user_id" value="john">
<label>Recorded at
<input type="datetime-local" name="recorded_at" required>
</label>
<div class="grid cols-2 compact-grid">
<label>Waist (in)
<input type="number" name="waist_inches" min="0" step="0.1">
</label>
<label>Hips (in)
<input type="number" name="hip_inches" min="0" step="0.1">
</label>
<label>Chest (in)
<input type="number" name="chest_inches" min="0" step="0.1">
</label>
<label>Neck (in)
<input type="number" name="neck_inches" min="0" step="0.1">
</label>
</div>
<label>Notes
<textarea name="notes" rows="2" placeholder="Flexed, relaxed, baseline, etc."></textarea>
</label>
<button type="submit">Save measurement entry</button>
</form>
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Exercise library</span><span class="pill">Template intake</span></div>
<h2>Add exercise template</h2>
<form class="stack-form intake-docket" method="post" action="/templates">
<label>Name
<input type="text" name="name" placeholder="Bench Press" required>
</label>
<div class="grid cols-2 compact-grid">
<label>Primary muscle
<input type="text" name="primary_muscle" placeholder="Chest">
</label>
<label>Equipment
<input type="text" name="equipment" placeholder="Barbell">
</label>
</div>
<label>Notes
<textarea name="notes" rows="3" placeholder="Technique cue, setup reminder, variation notes."></textarea>
</label>
<button type="submit">Add template</button>
</form>
</article>
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Routine intake</span><span class="pill">Draft split</span></div>
<h2>Build routine draft</h2>
<form class="stack-form intake-docket" method="post" action="/routines">
<input type="hidden" name="user_id" value="john">
<label>Routine name
<input type="text" name="name" placeholder="Push Day Draft" required>
</label>
<label>Exercise lines
<textarea name="exercise_lines" rows="6" placeholder="Bench Press|4|6-8&#10;Incline Dumbbell Press|3|8-10&#10;Triceps Pushdown|3|12-15" required></textarea>
</label>
<p class="hint">Format: exercise | target sets | target reps | optional notes</p>
<label>Notes
<textarea name="notes" rows="2" placeholder="Temporary until the real split gets entered."></textarea>
</label>
<button type="submit">Save routine draft</button>
</form>
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Workout report</span><span class="pill">Quick log</span></div>
<h2>Quick log workout</h2>
<form class="stack-form intake-docket" method="post" action="/workouts/quick-log">
<input type="hidden" name="user_id" value="john">
<label>Workout name
<input type="text" name="routine_name" placeholder="Push Day" required>
</label>
<label>Performed at
<input type="datetime-local" name="performed_at" required>
</label>
<label>Exercise lines
<textarea name="exercise_lines" rows="6" placeholder="Bench Press|8@135, 8@145&#10;Incline Dumbbell Press|10@50, 8@55|Last set close to failure" required></textarea>
</label>
<p class="hint">Format: exercise | reps@weight, reps@weight | optional notes</p>
<label>Notes
<textarea name="notes" rows="2" placeholder="Energy, pain, wins, misses."></textarea>
</label>
<button type="submit">Save workout</button>
</form>
</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>
<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>
{% endif %}
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Recent logs</span><span class="pill">Session reports</span></div>
<h2>Recent workouts</h2>
{% if john_workout_history %}
<ul class="list">
{% for workout in john_workout_history %}
<li>
<strong>{{ workout.routine_name }}</strong>
<p class="muted">{{ workout.performed_at }} · {{ workout.set_count }} sets · {{ workout.total_volume_lbs }} lb volume</p>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No workouts logged yet.</p>
{% endif %}
</article>
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">History snapshot</span><span class="pill">John file</span></div>
<h2>John history snapshot</h2>
<div class="history-block">
<h3>Recent weights</h3>
{% if john_weight_history %}
<ul class="list">
{% for entry in john_weight_history %}
<li>{{ entry.recorded_at }} · {{ entry.weight_lbs }} lb{% if entry.bmi is not none %} · BMI {{ '%.1f'|format(entry.bmi) }}{% endif %}</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No weight entries yet.</p>
{% endif %}
</div>
<div class="history-block">
<h3>Recent measurements</h3>
{% if john_measurement_history %}
<ul class="list">
{% for entry in john_measurement_history %}
<li>{{ entry.recorded_at }} · Waist {{ entry.waist_inches or '—' }} · Chest {{ entry.chest_inches or '—' }} · Hips {{ entry.hip_inches or '—' }} · Neck {{ entry.neck_inches or '—' }}</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No measurement entries yet.</p>
{% endif %}
</div>
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Current counts</span><span class="pill">Inventory</span></div>
<h2>Current counts</h2>
<ul class="list case-list">
<li>Workout sessions: {{ summary.counts.workout_sessions }}</li>
<li>Weight entries: {{ summary.counts.weight_entries }}</li>
<li>Measurement entries: {{ summary.counts.measurement_entries }}</li>
<li>Exercise templates: {{ summary.counts.exercise_templates }}</li>
<li>Routines: {{ summary.counts.routines }}</li>
</ul>
</article>
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Exercise bests</span><span class="pill">PR board</span></div>
<h2>Exercise bests</h2>
{% if john_progression %}
<ul class="list">
{% for entry in john_progression %}
<li>
<strong>{{ entry.exercise_name }}</strong>
<p class="muted">{{ entry.sessions }} sessions · max {{ entry.max_weight_lbs }} lb · est 1RM {{ entry.best_set_estimated_1rm }} lb</p>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No exercise progression data yet.</p>
{% endif %}
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Next likely build</span><span class="pill">Open casework</span></div>
<h2>Next likely build</h2>
<ul class="list case-list">
<li>Replace placeholder routines with Johns real day-by-day program tomorrow.</li>
<li>Routine-specific workout entry flow driven directly from the saved split.</li>
<li>Exercise progression and PR views.</li>
</ul>
</article>
</section>
{% endblock %}

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ ! -d .venv ]]; then
python3 -m venv .venv
fi
source .venv/bin/activate
pip install -r requirements.txt >/dev/null
exec uvicorn app.main:app --reload --host 0.0.0.0 --port 8093

View File

@@ -0,0 +1,31 @@
services:
doris-barbell:
container_name: doris-barbell
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "8093:8093"
env_file:
- .env
environment:
TZ: ${TZ}
BARBELL_HOST: ${BARBELL_HOST:-0.0.0.0}
BARBELL_PORT: ${BARBELL_PORT:-8093}
BARBELL_STATE_DIR: ${BARBELL_STATE_DIR:-/data/state}
BARBELL_SHARED_PASSWORD: ${BARBELL_SHARED_PASSWORD:-}
volumes:
- /mnt/docker-ssd/docker/appdata/doris-barbell:/data
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8093/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- doris-barbell-net
networks:
doris-barbell-net:
driver: bridge

View File

@@ -0,0 +1,2 @@
[pytest]
pythonpath = .

View File

@@ -0,0 +1,6 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
jinja2==3.1.4
python-multipart==0.0.9
httpx==0.27.2
pytest==8.3.3

View File

@@ -0,0 +1,411 @@
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import create_app
def make_client(tmp_path: Path) -> TestClient:
app = create_app(state_dir=tmp_path)
return TestClient(app)
def test_health_endpoint_reports_ok(tmp_path: Path) -> None:
client = make_client(tmp_path)
response = client.get('/api/health')
assert response.status_code == 200
assert response.json() == {'status': 'ok'}
def test_dashboard_summary_seeds_john_and_manndra(tmp_path: Path) -> None:
client = make_client(tmp_path)
response = client.get('/api/dashboard/summary')
assert response.status_code == 200
payload = response.json()
user_ids = [user['id'] for user in payload['users']]
assert user_ids == ['john', 'manndra']
assert payload['counts'] == {
'exercise_templates': 0,
'measurement_entries': 0,
'routines': 0,
'weight_entries': 0,
'workout_sessions': 0,
}
john = next(user for user in payload['users'] if user['id'] == 'john')
assert john['measurement_system'] == 'imperial'
def test_updating_profile_and_logging_weight_computes_bmi(tmp_path: Path) -> None:
client = make_client(tmp_path)
profile_response = client.put(
'/api/profile/john',
json={
'display_name': 'John',
'height_inches': 68.5,
},
)
assert profile_response.status_code == 200
weight_response = client.post(
'/api/profile/john/weight',
json={
'recorded_at': '2026-05-21T08:30:00',
'weight_lbs': 220,
'body_fat_percent': 24.5,
},
)
assert weight_response.status_code == 201
saved_entry = weight_response.json()
assert saved_entry['user_id'] == 'john'
assert saved_entry['weight_lbs'] == 220
assert saved_entry['body_fat_percent'] == 24.5
assert round(saved_entry['bmi'], 1) == 33.0
summary = client.get('/api/dashboard/summary').json()
john = next(user for user in summary['users'] if user['id'] == 'john')
assert john['current_weight_lbs'] == 220
assert round(john['current_bmi'], 1) == 33.0
def test_logging_measurements_tracks_latest_imperial_body_metrics(tmp_path: Path) -> None:
client = make_client(tmp_path)
response = client.post(
'/api/profile/john/measurements',
json={
'recorded_at': '2026-05-21T08:45:00',
'waist_inches': 42,
'hip_inches': 41,
'chest_inches': 46,
'neck_inches': 17,
'notes': 'Post-op baseline',
},
)
assert response.status_code == 201
saved_entry = response.json()
assert saved_entry['user_id'] == 'john'
assert saved_entry['waist_inches'] == 42
assert saved_entry['hip_inches'] == 41
assert saved_entry['chest_inches'] == 46
assert saved_entry['neck_inches'] == 17
summary = client.get('/api/dashboard/summary').json()
john = next(user for user in summary['users'] if user['id'] == 'john')
assert summary['counts']['measurement_entries'] == 1
assert john['latest_measurements'] == {
'recorded_at': '2026-05-21T08:45:00',
'waist_inches': 42,
'hip_inches': 41,
'chest_inches': 46,
'neck_inches': 17,
}
def test_can_create_and_list_exercise_templates(tmp_path: Path) -> None:
client = make_client(tmp_path)
create_response = client.post(
'/api/exercises/templates',
json={
'name': 'Bench Press',
'primary_muscle': 'chest',
'equipment': 'barbell',
'notes': 'Flat bench barbell press.',
},
)
assert create_response.status_code == 201
saved_template = create_response.json()
assert saved_template['name'] == 'Bench Press'
assert saved_template['equipment'] == 'barbell'
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'
summary = client.get('/api/dashboard/summary').json()
assert summary['counts']['exercise_templates'] == 1
def test_can_create_routine_before_real_program_arrives(tmp_path: Path) -> None:
client = make_client(tmp_path)
client.post(
'/api/exercises/templates',
json={
'name': 'Bench Press',
'primary_muscle': 'chest',
'equipment': 'barbell',
},
)
client.post(
'/api/exercises/templates',
json={
'name': 'Incline Dumbbell Press',
'primary_muscle': 'chest',
'equipment': 'dumbbells',
},
)
create_response = client.post(
'/api/routines',
json={
'user_id': 'john',
'name': 'Push Day Placeholder',
'notes': 'Temporary until John sends the real split.',
'exercises': [
{'exercise_name': 'Bench Press', 'target_sets': 3, 'target_reps': '8-10'},
{'exercise_name': 'Incline Dumbbell Press', 'target_sets': 3, 'target_reps': '10-12'},
],
},
)
assert create_response.status_code == 201
saved_routine = create_response.json()
assert saved_routine['user_id'] == 'john'
assert saved_routine['name'] == 'Push Day Placeholder'
assert len(saved_routine['exercises']) == 2
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'
summary = client.get('/api/dashboard/summary').json()
assert summary['counts']['routines'] == 1
def test_history_endpoints_return_newest_first(tmp_path: Path) -> None:
client = make_client(tmp_path)
client.post(
'/api/profile/john/weight',
json={'recorded_at': '2026-05-20T08:30:00', 'weight_lbs': 222},
)
client.post(
'/api/profile/john/weight',
json={'recorded_at': '2026-05-21T08:30:00', 'weight_lbs': 220},
)
client.post(
'/api/profile/john/measurements',
json={'recorded_at': '2026-05-20T08:45:00', 'waist_inches': 43},
)
client.post(
'/api/profile/john/measurements',
json={'recorded_at': '2026-05-21T08:45:00', 'waist_inches': 42},
)
client.post(
'/api/workouts',
json={
'user_id': 'john',
'performed_at': '2026-05-20T09:15:00',
'routine_name': 'Old Workout',
'exercises': [{'exercise_name': 'Bench Press', 'sets': [{'reps': 8, 'weight_lbs': 135}]}],
},
)
client.post(
'/api/workouts',
json={
'user_id': 'john',
'performed_at': '2026-05-21T09:15:00',
'routine_name': 'New Workout',
'exercises': [{'exercise_name': 'Bench Press', 'sets': [{'reps': 8, 'weight_lbs': 145}]}],
},
)
weight_history = client.get('/api/history/john/weights')
measurement_history = client.get('/api/history/john/measurements')
workout_history = client.get('/api/history/john/workouts')
assert weight_history.status_code == 200
assert measurement_history.status_code == 200
assert workout_history.status_code == 200
assert [entry['weight_lbs'] for entry in weight_history.json()] == [220, 222]
assert [entry['waist_inches'] for entry in measurement_history.json()] == [42, 43]
assert [entry['routine_name'] for entry in workout_history.json()] == ['New Workout', 'Old Workout']
def test_logging_structured_workout_updates_recent_activity(tmp_path: Path) -> None:
client = make_client(tmp_path)
response = client.post(
'/api/workouts',
json={
'user_id': 'john',
'performed_at': '2026-05-21T09:15:00',
'routine_name': 'Push Day',
'notes': 'First pass',
'exercises': [
{
'exercise_name': 'Bench Press',
'sets': [
{'reps': 8, 'weight_lbs': 135, 'rpe': 7.5, 'rest_seconds': 120},
{'reps': 8, 'weight_lbs': 145, 'rpe': 8.0, 'rest_seconds': 120},
],
}
],
},
)
assert response.status_code == 201
workout = response.json()
assert workout['user_id'] == 'john'
assert workout['routine_name'] == 'Push Day'
assert workout['exercise_count'] == 1
assert workout['set_count'] == 2
assert workout['total_volume_lbs'] == 2240
summary = client.get('/api/dashboard/summary').json()
assert summary['counts']['workout_sessions'] == 1
assert summary['recent_workouts'][0]['routine_name'] == 'Push Day'
assert summary['recent_workouts'][0]['total_volume_lbs'] == 2240
def test_progression_endpoint_summarizes_exercise_bests(tmp_path: Path) -> None:
client = make_client(tmp_path)
client.post(
'/api/workouts',
json={
'user_id': 'john',
'performed_at': '2026-05-20T09:15:00',
'routine_name': 'Push Day',
'exercises': [
{
'exercise_name': 'Bench Press',
'sets': [
{'reps': 8, 'weight_lbs': 135},
{'reps': 5, 'weight_lbs': 155},
],
}
],
},
)
client.post(
'/api/workouts',
json={
'user_id': 'john',
'performed_at': '2026-05-22T09:15:00',
'routine_name': 'Push Day',
'exercises': [
{
'exercise_name': 'Bench Press',
'sets': [
{'reps': 3, 'weight_lbs': 175},
],
},
{
'exercise_name': 'Incline Dumbbell Press',
'sets': [
{'reps': 10, 'weight_lbs': 55},
],
},
],
},
)
response = client.get('/api/progression/john/exercises')
assert response.status_code == 200
payload = response.json()
assert payload[0]['exercise_name'] == 'Bench Press'
assert payload[0]['sessions'] == 2
assert payload[0]['max_weight_lbs'] == 175
assert payload[0]['best_set_estimated_1rm'] == 192.5
assert payload[0]['last_performed_at'] == '2026-05-22T09:15:00'
assert payload[1]['exercise_name'] == 'Incline Dumbbell Press'
assert payload[1]['sessions'] == 1
def test_homepage_renders_core_sections_and_forms(tmp_path: Path) -> None:
client = make_client(tmp_path)
response = client.get('/')
assert response.status_code == 200
body = response.text
assert 'Doris Barbell' in body
assert 'Recent workouts' in body
assert 'Current body metrics' in body
assert 'Log weight' in body
assert 'Add exercise template' in body
assert 'Build routine draft' in body
def test_weight_form_submission_redirects_and_updates_dashboard(tmp_path: Path) -> None:
client = make_client(tmp_path)
response = client.post(
'/log/weight',
data={
'user_id': 'john',
'recorded_at': '2026-05-22T06:15:00',
'weight_lbs': '218.5',
'body_fat_percent': '23.7',
'notes': 'Pre-gym weigh-in',
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers['location'] == '/'
summary = client.get('/api/dashboard/summary').json()
john = next(user for user in summary['users'] if user['id'] == 'john')
assert john['current_weight_lbs'] == 218.5
history = client.get('/api/history/john/weights').json()
assert history[0]['notes'] == 'Pre-gym weigh-in'
def test_template_and_routine_form_submissions_redirect_and_render_entries(tmp_path: Path) -> None:
client = make_client(tmp_path)
template_response = client.post(
'/templates',
data={
'name': 'Leg Press',
'primary_muscle': 'quads',
'equipment': 'machine',
'notes': 'Plate-loaded sled.',
},
follow_redirects=False,
)
assert template_response.status_code == 303
assert template_response.headers['location'] == '/'
routine_response = client.post(
'/routines',
data={
'user_id': 'john',
'name': 'Leg Day Draft',
'notes': 'Temporary until John sends the real split.',
'exercise_lines': 'Leg Press|4|10-12\nHamstring Curl|3|10-12|Slow negative',
},
follow_redirects=False,
)
assert routine_response.status_code == 303
assert routine_response.headers['location'] == '/'
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'

View File

@@ -0,0 +1,97 @@
from pathlib import Path
ROOT = Path('/home/fizzlepoof/repos/truenas-stacks/home')
def read(relative_path: str) -> str:
return (ROOT / relative_path).read_text()
def test_hot_fuzz_theme_markers_exist_across_doris_sites() -> None:
expectations = {
'doris-kitchen/app/templates/base.html': [
'doris-hot-fuzz',
'Doris Constabulary',
'N.W.A. Case File',
'hot-fuzz-art',
'constable-silhouette',
'swan-stamp',
'cinematic-glow',
'app-casefile app-casefile-kitchen',
'Pantry Evidence',
'recipe-evidence-tag',
],
'doris-schoolhouse/app/templates/base.html': [
'doris-hot-fuzz',
'Doris Constabulary',
'N.W.A. Case File',
'hot-fuzz-art',
'constable-silhouette',
'swan-stamp',
'cinematic-glow',
'app-casefile app-casefile-schoolhouse',
'Records Intake',
'coursework-evidence-tag',
],
'doris-barbell/app/templates/base.html': [
'doris-hot-fuzz',
'Doris Constabulary',
'N.W.A. Case File',
'hot-fuzz-art',
'constable-silhouette',
'swan-stamp',
'cinematic-glow',
'app-casefile app-casefile-barbell',
'Training Dossier',
'barbell-evidence-tag',
],
'doris-dashboard/bin/generate_dashboard.py': [
'Doris Constabulary',
'N.W.A. Case File',
'for-the-greater-good',
'hot-fuzz-art',
'constable-silhouette',
'swan-stamp',
'cinematic-glow',
'Operator Evidence Board',
'operator-evidence-tag',
'hero-board',
'lead-warrant',
'hero-slip-grid',
],
}
for relative_path, markers in expectations.items():
content = read(relative_path)
for marker in markers:
assert marker in content, f'{marker} missing from {relative_path}'
def test_hot_fuzz_theme_tokens_exist_in_site_stylesheets() -> None:
expectations = {
'doris-kitchen/app/static/app.css': ['--hf-red', '--hf-amber', '.incident-tape', '.casefile-header', '.hot-fuzz-art', '.constable-silhouette', '.swan-stamp', '.cinematic-glow', '.app-casefile', '.film-grain', '@keyframes emergencyPulse', '.evidence-board', '.case-legend', '.dossier-grid'],
'doris-schoolhouse/app/static/app.css': ['--hf-red', '--hf-amber', '.incident-tape', '.casefile-header', '.hot-fuzz-art', '.constable-silhouette', '.swan-stamp', '.cinematic-glow', '.app-casefile', '.film-grain', '@keyframes emergencyPulse', '.evidence-board', '.case-legend', '.dossier-grid'],
'doris-barbell/app/static/app.css': ['--hf-red', '--hf-amber', '.incident-tape', '.casefile-header', '.hot-fuzz-art', '.constable-silhouette', '.swan-stamp', '.cinematic-glow', '.app-casefile', '.film-grain', '@keyframes emergencyPulse', '.evidence-board', '.case-legend', '.dossier-grid'],
'doris-dashboard/public/style.css': ['--hf-red', '--hf-amber', '.incident-tape', '.casefile-header', '.hot-fuzz-art', '.constable-silhouette', '.swan-stamp', '.cinematic-glow', '.app-casefile', '.film-grain', '@keyframes emergencyPulse', '.evidence-board', '.case-legend', '.dossier-grid', '.hero-board', '.hero-dossier-card', '.hero-slip-grid'],
}
for relative_path, markers in expectations.items():
content = read(relative_path)
for marker in markers:
assert marker in content, f'{marker} missing from {relative_path}'
def test_interior_casefile_markers_exist_on_key_pages() -> None:
expectations = {
'doris-kitchen/app/templates/dashboard.html': ['evidence-board', 'case-legend', 'Kitchen queue board', 'dossier-grid'],
'doris-kitchen/app/templates/search.html': ['evidence-board', 'case-legend', 'Lead intake', 'Search warrant'],
'doris-schoolhouse/app/templates/dashboard.html': ['evidence-board', 'case-legend', 'Course board', 'dossier-grid'],
'doris-schoolhouse/app/templates/assignment_upload.html': ['evidence-board', 'chain-of-custody', 'Submission packet', 'intake-docket'],
'doris-barbell/app/templates/dashboard.html': ['evidence-board', 'case-legend', 'Training floor board', 'dossier-grid'],
'doris-dashboard/bin/generate_dashboard.py': ['evidence-board', 'case-legend', 'Operator board', 'dossier-grid'],
}
for relative_path, markers in expectations.items():
content = read(relative_path)
for marker in markers:
assert marker in content, f'{marker} missing from {relative_path}'