// Turbocoach - Live React app (beta) // Built by scripts/build_react_app.py from design-src/ // ---------------------------------------------------- const { useState, useEffect, useRef, useMemo, useCallback } = React; /* === API HELPER === */ const STORAGE_TOKEN = 'tc_token'; function getAuthToken() { try { return localStorage.getItem(STORAGE_TOKEN); } catch(e) { return null; } } function setAuthToken(t) { try { if (t) localStorage.setItem(STORAGE_TOKEN, t); else localStorage.removeItem(STORAGE_TOKEN); } catch(e) {} } async function api(path, opts = {}) { const headers = Object.assign({}, opts.headers || {}); if (opts.body && !(opts.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; if (typeof opts.body !== 'string') opts.body = JSON.stringify(opts.body); } const tok = getAuthToken(); if (tok) headers['Authorization'] = 'Bearer ' + tok; const res = await fetch(path, { ...opts, headers, credentials: 'include' }); const ct = res.headers.get('content-type') || ''; const body = ct.includes('json') ? await res.json().catch(() => ({})) : await res.text(); if (!res.ok) { const err = new Error((body && body.detail) || ('HTTP ' + res.status)); err.status = res.status; err.body = body; throw err; } return body; } /* === DATA TRANSFORMERS === */ function mapActivity(a) { const type = a.activity_type === 'cycling' ? 'bike' : a.activity_type === 'swimming' ? 'swim' : 'run'; // a.avg_pace zit ALTIJD in m/s (Garmin native). Converteer per sport-type. const ms = Number(a.avg_pace) || 0; const dist = a.distance ? (type === 'swim' ? Math.round(a.distance * 1000) : Number(a.distance).toFixed(1)) : ''; const dur = a.duration ? fmtDuration(a.duration) : ''; let pace = ''; let speed = ''; if (ms > 0) { if (type === 'run') { pace = fmtPace(1000 / (ms * 60)); // min/km } else if (type === 'swim') { pace = fmtPace(100 / (ms * 60)); // min/100m } else if (type === 'bike') { speed = (ms * 3.6).toFixed(1); // km/u } } return { id: a.id, type, name: a.activity_name || (type === 'run' ? 'Loop' : type === 'bike' ? 'Rit' : 'Zwem'), date: fmtRelativeDate(a.start_time), startTime: a.start_time, dist, pace, speed, dur, hr: a.avg_hr || 0, elev: Math.round(a.elevation_gain || 0), cal: a.calories || 0, cad: a.avg_cadence ? Math.round(Number(a.avg_cadence)) : 0, raw: a, }; } function fmtDuration(seconds) { if (!seconds) return ''; const s = Math.round(seconds); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const ss = s % 60; if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}`; return `${m}:${String(ss).padStart(2,'0')}`; } function fmtPace(minPerKm) { if (!minPerKm) return ''; const m = Math.floor(minPerKm); const s = Math.round((minPerKm - m) * 60); return `${m}:${String(s).padStart(2,'0')}`; } function fmtRelativeDate(iso) { if (!iso) return ''; const d = new Date(iso); const today = new Date(); const yest = new Date(); yest.setDate(today.getDate() - 1); const sameDay = (a, b) => a.toDateString() === b.toDateString(); const hm = `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; if (sameDay(d, today)) return `vandaag - ${hm}`; if (sameDay(d, yest)) return `gisteren - ${hm}`; const dayNames = ['zo','ma','di','wo','do','vr','za']; const daysDiff = Math.floor((today - d) / 86400000); if (daysDiff < 7) return `${dayNames[d.getDay()]} - ${hm}`; return `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')} - ${hm}`; } function formatHours(h) { if (!h || h <= 0) return '0u'; const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); if (hh === 0) return mm + 'm'; if (mm === 0) return hh + 'u'; return hh + 'u ' + mm + 'm'; } function buildWeekStrip(activities) { const today = new Date(); const dayKeys = ['zo','ma','di','wo','do','vr','za']; const dayLabels = ['zondag','maandag','dinsdag','woensdag','donderdag','vrijdag','zaterdag']; const mo = ['jan','feb','mrt','apr','mei','jun','jul','aug','sep','okt','nov','dec']; const out = []; // Laatste 7 dagen (rolling): van 6 dagen geleden tot en met vandaag for (let i = 6; i >= 0; i--) { const d = new Date(today); d.setDate(today.getDate() - i); d.setHours(0,0,0,0); const dayEnd = new Date(d); dayEnd.setHours(23,59,59,999); const sessions = activities.filter(a => { if (!a.startTime) return false; const dt = new Date(a.startTime); return dt >= d && dt <= dayEnd; }).map(a => ({ id: a.id, type: a.type, name: a.name, time: a.startTime ? new Date(a.startTime).toTimeString().slice(0,5) : '', dist: a.type === 'swim' ? `${a.dist} m` : `${a.dist} km`, pace: a.type === 'bike' ? `${a.speed} km/u` : (a.type === 'swim' ? `${a.pace} /100m` : `${a.pace} /km`), dur: a.dur, hr: a.hr, cal: a.cal, })); let kind = null; if (sessions.length > 1) kind = 'multi'; else if (sessions.length === 1) kind = sessions[0].type; const isToday = d.toDateString() === today.toDateString(); out.push({ d: dayKeys[d.getDay()], label: dayLabels[d.getDay()], dateLabel: isToday ? 'vandaag' : `${d.getDate()} ${mo[d.getMonth()]}`, kind, today: isToday, sessions, }); } return out; } /* === AUTH SCREEN === */ function AuthScreen({ onAuth }) { const [mode, setMode] = useState('login'); const [username, setUsername] = useState(''); const [displayName, setDisplayName] = useState(''); const [password, setPassword] = useState(''); const [inviteToken, setInviteToken] = useState(''); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); useEffect(() => { const m = window.location.pathname.match(/^\/invite\/([^/]+)/); if (m) { setInviteToken(m[1]); setMode('register'); } }, []); async function submit(e) { if (e) e.preventDefault(); setErr(''); setBusy(true); try { if (mode === 'login') { const r = await api('/api/auth/login', { method: 'POST', body: { username, password } }); if (r.token || r.access_token) setAuthToken(r.token || r.access_token); onAuth(r.user || r); } else { const path = inviteToken ? '/api/auth/register-invite' : '/api/auth/register'; const body = inviteToken ? { token: inviteToken, username, display_name: displayName || username, password } : { username, display_name: displayName || username, password }; const r = await api(path, { method: 'POST', body }); if (r.token || r.access_token) setAuthToken(r.token || r.access_token); onAuth(r.user || r); } } catch (ex) { setErr(ex.message || 'Fout'); } finally { setBusy(false); } } const wrap = { minHeight: '100vh', padding: '32px 20px', background: '#fafafb', display: 'flex', flexDirection: 'column', justifyContent: 'center' }; const logo = { width: 72, height: 72, margin: '0 auto 18px', borderRadius: 18, background: TC.orange, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }; const input = { width: '100%', padding: '12px 14px', borderRadius: 12, border: `1px solid ${TC.line}`, fontSize: 15, marginBottom: 10, background: '#fff', fontFamily: 'inherit' }; const btn = { width: '100%', padding: '14px', borderRadius: 14, background: TC.orange, color: '#fff', fontSize: 15, fontWeight: 700, border: 'none', cursor: 'pointer', marginTop: 6 }; const switchBtn = { background: 'transparent', border: 'none', color: TC.orange, fontSize: 14, fontWeight: 600, padding: '12px', cursor: 'pointer', marginTop: 12 }; return (
{ e.target.style.display = 'none'; }} />

