// 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')}
{!inviteToken && (
{ setMode(mode==='login'?'register':'login'); setErr(''); }}>
{mode === 'login' ? 'Nog geen account? Registreren' : 'Al een account? Aanmelden'}
)}
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.
{garmin.status === 'mfa' || garmin.status === 'verifying' || garmin.status === 'ok' ? (
) : 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.
)}
{step === 3 && (
Nieuw wachtwoord
Optioneel
Stel je eigen wachtwoord in of sla over.
Nieuw wachtwoord
setPwNew(e.target.value)} />
{pwNew && pwNew.length < 8 &&
Minimaal 8 tekens
}
)}
{err &&
{err}
}
{step > 0 && setStep(step-1)}>Vorige }
{primaryLabel()}
{step < TOTAL - 1 && step !== 0 &&
setStep(step+1)}>Deze stap overslaan }
{step === TOTAL - 1 &&
Overslaan en afronden }
);
}
// ─── 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 && (
Ok
)}
);
}
// 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 (
);
}
function Chip({ children, active, onClick, color = TC.orange }) {
return (
{children}
);
}
function Card({ children, style, onClick }) {
return (
{children}
);
}
function SectionTitle({ children, right }) {
return (
);
}
// 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 => (
setTab(t.id)} style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px', borderRadius: 999,
border: `1px solid ${tab === t.id ? TC.orange : TC.line}`,
background: tab === t.id ? TC.orange : '#fff',
color: tab === t.id ? '#fff' : TC.ink2,
fontSize: 14, fontWeight: 600, cursor: 'pointer',
}}>
{t.label}
))}
{/* 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) => (
))}
{/* 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) => (
))}
{/* 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 (
onDayClick(d)} aria-label={`${d.label} — ${on ? d.sessions.length + ' sessie' + (multi ? 's' : '') : 'rustdag'}`} style={{
display: 'block', textAlign: 'center',
background: 'none', border: 'none', padding: 0, cursor: 'pointer',
}}>
{on
? (multi
?
: )
:
}
{multi && (
{d.sessions.length}
)}
{d.d}
);
})}
{/* 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 (
{children}
);
}
function ActivityRow({ a, onClick }) {
return (
{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 */}
{/* 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 => (
send(q)} style={{
padding: '8px 12px', borderRadius: 999,
border: `1px solid ${TC.orangeBorder}`,
background: '#fff', color: TC.orangeDark,
fontSize: 12, fontWeight: 600, cursor: 'pointer',
textAlign: 'left',
}}>{q}
))}
)}
{/* Input */}
);
}
// ─── Thinking animation ─────────────────────────────────────
function ThinkingIndicator({ phase }) {
return (
);
}
function Dot({ delay }) {
return ;
}
// Render user messages met nette structuur.
// ALTIJD toegepast op zowel nieuwe berichten als chat-history load,
// zodat lange paragrafen nooit als wall-of-text verschijnen.
function renderUserText(text) {
if (!text) return null;
let lines = String(text).split(/\r?\n/);
lines = lines.flatMap(ln => {
const trimmed = ln.trim();
if (trimmed.length <= 120) return [ln];
const sentences = trimmed.split(/([.!?])\s+(?=[A-Z0-9À-ſ])/);
const out = [];
for (let i = 0; i < sentences.length; i += 2) {
const body = sentences[i];
const punct = sentences[i + 1] || '';
if (body) out.push((body + punct).trim());
}
return out.length > 1 ? out : [ln];
});
function inline(s) {
const parts = [];
let rest = s; let key = 0;
const rx = /(\*\*([^*]+)\*\*|\*([^*]+)\*)/;
while (rest.length) {
const m = rest.match(rx);
if (!m) { parts.push(rest); break; }
if (m.index > 0) parts.push(rest.slice(0, m.index));
if (m[2]) parts.push(React.createElement('strong', { key: key++ }, m[2]));
else if (m[3]) parts.push(React.createElement('em', { key: key++ }, m[3]));
rest = rest.slice(m.index + m[0].length);
}
return parts;
}
return lines.map((ln, i) => {
const t = ln.trim();
if (!t) return React.createElement('div', { key: i, style: { height: 6 } });
const isLast = i === lines.length - 1;
const mb = isLast ? 0 : 6;
const labelMatch = t.match(/^([A-Za-z][A-Za-z0-9 &/_.-]{0,39}):\s+(.+)$/);
if (labelMatch) {
return React.createElement('div', { key: i, style: { marginBottom: mb } }, [
React.createElement('strong', { key: 'l', style: { fontWeight: 700 } }, labelMatch[1]),
': ',
...inline(labelMatch[2]),
]);
}
const isQuestion = t.endsWith('?');
return React.createElement('div', { key: i, style: { marginBottom: mb, fontWeight: isQuestion ? 600 : 500 } }, inline(t));
});
}
// ─── Chat message ───────────────────────────────────────────
function ChatMessage({ msg, revealCount }) {
if (msg.role === 'user') {
return (
{renderUserText(msg.text || '')}
);
}
// Coach message with rich blocks
const blocks = msg.blocks || [];
const n = Math.min(blocks.length, revealCount);
return (
{blocks.slice(0, n).map((b, i) => (
))}
);
}
// ─── Block renderer ─────────────────────────────────────────
function CoachBlock({ block }) {
switch (block.type) {
case 'intro':
return (
{block.text}
);
case 'summary':
return (
Samenvatting
{block.tldr}
{block.bullets.map((b, i) => (
{b.label}
{b.value}
))}
);
case 'section':
return ;
case 'outro':
return (
{block.text}
);
case 'workout':
return ;
default: return null;
}
}
function CoachSection({ block }) {
// Render parts array (sequential text/table/bullets) als die aanwezig is,
// anders backward-compat via block.text/block.table/block.bullets.
const parts = Array.isArray(block.parts) && block.parts.length
? block.parts
: [
block.text ? { type: 'text', text: block.text } : null,
block.table ? { type: 'table', table: block.table } : null,
block.bullets ? { type: 'bullets', bullets: block.bullets } : null,
].filter(Boolean);
return (
{block.highlight && (
{block.highlight}
)}
{parts.map((p, i) => {
const isLast = i === parts.length - 1;
const mb = isLast ? 0 : 10;
if (p.type === 'text') {
return (
{p.text}
);
}
if (p.type === 'subheader') {
return (
{p.text}
);
}
if (p.type === 'quote') {
return (
{p.text}
);
}
if (p.type === 'table') {
return (
);
}
if (p.type === 'bullets') {
return (
{p.bullets.map((b, j) => )}
);
}
return null;
})}
{block.footer && (
{block.footer.type === 'ok' && }
{block.footer.text}
)}
);
}
function BulletLine({ b }) {
return (
{b.bold && {b.bold}: }
{b.text}
{b.bold2 && {b.bold2} }
{b.textAfter && {b.textAfter} }
);
}
function CoachTable({ table }) {
return (
{table.head.map((h, i) => (
{h}
))}
{table.rows.map((row, ri) => (
{row.map((cell, ci) => (
0 ? `1px solid ${TC.lineSoft}` : 'none',
fontSize: 12, lineHeight: 1.4,
verticalAlign: 'top',
color: ci === 0 ? TC.ink : TC.ink2,
fontWeight: ci === 0 ? 600 : 400,
}}>
))}
))}
);
}
function TableCell({ cell }) {
if (cell && typeof cell === 'object') {
if (cell.ok) {
return (
{cell.text}
);
}
if (cell.em) {
return {cell.em} ;
}
}
return {cell} ;
}
function WorkoutCard({ block }) {
const [pushed, setPushed] = useState(false);
const [pushing, setPushing] = useState(false);
const [pushError, setPushError] = useState(null);
async function doPush() {
if (pushing || pushed) return;
setPushing(true); setPushError(null);
try {
await api('/api/workout/garmin', { method: 'POST', body: block.raw });
setPushed(true);
} catch (e) {
setPushError((e && e.message) ? e.message : 'Upload mislukt');
} finally {
setPushing(false);
}
}
return (
Workout klaar om te pushen
{block.name}
{block.sport} · {block.duration}
{block.steps.map((s, i) => (
{i + 1}
{s.label}
{s.detail}
))}
{pushed ? (
<>
Gepusht naar Garmin
>
) : (
<>
Push naar Garmin
>
)}
{pushed && (
Staat klaar in Garmin Connect. Push de workout naar je toestel via de app.
)}
);
}
// ─── Race predictions modal ─────────────────────────────────
function RacePredictionsSheet({ open, onClose, onTipTap }) {
const [explainOpen, setExplainOpen] = useState(false);
if (!open) return null;
return (
Verwachte tijden bij je huidige vorm. Gebaseerd op VO₂max-trend, drempelritten en recente ritten.
setExplainOpen(v => !v)} style={{
padding: '8px 12px', borderRadius: 999,
border: `1px solid ${TC.line}`, background: '#fff',
color: TC.ink2, fontSize: 12, fontWeight: 600, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
}}>
Hoe wordt dit berekend?
{explainOpen && (
Turbocoach weegt je laatste 30 dagen: VO₂max-schatting (50%), drempelritten (30%) en recente wedstrijden (20%). Voorspellingen zijn indicatief — weer, route en rust tellen ook.
)}
{RACE_PREDICTIONS.map((r, i) => (
{r.label}
{r.time}
{r.delta}
))}
Tip van Turbocoach: je halve-marathontijd daalt al drie weken. Wil je dat ik een piekblok inplan richting je 21.1K doel?
);
}
// ─── ERG power zones sheet ──────────────────────────────────
function PowerSheet({ open, onClose }) {
if (!open) return null;
return (
Je FTP is ingesteld op 252 W . Zones zijn berekend op basis van je laatste 20-min test.
{POWER_ZONES.map((z, i) => (
))}
Laatste ERG-workout
Sweet spot 4×8 min · 91% FTP
Normalized Power 231 W · IF 0.92 · TSS 84
);
}
// ─── Settings modal ─────────────────────────────────────────
function SettingsSheet({ open, onClose, onOpenAdmin, isAdmin, onLogout }) {
const [tab, setTab] = useState('connect');
const [hasInjury, setHasInjury] = useState(false);
const [saving, setSaving] = useState(false);
const connectSaveRef = useRef(null);
const profileSaveRef = useRef(null);
useEffect(() => {
if (!open) return;
let alive = true;
api('/api/injury').then(d => {
if (alive) setHasInjury(!!(d && d.injury && String(d.injury).trim()));
}).catch(() => {});
return () => { alive = false; };
}, [open]);
if (!open) return null;
const handleSave = async () => {
setSaving(true);
try {
if (connectSaveRef.current) await connectSaveRef.current();
if (profileSaveRef.current) await profileSaveRef.current();
} catch (e) { console.error('Save failed', e); }
finally { setSaving(false); onClose(); }
};
const footerButtons = (
{isAdmin && (
Admin
)}
{saving ? 'Opslaan…' : 'Opslaan'}
);
return (
{[
{ id: 'connect', label: 'Verbinding' },
{ id: 'profile', label: 'Profiel' },
{ id: 'races', label: 'Wedstrijden' },
{ id: 'app', label: 'App' },
{ id: 'injury', label: 'Blessure' },
].map(t => (
setTab(t.id)} style={{
padding: '10px 12px', border: 'none', background: 'none',
color: tab === t.id ? TC.orangeDark : TC.muted,
borderBottom: `2px solid ${tab === t.id ? TC.orange : 'transparent'}`,
fontSize: 13, fontWeight: 700, cursor: 'pointer',
marginBottom: -1,
}}>
{t.label}
{t.id === 'injury' && hasInjury && (
)}
))}
{tab === 'connect' && }
{tab === 'profile' && }
{tab === 'app' && (
{onLogout && (
{ if (window.confirm('Wil je uitloggen?')) { onClose(); onLogout(); } }} style={{
marginTop: 18, padding: '12px 14px', borderRadius: 12,
background: '#fff', border: `1px solid ${TC.red}`,
color: TC.red, fontSize: 14, fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}>Uitloggen
)}
)}
{tab === 'races' && }
{tab === 'injury' && }
);
}
// ─── Wedstrijden tab ────────────────────────────────────────
// Load + save + delete via /api/races. Authoritatief voor de AI coach.
function RacesTab() {
const [loaded, setLoaded] = useState(false);
const [races, setRaces] = useState([]);
const [editing, setEditing] = useState(null); // null = lijst, {} = nieuw, {id,...} = bewerken
const [saving, setSaving] = useState(false);
const reload = async () => {
try {
const d = await api('/api/races');
setRaces(Array.isArray(d.races) ? d.races : []);
setLoaded(true);
} catch (e) { setLoaded(true); }
};
useEffect(() => { reload(); }, []);
if (!loaded) return Wedstrijden laden…
;
if (editing !== null) {
return setEditing(null)}
onSaved={() => { setEditing(null); reload(); }}
/>;
}
const today = new Date(); today.setHours(0,0,0,0);
const upcoming = races.filter(r => new Date(r.race_date) >= today);
const past = races.filter(r => new Date(r.race_date) < today).reverse();
const fmtDateNL = (iso) => {
try {
const d = new Date(iso + 'T12:00:00');
const m = ['jan','feb','mrt','apr','mei','jun','jul','aug','sep','okt','nov','dec'];
return `${d.getDate()} ${m[d.getMonth()]} ${d.getFullYear()}`;
} catch (e) { return iso; }
};
const daysUntil = (iso) => {
try {
const d = new Date(iso + 'T12:00:00');
const t = new Date(); t.setHours(0,0,0,0);
return Math.round((d - t) / 86400000);
} catch (e) { return null; }
};
const Row = ({ r, isPast }) => {
const du = daysUntil(r.race_date);
return (
setEditing(r)} style={{
textAlign: 'left', width: '100%',
padding: '12px 14px', borderRadius: 12,
border: `1px solid ${isPast ? TC.line : TC.orangeBorder}`,
background: isPast ? TC.bgSoft : '#fff',
cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 4,
}}>
{r.name}
{isPast ? `${Math.abs(du)} d geleden` : du === 0 ? 'VANDAAG' : `nog ${du} d`}
{fmtDateNL(r.race_date)}
{r.distance_km ? · {r.distance_km} km : null}
{r.elevation_m ? · {r.elevation_m} m D+ : null}
{r.target_time ? · doel {r.target_time} : null}
{r.priority && r.priority !== 'A' ? · prio {r.priority} : null}
);
};
return (
Hier ingegeven wedstrijden zijn de énige bron van waarheid voor de AI coach. Datum, afstand en doel worden exact zo gebruikt zonder eigen interpretatie.
setEditing({})} style={{
padding: '12px 14px', borderRadius: 12,
background: TC.orange, color: '#fff', border: 'none',
fontSize: 14, fontWeight: 700, cursor: 'pointer',
}}>+ Nieuwe wedstrijd
{upcoming.length > 0 && (
Opkomend
{upcoming.map(r =>
)}
)}
{past.length > 0 && (
Recent (afgelopen)
{past.slice(0, 5).map(r =>
)}
)}
{upcoming.length === 0 && past.length === 0 && (
Nog geen wedstrijden ingegeven.
)}
);
}
function RaceForm({ initial, onCancel, onSaved }) {
const [name, setName] = useState(initial.name || '');
const [date, setDate] = useState(initial.race_date || '');
const [sport, setSport] = useState(initial.sport || 'running');
const [distance, setDistance] = useState(initial.distance_km != null ? String(initial.distance_km) : '');
const [elevation, setElevation] = useState(initial.elevation_m != null ? String(initial.elevation_m) : '');
const [target, setTarget] = useState(initial.target_time || '');
const [priority, setPriority] = useState(initial.priority || 'A');
const [notes, setNotes] = useState(initial.notes || '');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState(null);
const isEdit = !!initial.id;
const save = async () => {
setErr(null);
if (!name.trim()) { setErr('Naam is verplicht'); return; }
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { setErr('Datum is verplicht (YYYY-MM-DD)'); return; }
setSaving(true);
try {
const payload = {
name: name.trim(),
race_date: date,
sport,
priority,
};
if (isEdit) payload.id = initial.id;
if (distance.trim()) { const n = parseFloat(distance.replace(',', '.')); if (!Number.isNaN(n)) payload.distance_km = n; }
if (elevation.trim()) { const n = parseInt(elevation, 10); if (!Number.isNaN(n)) payload.elevation_m = n; }
if (target.trim()) payload.target_time = target.trim();
if (notes.trim()) payload.notes = notes.trim();
await api('/api/races', { method: 'POST', body: JSON.stringify(payload) });
onSaved();
} catch (e) {
setErr(e.message || 'Opslaan mislukt');
} finally { setSaving(false); }
};
const remove = async () => {
if (!isEdit) { onCancel(); return; }
if (!window.confirm(`Wedstrijd "${initial.name}" verwijderen?`)) return;
setSaving(true);
try {
await api('/api/races/' + initial.id, { method: 'DELETE' });
onSaved();
} catch (e) { setErr(e.message || 'Verwijderen mislukt'); setSaving(false); }
};
return (
{isEdit ? 'Wedstrijd bewerken' : 'Nieuwe wedstrijd'}
Sport
setSport(e.target.value)} style={{
width: '100%', padding: '11px 14px', borderRadius: 12,
border: `1px solid ${TC.line}`, background: TC.bgSoft,
fontSize: 14, fontFamily: 'inherit', color: TC.ink, outline: 'none',
boxSizing: 'border-box',
}}>
Hardlopen
Trail
Fietsen
Zwemmen
Triatlon
Andere
Prioriteit
setPriority(e.target.value)} style={{
width: '100%', padding: '11px 14px', borderRadius: 12,
border: `1px solid ${TC.line}`, background: TC.bgSoft,
fontSize: 14, fontFamily: 'inherit', color: TC.ink, outline: 'none',
boxSizing: 'border-box',
}}>
A — hoofddoel
B — belangrijk
C — trainingswedstrijd
{err &&
{err}
}
{isEdit && (
Verwijderen
)}
Annuleren
{saving ? 'Opslaan…' : 'Opslaan'}
);
}
// ─── Helper: relatieve tijd ───────────────────────────────
function relativeTime(iso) {
if (!iso) return null;
const t = Date.parse(iso);
if (Number.isNaN(t)) return null;
const diffMs = Date.now() - t;
if (diffMs < 0) return 'nu';
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return 'net nu';
if (mins < 60) return `${mins} min geleden`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs} uur geleden`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days} dag${days === 1 ? '' : 'en'} geleden`;
const weeks = Math.floor(days / 7);
if (weeks < 5) return `${weeks} week${weeks === 1 ? '' : 'en'} geleden`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} maand${months === 1 ? '' : 'en'} geleden`;
return `${Math.floor(days / 365)} jaar geleden`;
}
// ─── Connect tab (backend-gebacked) ────────────────────────
function ConnectTab({ saveRef }) {
const [loaded, setLoaded] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [ouraToken, setOuraToken] = useState('');
const [ouraHint, setOuraHint] = useState('');
const [garminConnected, setGarminConnected] = useState(false);
const [ouraConnected, setOuraConnected] = useState(false);
const [lastActivityAt, setLastActivityAt] = useState(null);
useEffect(() => {
let alive = true;
api('/api/settings').then(d => {
if (!alive || !d) return;
setEmail(d.garmin_email || '');
setGarminConnected(!!d.garmin_connected);
setOuraConnected(!!d.oura_connected);
setOuraHint(d.oura_token_hint || '');
setLastActivityAt(d.last_activity_at || null);
setLoaded(true);
}).catch(() => setLoaded(true));
return () => { alive = false; };
}, []);
if (saveRef) saveRef.current = async () => {
const payload = {};
if (email) payload.garmin_email = email;
if (password) payload.garmin_password = password;
if (ouraToken) payload.oura_token = ouraToken;
if (Object.keys(payload).length === 0) return;
await api('/api/settings', { method: 'POST', body: JSON.stringify(payload) });
};
const rel = relativeTime(lastActivityAt);
return (
{loaded && (
garminConnected ? (
Verbonden met Garmin Connect{rel ? ` · laatste activiteit ${rel}` : ''}
) : (
Nog niet verbonden — vul e-mail + wachtwoord in en tik Opslaan
)
)}
);
}
function Field({ label, defaultValue, placeholder, type = 'text', value, onChange }) {
// Controlled wanneer onChange meegegeven wordt; anders uncontrolled met defaultValue.
const isControlled = typeof onChange === 'function';
const inputProps = isControlled
? { value: value == null ? '' : value, onChange: (e) => onChange(e.target.value) }
: { defaultValue };
return (
{label}
);
}
function TextArea({ label, defaultValue, placeholder, rows = 3, value, onChange }) {
const isControlled = typeof onChange === 'function';
const areaProps = isControlled
? { value: value == null ? '' : value, onChange: (e) => onChange(e.target.value) }
: { defaultValue };
return (
{label}
);
}
// ─── Profile tab ────────────────────────────────────────────
// Laadt bestaande profieldata via /api/profile en slaat op via /api/profile.
function ProfileTab({ saveRef }) {
const [loaded, setLoaded] = useState(false);
const [dirty, setDirty] = useState(false);
const [p, setP] = useState({});
useEffect(() => {
let alive = true;
api('/api/profile').then(d => {
if (!alive) return;
setP(d || {});
setLoaded(true);
}).catch(() => setLoaded(true));
return () => { alive = false; };
}, []);
const upd = (k) => (v) => { setP(prev => ({ ...prev, [k]: v })); setDirty(true); };
if (saveRef) saveRef.current = async () => {
if (!dirty) return;
const intFields = ['run_hr_rest','run_hr_max','run_hr_aerobic','run_hr_anaerobic','bike_ftp','bike_hr_max','bike_hr_aerobic','bike_hr_anaerobic','age'];
const floatFields = ['run_vo2max','bike_weight'];
const clean = {};
for (const [k, v] of Object.entries(p)) {
if (v === '' || v === null || v === undefined) continue;
if (intFields.includes(k)) {
const n = parseInt(v, 10); if (!Number.isNaN(n)) clean[k] = n;
} else if (floatFields.includes(k)) {
const n = parseFloat(v); if (!Number.isNaN(n)) clean[k] = n;
} else {
clean[k] = String(v);
}
}
await api('/api/profile', { method: 'POST', body: JSON.stringify(clean) });
};
if (!loaded) {
return Profiel laden…
;
}
const val = (k) => (p[k] === undefined || p[k] === null ? '' : String(p[k]));
return (
Persoonlijk
Hartslag & drempels — lopen
Fietsen
);
}
function SectionLabel({ children }) {
return (
{children}
);
}
// ─── Injury tab ─────────────────────────────────────────────
function InjuryTab() {
const [loaded, setLoaded] = useState(false);
const [hasInjury, setHasInjury] = useState(false);
const [desc, setDesc] = useState('');
const [severity, setSeverity] = useState('mild');
const [since, setSince] = useState('');
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState('');
useEffect(() => {
let alive = true;
api('/api/injury').then(data => {
if (!alive) return;
const d = (data && data.injury) || '';
if (d) {
setDesc(d);
setHasInjury(true);
if (data.severity) setSeverity(data.severity);
if (data.since) setSince(data.since);
}
setLoaded(true);
}).catch(() => setLoaded(true));
return () => { alive = false; };
}, []);
async function saveInjury() {
setSaving(true); setSaveMsg('');
try {
await api('/api/injury', { method: 'POST', body: { description: desc, severity, since } });
setSaveMsg('Opgeslagen');
setTimeout(() => setSaveMsg(''), 2500);
} catch(e) { setSaveMsg('Kon niet opslaan'); } finally { setSaving(false); }
}
async function clearInjury() {
setSaving(true); setSaveMsg('');
try {
await api('/api/injury', { method: 'POST', body: { description: '' } });
setDesc(''); setSeverity('mild'); setSince(''); setHasInjury(false); setSaveMsg('Gewist');
setTimeout(() => setSaveMsg(''), 2500);
} catch(e) { setSaveMsg('Kon niet wissen'); } finally { setSaving(false); }
}
if (!loaded) {
return Laden…
;
}
if (!hasInjury) {
return (
Geen actieve blessure
Meld hier een nieuwe blessure zodat de coach er rekening mee houdt in elk advies.
setHasInjury(true)} style={{
padding: '10px 18px', borderRadius: 999, border: 'none',
background: TC.red, color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>+ Blessure melden
);
}
return (
Actieve blessure. Deze info wordt altijd meegenomen in elk advies van de coach.
Ernst
{[
{ id: 'mild', label: 'Licht', color: '#e0a810' },
{ id: 'moderate', label: 'Matig', color: '#e0773b' },
{ id: 'severe', label: 'Zwaar', color: TC.red },
].map(s => (
setSeverity(s.id)} style={{
flex: 1, padding: '0 4px', border: 'none', borderRadius: 8,
background: severity === s.id ? '#fff' : 'transparent',
color: severity === s.id ? s.color : TC.muted,
fontSize: 12, fontWeight: 700, cursor: 'pointer',
boxShadow: severity === s.id ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
}}>{s.label}
))}
Sinds
setSince(e.target.value)} style={{
width: '100%', height: 40, padding: '0 14px', borderRadius: 10,
border: `1px solid ${TC.line}`, background: TC.bgSoft,
fontSize: 14, fontFamily: 'inherit', color: TC.ink, outline: 'none',
boxSizing: 'border-box',
}} />
{saving ? 'Bezig…' : 'Opslaan'}
Blessure wissen
{saveMsg &&
{saveMsg}
}
setHasInjury(false)} style={{
display: 'none',
padding: '10px', borderRadius: 10,
background: '#fff', border: `1px solid ${TC.line}`,
color: TC.red, fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>Blessure opgelost — verwijderen
);
}
function InjuryCheck({ label }) {
const [on, setOn] = useState(false);
return (
{on && }
{label}
setOn(e.target.checked)} style={{ display: 'none' }} />
);
}
function Toggle({ label, defaultOn }) {
const [on, setOn] = useState(!!defaultOn);
return (
setOn(v => !v)} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 14px', borderRadius: 12,
border: `1px solid ${TC.line}`, background: '#fff', cursor: 'pointer',
}}>
{label}
);
}
// ─── Admin modal ────────────────────────────────────────────
const INITIAL_USERS = [
{ name: 'Cedric', handle: 'cedric', admin: true },
{ name: 'Dries', handle: 'dries' },
{ name: 'Alain', handle: 'alain' },
{ name: 'Katrien', handle: 'katrien' },
{ name: 'Stephanie', handle: 'stephanie' },
{ name: 'Rodrigue', handle: 'Rodrigue' },
{ name: 'Sam', handle: 'sam' },
];
const INITIAL_INVITES = [
{ code: '90ba9015', used: 'Rodrigue', when: '7 apr' },
{ code: '8cc6ea01', used: 'sam', when: '1 apr' },
];
function AdminSheet({ open, onClose, isAdmin }) {
if (open && !isAdmin) { setTimeout(onClose, 0); return null; }
const [users, setUsers] = useState(INITIAL_USERS);
const [invites, setInvites] = useState(INITIAL_INVITES);
const [newU, setNewU] = useState('');
const [newP, setNewP] = useState('');
const [resetFor, setResetFor] = useState(null);
const [resetPwd, setResetPwd] = useState('');
const [flash, setFlash] = useState(null);
if (!open) return null;
function flashMsg(text) {
setFlash(text);
setTimeout(() => setFlash(null), 2200);
}
function deleteUser(h) {
setUsers(u => u.filter(x => x.handle !== h));
flashMsg(`Gebruiker @${h} verwijderd`);
}
function addUser() {
if (!newU.trim() || newP.length < 8) return;
setUsers(u => [...u, { name: newU.trim(), handle: newU.trim().toLowerCase() }]);
setNewU(''); setNewP('');
flashMsg('Gebruiker toegevoegd');
}
function saveReset() {
if (resetPwd.length < 8) return;
flashMsg(`Wachtwoord van @${resetFor.handle} gereset`);
setResetFor(null); setResetPwd('');
}
function genInvite() {
const code = Math.random().toString(16).slice(2, 10);
setInvites(iv => [{ code, used: null, when: 'nu' }, ...iv]);
flashMsg('Uitnodigingslink gegenereerd');
}
return (
{flash && (
{flash}
)}
Hoofdadmin-paneel. Wijzigingen hier gelden voor alle beta-testers.
{/* Anthropic API key */}
flashMsg('API key opgeslagen')} style={primaryBtn}>API key opslaan
{/* Password reset for current admin */}
flashMsg('Wachtwoord gewijzigd')} style={primaryBtn}>Wachtwoord bijwerken
{/* User management */}
{users.map(u => (
{u.name}
(@{u.handle})
{u.admin && (
ADMIN
)}
{!u.admin && (
<>
setResetFor(u)} style={pillBtn}>Reset
deleteUser(u.handle)} style={{ ...pillBtn, color: TC.red, borderColor: '#f3c6c6' }}>Verwijder
>
)}
))}
{/* Reset inline form */}
{resetFor && (
Reset wachtwoord voor @{resetFor.handle}
setResetPwd(e.target.value)}
placeholder="Nieuw wachtwoord (min 8)"
style={inputStyle}
/>
{ setResetFor(null); setResetPwd(''); }} style={{ ...pillBtn, flex: 1 }}>Annuleren
Opslaan
)}
{/* New user */}
setNewU(e.target.value)} placeholder="Gebruikersnaam" style={inputStyle} />
setNewP(e.target.value)} placeholder="Wachtwoord (min 8)" type="password" style={inputStyle} />
= 8 ? TC.orange : TC.lineSoft,
color: newU.trim() && newP.length >= 8 ? '#fff' : TC.mutedSoft,
}}>+ Toevoegen
{/* Invite links */}
Uitnodigingslink genereren
{invites.map(iv => (
{iv.code}
{iv.used
? {iv.used}
: open
}
{iv.when}
{ navigator.clipboard?.writeText(iv.code); flashMsg('Gekopieerd'); }} style={pillBtn}>Kopieer
))}
);
}
const primaryBtn = {
width: '100%', marginTop: 10, padding: '12px', borderRadius: 12,
background: TC.orange, color: '#fff', border: 'none',
fontSize: 14, fontWeight: 700, cursor: 'pointer', letterSpacing: 0.3,
};
const pillBtn = {
padding: '6px 12px', borderRadius: 999,
background: '#fff', border: `1px solid ${TC.line}`,
color: TC.ink2, fontSize: 12, fontWeight: 700, cursor: 'pointer',
};
const inputStyle = {
width: '100%', padding: '10px 12px', borderRadius: 10,
border: `1px solid ${TC.line}`, background: TC.bgSoft,
fontSize: 14, fontFamily: 'inherit', color: TC.ink, outline: 'none',
boxSizing: 'border-box',
};
function AdminSection({ title, children }) {
return (
);
}
function AdminRow({ icon, title, subtitle }) {
return (
);
}
// ─── Confirm modal (in-app, not native) ─────────────────────
function ConfirmModal({ open, activity, onCancel, onConfirm }) {
if (!open) return null;
return (
e.stopPropagation()} style={{
width: '100%', background: '#fff',
borderRadius: '20px 20px 0 0',
padding: '20px 18px calc(28px + env(safe-area-inset-bottom, 0px))',
animation: 'tc-slide-up 200ms ease-out',
}}>
{activity?.isTip ? 'Tip laten analyseren?' : 'Deze activiteit analyseren?'}
{activity?.isTip
? <>Turbocoach analyseert {activity?.name} en stelt een concreet trainingsblok voor.>
: <>Turbocoach bekijkt {activity?.name} en geeft meteen specifiek advies.>
}
Annuleren
Analyseren
);
}
// ─── Sheet frame (bottom-sheet modal used by predictions/power/settings) ─
function SheetFrame({ title, onClose, children, footer }) {
return (
e.stopPropagation()} style={{
width: '100%', maxHeight: '85dvh', background: '#fff',
borderRadius: '20px 20px 0 0',
display: 'flex', flexDirection: 'column',
animation: 'tc-slide-up 220ms ease-out',
}}>
{children}
{footer && (
{footer}
)}
);
}
// ─── Day detail sheet (week-strip tap) ──────────────────────
function DaySheet({ open, day, onClose, onOpenSession, onOpenChat }) {
if (!open || !day) return null;
const count = day.sessions.length;
return (
{count === 0 && (
Rustdag
Geen sessies geregistreerd — slaap telt ook als training.
Plan een workout met Turbocoach
)}
{count >= 1 && (
{count > 1 && (
{count}× sessies
Dubbele trainingsdag. Tik op een sessie voor details.
)}
{day.sessions.map(s => (
onOpenSession(s)} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 14px', borderRadius: 14,
background: '#fff', border: `1px solid ${TC.line}`,
cursor: 'pointer', textAlign: 'left',
}}>
{s.name}
{s.time} · {s.dur} · HR {s.hr}
))}
{/* Day totals */}
{[
{ l: 'Sessies', v: count },
{ l: 'Totale tijd', v: sumTimes(day.sessions) },
{ l: 'Calorieën', v: day.sessions.reduce((a, s) => a + (s.cal || 0), 0) },
].map((t, i) => (
))}
)}
);
}
function sumTimes(sessions) {
// crude: sums "hh:mm:ss" / "mm:ss" strings; fine for mock
let total = 0;
sessions.forEach(s => {
const parts = s.dur.split(':').map(Number);
if (parts.length === 3) total += parts[0]*3600 + parts[1]*60 + parts[2];
else if (parts.length === 2) total += parts[0]*60 + parts[1];
});
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
return h ? `${h}:${String(m).padStart(2,'0')} u` : `${m} min`;
}
// ─── Root app ───────────────────────────────────────────────
function App({ currentUser, onLogout }) {
const isAdmin = !!(currentUser && currentUser.is_admin);
const [liveActivities, setLiveActivities] = useState([]);
const [liveHealth, setLiveHealth] = useState(null);
const [liveStats, setLiveStats] = useState(null);
const [dataVersion, setDataVersion] = useState(0);
const reloadData = useCallback(async () => {
try {
const [actsRes, healthRes, statsRes] = await Promise.all([
api('/api/activities?limit=20').catch(() => ({ activities: [] })),
api('/api/health').catch(() => null),
api('/api/stats').catch(() => null),
]);
const [raceRes, whRes] = await Promise.all([
api('/api/stats/race-times').catch(() => null),
api('/api/stats/weekly-hours').catch(() => null),
]);
const mapped = (actsRes.activities || []).map(mapActivity);
ACTIVITIES = mapped;
WEEK_STRIP = buildWeekStrip(mapped);
// Map race-times -> RACE_PREDICTIONS voor de 4-koloms card
const labelFor = (name) => name === '5 km' ? '5K' : name === '10 km' ? '10K' : name === 'Halve marathon' ? 'HM' : name === 'Marathon' ? 'MAR' : name;
const _whWeeks = (whRes && Array.isArray(whRes.weeks)) ? whRes.weeks : [];
CHART_WEEKS = _whWeeks.map(w => {
const lbl = (w.week_label || '').split(' ');
return {
run: Number(w.run_hours) || 0,
bike: Number(w.bike_hours) || 0,
swim: Number(w.swim_hours) || 0,
week: lbl[0] || '',
month: lbl[1] || '',
};
});
const rt = (raceRes && Array.isArray(raceRes.race_times)) ? raceRes.race_times : [];
RACE_PREDICTIONS = rt.slice(0, 4).map(r => {
const adj = typeof r.form_adjustment_pct === 'number' ? r.form_adjustment_pct : 0;
return {
label: labelFor(r.distance || ''),
time: r.time || '—',
trend: adj <= 0 ? 'down' : 'up',
delta: (adj === 0 ? '0%' : (adj > 0 ? '+' : '') + adj.toFixed(1) + '%'),
};
});
setLiveActivities(mapped);
setLiveHealth(healthRes);
setLiveStats(statsRes);
setDataVersion(v => v + 1);
} catch (e) { console.warn('Data fetch failed', e); }
}, []);
useEffect(() => { reloadData(); }, [reloadData]);
const [syncStatus, setSyncStatus] = useState(null);
const [chat, setChat] = useState(false);
const [chatCtx, setChatCtx] = useState(null);
const [predict, setPredict] = useState(false);
const [settings, setSet] = useState(false);
const [admin, setAdmin] = useState(false);
const [confirm, setConfirm] = useState(null); // activity OR tip
const [day, setDay] = useState(null); // week-strip day
const [syncing, setSyncing] = useState(false);
const [promptVisible, setPromptVisible] = useState(true);
function activityClicked(a) {
setConfirm(a);
}
function openChatFromConfirm() {
// Tag context zodat CoachOverlay direct de analyse start
setChatCtx(confirm ? { ...confirm, autoAnalyze: true } : null);
setConfirm(null);
setChat(true);
}
function sync() {
setSyncing(true); setSyncStatus({ phase: "running", title: "Synchroniseren...", message: "Nieuwe activiteiten ophalen van Garmin Connect." }); api("/api/sync",{method:"POST"}).then(data => { if (data && data.status === "in_progress") { setSyncStatus({ phase: "info", title: "Al bezig", message: "Een synchronisatie loopt al op de achtergrond." }); } else if (data && data.is_initial) { setSyncStatus({ phase: "info", title: "Eerste synchronisatie gestart", message: "Je volledige sportgeschiedenis wordt opgehaald. Dit kan enkele minuten duren.", hint: "Je kan de app gewoon blijven gebruiken." }); } else { const n = (data && typeof data.synced === "number") ? data.synced : 0; const extra = (data && data.details_caching > 0) ? " Details van " + data.details_caching + " activiteiten worden op de achtergrond opgehaald." : ""; setSyncStatus({ phase: "success", title: n > 0 ? (n + " nieuwe " + (n === 1 ? "activiteit" : "activiteiten")) : "Alles up-to-date", message: n > 0 ? ("Je dashboard is bijgewerkt." + extra) : "Geen nieuwe activiteiten sinds de laatste sync." }); } }).catch(e => { setSyncStatus({ phase: "error", title: "Synchronisatie mislukt", message: (e && e.message) ? e.message : "Onbekende fout. Probeer het later opnieuw." }); }).finally(() => { setSyncing(false); reloadData(); });
}
return (
{ setChatCtx(null); setChat(true); }}
onOpenPredictions={() => setPredict(true)}
onOpenSettings={() => setSet(true)}
onActivityClick={activityClicked}
onDayClick={(d) => setDay(d)}
onSync={sync}
syncing={syncing}
onPromptVisible={setPromptVisible}
/>
setSyncStatus(null)} />
{/* FAB — alleen wanneer 'Vraag Turbocoach iets' niet in beeld is */}
{!promptVisible && !chat && !predict && !settings && !admin && !confirm && !day && (
{ setChatCtx(null); setChat(true); }} aria-label="Open coach chat" style={{
position: 'absolute', right: 18, bottom: 20,
width: 60, height: 60, borderRadius: 18,
background: TC.orange, color: '#fff',
border: 'none', cursor: 'pointer',
boxShadow: '0 12px 28px rgba(245, 124, 42, 0.45), 0 2px 6px rgba(245,124,42,0.25)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 20,
animation: 'tc-fade 180ms ease-out',
}}>
)}
setChat(false)} contextActivity={chatCtx} />
setPredict(false)}
onTipTap={() => {
setPredict(false);
setTimeout(() => setConfirm({
id: 'tip-piekblok',
name: 'Piekblok richting halve marathon',
type: 'run',
isTip: true,
}), 180);
}}
/>
setSet(false)}
onOpenAdmin={() => { if (isAdmin) { setSet(false); setTimeout(() => setAdmin(true), 200); } }}
isAdmin={isAdmin}
onLogout={onLogout}
/>
setAdmin(false)} isAdmin={isAdmin} />
setConfirm(null)}
onConfirm={openChatFromConfirm}
/>
setDay(null)}
onOpenSession={(s) => { setDay(null); setConfirm({ id: s.id, name: s.name, type: s.type }); }}
onOpenChat={() => { setDay(null); setChatCtx(null); setChat(true); }}
/>
);
}
Object.assign(window, { App });
/* === ERROR BOUNDARY === */
class TCErrorBoundary extends React.Component {
constructor(p){ super(p); this.state = { err: null }; }
static getDerivedStateFromError(err){ return { err }; }
componentDidCatch(err, info){ console.error('TC render error', err, info); }
render(){
if (this.state.err) {
return React.createElement('div', { style: { padding: 24, fontFamily: 'system-ui', background: '#fff1e6', color: '#7a2e0a', minHeight: '100vh', fontSize: 13, whiteSpace: 'pre-wrap', wordBreak: 'break-word' } },
React.createElement('h2', { style: { marginTop: 0 } }, 'Turbocoach is gecrasht'),
React.createElement('div', { style: { marginBottom: 12 } }, 'Probeer /legacy voor de oude UI.'),
React.createElement('pre', { style: { fontSize: 11, background: '#fff', padding: 12, borderRadius: 8, overflow: 'auto' } }, String(this.state.err && (this.state.err.stack || this.state.err.message)))
);
}
return this.props.children;
}
}
/* === ROOT MOUNT WRAPPER === */
function TurbocoachRoot() {
const [me, setMe] = useState(undefined);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
api('/api/auth/me')
.then(u => { if (!cancelled) setMe(u); })
.catch(() => { if (!cancelled) setMe(null); });
return () => { cancelled = true; };
}, [reloadKey]);
if (me === undefined) {
return Laden...
;
}
if (me === null) {
return setReloadKey(k => k + 1)} />;
}
const needsOnb = (me.user && me.user.needs_onboarding) || me.needs_onboarding;
if (needsOnb) {
return setReloadKey(k => k + 1)} />;
}
return { setAuthToken(null); fetch('/api/auth/logout', { method:'POST', credentials:'include' }); setMe(null); }} />;
}
window.TurbocoachRoot = TurbocoachRoot;
window.TCErrorBoundary = TCErrorBoundary;