Add opencode serve requirement to README

This commit is contained in:
Jacob Hinkle 2026-06-29 11:48:46 -04:00
parent 15a80d1fd2
commit 9584744d97
24 changed files with 604 additions and 121 deletions

View File

@ -9,7 +9,7 @@ Track workouts, log daily check-ins, explore exercise history, and chat with an
- **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)
- **AI Coach**Persistent chat sidebar (desktop) / full-page chat (mobile) powered by opencode (Big Pickle model, free). Runs as a separate process.
- **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
@ -24,12 +24,17 @@ uv sync
uv run python scripts/schema.py
uv run python scripts/seed.py
# Start the AI coach (in a separate terminal)
opencode serve --port 4096
# Start the dev server
uv run uvicorn app.main:app --reload
```
Open http://localhost:8000, register a user, and you're ready.
> **Note:** Chat requires `opencode serve` running on port 4096. If `opencode` is not in PATH, use `~/.opencode/bin/opencode serve --port 4096`.
## Docker
```bash

View File

@ -1,7 +1,8 @@
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.workout import Phase, Workout, WorkoutSet, WorkoutSnapshot
from app.models.exercise import Exercise
from app.models.checkin import Checkin
from app.models.chat import ChatMessage
from app.models.measurement import MeasurementType, Measurement

24
app/models/measurement.py Normal file
View File

@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, String, Float, Text, ForeignKey
from app.models.base import Base
class MeasurementType(Base):
__tablename__ = "measurement_types"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False, unique=True)
description = Column(Text)
unit = Column(String(50))
instructions = Column(Text)
class Measurement(Base):
__tablename__ = "measurements"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
measurement_type_id = Column(Integer, ForeignKey("measurement_types.id"), nullable=False)
value = Column(Float, nullable=False)
date = Column(String(20), nullable=False)
notes = Column(Text)

View File

@ -10,10 +10,8 @@ class User(Base):
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)
vital_stats = Column(Text)
created_at = Column(String(20))

View File

@ -37,3 +37,14 @@ class WorkoutSet(Base):
weight = Column(Float)
rpe = Column(Float)
notes = Column(Text)
class WorkoutSnapshot(Base):
__tablename__ = "workout_snapshots"
id = Column(Integer, primary_key=True)
workout_id = Column(Integer, ForeignKey("workouts.id"), nullable=False)
changed_by = Column(String(50))
reason = Column(Text)
sets_snapshot = Column(Text) # JSON array
created_at = Column(String(20))

View File

