diff --git a/docs/planning/doris-barbell-v1-plan.md b/docs/planning/doris-barbell-v1-plan.md new file mode 100644 index 0000000..03460e4 --- /dev/null +++ b/docs/planning/doris-barbell-v1-plan.md @@ -0,0 +1,107 @@ +# Doris Barbell V1 Implementation Plan + +> For Hermes: use subagent-driven-development if this gets split into parallel implementation chunks later. + +Goal: ship a private-first exercise tracking site on PD that lets John log structured workouts, reps, weight, body weight, BMI, and related body metrics, while leaving a clean path for Manndra as a second household user later. + +Architecture: start with the proven Doris pattern already used elsewhere in the homelab: a small FastAPI + Jinja app with a JSON-backed store for fast delivery, then graduate the storage layer to SQLite or shared Postgres if the workflow proves sticky. Keep the initial data model structured enough that templates, charts, PR tracking, and multi-user auth can layer in without a rewrite. + +Tech stack: FastAPI, Jinja2, JSON state store for V1 bootstrap, Docker Compose on PD, responsive CSS, pytest. + +--- + +## Working assumptions locked in for now + +- Audience: John now, Manndra stubbed for later. +- Hosting: PD. +- Device target: both phone and desktop, mobile-friendly first. +- Access model default: private-first now, public route optional later. +- Backend default: Python / FastAPI because it matches the existing Doris app pattern and is fastest to ship here. +- Reporting default: build toward workout history, PR/1RM, and body-metric trends, but do not block V1 logging on all charts being perfect. +- Measurement preference: imperial only. +- Progress photos: out for V1. +- Muscle-size tracking: not a priority. +- Current height assumption for seed data: 68.5 inches unless John corrects it. +- Exercise catalog: John will send the real exercise list later; V1 should support templates/routines once that lands. + +## Current scaffold already started + +- New repo source: `home/doris-barbell` +- Initial FastAPI app skeleton exists. +- JSON state seeds `john` plus stub `manndra`. +- Implemented endpoints: + - `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` +- Homepage now renders current metrics, recent workouts, exercise bests, and placeholder template/routine scaffolding. +- Homepage now includes mobile-friendly HTML forms for quick weight entry, body measurements, exercise templates, routine drafts, and quick workout logging. +- Homepage now includes John-focused recent history snapshots so the app is useful before charts exist. +- Tests currently cover health, seeded users, BMI calculation, imperial measurement logging, exercise template creation, routine creation, history ordering, structured workout logging, progression summaries, homepage render, and form-post redirects. + +## Next build slices + +### Slice 1: stabilize the base data model +1. Add lightweight migration/version handling inside the JSON state. +2. Add validation rules around timestamps, duplicate entries, and bad user IDs. +3. Add additional non-muscle health/body metrics only if John actually wants them surfaced. +4. Keep every write user-scoped so Manndra can be added later without rework. + +### Slice 2: make workout logging genuinely usable +1. Add exercise template edit/delete. +2. Add routine edit/delete and clone-from-template flow. +3. Add support for warm-up vs working sets. +4. Add notes per exercise and per set. +5. Add estimated 1RM helpers for barbell lifts. + +### Slice 3: give John the actual dashboards he will care about +1. Current weight card and trend sparkline. +2. BMI and body-fat trend history. +3. Recent workouts table. +4. Exercise-specific progression view. +5. PR cards and recent milestone callouts. +6. Body-measurement history cards for waist, hips, chest, and neck. +7. UI screens that consume the new history endpoints. + +### Slice 4: prepare for shared household use without forcing it now +1. Add auth choice once John settles on private-only vs public exposure. +2. Preserve seeded Manndra user but hide stub-only clutter in the UI. +3. Keep imperial defaults while leaving room for future per-user units if needed. + +### Slice 5: deploy on PD cleanly +1. Add `.env` from `.env.example` in the live deploy path. +2. Validate with `docker compose --env-file .env config`. +3. Deploy under `/mnt/docker-ssd/docker/compose/doris-barbell`. +4. Decide whether it stays LAN-only or gets a routed hostname. + +## Biggest open product questions still waiting on John + +1. Exact exercise/routine list. +2. Preferred auth mode for V1. +3. Which extra non-photo metrics matter beyond weight, BMI, body fat, waist, hips, chest, and neck. +4. Whether 68.5 inches should be treated as the current real height, or whether he wants 69 inches used instead. +5. Whether he wants one-step quick workout entry or a fuller workout-builder flow first. + +## Verification commands + +```bash +cd /home/fizzlepoof/repos/truenas-stacks/home/doris-barbell +. .venv/bin/activate +pytest tests/test_app.py -q +python3 -m py_compile app/main.py app/config.py app/models.py app/routes/api.py app/routes/ui.py app/services/store.py +python3 - <<'PY' +import yaml +from pathlib import Path +print(yaml.safe_load(Path('docker-compose.yaml').read_text())['services'].keys()) +PY +``` diff --git a/home/doris-barbell/.env.example b/home/doris-barbell/.env.example new file mode 100644 index 0000000..0aca246 --- /dev/null +++ b/home/doris-barbell/.env.example @@ -0,0 +1,5 @@ +TZ=America/Chicago +BARBELL_HOST=0.0.0.0 +BARBELL_PORT=8093 +BARBELL_STATE_DIR=/data/state +BARBELL_SHARED_PASSWORD= diff --git a/home/doris-barbell/.gitignore b/home/doris-barbell/.gitignore new file mode 100644 index 0000000..37dc78d --- /dev/null +++ b/home/doris-barbell/.gitignore @@ -0,0 +1,6 @@ +.env +.venv/ +__pycache__/ +.pytest_cache/ +/data/ +*.pyc diff --git a/home/doris-barbell/Dockerfile b/home/doris-barbell/Dockerfile new file mode 100644 index 0000000..d9279d8 --- /dev/null +++ b/home/doris-barbell/Dockerfile @@ -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"] diff --git a/home/doris-barbell/README.md b/home/doris-barbell/README.md new file mode 100644 index 0000000..55b0000 --- /dev/null +++ b/home/doris-barbell/README.md @@ -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 +``` diff --git a/home/doris-barbell/app/__init__.py b/home/doris-barbell/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/home/doris-barbell/app/config.py b/home/doris-barbell/app/config.py new file mode 100644 index 0000000..3166360 --- /dev/null +++ b/home/doris-barbell/app/config.py @@ -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')) + diff --git a/home/doris-barbell/app/main.py b/home/doris-barbell/app/main.py new file mode 100644 index 0000000..5dccdaf --- /dev/null +++ b/home/doris-barbell/app/main.py @@ -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() diff --git a/home/doris-barbell/app/models.py b/home/doris-barbell/app/models.py new file mode 100644 index 0000000..e7bb8d3 --- /dev/null +++ b/home/doris-barbell/app/models.py @@ -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] diff --git a/home/doris-barbell/app/routes/api.py b/home/doris-barbell/app/routes/api.py new file mode 100644 index 0000000..5952a10 --- /dev/null +++ b/home/doris-barbell/app/routes/api.py @@ -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 diff --git a/home/doris-barbell/app/routes/ui.py b/home/doris-barbell/app/routes/ui.py new file mode 100644 index 0000000..b11e35f --- /dev/null +++ b/home/doris-barbell/app/routes/ui.py @@ -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 diff --git a/home/doris-barbell/app/services/store.py b/home/doris-barbell/app/services/store.py new file mode 100644 index 0000000..1abb549 --- /dev/null +++ b/home/doris-barbell/app/services/store.py @@ -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) diff --git a/home/doris-barbell/app/static/app.css b/home/doris-barbell/app/static/app.css new file mode 100644 index 0000000..a7e9b7c --- /dev/null +++ b/home/doris-barbell/app/static/app.css @@ -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; + } +} diff --git a/home/doris-barbell/app/templates/base.html b/home/doris-barbell/app/templates/base.html new file mode 100644 index 0000000..f7251b0 --- /dev/null +++ b/home/doris-barbell/app/templates/base.html @@ -0,0 +1,69 @@ + + + + + + {{ title or 'Doris Barbell' }} + + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/home/doris-barbell/app/templates/dashboard.html b/home/doris-barbell/app/templates/dashboard.html new file mode 100644 index 0000000..445825f --- /dev/null +++ b/home/doris-barbell/app/templates/dashboard.html @@ -0,0 +1,290 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ + Training Dossier +
+

Current body metrics

+
+ {% for user in summary.users %} +
+

{{ user.display_name }} {% if user.is_stub %}stub{% endif %}

+

{{ user.current_weight_lbs if user.current_weight_lbs is not none else '—' }}{% if user.current_weight_lbs is not none %} lb{% endif %}

+

BMI: {{ '%.1f'|format(user.current_bmi) if user.current_bmi is not none else 'Not set yet' }}

+

System: {{ user.measurement_system }}

+ {% if user.latest_measurements %} +

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

+ {% else %} +

No body measurements logged yet.

+ {% endif %} +
+ {% endfor %} +
+
+ +
+
+ + Operator notes +
+

V1 focus

+ +
+
+ +
+
+
Weight log
+

Log weight

+
+ + +
+ + +
+ + +
+
+ +
+
Measurements
+

Log body measurements

+
+ + +
+ + + + +
+ + +
+
+
+ +
+
+
Template intake
+

Add exercise template

+
+ +
+ + +
+ + +
+
+ +
+
Draft split
+

Build routine draft

+
+ + + +

Format: exercise | target sets | target reps | optional notes

+ + +
+
+
+ +
+
+
Quick log
+

Quick log workout

+
+ + + + +

Format: exercise | reps@weight, reps@weight | optional notes

+ + +
+
+ +
+
Scaffold
+

