Initial commit: FastAPI fitness web app with SQLite, auth, templates, and opencode AI coach integration

This commit is contained in:
Jacob Hinkle 2026-06-29 10:01:22 -04:00
commit 43078009cb
45 changed files with 1769 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
data/*.db
data/*.db-journal
data/*.db-wal
data/*.db-shm
__pycache__/
*.pyc
.venv/
.env
*.egg-info/
dist/
build/

50
AGENTS.md Normal file
View File

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

12
Dockerfile Normal file
View File

@ -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"]

58
README.md Normal file
View File

@ -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.

0
app/__init__.py Normal file
View File

71
app/auth.py Normal file
View File

@ -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()

8
app/config.py Normal file
View File

@ -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")

29
app/main.py Normal file
View File

@ -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)

7
app/models/__init__.py Normal file
View File

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

12
app/models/auth.py Normal file
View File

@ -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))

13
app/models/base.py Normal file
View File

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

14
app/models/chat.py Normal file
View File

@ -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))

17
app/models/checkin.py Normal file
View File

@ -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)

13
app/models/exercise.py Normal file
View File

@ -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)

19
app/models/user.py Normal file
View File

@ -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))

39
app/models/workout.py Normal file
View File

@ -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)

0
app/routers/__init__.py Normal file
View File

76
app/routers/auth.py Normal file
View File

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

89
app/routers/chat.py Normal file
View File

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

66
app/routers/checkins.py Normal file
View File

@ -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)

46
app/routers/dashboard.py Normal file
View File

@ -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,
})

39
app/routers/exercises.py Normal file
View File

@ -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,
})

48
app/routers/profile.py Normal file
View File

@ -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)

157
app/routers/workouts.py Normal file
View File

@ -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)

0
app/services/__init__.py Normal file
View File

View File

@ -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}"

48
app/static/style.css Normal file
View File

@ -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;
}

29
app/templates/base.html Normal file
View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Fitness{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="container-fluid">
<ul>
<li><strong><a href="/dashboard" class="contrast">Fitness</a></strong></li>
</ul>
<ul>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/workouts">Workouts</a></li>
<li><a href="/exercises">Exercises</a></li>
<li><a href="/checkins">Check-ins</a></li>
<li><a href="/chat">Chat</a></li>
<li><a href="/profile">Profile</a></li>
<li><a href="/logout">Logout</a></li>
</ul>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>

77
app/templates/chat.html Normal file
View File

@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}AI Coach{% endblock %}
{% block content %}
<h1>AI Coach</h1>
<div class="chat-messages" id="chat-messages">
{% for m in messages %}
<div class="chat-message {{ m.role }}">
<small>{{ m.role }} · {{ m.created_at[:10] if m.created_at else '' }}</small>
<p>{{ m.content }}</p>
</div>
{% endfor %}
</div>
<form class="chat-input" id="chat-form" onsubmit="sendMessage(event)">
<input type="text" id="chat-input" placeholder="Ask your coach..." required autocomplete="off">
<button type="submit">Send</button>
</form>
<script>
function sendMessage(event) {
event.preventDefault();
const input = document.getElementById('chat-input');
const messages = document.getElementById('chat-messages');
const text = input.value;
if (!text.trim()) return;
const userDiv = document.createElement('div');
userDiv.className = 'chat-message user';
userDiv.innerHTML = `<small>user</small><p>${text}</p>`;
messages.appendChild(userDiv);
const assistantDiv = document.createElement('div');
assistantDiv.className = 'chat-message assistant';
assistantDiv.innerHTML = '<small>assistant</small><p><em>Thinking...</em></p>';
messages.appendChild(assistantDiv);
messages.scrollTop = messages.scrollHeight;
input.disabled = true;
const formData = new FormData();
formData.append('message', text);
fetch('/chat', {
method: 'POST',
body: formData,
}).then(async response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
const p = assistantDiv.querySelector('p');
p.innerHTML = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
p.innerHTML += line.slice(6);
}
}
messages.scrollTop = messages.scrollHeight;
}
input.disabled = false;
input.value = '';
input.focus();
}).catch(() => {
assistantDiv.querySelector('p').innerHTML = 'Error connecting to coach.';
input.disabled = false;
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}New Check-in{% endblock %}
{% block content %}
<h1>New Check-in</h1>
<form method="post" action="/checkins/new">
<label>
Date
<input type="date" name="date" required>
</label>
<label>
Feeling
<input type="text" name="feeling" placeholder="e.g., Good, Tired, Sore">
</label>
<div class="grid">
<label>
Weight (lb)
<input type="number" name="weight_lb" step="0.1">
</label>
<label>
Calories
<input type="number" name="calories">
</label>
<label>
Steps
<input type="number" name="steps">
</label>
<label>
Sleep (hours)
<input type="number" name="sleep_hours" step="0.5">
</label>
</div>
<label>
Notes
<textarea name="notes" rows="3"></textarea>
</label>
<button type="submit">Save Check-in</button>
</form>
{% endblock %}

View File

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Check-ins{% endblock %}
{% block content %}
<h1>Check-ins</h1>
<a href="/checkins/new" role="button">New Check-in</a>
{% if checkins %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Feeling</th>
<th>Weight</th>
<th>Calories</th>
<th>Steps</th>
<th>Sleep</th>
</tr>
</thead>
<tbody>
{% for c in checkins %}
<tr>
<td>{{ c.date }}</td>
<td>{{ c.feeling or '—' }}</td>
<td>{{ c.weight_lb or '—' }}</td>
<td>{{ c.calories or '—' }}</td>
<td>{{ c.steps or '—' }}</td>
<td>{{ c.sleep_hours or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No check-ins yet.</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h1>Welcome, {{ user.display_name or user.username }}</h1>
<div class="grid">
<article>
<h3>Weight</h3>
<p style="font-size: 2rem;">{{ user.weight_lb or '—' }} lb</p>
</article>
<article>
<h3>Calorie Goal</h3>
<p style="font-size: 2rem;">{{ user.calorie_goal or '—' }}</p>
</article>
<article>
<h3>Step Goal</h3>
<p style="font-size: 2rem;">{{ user.step_goal or '—' }}</p>
</article>
</div>
{% if latest_checkin %}
<article>
<h3>Latest Check-in</h3>
<p>{{ latest_checkin.date }} — {{ latest_checkin.feeling or 'No feeling recorded' }}</p>
<p>Weight: {{ latest_checkin.weight_lb or '—' }} lb | Calories: {{ latest_checkin.calories or '—' }} | Steps: {{ latest_checkin.steps or '—' }} | Sleep: {{ latest_checkin.sleep_hours or '—' }}h</p>
</article>
{% endif %}
<h2>Recent Workouts</h2>
{% if recent_workouts %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Name</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for w in recent_workouts %}
<tr>
<td>{{ w.date }}</td>
<td>{{ w.name }}</td>
<td>{{ w.status }}</td>
<td><a href="/workouts/{{ w.id }}">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No workouts yet. <a href="/workouts/new">Plan one now</a>.</p>
{% endif %}
<div class="grid">
<a href="/workouts/new" role="button" class="secondary">New Workout</a>
<a href="/checkins/new" role="button" class="secondary">New Check-in</a>
<a href="/chat" role="button" class="secondary">AI Coach</a>
</div>
{% endblock %}

View File

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Exercises{% endblock %}
{% block content %}
<h1>Exercises</h1>
<details>
<summary>Filter by Body Part</summary>
<div>
<a href="/exercises" role="button" class="secondary {% if not selected_body_part %}outline{% endif %}">All</a>
{% for bp in body_parts %}
<a href="/exercises?body_part={{ bp }}" role="button" class="secondary {% if selected_body_part == bp %}outline{% endif %}">{{ bp }}</a>
{% endfor %}
</div>
</details>
<table>
<thead>
<tr>
<th>Name</th>
<th>Body Part</th>
<th>Equipment</th>
</tr>
</thead>
<tbody>
{% for e in exercises %}
<tr>
<td><strong>{{ e.name }}</strong></td>
<td>{{ e.body_part or '—' }}</td>
<td>{{ e.equipment or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

22
app/templates/login.html Normal file
View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<article style="max-width: 400px; margin: 4rem auto;">
<h1>Login</h1>
{% if error %}
<p style="color: var(--red)">{{ error }}</p>
{% endif %}
<form method="post" action="/login">
<label>
Username
<input type="text" name="username" required autocomplete="username">
</label>
<label>
Password
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit">Login</button>
</form>
<p>No account? <a href="/register">Register</a></p>
</article>
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Profile{% endblock %}
{% block content %}
<h1>Profile</h1>
<form method="post" action="/profile">
<label>
Display Name
<input type="text" name="display_name" value="{{ user.display_name or '' }}">
</label>
<div class="grid">
<label>
Weight (lb)
<input type="number" name="weight_lb" step="0.1" value="{{ user.weight_lb or '' }}">
</label>
<label>
Calorie Goal
<input type="number" name="calorie_goal" value="{{ user.calorie_goal or '' }}">
</label>
<label>
Step Goal
<input type="number" name="step_goal" value="{{ user.step_goal or '' }}">
</label>
</div>
<label>
Medical Notes
<textarea name="medical_notes" rows="4">{{ user.medical_notes or '' }}</textarea>
</label>
<label>
Goals
<textarea name="goals" rows="4">{{ user.goals or '' }}</textarea>
</label>
<label>
Equipment
<textarea name="equipment" rows="4">{{ user.equipment or '' }}</textarea>
</label>
<button type="submit">Save</button>
</form>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<article style="max-width: 400px; margin: 4rem auto;">
<h1>Register</h1>
{% if error %}
<p style="color: var(--red)">{{ error }}</p>
{% endif %}
<form method="post" action="/register">
<label>
Username
<input type="text" name="username" required autocomplete="username">
</label>
<label>
Display Name
<input type="text" name="display_name" autocomplete="name">
</label>
<label>
Password
<input type="password" name="password" required autocomplete="new-password">
</label>
<button type="submit">Register</button>
</form>
<p>Already have an account? <a href="/login">Login</a></p>
</article>
{% endblock %}

View File

@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}{{ workout.name }}{% endblock %}
{% block content %}
<h1>{{ workout.name }}</h1>
<p><strong>Date:</strong> {{ workout.date }} | <strong>Status:</strong> {{ workout.status }}</p>
{% if workout.notes %}
<p>{{ workout.notes }}</p>
{% endif %}
<h2>Sets</h2>
{% if sets %}
<table>
<thead>
<tr>
<th>Exercise</th>
<th>Set</th>
<th>Reps</th>
<th>Weight</th>
<th>RPE</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{% for s in sets %}
<tr>
<td>{{ s.exercise }}</td>
<td>{{ s.set_number }}</td>
<td>{{ s.reps or '—' }}</td>
<td>{{ s.weight or '—' }}</td>
<td>{{ s.rpe or '—' }}</td>
<td>{{ s.notes or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No sets logged yet.</p>
{% endif %}
<h3>Add Set</h3>
<form method="post" action="/workouts/{{ workout.id }}/add-set" class="set-row">
<label>
Exercise
<input type="text" name="exercise" required list="exercise-list">
<datalist id="exercise-list">
{% for s in sets %}
<option value="{{ s.exercise }}">
{% endfor %}
</datalist>
</label>
<label>
Set #
<input type="number" name="set_number" required min="1">
</label>
<label>
Reps
<input type="number" name="reps" min="1">
</label>
<label>
Weight
<input type="number" name="weight" step="0.5">
</label>
<label>
RPE
<input type="number" name="rpe" min="1" max="10" step="0.5">
</label>
<label>
Notes
<input type="text" name="notes">
</label>
<button type="submit">Add</button>
</form>
{% if workout.status == "plan" %}
<form method="post" action="/workouts/{{ workout.id }}/complete">
<button type="submit" class="secondary">Mark Complete</button>
</form>
{% endif %}
<a href="/workouts">Back to Workouts</a>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}New Workout{% endblock %}
{% block content %}
<h1>New Workout</h1>
<form method="post" action="/workouts/new">
<label>
Name
<input type="text" name="name" required placeholder="e.g., Upper Body A">
</label>
<label>
Date
<input type="date" name="date" required>
</label>
<label>
Phase
<select name="phase_id">
<option value="">None</option>
{% for p in phases %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</label>
<label>
Notes
<textarea name="notes" rows="3"></textarea>
</label>
<button type="submit">Create Workout</button>
</form>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Workouts{% endblock %}
{% block content %}
<h1>Workouts</h1>
<a href="/workouts/new" role="button">New Workout</a>
{% if workouts %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Name</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for w in workouts %}
<tr>
<td>{{ w.date }}</td>
<td>{{ w.name }}</td>
<td>{{ w.status }}</td>
<td><a href="/workouts/{{ w.id }}">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No workouts yet.</p>
{% endif %}
{% endblock %}

21
docker-compose.yml Normal file
View File

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

109
opencode/fitness-trainer.md Normal file
View File

@ -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/<YYYY-MM-DD>-<slug>.md`
with exercises, sets, reps, weights, and any notes. Present it to them
6. **Log** — Write a brief check-in entry to `logs/checkins/<YYYY-MM-DD>-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: <Name>
**Date:** <YYYY-MM-DD>
**Program:** <program name or "Custom">
**Status:** Plan
## Analysis
<brief section listing muscles targeted, the overall goal for the session, and how it fits into the broader plan (reference `plans/`). Keep it concise 3-5 sentences max. Update if the plan changes mid-session due to pain or feedback.>
## Exercises
- <Exercise>: <sets>x<reps> @ <weight> <notes>
- ...
## Notes
<context for the session>
```
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: <YYYY-MM-DD>
**Feeling:** <summary>
**Review:** <what was reviewed>
**Adjustments:** <changes made>
**Next session:** <reference to workout log>
```

18
pyproject.toml Normal file
View File

@ -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 = []

7
requirements.txt Normal file
View File

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

35
scripts/schema.py Normal file
View File

@ -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())

95
scripts/seed.py Normal file
View File

@ -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())