Turbocoach

{mode === 'login' ? 'Welkom terug' : (inviteToken ? 'Account aanmaken via uitnodiging' : 'Nieuw account')}

setUsername(e.target.value)} autoComplete="username" /> {mode === 'register' && ( setDisplayName(e.target.value)} /> )} setPassword(e.target.value)} autoComplete={mode==='login'?'current-password':'new-password'} /> {err &&
{err}
}
{!inviteToken && (
)}
beta - {window.location.hostname}
); } /* === ONBOARDING WIZARD === */ function OnboardingWizard({ user, onDone }) { const [step, setStep] = useState(0); const [garmin, setGarmin] = useState({ email: '', pw: '', status: 'idle', mfa: '' }); const [ouraToken, setOuraToken] = useState(''); const [prof, setProf] = useState({ age: '', hrRest: '', hrMax: '', goal: '' }); const [pwCurrent, setPwCurrent] = useState(''); const [pwNew, setPwNew] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const TOTAL = 4; async function connectGarmin() { if (!garmin.email || !garmin.pw) return; setBusy(true); setErr(''); setGarmin(g => ({ ...g, status: 'connecting' })); try { await api('/api/settings', { method: 'POST', body: { garmin_email: garmin.email, garmin_password: garmin.pw } }); const r = await api('/api/garmin/login', { method: 'POST' }); if (r.status === 'mfa_required') setGarmin(g => ({ ...g, status: 'mfa' })); else setGarmin(g => ({ ...g, status: 'ok' })); } catch (ex) { setErr(ex.message || 'Garmin verbinding mislukt'); setGarmin(g => ({ ...g, status: 'error' })); } finally { setBusy(false); } } async function submitMfa() { if (garmin.mfa.length < 6) return; setBusy(true); setErr(''); setGarmin(g => ({ ...g, status: 'verifying' })); try { await api('/api/garmin/mfa', { method: 'POST', body: { code: garmin.mfa } }); setGarmin(g => ({ ...g, status: 'ok' })); } catch (ex) { setErr(ex.message || 'Code onjuist'); setGarmin(g => ({ ...g, status: 'mfa' })); } finally { setBusy(false); } } async function saveOura() { if (!ouraToken) return true; try { await api('/api/settings', { method: 'POST', body: { oura_token: ouraToken } }); return true; } catch (ex) { setErr(ex.message); return false; } } async function saveProfile() { try { const body = {}; if (prof.age) body.age = parseInt(prof.age); if (prof.hrRest) body.run_hr_rest = parseInt(prof.hrRest); if (prof.hrMax) body.run_hr_max = parseInt(prof.hrMax); if (prof.goal) body.run_goal = prof.goal; if (Object.keys(body).length > 0) await api('/api/profile', { method: 'POST', body }); return true; } catch (ex) { setErr(ex.message); return false; } } async function changePw() { if (!pwNew || pwNew.length < 8) return true; try { await api('/api/auth/change-password', { method: 'POST', body: { current_password: pwCurrent, new_password: pwNew } }); return true; } catch (ex) { setErr(ex.message); return false; } } async function finish() { setBusy(true); try { await api('/api/auth/onboarding-done', { method: 'POST' }); onDone(); } catch (ex) { setErr(ex.message); setBusy(false); } } async function next() { setErr(''); let ok = true; if (step === 1) ok = await saveOura(); else if (step === 2) ok = await saveProfile(); else if (step === 3) { ok = await changePw(); if (ok) { await finish(); return; } } if (!ok) return; if (step < TOTAL - 1) setStep(step + 1); } async function primary() { if (step === 0) { if (garmin.status === 'idle' || garmin.status === 'error') { if (garmin.email && garmin.pw) return connectGarmin(); return setStep(1); // skip } if (garmin.status === 'mfa') return submitMfa(); if (garmin.status === 'ok') return setStep(1); return; } return next(); } function primaryLabel() { if (step === 0) { if (garmin.status === 'connecting') return 'Verbinden...'; if (garmin.status === 'mfa') return 'Code bevestigen'; if (garmin.status === 'verifying') return 'Verifieren...'; if (garmin.status === 'ok') return 'Volgende'; if (garmin.email && garmin.pw) return 'Verbinden'; return 'Overslaan'; } if (step === TOTAL - 1) return busy ? '...' : 'Afronden'; return 'Volgende'; } const wrap = { minHeight: '100vh', padding: '24px 20px', background: '#fafafb', display: 'flex', flexDirection: 'column', gap: 20 }; const h1Style = { fontSize: 20, fontWeight: 800, margin: 0, color: TC.ink }; const subStyle = { fontSize: 13, color: TC.muted, marginTop: 4 }; const input = { width: '100%', padding: '11px 14px', borderRadius: 11, border: `1px solid ${TC.line}`, fontSize: 15, background: '#fff', fontFamily: 'inherit' }; const fieldLbl = { fontSize: 12, fontWeight: 600, color: TC.ink2, marginBottom: 6, textTransform: 'uppercase', letterSpacing: 0.5 }; const btn = { padding: '13px 16px', borderRadius: 12, fontSize: 15, fontWeight: 700, border: 'none', cursor: 'pointer' }; const btnPrimary = { ...btn, background: TC.orange, color: '#fff', flex: 1.4 }; const btnGhost = { ...btn, background: 'transparent', color: TC.muted, fontWeight: 500, fontSize: 13, padding: 8 }; const dot = (active, done) => ({ width: active ? 18 : 8, height: 8, borderRadius: 4, background: done ? TC.good : (active ? TC.orange : TC.line), transition: 'all 200ms' }); const badge = (ok) => ({ fontSize: 11, fontWeight: 600, padding: '4px 10px', borderRadius: 10, background: ok ? TC.goodSoft : TC.lineSoft, color: ok ? TC.good : TC.muted }); const card = { background: '#fff', borderRadius: 14, padding: 14, border: `1px solid ${TC.line}` }; const errBox = { color: TC.red, fontSize: 13, padding: '8px 12px', background: '#fde8e8', borderRadius: 10 }; return (
e.target.style.display='none'} />
Welkom {user?.display_name || user?.username}
Stel je account in om te beginnen
{[0,1,2,3].map(i => (
))} Stap {step+1} van {TOTAL}
{step === 0 && (

Garmin Connect

{garmin.status === 'ok' ? 'Verbonden' : 'Stap 1 van 4'}
Koppel Garmin zodat de coach je trainingen ziet. Geen Garmin? Sla over.
E-mailadres
setGarmin(g=>({...g, email: e.target.value}))} disabled={garmin.status === 'ok'} />
Wachtwoord
setGarmin(g=>({...g, pw: e.target.value}))} disabled={garmin.status === 'ok'} />
{garmin.status === 'mfa' || garmin.status === 'verifying' || garmin.status === 'ok' ? (
Verificatiecode
setGarmin(g=>({...g, mfa: e.target.value.replace(/\D/g,'').slice(0,6)}))} disabled={garmin.status === 'ok' || garmin.status === 'verifying'} />
) : null}
)} {step === 1 && (

Oura Ring

Optioneel
Oura geeft betere slaap- en hersteldata. Geen Oura? Sla over.
Personal Access Token
setOuraToken(e.target.value)} autoCapitalize="none" />
Via cloud.ouraring.com/personal-access-tokens
)} {step === 2 && (

Jouw profiel

Optioneel
Vul in wat je weet. De AI leert de rest uit je data.
Leeftijd
setProf({...prof, age: e.target.value.replace(/\D/g,'').slice(0,3)})} />
Rust HR
setProf({...prof, hrRest: e.target.value.replace(/\D/g,'').slice(0,3)})} />
Max HR
setProf({...prof, hrMax: e.target.value.replace(/\D/g,'').slice(0,3)})} />
Loopdoel
setProf({...prof, goal: e.target.value})} />
)} {step === 3 && (

Nieuw wachtwoord

Optioneel
Stel je eigen wachtwoord in of sla over.
Huidig wachtwoord
setPwCurrent(e.target.value)} />
Nieuw wachtwoord
setPwNew(e.target.value)} /> {pwNew && pwNew.length < 8 &&
Minimaal 8 tekens
}
)}
{err &&
{err}
}
{step > 0 && }
{step < TOTAL - 1 && step !== 0 && } {step === TOTAL - 1 && }
); } // ─── Sync status modal ──────────────────────────────────── function SyncModal({ status, onClose }) { if (!status) return null; const { phase, title, message, hint } = status; const isRunning = phase === 'running'; const isOk = phase === 'success'; const isErr = phase === 'error'; const isInfo = phase === 'info'; const accent = isErr ? '#c94a2a' : (isOk ? '#2fb673' : '#d86f24'); return (
e.stopPropagation()} style={{ width: '100%', maxWidth: 340, background: '#fff', borderRadius: 20, padding: '22px 20px 18px', boxShadow: '0 14px 40px rgba(0,0,0,0.25)', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', animation: 'tc-slide-up 220ms ease-out', }} >
{isRunning ? : }
{title}
{message && (
{message}
)} {hint && (
{hint}
)} {!isRunning && ( )}
); } // tc-spin animatie if (typeof document !== 'undefined' && !document.getElementById('tc-spin-css')) { const s = document.createElement('style'); s.id = 'tc-spin-css'; s.textContent = '@keyframes tc-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }'; document.head.appendChild(s); } // ─── Markdown -> Coach-blocks parser ───────────────────── function stripMd(t) { if (!t) return ''; return String(t) .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/`([^`]+)`/g, '$1') .trim(); } function parseMarkdownTable(raw) { const rows = raw.trim().split('\n') .map(r => r.trim()) .filter(r => r.startsWith('|')) .map(r => { const inner = r.replace(/^\|/, '').replace(/\|$/, ''); return inner.split('|').map(c => stripMd(c)); }); if (rows.length < 2) return null; const [head, sep, ...body] = rows; // validate separator row: dashes / colons const sepOk = sep.every(c => /^[-:\s]+$/.test(c)); if (!sepOk) return null; return { head, rows: body }; } function parseCoachSection(title, bodyLines) { const iconMap = [ ['💓','heart'], ['❤️','heart'], ['🫀','heart'], ['📊','info'], ['📋','info'], ['ℹ️','info'], ['🔍','info'], ['📝','info'], ['🎯','target'], ['📌','target'], ['⚡','bolt'], ['💪','bolt'], ['🏃','bolt'], ['🚴','bolt'], ['🏊','bolt'], ['⚠️','warn'], ['🚨','warn'], ['🔴','warn'], ['📅','calendar'], ['🗓️','calendar'], ['✅','check'], ['🟢','check'], ]; let icon = 'info'; // Strip **bold**-wrapping uit de heading (AI schrijft vaak '## 🚴 **Titel**') let cleanTitle = String(title || '').replace(/\*\*([^*]+)\*\*/g, '$1').trim(); for (const [em, ic] of iconMap) { if (cleanTitle.startsWith(em)) { icon = ic; cleanTitle = cleanTitle.slice(em.length).trim(); break; } } // Fallback: keyword-based icon als er geen emoji is if (icon === 'info') { const lt = cleanTitle.toLowerCase(); if (/hart|hr|hrv|slaap|rhr|gezondheid|readiness/.test(lt)) icon = 'heart'; else if (/doel|target|focus/.test(lt)) icon = 'target'; else if (/workout|training|voorstel|plan|interval|blok/.test(lt)) icon = 'bolt'; else if (/let op|opgelet|risico|waarschuwing|niet/.test(lt)) icon = 'warn'; else if (/morgen|vandaag|week|dag|planning|datum|agenda/.test(lt)) icon = 'calendar'; } const body = bodyLines.join('\n'); // Parse sequential: ondersteunt meerdere tabellen + text/bullets ertussen. // parts = array van { type: 'text'|'table'|'bullets', ... } in document volgorde. const parts = []; const tableRe = /(^|\n)(\|[^\n]+\|\n\|[-:\s|]+\|\n(?:\|[^\n]+\|(?:\n|$))+)/g; let lastIdx = 0; let m; const pushTextBlock = (txt) => { if (!txt || !txt.trim()) return; const blines = txt.split('\n'); let currBullets = null; let currText = []; let currQuote = []; const flushText = () => { if (currText.length === 0) return; const joined = stripMd(currText.join(' ').replace(/\s+/g, ' ').trim()); if (joined) parts.push({ type: 'text', text: joined }); currText = []; }; const flushBullets = () => { if (!currBullets || currBullets.length === 0) { currBullets = null; return; } parts.push({ type: 'bullets', bullets: currBullets }); currBullets = null; }; const flushQuote = () => { if (currQuote.length === 0) return; const joined = stripMd(currQuote.join(' ').replace(/\s+/g, ' ').trim()); if (joined) parts.push({ type: 'quote', text: joined }); currQuote = []; }; const flushAll = () => { flushText(); flushBullets(); flushQuote(); }; for (const ln of blines) { // horizontal rule → paragraph break if (/^\s*-{3,}\s*$/.test(ln) || /^\s*\*{3,}\s*$/.test(ln)) { flushAll(); continue; } // subheader ### Title const hm = ln.match(/^\s*###\s+(.+)$/); if (hm) { flushAll(); parts.push({ type: 'subheader', text: stripMd(hm[1]) }); continue; } // blockquote > text (groepeer opeenvolgende quote-lijnen) const qm = ln.match(/^\s*>\s*(.*)$/); if (qm) { flushText(); flushBullets(); currQuote.push(qm[1]); continue; } // bullet (groepeer opeenvolgende) const bm = ln.match(/^\s*[-*]\s+(.*)$/); if (bm) { flushText(); flushQuote(); if (!currBullets) currBullets = []; const raw = bm[1]; const boldFirst = raw.match(/^\*\*([^*]+)\*\*\s*:?\s*(.*)$/); if (boldFirst) currBullets.push({ bold: stripMd(boldFirst[1]), text: stripMd(boldFirst[2]) }); else currBullets.push({ text: stripMd(raw) }); continue; } // lege regel → paragraph break if (!ln.trim()) { flushAll(); continue; } // anders: tekst-lijn flushBullets(); flushQuote(); currText.push(ln); } flushAll(); }; while ((m = tableRe.exec(body)) !== null) { // text voor deze tabel pushTextBlock(body.slice(lastIdx, m.index + m[1].length)); const parsedTable = parseMarkdownTable(m[2]); if (parsedTable) parts.push({ type: 'table', table: parsedTable }); lastIdx = tableRe.lastIndex; } // text na laatste tabel pushTextBlock(body.slice(lastIdx)); const sec = { type: 'section', icon, title: cleanTitle, parts }; // Backward-compat: vul ook single fields van eerste voorkomen in, // zodat oude CoachSection-renderer nog werkt voor niet-bijgewerkte clients. const firstText = parts.find(p => p.type === 'text'); const firstTable = parts.find(p => p.type === 'table'); const firstBullets = parts.find(p => p.type === 'bullets'); if (firstText) sec.text = firstText.text; if (firstTable) sec.table = firstTable.table; if (firstBullets) sec.bullets = firstBullets.bullets; return sec; } function parseWorkoutJson(src) { const out = []; const re = /```workout-json\s*\n([\s\S]*?)\n```/g; let m; while ((m = re.exec(src)) !== null) { try { const w = JSON.parse(m[1]); const unitFor = (tt) => { if (tt === 'power' || tt === 'power_zone') return 'W'; if (tt === 'cadence') return 'rpm'; if (tt === 'pace') return '/km'; if (tt === 'hr_zone') return ' bpm'; return ''; }; const steps = (w.steps || []).map(st => { const dur = st.duration_seconds ? Math.round(st.duration_seconds / 60) + ' min' : (st.duration || ''); let target = ''; if (st.target_low != null && st.target_high != null) { const u = unitFor(st.target_type); target = (st.target_low === st.target_high) ? (Math.round(st.target_low) + u) : (Math.round(st.target_low) + '–' + Math.round(st.target_high) + u); } else if (st.target_zone && st.target_type === 'hr_zone') { target = 'Z' + st.target_zone + ' HR'; } else if (st.target_zone && st.target_type === 'power_zone') { target = 'Z' + st.target_zone + ' power'; } else if (st.target) { target = st.target; } const detail = [dur, target].filter(Boolean).join(' · '); return { label: st.name || st.type || 'Stap', detail }; }); out.push({ type: 'workout', name: w.name || 'Training', duration: w.duration || '', sport: w.sport || '', steps, raw: w, }); } catch (e) { /* ignore bad JSON */ } } return out; } function parseCoachResponse(md) { if (!md || typeof md !== 'string') return [{ type: 'intro', text: '' }]; // Extract + strip workout-json const workouts = parseWorkoutJson(md); md = md.replace(/```workout-json\s*\n[\s\S]*?\n```/g, '').trim(); // Split op ## headers (level 2) — lagere kopjes gaan mee als bullets const lines = md.split('\n'); const introLines = []; const sections = []; let current = null; for (const ln of lines) { const h = ln.match(/^##\s+(.+)/); if (h && !ln.startsWith('###')) { if (current) sections.push(current); current = { title: h[1].trim(), body: [] }; } else { if (current) current.body.push(ln); else introLines.push(ln); } } if (current) sections.push(current); const result = []; const introText = stripMd(introLines.join('\n').trim()); if (introText) result.push({ type: 'intro', text: introText }); for (const sec of sections) result.push(parseCoachSection(sec.title, sec.body)); for (const w of workouts) result.push(w); // Fallback: als er niks geparsed werd, dump de hele md als intro if (result.length === 0) result.push({ type: 'intro', text: stripMd(md) }); return result; } /* === END HEADER — below this: design code === */ // Turbocoach beta — mobile-first prototype // Single file with all screens/components/state // ─── Brand tokens ─────────────────────────────────────────── const TC = { orange: '#f57c2a', orangeDark: '#e56a15', orangeSoft: '#fff1e6', orangeBorder: '#ffd9b8', ink: '#1b1b1f', ink2: '#3a3a42', muted: '#6f6f78', mutedSoft: '#a0a0aa', line: '#ececee', lineSoft: '#f4f4f6', bg: '#ffffff', bgSoft: '#fafafb', good: '#2aa775', goodSoft: '#e7f6ef', warn: '#d97706', blue: '#2f6fed', blueSoft: '#e8efff', red: '#d64545', }; // ─── Fake data ────────────────────────────────────────────── const MASCOT = '/static/coach-potato.png'; let ACTIVITIES = []; // live data // Each day can have 0, 1 or multiple sessions. `kind` on the button is the // "primary" icon shown on the strip (the session with most load, for the double-day). let WEEK_STRIP = []; // live data let RACE_PREDICTIONS = []; // live data const POWER_ZONES = [ { z: 'Z1', name: 'Actief herstel', range: '< 138 W', pct: 55, color: '#c7d8f5' }, { z: 'Z2', name: 'Duur', range: '138–189 W', pct: 72, color: '#8fb6ee' }, { z: 'Z3', name: 'Tempo', range: '190–227 W', pct: 48, color: TC.orange }, { z: 'Z4', name: 'Drempel', range: '228–265 W', pct: 28, color: TC.orangeDark }, { z: 'Z5', name: 'VO₂', range: '266–303 W', pct: 14, color: '#d64545' }, { z: 'Z6', name: 'Anaeroob', range: '> 303 W', pct: 6, color: '#9b2c2c' }, ]; // Rich block templates — het type "lange antwoord" uit de ontwerpmockups const LONG_ANSWER_BLOCKS = [ { type: 'summary', tldr: 'Ja, rol gerust — maar hou het licht. Ik stel 70 min progressieve flush voor met 4×3 min activatie. Morgen wacht je laatste lange loop (18–20 km).', bullets: [ { label: 'Readiness', value: '83/100 · groen' }, { label: 'Focus vandaag', value: 'benen losmaken, kuit sparen' }, { label: 'Morgen', value: 'lange loop 18–20 km @ 5:00–5:10/km' }, ], }, { type: 'section', icon: 'heart', title: 'Gezondheidsdata vandaag — zat 18/04', table: { head: ['Metric', 'Waarde', 'Beoordeling'], rows: [ ['HRV (Oura)', '43 ms', { ok: true, text: 'op baseline' }], ['Slaap', '6:30u · score 83', { ok: true, text: 'goed' }], ['RHR', '51 bpm', { ok: true, text: 'stabiel' }], ['Readiness', '83/100', { ok: true, text: 'goed' }], ], }, footer: { type: 'ok', text: 'Groen licht.' }, }, { type: 'section', icon: 'calendar', title: 'Context', bullets: [ { bold: 'Gisteren', text: '↗ loop 11,4 km — kuit duidelijk beter ✅' }, { bold: 'Donderdag', text: 'geen rollen (drukke agenda)' }, { bold: 'Morgen zondag', text: 'lange loop gepland — laatste echte lange duurloop vóór tapering', highlight: true }, { bold: 'Wings for Life', text: '22 dagen 🔴' }, ], }, { type: 'section', icon: 'target', title: 'Doel vandaag: flush + activatie voor morgen', text: 'Vandaag is géén dag om jezelf uit te putten. Morgen is de belangrijkste trainingsdag van de hele voorbereiding — laatste lange loop. Vandaag moet je:', bullets: [ { text: 'Benen losmaken na de loop van gisteren' }, { text: 'Aerobe basis onderhouden' }, { text: 'Gevoelige kuit volledig ontzien', highlight: true }, { text: 'Fris en uitgerust zijn voor morgen' }, ], }, { type: 'section', icon: 'bolt', title: 'Voorstel: progressieve flush met activatieblokken — 70 min', text: 'Geen zware intervallen. Geen maximale inspanning. Wel iets meer dan een pure hersteltrit zodat je morgen niet met stijve benen start.', table: { head: ['Onderdeel', 'Duur', 'Wattage', 'Cadans', 'Details'], rows: [ ['Opwarming trap 1', '5 min', '100–115 W', '85 rpm', 'benen losmaken'], ['Opwarming trap 2', '5 min', '115–130 W', '88 rpm', 'geleidelijk ophouden'], ['Opwarming trap 3', '5 min', '130–148 W', '90 rpm', 'richting Z2'], ['Z2 blok', '30 min', { em: '150–168 W' }, '88–92 rpm', 'comfortabel, gecontroleerd'], ['4×3 min activatie','4×3 min', { em: '178–195 W' }, '92–94 rpm', 'kort en scherp — géén uitputting'], ['Herstel tussen', '2 min', '115–125 W', '85 rpm', 'volledig loslaten'], ['Uitrijden', '10 min', '105–120 W', '83–87 rpm', 'benen uitspinnen voor morgen'], ['Totaal', { em: '~70 min' }, '', '', ''], ], }, }, { type: 'section', icon: 'info', title: 'Waarom deze wattages', table: { head: ['Zone', '% FTP (228 W)', 'Wattage', 'Doel'], rows: [ ['Opwarming', '44–65 %', '100–148 W', 'geleidelijk activeren'], ['Z2 blok', '66–74 %', '150–168 W', 'aerobe basis — géén stress'], ['Activatieblokken','78–86 %', '178–195 W', 'benen scherp houden — géén VO₂max'], ['Uitrijden', '46–53 %', '105–120 W', 'flush — lactaat wegwerken'], ], }, }, { type: 'section', icon: 'warn', title: 'Waarom geen zwaardere intervallen vandaag', table: { head: ['Reden', 'Details'], rows: [ ['Morgen lange loop', '18–20 km — benen moeten fris zijn'], ['Kuit herstelt', 'geen onnodige systemische vermoeidheid'], ['22 dagen tot race', 'risico/baat-verhouding: flush wint van zware training'], ], }, }, { type: 'section', icon: 'calendar', title: 'Morgen zondag 19/04 — preview', highlight: 'Laatste lange loop: 18–20 km @ 5:00–5:10/km', bullets: [ { text: 'Hard plafond op ', bold2: '20 km', textAfter: ' — kuit niet voorbij de pijngrens duwen' }, { text: 'Voedingsprotocol testen: ', bold2: 'gel om de 35–40 min' }, { text: 'Zelfde opwarmroutine als gisteren: 10 min wandelen vóór je start' }, { text: 'Stop bij uitstraling naar voet' }, ], }, { type: 'outro', text: 'Geniet van de rolsessie — lekker uitspinnen, benen losmaken, en morgen fris aan de start van je laatste lange loop. 💪', }, { type: 'workout', name: 'Flush + activatie pre-lange loop', duration: '~70 min', sport: 'Fietsen · rollen', steps: [ { label: 'Opwarming', detail: '5 min · 100–115 W · 85 rpm' }, { label: 'Opwarming', detail: '5 min · 115–130 W · 88 rpm' }, { label: 'Opwarming', detail: '5 min · 130–148 W · 90 rpm' }, { label: 'Z2 blok', detail: '30 min · 150–168 W · 90 rpm' }, { label: '4× activatie', detail: '3 min · 178–195 W · 93 rpm (2 min herstel)' }, { label: 'Uitrijden', detail: '10 min · 105–120 W · 85 rpm' }, ], }, ]; let COACH_SEED = []; // live data // ─── Small UI atoms ───────────────────────────────────────── function Avatar({ size = 40 }) { return (
Turbocoach
); } function Chip({ children, active, onClick, color = TC.orange }) { return ( ); } function Card({ children, style, onClick }) { return (
{children}
); } function SectionTitle({ children, right }) { return (
{children}
{right}
); } // Two overlapping dots — shown on week-strip days with >1 session function MultiIcon({ color }) { return ( ); } // tiny inline icon set (stroke, 24) const Icon = ({ name, size = 20, color = 'currentColor', strokeWidth = 2 }) => { const p = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: color, strokeWidth, strokeLinecap: 'round', strokeLinejoin: 'round' }; switch (name) { case 'close': return ; case 'send': return ; case 'plus': return ; case 'chat': return ; case 'spark': return ; case 'info': return ; case 'chev': return ; case 'chevDn': return ; case 'run': return ; case 'bike': return ; case 'swim': return ; case 'bolt': return ; case 'gear': return ; case 'warn': return ; case 'arrowU': return ; case 'arrowD': return ; case 'sync': return ; case 'check': return ; case 'heart': return ; case 'target': return ; case 'calendar': return ; case 'garmin': return ; default: return null; } }; // ─── Dashboard screen ─────────────────────────────────────── function Dashboard({ onOpenChat, onOpenPredictions, onOpenSettings, onActivityClick, onDayClick, onSync, syncing, onPromptVisible, health }) { const [tab, setTab] = useState('run'); // run / bike / swim const latest = useMemo(() => ACTIVITIES.find(a => a.type === tab) || { name: tab === 'run' ? 'Nog geen run' : tab === 'bike' ? 'Nog geen rit' : 'Nog geen zwemsessie', date: '', type: tab, dist: '—', pace: '—', speed: '—', dur: '—', hr: '—', tss: '—', elev: '—', cad: '—' }, [tab]); const promptRef = useRef(null); useEffect(() => { if (!promptRef.current) return; const io = new IntersectionObserver( ([entry]) => onPromptVisible(entry.isIntersecting && entry.intersectionRatio > 0.4), { threshold: [0, 0.4, 1] } ); io.observe(promptRef.current); return () => io.disconnect(); }, [onPromptVisible]); const tabs = [ { id: 'run', label: 'Lopen', icon: 'run' }, { id: 'bike', label: 'Fietsen', icon: 'bike' }, { id: 'swim', label: 'Zwemmen', icon: 'swim' }, ]; return (
{/* Top header bar */}
Turbocoach
{(() => { const parts = []; if (health && health.garmin_configured) parts.push("Garmin " + (health.garmin_connected ? "verbonden" : "niet verbonden")); if (health && health.oura_configured) parts.push("Oura " + (health.oura_connected ? "verbonden" : "niet verbonden")); return parts.length ? (
{parts.join(" · ")}
) : null; })()}
{/* Beta banner (rendered conditionally by host) */}
{/* Tabs */}
{tabs.map(t => ( ))}
{/* Activity hero card */}
Laatste {tab === 'run' ? 'run' : tab === 'bike' ? 'rit' : 'sessie'}
{latest.name}
{latest.date}
{/* Main 3 stats */}
{[ { l: 'Afstand', v: latest.dist, u: tab === 'swim' ? 'm' : 'km' }, { l: tab === 'bike' ? 'Snelheid' : 'Tempo', v: tab === 'bike' ? latest.speed : latest.pace, u: tab === 'bike' ? 'km/u' : tab === 'swim' ? '/100m' : 'min/km' }, { l: 'Tijd', v: latest.dur, u: '' }, ].map((s, i) => (
{s.l}
{s.v}
{s.u}
))}
{/* Secondary stats */}
{[ { l: 'Hartslag', v: latest.hr ? latest.hr + ' bpm' : '—' }, { l: 'Hoogte', v: latest.elev ? latest.elev + ' m' : '—' }, { l: 'Calorieën',v: latest.cal ? latest.cal : '—' }, { l: tab === 'run' ? 'Cadans' : tab === 'bike' ? 'Vermogen' : 'Slagen', v: tab === 'run' ? (latest.cad || '—') : tab === 'bike' ? '214 W' : '32 spm' }, ].map((s, i) => (
{s.l}
{s.v}
))}
{/* Week strip */}
Laatste 7 dagen
{WEEK_STRIP.filter(d=>d.sessions && d.sessions.length>0).length} / 7 dagen
{WEEK_STRIP.map((d, i) => { const on = d.sessions.length > 0; const multi = d.sessions.length > 1; return ( ); })}
{/* Coach prompt */}
Vraag Turbocoach iets
“Moet ik morgen rustig of intensief?”
{/* Row: race predictions */}
Wedstrijdvoorspellingen
Op basis van je laatste 30 dagen
{RACE_PREDICTIONS.map((r, i) => (
{r.label}
{r.time}
{r.delta}
))}
{/* Sporturen per week — stacked bar chart */} {/* Recent activities */}
Recente activiteiten
{ACTIVITIES.slice(0, 5).map(a => ( onActivityClick(a)} /> ))}
{/* FAB wordt door App gerenderd, gebaseerd op zichtbaarheid van de coach-prompt */}
); } // ─── Weekly hours stacked bar chart ───────────────────────── let CHART_WEEKS = []; // live data function TooltipRow({ color, label, hours }) { return (
{label}
{formatHours(hours)}
); } function WeeklyHoursChart() { const [hoverIdx, setHoverIdx] = useState(null); const weeks = CHART_WEEKS; const maxTotal = Math.max(...weeks.map(w => w.run + w.bike + w.swim)); const ceiling = Math.ceil(maxTotal / 2) * 2 || 2; // round up to even hours const yTicks = Array.from({ length: ceiling / 2 + 1 }, (_, i) => i * 2); // 0,2,4,6,8 // colours — tuned to the TC palette const C = { run: TC.orange, // #d86f24 bike: '#3ba6d6', // brand-friendly teal-blue swim: '#2fb673', // brand-friendly green }; return (
Sporturen per week
{/* Legend */}
{[ { label: 'Lopen', color: C.run }, { label: 'Fietsen', color: C.bike }, { label: 'Zwemmen', color: C.swim }, ].map(l => (
{l.label}
))}
{/* Plot area */}
{/* Y-axis */}
{yTicks.map(t => (
{t}u
))}
{/* Bars */}
{/* Grid lines */}
{yTicks.map((t, i) => (
))}
{/* Bar row */}
{weeks.map((w, i) => { const total = w.run + w.bike + w.swim; const hPct = (total / ceiling) * 100; const runPct = total ? (w.run / total) * 100 : 0; const bikePct = total ? (w.bike / total) * 100 : 0; const swimPct = total ? (w.swim / total) * 100 : 0; const isHover = hoverIdx === i; return (
setHoverIdx(i)} onMouseLeave={() => setHoverIdx(null)} onClick={() => setHoverIdx(hoverIdx === i ? null : i)} style={{ flex: 1, height: '100%', position: 'relative', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', minWidth: 0, cursor: 'pointer', }} > {/* Tooltip */} {isHover && (
{w.week} {w.month}
{w.run > 0 && } {w.bike > 0 && } {w.swim > 0 && } {total > 0 && (
Totaal {formatHours(total)}
)} {total === 0 &&
Geen activiteit
} {/* arrow */}
)}
{w.run > 0 &&
} {w.bike > 0 &&
} {w.swim > 0 &&
}
); })}
{/* X-axis labels — weeknr + maand alleen bij wissel */}
{weeks.map((w, i) => { const showMonth = i === 0 || weeks[i - 1].month !== w.month; return (
{w.week.replace('W', '')}
{showMonth ? w.month : '·'}
); })}
); } function IconBtn({ children, onClick, title, spin }) { return ( ); } function ActivityRow({ a, onClick }) { return (
{a.name}
{a.date}
{a.dist}{a.type === 'swim' ? ' m' : ' km'}
{a.type === 'bike' ? a.speed + ' km/u' : a.pace + (a.type === 'swim' ? ' /100m' : ' /km')}
); } // ─── Beta banner ──────────────────────────────────────────── function BetaBanner() { const [show, setShow] = useState(false); useEffect(() => { // Per spec: only show when 'beta' in hostname. We fake it via URL param or simulate on const h = window.location.hostname.toLowerCase(); const q = new URLSearchParams(window.location.search); setShow(h.includes('beta') || q.get('beta') === '1'); }, []); if (!show) return null; return (
BETA Je test de publieke beta. Feedback? Stuur Turbocoach een bericht.
); } // ─── Coach chat overlay ───────────────────────────────────── function CoachOverlay({ open, onClose, contextActivity }) { const [messages, setMessages] = useState(COACH_SEED); const [input, setInput] = useState(''); const [thinking, setThinking] = useState(null); // { label, phase } const [revealedIdx, setRevealedIdx] = useState({}); // messageIndex -> block count revealed const scrollRef = useRef(null); // Ref zodat de autoAnalyze-effect send() kan aanroepen (send wordt later gedefinieerd) const sendRef = useRef(null); // Per task_id: voorkomt dat send() en de chat-open resume tegelijk dezelfde task pollen // (anders krijg je bij een netwerk-blip twee identieke 'verbinding kwijt' meldingen). const activeTaskRef = useRef(new Set()); useEffect(() => { if (!open || !contextActivity) return; if (contextActivity.autoAnalyze) { // Plaats user-bericht in chat en trigger analyse via de backend. const actName = contextActivity.name || 'deze activiteit'; const prompt = `Analyseer ${actName} en geef specifiek advies.`; setMessages([{ role: 'user', text: prompt }]); setTimeout(() => { try { sendRef.current && sendRef.current(prompt, { skipAppend: true }); } catch(e) {} }, 50); return; } const isTip = contextActivity.isTip; setMessages([ { role: 'coach', blocks: [{ type: 'intro', text: isTip ? `Top, ik ga met "${contextActivity.name}" aan de slag. Op basis van je laatste 3 weken zie ik je drempel +0,12 w/kg, en je halve-marathontempo daalt. Wil je dat ik een piekblok plan?` : `Je tikte op "${contextActivity.name}". Wil je dat ik ${contextActivity.type === 'run' ? 'de tempoverdeling' : contextActivity.type === 'bike' ? 'het vermogen' : 'de splits'} analyseer, of liever advies voor morgen?`, }] } ]); }, [open, contextActivity]); useEffect(() => { if (!open) return; let cancelled = false; (async () => { try { const r = await api('/api/chat/history'); if (cancelled) return; const msgs = (r.messages || []).slice(-100).map(m => ({ role: m.role === 'assistant' ? 'coach' : 'user', text: m.content, blocks: m.role === 'assistant' ? parseCoachResponse(m.content) : undefined, })); if (msgs.length > 0) { setMessages(msgs); const r2 = {}; msgs.forEach((m, i) => { if (m.role === 'coach') r2[i] = (m.blocks ? m.blocks.length : 1); }); setRevealedIdx(r2); } if (r.conversation_id) window.__tcConv = r.conversation_id; } catch (e) { /* silent */ } // Hervat een eventuele pending chat-task: handig na fetch-failure of refresh. try { const raw = localStorage.getItem('tc_pendingTask'); if (raw && !cancelled) { const pending = JSON.parse(raw); // Maximaal 10 minuten oud — oudere tasks zijn waarschijnlijk al lang afgerond/cleaned. if (pending && pending.taskId && (Date.now() - (pending.ts || 0)) < 10 * 60 * 1000) { setThinking({ label: 'Antwoord ophalen', ms: 9999 }); await _chatRunTask(pending.taskId, pending.userText || '', null); } else { localStorage.removeItem('tc_pendingTask'); } } } catch (e) { /* silent */ } })(); return () => { cancelled = true; }; }, [open]); // Scroll: bij nieuwe coach-msg ankert de TOP van dat bericht bovenaan; // bij user-msg of thinking-indicator scrollen we naar onder zoals gewoonlijk. // Block-reveals (revealedIdx) triggeren GEEN scroll meer — anders zou het // antwoord onder je ogen vandaan vegen terwijl je leest. const prevLenRef = useRef(0); useEffect(() => { const sc = scrollRef.current; if (!sc) return; const prevLen = prevLenRef.current; const grew = messages.length > prevLen; const last = messages[messages.length - 1]; prevLenRef.current = messages.length; if (grew && last && last.role === 'coach') { requestAnimationFrame(() => { const el = sc.querySelector(`[data-msg-idx="${messages.length - 1}"]`); if (el && typeof el.offsetTop === 'number') { sc.scrollTop = Math.max(0, el.offsetTop - 8); } else { sc.scrollTop = sc.scrollHeight; } }); } else { sc.scrollTop = sc.scrollHeight; } }, [messages, thinking, open]); // Retry helper: bij netwerk-fouten (TypeError 'failed to fetch', geen status) // proberen we maxRetries keer met exponentiele backoff. HTTP 4xx/5xx (heeft // wel een status) gaan meteen door zonder retry. async function _chatRetry(path, opts, maxRetries = 3, baseMs = 800) { let lastErr; for (let i = 0; i <= maxRetries; i++) { try { return await api(path, opts); } catch (e) { lastErr = e; if (e && e.status) throw e; // server gaf antwoord, geen netwerk-fout if (i < maxRetries) { await new Promise(r => setTimeout(r, baseMs * Math.pow(2, i))); } } } throw lastErr; } // Hervat een lopende task: gebruikt door zowel send() als de chat-open resume-useEffect. // Guard: per task_id mag maar 1 polling-loop tegelijk lopen. Als beide callers dezelfde // taskId proberen te pollen, exit de tweede stil — anders krijg je bij een netwerk-blip // twee identieke foutmeldingen in de chat. async function _chatRunTask(taskId, userText, phaseTimer) { if (activeTaskRef.current.has(taskId)) { if (phaseTimer) clearInterval(phaseTimer); return false; } activeTaskRef.current.add(taskId); try { try { let tries = 0; let consecutiveNetErr = 0; while (tries < 180) { await new Promise(res => setTimeout(res, 1000)); let ss; try { ss = await api('/api/chat/task/' + taskId); consecutiveNetErr = 0; } catch (e) { if (e && e.status) { // 4xx/5xx — meteen falen throw e; } // Netwerk-blip: tel mee maar blijf doorgaan tot 8 opeenvolgende mislukten. consecutiveNetErr++; if (consecutiveNetErr >= 8) throw e; tries++; continue; } if (ss.status === 'done') { if (phaseTimer) clearInterval(phaseTimer); setThinking(null); setMessages(m => { const next = [...m, { role: 'coach', text: ss.response, blocks: parseCoachResponse(ss.response) }]; const msgIdx = next.length - 1; setRevealedIdx(r => ({ ...r, [msgIdx]: next[msgIdx].blocks.length })); return next; }); try { localStorage.removeItem('tc_pendingTask'); } catch(e) {} // Server schrijft user+assistant zelf weg in _run_chat_task — geen client-POST meer nodig. return true; } if (ss.status === 'error') throw new Error(ss.error || 'Chatfout'); tries++; } throw new Error('Tijd verstreken'); } catch (ex) { if (phaseTimer) clearInterval(phaseTimer); setThinking(null); let msg; if (ex && ex.status) { msg = 'De coach gaf een serverfout (HTTP ' + ex.status + ')' + (ex.message ? ': ' + ex.message : '') + '. Probeer het opnieuw.'; } else if (ex && ex.message === 'Tijd verstreken') { msg = 'Het antwoord duurde langer dan 3 minuten en is daarna afgebroken. Sluit de chat en open hem opnieuw — als de coach het toch op tijd afmaakte staat het antwoord in je geschiedenis.'; } else if (ex && ex.message === 'Chatfout') { msg = 'De coach kon je vraag niet verwerken. Probeer een kortere of duidelijkere formulering.'; } else { msg = 'De internetverbinding viel even weg vóór het antwoord binnen was. De coach werkt zijn analyse op de server af — sluit de chat en open hem opnieuw om het resultaat op te halen.'; } setMessages(m => { const next = [...m, { role: 'coach', blocks: [{ type: 'intro', text: msg }] }]; const msgIdx = next.length - 1; setRevealedIdx(r => ({ ...r, [msgIdx]: next[msgIdx].blocks.length })); return next; }); try { localStorage.removeItem('tc_pendingTask'); } catch(e) {} return false; } } finally { activeTaskRef.current.delete(taskId); } } // Houd sendRef up-to-date zodat de autoAnalyze useEffect send() kan aanroepen useEffect(() => { sendRef.current = send; }); function send(textOverride, opts) { const t = (textOverride ?? input).trim(); if (!t) return; if (!(opts && opts.skipAppend)) { setMessages(m => [...m, { role: 'user', text: t }]); } setInput(''); const phases = [ { label: 'Gezondheidsdata ophalen', ms: 9999 }, { label: 'Training-context wegen', ms: 9999 }, { label: 'Voorstel opbouwen', ms: 9999 }, ]; let phaseIdx = 0; setThinking(phases[0]); const phaseTimer = setInterval(() => { phaseIdx = Math.min(phaseIdx + 1, 2); setThinking(phases[phaseIdx]); }, 1800); (async () => { let taskId = null; try { const hist = messages.filter(m => m.role === 'user' || m.role === 'coach').map(m => ({ role: m.role === 'coach' ? 'assistant' : 'user', content: typeof m.text === 'string' ? m.text : ((m.blocks && m.blocks[0] && m.blocks[0].tldr) || '') })); const r = await _chatRetry('/api/chat/async', { method: 'POST', body: { message: t, history: hist, activity_id: (contextActivity && contextActivity.raw && contextActivity.raw.activity_id) || null, conversation_id: window.__tcConv || null } }, 3); taskId = r.task_id; if (r.conversation_id) window.__tcConv = r.conversation_id; try { localStorage.setItem('tc_pendingTask', JSON.stringify({ taskId, userText: t, ts: Date.now() })); } catch(e) {} } catch (ex) { clearInterval(phaseTimer); setThinking(null); const msg = ex && ex.status ? 'Er ging iets mis: ' + (ex.message || 'onbekende fout') : 'Geen verbinding met de coach. Controleer je internet en probeer opnieuw.'; setMessages(m => { const next = [...m, { role: 'coach', blocks: [{ type: 'intro', text: msg }] }]; const msgIdx = next.length - 1; setRevealedIdx(r => ({ ...r, [msgIdx]: next[msgIdx].blocks.length })); return next; }); return; } await _chatRunTask(taskId, t, phaseTimer); })(); } if (!open) return null; const hasUserSpoken = messages.some(m => m.role === 'user'); return (
{/* Header */}
Turbocoach
Online
{/* Messages */}
{messages.map((m, i) => (
))} {thinking && } {!hasUserSpoken && !thinking && (
{[ 'Kan ik tussen 60 en 80 min rollen vandaag, wat stel je voor?', 'Analyseer m’n laatste run', 'Moet ik rusten?', 'Zet VO₂-workout klaar', ].map(q => ( ))}
)}
{/* Input */}