Template and routine scaffold

+

You can start building the library now, even before the real split lands tomorrow.

+ + {% if summary.exercise_templates %} +

Templates

+ + {% endif %} + {% if summary.routines %} +

Routines

+ + {% endif %} +
+
+ +
+
+
Session reports
+

Recent workouts

+ {% if john_workout_history %} + + {% else %} +

No workouts logged yet.

+ {% endif %} +
+ +
+
John file
+

John history snapshot

+
+

Recent weights

+ {% if john_weight_history %} +
    + {% for entry in john_weight_history %} +
  • {{ entry.recorded_at }} · {{ entry.weight_lbs }} lb{% if entry.bmi is not none %} · BMI {{ '%.1f'|format(entry.bmi) }}{% endif %}
  • + {% endfor %} +
+ {% else %} +

No weight entries yet.

+ {% endif %} +
+
+

Recent measurements

+ {% if john_measurement_history %} +
    + {% for entry in john_measurement_history %} +
  • {{ entry.recorded_at }} · Waist {{ entry.waist_inches or '—' }} · Chest {{ entry.chest_inches or '—' }} · Hips {{ entry.hip_inches or '—' }} · Neck {{ entry.neck_inches or '—' }}
  • + {% endfor %} +
+ {% else %} +

No measurement entries yet.

+ {% endif %} +
+
+
+ +
+
+
Inventory
+

Current counts

+ +
+ +
+
PR board
+

Exercise bests

+ {% if john_progression %} + + {% else %} +

No exercise progression data yet.

+ {% endif %} +
+
+ +
+
+
Open casework
+

Next likely build

+ +
+
+{% endblock %} diff --git a/home/doris-barbell/bin/run-local.sh b/home/doris-barbell/bin/run-local.sh new file mode 100755 index 0000000..a38a203 --- /dev/null +++ b/home/doris-barbell/bin/run-local.sh @@ -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 diff --git a/home/doris-barbell/docker-compose.yaml b/home/doris-barbell/docker-compose.yaml new file mode 100644 index 0000000..3e2244c --- /dev/null +++ b/home/doris-barbell/docker-compose.yaml @@ -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 diff --git a/home/doris-barbell/pytest.ini b/home/doris-barbell/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/home/doris-barbell/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/home/doris-barbell/requirements.txt b/home/doris-barbell/requirements.txt new file mode 100644 index 0000000..358bf4b --- /dev/null +++ b/home/doris-barbell/requirements.txt @@ -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 diff --git a/home/doris-barbell/tests/test_app.py b/home/doris-barbell/tests/test_app.py new file mode 100644 index 0000000..57c2d9f --- /dev/null +++ b/home/doris-barbell/tests/test_app.py @@ -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' diff --git a/home/doris-barbell/tests/test_hotfuzz_theme.py b/home/doris-barbell/tests/test_hotfuzz_theme.py new file mode 100644 index 0000000..f6aa340 --- /dev/null +++ b/home/doris-barbell/tests/test_hotfuzz_theme.py @@ -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}'