From 53e62f694fe16e443dfe9fe5bfaceab46cbca1d9 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Tue, 24 Feb 2026 14:19:49 -0600 Subject: [PATCH] docs: replace implementation plans with architecture and API reference docs Remove phase implementation plans, design notes, and source spreadsheet that are no longer needed. Add architecture.md, API_REFERENCE.md, and database_schema.md for ongoing development and debugging reference. Co-Authored-By: Claude Opus 4.6 --- docs/API_REFERENCE.md | 278 ++++++++++++++++++ docs/CLAUDE.md | 17 +- docs/architecture.md | 197 +++++++++++++ docs/database_schema.md | 202 +++++++++++++ .../2026-02-23-roadmap-and-data-design.md | 76 ----- docs/roadmap.md | 95 ++---- workout_plan_v2.xlsx | Bin 25859 -> 0 bytes 7 files changed, 706 insertions(+), 159 deletions(-) create mode 100644 docs/API_REFERENCE.md create mode 100644 docs/architecture.md create mode 100644 docs/database_schema.md delete mode 100644 docs/plans/2026-02-23-roadmap-and-data-design.md delete mode 100644 workout_plan_v2.xlsx diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..0911a50 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,278 @@ +# API Reference + +All endpoints return HTML (full pages or HTMX partials) unless noted otherwise. Protected routes require a valid admin session cookie. + +--- + +## Authentication + +### `GET /login` + +Render the login form. + +- **Auth:** None +- **Response:** Full page (`pages/login.html`) + +### `POST /login` + +Authenticate admin credentials and start a session. + +- **Auth:** None +- **Content-Type:** `application/x-www-form-urlencoded` +- **Form fields:** + - `username` (string) — admin username + - `password` (string) — admin password +- **Success:** 303 redirect to `/`, sets `session` cookie (httponly, samesite=lax, 24h TTL) +- **Failure:** 200 with login page re-rendered and error message + +### `GET /logout` + +End the admin session. + +- **Auth:** None (clears cookies regardless) +- **Response:** 303 redirect to `/login`, deletes `session` and `active_profile_id` cookies + +--- + +## Health + +### `GET /health` + +Application health check for monitoring and readiness probes. + +- **Auth:** None +- **Response:** JSON + ```json + { + "app": "sneakyswole", + "version": "0.1.0", + "status": "ok" + } + ``` + +--- + +## Pages + +### `GET /` + +Home page. + +- **Auth:** None +- **Response:** Full page (`pages/home.html`) + +--- + +## Profiles + +All profile routes require admin authentication. + +### `GET /profiles` + +List all user profiles (excludes admin). + +- **Auth:** Admin session +- **Response:** Full page (`pages/profiles.html`) +- **Template context:** `profiles`, `active_profile_id`, `admin` + +### `POST /profiles/switch` + +Switch the active user profile. + +- **Auth:** Admin session +- **Content-Type:** `application/x-www-form-urlencoded` +- **Form fields:** + - `profile_id` (string) — ID of the profile to activate +- **Response:** 303 redirect to referring page, sets `active_profile_id` cookie + +### `GET /profiles/{profile_id}/edit` + +Render the profile edit form. + +- **Auth:** Admin session +- **Path params:** `profile_id` (int) +- **Response:** Full page (`pages/profile_edit.html`) + +### `POST /profiles/{profile_id}/edit` + +Update a user profile. + +- **Auth:** Admin session +- **Path params:** `profile_id` (int) +- **Content-Type:** `application/x-www-form-urlencoded` +- **Form fields:** + - `display_name` (string) + - `height` (string) + - `weight` (string) + - `goals` (string) +- **Response:** 303 redirect to `/profiles` + +--- + +## Workouts + +All workout routes require admin authentication. + +### `GET /workouts` + +List all workout days as clickable cards. + +- **Auth:** Admin session +- **Response:** Full page (`pages/workout_days.html`) +- **Template context:** `days`, `admin` + +### `GET /workouts/{day_name}` + +Display a full workout day with warmups, exercises, programming targets, and inline logging. + +- **Auth:** Admin session +- **Path params:** `day_name` (string) — URL-friendly name, e.g., "push", "pull", "lower", "full-body" +- **Response:** Full page (`pages/workout_day.html`) +- **Template context:** `day_name`, `warmups`, `exercises`, `programs`, `active_profile`, `existing_logs`, `suggestions`, `workout_day_id`, `admin` +- **Notes:** Day name is normalized (e.g., "full-body" → "Full Body"). If an active profile is set, includes programming targets, progression suggestions, and today's existing logs. + +--- + +## Exercises + +All exercise routes require admin authentication. + +### `GET /exercises` + +Render the exercise browser with filter dropdowns. + +- **Auth:** Admin session +- **Response:** Full page (`pages/exercise_browser.html`) +- **Template context:** `exercises`, `workout_days`, `muscle_groups`, `admin` + +### `GET /exercises/search` + +HTMX partial — return filtered exercise list. + +- **Auth:** Admin session +- **Query params:** + - `workout_day` (string, optional) — filter by workout day name + - `muscle_group` (string, optional) — filter by muscle group +- **Response:** HTMX partial (`partials/exercise_list.html`) +- **Usage:** Called via `hx-get` from exercise browser filter dropdowns + +--- + +## Workout Logging + +All logging routes require admin authentication. Responses are HTMX partials that update inline. + +### `POST /log` + +Log a single set for an exercise. Auto-creates today's workout session if needed. + +- **Auth:** Admin session +- **Content-Type:** `application/x-www-form-urlencoded` +- **Form fields:** + - `exercise_id` (int) + - `workout_day_id` (int) + - `set_number` (int, default=1) + - `reps` (int) + - `weight` (string) — e.g., "30 lbs", "BW" + - `felt_easy` (checkbox, "on" = true) +- **Response:** HTMX partial (`partials/log_entry.html`) with updated logs for this exercise +- **Error:** If no active profile selected, returns `partials/flash_message.html` with error + +### `POST /log/{log_id}/edit` + +Edit an existing log entry. + +- **Auth:** Admin session +- **Path params:** `log_id` (int) +- **Content-Type:** `application/x-www-form-urlencoded` +- **Form fields:** + - `reps` (int) + - `weight` (string) + - `felt_easy` (checkbox) + - `notes` (string, optional) +- **Response:** HTMX partial (`partials/log_entry.html`) with updated logs + +### `POST /log/{log_id}/delete` + +Delete a log entry. + +- **Auth:** Admin session +- **Path params:** `log_id` (int) +- **Response:** HTMX partial (`partials/log_entry.html`) with remaining logs, or empty HTML if log not found + +--- + +## History + +All history routes require admin authentication. + +### `GET /history` + +Display log history for the active profile — list of past sessions, most recent first. + +- **Auth:** Admin session +- **Response:** Full page (`pages/log_history.html`) +- **Template context:** `sessions`, `days_by_id`, `active_profile`, `admin` +- **Notes:** Requires active profile to show sessions + +### `GET /history/{session_id}` + +Display detailed logs for a specific workout session, grouped by exercise. + +- **Auth:** Admin session +- **Path params:** `session_id` (int) +- **Response:** Full page (`pages/session_detail.html`) +- **Template context:** `workout_session`, `logs_by_exercise`, `exercises_by_id`, `days_by_id`, `admin` + +--- + +## Dashboard + +All dashboard routes require admin authentication. + +### `GET /dashboard` + +Render the progress dashboard with summary stats, volume chart, and exercise links. + +- **Auth:** Admin session +- **Response:** Full page (`pages/dashboard.html`) +- **Template context:** `stats`, `volume_data_json`, `exercises`, `active_profile`, `admin` +- **Notes:** Stats and volume data require an active profile. Chart.js renders client-side charts from JSON embedded in the template. + +### `GET /dashboard/exercise/{exercise_id}` + +Per-exercise progress page with charts and progression suggestions. + +- **Auth:** Admin session +- **Path params:** `exercise_id` (int) +- **Response:** Full page (`pages/exercise_progress.html`) +- **Template context:** `exercise`, `progress_data_json`, `suggestion`, `active_profile`, `admin` + +--- + +## Schedule + +### `GET /schedule` + +4-week calendar view showing which workout day maps to which date. + +- **Auth:** Admin session +- **Response:** Full page (`pages/schedule.html`) +- **Template context:** `weeks`, `active_profile`, `admin` +- **Notes:** Calendar starts from Monday of the current week. Days with completed sessions are highlighted. Requires active profile for session completion data. + +--- + +## Authentication Details + +### Cookies + +| Cookie | Purpose | Flags | TTL | +|--------|---------|-------|-----| +| `session` | Admin session token (itsdangerous signed) | httponly, samesite=lax | 24 hours | +| `active_profile_id` | Currently selected user profile ID | httponly, samesite=lax | 24 hours | + +### Auth Failure Behavior + +- Missing or invalid session cookie → 302 redirect to `/login` +- Handled via `NotAuthenticatedError` exception + registered handler in `app/main.py` diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 3311b62..69410bb 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -1,6 +1,6 @@ -# Documentation Folder +# Documentation -This folder contains business planning, architecture decisions, and documentation. +This folder contains architecture, API reference, and development documentation. **No application code belongs here.** @@ -8,15 +8,16 @@ This folder contains business planning, architecture decisions, and documentatio | Document | Purpose | |----------|---------| -| `code_guidelines.md` | Code creation guidelines and Git strategy | -| `security.md` | Python focused security code suggestions | -| `roadmap.md` | 5-phase development roadmap | -| `plans/` | Design documents and implementation plans | - +| `architecture.md` | App architecture, layers, patterns, request lifecycle | +| `API_REFERENCE.md` | All 23 endpoints with params, auth, and response details | +| `database_schema.md` | All 8 tables, columns, relationships, migrations | +| `code_guidelines.md` | Coding standards and Git strategy | +| `security.md` | Python-focused security guidance | +| `roadmap.md` | Feature status and future plans | ## Guidelines - Keep documents focused and concise - Update docs when architecture decisions change - Use markdown tables for structured information -- Include links to external documentation where relevant \ No newline at end of file +- Include links to external documentation where relevant diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d35fa3e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,197 @@ +# 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 +``` diff --git a/docs/database_schema.md b/docs/database_schema.md new file mode 100644 index 0000000..8f7f9a8 --- /dev/null +++ b/docs/database_schema.md @@ -0,0 +1,202 @@ +# Database Schema + +SQLite database at `data/sneakyswole.db`, managed by SQLModel ORM with Alembic migrations. + +--- + +## Tables + +### `users` + +User profiles (admin and regular). Admin has login credentials; regular profiles are managed by the admin. + +| Column | Type | Constraints | Description | +|--------|------|------------|-------------| +| `id` | INTEGER | PK, auto-increment | | +| `username` | VARCHAR | UNIQUE, indexed | Login identifier | +| `password_hash` | VARCHAR | default="" | bcrypt hash (admin only) | +| `display_name` | VARCHAR | default="" | Name shown in UI | +| `height` | VARCHAR | nullable | e.g., "6'0\"" | +| `weight` | VARCHAR | nullable | e.g., "260 lbs" | +| `goals` | VARCHAR | nullable | Free-text training goals | +| `is_admin` | BOOLEAN | default=False | Admin privileges flag | +| `created_at` | DATETIME | auto | Record creation time | +| `updated_at` | DATETIME | auto | Last update time | + +**Model:** `app/models/user.py:User` + +--- + +### `exercises` + +Exercise library catalog. Each exercise belongs to a workout day. + +| Column | Type | Constraints | Description | +|--------|------|------------|-------------| +| `id` | INTEGER | PK, auto-increment | | +| `name` | VARCHAR | indexed | e.g., "DB Chest Press (Floor)" | +| `muscle_group` | VARCHAR | default="" | e.g., "Chest", "Shoulders" | +| `workout_day` | VARCHAR | indexed | "Push", "Pull", "Lower", "Full Body" | +| `sets` | INTEGER | default=3 | Default number of sets | +| `tempo` | VARCHAR | default="" | e.g., "3-1-2" (eccentric-pause-concentric) | +| `form_cues` | VARCHAR | default="" | Detailed form instructions | +| `created_at` | DATETIME | auto | Record creation time | + +**Model:** `app/models/exercise.py:Exercise` + +--- + +### `warmups` + +Standardized warmup routine displayed before every workout. + +| Column | Type | Constraints | Description | +|--------|------|------------|-------------| +| `id` | INTEGER | PK, auto-increment | | +| `name` | VARCHAR | indexed | e.g., "Cat / Cow" | +| `type` | VARCHAR | default="" | Category: "Thoracic Mob", "Hip Mobility", etc. | +| `reps` | VARCHAR | default="" | e.g., "8 reps", "8 each side" | +| `form_cues` | VARCHAR | default="" | Detailed form instructions | +| `sort_order` | INTEGER | default=0 | Display order in warmup sequence | +| `created_at` | DATETIME | auto | Record creation time | + +**Model:** `app/models/warmup.py:Warmup` + +--- + +### `workout_days` + +The 4-day training split definition. + +| Column | Type | Constraints | Description | +|--------|------|------------|-------------| +| `id` | INTEGER | PK, auto-increment | | +| `name` | VARCHAR | UNIQUE, indexed | "Push", "Pull", "Lower", "Full Body" | +| `day_number` | INTEGER | UNIQUE | Order in rotation (1-4) | +| `description` | VARCHAR | default="" | Brief focus description | + +**Model:** `app/models/workout_day.py:WorkoutDay` + +--- + +### `user_exercise_programs` + +Per-user programming targets linking users to exercises with week 1/4 goals. + +| Column | Type | Constraints | Description | +|--------|------|------------|-------------| +| `id` | INTEGER | PK, auto-increment | | +| `user_id` | INTEGER | FK → `users.id`, indexed | Profile this applies to | +| `exercise_id` | INTEGER | FK → `exercises.id`, indexed | Exercise being programmed | +| `wk1_reps` | VARCHAR | default="" | Week 1 target reps (e.g., "10", "30 sec") | +| `wk4_reps` | VARCHAR | default="" | Week 4 target reps | +| `wk1_weight` | VARCHAR | default="" | Week 1 target weight (e.g., "30 lbs", "BW") | +| `wk4_weight` | VARCHAR | default="" | Week 4 target weight | +| `created_at` | DATETIME | auto | Record creation time | +| `updated_at` | DATETIME | auto | Last update time | + +**Model:** `app/models/user_exercise_program.py:UserExerciseProgram` + +--- + +### `workout_sessions` + +A completed workout session — ties a user to a workout day on a date. + +| Column | Type | Constraints | Description | +|--------|------|------------|-------------| +| `id` | INTEGER | PK, auto-increment | | +| `user_id` | INTEGER | FK → `users.id`, indexed | Who did the workout | +| `workout_day_id` | INTEGER | FK → `workout_days.id` | Which day was trained | +| `date` | DATE | default=today | Date the workout was performed | +| `notes` | VARCHAR | nullable | Free-text session notes | +| `created_at` | DATETIME | auto | Record creation time | + +**Model:** `app/models/workout_session.py:WorkoutSession` + +--- + +### `workout_logs` + +Individual set logs within a session. Each row = one set of one exercise. + +| Column | Type | Constraints | Description | +|--------|------|------------|-------------| +| `id` | INTEGER | PK, auto-increment | | +| `session_id` | INTEGER | FK → `workout_sessions.id`, indexed | Parent session | +| `exercise_id` | INTEGER | FK → `exercises.id` | Which exercise | +| `set_number` | INTEGER | default=1 | Set number (1, 2, 3...) | +| `reps_completed` | INTEGER | default=0 | Actual reps performed | +| `weight_used` | VARCHAR | default="" | Weight used (e.g., "30 lbs", "BW") | +| `felt_easy` | BOOLEAN | default=False | Progression signal | +| `notes` | VARCHAR | nullable | Per-set notes | +| `created_at` | DATETIME | auto | Record creation time | + +**Model:** `app/models/workout_log.py:WorkoutLog` + +--- + +### `progress_log` + +Progression tracking — what the engine suggested vs what the user actually did. + +| Column | Type | Constraints | Description | +|--------|------|------------|-------------| +| `id` | INTEGER | PK, auto-increment | | +| `user_id` | INTEGER | FK → `users.id`, indexed | Profile being tracked | +| `exercise_id` | INTEGER | FK → `exercises.id` | Exercise being tracked | +| `date` | DATE | default=today | Date of progression entry | +| `suggested_reps` | INTEGER | nullable | Engine recommendation | +| `suggested_weight` | VARCHAR | nullable | Engine recommendation | +| `actual_reps` | INTEGER | nullable | What user actually did | +| `actual_weight` | VARCHAR | nullable | What user actually used | +| `progression_applied` | VARCHAR | nullable | Type: "reps_increase", "weight_increase", "deload" | +| `created_at` | DATETIME | auto | Record creation time | + +**Model:** `app/models/progress_log.py:ProgressLog` + +--- + +## Relationships + +``` +users + ├── user_exercise_programs (1:N via user_id) + ├── workout_sessions (1:N via user_id) + └── progress_log (1:N via user_id) + +exercises + ├── user_exercise_programs (1:N via exercise_id) + ├── workout_logs (1:N via exercise_id) + └── progress_log (1:N via exercise_id) + +workout_days + └── workout_sessions (1:N via workout_day_id) + +workout_sessions + └── workout_logs (1:N via session_id) +``` + +--- + +## Migrations + +Managed by Alembic. Config at `alembic.ini`, migration scripts at `alembic/versions/`. + +- **Initial migration:** `1855836abf6c_initial_schema_8_tables.py` — creates all 8 tables + +To create a new migration: +```bash +alembic revision --autogenerate -m "description" +alembic upgrade head +``` + +--- + +## Seeding + +On first startup, `SeedService.seed_all()` reads: +- `config/exercises.yaml` — exercise catalog + warmups + workout days +- `config/user_programs.yaml` — per-user week 1/4 targets + +Admin user is created from `ADMIN_USERNAME` / `ADMIN_PASSWORD` env vars with bcrypt hash. Seeding is skipped if data already exists. diff --git a/docs/plans/2026-02-23-roadmap-and-data-design.md b/docs/plans/2026-02-23-roadmap-and-data-design.md deleted file mode 100644 index 3e0b201..0000000 --- a/docs/plans/2026-02-23-roadmap-and-data-design.md +++ /dev/null @@ -1,76 +0,0 @@ -# Design: Roadmap, Exercise Data, and Auth Model - -**Date:** 2026-02-23 -**Status:** Approved - -## Overview - -This document captures the design decisions for the SneakySwole roadmap structure, exercise data management, and authentication model. - -## Roadmap Structure - -Five phases, each producing a testable milestone: - -1. **Scaffold & Infrastructure** — project structure, Docker, base template, logging -2. **Data Layer & Seeding** — SQLite schema, Alembic migrations, YAML-based exercise/program seeding -3. **Workout UI** — admin auth, profile management, workout viewer, exercise browser -4. **Logging & Tracking** — per-exercise logging, session tracking, log history -5. **Progression & Analytics** — auto-progression, schedule view, progress dashboard - -Full details in `docs/roadmap.md`. - -## Exercise Data Design - -### Separated YAML Files - -Exercise data is split into two config files for clean separation of concerns: - -- **`config/exercises.yaml`** — Universal exercise library - - Exercise name, muscle group, workout day, sets, tempo, form cues - - Warmup exercises (name, type, reps/time, form cues) - - Rarely changes; defines the available exercise catalog - -- **`config/user_programs.yaml`** — Per-user programming - - References exercises by name - - Week 1 and week 4 reps and weights per user per exercise - - Changes when users adjust their programming or new users are added - -### Seeding Flow - -1. On first run (empty DB), the seed script reads both YAML files -2. Exercises and warmups are inserted into their respective tables -3. User profiles are created (admin from `.env`, others from user_programs.yaml) -4. User-specific programming (reps/weights) is linked to exercises and users -5. Subsequent runs skip seeding if data already exists - -### Why YAML Over Spreadsheet - -- Human-readable and version-controllable -- Easy to update without special tools -- Can be validated with schema checks -- Spreadsheet remains source of truth for initial data extraction only - -## Authentication Model - -### Phase 1: Simple Admin Auth - -- Single admin user with credentials stored in `.env` (`ADMIN_USERNAME`, `ADMIN_PASSWORD`) -- On first run, admin user is created in DB with bcrypt-hashed password -- Admin logs in via a simple login form (session-based auth) -- Admin creates user profiles through the UI (no login for profiles) -- Profile switcher in navigation allows admin to select the active profile -- All workout logging happens under the selected profile - -### Security Considerations - -- Passwords hashed with bcrypt (never stored plaintext) -- `.env` file is gitignored and never committed -- `.env.example` documents required variables without real values -- Session tokens with reasonable expiry -- Admin-only routes protected by auth middleware - -### Future Upgrade Path - -- Phase 1 auth is designed to be replaceable -- If multi-user login is needed later, add login credentials to the user profiles table -- Profile switcher becomes unnecessary when each user logs in independently diff --git a/docs/roadmap.md b/docs/roadmap.md index bc5cd00..3fb78c0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,85 +1,30 @@ # SneakySwole Roadmap -## Phase 1: Scaffold & Infrastructure +## Completed -Set up the project foundation — everything needed before writing features. +### Phase 1: Scaffold & Infrastructure +FastAPI project structure, Dockerfile + docker-compose.yaml, Pico CSS dark theme base template, `.env` config, structlog logging, health check endpoint. -- FastAPI project structure (`app/` with routes, services, models, templates, static, utils) -- Dockerfile + docker-compose.yaml (SQLite volume, port 8000, `.env` support) -- Pico CSS dark theme base template (Jinja2 with `data-theme="dark"`) -- `.env` / `.env.example` with admin credentials (`ADMIN_USERNAME`, `ADMIN_PASSWORD`) -- `uv` for dependency management, `requirements.txt` with pinned versions -- Structlog logging setup -- Basic health check route (`/health`) +### Phase 2: Data Layer & Seeding +8-table SQLite schema via SQLModel + Alembic migrations. YAML-driven seed script (`config/exercises.yaml`, `config/user_programs.yaml`). Service layer for all DB access. Admin user auto-created from `.env` with bcrypt. -## Phase 2: Data Layer & Seeding +### Phase 3: Workout UI +Admin login (bcrypt + signed session cookies), profile switcher, workout day viewer with warmups and exercise cards, HTMX-powered exercise browser with search/filter. NavContextMiddleware for automatic template context. -Build the database schema and seed it from YAML config files. +### Phase 4: Logging & Tracking +Inline set logging from the workout day view via HTMX. Auto-created workout sessions. Log history with per-session detail view. Edit and delete log entries. -- SQLite schema via Alembic migrations - - Tables: users, exercises, warmups, workout_days, user_exercise_programs, sets, progress_log -- `config/exercises.yaml` — exercise library (name, muscle group, workout day, sets, tempo, form cues) -- `config/user_programs.yaml` — per-user week 1/4 reps and weights for each exercise -- Seed script that loads YAML into DB on first run -- Admin user auto-created from `.env` credentials on startup (password hashed with bcrypt) -- Two initial user profiles seeded: Phillip and Daughter -- Service layer for all DB access (no direct queries from routes) +### Phase 5: Progression & Analytics +Auto-progression engine (+reps/+weight/deload rules), 4-week schedule calendar, progress dashboard with Chart.js charts, per-exercise progress pages with suggestions. -### Phase 3: Workout UI ✅ +--- -**Completed:** 2026-02-24 +## Future Ideas -**Summary:** Built the core user-facing experience — admin login with bcrypt + signed session cookies, profile switcher, workout day viewer with warmups and exercise cards, and HTMX-powered exercise browser with search/filter. Added Jinja2 context processor middleware for automatic nav context injection. - -**Key files:** -- `app/services/auth_service.py` — AuthService (bcrypt auth + itsdangerous session tokens) -- `app/utils/auth.py` — `get_current_admin_user` (303 redirect to /login), `get_active_profile_id` -- `app/routes/auth.py` — login/logout routes -- `app/routes/profiles.py` — profile list, switch, edit routes -- `app/routes/workouts.py` — workout day list + detail viewer -- `app/routes/exercises.py` — exercise browser with HTMX search -- `app/templates/partials/nav.html` — profile switcher dropdown (reads from request.state) -- `app/main.py` — NavContextMiddleware, secret_key, all routers registered - -**Endpoints created:** -- `GET /login` — render login form -- `POST /login` — authenticate and set session cookie -- `GET /logout` — clear session, redirect to /login -- `GET /profiles` — list user profiles -- `POST /profiles/switch` — set active profile cookie -- `GET /profiles/{id}/edit` — profile edit form -- `POST /profiles/{id}/edit` — update profile -- `GET /workouts` — workout day cards -- `GET /workouts/{day_name}` — warmups + exercises + programming targets -- `GET /exercises` — exercise browser with filter dropdowns -- `GET /exercises/search` — HTMX partial for filtered exercise list - -**Key details:** -- Auth uses 303 redirect to /login (not 401) for browser UX -- Nav context injected via `NavContextMiddleware` into `request.state` (admin, profiles, active_profile) -- Session cookie: httponly=True, samesite="lax", max_age=86400 (24h) -- `itsdangerous>=2.2.0` added to requirements.txt -- `python-multipart>=0.0.20` added for form data parsing -- 66 tests pass (12 new Phase 3 tests + 54 existing) - -## Phase 4: Logging & Tracking - -Enable workout logging so users can track what they actually did. - -- Per-exercise logging: sets completed, reps, weight used, "felt easy?" toggle -- Workout session model (date, user profile, workout day) -- HTMX inline logging — log directly from the workout day view -- Log history view per user profile -- Edit/delete past log entries - -## Phase 5: Progression & Analytics - -Smart suggestions and visual progress tracking. - -- Auto-progression engine based on log history - - +1-2 reps/week, +5 lbs every 2 weeks - - Deload detection at week 5 (-20% weight) -- 4-week schedule view (calendar-style, shows which day maps to which date) -- Progress dashboard per user profile -- Per-exercise progress history and trends -- Summary stats (total volume, streak tracking) +- Multi-user login (replace profile switcher with individual logins) +- REST API for mobile clients +- Exercise video/image attachments +- Custom workout program builder +- Export/import workout data (CSV/JSON) +- Notifications/reminders +- Social features (sharing workouts) diff --git a/workout_plan_v2.xlsx b/workout_plan_v2.xlsx deleted file mode 100644 index 2bfa387ec912276f92587782bbb961ae59c32d99..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25859 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