Single-container AI coach with agent API endpoints and UI polish
- Merge opencode-serve into the web container via entrypoint script - Add /api/agent/* JSON endpoints for workouts, sets, checkins - Rewrite fitness-trainer.md to use API instead of markdown files - Pass recent workouts and check-ins as chat context to the coach - Show current training phase on dashboard - Clarify check-ins as morning check-ins (calories/steps = yesterday) - Add NixOS deployment section to README - Make all check-in fields explicitly optional in UI
This commit is contained in:
parent
bfab3e8f01
commit
5584022a23
16
AGENTS.md
16
AGENTS.md
@ -12,7 +12,7 @@ Replaces the fitness-agent markdown-based training repo.
|
||||
- **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.
|
||||
- **opencode serve** — AI coach service (Big Pickle model, free). Runs alongside uvicorn in the same container, managed by a shell entrypoint.
|
||||
|
||||
## Key Files
|
||||
- `app/main.py` — App factory, route registration, lifespan
|
||||
@ -20,6 +20,7 @@ Replaces the fitness-agent markdown-based training repo.
|
||||
- `app/auth.py` — Password hashing, session management, `get_current_user` dependency
|
||||
- `app/models/` — SQLAlchemy ORM models
|
||||
- `app/routers/` — Route handlers (one per feature)
|
||||
- `app/routers/agent_api.py` — JSON API for the AI coach to create workouts/check-ins
|
||||
- `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)
|
||||
@ -38,13 +39,14 @@ uv run uvicorn app.main:app --reload # Dev server on :8000
|
||||
- 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)
|
||||
- Docker Compose for deployment (opencode serve runs as background process in same container)
|
||||
|
||||
## 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
|
||||
3. Enable opencode serve integration (Dockerfile + entrypoint done, single-container approach)
|
||||
4. Add agent API for DB writes (endpoints + agent config done)
|
||||
5. Migrate existing markdown logs from fitness-agent repo into DB
|
||||
6. Migrate Juggernaut training xlsx data into DB
|
||||
7. Add calendar view for training history
|
||||
8. Update `fitness-trainer.md` agent config to work with DB-backed context instead of markdown files (done)
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@ -1,12 +1,20 @@
|
||||
FROM node:22-alpine AS opencode-build
|
||||
RUN apk add --no-cache curl && \
|
||||
curl -fsSL https://opencode.ai/install | sh
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install --no-cache-dir uv
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN uv pip install --system --no-cache-dir -r requirements.txt
|
||||
COPY --from=opencode-build /usr/local/bin/opencode /usr/local/bin/opencode
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
ENV OPENCODE_SERVE_URL=http://127.0.0.1:4096
|
||||
ENV AGENT_API_KEY=dev-agent-key-change-in-production
|
||||
RUN mkdir -p /root/.config && \
|
||||
ln -s /app/opencode /root/.config/opencode && \
|
||||
printf '#!/bin/sh\nopencode serve --host 127.0.0.1 --port 4096 &\nsleep 1\nexec uvicorn app.main:app --host 0.0.0.0 --port 8000\n' > /entrypoint.sh && chmod +x /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
67
README.md
67
README.md
@ -12,6 +12,7 @@ Track workouts, log daily check-ins, explore exercise history, and chat with an
|
||||
- **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
|
||||
- **NixOS-ready** — Docker-based deploy with provided NixOS module
|
||||
|
||||
## Quick Start
|
||||
|
||||
@ -35,6 +36,72 @@ Open http://localhost:8000, register a user, and you're ready.
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## NixOS Deployment
|
||||
|
||||
A single container runs both the web app and the AI coach (opencode-serve) together, sharing the SQLite database on the same filesystem.
|
||||
|
||||
### 1. Build image
|
||||
|
||||
```bash
|
||||
docker build -t fitness-web:latest .
|
||||
```
|
||||
|
||||
### 2. NixOS module
|
||||
|
||||
Add `machines/cj/fitness-web.nix` to your nix_config:
|
||||
|
||||
```nix
|
||||
{ serverIP, serverIP6 }: {
|
||||
fitness-web = {
|
||||
image = "fitness-web:latest";
|
||||
ports = [ "8688:8000" ];
|
||||
environment = {
|
||||
TZ = "America/New_York";
|
||||
SESSION_SECRET = "change-me-in-production";
|
||||
};
|
||||
volumes = [
|
||||
"/serverdata/fitness-web/data:/app/data"
|
||||
"/serverdata/fitness-web/opencode:/root/.config/opencode"
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Wire it into `configuration.nix`:
|
||||
|
||||
```nix
|
||||
virtualisation.oci-containers.containers = let
|
||||
ips = { ... };
|
||||
in {
|
||||
# ... existing containers ...
|
||||
fitness-web = (import ./fitness-web.nix ips).fitness-web;
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Nginx reverse proxy
|
||||
|
||||
In `nginx.nix`, add a vhost:
|
||||
|
||||
```nix
|
||||
"fitness.jhink.org" = simpleProxy 8688;
|
||||
```
|
||||
|
||||
### 4. Firewall
|
||||
|
||||
Add to `allowedTCPPorts` in `firewall.nix`:
|
||||
|
||||
```nix
|
||||
8688 # fitness-web
|
||||
```
|
||||
|
||||
### 5. Deploy
|
||||
|
||||
```bash
|
||||
nixos-rebuild switch --flake .#cj
|
||||
```
|
||||
|
||||
Then access at https://fitness.jhink.org (or your chosen domain).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
@ -6,3 +6,4 @@ 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")
|
||||
AGENT_API_KEY = os.environ.get("AGENT_API_KEY", "")
|
||||
|
||||
@ -4,7 +4,7 @@ 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 app.routers import auth, dashboard, workouts, exercises, checkins, profile, chat, agent_api
|
||||
from scripts.schema import init_db
|
||||
|
||||
|
||||
@ -27,3 +27,4 @@ app.include_router(exercises.router)
|
||||
app.include_router(checkins.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(agent_api.router)
|
||||
|
||||
143
app/routers/agent_api.py
Normal file
143
app/routers/agent_api.py
Normal file
@ -0,0 +1,143 @@
|
||||
from fastapi import APIRouter, Header, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import AGENT_API_KEY
|
||||
from app.models.base import async_session
|
||||
from app.models.user import User
|
||||
from app.models.workout import Workout, WorkoutSet
|
||||
from app.models.checkin import Checkin
|
||||
|
||||
router = APIRouter(prefix="/api/agent", tags=["agent"])
|
||||
|
||||
|
||||
async def verify_agent(x_api_key: str = Header("")):
|
||||
if AGENT_API_KEY and x_api_key != AGENT_API_KEY:
|
||||
raise HTTPException(status_code=403, detail="invalid api key")
|
||||
|
||||
|
||||
class CreateWorkoutRequest(BaseModel):
|
||||
username: str
|
||||
name: str
|
||||
date: str
|
||||
phase_id: int | None = None
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class CreateSetRequest(BaseModel):
|
||||
exercise: str
|
||||
set_number: int
|
||||
reps: int | None = None
|
||||
weight: float | None = None
|
||||
rpe: float | None = None
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class CreateCheckinRequest(BaseModel):
|
||||
username: str
|
||||
date: str
|
||||
feeling: str | None = None
|
||||
weight_lb: float | None = None
|
||||
calories: int | None = None
|
||||
steps: int | None = None
|
||||
sleep_hours: float | None = None
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@router.post("/workouts")
|
||||
async def agent_create_workout(
|
||||
body: CreateWorkoutRequest,
|
||||
_=Depends(verify_agent),
|
||||
):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == body.username)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="user not found")
|
||||
|
||||
workout = Workout(
|
||||
user_id=user.id,
|
||||
phase_id=body.phase_id,
|
||||
name=body.name,
|
||||
date=body.date,
|
||||
notes=body.notes,
|
||||
status="plan",
|
||||
)
|
||||
session.add(workout)
|
||||
await session.commit()
|
||||
await session.refresh(workout)
|
||||
|
||||
return {"id": workout.id, "name": workout.name, "date": workout.date, "status": workout.status}
|
||||
|
||||
|
||||
@router.post("/workouts/{workout_id}/sets")
|
||||
async def agent_add_set(
|
||||
workout_id: int,
|
||||
body: CreateSetRequest,
|
||||
_=Depends(verify_agent),
|
||||
):
|
||||
async with async_session() as session:
|
||||
ws = WorkoutSet(
|
||||
workout_id=workout_id,
|
||||
exercise=body.exercise,
|
||||
set_number=body.set_number,
|
||||
reps=body.reps,
|
||||
weight=body.weight,
|
||||
rpe=body.rpe,
|
||||
notes=body.notes,
|
||||
)
|
||||
session.add(ws)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
|
||||
return {"id": ws.id, "exercise": ws.exercise, "set_number": ws.set_number}
|
||||
|
||||
|
||||
@router.put("/workouts/{workout_id}/complete")
|
||||
async def agent_complete_workout(
|
||||
workout_id: int,
|
||||
_=Depends(verify_agent),
|
||||
):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Workout).where(Workout.id == workout_id)
|
||||
)
|
||||
workout = result.scalar_one_or_none()
|
||||
if not workout:
|
||||
raise HTTPException(status_code=404, detail="workout not found")
|
||||
workout.status = "complete"
|
||||
await session.commit()
|
||||
|
||||
return {"id": workout.id, "status": "complete"}
|
||||
|
||||
|
||||
@router.post("/checkins")
|
||||
async def agent_create_checkin(
|
||||
body: CreateCheckinRequest,
|
||||
_=Depends(verify_agent),
|
||||
):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == body.username)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="user not found")
|
||||
|
||||
checkin = Checkin(
|
||||
user_id=user.id,
|
||||
date=body.date,
|
||||
feeling=body.feeling,
|
||||
weight_lb=body.weight_lb,
|
||||
calories=body.calories,
|
||||
steps=body.steps,
|
||||
sleep_hours=body.sleep_hours,
|
||||
notes=body.notes,
|
||||
)
|
||||
session.add(checkin)
|
||||
await session.commit()
|
||||
await session.refresh(checkin)
|
||||
|
||||
return {"id": checkin.id, "date": checkin.date}
|
||||
@ -8,6 +8,8 @@ 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.models.workout import Workout
|
||||
from app.models.checkin import Checkin
|
||||
from app.auth import get_current_user
|
||||
from app.services.opencode_proxy import query_opencode
|
||||
|
||||
@ -48,12 +50,55 @@ async def chat_send(
|
||||
message: str = Form(),
|
||||
):
|
||||
session_id = request.cookies.get("chat_session_id") or str(uuid.uuid4())
|
||||
|
||||
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(5)
|
||||
)
|
||||
recent_checkins = result.scalars().all()
|
||||
|
||||
workout_lines = []
|
||||
for w in recent_workouts:
|
||||
workout_lines.append(f" {w.date} — {w.name} ({w.status})")
|
||||
checkin_lines = []
|
||||
for c in recent_checkins:
|
||||
parts = []
|
||||
if c.feeling:
|
||||
parts.append(f"feeling={c.feeling}")
|
||||
if c.weight_lb:
|
||||
parts.append(f"weight={c.weight_lb}lb")
|
||||
if c.calories:
|
||||
parts.append(f"cal(yesterday)={c.calories}")
|
||||
if c.steps:
|
||||
parts.append(f"steps(yesterday)={c.steps}")
|
||||
if c.sleep_hours:
|
||||
parts.append(f"sleep={c.sleep_hours}h")
|
||||
checkin_lines.append(f" {c.date} — {' | '.join(parts)}")
|
||||
|
||||
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'}"
|
||||
f"Equipment: {user.equipment or 'Not specified'}. "
|
||||
f"Medical: {user.medical_notes or 'None'}. "
|
||||
f"Calorie goal: {user.calorie_goal or 'Not set'}. "
|
||||
f"Step goal: {user.step_goal or 'Not set'}. "
|
||||
)
|
||||
if recent_workouts:
|
||||
user_context += "Recent workouts:\n" + "\n".join(workout_lines) + ". "
|
||||
if recent_checkins:
|
||||
user_context += "Recent check-ins:\n" + "\n".join(checkin_lines) + ". "
|
||||
|
||||
async def stream():
|
||||
async with async_session() as session:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from fastapi import APIRouter, Request, Depends, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@ -32,6 +32,7 @@ async def checkin_list(request: Request, user: User = Depends(get_current_user))
|
||||
async def new_checkin_page(request: Request, user: User = Depends(get_current_user)):
|
||||
return templates.TemplateResponse(request, "checkin_new.html", {
|
||||
"user": user,
|
||||
"today": date.today().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ 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.workout import Phase, Workout
|
||||
from app.models.checkin import Checkin
|
||||
from app.auth import get_current_user
|
||||
|
||||
@ -38,8 +38,14 @@ async def dashboard(request: Request, user: User = Depends(get_current_user)):
|
||||
)
|
||||
latest_checkin = result.scalar_one_or_none()
|
||||
|
||||
result = await session.execute(
|
||||
select(Phase).order_by(desc(Phase.start_date)).limit(1)
|
||||
)
|
||||
current_phase = result.scalar_one_or_none()
|
||||
|
||||
return templates.TemplateResponse(request, "dashboard.html", {
|
||||
"user": user,
|
||||
"recent_workouts": recent_workouts,
|
||||
"latest_checkin": latest_checkin,
|
||||
"current_phase": current_phase,
|
||||
})
|
||||
|
||||
@ -89,10 +89,16 @@ async def workout_detail(
|
||||
)
|
||||
sets = result.scalars().all()
|
||||
|
||||
phase = None
|
||||
if workout.phase_id:
|
||||
result = await session.execute(select(Phase).where(Phase.id == workout.phase_id))
|
||||
phase = result.scalar_one_or_none()
|
||||
|
||||
return templates.TemplateResponse(request, "workout_detail.html", {
|
||||
"user": user,
|
||||
"workout": workout,
|
||||
"sets": sets,
|
||||
"phase": phase,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -1,37 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}New Check-in{% endblock %}
|
||||
{% block title %}Morning Check-in{% endblock %}
|
||||
{% block content %}
|
||||
<h1>New Check-in</h1>
|
||||
<h1>Morning Check-in</h1>
|
||||
<p><small>All fields are optional — fill in whatever you have each morning.</small></p>
|
||||
<form method="post" action="/checkins/new">
|
||||
<label>
|
||||
Date
|
||||
<input type="date" name="date" required>
|
||||
<input type="date" name="date" value="{{ today }}" required>
|
||||
<small>Morning of this check-in</small>
|
||||
</label>
|
||||
<label>
|
||||
Feeling
|
||||
<input type="text" name="feeling" placeholder="e.g., Good, Tired, Sore">
|
||||
<small>How are you feeling this morning?</small>
|
||||
</label>
|
||||
<div class="grid">
|
||||
<label>
|
||||
Weight (lb)
|
||||
<input type="number" name="weight_lb" step="0.1">
|
||||
<small>Morning weight</small>
|
||||
</label>
|
||||
<label>
|
||||
Calories
|
||||
<input type="number" name="calories">
|
||||
<small>Yesterday's total</small>
|
||||
</label>
|
||||
<label>
|
||||
Steps
|
||||
<input type="number" name="steps">
|
||||
<small>Yesterday's total</small>
|
||||
</label>
|
||||
<label>
|
||||
Sleep (hours)
|
||||
<input type="number" name="sleep_hours" step="0.5">
|
||||
<small>Last night</small>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Notes
|
||||
<textarea name="notes" rows="3"></textarea>
|
||||
<textarea name="notes" rows="3" placeholder="Anything else to note?"></textarea>
|
||||
<small>Optional notes about yesterday or today</small>
|
||||
</label>
|
||||
<button type="submit">Save Check-in</button>
|
||||
</form>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Check-ins{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Check-ins</h1>
|
||||
<h1>Morning Check-ins</h1>
|
||||
<p><small>Log what's on your mind and what happened yesterday — all fields optional.</small></p>
|
||||
<a href="/checkins/new" role="button">New Check-in</a>
|
||||
|
||||
{% if checkins %}
|
||||
@ -11,8 +12,8 @@
|
||||
<th>Date</th>
|
||||
<th>Feeling</th>
|
||||
<th>Weight</th>
|
||||
<th>Calories</th>
|
||||
<th>Steps</th>
|
||||
<th>Calories <small>(yesterday)</small></th>
|
||||
<th>Steps <small>(yesterday)</small></th>
|
||||
<th>Sleep</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -30,6 +31,6 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No check-ins yet.</p>
|
||||
<p>No check-ins yet. <a href="/checkins/new">Start your first morning check-in</a>.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@ -18,11 +18,20 @@
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{% if current_phase %}
|
||||
<article>
|
||||
<h3>Current Phase: {{ current_phase.name }}</h3>
|
||||
<p>{{ current_phase.description }}</p>
|
||||
{% if current_phase.notes %}<p><em>{{ current_phase.notes }}</em></p>{% endif %}
|
||||
{% if current_phase.start_date %}<small>Started {{ current_phase.start_date }}</small>{% endif %}
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
{% if latest_checkin %}
|
||||
<article>
|
||||
<h3>Latest Check-in</h3>
|
||||
<h3>Latest Morning 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>
|
||||
<p>Weight: {{ latest_checkin.weight_lb or '—' }} lb | Calories <small>(yesterday)</small>: {{ latest_checkin.calories or '—' }} | Steps <small>(yesterday)</small>: {{ latest_checkin.steps or '—' }} | Sleep: {{ latest_checkin.sleep_hours or '—' }}h</p>
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
@ -49,12 +58,12 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No workouts yet. <a href="/workouts/new">Plan one now</a>.</p>
|
||||
<p>No workouts yet. Ask the <a href="/chat">AI Coach</a> to plan one.</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>
|
||||
<a href="/workouts" role="button" class="secondary">All Workouts</a>
|
||||
<a href="/checkins" role="button" class="secondary">All Check-ins</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
{% block content %}
|
||||
<h1>{{ workout.name }}</h1>
|
||||
<p><strong>Date:</strong> {{ workout.date }} | <strong>Status:</strong> {{ workout.status }}</p>
|
||||
{% if phase %}
|
||||
<p><strong>Phase:</strong> {{ phase.name }}</p>
|
||||
{% endif %}
|
||||
{% if workout.notes %}
|
||||
<p>{{ workout.notes }}</p>
|
||||
{% endif %}
|
||||
|
||||
@ -1,21 +1,11 @@
|
||||
services:
|
||||
web:
|
||||
fitness-web:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./opencode:/root/.config/opencode
|
||||
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
|
||||
- AGENT_API_KEY=dev-agent-key-change-in-production
|
||||
|
||||
@ -1,23 +1,28 @@
|
||||
---
|
||||
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.
|
||||
you're feeling, and logs everything via the web app API. 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`.
|
||||
provided their equipment, goals, and medical history through the fitness web
|
||||
app. Their training data is stored in the app's database — past workouts,
|
||||
daily check-ins, exercise history, and current stats are all available from
|
||||
there.
|
||||
|
||||
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
|
||||
- Always consider their medical history (especially the distal radius
|
||||
fracture) and available equipment when programming
|
||||
- The web app passes your client's current stats (weight, goals, medical
|
||||
notes, recent workouts, recent check-ins) alongside each message. Use this
|
||||
context to understand their situation.
|
||||
- **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,
|
||||
@ -30,9 +35,14 @@ honest. You are their single point of contact for training chat.
|
||||
- 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.
|
||||
- If they want something new, design intelligently using sound programming
|
||||
principles
|
||||
- **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
|
||||
@ -40,70 +50,74 @@ honest. You are their single point of contact for training chat.
|
||||
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
|
||||
## API — Creating Workouts and Check-ins
|
||||
|
||||
When they want to check in, follow this structure:
|
||||
You write workout plans and check-in logs directly to the database via the
|
||||
web app's internal API. The API key is in the environment variable
|
||||
`AGENT_API_KEY`. All endpoints are at `http://localhost:8000/api/agent/`.
|
||||
|
||||
Use `curl` with the API key header:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8000/api/agent/workouts \
|
||||
-H "X-API-Key: $AGENT_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "jacob", "name": "Upper Body A", "date": "2026-06-30", "notes": "..."}'
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
|
||||
**Create a workout plan:**
|
||||
```
|
||||
POST /api/agent/workouts
|
||||
Body: { username, name, date, phase_id?, notes? }
|
||||
```
|
||||
|
||||
**Add a set to a workout:**
|
||||
```
|
||||
POST /api/agent/workouts/{id}/sets
|
||||
Body: { exercise, set_number, reps?, weight?, rpe?, notes? }
|
||||
```
|
||||
|
||||
**Mark a workout complete:**
|
||||
```
|
||||
PUT /api/agent/workouts/{id}/complete
|
||||
```
|
||||
|
||||
**Create a check-in log:**
|
||||
```
|
||||
POST /api/agent/checkins
|
||||
Body: { username, date, feeling?, weight_lb?, calories?, steps?, sleep_hours?, notes? }
|
||||
```
|
||||
|
||||
Always use the username from the context provided with each message.
|
||||
|
||||
## Check-in Flow
|
||||
|
||||
When the user wants to check in or discuss their training:
|
||||
|
||||
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.
|
||||
sleep, weight, motivation. Reference trends from the context.
|
||||
2. **Nutrition & Steps** — Ask if they'd like to review or adjust their
|
||||
calorie goal and daily steps. Suggest adjustments based on weight trend
|
||||
and activity level.
|
||||
3. **Review** — Look at their recent workouts (from context). Did they
|
||||
complete them? How did each exercise feel?
|
||||
4. **Adjust** — Based on feedback + programming guidelines + history, suggest
|
||||
adjustments for the next session (weight, volume, exercise selection, or
|
||||
rest day)
|
||||
5. **Plan & Save** — Design the next workout with exercises, sets, reps,
|
||||
weights, and notes. **Create it in the database** using the API
|
||||
(`POST /api/agent/workouts` followed by `POST /api/agent/workouts/{id}/sets`
|
||||
for each exercise). Present the plan to the user.
|
||||
6. **Log the check-in** — After the conversation wraps, **create a check-in
|
||||
entry** via the API (`POST /api/agent/checkins`) summarizing the key
|
||||
decisions, metrics, and any adjustments made.
|
||||
|
||||
## Log format
|
||||
## Session Analysis
|
||||
|
||||
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>
|
||||
```
|
||||
When discussing a specific workout, briefly note muscles targeted, the
|
||||
overall goal for the session, and how it fits into their broader training
|
||||
context. Use the workout history from the context to reference progression
|
||||
and past performance.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user