@ -1,3 +1,5 @@
from datetime import datetime, timezone
import json
from fastapi import APIRouter, Header, HTTPException, Depends
from pydantic import BaseModel
from sqlalchemy import select
@ -5,7 +7,7 @@ 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 Phase, Workout, WorkoutSet
from app.models.workout import Phase, Workout, WorkoutSet, WorkoutSnapshot
from app.models.checkin import Checkin
router = APIRouter(prefix="/api/agent", tags=["agent"])
@ -16,6 +18,39 @@ async def verify_agent(x_api_key: str = Header("")):
raise HTTPException(status_code=403, detail="invalid api key")
async def snapshot_sets(workout_id: int, changed_by: str, reason: str):
async with async_session() as session:
result = await session.execute(
select(WorkoutSet)
.where(WorkoutSet.workout_id == workout_id)
.order_by(WorkoutSet.exercise, WorkoutSet.set_number)
)
current_sets = result.scalars().all()
if not current_sets:
return
snapshot_data = [
{
"id": s.id,
"exercise": s.exercise,
"set_number": s.set_number,
"reps": s.reps,
"weight": s.weight,
"rpe": s.rpe,
"notes": s.notes,
}
for s in current_sets
]
snap = WorkoutSnapshot(
workout_id=workout_id,
changed_by=changed_by,
reason=reason,
sets_snapshot=json.dumps(snapshot_data),
created_at=datetime.now(timezone.utc).isoformat(),
)
session.add(snap)
await session.commit()
class CreatePhaseRequest(BaseModel):
name: str
description: str = ""
@ -39,6 +74,17 @@ class CreateSetRequest(BaseModel):
weight: float | None = None
rpe: float | None = None
notes: str = ""
reason: str = ""
class UpdateSetRequest(BaseModel):
exercise: str | None = None
set_number: int | None = None
reps: int | None = None
weight: float | None = None
rpe: float | None = None
notes: str | None = None
reason: str = ""
class CreateCheckinRequest(BaseModel):
@ -86,6 +132,9 @@ async def agent_add_set(
body: CreateSetRequest,
_=Depends(verify_agent),
):
if body.reason:
await snapshot_sets(workout_id, "agent", body.reason)
async with async_session() as session:
ws = WorkoutSet(
workout_id=workout_id,
@ -103,6 +152,71 @@ async def agent_add_set(
return {"id": ws.id, "exercise": ws.exercise, "set_number": ws.set_number}
@router.put("/workouts/{workout_id}/sets/{set_id}")
async def agent_update_set(
workout_id: int,
set_id: int,
body: UpdateSetRequest,
_=Depends(verify_agent),
):
if body.reason:
await snapshot_sets(workout_id, "agent", body.reason)
async with async_session() as session:
result = await session.execute(
select(WorkoutSet).where(
WorkoutSet.id == set_id,
WorkoutSet.workout_id == workout_id,
)
)
ws = result.scalar_one_or_none()
if not ws:
raise HTTPException(status_code=404, detail="set not found")
if body.exercise is not None:
ws.exercise = body.exercise
if body.set_number is not None:
ws.set_number = body.set_number
if body.reps is not None:
ws.reps = body.reps
if body.weight is not None:
ws.weight = body.weight
if body.rpe is not None:
ws.rpe = body.rpe
if body.notes is not None:
ws.notes = body.notes
await session.commit()
return {"id": ws.id, "exercise": ws.exercise, "set_number": ws.set_number}
@router.delete("/workouts/{workout_id}/sets/{set_id}")
async def agent_delete_set(
workout_id: int,
set_id: int,
reason: str = "",
_=Depends(verify_agent),
):
if reason:
await snapshot_sets(workout_id, "agent", reason)
async with async_session() as session:
result = await session.execute(
select(WorkoutSet).where(
WorkoutSet.id == set_id,
WorkoutSet.workout_id == workout_id,
)
)
ws = result.scalar_one_or_none()
if not ws:
raise HTTPException(status_code=404, detail="set not found")
await session.delete(ws)
await session.commit()
return {"deleted": set_id}
@router.put("/workouts/{workout_id}/complete")
async def agent_complete_workout(
workout_id: int,

View File

@ -6,6 +6,7 @@ from sqlalchemy import select
from app.models.base import async_session
from app.models.user import User
from app.models.measurement import Measurement, MeasurementType
from app.auth import hash_password, verify_password, create_session
router = APIRouter()
@ -45,6 +46,11 @@ async def register(
username: str = Form(),
password: str = Form(),
display_name: str = Form(default=""),
initial_weight: float = Form(default=None),
vital_stats: str = Form(default=""),
medical_notes: str = Form(default=""),
goals: str = Form(default=""),
equipment: str = Form(default=""),
):
async with async_session() as session:
result = await session.execute(select(User).where(User.username == username))
@ -57,14 +63,33 @@ async def register(
username=username,
password_hash=hash_password(password),
display_name=display_name or username,
vital_stats=vital_stats,
medical_notes=medical_notes,
goals=goals,
equipment=equipment,
created_at=datetime.now(timezone.utc).isoformat(),
)
session.add(user)
await session.commit()
await session.refresh(user)
if initial_weight is not None:
result = await session.execute(
select(MeasurementType).where(MeasurementType.name == "Weight")
)
wt = result.scalar_one_or_none()
if wt:
session.add(Measurement(
user_id=user.id,
measurement_type_id=wt.id,
value=initial_weight,
date=datetime.now(timezone.utc).strftime("%Y-%m-%d"),
))
await session.commit()
token = await create_session(user.id)
resp = RedirectResponse(url="/dashboard", status_code=303)
resp = RedirectResponse(url="/chat?first=1", status_code=303)
resp.set_cookie(key="session_token", value=token, httponly=True, max_age=86400 * 30)
return resp

View File

@ -1,7 +1,7 @@
from datetime import datetime, timezone
import uuid
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select, desc
@ -10,15 +10,46 @@ 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.models.measurement import Measurement, MeasurementType
from app.auth import get_current_user
from app.services.opencode_proxy import query_opencode
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
ONBOARDING_PROMPT = (
"I am a new user and this is the first interaction with this application. "
"Please review my goals, equipment, medical history, and stats (all in your context). "
"Create a high-level training plan that you can use to make weekly plans and daily workouts. "
"Ask for more clarity whenever you need it. "
"When the workout planning is ready for me to begin, prompt me to switch to the Workouts or Dashboard tab."
)
@router.get("/api/chat/messages")
async def get_chat_messages(request: Request, user: User = Depends(get_current_user)):
session_id = request.cookies.get("chat_session_id")
if not session_id:
return JSONResponse([])
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(desc(ChatMessage.created_at))
.limit(15)
)
rows = result.scalars().all()
rows = list(reversed(rows))
return JSONResponse([
{"role": m.role, "content": m.content, "created_at": m.created_at} for m in rows
])
@router.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request, user: User = Depends(get_current_user)):
async def chat_page(request: Request, user: User = Depends(get_current_user), first: int = 0):
session_id = request.cookies.get("chat_session_id")
if not session_id:
session_id = str(uuid.uuid4())
@ -38,6 +69,8 @@ async def chat_page(request: Request, user: User = Depends(get_current_user)):
"user": user,
"messages": messages,
"session_id": session_id,
"first": first,
"onboarding_prompt": ONBOARDING_PROMPT,
})
resp.set_cookie(key="chat_session_id", value=session_id, httponly=True, max_age=86400 * 30)
return resp
@ -68,6 +101,18 @@ async def chat_send(
)
recent_checkins = result.scalars().all()
result = await session.execute(
select(Measurement)
.join(MeasurementType, Measurement.measurement_type_id == MeasurementType.id)
.where(
Measurement.user_id == user.id,
MeasurementType.name == "Weight",
)
.order_by(desc(Measurement.date))
.limit(1)
)
latest_weight = result.scalar_one_or_none()
workout_lines = []
for w in recent_workouts:
workout_lines.append(f" {w.date}{w.name} ({w.status})")
@ -86,14 +131,15 @@ async def chat_send(
parts.append(f"sleep={c.sleep_hours}h")
checkin_lines.append(f" {c.date}{' | '.join(parts)}")
weight_str = f"{latest_weight.value} lb" if latest_weight else "Not recorded"
user_context = (
f"Username: {user.username}. "
f"Weight: {user.weight_lb} lb. "
f"Weight: {weight_str}. "
f"Goals: {user.goals or 'Not specified'}. "
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'}. "
f"Vital stats: {user.vital_stats or 'Not specified'}. "
)
if recent_workouts:
user_context += "Recent workouts:\n" + "\n".join(workout_lines) + ". "

View File

@ -8,6 +8,7 @@ from app.models.base import async_session
from app.models.user import User
from app.models.workout import Phase, Workout
from app.models.checkin import Checkin
from app.models.measurement import Measurement, MeasurementType
from app.auth import get_current_user
router = APIRouter()
@ -64,6 +65,18 @@ async def dashboard(request: Request, user: User = Depends(get_current_user)):
)
this_week = result.scalars().all()
result = await session.execute(
select(Measurement)
.join(MeasurementType, Measurement.measurement_type_id == MeasurementType.id)
.where(
Measurement.user_id == user.id,
MeasurementType.name == "Weight",
)
.order_by(desc(Measurement.date))
.limit(1)
)
latest_weight = result.scalar_one_or_none()
return templates.TemplateResponse(request, "dashboard.html", {
"user": user,
"recent_workouts": recent_workouts,
@ -72,4 +85,5 @@ async def dashboard(request: Request, user: User = Depends(get_current_user)):
"this_week": this_week,
"week_start": week_start,
"week_end": week_end,
"latest_weight": latest_weight.value if latest_weight else None,
})

