Add Plan page with phase timeline and agent phase management API

- New /plan page shows all phases in order with current phase highlighted
- Add GET/POST/PUT /api/agent/phases endpoints for the AI coach
- Plan link added to navigation bar
- Agent config updated with phase management instructions
This commit is contained in:
Jacob Hinkle 2026-06-29 10:51:28 -04:00
parent 5584022a23
commit 1a2509ab34
7 changed files with 151 additions and 3 deletions

View File

@ -4,7 +4,7 @@ from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from app.config import DATA_DIR from app.config import DATA_DIR
from app.routers import auth, dashboard, workouts, exercises, checkins, profile, chat, agent_api from app.routers import auth, dashboard, workouts, exercises, checkins, profile, chat, agent_api, plan
from scripts.schema import init_db from scripts.schema import init_db
@ -28,3 +28,4 @@ app.include_router(checkins.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(chat.router) app.include_router(chat.router)
app.include_router(agent_api.router) app.include_router(agent_api.router)
app.include_router(plan.router)

View File

@ -5,7 +5,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 Workout, WorkoutSet from app.models.workout import Phase, Workout, WorkoutSet
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 +16,14 @@ 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")
class CreatePhaseRequest(BaseModel):
name: str
description: str = ""
start_date: str | None = None
end_date: str | None = None
notes: str = ""
class CreateWorkoutRequest(BaseModel): class CreateWorkoutRequest(BaseModel):
username: str username: str
name: str name: str
@ -141,3 +149,56 @@ async def agent_create_checkin(
await session.refresh(checkin) await session.refresh(checkin)
return {"id": checkin.id, "date": checkin.date} return {"id": checkin.id, "date": checkin.date}
@router.get("/phases")
async def agent_list_phases(_=Depends(verify_agent)):
async with async_session() as session:
result = await session.execute(select(Phase).order_by(Phase.start_date.nulls_last()))
phases = result.scalars().all()
return [
{"id": p.id, "name": p.name, "description": p.description,
"start_date": p.start_date, "end_date": p.end_date, "notes": p.notes}
for p in phases
]
@router.post("/phases")
async def agent_create_phase(
body: CreatePhaseRequest,
_=Depends(verify_agent),
):
async with async_session() as session:
phase = Phase(
name=body.name,
description=body.description,
start_date=body.start_date,
end_date=body.end_date,
notes=body.notes,
)
session.add(phase)
await session.commit()
await session.refresh(phase)
return {"id": phase.id, "name": phase.name}
@router.put("/phases/{phase_id}")
async def agent_update_phase(
phase_id: int,
body: CreatePhaseRequest,
_=Depends(verify_agent),
):
async with async_session() as session:
result = await session.execute(select(Phase).where(Phase.id == phase_id))
phase = result.scalar_one_or_none()
if not phase:
raise HTTPException(status_code=404, detail="phase not found")
phase.name = body.name
phase.description = body.description
phase.start_date = body.start_date
phase.end_date = body.end_date
phase.notes = body.notes
await session.commit()
return {"id": phase.id, "name": phase.name}

View File

@ -39,7 +39,7 @@ async def dashboard(request: Request, user: User = Depends(get_current_user)):
latest_checkin = result.scalar_one_or_none() latest_checkin = result.scalar_one_or_none()
result = await session.execute( result = await session.execute(
select(Phase).order_by(desc(Phase.start_date)).limit(1) select(Phase).order_by(Phase.start_date.desc().nulls_last()).limit(1)
) )
current_phase = result.scalar_one_or_none() current_phase = result.scalar_one_or_none()

26
app/routers/plan.py Normal file
View File

@ -0,0 +1,26 @@
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select, desc
from app.models.base import async_session
from app.models.user import User
from app.models.workout import Phase
from app.auth import get_current_user
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/plan", response_class=HTMLResponse)
async def plan_page(request: Request, user: User = Depends(get_current_user)):
async with async_session() as session:
result = await session.execute(
select(Phase).order_by(Phase.start_date.nulls_last())
)
phases = result.scalars().all()
return templates.TemplateResponse(request, "plan.html", {
"user": user,
"phases": phases,
})

View File

@ -14,6 +14,7 @@
</ul> </ul>
<ul> <ul>
<li><a href="/dashboard">Dashboard</a></li> <li><a href="/dashboard">Dashboard</a></li>
<li><a href="/plan">Plan</a></li>
<li><a href="/workouts">Workouts</a></li> <li><a href="/workouts">Workouts</a></li>
<li><a href="/exercises">Exercises</a></li> <li><a href="/exercises">Exercises</a></li>
<li><a href="/checkins">Check-ins</a></li> <li><a href="/checkins">Check-ins</a></li>

29
app/templates/plan.html Normal file
View File

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Training Plan{% endblock %}
{% block content %}
<h1>Training Plan</h1>
<p><small>Your overall training plan, broken into phases. The AI coach creates and manages these phases based on your goals and progress.</small></p>
{% for phase in phases %}
<article{% if loop.first %} style="border-left: 4px solid var(--primary);"{% endif %}>
<h3>{{ phase.name }}</h3>
{% if loop.first %}<p><small>Current phase</small></p>{% endif %}
{% if phase.start_date or phase.end_date %}
<p><small>
{% if phase.start_date %}{{ phase.start_date }}{% endif %}
{% if phase.start_date and phase.end_date %} — {% endif %}
{% if phase.end_date %}{{ phase.end_date }}{% endif %}
</small></p>
{% endif %}
<p>{{ phase.description }}</p>
{% if phase.notes %}
<p><small>{{ phase.notes }}</small></p>
{% endif %}
</article>
{% else %}
<article>
<h3>No phases yet</h3>
<p>Ask the <a href="/chat">AI Coach</a> to set up a training plan with phases.</p>
</article>
{% endfor %}
{% endblock %}

View File

@ -91,8 +91,38 @@ POST /api/agent/checkins
Body: { username, date, feeling?, weight_lb?, calories?, steps?, sleep_hours?, notes? } Body: { username, date, feeling?, weight_lb?, calories?, steps?, sleep_hours?, notes? }
``` ```
**List all phases:**
```
GET /api/agent/phases
```
**Create a new phase:**
```
POST /api/agent/phases
Body: { name, description?, start_date?, end_date?, notes? }
```
**Update a phase:**
```
PUT /api/agent/phases/{id}
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.
## Managing the Training Plan
You maintain a training plan broken into phases. The plan lives in the
database as a series of Phase records. Each phase has a name, description,
start/end dates, and notes where you can store the plan details.
- Create phases for the overall training arc (e.g., Tendon Adaptation →
Progressive Loading → Strength Building)
- Update phase descriptions and notes as the plan evolves
- Assign workouts to a phase by including `phase_id` when creating them
- The Plan page in the web UI shows all phases in order so the user can see
their training context
## Check-in Flow ## Check-in Flow
When the user wants to check in or discuss their training: When the user wants to check in or discuss their training: