diff --git a/README.md b/README.md index 3326f2c..aef11e0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 51a3a35..f91a254 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/measurement.py b/app/models/measurement.py new file mode 100644 index 0000000..6beb039 --- /dev/null +++ b/app/models/measurement.py @@ -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) diff --git a/app/models/user.py b/app/models/user.py index 74ac841..b33e1e8 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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)) diff --git a/app/models/workout.py b/app/models/workout.py index 78cfdd0..f178f08 100644 --- a/app/models/workout.py +++ b/app/models/workout.py @@ -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)) diff --git a/app/routers/agent_api.py b/app/routers/agent_api.py index 6241397..39752dc 100644 --- a/app/routers/agent_api.py +++ b/app/routers/agent_api.py @@ -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, diff --git a/app/routers/auth.py b/app/routers/auth.py index b122af3..38c0c4c 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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 diff --git a/app/routers/chat.py b/app/routers/chat.py index 0dbf510..b7436ee 100644 --- a/app/routers/chat.py +++ b/app/routers/chat.py @@ -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) + ". " diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 215b5ce..7a4d5c0 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -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, }) diff --git a/app/routers/profile.py b/app/routers/profile.py index 677d4bd..2954ea4 100644 --- a/app/routers/profile.py +++ b/app/routers/profile.py @@ -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 diff --git a/app/routers/workouts.py b/app/routers/workouts.py index 17c5ead..663cb1c 100644 --- a/app/routers/workouts.py +++ b/app/routers/workouts.py @@ -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, + }) diff --git a/app/static/chat-sidebar.js b/app/static/chat-sidebar.js new file mode 100644 index 0000000..f3606f2 --- /dev/null +++ b/app/static/chat-sidebar.js @@ -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 = '
' + content + '
'; + 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 = ''; + } + } + + 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 = 'Thinking...
'; + 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 = 'No response.'; + } + + setStatus(''); + } catch (err) { + assistantDiv.querySelector('p').innerHTML = 'Error.'; + 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(); +})(); \ No newline at end of file diff --git a/app/static/style.css b/app/static/style.css index d097da1..3709557 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; + } +} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 47f6b67..1eaad0b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,13 +6,15 @@