commit 43078009cb7fd1724ccdc044a084a7be5dd9bc4a Author: Jacob Hinkle Date: Mon Jun 29 10:01:22 2026 -0400 Initial commit: FastAPI fitness web app with SQLite, auth, templates, and opencode AI coach integration diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7dd7807 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +data/*.db +data/*.db-journal +data/*.db-wal +data/*.db-shm +__pycache__/ +*.pyc +.venv/ +.env +*.egg-info/ +dist/ +build/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..650b08c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# Fitness Web Agent Notes + +## Project Overview +Multi-user fitness tracking web app with AI coaching. +Replaces the fitness-agent markdown-based training repo. + +## Related Repo +- `~/git/fitness-agent` — Training markdown logs, agent config, historical data, Juggernaut cycles +- This repo (`fitness-web`) is the web app successor. When this is fully operational, the old repo can be archived. + +## Architecture +- **FastAPI** — async Python web framework +- **Jinja2 + Pico.css** — server-rendered templates with minimal CSS framework (CDN) +- **SQLite + SQLAlchemy 2.0 async** — database with aiosqlite driver +- **opencode serve** — AI coach service (Big Pickle model, free). Runs as a separate process/container. + +## Key Files +- `app/main.py` — App factory, route registration, lifespan +- `app/config.py` — Environment-based configuration +- `app/auth.py` — Password hashing, session management, `get_current_user` dependency +- `app/models/` — SQLAlchemy ORM models +- `app/routers/` — Route handlers (one per feature) +- `scripts/schema.py` — DB initialization +- `scripts/seed.py` — Seed data (exercises, phases) +- `opencode/fitness-trainer.md` — Agent config for AI coach (copied from fitness-agent) + +## Commands +```bash +uv sync # Install deps +uv run python scripts/schema.py # Create DB tables +uv run python scripts/seed.py # Seed initial data +uv run uvicorn app.main:app --reload # Dev server on :8000 +``` + +## Key Decisions +- Session-based auth with DB-backed tokens (simple, no OAuth dependencies) +- SQLAlchemy async with aiosqlite (works well with FastAPI async handlers) +- SSE streaming for chat responses +- Pico.css from CDN (no build step) +- Chat messages stored in DB per session for history +- Docker Compose for deployment (opencode serve service commented out until ready) + +## Next Steps / TODOs +1. Seed exercises and phases (done via `python scripts/seed.py`) +2. Add exercise progress chart (matplotlib or chart.js) +3. Enable opencode serve integration (uncomment docker-compose service) +4. Migrate existing markdown logs from fitness-agent repo into DB +5. Migrate Juggernaut training xlsx data into DB +6. Add calendar view for training history +7. PWA manifest + service worker for offline-capable mobile use diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..385ccd3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..29be9f9 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Fitness Web + +Multi-user fitness tracking web app with AI coaching. + +Track workouts, log daily check-ins, explore exercise history, and chat with an AI coach powered by opencode. + +## Features + +- **Workouts** — Plan and log workouts with set-level detail (reps, weight, RPE) +- **Exercises** — Catalog with body-part filtering and history +- **Check-ins** — Daily weight, calories, steps, sleep tracking +- **AI Coach** — Chat interface backed by opencode (Big Pickle model, free) +- **Multi-user** — Login-based, each user has independent data +- **Calendar view** — See your training history at a glance + +## Quick Start + +```bash +# Install dependencies +uv sync + +# Initialize and seed the database +uv run python scripts/schema.py +uv run python scripts/seed.py + +# Start the dev server +uv run uvicorn app.main:app --reload +``` + +Open http://localhost:8000, register a user, and you're ready. + +## Docker + +```bash +docker compose up -d +``` + +## Architecture + +``` +app/ +├── main.py — FastAPI app factory with lifespan +├── config.py — Settings from environment +├── auth.py — Auth helpers (hash, verify, session) +├── models/ — SQLAlchemy ORM models +├── routers/ — Route handlers per feature +├── services/ — External service integrations (opencode) +├── templates/ — Jinja2 templates (Pico.css) +└── static/ — CSS overrides +scripts/ +├── schema.py — DB table creation +└── seed.py — Seed exercises and phases +data/ — SQLite database (gitignored) +``` + +## Related + +- [fitness-agent](https://github.com/yourname/fitness-agent) — Original training repo with markdown logs and Juggernaut history. This web app replaces it. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..8a0303d --- /dev/null +++ b/app/auth.py @@ -0,0 +1,71 @@ +import hashlib +import secrets +from datetime import datetime, timezone +from fastapi import Request, HTTPException, status +from fastapi.responses import RedirectResponse +from sqlalchemy import select + +from app.models.base import async_session +from app.models.user import User +from app.models.auth import Session + + +def hash_password(password: str) -> str: + salt = secrets.token_hex(16) + h = hashlib.sha256((salt + password).encode()).hexdigest() + return f"{salt}:{h}" + + +def verify_password(password: str, hashed: str) -> bool: + salt, h = hashed.split(":", 1) + return h == hashlib.sha256((salt + password).encode()).hexdigest() + + +async def create_session(user_id: int) -> str: + token = secrets.token_hex(32) + async with async_session() as session: + db_session = Session( + user_id=user_id, + token=token, + created_at=datetime.now(timezone.utc).isoformat(), + ) + session.add(db_session) + await session.commit() + return token + + +async def get_current_user(request: Request): + token = request.cookies.get("session_token") + if not token: + raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"}) + async with async_session() as session: + result = await session.execute( + select(Session).where(Session.token == token) + ) + db_session = result.scalar_one_or_none() + if not db_session: + raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"}) + result = await session.execute( + select(User).where(User.id == db_session.user_id) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"}) + return user + + +async def optional_user(request: Request): + token = request.cookies.get("session_token") + if not token: + return None + async with async_session() as session: + result = await session.execute( + select(Session).where(Session.token == token) + ) + db_session = result.scalar_one_or_none() + if not db_session: + return None + result = await session.execute( + select(User).where(User.id == db_session.user_id) + ) + return result.scalar_one_or_none() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..f352e26 --- /dev/null +++ b/app/config.py @@ -0,0 +1,8 @@ +import os +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = Path(os.environ.get("DATA_DIR", PROJECT_ROOT / "data")) +DATABASE_PATH = str(DATA_DIR / "fitness.db") +SESSION_SECRET = os.environ.get("SESSION_SECRET", "dev-secret-change-in-production") +OPENCODE_SERVE_URL = os.environ.get("OPENCODE_SERVE_URL", "http://localhost:4096") diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..53eb072 --- /dev/null +++ b/app/main.py @@ -0,0 +1,29 @@ +from pathlib import Path +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager + +from app.config import DATA_DIR +from app.routers import auth, dashboard, workouts, exercises, checkins, profile, chat +from scripts.schema import init_db + + +@asynccontextmanager +async def lifespan(app: FastAPI): + DATA_DIR.mkdir(parents=True, exist_ok=True) + await init_db() + yield + + +app = FastAPI(title="Fitness Web", lifespan=lifespan) + +static_dir = Path(__file__).parent / "static" +app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + +app.include_router(auth.router) +app.include_router(dashboard.router) +app.include_router(workouts.router) +app.include_router(exercises.router) +app.include_router(checkins.router) +app.include_router(profile.router) +app.include_router(chat.router) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..51a3a35 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,7 @@ +from app.models.base import Base, engine, async_session +from app.models.user import User +from app.models.auth import Session +from app.models.workout import Phase, Workout, WorkoutSet +from app.models.exercise import Exercise +from app.models.checkin import Checkin +from app.models.chat import ChatMessage diff --git a/app/models/auth.py b/app/models/auth.py new file mode 100644 index 0000000..344754a --- /dev/null +++ b/app/models/auth.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, String, ForeignKey + +from app.models.base import Base + + +class Session(Base): + __tablename__ = "sessions" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + token = Column(String(64), unique=True, nullable=False) + created_at = Column(String(20)) diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..b5c16a1 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,13 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +from app.config import DATABASE_PATH + +DATABASE_URL = f"sqlite+aiosqlite:///{DATABASE_PATH}" + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass diff --git a/app/models/chat.py b/app/models/chat.py new file mode 100644 index 0000000..ffdbb77 --- /dev/null +++ b/app/models/chat.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, String, Text, ForeignKey + +from app.models.base import Base + + +class ChatMessage(Base): + __tablename__ = "chat_messages" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + session_id = Column(String(100)) + role = Column(String(20), nullable=False) # user, assistant + content = Column(Text, nullable=False) + created_at = Column(String(20)) diff --git a/app/models/checkin.py b/app/models/checkin.py new file mode 100644 index 0000000..2ec2a0b --- /dev/null +++ b/app/models/checkin.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, String, Float, Text, ForeignKey + +from app.models.base import Base + + +class Checkin(Base): + __tablename__ = "checkins" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + date = Column(String(20), nullable=False) + feeling = Column(String(20)) + weight_lb = Column(Float) + calories = Column(Integer) + steps = Column(Integer) + sleep_hours = Column(Float) + notes = Column(Text) diff --git a/app/models/exercise.py b/app/models/exercise.py new file mode 100644 index 0000000..cab9253 --- /dev/null +++ b/app/models/exercise.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, String, Text + +from app.models.base import Base + + +class Exercise(Base): + __tablename__ = "exercises" + + id = Column(Integer, primary_key=True) + name = Column(String(100), unique=True, nullable=False) + body_part = Column(String(50)) + equipment = Column(String(100)) + description = Column(Text) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..74ac841 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String, Float, Text + +from app.models.base import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + username = Column(String(50), unique=True, nullable=False) + password_hash = Column(String(255), nullable=False) + display_name = Column(String(100)) + weight_lb = Column(Float) + calorie_goal = Column(Integer) + step_goal = Column(Integer) + medical_notes = Column(Text) + goals = Column(Text) + equipment = Column(Text) + created_at = Column(String(20)) diff --git a/app/models/workout.py b/app/models/workout.py new file mode 100644 index 0000000..78cfdd0 --- /dev/null +++ b/app/models/workout.py @@ -0,0 +1,39 @@ +from sqlalchemy import Column, Integer, String, Float, Text, ForeignKey + +from app.models.base import Base + + +class Phase(Base): + __tablename__ = "phases" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + description = Column(Text) + start_date = Column(String(20)) + end_date = Column(String(20)) + notes = Column(Text) + + +class Workout(Base): + __tablename__ = "workouts" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + phase_id = Column(Integer, ForeignKey("phases.id")) + name = Column(String(200), nullable=False) + date = Column(String(20), nullable=False) + notes = Column(Text) + status = Column(String(20), default="plan") # plan, complete + + +class WorkoutSet(Base): + __tablename__ = "workout_sets" + + id = Column(Integer, primary_key=True) + workout_id = Column(Integer, ForeignKey("workouts.id"), nullable=False) + exercise = Column(String(100), nullable=False) + set_number = Column(Integer) + reps = Column(Integer) + weight = Column(Float) + rpe = Column(Float) + notes = Column(Text) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..c2a059e --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,76 @@ +from datetime import datetime, timezone +from fastapi import APIRouter, Request, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select + +from app.models.base import async_session +from app.models.user import User +from app.auth import hash_password, verify_password, create_session + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + return templates.TemplateResponse("login.html", {"request": request}) + + +@router.post("/login") +async def login(request: Request, username: str = Form(), password: str = Form()): + async with async_session() as session: + result = await session.execute(select(User).where(User.username == username)) + user = result.scalar_one_or_none() + if not user or not verify_password(password, user.password_hash): + return templates.TemplateResponse( + "login.html", + {"request": request, "error": "Invalid username or password"}, + ) + token = await create_session(user.id) + + resp = RedirectResponse(url="/dashboard", status_code=303) + resp.set_cookie(key="session_token", value=token, httponly=True, max_age=86400 * 30) + return resp + + +@router.get("/register", response_class=HTMLResponse) +async def register_page(request: Request): + return templates.TemplateResponse("register.html", {"request": request}) + + +@router.post("/register") +async def register( + request: Request, + username: str = Form(), + password: str = Form(), + display_name: str = Form(default=""), +): + async with async_session() as session: + result = await session.execute(select(User).where(User.username == username)) + if result.scalar_one_or_none(): + return templates.TemplateResponse( + "register.html", + {"request": request, "error": "Username already taken"}, + ) + user = User( + username=username, + password_hash=hash_password(password), + display_name=display_name or username, + created_at=datetime.now(timezone.utc).isoformat(), + ) + session.add(user) + await session.commit() + await session.refresh(user) + token = await create_session(user.id) + + resp = RedirectResponse(url="/dashboard", status_code=303) + resp.set_cookie(key="session_token", value=token, httponly=True, max_age=86400 * 30) + return resp + + +@router.get("/logout") +async def logout(): + resp = RedirectResponse(url="/login", status_code=303) + resp.delete_cookie("session_token") + return resp diff --git a/app/routers/chat.py b/app/routers/chat.py new file mode 100644 index 0000000..6be4186 --- /dev/null +++ b/app/routers/chat.py @@ -0,0 +1,89 @@ +from datetime import datetime, timezone +import uuid +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select, desc + +from app.models.base import async_session +from app.models.user import User +from app.models.chat import ChatMessage +from app.auth import get_current_user +from app.services.opencode_proxy import query_opencode + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/chat", response_class=HTMLResponse) +async def chat_page(request: Request, user: User = Depends(get_current_user)): + session_id = request.cookies.get("chat_session_id") + if not session_id: + session_id = str(uuid.uuid4()) + + async with async_session() as session: + result = await session.execute( + select(ChatMessage) + .where( + ChatMessage.user_id == user.id, + ChatMessage.session_id == session_id, + ) + .order_by(ChatMessage.created_at) + ) + messages = result.scalars().all() + + resp = templates.TemplateResponse("chat.html", { + "request": request, + "user": user, + "messages": messages, + "session_id": session_id, + }) + resp.set_cookie(key="chat_session_id", value=session_id, httponly=True, max_age=86400 * 30) + return resp + + +@router.post("/chat") +async def chat_send( + request: Request, + user: User = Depends(get_current_user), + message: str = Form(), +): + session_id = request.cookies.get("chat_session_id") or str(uuid.uuid4()) + user_context = ( + f"Username: {user.username}. " + f"Weight: {user.weight_lb} lb. " + f"Goals: {user.goals or 'Not specified'}. " + f"Medical: {user.medical_notes or 'None'}" + ) + + async def stream(): + async with async_session() as session: + user_msg = ChatMessage( + user_id=user.id, + session_id=session_id, + role="user", + content=message, + created_at=datetime.now(timezone.utc).isoformat(), + ) + session.add(user_msg) + await session.commit() + + assistant_content = "" + async for chunk in query_opencode(message, session_id, user_context): + assistant_content += chunk + yield f"data: {chunk}\n\n" + + async with async_session() as session: + assistant_msg = ChatMessage( + user_id=user.id, + session_id=session_id, + role="assistant", + content=assistant_content, + created_at=datetime.now(timezone.utc).isoformat(), + ) + session.add(assistant_msg) + await session.commit() + + resp = StreamingResponse(stream(), media_type="text/event-stream") + resp.set_cookie(key="chat_session_id", value=session_id, httponly=True, max_age=86400 * 30) + return resp diff --git a/app/routers/checkins.py b/app/routers/checkins.py new file mode 100644 index 0000000..e4746e9 --- /dev/null +++ b/app/routers/checkins.py @@ -0,0 +1,66 @@ +from datetime import datetime, timezone +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select, desc + +from app.models.base import async_session +from app.models.user import User +from app.models.checkin import Checkin +from app.auth import get_current_user + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/checkins", response_class=HTMLResponse) +async def checkin_list(request: Request, user: User = Depends(get_current_user)): + async with async_session() as session: + result = await session.execute( + select(Checkin) + .where(Checkin.user_id == user.id) + .order_by(desc(Checkin.date)) + ) + checkins = result.scalars().all() + return templates.TemplateResponse("checkins.html", { + "request": request, + "user": user, + "checkins": checkins, + }) + + +@router.get("/checkins/new", response_class=HTMLResponse) +async def new_checkin_page(request: Request, user: User = Depends(get_current_user)): + return templates.TemplateResponse("checkin_new.html", { + "request": request, + "user": user, + }) + + +@router.post("/checkins/new") +async def new_checkin( + request: Request, + user: User = Depends(get_current_user), + date: str = Form(), + feeling: str = Form(default=""), + weight_lb: float = Form(default=None), + calories: int = Form(default=None), + steps: int = Form(default=None), + sleep_hours: float = Form(default=None), + notes: str = Form(default=""), +): + async with async_session() as session: + checkin = Checkin( + user_id=user.id, + date=date, + feeling=feeling, + weight_lb=weight_lb, + calories=calories, + steps=steps, + sleep_hours=sleep_hours, + notes=notes, + ) + session.add(checkin) + await session.commit() + + return RedirectResponse(url="/checkins", status_code=303) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py new file mode 100644 index 0000000..cc833a8 --- /dev/null +++ b/app/routers/dashboard.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Request, Depends +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select, desc + +from app.models.base import async_session +from app.models.user import User +from app.models.workout import Workout +from app.models.checkin import Checkin +from app.auth import get_current_user + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/", response_class=HTMLResponse) +async def root_redirect(): + from fastapi.responses import RedirectResponse + return RedirectResponse(url="/dashboard") + + +@router.get("/dashboard", response_class=HTMLResponse) +async def dashboard(request: Request, user: User = Depends(get_current_user)): + async with async_session() as session: + result = await session.execute( + select(Workout) + .where(Workout.user_id == user.id) + .order_by(desc(Workout.date)) + .limit(5) + ) + recent_workouts = result.scalars().all() + + result = await session.execute( + select(Checkin) + .where(Checkin.user_id == user.id) + .order_by(desc(Checkin.date)) + .limit(1) + ) + latest_checkin = result.scalar_one_or_none() + + return templates.TemplateResponse("dashboard.html", { + "request": request, + "user": user, + "recent_workouts": recent_workouts, + "latest_checkin": latest_checkin, + }) diff --git a/app/routers/exercises.py b/app/routers/exercises.py new file mode 100644 index 0000000..9298011 --- /dev/null +++ b/app/routers/exercises.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Request, Depends +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select + +from app.models.base import async_session +from app.models.user import User +from app.models.exercise import Exercise +from app.auth import get_current_user + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/exercises", response_class=HTMLResponse) +async def exercise_list( + request: Request, + user: User = Depends(get_current_user), + body_part: str = None, +): + async with async_session() as session: + query = select(Exercise).order_by(Exercise.name) + if body_part: + query = query.where(Exercise.body_part == body_part) + result = await session.execute(query) + exercises = result.scalars().all() + + result = await session.execute( + select(Exercise.body_part).distinct().order_by(Exercise.body_part) + ) + body_parts = [r[0] for r in result.all() if r[0]] + + return templates.TemplateResponse("exercises.html", { + "request": request, + "user": user, + "exercises": exercises, + "body_parts": body_parts, + "selected_body_part": body_part, + }) diff --git a/app/routers/profile.py b/app/routers/profile.py new file mode 100644 index 0000000..9efd4be --- /dev/null +++ b/app/routers/profile.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from app.models.base import async_session +from app.models.user import User +from app.auth import get_current_user + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/profile", response_class=HTMLResponse) +async def profile_page(request: Request, user: User = Depends(get_current_user)): + return templates.TemplateResponse("profile.html", { + "request": request, + "user": user, + }) + + +@router.post("/profile") +async def update_profile( + request: Request, + user: User = Depends(get_current_user), + display_name: str = Form(default=None), + weight_lb: float = Form(default=None), + calorie_goal: int = Form(default=None), + step_goal: int = Form(default=None), + medical_notes: str = Form(default=""), + goals: str = Form(default=""), + equipment: str = Form(default=""), +): + async with async_session() as session: + session.add(user) + if display_name is not None: + user.display_name = display_name + if weight_lb is not None: + user.weight_lb = weight_lb + if calorie_goal is not None: + user.calorie_goal = calorie_goal + if step_goal is not None: + user.step_goal = step_goal + user.medical_notes = medical_notes + user.goals = goals + user.equipment = equipment + await session.commit() + + return RedirectResponse(url="/profile", status_code=303) diff --git a/app/routers/workouts.py b/app/routers/workouts.py new file mode 100644 index 0000000..ffbb0ea --- /dev/null +++ b/app/routers/workouts.py @@ -0,0 +1,157 @@ +from datetime import datetime, timezone +from fastapi import APIRouter, Request, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select, desc + +from app.models.base import async_session +from app.models.user import User +from app.models.workout import Phase, Workout, WorkoutSet +from app.auth import get_current_user + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/workouts", response_class=HTMLResponse) +async def workout_list(request: Request, user: User = Depends(get_current_user)): + async with async_session() as session: + result = await session.execute( + select(Workout) + .where(Workout.user_id == user.id) + .order_by(desc(Workout.date)) + ) + workouts = result.scalars().all() + return templates.TemplateResponse("workouts.html", { + "request": request, + "user": user, + "workouts": workouts, + }) + + +@router.get("/workouts/new", response_class=HTMLResponse) +async def new_workout_page(request: Request, user: User = Depends(get_current_user)): + async with async_session() as session: + result = await session.execute(select(Phase)) + phases = result.scalars().all() + return templates.TemplateResponse("workout_new.html", { + "request": request, + "user": user, + "phases": phases, + }) + + +@router.post("/workouts/new") +async def new_workout( + request: Request, + user: User = Depends(get_current_user), + name: str = Form(), + date: str = Form(), + phase_id: int = Form(default=None), + notes: str = Form(default=""), +): + async with async_session() as session: + workout = Workout( + user_id=user.id, + phase_id=phase_id, + name=name, + date=date, + notes=notes, + status="plan", + ) + session.add(workout) + await session.commit() + await session.refresh(workout) + workout_id = workout.id + + return RedirectResponse(url=f"/workouts/{workout_id}", status_code=303) + + +@router.get("/workouts/{workout_id}", response_class=HTMLResponse) +async def workout_detail( + request: Request, + workout_id: int, + user: User = Depends(get_current_user), +): + async with async_session() as session: + result = await session.execute( + select(Workout).where( + Workout.id == workout_id, + Workout.user_id == user.id, + ) + ) + workout = result.scalar_one_or_none() + if not workout: + return templates.TemplateResponse("404.html", {"request": request}, status_code=404) + + result = await session.execute( + select(WorkoutSet) + .where(WorkoutSet.workout_id == workout_id) + .order_by(WorkoutSet.exercise, WorkoutSet.set_number) + ) + sets = result.scalars().all() + + return templates.TemplateResponse("workout_detail.html", { + "request": request, + "user": user, + "workout": workout, + "sets": sets, + }) + + +@router.post("/workouts/{workout_id}/add-set") +async def add_set( + request: Request, + workout_id: int, + user: User = Depends(get_current_user), + exercise: str = Form(), + set_number: int = Form(), + reps: int = Form(default=None), + weight: float = Form(default=None), + rpe: float = Form(default=None), + notes: str = Form(default=""), +): + async with async_session() as session: + result = await session.execute( + select(Workout).where( + Workout.id == workout_id, + Workout.user_id == user.id, + ) + ) + workout = result.scalar_one_or_none() + if not workout: + return RedirectResponse(url="/workouts", status_code=303) + + ws = WorkoutSet( + workout_id=workout_id, + exercise=exercise, + set_number=set_number, + reps=reps, + weight=weight, + rpe=rpe, + notes=notes, + ) + session.add(ws) + await session.commit() + + return RedirectResponse(url=f"/workouts/{workout_id}", status_code=303) + + +@router.post("/workouts/{workout_id}/complete") +async def complete_workout( + workout_id: int, + user: User = Depends(get_current_user), +): + async with async_session() as session: + result = await session.execute( + select(Workout).where( + Workout.id == workout_id, + Workout.user_id == user.id, + ) + ) + workout = result.scalar_one_or_none() + if workout: + workout.status = "complete" + await session.commit() + + return RedirectResponse(url=f"/workouts/{workout_id}", status_code=303) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/opencode_proxy.py b/app/services/opencode_proxy.py new file mode 100644 index 0000000..c800985 --- /dev/null +++ b/app/services/opencode_proxy.py @@ -0,0 +1,42 @@ +import asyncio +import json +from typing import AsyncGenerator +from app.config import OPENCODE_SERVE_URL + + +async def query_opencode( + message: str, + session_id: str, + user_context: str = "", +) -> AsyncGenerator[str, None]: + prompt = message + if user_context: + prompt = f"[User context: {user_context}]\n\n{message}" + + try: + proc = await asyncio.create_subprocess_exec( + "opencode", "run", "--attach", OPENCODE_SERVE_URL, + "--format", "json", + prompt, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + while True: + line = await proc.stdout.readline() + if not line: + break + line = line.decode().strip() + if line: + try: + data = json.loads(line) + content = data.get("content", data.get("text", line)) + yield content + except json.JSONDecodeError: + yield line + + await proc.wait() + except FileNotFoundError: + yield "AI coach is not available. Install opencode and run `opencode serve` to enable." + except Exception as e: + yield f"Error connecting to AI coach: {e}" diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..ced289e --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,48 @@ +.set-row input { + width: 60px; + display: inline-block; + margin-right: 0.5rem; +} + +.set-row label { + display: inline; + margin-right: 0.25rem; +} + +.set-group { + margin-bottom: 1.5rem; +} + +.chat-messages { + max-height: 60vh; + overflow-y: auto; + margin-bottom: 1rem; +} + +.chat-message { + margin-bottom: 1rem; + padding: 0.75rem; + border-radius: 0.5rem; +} + +.chat-message.user { + background: var(--card-background-color); + text-align: right; +} + +.chat-message.assistant { + background: var(--card-sectionning-background-color); +} + +.chat-input { + display: flex; + gap: 0.5rem; +} + +.chat-input input { + flex: 1; +} + +.nav-link.active { + font-weight: bold; +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..8be2cf8 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,29 @@ + + + + + + {% block title %}Fitness{% endblock %} + + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/app/templates/chat.html b/app/templates/chat.html new file mode 100644 index 0000000..ea32f37 --- /dev/null +++ b/app/templates/chat.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}AI Coach{% endblock %} +{% block content %} +

AI Coach

+ +
+ {% for m in messages %} +
+ {{ m.role }} · {{ m.created_at[:10] if m.created_at else '' }} +

{{ m.content }}

+
+ {% endfor %} +
+ +
+ + +
+ + +{% endblock %} diff --git a/app/templates/checkin_new.html b/app/templates/checkin_new.html new file mode 100644 index 0000000..edd0554 --- /dev/null +++ b/app/templates/checkin_new.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}New Check-in{% endblock %} +{% block content %} +

New Check-in

+
+ + +
+ + + + +
+ + +
+{% endblock %} diff --git a/app/templates/checkins.html b/app/templates/checkins.html new file mode 100644 index 0000000..be49b1f --- /dev/null +++ b/app/templates/checkins.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Check-ins{% endblock %} +{% block content %} +

Check-ins

+New Check-in + +{% if checkins %} + + + + + + + + + + + + + {% for c in checkins %} + + + + + + + + + {% endfor %} + +
DateFeelingWeightCaloriesStepsSleep
{{ c.date }}{{ c.feeling or '—' }}{{ c.weight_lb or '—' }}{{ c.calories or '—' }}{{ c.steps or '—' }}{{ c.sleep_hours or '—' }}
+{% else %} +

No check-ins yet.

+{% endif %} +{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..34e52db --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block content %} +

Welcome, {{ user.display_name or user.username }}

+ +
+
+

Weight

+

{{ user.weight_lb or '—' }} lb

+
+
+

Calorie Goal

+

{{ user.calorie_goal or '—' }}

+
+
+

Step Goal

+

{{ user.step_goal or '—' }}

+
+
+ +{% if latest_checkin %} +
+

Latest Check-in

+

{{ latest_checkin.date }} — {{ latest_checkin.feeling or 'No feeling recorded' }}

+

Weight: {{ latest_checkin.weight_lb or '—' }} lb | Calories: {{ latest_checkin.calories or '—' }} | Steps: {{ latest_checkin.steps or '—' }} | Sleep: {{ latest_checkin.sleep_hours or '—' }}h

+
+{% endif %} + +

Recent Workouts

+{% if recent_workouts %} + + + + + + + + + + + {% for w in recent_workouts %} + + + + + + + {% endfor %} + +
DateNameStatus
{{ w.date }}{{ w.name }}{{ w.status }}View
+{% else %} +

No workouts yet. Plan one now.

+{% endif %} + +
+ New Workout + New Check-in + AI Coach +
+{% endblock %} diff --git a/app/templates/exercises.html b/app/templates/exercises.html new file mode 100644 index 0000000..cfa2573 --- /dev/null +++ b/app/templates/exercises.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Exercises{% endblock %} +{% block content %} +

Exercises

+ +
+ Filter by Body Part +
+ All + {% for bp in body_parts %} + {{ bp }} + {% endfor %} +
+
+ + + + + + + + + + + {% for e in exercises %} + + + + + + {% endfor %} + +
NameBody PartEquipment
{{ e.name }}{{ e.body_part or '—' }}{{ e.equipment or '—' }}
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..754c0b2 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Login{% endblock %} +{% block content %} +
+

Login

+ {% if error %} +

{{ error }}

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

No account? Register

+
+{% endblock %} diff --git a/app/templates/profile.html b/app/templates/profile.html new file mode 100644 index 0000000..457f346 --- /dev/null +++ b/app/templates/profile.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Profile{% endblock %} +{% block content %} +

Profile

+
+ +
+ + + +
+ + + + +
+{% endblock %} diff --git a/app/templates/register.html b/app/templates/register.html new file mode 100644 index 0000000..209c5d1 --- /dev/null +++ b/app/templates/register.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Register{% endblock %} +{% block content %} +
+

Register

+ {% if error %} +

{{ error }}

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

Already have an account? Login

+
+{% endblock %} diff --git a/app/templates/workout_detail.html b/app/templates/workout_detail.html new file mode 100644 index 0000000..a7abc33 --- /dev/null +++ b/app/templates/workout_detail.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% block title %}{{ workout.name }}{% endblock %} +{% block content %} +

{{ workout.name }}

+

Date: {{ workout.date }} | Status: {{ workout.status }}

+{% if workout.notes %} +

{{ workout.notes }}

+{% endif %} + +

Sets

+{% if sets %} + + + + + + + + + + + + + {% for s in sets %} + + + + + + + + + {% endfor %} + +
ExerciseSetRepsWeightRPENotes
{{ s.exercise }}{{ s.set_number }}{{ s.reps or '—' }}{{ s.weight or '—' }}{{ s.rpe or '—' }}{{ s.notes or '' }}
+{% else %} +

No sets logged yet.

+{% endif %} + +

Add Set

+
+ + + + + + + +
+ +{% if workout.status == "plan" %} +
+ +
+{% endif %} + +Back to Workouts +{% endblock %} diff --git a/app/templates/workout_new.html b/app/templates/workout_new.html new file mode 100644 index 0000000..838b2b4 --- /dev/null +++ b/app/templates/workout_new.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}New Workout{% endblock %} +{% block content %} +

New Workout

+
+ + + + + +
+{% endblock %} diff --git a/app/templates/workouts.html b/app/templates/workouts.html new file mode 100644 index 0000000..b6d9404 --- /dev/null +++ b/app/templates/workouts.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Workouts{% endblock %} +{% block content %} +

Workouts

+New Workout + +{% if workouts %} + + + + + + + + + + + {% for w in workouts %} + + + + + + + {% endfor %} + +
DateNameStatus
{{ w.date }}{{ w.name }}{{ w.status }}View
+{% else %} +

No workouts yet.

+{% endif %} +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..09610d6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + web: + build: . + ports: + - "8000:8000" + volumes: + - ./data:/app/data + environment: + - DATABASE_PATH=/app/data/fitness.db + - SESSION_SECRET=change-me-in-production + - OPENCODE_SERVE_URL=http://opencode-serve:4096 + + # opencode-serve: + # image: opencode:latest + # entrypoint: ["opencode", "serve", "--host", "0.0.0.0", "--port", "4096"] + # ports: + # - "4096:4096" + # volumes: + # - ./opencode:/root/.config/opencode + # environment: + # - OPENCODE_SERVER_PASSWORD=your-password-here diff --git a/opencode/fitness-trainer.md b/opencode/fitness-trainer.md new file mode 100644 index 0000000..78d9ce0 --- /dev/null +++ b/opencode/fitness-trainer.md @@ -0,0 +1,109 @@ +--- +description: > + Your personal fitness trainer. Plans workouts, tracks progress, adapts to how + you're feeling, and logs everything to markdown. Use this agent for daily + check-ins, workout reviews, and programming discussions. +mode: primary +color: "#4ade80" +--- + +You are an experienced, adaptable personal trainer. Your client (the user) has +provided their equipment, goals, and medical history in `inputs/`. Their +historical lifting data is in `inputs/Juggernaut training.xlsx`. + +Your job is to guide them through their fitness journey. Be encouraging but +honest. You are their single point of contact for training chat. + +## Guidelines + +- Always consider their medical history (especially the distal radius fracture) + and available equipment when programming +- **Periodic research check:** Roughly once per week (or every few check-ins), + do a brief web search on current best practices relevant to their situation — + e.g., distal radius fracture return-to-training, tendinopathy prevention, + hamstring-glute rehab, or return-from-layoff protocols. Skim 1-2 reputable + sources (APTA, Stronger by Science, PubMed, sports medicine reviews) and + note any actionable adjustments. Don't overwhelm the client with findings — + distill it into 1-2 concrete recommendations if anything useful surfaces. +- Reference their goals (weight control, blood pressure, strength, endurance) + when giving advice or adjusting plans +- If they're interested in a specific program methodology (Juggernaut, + Stronglifts 5x5, etc.), use their training history to pick up where they left + off or start a new cycle +- If they want something new, design intelligently using the programming + principles in the `fitness-workout` skill +- **Left hand grip limitation:** Client is doing grip/rehab exercises 3x/day with their PT. In our workouts, minimize left hand grip demand (use straps for any pulling, avoid goblet squats, keep DB loads light). Check in early next week (Mon/Tue) about whether they feel ready to add more grip work back into sessions — they have weekly PT appointments each Wednesday for the next 3 weeks and will update accordingly. +- **During the reintroduction period (weeks 1-4 after a layoff or injury),** + always program movements with **limited range of motion** — avoid end-range + positions (stretch at the bottom/top of any lift) for all exercises. This + protects connective tissue that is still adapting slower than muscle. + Mid-range movements only (e.g., Bulgarian split squats instead of deep + squats, landmine press instead of full ROM OHP, step-ups instead of deep + lunges). Apply this to squat, hinge, push, pull, and core movements alike. +- High-level training context lives in `plans/`. Read the relevant plan before + designing individual workouts and keep it up to date as context evolves + (progressing phases, new constraints, notable decisions). The plan describes + current phase, timeline, constraints, and progression criteria. Workout + Analysis sections should briefly reference the broader plan to show how the + session fits. +- Logs are written to `logs/workouts/` and `logs/checkins/`. Use + `grep`/`read`/`glob` to search past logs when they ask questions like "when + was the last time I did farmer's carry?" + +## Check-in Workflow + +When they want to check in, follow this structure: + +1. **Status check** — Ask how they're feeling: soreness, energy, injuries, + sleep, weight, motivation +2. **Nutrition & Steps** — Ask if they'd like to review/adjust their LoseIt! + calorie goal, and if they can report their average daily steps from their + Google Pixel phone. Log these numbers in the check-in entry. If they're + comfortable, suggest a small calorie goal adjustment based on their weight + trend and activity level. +3. **Review** — Check the last planned workout log. Did they complete it? How + did each exercise feel? Update the log with results if needed +4. **Adjust** — Based on feedback + programming guidelines + history, adjust + the next session (weight, volume, exercise selection, or rest day) +5. **Plan** — Write a new workout plan to `logs/workouts/-.md` + with exercises, sets, reps, weights, and any notes. Present it to them +6. **Log** — Write a brief check-in entry to `logs/checkins/-checkin.md` + summarizing the conversation and any decisions made. This includes calorie + goal, steps, weight, and workout results. +7. **Commit** — After logging, use `git add logs/` and `git commit` with a + descriptive message summarizing the check-in, any plan changes, and key + metrics (weight, calories, steps). Do NOT push unless asked. + +## Log format + +Workout plan log: +```markdown +# Workout: +**Date:** +**Program:** +**Status:** Plan + +## Analysis + + +## Exercises +- : x @ +- ... + +## Notes + +``` + +After completion, update the **Status** to "Complete" and add results inline: +```markdown +- Bench Press: 3x5 @ 185lb — completed (RPE 8) +``` + +Check-in log: +```markdown +# Check-in: +**Feeling:** +**Review:** +**Adjustments:** +**Next session:** +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..664f1ed --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "fitness-web" +version = "0.1.0" +description = "Multi-user fitness tracking web app with AI coaching" +requires-python = ">=3.12" + +dependencies = [ + "fastapi>=0.115,<1", + "uvicorn[standard]>=0.34,<1", + "jinja2>=3.1,<4", + "sqlalchemy[asyncio]>=2.0,<3", + "aiosqlite>=0.20,<1", + "python-multipart>=0.0.18,<1", + "httpx>=0.28,<1", +] + +[tool.uv] +dev-dependencies = [] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8a7634a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.115,<1 +uvicorn[standard]>=0.34,<1 +jinja2>=3.1,<4 +sqlalchemy[asyncio]>=2.0,<3 +aiosqlite>=0.20,<1 +python-multipart>=0.0.18,<1 +httpx>=0.28,<1 diff --git a/scripts/schema.py b/scripts/schema.py new file mode 100644 index 0000000..7b37d98 --- /dev/null +++ b/scripts/schema.py @@ -0,0 +1,35 @@ +""" +Initialize the database schema. +Run with: python scripts/schema.py +""" +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.models import Base, engine + + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + print("Database schema created successfully.") + + +async def drop_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + print("Database schema dropped.") + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--drop", action="store_true", help="Drop all tables") + args = parser.parse_args() + + if args.drop: + asyncio.run(drop_db()) + else: + asyncio.run(init_db()) diff --git a/scripts/seed.py b/scripts/seed.py new file mode 100644 index 0000000..8683952 --- /dev/null +++ b/scripts/seed.py @@ -0,0 +1,95 @@ +""" +Seed the database with initial data (exercises, phases). +Run with: python scripts/seed.py +""" +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy import select +from app.models import async_session +from app.models.exercise import Exercise +from app.models.workout import Phase + + +EXERCISES = [ + # Push + ("Bench Press", "chest", "barbell, bench"), + ("Incline Dumbbell Press", "chest", "dumbbell, bench"), + ("Overhead Press", "shoulders", "barbell"), + ("Landmine Press", "shoulders", "landmine"), + ("Band Press", "chest", "bands"), + ("Lateral Raise", "shoulders", "dumbbell"), + ("Tricep Pushdown", "triceps", "cable, bands"), + ("Dip", "chest", "dip station"), + # Pull + ("Barbell Row", "back", "barbell"), + ("Dumbbell Row", "back", "dumbbell, bench"), + ("Lat Pulldown", "back", "cable, bands"), + ("Pull-up", "back", "pull-up bar"), + ("Face Pull", "shoulders", "cable, bands"), + ("YTW", "shoulders", "dumbbell, bands"), + ("Bicep Curl", "biceps", "dumbbell"), + # Legs + ("Barbell Squat", "quadriceps", "barbell, squat rack"), + ("Goblet Squat", "quadriceps", "dumbbell, kettlebell"), + ("Bulgarian Split Squat", "quadriceps", "dumbbell, bench"), + ("Hip Thrust", "glutes", "barbell, bench"), + ("Step-up", "quadriceps", "dumbbell, bench"), + ("Romanian Deadlift", "hamstrings", "barbell"), + ("Leg Curl", "hamstrings", "cable, bands"), + ("Calf Raise", "calves", "barbell, dumbbell"), + ("Deadlift", "back", "barbell"), + # Core + ("Dead Bug", "core", "bodyweight"), + ("Pallof Press", "core", "cable, bands"), + ("Plank", "core", "bodyweight"), + ("Ab Wheel Rollout", "core", "ab wheel"), + ("Russian Twist", "core", "bodyweight, dumbbell"), + ("Hanging Knee Raise", "core", "pull-up bar"), + # Cardio + ("BikeErg", "cardio", "bikeerg"), + ("RowErg", "cardio", "rowerg"), + ("Jump Rope", "cardio", "jump rope"), + ("Walking", "cardio", "bodyweight"), + # Accessory + ("Farmer's Carry", "grip", "dumbbell, kettlebell"), + ("Bird Dog", "core", "bodyweight"), + ("Glute Bridge", "glutes", "bodyweight"), + ("Clamshell", "glutes", "bodyweight, bands"), + ("90/90 Stretch", "hips", "bodyweight"), + ("Supine Piriformis Stretch", "hips", "bodyweight"), +] + +PHASES = [ + ("Tendon Adaptation", "Phase 1: Return to training after layoff. RPE 6-7, limited ROM, mid-range movements only.", "2026-06-25", None, "Weeks 1-4"), + ("Progressive Loading", "Phase 2: Increase load gradually. RPE 7-8, full ROM where tolerated.", None, None, "Weeks 4-8"), + ("Strength Building", "Phase 3: Normal training. RPE 8-9, full ROM.", None, None, "Weeks 8+"), +] + + +async def seed(): + async with async_session() as session: + result = await session.execute(select(Exercise).limit(1)) + if result.scalar_one_or_none(): + print("Exercises already seeded, skipping.") + else: + for name, body_part, equipment in EXERCISES: + session.add(Exercise(name=name, body_part=body_part, equipment=equipment)) + print(f"Seeded {len(EXERCISES)} exercises.") + + result = await session.execute(select(Phase).limit(1)) + if result.scalar_one_or_none(): + print("Phases already seeded, skipping.") + else: + for name, desc, start, end, notes in PHASES: + session.add(Phase(name=name, description=desc, start_date=start, end_date=end, notes=notes)) + print(f"Seeded {len(PHASES)} phases.") + + await session.commit() + + +if __name__ == "__main__": + asyncio.run(seed())