Initial commit: FastAPI fitness web app with SQLite, auth, templates, and opencode AI coach integration
This commit is contained in:
commit
43078009cb
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
50
AGENTS.md
Normal 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
12
Dockerfile
Normal 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
58
README.md
Normal 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
0
app/__init__.py
Normal file
71
app/auth.py
Normal file
71
app/auth.py
Normal 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
8
app/config.py
Normal 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
29
app/main.py
Normal 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
7
app/models/__init__.py
Normal 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
12
app/models/auth.py
Normal 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
13
app/models/base.py
Normal 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
14
app/models/chat.py
Normal 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
17
app/models/checkin.py
Normal 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
13
app/models/exercise.py
Normal 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
19
app/models/user.py
Normal 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
39
app/models/workout.py
Normal 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
0
app/routers/__init__.py
Normal file
76
app/routers/auth.py
Normal file
76
app/routers/auth.py
Normal 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
89
app/routers/chat.py
Normal 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
66
app/routers/checkins.py
Normal 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
46
app/routers/dashboard.py
Normal 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
39
app/routers/exercises.py
Normal 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
48
app/routers/profile.py
Normal 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
157
app/routers/workouts.py
Normal 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
0
app/services/__init__.py
Normal file
42
app/services/opencode_proxy.py
Normal file
42
app/services/opencode_proxy.py
Normal 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
48
app/static/style.css
Normal 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
29
app/templates/base.html
Normal 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
77
app/templates/chat.html
Normal 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 %}
|
||||||
38
app/templates/checkin_new.html
Normal file
38
app/templates/checkin_new.html
Normal 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 %}
|
||||||
35
app/templates/checkins.html
Normal file
35
app/templates/checkins.html
Normal 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 %}
|
||||||
60
app/templates/dashboard.html
Normal file
60
app/templates/dashboard.html
Normal 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 %}
|
||||||
34
app/templates/exercises.html
Normal file
34
app/templates/exercises.html
Normal 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
22
app/templates/login.html
Normal 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 %}
|
||||||
38
app/templates/profile.html
Normal file
38
app/templates/profile.html
Normal 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 %}
|
||||||
26
app/templates/register.html
Normal file
26
app/templates/register.html
Normal 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 %}
|
||||||
81
app/templates/workout_detail.html
Normal file
81
app/templates/workout_detail.html
Normal 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 %}
|
||||||
29
app/templates/workout_new.html
Normal file
29
app/templates/workout_new.html
Normal 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 %}
|
||||||
31
app/templates/workouts.html
Normal file
31
app/templates/workouts.html
Normal 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
21
docker-compose.yml
Normal 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
109
opencode/fitness-trainer.md
Normal 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
18
pyproject.toml
Normal 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
7
requirements.txt
Normal 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
35
scripts/schema.py
Normal 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
95
scripts/seed.py
Normal 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())
|
||||||
Loading…
x
Reference in New Issue
Block a user