View File

@ -22,9 +22,7 @@ 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),
vital_stats: str = Form(default=""),
medical_notes: str = Form(default=""),
goals: str = Form(default=""),
equipment: str = Form(default=""),
@ -33,12 +31,7 @@ async def update_profile(
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.vital_stats = vital_stats
user.medical_notes = medical_notes
user.goals = goals
user.equipment = equipment

View File

@ -142,6 +142,7 @@ async def add_set(
@router.post("/workouts/{workout_id}/complete")
async def complete_workout(
request: Request,
workout_id: int,
user: User = Depends(get_current_user),
):
@ -157,4 +158,24 @@ async def complete_workout(
workout.status = "complete"
await session.commit()
return RedirectResponse(url=f"/workouts/{workout_id}", status_code=303)
if not workout:
return RedirectResponse(url="/workouts", status_code=303)
result = await session.execute(
select(WorkoutSet)
.where(WorkoutSet.workout_id == workout_id)
.order_by(WorkoutSet.exercise, WorkoutSet.set_number)
)
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,
})

113
app/static/chat-sidebar.js Normal file
View File

@ -0,0 +1,113 @@
(function() {
const sidebar = document.getElementById('chat-sidebar');
if (!sidebar) return;
const messagesEl = document.getElementById('chat-sidebar-messages');
const form = document.getElementById('chat-sidebar-form');
const input = document.getElementById('chat-sidebar-input');
const status = document.getElementById('chat-sidebar-status');
function setStatus(msg, isError) {
if (status) {
status.textContent = msg;
status.style.color = isError ? 'var(--del-color, #b33)' : 'var(--muted-color, #888)';
}
}
function appendMessage(role, content) {
const div = document.createElement('div');
div.className = 'chat-message ' + role;
div.innerHTML = '<p>' + content + '</p>';
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function removeLoading() {
const ld = messagesEl.querySelector('.chat-sidebar-loading');
if (ld) ld.remove();
}
async function loadMessages() {
try {
const resp = await fetch('/api/chat/messages');
if (!resp.ok) return;
const data = await resp.json();
removeLoading();
for (const m of data) {
appendMessage(m.role, m.content);
}
} catch (e) {
removeLoading();
messagesEl.innerHTML = '<p class="chat-sidebar-loading">Could not load messages.</p>';
}
}
async function sendMessage(event) {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
removeLoading();
appendMessage('user', text);
input.value = '';
input.disabled = true;
setStatus('Waiting...');
const assistantDiv = document.createElement('div');
assistantDiv.className = 'chat-message assistant';
assistantDiv.innerHTML = '<p><em>Thinking...</em></p>';
messagesEl.appendChild(assistantDiv);
try {
const formData = new FormData();
formData.append('message', text);
const response = await fetch('/chat', { method: 'POST', body: formData });
if (!response.ok) throw new Error('Server returned ' + response.status);
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);
}
}
messagesEl.scrollTop = messagesEl.scrollHeight;
}
if (!p.innerHTML.trim()) {
p.innerHTML = '<em>No response.</em>';
}
setStatus('');
} catch (err) {
assistantDiv.querySelector('p').innerHTML = '<em>Error.</em>';
setStatus('Connection error. Is opencode serve running?', true);
}
input.disabled = false;
input.focus();
}
if (form) form.addEventListener('submit', sendMessage);
if (input) {
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.dispatchEvent(new Event('submit'));
}
});
}
loadMessages();
})();

