# Architecture ## Overview SneakySwole is a workout tracking and programming app built with FastAPI, HTMX, and SQLite. It follows a layered architecture with strict separation of concerns: routes handle HTTP, services handle business logic and data access, and models define the schema. All user interactions return HTML (full pages or HTMX partials) — there are no JSON APIs except the health check. --- ## Technology Stack | Layer | Technology | Notes | |-------|-----------|-------| | Web framework | FastAPI + Uvicorn | Async, Python 3.12 | | Frontend | Jinja2 + HTMX + Pico CSS | Dark theme, no JS framework | | Database | SQLite3 + SQLModel ORM | Single file at `data/sneakyswole.db` | | Migrations | Alembic | Schema versioning, auto-generated DDL | | Auth | bcrypt + itsdangerous | Hashed passwords, signed session cookies | | Logging | structlog | Structured JSON logging | | Config | pydantic-settings | Typed `.env` loader with validation | | Container | Docker + docker-compose | Single service, port 8000, named volume | | Testing | pytest | 30+ test modules | | Dependencies | uv + requirements.txt | Pinned versions | --- ## Application Layers ### 1. Routes (`app/routes/`) HTTP handlers that parse requests, call services, and return rendered templates. Each file covers one domain. | File | Prefix | Purpose | |------|--------|---------| | `auth.py` | `/login`, `/logout` | Login form, credential verification, session cookies | | `pages.py` | `/` | Home page | | `profiles.py` | `/profiles` | Profile list, switch, edit | | `workouts.py` | `/workouts` | Workout day list and detail viewer | | `exercises.py` | `/exercises` | Exercise browser with HTMX search | | `logging.py` | `/log` | Inline set logging (create/edit/delete) | | `history.py` | `/history` | Past session list and detail | | `dashboard.py` | `/dashboard` | Progress stats, volume charts, per-exercise progress | | `schedule.py` | `/schedule` | 4-week calendar view | | `health.py` | `/health` | JSON health check | **Rules:** - Routes never query the database directly — all data access goes through services - All routes (except `/login`, `/logout`, `/health`) require admin authentication via `@Depends(get_current_admin_user)` - Routes return `TemplateResponse` (HTML), not JSON ### 2. Services (`app/services/`) Business logic and all database access. Each service is instantiated with a SQLModel `Session`. | Service | File | Responsibility | |---------|------|---------------| | `AuthService` | `auth_service.py` | bcrypt password verification, session token creation/validation (itsdangerous) | | `UserService` | `user_service.py` | User CRUD, list profiles, update stats | | `ExerciseService` | `exercise_service.py` | Exercise queries (by day, muscle group), warmup listing, workout day listing | | `LogService` | `log_service.py` | Workout log CRUD (create/read/update/delete sets) | | `WorkoutSessionService` | `workout_session_service.py` | Session management (get_or_create, list, lookup) | | `ProgressionService` | `progression_service.py` | Auto-progression suggestions (+reps/+weight/deload) | | `AnalyticsService` | `analytics_service.py` | User stats, volume by day, per-exercise progress data | | `SeedService` | `seed_service.py` | YAML-driven database initialization from `config/` files | ### 3. Models (`app/models/`) SQLModel ORM classes defining 8 database tables. See `docs/database_schema.md` for full details. ### 4. Utils (`app/utils/`) Shared utilities — currently just auth dependencies: - **`auth.py`** — `get_current_admin_user()` (FastAPI dependency), `get_active_profile_id()`, `NotAuthenticatedError`, `SESSION_COOKIE_NAME` ### 5. Templates (`app/templates/`) Jinja2 templates split into full pages and reusable HTMX partials. - **`base.html`** — Master layout with Pico CSS dark theme, HTMX script, nav bar - **`pages/`** — 12 full-page templates (login, home, dashboard, workout_day, etc.) - **`partials/`** — 13 HTMX fragment templates (exercise_card, log_form, nav, stats_card, etc.) ### 6. Configuration - **`app/config.py`** — Typed `Settings` class (pydantic-settings), singleton via `@lru_cache` - **`.env`** — Runtime secrets (gitignored), referenced by docker-compose - **`.env.example`** — Template documenting required variables - **`config/exercises.yaml`** — Exercise library (name, muscle group, sets, tempo, form cues) - **`config/user_programs.yaml`** — Per-user programming targets (week 1/4 reps and weights) --- ## Key Patterns ### Authentication Flow 1. Admin submits credentials via `POST /login` 2. `AuthService.authenticate()` verifies bcrypt hash 3. On success, `AuthService.create_session_token()` creates a signed token (itsdangerous) 4. Token stored in httponly cookie (`session`), samesite=lax, 24h TTL 5. `get_current_admin_user()` dependency validates token on every protected route 6. Invalid/missing token raises `NotAuthenticatedError` → 302 redirect to `/login` ### Profile Switching - Admin selects active profile via `POST /profiles/switch` - Profile ID stored in separate httponly cookie (`active_profile_id`) - `get_active_profile_id()` extracts it from the request - Workout logging happens under the active profile ### NavContextMiddleware Starlette middleware (`app/main.py:NavContextMiddleware`) runs on every request: 1. Reads session cookie and validates token 2. If valid admin: loads `admin`, `profiles` list, and `active_profile` into `request.state` 3. Templates read from `request.state` to render the nav bar (profile switcher, etc.) ### HTMX Partial Pattern All dynamic updates use HTMX with HTML fragment responses: - Filter dropdowns trigger `hx-get` to `/exercises/search` → returns `partials/exercise_list.html` - Log form submits `hx-post` to `/log` → returns `partials/log_entry.html` - No JSON APIs, no fetch calls, no vanilla JS ### Database Startup 1. `create_app()` creates SQLModel engine and calls `SQLModel.metadata.create_all()` 2. `@app.on_event("startup")` triggers `SeedService.seed_all()` 3. Seed service reads `config/exercises.yaml` + `config/user_programs.yaml` 4. Inserts exercises, warmups, workout days, user profiles, and programming targets 5. Creates admin user from `.env` credentials (bcrypt-hashed) 6. Skips seeding if data already exists --- ## Request Lifecycle ``` Client Request ↓ NavContextMiddleware (injects admin/profiles/active_profile into request.state) ↓ FastAPI Router (matches route) ↓ Auth Dependency (get_current_admin_user — validates session cookie) ↓ Route Handler (parses request, calls service(s)) ↓ Service Layer (business logic, DB queries via SQLModel Session) ↓ Jinja2 TemplateResponse (renders full page or HTMX partial) ↓ Client Response (HTML) ``` --- ## Docker Setup - **`Dockerfile`** — Slim Python 3.12 base, copies app + config, installs deps, runs Uvicorn - **`docker-compose.yaml`** — Single `app` service, port 8000, named volume for `data/`, `.env` file - Static files and templates are baked into the image - SQLite DB persists via Docker volume mount at `data/` --- ## Directory Structure ``` SneakySwole/ ├── app/ │ ├── __init__.py │ ├── config.py # Typed settings (pydantic-settings) │ ├── database.py # Engine factory + session dependency │ ├── logging_config.py # structlog setup │ ├── main.py # App factory, middleware, router registration │ ├── models/ # SQLModel ORM (8 tables) │ ├── routes/ # FastAPI handlers (10 modules) │ ├── services/ # Business logic + DB access (8 services) │ ├── static/css/ # Pico CSS overrides │ ├── templates/ # Jinja2 (pages/ + partials/) │ └── utils/ # Auth dependencies ├── config/ # YAML seed data │ ├── exercises.yaml │ └── user_programs.yaml ├── alembic/ # Database migrations ├── tests/ # pytest test suite ├── data/ # SQLite DB (gitignored, Docker volume) ├── docs/ # Documentation ├── Dockerfile ├── docker-compose.yaml ├── pyproject.toml ├── requirements.txt └── .env.example ```