Add AI trainer chat with persistent goals and conversation history

Adds ChatMessage and Goal DB models, five new API endpoints
(/api/chat/history, /api/chat, /api/goals CRUD), and a Trainer panel
in the SPA with streaming chat, goal management, and ride-aware context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 17:32:39 -04:00
parent 40b685e6d0
commit 96d2d9e0cc
3 changed files with 493 additions and 3 deletions
+275 -1
View File
@@ -144,10 +144,57 @@
@keyframes blink { 50% { opacity: 0; } }
.no-ride-msg { color: var(--muted); font-size: 12px; text-align: center; padding: 24px 16px; }
/* Trainer panel */
#trainer-panel { display: none; flex: 1; flex-direction: column; background: var(--surface); overflow: hidden; }
#trainer-panel.trainer-active { display: flex; }
#goals-section { border-bottom: 1px solid var(--border); padding: 12px 16px; flex-shrink: 0; max-height: 220px; overflow-y: auto; }
#goals-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
#goals-header .section-title { margin-bottom: 0; }
#add-goal-btn { font-size: 20px; background: none; border: none; color: var(--accent); cursor: pointer; line-height: 1; padding: 0 2px; }
.goal-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.goal-item:last-child { border-bottom: none; }
.goal-text { flex: 1; }
.goal-title { font-size: 12px; font-weight: 500; color: var(--text); }
.goal-desc { font-size: 11px; color: var(--muted); margin-top: 2px; }
.goal-delete { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 16px; flex-shrink: 0; padding: 0; transition: color 0.15s; }
.goal-delete:hover { color: var(--red); }
#no-goals-msg { font-size: 12px; color: var(--muted); font-style: italic; }
#goal-add-form { display: none; flex-direction: column; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
#goal-add-form.open { display: flex; }
#goal-title-input, #goal-desc-input { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 12px; padding: 6px 8px; width: 100%; font-family: inherit; }
#goal-title-input:focus, #goal-desc-input:focus { outline: none; border-color: var(--accent); }
#goal-desc-input { resize: none; height: 48px; }
.goal-form-actions { display: flex; gap: 6px; }
.goal-form-actions button { flex: 1; padding: 6px; border-radius: 6px; font-size: 11px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); transition: all 0.15s; }
#goal-save-btn { background: var(--accent); color: #fff; border-color: var(--accent); }
#goal-save-btn:hover { opacity: 0.85; }
#goal-cancel-btn { background: var(--surface2); color: var(--muted); }
#goal-cancel-btn:hover { color: var(--text); }
#chat-messages { flex: 1; overflow-y: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; }
#chat-messages::-webkit-scrollbar { width: 4px; }
#chat-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.chat-msg { max-width: 85%; }
.chat-msg.user { align-self: flex-end; }
.chat-msg.assistant { align-self: flex-start; }
.chat-bubble { padding: 8px 12px; border-radius: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
.chat-msg.user .chat-bubble { background: var(--accent); color: #fff; border-bottom-right-radius: 3px; }
.chat-msg.assistant .chat-bubble { background: var(--surface2); color: var(--text); border-bottom-left-radius: 3px; }
.chat-time { font-size: 10px; color: var(--muted); margin-top: 3px; }
.chat-msg.user .chat-time { text-align: right; }
#chat-input-bar { border-top: 1px solid var(--border); padding: 10px 12px; display: flex; gap: 8px; align-items: flex-end; background: var(--surface); flex-shrink: 0; }
#chat-input { flex: 1; background: var(--surface2); border: 1px solid var(--border); border-radius: 18px; color: var(--text); font-size: 13px; padding: 8px 14px; resize: none; font-family: inherit; line-height: 1.4; max-height: 100px; overflow-y: auto; }
#chat-input:focus { outline: none; border-color: var(--accent); }
#chat-send-btn { background: var(--accent); border: none; border-radius: 50%; width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; transition: opacity 0.15s; }
#chat-send-btn:hover { opacity: 0.85; }
#chat-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#chat-send-btn svg { width: 16px; height: 16px; stroke: #fff; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
#trainer-sidebar-btn { margin-top: 4px; width: 100%; padding: 6px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); font-size: 11px; cursor: pointer; transition: all 0.15s; }
#trainer-sidebar-btn:hover { border-color: var(--accent); color: var(--text); }
/* Mobile */
#mobile-nav { display: none; }
@media (max-width: 768px) {
#sidebar, #main, #details {
#sidebar, #main, #details, #trainer-panel {
position: fixed;
top: 0; left: 0; right: 0;
bottom: calc(48px + env(safe-area-inset-bottom));
@@ -155,6 +202,7 @@
display: none;
}
#sidebar.mobile-active, #main.mobile-active, #details.mobile-active { display: flex; }
#trainer-panel.mobile-active { display: flex; }
#sidebar { border-right: none; }
#details { border-left: none; }
#mobile-nav {
@@ -212,6 +260,7 @@
<div id="gen-all-label"></div>
</div>
<button id="fetch-btn">Fetch new rides from Garmin</button>
<button id="trainer-sidebar-btn">Open Trainer</button>
</div>
<div id="fetch-modal-overlay">
@@ -295,6 +344,33 @@
</div>
</div>
<div id="trainer-panel">
<div id="goals-section">
<div id="goals-header">
<div class="section-title">Goals</div>
<button id="add-goal-btn" title="Add goal">+</button>
</div>
<div id="goals-list">
<div id="no-goals-msg">No goals set. Add one to give your trainer context.</div>
</div>
<div id="goal-add-form">
<input id="goal-title-input" type="text" placeholder="Goal (e.g. Complete a 100km ride)">
<textarea id="goal-desc-input" placeholder="Details (optional)"></textarea>
<div class="goal-form-actions">
<button id="goal-cancel-btn">Cancel</button>
<button id="goal-save-btn">Save Goal</button>
</div>
</div>
</div>
<div id="chat-messages"></div>
<div id="chat-input-bar">
<textarea id="chat-input" rows="1" placeholder="Ask your trainer…"></textarea>
<button id="chat-send-btn" title="Send">
<svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22,2 15,22 11,13 2,9"/></svg>
</button>
</div>
</div>
<nav id="mobile-nav">
<button class="mobile-nav-btn active" data-tab="list" onclick="setMobileTab('list')">
<svg viewBox="0 0 24 24"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
@@ -308,6 +384,10 @@
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Details
</button>
<button class="mobile-nav-btn" data-tab="trainer" onclick="setMobileTab('trainer')">
<svg viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Trainer
</button>
</nav>
<script>
@@ -877,11 +957,205 @@ function setMobileTab(tab) {
document.getElementById('sidebar').classList.toggle('mobile-active', tab === 'list');
document.getElementById('main').classList.toggle('mobile-active', tab === 'map');
document.getElementById('details').classList.toggle('mobile-active', tab === 'details');
document.getElementById('trainer-panel').classList.toggle('mobile-active', tab === 'trainer');
document.querySelectorAll('.mobile-nav-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab));
if (tab === 'map') setTimeout(() => map.invalidateSize(), 50);
if (tab === 'trainer') openTrainerPanel();
}
setMobileTab('list');
// ---------------------------------------------------------------------------
// Trainer
// ---------------------------------------------------------------------------
let goals = [];
let goalsLoaded = false;
let chatLoaded = false;
let trainerActive = false;
function escHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function openTrainerPanel() {
if (!goalsLoaded) loadGoals();
if (!chatLoaded) loadChatHistory();
}
function setTrainerMode(active) {
if (window.matchMedia('(max-width: 768px)').matches) {
if (active) setMobileTab('trainer');
return;
}
trainerActive = active;
document.getElementById('main').style.display = active ? 'none' : '';
document.getElementById('details').style.display = active ? 'none' : '';
document.getElementById('trainer-panel').classList.toggle('trainer-active', active);
document.getElementById('trainer-sidebar-btn').textContent = active ? 'Back to Rides' : 'Open Trainer';
if (active) openTrainerPanel();
}
document.getElementById('trainer-sidebar-btn').addEventListener('click', () => setTrainerMode(!trainerActive));
// Goals
async function loadGoals() {
goalsLoaded = true;
const res = await fetch('/api/goals');
goals = await res.json();
renderGoals();
}
function renderGoals() {
const list = document.getElementById('goals-list');
if (!goals.length) {
list.innerHTML = '<div id="no-goals-msg">No goals set. Add one to give your trainer context.</div>';
return;
}
list.innerHTML = goals.map(g => `
<div class="goal-item">
<div class="goal-text">
<div class="goal-title">${escHtml(g.title)}</div>
${g.description ? `<div class="goal-desc">${escHtml(g.description)}</div>` : ''}
</div>
<button class="goal-delete" data-id="${g.id}" title="Remove goal">×</button>
</div>
`).join('');
list.querySelectorAll('.goal-delete').forEach(btn => {
btn.addEventListener('click', () => deleteGoal(btn.dataset.id));
});
}
async function deleteGoal(id) {
await fetch(`/api/goals/${encodeURIComponent(id)}`, { method: 'DELETE' });
goals = goals.filter(g => g.id !== id);
renderGoals();
}
document.getElementById('add-goal-btn').addEventListener('click', () => {
document.getElementById('goal-add-form').classList.add('open');
document.getElementById('goal-title-input').focus();
});
document.getElementById('goal-cancel-btn').addEventListener('click', () => {
document.getElementById('goal-add-form').classList.remove('open');
document.getElementById('goal-title-input').value = '';
document.getElementById('goal-desc-input').value = '';
});
document.getElementById('goal-save-btn').addEventListener('click', async () => {
const title = document.getElementById('goal-title-input').value.trim();
if (!title) return;
const desc = document.getElementById('goal-desc-input').value.trim();
const res = await fetch('/api/goals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: desc }),
});
const newGoal = await res.json();
goals.push(newGoal);
renderGoals();
document.getElementById('goal-cancel-btn').click();
});
// Chat
function scrollChatBottom() {
const c = document.getElementById('chat-messages');
c.scrollTop = c.scrollHeight;
}
function appendChatBubble(role, content, isoTime) {
const container = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = `chat-msg ${role}`;
const timeStr = isoTime
? new Date(isoTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
: '';
const bubble = document.createElement('div');
bubble.className = 'chat-bubble';
bubble.textContent = content;
div.appendChild(bubble);
if (timeStr) {
const t = document.createElement('div');
t.className = 'chat-time';
t.textContent = timeStr;
div.appendChild(t);
}
container.appendChild(div);
return bubble;
}
async function loadChatHistory() {
chatLoaded = true;
const res = await fetch('/api/chat/history');
const history = await res.json();
const container = document.getElementById('chat-messages');
container.innerHTML = '';
for (const msg of history) appendChatBubble(msg.role, msg.content, msg.created_at);
scrollChatBottom();
}
async function sendChatMessage() {
const input = document.getElementById('chat-input');
const sendBtn = document.getElementById('chat-send-btn');
const message = input.value.trim();
if (!message || sendBtn.disabled) return;
input.value = '';
input.style.height = '';
sendBtn.disabled = true;
appendChatBubble('user', message, new Date().toISOString());
scrollChatBottom();
const container = document.getElementById('chat-messages');
const assistantDiv = document.createElement('div');
assistantDiv.className = 'chat-msg assistant';
const bubble = document.createElement('div');
bubble.className = 'chat-bubble';
bubble.innerHTML = '<span class="blinking"></span>';
assistantDiv.appendChild(bubble);
container.appendChild(assistantDiv);
scrollChatBottom();
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let text = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
bubble.textContent = text;
scrollChatBottom();
}
bubble.textContent = text;
const timeDiv = document.createElement('div');
timeDiv.className = 'chat-time';
timeDiv.textContent = new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
assistantDiv.appendChild(timeDiv);
} catch (e) {
bubble.textContent = `Error: ${e.message}`;
} finally {
sendBtn.disabled = false;
input.focus();
}
}
document.getElementById('chat-send-btn').addEventListener('click', sendChatMessage);
document.getElementById('chat-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
});
document.getElementById('chat-input').addEventListener('input', function () {
this.style.height = '';
this.style.height = Math.min(this.scrollHeight, 100) + 'px';
});
async function initAuth() {
const res = await fetch('/api/me');
if (res.status === 401) {