View File

@ -34,26 +34,6 @@
background: var(--card-sectionning-background-color);
}
.chat-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.chat-input textarea {
width: 100%;
resize: vertical;
min-height: 2.5rem;
box-sizing: border-box;
}
.chat-input button {
width: auto;
align-self: flex-end;
padding: 0.25rem 1rem;
font-size: 0.9rem;
}
.chat-status {
margin-top: 0.25rem;
font-size: 0.85rem;
@ -62,3 +42,91 @@
.nav-link.active {
font-weight: bold;
}
/* App layout with sidebar */
.app-layout {
display: flex;
gap: 1rem;
max-width: var(--pico-fluid-max-width, 1200px);
margin: 0 auto;
padding: 0 var(--pico-spacing, 1rem);
}
#main-content {
flex: 1;
min-width: 0;
}
#chat-sidebar {
width: 360px;
flex-shrink: 0;
position: sticky;
top: 0;
height: calc(100vh - 4rem);
overflow-y: auto;
border-left: 1px solid var(--muted-border-color, #ccc);
padding-left: 1rem;
}
.app-layout.no-sidebar #chat-sidebar {
display: none;
}
/* Sidebar chat components */
.chat-sidebar-header {
padding: 0.5rem 0;
border-bottom: 1px solid var(--muted-border-color, #ccc);
margin-bottom: 0.5rem;
}
.chat-sidebar-messages {
max-height: calc(100vh - 14rem);
overflow-y: auto;
margin-bottom: 0.5rem;
}
.chat-sidebar-messages .chat-message {
padding: 0.4rem 0.6rem;
margin-bottom: 0.4rem;
font-size: 0.9rem;
}
.chat-sidebar-messages .chat-message p {
margin: 0;
}
.chat-sidebar-loading {
color: var(--muted-color, #888);
text-align: center;
font-size: 0.85rem;
}
.chat-sidebar-form {
display: flex;
gap: 0.3rem;
align-items: flex-end;
}
.chat-sidebar-form textarea {
flex: 1;
resize: none;
min-height: 2rem;
font-size: 0.9rem;
padding: 0.3rem;
}
.chat-sidebar-form button {
width: auto;
padding: 0.2rem 0.8rem;
font-size: 0.85rem;
margin: 0;
}
@media (max-width: 767px) {
#chat-sidebar {
display: none;
}
.app-layout {
padding: 0;
}
}

View File

@ -6,13 +6,15 @@
<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">
<script src="https://unpkg.com/htmx.org@2"></script>
<script src="/static/chat-sidebar.js" defer></script>
</head>
<body>
<nav class="container-fluid">
<ul>
<li><strong><a href="/dashboard" class="contrast">Fitness</a></strong></li>
</ul>
<ul>
<ul hx-boost="true" hx-target="#main-content" hx-select="#main-content" hx-swap="innerHTML" hx-push-url="true">
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/plan">Plan</a></li>
<li><a href="/workouts">Workouts</a></li>
@ -20,11 +22,18 @@
<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>
<li><a href="/logout" hx-boost="false">Logout</a></li>
</ul>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<div class="app-layout {% if hide_sidebar %}no-sidebar{% endif %}">
<main id="main-content">
{% block content %}{% endblock %}
</main>
<aside id="chat-sidebar">
{% if not hide_sidebar %}
{% include "chat_sidebar.html" %}
{% endif %}
</aside>
</div>
</body>
</html>
</html>

View File

@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block title %}AI Coach{% endblock %}
{% set hide_sidebar = True %}
{% block content %}
<h1>AI Coach</h1>
@ -12,7 +13,8 @@
{% endfor %}
</div>
<form class="chat-input" id="chat-form" onsubmit="sendMessage(event)">
<form class="chat-input" id="chat-form" onsubmit="sendMessage(event)"
data-first="{{ first }}" data-onboarding="{{ onboarding_prompt|e }}">
<textarea id="chat-input" placeholder="Ask your coach..." rows="3" required autocomplete="off"></textarea>
<button type="submit" id="chat-send-btn">Send</button>
</form>
@ -25,6 +27,9 @@ const chatForm = document.getElementById('chat-form');
const chatSendBtn = document.getElementById('chat-send-btn');
const chatStatus = document.getElementById('chat-status');
const IS_FIRST = chatForm.dataset.first === '1';
const ONBOARDING_PROMPT = chatForm.dataset.onboarding;
chatInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@ -106,5 +111,10 @@ async function sendMessage(event) {
chatSendBtn.disabled = false;
}
}
if (IS_FIRST && ONBOARDING_PROMPT) {
chatInput.value = ONBOARDING_PROMPT;
chatForm.dispatchEvent(new Event('submit'));
}
</script>
{% endblock %}

View File

@ -0,0 +1,11 @@
<div class="chat-sidebar-header">
<strong>AI Coach</strong>
</div>
<div id="chat-sidebar-messages" class="chat-sidebar-messages">
<p class="chat-sidebar-loading">Loading...</p>
</div>
<form id="chat-sidebar-form" class="chat-sidebar-form">
<textarea id="chat-sidebar-input" rows="2" placeholder="Ask your coach..." autocomplete="off"></textarea>
<button type="submit">Send</button>
</form>
<p id="chat-sidebar-status" class="chat-status"></p>

View File

@ -6,15 +6,7 @@
<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>
<p style="font-size: 2rem;">{{ latest_weight or '—' }} lb</p>
</article>
</div>

View File

@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% set hide_sidebar = True %}
{% block content %}
<article style="max-width: 400px; margin: 4rem auto;">
<h1>Login</h1>

View File

@ -7,20 +7,11 @@
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>
Vital Stats
<textarea name="vital_stats" rows="5" placeholder="Birth Date:&#10;Height:&#10;Gender:&#10;Other:">{{ user.vital_stats or '' }}</textarea>
</label>
<small>Free-form stats passed to your AI coach.</small>
<label>
Medical Notes
<textarea name="medical_notes" rows="4">{{ user.medical_notes or '' }}</textarea>

View File

@ -1,26 +1,54 @@
{% extends "base.html" %}
{% block title %}Register{% endblock %}
{% set hide_sidebar = True %}
{% block content %}
<article style="max-width: 400px; margin: 4rem auto;">
<article style="max-width: 600px; margin: 4rem auto;">
<h1>Register</h1>
{% if error %}
<p style="color: var(--red)">{{ error }}</p>
{% endif %}
<form method="post" action="/register">
<h3>Account</h3>
<label>
Username
<input type="text" name="username" required autocomplete="username">
</label>
<label>
Password
<input type="password" name="password" required autocomplete="new-password">
</label>
<h3>Profile</h3>
<label>
Display Name
<input type="text" name="display_name" autocomplete="name">
</label>
<label>
Password
<input type="password" name="password" required autocomplete="new-password">
Initial Weight (lb)
<input type="number" name="initial_weight" step="0.1">
</label>
<label>
Vital Stats
<textarea name="vital_stats" rows="5" placeholder="Birth Date:&#10;Height:&#10;Gender:&#10;Other:"></textarea>
</label>
<small>Free-form stats passed to your AI coach. Fill in what you want.</small>
<h3>Background</h3>
<label>
Medical Notes
<textarea name="medical_notes" rows="3" placeholder="Injuries, conditions, limitations..."></textarea>
</label>
<label>
Goals
<textarea name="goals" rows="3" placeholder="Weight control, strength, endurance..."></textarea>
</label>
<label>
Equipment
<textarea name="equipment" rows="3" placeholder="Dumbbells, bench, bands, pull-up bar..."></textarea>
</label>
<button type="submit">Register</button>
</form>
<p>Already have an account? <a href="/login">Login</a></p>
</article>
{% endblock %}
{% endblock %}

View File

@ -40,44 +40,13 @@
<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>
<button type="submit" class="secondary"
hx-post="/workouts/{{ workout.id }}/complete"
hx-target="#main-content"
hx-select="#main-content"
hx-swap="innerHTML"
hx-push-url="/workouts/{{ workout.id }}">Mark Complete</button>
{% endif %}
<a href="/workouts">Back to Workouts</a>

0
fitness.db Normal file
View File

View File

@ -50,6 +50,15 @@ 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.
- **Chat sidebar is always available.** Your chat interface appears on the
right side of the app on desktop. The user can message you from any page
(dashboard, workout detail, etc.). Be responsive to mid-workout questions
and adjustments.
- **Post-workout follow-up.** After a workout is marked complete, proactively
ask how it went and whether the user should rest or train next. Suggest
adjustments to the weekly plan and create the next workout in the database.
The sidebar context includes the user's recent workout status, so check
whether the most recent workout was just completed.
## API — Creating Workouts and Check-ins
@ -77,7 +86,19 @@ 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? }
Body: { exercise, set_number, reps?, weight?, rpe?, notes?, reason? }
```
Include `reason` when making changes mid-workout — it triggers a snapshot.
**Update a set (mid-workout adjustments):**
```
PUT /api/agent/workouts/{id}/sets/{set_id}
Body: { exercise?, set_number?, reps?, weight?, rpe?, notes?, reason? }
```
**Delete a set:**
```
DELETE /api/agent/workouts/{id}/sets/{set_id}?reason=...
```
**Mark a workout complete:**
@ -110,6 +131,10 @@ Body: { name, description?, start_date?, end_date?, notes? }
Always use the username from the context provided with each message.
Whenever you modify a workout's sets (add, update, delete), include a
`reason` field describing why. This triggers a snapshot of the sets before
the change, preserving the previous state for review.
## Managing the Training Plan
You maintain a training plan broken into phases. The plan lives in the

View File

@ -1,5 +1,5 @@
"""
Seed the database with initial data (exercises, phases).
Seed the database with initial data (exercises, phases, measurement types).
Run with: python scripts/seed.py
"""
import asyncio
@ -12,10 +12,10 @@ from sqlalchemy import select
from app.models import async_session
from app.models.exercise import Exercise
from app.models.workout import Phase
from app.models.measurement import MeasurementType
EXERCISES = [
# Push
("Bench Press", "chest", "barbell, bench"),
("Incline Dumbbell Press", "chest", "dumbbell, bench"),
("Overhead Press", "shoulders", "barbell"),
@ -24,7 +24,6 @@ EXERCISES = [
("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"),
@ -32,7 +31,6 @@ EXERCISES = [
("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"),
@ -42,19 +40,16 @@ EXERCISES = [
("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"),
@ -69,6 +64,17 @@ PHASES = [
("Strength Building", "Phase 3: Normal training. RPE 8-9, full ROM.", None, None, "Weeks 8+"),
]
MEASUREMENT_TYPES = [
("Weight", "Body weight measurement", "lb", "Weigh yourself first thing in the morning, after using the bathroom, before eating or drinking."),
("Waist", "Waist circumference", "in", "Measure at the narrowest point of your waist, typically just above the belly button."),
("Neck", "Neck circumference", "in", "Measure at the narrowest point, just below the Adam's apple."),
("Chest", "Chest circumference", "in", "Measure at the fullest part of the chest, arms relaxed at sides."),
("Arm", "Upper arm circumference", "in", "Measure at the midpoint between shoulder and elbow, arm relaxed."),
("Thigh", "Thigh circumference", "in", "Measure at the midpoint between hip and knee."),
("Hip", "Hip circumference", "in", "Measure at the widest part of the hips/glutes."),
("Body Fat", "Estimated body fat percentage", "%", "Use calipers or smart scale. Consistent method is more important than absolute accuracy."),
]
async def seed():
async with async_session() as session:
@ -88,6 +94,14 @@ async def seed():
session.add(Phase(name=name, description=desc, start_date=start, end_date=end, notes=notes))
print(f"Seeded {len(PHASES)} phases.")
result = await session.execute(select(MeasurementType).limit(1))
if result.scalar_one_or_none():
print("Measurement types already seeded, skipping.")
else:
for name, desc, unit, instructions in MEASUREMENT_TYPES:
session.add(MeasurementType(name=name, description=desc, unit=unit, instructions=instructions))
print(f"Seeded {len(MEASUREMENT_TYPES)} measurement types.")
await session.commit()