Fix chat UI, add weekly plan view to dashboard

- Chat: textarea instead of single-line input, compact send button
- Chat: show status messages and better error handling
- Dashboard: new 'This Week' section showing planned workouts
- Chat: Enter sends, Shift+Enter adds newline
This commit is contained in:
Jacob Hinkle 2026-06-29 10:57:49 -04:00
parent 87a5da6f03
commit 0b67939a53
4 changed files with 125 additions and 28 deletions

View File

@ -1,3 +1,4 @@
from datetime import date, timedelta
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@ -13,6 +14,13 @@ router = APIRouter()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
def _week_bounds() -> tuple[str, str]:
today = date.today()
monday = today - timedelta(days=today.weekday())
sunday = monday + timedelta(days=6)
return monday.isoformat(), sunday.isoformat()
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def root_redirect(): async def root_redirect():
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@ -21,6 +29,8 @@ async def root_redirect():
@router.get("/dashboard", response_class=HTMLResponse) @router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, user: User = Depends(get_current_user)): async def dashboard(request: Request, user: User = Depends(get_current_user)):
week_start, week_end = _week_bounds()
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Workout) select(Workout)
@ -43,9 +53,23 @@ async def dashboard(request: Request, user: User = Depends(get_current_user)):
) )
current_phase = result.scalar_one_or_none() current_phase = result.scalar_one_or_none()
result = await session.execute(
select(Workout)
.where(
Workout.user_id == user.id,
Workout.date >= week_start,
Workout.date <= week_end,
)
.order_by(Workout.date)
)
this_week = result.scalars().all()
return templates.TemplateResponse(request, "dashboard.html", { return templates.TemplateResponse(request, "dashboard.html", {
"user": user, "user": user,
"recent_workouts": recent_workouts, "recent_workouts": recent_workouts,
"latest_checkin": latest_checkin, "latest_checkin": latest_checkin,
"current_phase": current_phase, "current_phase": current_phase,
"this_week": this_week,
"week_start": week_start,
"week_end": week_end,
}) })

View File

@ -37,10 +37,24 @@
.chat-input { .chat-input {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: flex-start;
} }
.chat-input input { .chat-input textarea {
flex: 1; flex: 1;
resize: vertical;
min-height: 2.5rem;
}
.chat-input button {
width: auto;
padding: 0.5rem 1.5rem;
flex-shrink: 0;
}
.chat-status {
margin-top: 0.25rem;
font-size: 0.85rem;
} }
.nav-link.active { .nav-link.active {

View File

@ -13,38 +13,64 @@
</div> </div>
<form class="chat-input" id="chat-form" onsubmit="sendMessage(event)"> <form class="chat-input" id="chat-form" onsubmit="sendMessage(event)">
<input type="text" id="chat-input" placeholder="Ask your coach..." required autocomplete="off"> <textarea id="chat-input" placeholder="Ask your coach..." rows="3" required autocomplete="off"></textarea>
<button type="submit">Send</button> <button type="submit" id="chat-send-btn">Send</button>
</form> </form>
<p id="chat-status" class="chat-status"></p>
<script> <script>
function sendMessage(event) { const chatMessages = document.getElementById('chat-messages');
const chatInput = document.getElementById('chat-input');
const chatForm = document.getElementById('chat-form');
const chatSendBtn = document.getElementById('chat-send-btn');
const chatStatus = document.getElementById('chat-status');
chatInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
chatForm.dispatchEvent(new Event('submit'));
}
});
function appendMessage(role, content) {
const div = document.createElement('div');
div.className = 'chat-message ' + role;
div.innerHTML = '<small>' + role + '</small><p>' + content + '</p>';
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function setStatus(msg, isError) {
chatStatus.textContent = msg;
chatStatus.style.color = isError ? 'var(--del-color, #b33)' : 'var(--muted-color, #888)';
}
async function sendMessage(event) {
event.preventDefault(); event.preventDefault();
const input = document.getElementById('chat-input'); const text = chatInput.value;
const messages = document.getElementById('chat-messages');
const text = input.value;
if (!text.trim()) return; if (!text.trim()) return;
const userDiv = document.createElement('div'); appendMessage('user', text);
userDiv.className = 'chat-message user'; chatInput.value = '';
userDiv.innerHTML = `<small>user</small><p>${text}</p>`; chatInput.disabled = true;
messages.appendChild(userDiv); chatSendBtn.disabled = true;
setStatus('Waiting for coach...');
const assistantDiv = document.createElement('div'); const assistantDiv = document.createElement('div');
assistantDiv.className = 'chat-message assistant'; assistantDiv.className = 'chat-message assistant';
assistantDiv.innerHTML = '<small>assistant</small><p><em>Thinking...</em></p>'; assistantDiv.innerHTML = '<small>assistant</small><p><em>Thinking...</em></p>';
messages.appendChild(assistantDiv); chatMessages.appendChild(assistantDiv);
messages.scrollTop = messages.scrollHeight; chatMessages.scrollTop = chatMessages.scrollHeight;
input.disabled = true;
const formData = new FormData(); const formData = new FormData();
formData.append('message', text); formData.append('message', text);
fetch('/chat', { try {
method: 'POST', const response = await fetch('/chat', { method: 'POST', body: formData });
body: formData, if (!response.ok) {
}).then(async response => { throw new Error('Server returned ' + response.status);
}
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const p = assistantDiv.querySelector('p'); const p = assistantDiv.querySelector('p');
@ -62,16 +88,23 @@ function sendMessage(event) {
p.innerHTML += line.slice(6); p.innerHTML += line.slice(6);
} }
} }
messages.scrollTop = messages.scrollHeight; chatMessages.scrollTop = chatMessages.scrollHeight;
} }
input.disabled = false; if (!p.innerHTML.trim()) {
input.value = ''; p.innerHTML = '<em>No response received. Make sure the AI coach is running (<code>opencode serve</code>).</em>';
input.focus(); }
}).catch(() => {
assistantDiv.querySelector('p').innerHTML = 'Error connecting to coach.'; setStatus('');
input.disabled = false; chatInput.disabled = false;
}); chatSendBtn.disabled = false;
chatInput.focus();
} catch (err) {
assistantDiv.querySelector('p').innerHTML = '<em>Error connecting to coach.</em>';
setStatus('Could not reach the AI coach. Is <code>opencode serve</code> running?', true);
chatInput.disabled = false;
chatSendBtn.disabled = false;
}
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@ -27,6 +27,32 @@
</article> </article>
{% endif %} {% endif %}
<h2>This Week ({{ week_start }} — {{ week_end }})</h2>
{% if this_week %}
<table>
<thead>
<tr>
<th>Day</th>
<th>Name</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for w in this_week %}
<tr>
<td>{{ w.date }}</td>
<td>{{ w.name }}</td>
<td>{{ w.status }}</td>
<td><a href="/workouts/{{ w.id }}">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No workouts planned this week. Ask the <a href="/chat">AI Coach</a> to plan your week.</p>
{% endif %}
{% if latest_checkin %} {% if latest_checkin %}
<article> <article>
<h3>Latest Morning Check-in</h3> <h3>Latest Morning Check-in</h3>
@ -35,7 +61,7 @@
</article> </article>
{% endif %} {% endif %}
<h2>Recent Workouts</h2> <h2>Past Workouts</h2>
{% if recent_workouts %} {% if recent_workouts %}
<table> <table>
<thead> <thead>