Add opencode serve requirement to README
This commit is contained in:
parent
15a80d1fd2
commit
9584744d97
@ -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)
|
- **Workouts** — Plan and log workouts with set-level detail (reps, weight, RPE)
|
||||||
- **Exercises** — Catalog with body-part filtering and history
|
- **Exercises** — Catalog with body-part filtering and history
|
||||||
- **Check-ins** — Daily weight, calories, steps, sleep tracking
|
- **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
|
- **Multi-user** — Login-based, each user has independent data
|
||||||
- **Calendar view** — See your training history at a glance
|
- **Calendar view** — See your training history at a glance
|
||||||
- **NixOS-ready** — Docker-based deploy with provided NixOS module
|
- **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/schema.py
|
||||||
uv run python scripts/seed.py
|
uv run python scripts/seed.py
|
||||||
|
|
||||||
|
# Start the AI coach (in a separate terminal)
|
||||||
|
opencode serve --port 4096
|
||||||
|
|
||||||
# Start the dev server
|
# Start the dev server
|
||||||
uv run uvicorn app.main:app --reload
|
uv run uvicorn app.main:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:8000, register a user, and you're ready.
|
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
|
## Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
from app.models.base import Base, engine, async_session
|
from app.models.base import Base, engine, async_session
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.auth import Session
|
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.exercise import Exercise
|
||||||
from app.models.checkin import Checkin
|
from app.models.checkin import Checkin
|
||||||
from app.models.chat import ChatMessage
|
from app.models.chat import ChatMessage
|
||||||
|
from app.models.measurement import MeasurementType, Measurement
|
||||||
|
|||||||
24
app/models/measurement.py
Normal file
24
app/models/measurement.py
Normal 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)
|
||||||
@ -10,10 +10,8 @@ class User(Base):
|
|||||||
username = Column(String(50), unique=True, nullable=False)
|
username = Column(String(50), unique=True, nullable=False)
|
||||||
password_hash = Column(String(255), nullable=False)
|
password_hash = Column(String(255), nullable=False)
|
||||||
display_name = Column(String(100))
|
display_name = Column(String(100))
|
||||||
weight_lb = Column(Float)
|
|
||||||
calorie_goal = Column(Integer)
|
|
||||||
step_goal = Column(Integer)
|
|
||||||
medical_notes = Column(Text)
|
medical_notes = Column(Text)
|
||||||
goals = Column(Text)
|
goals = Column(Text)
|
||||||
equipment = Column(Text)
|
equipment = Column(Text)
|
||||||
|
vital_stats = Column(Text)
|
||||||
created_at = Column(String(20))
|
created_at = Column(String(20))
|
||||||
|
|||||||
@ -37,3 +37,14 @@ class WorkoutSet(Base):
|
|||||||
weight = Column(Float)
|
weight = Column(Float)
|
||||||
rpe = Column(Float)
|
rpe = Column(Float)
|
||||||
notes = Column(Text)
|
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))
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
from fastapi import APIRouter, Header, HTTPException, Depends
|
from fastapi import APIRouter, Header, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@ -5,7 +7,7 @@ from sqlalchemy import select
|
|||||||
from app.config import AGENT_API_KEY
|
from app.config import AGENT_API_KEY
|
||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
from app.models.user import User
|
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
|
from app.models.checkin import Checkin
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/agent", tags=["agent"])
|
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")
|
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):
|
class CreatePhaseRequest(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
@ -39,6 +74,17 @@ class CreateSetRequest(BaseModel):
|
|||||||
weight: float | None = None
|
weight: float | None = None
|
||||||
rpe: float | None = None
|
rpe: float | None = None
|
||||||
notes: str = ""
|
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):
|
class CreateCheckinRequest(BaseModel):
|
||||||
@ -86,6 +132,9 @@ async def agent_add_set(
|
|||||||
body: CreateSetRequest,
|
body: CreateSetRequest,
|
||||||
_=Depends(verify_agent),
|
_=Depends(verify_agent),
|
||||||
):
|
):
|
||||||
|
if body.reason:
|
||||||
|
await snapshot_sets(workout_id, "agent", body.reason)
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
ws = WorkoutSet(
|
ws = WorkoutSet(
|
||||||
workout_id=workout_id,
|
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}
|
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")
|
@router.put("/workouts/{workout_id}/complete")
|
||||||
async def agent_complete_workout(
|
async def agent_complete_workout(
|
||||||
workout_id: int,
|
workout_id: int,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.measurement import Measurement, MeasurementType
|
||||||
from app.auth import hash_password, verify_password, create_session
|
from app.auth import hash_password, verify_password, create_session
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -45,6 +46,11 @@ async def register(
|
|||||||
username: str = Form(),
|
username: str = Form(),
|
||||||
password: str = Form(),
|
password: str = Form(),
|
||||||
display_name: str = Form(default=""),
|
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:
|
async with async_session() as session:
|
||||||
result = await session.execute(select(User).where(User.username == username))
|
result = await session.execute(select(User).where(User.username == username))
|
||||||
@ -57,14 +63,33 @@ async def register(
|
|||||||
username=username,
|
username=username,
|
||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
display_name=display_name or username,
|
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(),
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
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)
|
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)
|
resp.set_cookie(key="session_token", value=token, httponly=True, max_age=86400 * 30)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import uuid
|
import uuid
|
||||||
from fastapi import APIRouter, Request, Depends, Form
|
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 fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import select, desc
|
from sqlalchemy import select, desc
|
||||||
|
|
||||||
@ -10,15 +10,46 @@ from app.models.user import User
|
|||||||
from app.models.chat import ChatMessage
|
from app.models.chat import ChatMessage
|
||||||
from app.models.workout import Workout
|
from app.models.workout import Workout
|
||||||
from app.models.checkin import Checkin
|
from app.models.checkin import Checkin
|
||||||
|
from app.models.measurement import Measurement, MeasurementType
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.services.opencode_proxy import query_opencode
|
from app.services.opencode_proxy import query_opencode
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
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)
|
@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")
|
session_id = request.cookies.get("chat_session_id")
|
||||||
if not session_id:
|
if not session_id:
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
@ -38,6 +69,8 @@ async def chat_page(request: Request, user: User = Depends(get_current_user)):
|
|||||||
"user": user,
|
"user": user,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"session_id": session_id,
|
"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)
|
resp.set_cookie(key="chat_session_id", value=session_id, httponly=True, max_age=86400 * 30)
|
||||||
return resp
|
return resp
|
||||||
@ -68,6 +101,18 @@ async def chat_send(
|
|||||||
)
|
)
|
||||||
recent_checkins = result.scalars().all()
|
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 = []
|
workout_lines = []
|
||||||
for w in recent_workouts:
|
for w in recent_workouts:
|
||||||
workout_lines.append(f" {w.date} — {w.name} ({w.status})")
|
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")
|
parts.append(f"sleep={c.sleep_hours}h")
|
||||||
checkin_lines.append(f" {c.date} — {' | '.join(parts)}")
|
checkin_lines.append(f" {c.date} — {' | '.join(parts)}")
|
||||||
|
|
||||||
|
weight_str = f"{latest_weight.value} lb" if latest_weight else "Not recorded"
|
||||||
|
|
||||||
user_context = (
|
user_context = (
|
||||||
f"Username: {user.username}. "
|
f"Username: {user.username}. "
|
||||||
f"Weight: {user.weight_lb} lb. "
|
f"Weight: {weight_str}. "
|
||||||
f"Goals: {user.goals or 'Not specified'}. "
|
f"Goals: {user.goals or 'Not specified'}. "
|
||||||
f"Equipment: {user.equipment or 'Not specified'}. "
|
f"Equipment: {user.equipment or 'Not specified'}. "
|
||||||
f"Medical: {user.medical_notes or 'None'}. "
|
f"Medical: {user.medical_notes or 'None'}. "
|
||||||
f"Calorie goal: {user.calorie_goal or 'Not set'}. "
|
f"Vital stats: {user.vital_stats or 'Not specified'}. "
|
||||||
f"Step goal: {user.step_goal or 'Not set'}. "
|
|
||||||
)
|
)
|
||||||
if recent_workouts:
|
if recent_workouts:
|
||||||
user_context += "Recent workouts:\n" + "\n".join(workout_lines) + ". "
|
user_context += "Recent workouts:\n" + "\n".join(workout_lines) + ". "
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from app.models.base import async_session
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.workout import Phase, Workout
|
from app.models.workout import Phase, Workout
|
||||||
from app.models.checkin import Checkin
|
from app.models.checkin import Checkin
|
||||||
|
from app.models.measurement import Measurement, MeasurementType
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -64,6 +65,18 @@ async def dashboard(request: Request, user: User = Depends(get_current_user)):
|
|||||||
)
|
)
|
||||||
this_week = result.scalars().all()
|
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", {
|
return templates.TemplateResponse(request, "dashboard.html", {
|
||||||
"user": user,
|
"user": user,
|
||||||
"recent_workouts": recent_workouts,
|
"recent_workouts": recent_workouts,
|
||||||
@ -72,4 +85,5 @@ async def dashboard(request: Request, user: User = Depends(get_current_user)):
|
|||||||
"this_week": this_week,
|
"this_week": this_week,
|
||||||
"week_start": week_start,
|
"week_start": week_start,
|
||||||
"week_end": week_end,
|
"week_end": week_end,
|
||||||
|
"latest_weight": latest_weight.value if latest_weight else None,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -22,9 +22,7 @@ async def update_profile(
|
|||||||
request: Request,
|
request: Request,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
display_name: str = Form(default=None),
|
display_name: str = Form(default=None),
|
||||||
weight_lb: float = Form(default=None),
|
vital_stats: str = Form(default=""),
|
||||||
calorie_goal: int = Form(default=None),
|
|
||||||
step_goal: int = Form(default=None),
|
|
||||||
medical_notes: str = Form(default=""),
|
medical_notes: str = Form(default=""),
|
||||||
goals: str = Form(default=""),
|
goals: str = Form(default=""),
|
||||||
equipment: str = Form(default=""),
|
equipment: str = Form(default=""),
|
||||||
@ -33,12 +31,7 @@ async def update_profile(
|
|||||||
session.add(user)
|
session.add(user)
|
||||||
if display_name is not None:
|
if display_name is not None:
|
||||||
user.display_name = display_name
|
user.display_name = display_name
|
||||||
if weight_lb is not None:
|
user.vital_stats = vital_stats
|
||||||
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.medical_notes = medical_notes
|
||||||
user.goals = goals
|
user.goals = goals
|
||||||
user.equipment = equipment
|
user.equipment = equipment
|
||||||
|
|||||||
@ -142,6 +142,7 @@ async def add_set(
|
|||||||
|
|
||||||
@router.post("/workouts/{workout_id}/complete")
|
@router.post("/workouts/{workout_id}/complete")
|
||||||
async def complete_workout(
|
async def complete_workout(
|
||||||
|
request: Request,
|
||||||
workout_id: int,
|
workout_id: int,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@ -157,4 +158,24 @@ async def complete_workout(
|
|||||||
workout.status = "complete"
|
workout.status = "complete"
|
||||||
await session.commit()
|
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
113
app/static/chat-sidebar.js
Normal 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();
|
||||||
|
})();
|
||||||
@ -34,26 +34,6 @@
|
|||||||
background: var(--card-sectionning-background-color);
|
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 {
|
.chat-status {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@ -62,3 +42,91 @@
|
|||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
font-weight: bold;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,13 +6,15 @@
|
|||||||
<title>{% block title %}Fitness{% endblock %}</title>
|
<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="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="container-fluid">
|
<nav class="container-fluid">
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong><a href="/dashboard" class="contrast">Fitness</a></strong></li>
|
<li><strong><a href="/dashboard" class="contrast">Fitness</a></strong></li>
|
||||||
</ul>
|
</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="/dashboard">Dashboard</a></li>
|
||||||
<li><a href="/plan">Plan</a></li>
|
<li><a href="/plan">Plan</a></li>
|
||||||
<li><a href="/workouts">Workouts</a></li>
|
<li><a href="/workouts">Workouts</a></li>
|
||||||
@ -20,11 +22,18 @@
|
|||||||
<li><a href="/checkins">Check-ins</a></li>
|
<li><a href="/checkins">Check-ins</a></li>
|
||||||
<li><a href="/chat">Chat</a></li>
|
<li><a href="/chat">Chat</a></li>
|
||||||
<li><a href="/profile">Profile</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>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<main class="container">
|
<div class="app-layout {% if hide_sidebar %}no-sidebar{% endif %}">
|
||||||
{% block content %}{% endblock %}
|
<main id="main-content">
|
||||||
</main>
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<aside id="chat-sidebar">
|
||||||
|
{% if not hide_sidebar %}
|
||||||
|
{% include "chat_sidebar.html" %}
|
||||||
|
{% endif %}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -1,5 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}AI Coach{% endblock %}
|
{% block title %}AI Coach{% endblock %}
|
||||||
|
{% set hide_sidebar = True %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>AI Coach</h1>
|
<h1>AI Coach</h1>
|
||||||
|
|
||||||
@ -12,7 +13,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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>
|
<textarea id="chat-input" placeholder="Ask your coach..." rows="3" required autocomplete="off"></textarea>
|
||||||
<button type="submit" id="chat-send-btn">Send</button>
|
<button type="submit" id="chat-send-btn">Send</button>
|
||||||
</form>
|
</form>
|
||||||
@ -25,6 +27,9 @@ const chatForm = document.getElementById('chat-form');
|
|||||||
const chatSendBtn = document.getElementById('chat-send-btn');
|
const chatSendBtn = document.getElementById('chat-send-btn');
|
||||||
const chatStatus = document.getElementById('chat-status');
|
const chatStatus = document.getElementById('chat-status');
|
||||||
|
|
||||||
|
const IS_FIRST = chatForm.dataset.first === '1';
|
||||||
|
const ONBOARDING_PROMPT = chatForm.dataset.onboarding;
|
||||||
|
|
||||||
chatInput.addEventListener('keydown', function(e) {
|
chatInput.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -106,5 +111,10 @@ async function sendMessage(event) {
|
|||||||
chatSendBtn.disabled = false;
|
chatSendBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IS_FIRST && ONBOARDING_PROMPT) {
|
||||||
|
chatInput.value = ONBOARDING_PROMPT;
|
||||||
|
chatForm.dispatchEvent(new Event('submit'));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
11
app/templates/chat_sidebar.html
Normal file
11
app/templates/chat_sidebar.html
Normal 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>
|
||||||
@ -6,15 +6,7 @@
|
|||||||
<div class="grid">
|
<div class="grid">
|
||||||
<article>
|
<article>
|
||||||
<h3>Weight</h3>
|
<h3>Weight</h3>
|
||||||
<p style="font-size: 2rem;">{{ user.weight_lb or '—' }} lb</p>
|
<p style="font-size: 2rem;">{{ latest_weight 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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Login{% endblock %}
|
{% block title %}Login{% endblock %}
|
||||||
|
{% set hide_sidebar = True %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<article style="max-width: 400px; margin: 4rem auto;">
|
<article style="max-width: 400px; margin: 4rem auto;">
|
||||||
<h1>Login</h1>
|
<h1>Login</h1>
|
||||||
|
|||||||
@ -7,20 +7,11 @@
|
|||||||
Display Name
|
Display Name
|
||||||
<input type="text" name="display_name" value="{{ user.display_name or '' }}">
|
<input type="text" name="display_name" value="{{ user.display_name or '' }}">
|
||||||
</label>
|
</label>
|
||||||
<div class="grid">
|
<label>
|
||||||
<label>
|
Vital Stats
|
||||||
Weight (lb)
|
<textarea name="vital_stats" rows="5" placeholder="Birth Date: Height: Gender: Other:">{{ user.vital_stats or '' }}</textarea>
|
||||||
<input type="number" name="weight_lb" step="0.1" value="{{ user.weight_lb or '' }}">
|
</label>
|
||||||
</label>
|
<small>Free-form stats passed to your AI coach.</small>
|
||||||
<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>
|
<label>
|
||||||
Medical Notes
|
Medical Notes
|
||||||
<textarea name="medical_notes" rows="4">{{ user.medical_notes or '' }}</textarea>
|
<textarea name="medical_notes" rows="4">{{ user.medical_notes or '' }}</textarea>
|
||||||
|
|||||||
@ -1,26 +1,54 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Register{% endblock %}
|
{% block title %}Register{% endblock %}
|
||||||
|
{% set hide_sidebar = True %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<article style="max-width: 400px; margin: 4rem auto;">
|
<article style="max-width: 600px; margin: 4rem auto;">
|
||||||
<h1>Register</h1>
|
<h1>Register</h1>
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<p style="color: var(--red)">{{ error }}</p>
|
<p style="color: var(--red)">{{ error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="/register">
|
<form method="post" action="/register">
|
||||||
|
<h3>Account</h3>
|
||||||
<label>
|
<label>
|
||||||
Username
|
Username
|
||||||
<input type="text" name="username" required autocomplete="username">
|
<input type="text" name="username" required autocomplete="username">
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" name="password" required autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<h3>Profile</h3>
|
||||||
<label>
|
<label>
|
||||||
Display Name
|
Display Name
|
||||||
<input type="text" name="display_name" autocomplete="name">
|
<input type="text" name="display_name" autocomplete="name">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Password
|
Initial Weight (lb)
|
||||||
<input type="password" name="password" required autocomplete="new-password">
|
<input type="number" name="initial_weight" step="0.1">
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Vital Stats
|
||||||
|
<textarea name="vital_stats" rows="5" placeholder="Birth Date: Height: Gender: 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>
|
<button type="submit">Register</button>
|
||||||
</form>
|
</form>
|
||||||
<p>Already have an account? <a href="/login">Login</a></p>
|
<p>Already have an account? <a href="/login">Login</a></p>
|
||||||
</article>
|
</article>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -40,44 +40,13 @@
|
|||||||
<p>No sets logged yet.</p>
|
<p>No sets logged yet.</p>
|
||||||
{% endif %}
|
{% 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" %}
|
{% if workout.status == "plan" %}
|
||||||
<form method="post" action="/workouts/{{ workout.id }}/complete">
|
<button type="submit" class="secondary"
|
||||||
<button type="submit" class="secondary">Mark Complete</button>
|
hx-post="/workouts/{{ workout.id }}/complete"
|
||||||
</form>
|
hx-target="#main-content"
|
||||||
|
hx-select="#main-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-push-url="/workouts/{{ workout.id }}">Mark Complete</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="/workouts">Back to Workouts</a>
|
<a href="/workouts">Back to Workouts</a>
|
||||||
|
|||||||
0
fitness.db
Normal file
0
fitness.db
Normal 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
|
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
|
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.
|
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
|
## API — Creating Workouts and Check-ins
|
||||||
|
|
||||||
@ -77,7 +86,19 @@ Body: { username, name, date, phase_id?, notes? }
|
|||||||
**Add a set to a workout:**
|
**Add a set to a workout:**
|
||||||
```
|
```
|
||||||
POST /api/agent/workouts/{id}/sets
|
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:**
|
**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.
|
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
|
## Managing the Training Plan
|
||||||
|
|
||||||
You maintain a training plan broken into phases. The plan lives in the
|
You maintain a training plan broken into phases. The plan lives in the
|
||||||
|
|||||||
@ -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
|
Run with: python scripts/seed.py
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -12,10 +12,10 @@ from sqlalchemy import select
|
|||||||
from app.models import async_session
|
from app.models import async_session
|
||||||
from app.models.exercise import Exercise
|
from app.models.exercise import Exercise
|
||||||
from app.models.workout import Phase
|
from app.models.workout import Phase
|
||||||
|
from app.models.measurement import MeasurementType
|
||||||
|
|
||||||
|
|
||||||
EXERCISES = [
|
EXERCISES = [
|
||||||
# Push
|
|
||||||
("Bench Press", "chest", "barbell, bench"),
|
("Bench Press", "chest", "barbell, bench"),
|
||||||
("Incline Dumbbell Press", "chest", "dumbbell, bench"),
|
("Incline Dumbbell Press", "chest", "dumbbell, bench"),
|
||||||
("Overhead Press", "shoulders", "barbell"),
|
("Overhead Press", "shoulders", "barbell"),
|
||||||
@ -24,7 +24,6 @@ EXERCISES = [
|
|||||||
("Lateral Raise", "shoulders", "dumbbell"),
|
("Lateral Raise", "shoulders", "dumbbell"),
|
||||||
("Tricep Pushdown", "triceps", "cable, bands"),
|
("Tricep Pushdown", "triceps", "cable, bands"),
|
||||||
("Dip", "chest", "dip station"),
|
("Dip", "chest", "dip station"),
|
||||||
# Pull
|
|
||||||
("Barbell Row", "back", "barbell"),
|
("Barbell Row", "back", "barbell"),
|
||||||
("Dumbbell Row", "back", "dumbbell, bench"),
|
("Dumbbell Row", "back", "dumbbell, bench"),
|
||||||
("Lat Pulldown", "back", "cable, bands"),
|
("Lat Pulldown", "back", "cable, bands"),
|
||||||
@ -32,7 +31,6 @@ EXERCISES = [
|
|||||||
("Face Pull", "shoulders", "cable, bands"),
|
("Face Pull", "shoulders", "cable, bands"),
|
||||||
("YTW", "shoulders", "dumbbell, bands"),
|
("YTW", "shoulders", "dumbbell, bands"),
|
||||||
("Bicep Curl", "biceps", "dumbbell"),
|
("Bicep Curl", "biceps", "dumbbell"),
|
||||||
# Legs
|
|
||||||
("Barbell Squat", "quadriceps", "barbell, squat rack"),
|
("Barbell Squat", "quadriceps", "barbell, squat rack"),
|
||||||
("Goblet Squat", "quadriceps", "dumbbell, kettlebell"),
|
("Goblet Squat", "quadriceps", "dumbbell, kettlebell"),
|
||||||
("Bulgarian Split Squat", "quadriceps", "dumbbell, bench"),
|
("Bulgarian Split Squat", "quadriceps", "dumbbell, bench"),
|
||||||
@ -42,19 +40,16 @@ EXERCISES = [
|
|||||||
("Leg Curl", "hamstrings", "cable, bands"),
|
("Leg Curl", "hamstrings", "cable, bands"),
|
||||||
("Calf Raise", "calves", "barbell, dumbbell"),
|
("Calf Raise", "calves", "barbell, dumbbell"),
|
||||||
("Deadlift", "back", "barbell"),
|
("Deadlift", "back", "barbell"),
|
||||||
# Core
|
|
||||||
("Dead Bug", "core", "bodyweight"),
|
("Dead Bug", "core", "bodyweight"),
|
||||||
("Pallof Press", "core", "cable, bands"),
|
("Pallof Press", "core", "cable, bands"),
|
||||||
("Plank", "core", "bodyweight"),
|
("Plank", "core", "bodyweight"),
|
||||||
("Ab Wheel Rollout", "core", "ab wheel"),
|
("Ab Wheel Rollout", "core", "ab wheel"),
|
||||||
("Russian Twist", "core", "bodyweight, dumbbell"),
|
("Russian Twist", "core", "bodyweight, dumbbell"),
|
||||||
("Hanging Knee Raise", "core", "pull-up bar"),
|
("Hanging Knee Raise", "core", "pull-up bar"),
|
||||||
# Cardio
|
|
||||||
("BikeErg", "cardio", "bikeerg"),
|
("BikeErg", "cardio", "bikeerg"),
|
||||||
("RowErg", "cardio", "rowerg"),
|
("RowErg", "cardio", "rowerg"),
|
||||||
("Jump Rope", "cardio", "jump rope"),
|
("Jump Rope", "cardio", "jump rope"),
|
||||||
("Walking", "cardio", "bodyweight"),
|
("Walking", "cardio", "bodyweight"),
|
||||||
# Accessory
|
|
||||||
("Farmer's Carry", "grip", "dumbbell, kettlebell"),
|
("Farmer's Carry", "grip", "dumbbell, kettlebell"),
|
||||||
("Bird Dog", "core", "bodyweight"),
|
("Bird Dog", "core", "bodyweight"),
|
||||||
("Glute Bridge", "glutes", "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+"),
|
("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 def seed():
|
||||||
async with async_session() as session:
|
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))
|
session.add(Phase(name=name, description=desc, start_date=start, end_date=end, notes=notes))
|
||||||
print(f"Seeded {len(PHASES)} phases.")
|
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()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user