Code.gs
const PL = {
SHEET_ID: '1UO7IyDLK5XgOvGDr87uQa8a2MjcXKc-hOuQrY98lqCQ',
APP: 'PadelLeraar · KNLTB Reminder',
VERSION: '4.0.0',
SESSION_HOURS: 24,
CODE_MINUTES: 15,
PASSWORD_MIN: 10
};
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle(PL.APP)
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function _ss(){ return SpreadsheetApp.openById(PL.SHEET_ID); }
function _sh(n){ const s=_ss().getSheetByName(n); if(!s) throw new Error('Sheet ontbreekt: '+n); return s; }
function _now(){ return new Date(); }
function _iso(v){ return (v instanceof Date ? v : new Date(v)).toISOString(); }
function _client(obj){ return JSON.parse(JSON.stringify(obj)); }
function _id(prefix){ return prefix+'_'+Utilities.getUuid(); }
function _normEmail(v){ return String(v||'').trim().toLowerCase(); }
function _hash(v){ const b=Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256,String(v),Utilities.Charset.UTF_8); return b.map(x=>('0'+((x<0?x+256:x).toString(16))).slice(-2)).join(''); }
function _salt(){ return Utilities.getUuid().replace(/-/g,''); }
function _passwordHash(password,salt){ return _hash(salt+'|'+password+'|PadelLeraar'); }
function _headers(sh){ return sh.getRange(1,1,1,sh.getLastColumn()).getValues()[0]; }
function _rows(sh){ if(sh.getLastRow()<2) return []; const h=_headers(sh); return sh.getRange(2,1,sh.getLastRow()-1,h.length).getValues().map((r,i)=>({row:i+2,obj:Object.fromEntries(h.map((k,j)=>[k,r[j]]))})); }
function _append(name,obj){ const sh=_sh(name), h=_headers(sh); sh.appendRow(h.map(k=>obj[k]??'')); return obj; }
function _updateRow(sh,row,obj){ const h=_headers(sh); const current=sh.getRange(row,1,1,h.length).getValues()[0]; sh.getRange(row,1,1,h.length).setValues([h.map((k,i)=>Object.prototype.hasOwnProperty.call(obj,k)?obj[k]:current[i])]); }
function _findUser(email){ return _rows(_sh('Users')).find(x=>_normEmail(x.obj.email)===_normEmail(email)); }
function _publicProfile(u){ if(!u) return {}; return {userId:u.userId,email:u.email,name:u.name,phone:u.phone,site:u.siteUrl,quote:u.ownQuote,role:u.role}; }
function _log(action,email,status,message,entityType,entityId){ try{_append('AuditLog',{logId:_id('log'),createdAt:_now(),email:_normEmail(email),action,entityType:entityType||'',entityId:entityId||'',status:status||'OK',message:message||''});}catch(e){} }
function _latestUserRow(sheetName,email){ const rows=_rows(_sh(sheetName)).filter(x=>_normEmail(x.obj.email)===_normEmail(email)); return rows.length?rows[rows.length-1]:null; }
function _safeJson(v,fallback){ try{return v?JSON.parse(String(v)):fallback}catch(e){return fallback} }
function plRequestLoginCode(req){
const email=_normEmail(req&&req.email); if(!email||!email.includes('@')) throw new Error('Ongeldig e-mailadres.');
const code=String(Math.floor(100000+Math.random()*900000));
const codeId=_id('code'), expires=new Date(Date.now()+PL.CODE_MINUTES*60000);
_append('LoginCodes',{codeId,email,codeHash:_hash(code),expiresAt:expires,attempts:0,createdAt:_now(),purpose:'LOGIN',status:'OPEN'});
MailApp.sendEmail({to:email,subject:'Jouw PadelLeraar login-code',htmlBody:'<p>Jouw code voor <b>'+PL.APP+'</b> is:</p><p style="font-size:28px;font-weight:bold;letter-spacing:4px">'+code+'</p><p>Deze code is '+PL.CODE_MINUTES+' minuten geldig.</p><p><a href="https://sites.google.com/view/padelstudie/reminder">PadelStudie Reminder</a></p>'});
_log('REQUEST_CODE',email,'OK','Code verzonden','LoginCode',codeId); return {ok:true,message:'Code verzonden naar '+email+'.'};
}
function _verifyCode(email,code){
const rows=_rows(_sh('LoginCodes')).filter(x=>_normEmail(x.obj.email)===email && String(x.obj.status)==='OPEN').reverse();
const hit=rows.find(x=>new Date(x.obj.expiresAt).getTime()>Date.now()); if(!hit) throw new Error('Geen geldige code gevonden. Vraag een nieuwe code aan.');
const attempts=Number(hit.obj.attempts||0)+1; if(attempts>5){_updateRow(_sh('LoginCodes'),hit.row,{attempts,status:'BLOCKED'});throw new Error('Te veel pogingen. Vraag een nieuwe code aan.');}
if(_hash(String(code||''))!==String(hit.obj.codeHash)){_updateRow(_sh('LoginCodes'),hit.row,{attempts});throw new Error('Code klopt niet.');}
_updateRow(_sh('LoginCodes'),hit.row,{attempts,usedAt:_now(),status:'USED'}); return true;
}
function _session(user){ const token=Utilities.getUuid()+Utilities.getUuid(); const created=_now(); const expires=new Date(Date.now()+PL.SESSION_HOURS*3600000); const s={sessionId:_id('sess'),userId:user.userId,email:user.email,tokenHash:_hash(token),createdAt:created,expiresAt:expires,lastSeenAt:created,status:'ACTIVE'}; _append('Sessions',s); return {token,email:String(user.email||''),userId:String(user.userId||''),expiresAt:_iso(expires)}; }
function _require(token){ const h=_hash(String(token||'')); const hit=_rows(_sh('Sessions')).find(x=>String(x.obj.tokenHash)===h&&String(x.obj.status)==='ACTIVE'&&new Date(x.obj.expiresAt).getTime()>Date.now()); if(!hit) throw new Error('Sessie verlopen. Log opnieuw in.'); _updateRow(_sh('Sessions'),hit.row,{lastSeenAt:_now()}); const u=_findUser(hit.obj.email); if(!u) throw new Error('Gebruiker niet gevonden.'); return {session:hit,user:u}; }
function plVerifyLoginCode(req){ const email=_normEmail(req&&req.email); _verifyCode(email,req&&req.code); let u=_findUser(email); if(!u){ const obj={userId:_id('usr'),email,name:'',role:'USER',status:'ACTIVE',createdAt:_now(),updatedAt:_now(),lastLoginAt:_now()}; _append('Users',obj); u={obj,row:_sh('Users').getLastRow()}; } else { _updateRow(_sh('Users'),u.row,{lastLoginAt:_now(),status:'ACTIVE'}); u.obj.lastLoginAt=_now(); u.obj.status='ACTIVE'; } const result={ok:true,session:_session(u.obj),profile:_publicProfile(u.obj)}; _log('VERIFY_CODE',email,'OK','Code geverifieerd en sessie aangemaakt','User',u.obj.userId); return _client(result); }
function plSetPassword(req){ const a=_require(req&&req.token); const pw=String(req&&req.password||''); if(pw.length<PL.PASSWORD_MIN) throw new Error('Wachtwoord moet minimaal '+PL.PASSWORD_MIN+' tekens bevatten.'); const salt=_salt(); _updateRow(_sh('Users'),a.user.row,{passwordSalt:salt,passwordHash:_passwordHash(pw,salt),updatedAt:_now()}); _log('SET_PASSWORD',a.user.obj.email,'OK','Wachtwoord ingesteld','User',a.user.obj.userId); return {ok:true,message:'Wachtwoord ingesteld. Je kunt voortaan ook met e-mail + wachtwoord inloggen.'}; }
function plPasswordLogin(req){ const email=_normEmail(req&&req.email),u=_findUser(email); if(!u||!u.obj.passwordHash) throw new Error('Geen account met wachtwoord gevonden.'); if(_passwordHash(String(req&&req.password||''),u.obj.passwordSalt)!==String(u.obj.passwordHash)) throw new Error('E-mail of wachtwoord klopt niet.'); _updateRow(_sh('Users'),u.row,{lastLoginAt:_now()}); return _client({ok:true,session:_session(u.obj),profile:_publicProfile(u.obj)}); }
function plLogout(req){ const a=_require(req&&req.token); _updateRow(_sh('Sessions'),a.session.row,{status:'LOGGED_OUT',logoutAt:_now()}); return {ok:true}; }
function plSaveSettings(req){ const a=_require(req&&req.token),p=req.profile||{},s=req.settings||{}; _updateRow(_sh('Users'),a.user.row,{name:p.name||a.user.obj.name,phone:p.phone||'',siteUrl:p.site||'',ownQuote:p.quote||'',updatedAt:_now()}); _append('Settings',{settingId:_id('set'),userId:a.user.obj.userId,email:a.user.obj.email,fontSize:s.fontSize||18,accent:s.accent||'#0b6cff',updatedAt:_now(),settingsJson:JSON.stringify(s)}); return {ok:true,message:'Settings opgeslagen.'}; }
function _userState(email){ const hit=_latestUserRow('UserState',email); return hit?_safeJson(hit.obj.stateJson,{}):{}; }
function _userSettings(email){ const hit=_latestUserRow('Settings',email); return hit?_safeJson(hit.obj.settingsJson,{}):{}; }
function plSaveUserState(req){ const a=_require(req&&req.token), state=(req&&req.state)||{}; const json=JSON.stringify(state); if(json.length>45000) throw new Error('Persoonlijke state is te groot om veilig op te slaan.'); const hit=_latestUserRow('UserState',a.user.obj.email), now=_now(); if(hit) _updateRow(_sh('UserState'),hit.row,{stateJson:json,updatedAt:now,version:PL.VERSION}); else _append('UserState',{stateId:_id('state'),userId:a.user.obj.userId,email:a.user.obj.email,stateJson:json,createdAt:now,updatedAt:now,version:PL.VERSION}); return {ok:true,savedAt:_iso(now)}; }
function plSyncBackup(req){ const a=_require(req&&req.token),payload=req.payload||{}; const json=JSON.stringify(payload); _append('Imports',{importId:_id('sync'),userId:a.user.obj.userId,email:a.user.obj.email,createdAt:_now(),fileName:'browser-sync.json',fileType:'application/json',mode:'SYNC',rowCount:1,status:'OK',checksum:_hash(json),resultMessage:'Browser backup gesynchroniseerd',payloadJson:json.slice(0,45000)}); _log('SYNC',a.user.obj.email,'OK','Browser backup','Import',''); return {ok:true,message:'Backup met Sheet gesynchroniseerd.'}; }
function plSaveLesson(req){
const a=_require(req&&req.token), l=(req&&req.lesson)||{}, now=_now(), id=_id('les');
const obj={lessonId:id,userId:a.user.obj.userId,email:a.user.obj.email,createdAt:now,updatedAt:now,playerName:l.playerName||'',level:l.level||'',situation:l.situation||'',stroke:l.stroke||'',intention:l.intention||'',nbf:l.nbf||'',smartGoal:l.smartGoal||'',primaryCue:l.primaryCue||'',a1:l.a1||'',a2:l.a2||'',bForm:l.bForm||'',evaluation:l.evaluation||'',easier:l.easier||'',harder:l.harder||'',lessonJson:JSON.stringify(l).slice(0,45000)};
_append('Lessons',obj); _log('SAVE_LESSON',a.user.obj.email,'OK','Les opgeslagen','Lesson',id); return {ok:true,lessonId:id,savedAt:_iso(now)};
}
function plListLessons(req){ const a=_require(req&&req.token), limit=Math.max(1,Math.min(50,Number(req&&req.limit||20))); const rows=_rows(_sh('Lessons')).filter(x=>_normEmail(x.obj.email)===_normEmail(a.user.obj.email)).slice(-limit).reverse().map(x=>x.obj); return _client({ok:true,items:rows}); }
function plSaveObservation(req){
const a=_require(req&&req.token), o=(req&&req.observation)||{}, now=_now(), id=_id('obs');
const obj={observationId:id,userId:a.user.obj.userId,email:a.user.obj.email,createdAt:now,playerName:o.playerName||'',level:o.level||'',situation:o.situation||'',stroke:o.stroke||'',waarnemen:o.waarnemen||'',beslissen:o.beslissen||'',uitvoeren:o.uitvoeren||'',terugkoppelen:o.terugkoppelen||'',gras:o.gras||'',fact:o.fact||'',vvf:o.vvf||'',nbf:o.nbf||'',priority:o.priority||'',intervention:o.intervention||'',nextStep:o.nextStep||'',observationJson:JSON.stringify(o).slice(0,45000)};
_append('Observations',obj); _log('SAVE_OBSERVATION',a.user.obj.email,'OK','Observatie opgeslagen','Observation',id); return {ok:true,observationId:id,savedAt:_iso(now)};
}
function plListObservations(req){ const a=_require(req&&req.token), limit=Math.max(1,Math.min(50,Number(req&&req.limit||20))); const rows=_rows(_sh('Observations')).filter(x=>_normEmail(x.obj.email)===_normEmail(a.user.obj.email)).slice(-limit).reverse().map(x=>x.obj); return _client({ok:true,items:rows}); }
function plSaveProgress(req){
const a=_require(req&&req.token),p=(req&&req.progress)||{},goalId=String(p.goalId||'');
if(!goalId) throw new Error('Leerdoel ontbreekt.');
const rows=_rows(_sh('ExamProgress')).filter(x=>_normEmail(x.obj.email)===_normEmail(a.user.obj.email)&&String(x.obj.goalId)===goalId);
const hit=rows.length?rows[rows.length-1]:null,now=_now();
const data={status:p.status||'TE_LEREN',confidence:Number(p.confidence||0),lastPracticedAt:p.lastPracticedAt||now,evidence:p.evidence||'',reflection:p.reflection||'',nextAction:p.nextAction||'',updatedAt:now,metadataJson:JSON.stringify(p.metadata||{})};
if(hit) _updateRow(_sh('ExamProgress'),hit.row,data);
else _append('ExamProgress',Object.assign({progressId:_id('prog'),userId:a.user.obj.userId,email:a.user.obj.email,goalId},data));
_log('SAVE_PROGRESS',a.user.obj.email,'OK',data.status,'LearningGoal',goalId);
return {ok:true,savedAt:_iso(now)};
}
function plListProgress(req){ const a=_require(req&&req.token); return _client({ok:true,items:_rows(_sh('ExamProgress')).filter(x=>_normEmail(x.obj.email)===_normEmail(a.user.obj.email)).map(x=>x.obj)}); }
function plGetBootstrap(req){
const token=req&&req.token; let profile={},settings={},userState={},authenticated=false,lessons=[],observations=[],progress=[];
if(token){ try{const a=_require(token); profile=_publicProfile(a.user.obj); settings=_userSettings(a.user.obj.email); userState=_userState(a.user.obj.email); authenticated=true; lessons=_rows(_sh('Lessons')).filter(x=>_normEmail(x.obj.email)===_normEmail(a.user.obj.email)).slice(-10).reverse().map(x=>x.obj); observations=_rows(_sh('Observations')).filter(x=>_normEmail(x.obj.email)===_normEmail(a.user.obj.email)).slice(-10).reverse().map(x=>x.obj); progress=_rows(_sh('ExamProgress')).filter(x=>_normEmail(x.obj.email)===_normEmail(a.user.obj.email)).map(x=>x.obj);}catch(e){} }
return _client({ok:true,authenticated,version:PL.VERSION,config:_rows(_sh('Config')).map(x=>x.obj),assignments:_rows(_sh('Assignments')).map(x=>x.obj),checklist:_rows(_sh('Checklist')).map(x=>x.obj),quiz:_rows(_sh('Quiz')).map(x=>x.obj),goals:_rows(_sh('LearningGoals')).map(x=>x.obj),cards:_rows(_sh('ExerciseCards')).map(x=>x.obj),progress,profile,settings,userState,lessons,observations});
}
function plHealthCheck(req){
const out={ok:true,version:PL.VERSION,app:PL.APP,time:_iso(_now()),sheetId:PL.SHEET_ID,tabs:{}};
['Config','Users','LoginCodes','Sessions','Settings','Assignments','Checklist','Notes','Lessons','Observations','Imports','Exports','AuditLog','Quiz','UserState','LearningGoals','ExerciseCards','ExamProgress'].forEach(n=>{try{const s=_sh(n);out.tabs[n]={ok:true,rows:s.getLastRow(),cols:s.getLastColumn()};}catch(e){out.tabs[n]={ok:false,error:e.message};out.ok=false;}});
if(req&&req.token){try{const a=_require(req.token);out.session={ok:true,email:a.user.obj.email,expiresAt:_iso(a.session.obj.expiresAt)};}catch(e){out.session={ok:false,error:e.message};out.ok=false;}}
return _client(out);
}
function plPing(){ return {ok:true,app:PL.APP,version:PL.VERSION,sheetId:PL.SHEET_ID,time:new Date().toISOString()}; }
Index
<!doctype html>
<html lang="nl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>PadelLeraar · PadelStudie OS</title>
<style>
:root{--bg:#f5f7fb;--card:#fff;--ink:#14213d;--muted:#667085;--line:#dfe5ee;--blue:#0b6cff;--green:#0c9b68;--orange:#f59e0b;--red:#d92d20;--shadow:0 8px 24px rgba(16,24,40,.08)}*{box-sizing:border-box}body{margin:0;font-family:Inter,system-ui,Arial,sans-serif;background:var(--bg);color:var(--ink)}button,input,textarea,select{font:inherit}.app{max-width:1280px;margin:auto;min-height:100vh}.top{position:sticky;top:0;z-index:30;background:#fffffff2;backdrop-filter:blur(10px);border-bottom:1px solid var(--line);padding:12px 16px}.brand{display:flex;align-items:center;gap:10px}.logo{width:44px;height:44px;border-radius:14px;background:linear-gradient(135deg,#00bcd4,var(--blue));color:#fff;display:grid;place-items:center;font-size:23px}.brand h1{font-size:19px;margin:0}.small{font-size:12px}.muted{color:var(--muted)}.status{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px}.chip,.pill{border:1px solid var(--line);background:#fff;border-radius:999px;padding:7px 10px;font-size:12px}.pill{cursor:pointer}.good{background:#ecfdf3;color:#067647}.warn{background:#fffaeb;color:#b54708}.bad{background:#fef3f2;color:#b42318}.nav{display:flex;gap:6px;overflow:auto;padding:10px 14px 0}.nav button{white-space:nowrap;border:0;background:#e9eef6;border-radius:999px;padding:9px 11px;cursor:pointer}.nav button.active{background:var(--blue);color:#fff}.main{padding:14px}.view{display:none}.view.active{display:block}.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:15px;box-shadow:var(--shadow)}.s12{grid-column:span 12}.s8{grid-column:span 8}.s6{grid-column:span 6}.s4{grid-column:span 4}.s3{grid-column:span 3}.title{font-size:16px;font-weight:800;margin:0 0 8px}.kpi{font-size:30px;font-weight:900}.flow{display:flex;flex-wrap:wrap;gap:7px;align-items:center}.step{background:#eef4ff;border:1px solid #cfe0ff;padding:8px 10px;border-radius:11px;font-weight:700}.field{margin-bottom:9px}.field label{display:block;font-size:12px;font-weight:700;color:#475467;margin-bottom:4px}.field input,.field select,.field textarea{width:100%;border:1px solid var(--line);border-radius:10px;padding:9px;background:#fff}.field textarea{min-height:80px;resize:vertical}.two{display:grid;grid-template-columns:1fr 1fr;gap:10px}.three{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.btn{border:0;background:var(--blue);color:#fff;border-radius:11px;padding:9px 12px;font-weight:700;cursor:pointer}.btn.green{background:var(--green)}.btn.ghost{background:#fff;color:var(--ink);border:1px solid var(--line)}.btn.orange{background:var(--orange)}.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0}.output{white-space:pre-wrap;background:#101828;color:#f2f4f7;padding:13px;border-radius:13px;min-height:120px}.assignment{margin-bottom:10px}.assignment summary{cursor:pointer;font-weight:800;padding:9px 0}.checkrow{display:flex;gap:9px;align-items:flex-start;padding:5px 0}.badge{display:inline-block;padding:4px 7px;border-radius:999px;background:#eef4ff;color:#175cd3;font-size:12px;font-weight:700}.progress{height:10px;background:#e9eef6;border-radius:99px;overflow:hidden}.progress>span{display:block;height:100%;background:var(--blue)}.modal{position:fixed;inset:0;background:#10182888;display:none;place-items:center;z-index:100;padding:18px}.modal.show{display:grid}.modalbox{background:#fff;width:min(650px,100%);max-height:92vh;overflow:auto;border-radius:20px;padding:18px}.tabs2{display:flex;gap:6px;flex-wrap:wrap;margin:12px 0}.tabs2 button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:8px 10px}.tabs2 button.active{background:var(--ink);color:#fff}.loginpane{display:none}.loginpane.active{display:block}.footer{text-align:center;padding:24px;color:var(--muted);font-size:12px}.traffic button{width:31px;height:31px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px var(--line);margin-right:4px}.r{background:#f97066}.o{background:#fdb022}.g{background:#32d583}.selected{outline:3px solid #344054}.listitem{padding:9px 0;border-bottom:1px solid #eef2f6}.testline{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid #eef2f6}.quick{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.quick button{border:1px solid var(--line);background:#fff;border-radius:12px;padding:10px;text-align:left;cursor:pointer}.quick b{display:block}
@media(max-width:850px){.s8,.s6,.s4,.s3{grid-column:span 12}.two,.three,.quick{grid-template-columns:1fr}.top{padding-top:max(12px,env(safe-area-inset-top))}}
</style></head><body><div class="app">
<header class="top"><div class="brand"><div class="logo">🎾</div><div><h1>PadelLeraar · PadelStudie OS</h1><div class="small muted">van waarnemen naar lesgeven · opleiding, coaching en examen</div></div></div><div class="status"><a class="chip" href="https://sites.google.com/view/padelstudie/reminder" target="_blank">PadelStudie Reminder ↗</a><span id="syncStatus" class="chip">Lokaal</span><span id="userStatus" class="chip">Gast</span><button class="pill" id="loginOpen">Login</button><button class="pill" id="settingsOpen">Settings</button><button class="pill" id="exportOpen">Backup</button></div></header>
<nav class="nav" id="nav"></nav><main class="main">
<section class="view active" data-view="dashboard"><div class="grid"><div class="card s8"><div class="title">Coach Engine</div><div class="flow"><span class="step">Observeer</span>→<span class="step">Diagnose</span>→<span class="step">Socratic</span>→<span class="step">SMART</span>→<span class="step">A1/A2/B</span>→<span class="step">Measure</span>→<span class="step">NEXT</span></div><p class="small muted">De rode draad koppelt KNLTB-opleiding, speleranalyse, lesvoorbereiding en reflectie aan elkaar.</p></div><div class="card s4"><div class="title">Opleidingsvoortgang</div><div class="kpi" id="kpiProgress">0%</div><div class="progress"><span id="progressBar" style="width:0%"></span></div></div>
<div class="card s3"><div class="title">Checklist</div><div class="kpi" id="kpiChecks">0/0</div></div><div class="card s3"><div class="title">Lessen</div><div class="kpi" id="kpiLessons">0</div></div><div class="card s3"><div class="title">Observaties</div><div class="kpi" id="kpiObs">0</div></div><div class="card s3"><div class="title">NEXT</div><div class="kpi" id="kpiNext">1</div></div>
<div class="card s8"><div class="title">Snelle tools</div><div class="quick"><button data-go="lesson"><b>Lesvoorbereiding</b><span class="small muted">SMART + A1/A2/B</span></button><button data-go="player"><b>Speleranalyse</b><span class="small muted">beginsituatie</span></button><button data-go="observation"><b>Observatie</b><span class="small muted">padelhandeling</span></button><button data-go="exam"><b>Examentrainer</b><span class="small muted">focus & verkeerlicht</span></button></div></div><div class="card s4"><div class="title">Systeemtest</div><div id="testResults" class="small muted">Nog niet getest.</div><div class="toolbar"><button class="btn" id="runTests">Test backend + sessie + Sheet</button></div></div></div></section>
<section class="view" data-view="opleiding"><div class="grid"><div class="card s12"><div class="title">Mijn opleiding · checklist + reflectie</div><div id="assignmentsList"></div></div></div></section>
<section class="view" data-view="lesson"><div class="grid"><div class="card s8"><div class="title">Lesvoorbereiding</div><div class="two"><div class="field"><label>Speler / groep</label><input id="lPlayer"></div><div class="field"><label>Niveau</label><input id="lLevel" value="9–6"></div><div class="field"><label>Hoofdspelsituatie</label><select id="lSituation"><option>Service</option><option>Return</option><option>Achterspel</option><option>Transitie</option><option>Netspel</option></select></div><div class="field"><label>Slag / thema</label><input id="lStroke" value="Forehand lob"></div><div class="field"><label>Bedoeling</label><select id="lIntent"><option>Scoren</option><option>Uitlokken</option><option selected>Opbouwen</option><option>Neutraliseren</option><option>Voorkomen</option></select></div><div class="field"><label>NBF lens</label><input id="lNbf" placeholder="timing, ruimte, keuze, druk..."></div></div><div class="field"><label>SMART leerdoel</label><input id="lSmart" value="7 van 10 ballen functioneel uitvoeren"></div><div class="field"><label>Primaire coachaanwijzing</label><input id="lCue" value="Raakpunt vóór en ruimte maken"></div><div class="three"><div class="field"><label>A1 · techniek</label><textarea id="lA1"></textarea></div><div class="field"><label>A2 · waarnemen + kiezen</label><textarea id="lA2"></textarea></div><div class="field"><label>B · spelrealistisch</label><textarea id="lB"></textarea></div></div><div class="two"><div class="field"><label>Stap terug</label><input id="lEasy" value="rustiger AKB, groter doel"></div><div class="field"><label>Stap verder</label><input id="lHard" value="meer variatie, kleiner doel, druk"></div></div><div class="field"><label>Evaluatievraag</label><input id="lEval" value="Wat zag je, wat koos je en wat doe je de volgende keer?"></div><div class="toolbar"><button class="btn" id="buildLesson">Bouw les</button><button class="btn green" id="saveLesson">Opslaan in Sheet</button></div></div><div class="card s4"><div class="title">Lesreminder</div><div class="output" id="lessonOutput">Vul je les in en klik op Bouw les.</div></div><div class="card s12"><div class="title">Recente lessen</div><div id="recentLessons" class="small muted">Login om lessen uit de Sheet te laden.</div></div></div></section>
<section class="view" data-view="player"><div class="grid"><div class="card s12"><div class="title">Speleranalyse · beginsituatie</div><div class="two"><div class="field"><label>Speler</label><input id="pName"></div><div class="field"><label>Niveau</label><input id="pLevel"></div><div class="field"><label>Motief / verwachting</label><textarea id="pMotive"></textarea></div><div class="field"><label>Techniek</label><textarea id="pTech"></textarea></div><div class="field"><label>Tactiek</label><textarea id="pTactic"></textarea></div><div class="field"><label>Fysiek</label><textarea id="pPhysical"></textarea></div><div class="field"><label>Mentaal / gedrag</label><textarea id="pMental"></textarea></div><div class="field"><label>Prioriteit</label><textarea id="pPriority"></textarea></div></div><div class="toolbar"><button class="btn" id="savePlayerState">Bewaar in persoonlijke state</button></div></div></div></section>
<section class="view" data-view="observation"><div class="grid"><div class="card s8"><div class="title">Observatie · Padelhandeling</div><div class="two"><div class="field"><label>Speler</label><input id="oPlayer"></div><div class="field"><label>Niveau</label><input id="oLevel"></div><div class="field"><label>Spelsituatie</label><select id="oSituation"><option>Achterspel</option><option>Netspel</option><option>Service</option><option>Return</option><option>Transitie</option></select></div><div class="field"><label>Slag</label><input id="oStroke"></div></div><div class="field"><label>👀 Waarnemen</label><textarea id="oW"></textarea></div><div class="field"><label>🧠 Beslissen</label><textarea id="oD"></textarea></div><div class="field"><label>🎾 Uitvoeren</label><textarea id="oU"></textarea></div><div class="field"><label>🔁 Terugkoppelen</label><textarea id="oT"></textarea></div><div class="three"><div class="field"><label>GRAS</label><textarea id="oGras"></textarea></div><div class="field"><label>FACT</label><textarea id="oFact"></textarea></div><div class="field"><label>VVF</label><textarea id="oVvf"></textarea></div></div><div class="field"><label>NBF / denklens</label><textarea id="oNbf"></textarea></div><div class="field"><label>Prioriteit</label><input id="oPriority"></div><div class="field"><label>Coachinginterventie</label><input id="oIntervention"></div><div class="field"><label>NEXT</label><input id="oNext"></div><div class="toolbar"><button class="btn green" id="saveObservation">Observatie opslaan</button></div></div><div class="card s4"><div class="title">Observatievragen</div><ul><li>Wat ziet de speler vóór de bal komt?</li><li>Welke bedoeling kiest de speler?</li><li>Is techniek beperkend voor tactiek?</li><li>Timing, balans, bladcontrole?</li><li>Wat is het rendement?</li><li>Wat is één beste volgende interventie?</li></ul></div><div class="card s12"><div class="title">Recente observaties</div><div id="recentObs" class="small muted">Login om observaties te laden.</div></div></div></section>
<section class="view" data-view="handeling"><div class="grid"><div class="card s12"><div class="title">Padelhandeling</div><div class="flow"><span class="step">Waarnemen</span>→<span class="step">Beslissen</span>→<span class="step">Uitvoeren</span>→<span class="step">Terugkoppelen</span></div></div><div class="card s3"><b>Waarnemen</b><p>tegenstander · AKB · eigen positie · partner · ruimte</p></div><div class="card s3"><b>Beslissen</b><p>bedoeling · score · techniek · omstandigheden · emotie</p></div><div class="card s3"><b>Uitvoeren</b><p>bewegen · timing · balans · bladcontrole · GRAS</p></div><div class="card s3"><b>Terugkoppelen</b><p>resultaat · rendement · analyse · gevoel · nieuwe keuze</p></div></div></section>
<section class="view" data-view="gras"><div class="grid"><div class="card s4"><div class="title">GRAS</div><ul><li>Grip</li><li>Raakpunt</li><li>Armactie</li><li>Stand / lichaam</li></ul></div><div class="card s4"><div class="title">FACT</div><ul><li>Footwork / voorbereiding</li><li>Afstand / armactie</li><li>Contact / controle</li><li>Timing / transfer</li></ul><p class="small muted">Gebruik jouw eigen FACT-definitie in observaties en coaching.</p></div><div class="card s4"><div class="title">VVF</div><ul><li>Vastheid</li><li>Variatie</li><li>Functie / effect</li></ul><p class="small muted">Diagnose: wat lukt, onder welke druk, met welk spelresultaat?</p></div></div></section>
<section class="view" data-view="nbf"><div class="grid"><div class="card s6"><div class="title">NBF · denklens</div><p>Gebruik NBF als lens om niet alleen de slag te zien, maar vooral tijd, ruimte, balbaan, keuze en vervolgactie.</p><div class="field"><label>Mijn NBF-observatie</label><textarea id="nbfNote"></textarea></div></div><div class="card s6"><div class="title">8 timingfactoren</div><ol><li>oog-hand / oog-voet</li><li>snelheid AKB</li><li>richting AKB</li><li>snelheid lichaam</li><li>plaats balbehandeling VTB</li><li>zwaaisnelheid racket</li><li>speelrichting VTB</li><li>rotatie AKB / VTB</li></ol></div></div></section>
<section class="view" data-view="smart"><div class="grid"><div class="card s8"><div class="title">SMART-generator</div><div class="field"><label>Wat moet de speler doen?</label><input id="smAction"></div><div class="two"><div class="field"><label>Meetbaar</label><input id="smMeasure" placeholder="7 van 10"></div><div class="field"><label>Tijd</label><input id="smTime" placeholder="binnen 10 minuten / einde les"></div></div><div class="field"><label>Context / spelsituatie</label><input id="smContext"></div><div class="toolbar"><button class="btn" id="buildSmart">Maak SMART-doel</button></div></div><div class="card s4"><div class="title">Resultaat</div><div class="output" id="smartOutput">Nog geen doel gemaakt.</div></div></div></section>
<section class="view" data-view="a1a2b"><div class="grid"><div class="card s4"><div class="title">A1 · technisch</div><p>Veel herhalen, voorspelbaar, 1 technisch accent, succes opbouwen.</p></div><div class="card s4"><div class="title">A2 · waarnemen + kiezen</div><p>Herkenbare AKB, keuze toevoegen, nog steeds beheersbaar.</p></div><div class="card s4"><div class="title">B · spelrealistisch</div><p>Transfer naar rally / 2 tegen 2 met bedoeling en positionering.</p></div><div class="card s12"><div class="title">Transfercheck</div><label class="checkrow"><input type="checkbox" data-state="transfer_1"> Techniek uit A1 komt terug in A2</label><label class="checkrow"><input type="checkbox" data-state="transfer_2"> Speler moet in A2 echt waarnemen en kiezen</label><label class="checkrow"><input type="checkbox" data-state="transfer_3"> B-vorm bevat dezelfde spelsituatie en bedoeling</label><label class="checkrow"><input type="checkbox" data-state="transfer_4"> Evaluatie meet het SMART-doel</label></div></div></section>
<section class="view" data-view="aangeven"><div class="grid"><div class="card s8"><div class="title">Aangeefvaardigheid · 10 ballen</div><p class="small muted">Vink elke passende aanspeelbal aan. Richtwaarde: minimaal 70% passend.</p><div id="feedChecks" class="flow"></div><div class="toolbar"><button class="btn ghost" id="resetFeed">Reset</button></div></div><div class="card s4"><div class="title">Score</div><div class="kpi" id="feedScore">0%</div><div id="feedAdvice" class="small muted">Begin met meten.</div></div></div></section>
<section class="view" data-view="exam"><div class="grid"><div class="card s8"><div class="title">Examentrainer</div><div id="examList"></div></div><div class="card s4"><div class="title">40 minuten</div><ul><li>5 min warming-up</li><li>15 min Kern A</li><li>15 min Kern B incl. evaluatie</li><li>5 min afronding / wissel</li></ul><div class="field"><label>Mijn examennext</label><textarea id="examNext"></textarea></div></div></div></section>
<section class="view" data-view="quiz"><div class="grid"><div class="card s8"><div class="title">Quiz / flashcards</div><div style="font-size:22px;font-weight:800" id="quizQ"></div><div class="output" id="quizA" style="display:none;margin-top:10px"></div><div class="toolbar"><button class="btn ghost" id="showQuiz">Antwoord</button><button class="btn" id="nextQuiz">Volgende</button></div></div><div class="card s4"><div class="title">Herhalen</div><p>Gebruik quizvragen om begrippen te herkennen, maar koppel ze steeds aan een echte spelsituatie.</p></div></div></section>
<section class="view" data-view="progress"><div class="grid"><div class="card s6"><div class="title">Voortgang opleiding</div><div class="kpi" id="progressPct">0%</div><div class="progress"><span id="progressPctBar" style="width:0%"></span></div><div id="progressDetail" class="small muted"></div></div><div class="card s6"><div class="title">Sterk / ontwikkelen</div><div id="progressFocus"></div></div></div></section>
<section class="view" data-view="next"><div class="grid"><div class="card s8"><div class="title">NEXT · één beste volgende stap</div><div class="output" id="nextOutput">Vul opdrachten, observaties en examenstatus in. NEXT kiest daarna je volgende focus.</div><div class="toolbar"><button class="btn" id="makeNext">Bereken NEXT</button></div></div><div class="card s4"><div class="title">Eigen focus</div><div class="field"><label>Wat wil ik volgende les bewust doen?</label><textarea id="profileNote"></textarea></div></div></div></section>
</main>
<div class="footer">PadelLeraar · PadelStudie OS v3.0 · <a href="https://sites.google.com/view/padelstudie" target="_blank">PadelStudie</a> · Sheet + Apps Script + persoonlijke login</div>
<div class="modal" id="loginModal"><div class="modalbox"><div style="display:flex;justify-content:space-between"><div><div class="title">Login & account</div><div class="small muted">e-mailcode → sessie → wachtwoord</div></div><button class="pill" data-close="loginModal">Sluit</button></div><div class="tabs2"><button class="active" data-login-tab="code">Code via mail</button><button data-login-tab="password">Wachtwoord</button><button data-login-tab="account">Wachtwoord instellen</button></div><div class="loginpane active" data-login-pane="code"><div class="field"><label>E-mail</label><input id="authEmail" type="email"></div><button class="btn" id="requestCode">Stuur code</button><div class="field" style="margin-top:10px"><label>6-cijferige code</label><input id="authCode" inputmode="numeric" maxlength="6"></div><button class="btn green" id="verifyCode">Controleer & login</button></div><div class="loginpane" data-login-pane="password"><div class="field"><label>E-mail</label><input id="pwEmail" type="email"></div><div class="field"><label>Wachtwoord</label><input id="pwPassword" type="password"></div><button class="btn" id="passwordLogin">Login</button></div><div class="loginpane" data-login-pane="account"><div class="field"><label>Naam</label><input id="newName"></div><div class="field"><label>Nieuw wachtwoord</label><input id="newPassword" type="password"></div><button class="btn" id="setPassword">Wachtwoord instellen</button></div><p id="authMessage" class="small muted"></p><button class="btn ghost" id="logoutBtn">Uitloggen</button></div></div>
<div class="modal" id="settingsModal"><div class="modalbox"><div style="display:flex;justify-content:space-between"><div class="title">Settings</div><button class="pill" data-close="settingsModal">Sluit</button></div><div class="two"><div class="field"><label>Naam</label><input id="setName"></div><div class="field"><label>Telefoon</label><input id="setPhone"></div><div class="field"><label>Eigen site</label><input id="setSite"></div><div class="field"><label>Eigen quote</label><input id="setQuote"></div><div class="field"><label>Lettergrootte</label><input id="setFont" type="number" min="14" max="26" value="18"></div><div class="field"><label>Accent</label><select id="setAccent"><option value="#0b6cff">Blauw</option><option value="#0c9b68">Groen</option><option value="#7f56d9">Paars</option><option value="#f79009">Oranje</option></select></div></div><button class="btn" id="saveSettings">Opslaan</button></div></div>
<div class="modal" id="exportModal"><div class="modalbox"><div style="display:flex;justify-content:space-between"><div class="title">Backup & export</div><button class="pill" data-close="exportModal">Sluit</button></div><div class="toolbar"><button class="btn" id="exportJson">JSON</button><button class="btn ghost" id="exportTxt">TXT</button><button class="btn ghost" id="exportCsv">CSV</button><button class="btn green" id="exportWhatsapp">WhatsApp</button><button class="btn orange" id="syncNow">Sync Sheet</button></div><div class="field"><label>Import JSON</label><input id="importFile" type="file" accept=".json,application/json"></div><button class="btn" id="importJson">Importeer</button><p id="ioMessage" class="small muted"></p></div></div>
</div>
<script>
const V='3.0.0', KEY='padelleraar_os_v3';
const tabs=[['dashboard','Dashboard'],['opleiding','Mijn opleiding'],['lesson','Lesvoorbereiding'],['player','Speleranalyse'],['observation','Observatie'],['handeling','Padelhandeling'],['gras','GRAS/FACT/VVF'],['nbf','NBF'],['smart','SMART'],['a1a2b','A1/A2/B'],['aangeven','Aangeefvaardigheid'],['exam','Examentrainer'],['quiz','Quiz'],['progress','Voortgang'],['next','NEXT']];
const nav=document.getElementById('nav');tabs.forEach(([id,l],i)=>{const b=document.createElement('button');b.textContent=l;b.dataset.target=id;if(!i)b.classList.add('active');b.onclick=()=>show(id);nav.appendChild(b)});function show(id){document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.dataset.view===id));document.querySelectorAll('.nav button').forEach(b=>b.classList.toggle('active',b.dataset.target===id));window.scrollTo({top:0,behavior:'smooth'});updateDashboard()};document.querySelectorAll('[data-go]').forEach(b=>b.onclick=()=>show(b.dataset.go));
const defaults={profile:{name:'',email:'',phone:'',site:'https://sites.google.com/view/padelstudie',quote:''},settings:{fontSize:18,accent:'#0b6cff'},session:null,lastSync:null,remote:{lessons:[],observations:[]}};let APP=load();function load(){try{return Object.assign({},defaults,JSON.parse(localStorage.getItem(KEY)||'{}'))}catch(e){return structuredClone(defaults)}}function save(){localStorage.setItem(KEY,JSON.stringify(APP));applySettings();updateUserUI()}
function hasGas(){return typeof google!=='undefined'&&google.script&&google.script.run}function gas(fn,args){return new Promise((res,rej)=>{if(!hasGas())return rej(new Error('Apps Script backend niet actief'));google.script.run.withSuccessHandler(res).withFailureHandler(e=>rej(new Error(e&&e.message?e.message:String(e))))[fn](args)})}
function stateGet(k,d=''){const v=localStorage.getItem('pl3_'+k);return v===null?d:v}function stateSet(k,v){localStorage.setItem('pl3_'+k,String(v));scheduleStateSave()}function collectState(){const o={};for(let i=0;i<localStorage.length;i++){const k=localStorage.key(i);if(k&&k.startsWith('pl3_'))o[k.slice(4)]=localStorage.getItem(k)}return o}function hydrateState(o){Object.entries(o||{}).forEach(([k,v])=>localStorage.setItem('pl3_'+k,String(v)))}
function applySettings(){document.documentElement.style.fontSize=(APP.settings?.fontSize||18)+'px';document.documentElement.style.setProperty('--blue',APP.settings?.accent||'#0b6cff')}function updateUserUI(){userStatus.textContent=APP.session?.email||APP.profile?.name||'Gast';syncStatus.textContent=hasGas()?(APP.lastSync?'Gesynchroniseerd':'Backend actief'):'Lokaal';syncStatus.className='chip '+(hasGas()?'good':'')}
function openModal(id){document.getElementById(id).classList.add('show')}document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>document.getElementById(b.dataset.close).classList.remove('show'));loginOpen.onclick=()=>openModal('loginModal');settingsOpen.onclick=()=>{fillSettings();openModal('settingsModal')};exportOpen.onclick=()=>openModal('exportModal');document.querySelectorAll('[data-login-tab]').forEach(b=>b.onclick=()=>{document.querySelectorAll('[data-login-tab]').forEach(x=>x.classList.toggle('active',x===b));document.querySelectorAll('[data-login-pane]').forEach(x=>x.classList.toggle('active',x.dataset.loginPane===b.dataset.loginTab))});function auth(t){authMessage.textContent=t}
requestCode.onclick=async()=>{try{const r=await gas('plRequestLoginCode',{email:authEmail.value.trim()});auth(r.message)}catch(e){auth(e.message)}};verifyCode.onclick=async()=>{try{const r=await gas('plVerifyLoginCode',{email:authEmail.value.trim(),code:authCode.value.trim()});if(!r?.session)throw new Error('Geen sessie ontvangen.');APP.session=r.session;APP.profile=Object.assign(APP.profile,r.profile||{});save();await bootstrap(true);auth('✅ Ingelogd en persoonlijke gegevens geladen.')}catch(e){auth(e.message)}};passwordLogin.onclick=async()=>{try{const r=await gas('plPasswordLogin',{email:pwEmail.value.trim(),password:pwPassword.value});if(!r?.session)throw new Error('Geen sessie ontvangen.');APP.session=r.session;APP.profile=Object.assign(APP.profile,r.profile||{});save();await bootstrap(true);auth('✅ Ingelogd via wachtwoord.')}catch(e){auth(e.message)}};setPassword.onclick=async()=>{try{if(!APP.session?.token)throw new Error('Log eerst in.');const r=await gas('plSetPassword',{token:APP.session.token,password:newPassword.value});if(newName.value.trim()){APP.profile.name=newName.value.trim();save();await gas('plSaveSettings',{token:APP.session.token,profile:APP.profile,settings:APP.settings})}auth(r.message)}catch(e){auth(e.message)}};logoutBtn.onclick=async()=>{try{if(APP.session)await gas('plLogout',{token:APP.session.token})}catch(e){}APP.session=null;save();auth('Uitgelogd.')};
function fillSettings(){setName.value=APP.profile?.name||'';setPhone.value=APP.profile?.phone||'';setSite.value=APP.profile?.site||'';setQuote.value=APP.profile?.quote||'';setFont.value=APP.settings?.fontSize||18;setAccent.value=APP.settings?.accent||'#0b6cff'}saveSettings.onclick=async()=>{APP.profile=Object.assign(APP.profile,{name:setName.value.trim(),phone:setPhone.value.trim(),site:setSite.value.trim(),quote:setQuote.value.trim()});APP.settings=Object.assign(APP.settings,{fontSize:Number(setFont.value)||18,accent:setAccent.value});save();try{if(APP.session)await gas('plSaveSettings',{token:APP.session.token,profile:APP.profile,settings:APP.settings})}catch(e){}settingsModal.classList.remove('show')};
const assignments=[['01 Achterspel',['Padelhandeling beschrijven','Uitgangspositie en herstel observeren','Tactische keuzes koppelen aan AKB','Verslag spelconcept + afsluiting']],['02 Netspel',['Positionering tweede paal','Reiken/blokken foutloos','Plaatsing kort/lang links/rechts','Overzicht, controle en herstel']],['03 Transities',['Service → net','Netpositie behouden bij lob','Na goede lob naar voren','Na chiquita naar voren','2v2 teamposities']],['04 Voorbeeldvaardigheid',['Slagen demonstreren','GRAS benoemen','Eigen uitvoering reflecteren','Praatje → plaatje → daadje']],['05 SMART-doelen',['2 tactische + 2 technische doelen','SMART volledig','Passend bij niveau','Makkelijker/moeilijker']],['06 Motieven & beginsituatie',['Gesprek speler','Techniek/tactiek/fysiek/mentaal','Slagen testen','Beginsituatie vastleggen']],['07 Methodegroepen',['Eenvoudig → complex','Methodegroep per slag','A1/A2 naar B','Differentiëren']],['08 Biomechanica',['Keten benen-romp-arm-racket','Richting/spin/vaart','Video achter/zij','Schakels analyseren']],['09 Timing',['8 timingfactoren','Breekpunt','Vastheid/precisie/variatie/vaart','Hulpmiddel doelgericht']],['10 Hulpmiddelen',['Pionnen/doelen/zones','Touw/bankje/dwingend','GRAS + output','Veilig organiseren']],['11 Analyseren/corrigeren',['Spelrealistisch observeren','1 hoofdprobleem','Korte aanwijzing','Vorm aanpassen']],['12 Differentiëren',['Tempo/tijd/ruimte/voorspelbaarheid/druk','<40% stap terug','40–80% door','>80% verder']],['13 Meerwekenplan/LVF',['16 weken groep','8 weken privé','SMART + motieven','Organisatie/lesmanagement']],['14 Aangeefvaardigheid',['≥70% passend','Vaart/rotatie','Richting/frequentie','Wanden/2e stuit']],['15 TOF Padel',['Matrix + level','Praatje/plaatje/daadje','1 oefening + variatie','Evalueren/opruimen']],['16 Beroepshouding',['Professioneel','Bemoedigend/luisteren','Proactief/op tijd','Fair Play']],['Materiaal',['Rackets vergelijken','Advies per niveau','Ballen ervaren','Blessure/speltype']],['Fysiek',['Kracht','Snelheid','Uithoudingsvermogen','Lenigheid','Coördinatie']],['Proefexamen',['5 min warming-up','15 min A','15 min B + evaluatie','5 min wissel']]];
const al=document.getElementById('assignmentsList');assignments.forEach((a,ai)=>{let rows=a[1].map((x,i)=>`<label class="checkrow"><input type="checkbox" data-ass="${ai}_${i}"> ${x}</label>`).join('');al.insertAdjacentHTML('beforeend',`<details class="assignment"><summary>${a[0]} <span class="badge">${a[1].length}</span></summary>${rows}<div class="field"><label>Reflectie / bewijs / NEXT</label><textarea data-note="${ai}"></textarea></div></details>`)});document.querySelectorAll('[data-ass]').forEach(x=>{x.checked=stateGet('ass_'+x.dataset.ass)==='1';x.onchange=()=>{stateSet('ass_'+x.dataset.ass,x.checked?'1':'0');updateDashboard()}});document.querySelectorAll('[data-note]').forEach(x=>{x.value=stateGet('note_'+x.dataset.note);x.oninput=()=>stateSet('note_'+x.dataset.note,x.value)});
const examItems=['Veiligheid fysiek & sociaal','Lesonderdelen logisch afstemmen','Kort en bondig instrueren','Aanspelen in dienst van doel','Optimale leraarpositie','(Bio)mechanisch analyseren','Tactische waarde beoordelen','Feedback vanuit analyse','Positionering','AKB/VTB beoordelen','Techniek ↔ tactische keuze','Spelers actief houden'];examItems.forEach((x,i)=>examList.insertAdjacentHTML('beforeend',`<div class="testline"><span>${x}</span><span class="traffic" data-ex="${i}"><button class="r" data-v="r"></button><button class="o" data-v="o"></button><button class="g" data-v="g"></button></span></div>`));document.querySelectorAll('[data-ex] button').forEach(b=>{const p=b.parentElement,v=stateGet('exam_'+p.dataset.ex);if(b.dataset.v===v)b.classList.add('selected');b.onclick=()=>{p.querySelectorAll('button').forEach(z=>z.classList.remove('selected'));b.classList.add('selected');stateSet('exam_'+p.dataset.ex,b.dataset.v);updateDashboard()}});examNext.value=stateGet('examNext');examNext.oninput=()=>stateSet('examNext',examNext.value);
['pName','pLevel','pMotive','pTech','pTactic','pPhysical','pMental','pPriority','nbfNote'].forEach(id=>{const e=document.getElementById(id);e.value=stateGet(id);e.oninput=()=>stateSet(id,e.value)});savePlayerState.onclick=()=>{['pName','pLevel','pMotive','pTech','pTactic','pPhysical','pMental','pPriority'].forEach(id=>stateSet(id,document.getElementById(id).value));alert('Speleranalyse lokaal opgeslagen en wordt bij login gesynchroniseerd.')};
function lessonObj(){return{playerName:lPlayer.value,level:lLevel.value,situation:lSituation.value,stroke:lStroke.value,intention:lIntent.value,nbf:lNbf.value,smartGoal:lSmart.value,primaryCue:lCue.value,a1:lA1.value,a2:lA2.value,bForm:lB.value,easier:lEasy.value,harder:lHard.value,evaluation:lEval.value}}buildLesson.onclick=()=>{const l=lessonObj();lessonOutput.textContent=`${l.situation} · ${l.stroke}\nBedoeling: ${l.intention}\nSMART: ${l.smartGoal}\nNBF: ${l.nbf||'-'}\n\nA1 TECHNIEK\n${l.a1||'Veel herhalen, 1 technisch accent: '+l.primaryCue}\n\nA2 WAARNEMEN + KIEZEN\n${l.a2||'Voeg herkenbare AKB en keuze toe.'}\n\nB SPELREALISTISCH\n${l.bForm||'2 tegen 2/rallyvorm met dezelfde bedoeling.'}\n\nStap terug: ${l.easier}\nStap verder: ${l.harder}\nEvaluatie: ${l.evaluation}`};saveLesson.onclick=async()=>{try{if(!APP.session?.token)throw new Error('Log eerst in.');const r=await gas('plSaveLesson',{token:APP.session.token,lesson:lessonObj()});lessonOutput.textContent+='\n\n✅ Opgeslagen: '+r.lessonId;await loadLists()}catch(e){lessonOutput.textContent='Fout: '+e.message}};
function obsObj(){return{playerName:oPlayer.value,level:oLevel.value,situation:oSituation.value,stroke:oStroke.value,waarnemen:oW.value,beslissen:oD.value,uitvoeren:oU.value,terugkoppelen:oT.value,gras:oGras.value,fact:oFact.value,vvf:oVvf.value,nbf:oNbf.value,priority:oPriority.value,intervention:oIntervention.value,nextStep:oNext.value}}saveObservation.onclick=async()=>{try{if(!APP.session?.token)throw new Error('Log eerst in.');const r=await gas('plSaveObservation',{token:APP.session.token,observation:obsObj()});oNext.value='';await loadLists();alert('Observatie opgeslagen: '+r.observationId)}catch(e){alert(e.message)}};
buildSmart.onclick=()=>{smartOutput.textContent=`${smAction.value||'De speler voert de taak uit'} ${smMeasure.value?'met '+smMeasure.value:''} in ${smContext.value||'de gekozen spelsituatie'} ${smTime.value?'binnen '+smTime.value:''}.`.replace(/\s+/g,' ')};
for(let i=0;i<10;i++){feedChecks.insertAdjacentHTML('beforeend',`<label class="chip"><input type="checkbox" data-feed="${i}"> ${i+1}</label>`)}document.querySelectorAll('[data-feed]').forEach(x=>{x.checked=stateGet('feed_'+x.dataset.feed)==='1';x.onchange=()=>{stateSet('feed_'+x.dataset.feed,x.checked?'1':'0');calcFeed()}});function calcFeed(){const n=[...document.querySelectorAll('[data-feed]')].filter(x=>x.checked).length,p=n*10;feedScore.textContent=p+'%';feedAdvice.textContent=p<40?'Stap terug: aanspelen voorspelbaarder en rustiger.':p<70?'Nog onder de richtwaarde van 70%.':p<90?'Goed: passend niveau, probeer reproduceerbaar te blijven.':'Zeer stabiel: voeg gecontroleerd variatie toe.'}resetFeed.onclick=()=>{document.querySelectorAll('[data-feed]').forEach(x=>{x.checked=false;stateSet('feed_'+x.dataset.feed,'0')});calcFeed()};calcFeed();
const quiz=[['4 stappen padelhandeling?','Waarnemen → Beslissen → Uitvoeren → Terugkoppelen.'],['5 hoofdspelsituaties?','Service, Return, Achterspel, Transitie, Netspel.'],['5 bedoelingen?','Scoren, Uitlokken, Opbouwen, Neutraliseren, Voorkomen.'],['GRAS?','Grip, Raakpunt, Armactie, Stand/lichaam.'],['Differentiatieknoppen?','Tempo, Tijd, Ruimte, Voorspelbaarheid, Druk.'],['A1/A2/B?','A1 techniek; A2 waarnemen + kiezen; B spelrealistische transfer.']];let qi=0;function rq(){quizQ.textContent=quiz[qi][0];quizA.textContent=quiz[qi][1];quizA.style.display='none'}rq();showQuiz.onclick=()=>quizA.style.display=quizA.style.display==='none'?'block':'none';nextQuiz.onclick=()=>{qi=(qi+1)%quiz.length;rq()};
document.querySelectorAll('[data-state]').forEach(x=>{x.checked=stateGet(x.dataset.state)==='1';x.onchange=()=>stateSet(x.dataset.state,x.checked?'1':'0')});profileNote.value=stateGet('profileNote');profileNote.oninput=()=>stateSet('profileNote',profileNote.value);
function updateDashboard(){const checks=[...document.querySelectorAll('[data-ass]')],done=checks.filter(x=>x.checked).length,total=checks.length,p=total?Math.round(done/total*100):0;kpiChecks.textContent=done+'/'+total;kpiProgress.textContent=p+'%';progressBar.style.width=p+'%';progressPct.textContent=p+'%';progressPctBar.style.width=p+'%';progressDetail.textContent=`${done} van ${total} opleidingsreminders afgevinkt.`;kpiLessons.textContent=(APP.remote?.lessons||[]).length;kpiObs.textContent=(APP.remote?.observations||[]).length;const red=[...document.querySelectorAll('[data-ex]')].filter(p=>stateGet('exam_'+p.dataset.ex)==='r').length,orange=[...document.querySelectorAll('[data-ex]')].filter(p=>stateGet('exam_'+p.dataset.ex)==='o').length;progressFocus.innerHTML=`<p><b>Examen:</b> ${red} rood · ${orange} oranje</p><p><b>Aangeven:</b> ${feedScore.textContent}</p>`;kpiNext.textContent=red?red:(total-done?1:0)}
makeNext.onclick=()=>{const red=[...document.querySelectorAll('[data-ex]')].find(p=>stateGet('exam_'+p.dataset.ex)==='r');if(red){const i=Number(red.dataset.ex);return nextOutput.textContent='Prioriteit examen: '+examItems[i]+'. Maak hiervoor één concreet SMART-doel en verbind A1 → A2 → B.'}const unchecked=[...document.querySelectorAll('[data-ass]')].find(x=>!x.checked);if(unchecked){const [a,i]=unchecked.dataset.ass.split('_').map(Number);return nextOutput.textContent='Volgende opleidingsfocus: '+assignments[a][0]+' → '+assignments[a][1][i]+'. Noteer observatie, coachinginterventie en bewijs.'}nextOutput.textContent='Basis afgerond. Kies nu een echte speler, maak een observatie en zet die om naar SMART + A1/A2/B.'};
async function loadLists(){if(!APP.session?.token)return;try{const [l,o]=await Promise.all([gas('plListLessons',{token:APP.session.token,limit:10}),gas('plListObservations',{token:APP.session.token,limit:10})]);APP.remote.lessons=l.items||[];APP.remote.observations=o.items||[];save();renderLists();updateDashboard()}catch(e){}}function renderLists(){recentLessons.innerHTML=(APP.remote.lessons||[]).map(x=>`<div class="listitem"><b>${x.situation||''} · ${x.stroke||''}</b> — ${x.playerName||'groep'}<br><span class="muted">${x.smartGoal||''}</span></div>`).join('')||'Nog geen lessen.';recentObs.innerHTML=(APP.remote.observations||[]).map(x=>`<div class="listitem"><b>${x.playerName||'speler'} · ${x.situation||''}</b><br><span class="muted">Prioriteit: ${x.priority||'-'} · NEXT: ${x.nextStep||'-'}</span></div>`).join('')||'Nog geen observaties.'}renderLists();
let saveTimer=null;function scheduleStateSave(){if(!APP.session?.token||!hasGas())return;clearTimeout(saveTimer);saveTimer=setTimeout(async()=>{try{await gas('plSaveUserState',{token:APP.session.token,state:collectState()});APP.lastSync=new Date().toISOString();save()}catch(e){}},800)}
async function bootstrap(reload){if(!APP.session?.token||!hasGas())return;const b=await gas('plGetBootstrap',{token:APP.session.token});if(!b?.authenticated)return;APP.profile=Object.assign(APP.profile,b.profile||{});if(b.settings&&Object.keys(b.settings).length)APP.settings=Object.assign(APP.settings,b.settings);APP.remote.lessons=b.lessons||[];APP.remote.observations=b.observations||[];hydrateState(b.userState||{});APP.lastSync=new Date().toISOString();save();renderLists();if(reload&&!sessionStorage.getItem('pl3hydrated')){sessionStorage.setItem('pl3hydrated','1');location.reload()}updateDashboard()}
runTests.onclick=async()=>{testResults.innerHTML='Testen…';const lines=[];try{const p=await gas('plPing',{});lines.push(`<div class="testline"><span>Backend v${p.version}</span><b class="good">OK</b></div>`);const h=await gas('plHealthCheck',{token:APP.session?.token||''});lines.push(`<div class="testline"><span>Sheet tabs</span><b class="${h.ok?'good':'bad'}">${h.ok?'OK':'FOUT'}</b></div>`);if(APP.session?.token){const marker='test_'+Date.now();await gas('plSaveUserState',{token:APP.session.token,state:Object.assign(collectState(),{testMarker:marker})});const b=await gas('plGetBootstrap',{token:APP.session.token});lines.push(`<div class="testline"><span>Sessie</span><b class="${b.authenticated?'good':'bad'}">${b.authenticated?'OK':'FOUT'}</b></div>`);lines.push(`<div class="testline"><span>Sheet write/read</span><b class="${b.userState?.testMarker===marker?'good':'bad'}">${b.userState?.testMarker===marker?'OK':'FOUT'}</b></div>`)}else lines.push('<div class="testline"><span>Sessie + write/read</span><b class="warn">LOGIN NODIG</b></div>')}catch(e){lines.push(`<div class="bad">${e.message}</div>`)}testResults.innerHTML=lines.join('')};
function backup(){return{version:V,exportedAt:new Date().toISOString(),app:APP,state:collectState()}}function dl(n,t,type){const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([t],{type}));a.download=n;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),500)}function stamp(e){return'PadelLeraar_OS_'+new Date().toISOString().slice(0,10)+'.'+e}exportJson.onclick=()=>dl(stamp('json'),JSON.stringify(backup(),null,2),'application/json');exportTxt.onclick=()=>dl(stamp('txt'),'PadelLeraar OS\n'+JSON.stringify(backup(),null,2),'text/plain');exportCsv.onclick=()=>dl(stamp('csv'),'key,value\n'+Object.entries(collectState()).map(([k,v])=>'"'+k+'","'+String(v).replaceAll('"','""')+'"').join('\n'),'text/csv');exportWhatsapp.onclick=()=>window.open('https://wa.me/?text='+encodeURIComponent(['🎾 PadelLeraar · PadelStudie OS',APP.profile?.name||'',nextOutput.textContent,'https://sites.google.com/view/padelstudie/reminder'].filter(Boolean).join('\n\n')),'_blank');importJson.onclick=()=>{const f=importFile.files[0];if(!f)return ioMessage.textContent='Kies JSON.';const r=new FileReader();r.onload=()=>{try{const b=JSON.parse(r.result);if(b.state)hydrateState(b.state);if(b.app){APP=b.app;save()}ioMessage.textContent='Geïmporteerd. Herlaad de pagina.'}catch(e){ioMessage.textContent='Ongeldige backup.'}};r.readAsText(f)};syncNow.onclick=async()=>{try{if(!APP.session?.token)throw new Error('Log eerst in.');await gas('plSaveUserState',{token:APP.session.token,state:collectState()});await gas('plSyncBackup',{token:APP.session.token,payload:backup()});APP.lastSync=new Date().toISOString();save();ioMessage.textContent='✅ Sync klaar.'}catch(e){ioMessage.textContent=e.message}};
applySettings();updateUserUI();updateDashboard();if(APP.session?.token&&hasGas())bootstrap(false);
</script></body></html>