var TODAY = new Date(); TODAY.setHours(0,0,0,0); var goals = [], prefs, selDay = {}, addAmt = {}, renamingId = null, renameVal = '', collapsed = {}; var userName = ''; function loadPref(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } } function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); } prefs = loadPref('zt_p','{}'); function tOff(g){ return Math.round((TODAY - new Date(g.start))/86400000); } function o2d(g,i){ var d=new Date(new Date(g.start).getTime()+i*86400000); d.setHours(0,0,0,0); return d; } function dTot(g,o){ return (g.sets[String(o)]||[]).reduce(function(a,b){return a+b.amount;},0); } function fd(d){ return d.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'}); } function fs(d){ return d.toLocaleDateString('de-DE',{day:'numeric',month:'short'}); } function editable(g,o){ var t=tOff(g); return o===t||o===t-1; } function now(){ var n=new Date(); return String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0'); } function heuteColor(tdone,daily){ if(tdone===0) return 'var(--red)'; if(tdone>=daily*1.1) return 'var(--blue)'; if(tdone>=daily) return 'var(--green)'; return 'var(--amber)'; } function isCollapsed(id){ return collapsed[id]!==false; } function toggleCollapse(id){ var wasCollapsed=isCollapsed(id); collapsed[id]=!wasCollapsed; if(wasCollapsed){ var g=goals.filter(function(x){return x.id===id;})[0]; if(g) selDay[id]=tOff(g); } render(); } function calc(g){ var t=tOff(g), tot=g.daily*g.days; var dr=Math.max(0,g.days-t-1); var sd=new Date(g.start); sd.setHours(0,0,0,0); var end=new Date(sd.getTime()+g.days*86400000); var past=0; for(var i=0;i=pd}; } function dcls(g,i){ var t=tOff(g); if(i>t) return 'dot df'; var v=dTot(g,i); var c=v===0?'dot dm':v>=g.daily*1.1?'dot db':v>=g.daily?'dot dd':'dot dp'; return c+(editable(g,i)?' de':' dl'); } function dlbl(g,i){ var t=tOff(g); if(i>t) return String(i+1); var v=dTot(g,i); if(v===0) return '✕'; if(v>=g.daily*1.1) return '+'; if(v>=g.daily) return '✓'; return Math.round(v/g.daily*100)+'%'; } // ── API ────────────────────────────────────────────────────────────────────── function api(method, path, body){ var opts = {method:method, credentials:'include', headers:{'Content-Type':'application/json'}}; if(body) opts.body = JSON.stringify(body); return fetch('api/' + path, opts).then(function(res){ return res.json().then(function(data){ if(!res.ok){ var e=new Error(data.error||'Fehler'); e.status=res.status; throw e; } return data; }); }); } function loadGoals(){ return api('GET','goals').then(function(data){ return data; }); } function saveGoal(g){ api('PATCH','goals/'+g.id,{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets}) .catch(function(e){ if(e.status===401){ showLogin(); } else showToast('Speicherfehler'); }); } // ── Toast ───────────────────────────────────────────────────────────────────── function showToast(msg){ var t=document.createElement('div'); t.className='toast'; t.textContent=msg; document.body.appendChild(t); setTimeout(function(){t.remove();},3000); } // ── Goal-Aktionen ───────────────────────────────────────────────────────────── function addSet(gid,off){ var g=goals.filter(function(x){return x.id===gid;})[0]; if(!g||!editable(g,off)) return; var k=gid+'_'+off, amt=parseInt(addAmt[k]||'0',10); if(amt<=0) return; if(!g.sets[String(off)]) g.sets[String(off)]=[]; g.sets[String(off)].push({amount:amt,time:off===tOff(g)?now():'—'}); addAmt[k]=''; saveGoal(g); render(); } function remSet(gid,off,idx){ var g=goals.filter(function(x){return x.id===gid;})[0]; if(!g||!editable(g,off)) return; g.sets[String(off)].splice(idx,1); saveGoal(g); render(); } function delGoal(id){ if(!confirm('Ziel wirklich löschen?')) return; goals=goals.filter(function(g){return g.id!==id;}); render(); api('DELETE','goals/'+id).catch(function(){ showToast('Fehler beim Löschen'); }); } function selD(gid,off){ var g=goals.filter(function(x){return x.id===gid;})[0]; if(!g||!editable(g,off)) return; selDay[gid]=selDay[gid]===off?null:off; render(); } function startRen(id){ var g=goals.filter(function(x){return x.id===id;})[0]; if(!g) return; renamingId=id; renameVal=g.name; render(); setTimeout(function(){ var el=document.getElementById('ri'+id); if(el){el.focus();el.select();} },50); } function commitRen(id){ var g=goals.filter(function(x){return x.id===id;})[0]; if(g&&renameVal.trim()){g.name=renameVal.trim(); saveGoal(g);} renamingId=null; render(); } function cancelRen(){ renamingId=null; render(); } // ── Template-Helper ─────────────────────────────────────────────────────────── function tpl(id){ return document.getElementById(id).content.cloneNode(true).firstElementChild; } // ── Overlays ────────────────────────────────────────────────────────────────── var OV_CSS='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;justify-content:center;animation:fi .2s ease'; function closeOv(){ var o=document.getElementById('ov'); o.style.display='none'; o.innerHTML=''; } function showSheet(content, dismissable){ var o=document.getElementById('ov'); o.style.cssText=OV_CSS; var sheet=tpl('tpl-sheet'); sheet.appendChild(content); o.innerHTML=''; o.appendChild(sheet); o.onclick=dismissable!==false?function(e){if(e.target===o)closeOv();}:null; } // ── Login ───────────────────────────────────────────────────────────────────── function showLogin(err){ var c=tpl('tpl-login'); if(err){ var e=c.querySelector('.login-err'); e.textContent=err; e.style.display=''; } showSheet(c,false); var email=c.querySelector('.lf-email'), pass=c.querySelector('.lf-pass'), sub=c.querySelector('.lf-sub'); setTimeout(function(){email.focus();},50); email.onkeydown=function(e){if(e.key==='Enter')pass.focus();}; pass.onkeydown=function(e){if(e.key==='Enter')sub.click();}; c.querySelector('.lf-fgt').onclick=function(){showForgotPassword();}; sub.onclick=function(){ var ev=email.value.trim(), pv=pass.value; if(!ev||!pv){ var errEl=c.querySelector('.login-err'); errEl.textContent='Bitte E-Mail und Passwort eingeben'; errEl.style.display=''; return; } sub.disabled=true; sub.textContent='…'; api('POST','login',{email:ev,password:pv}) .then(function(){ return loadGoals(); }) .then(function(g){ goals=g; closeOv(); render(); }) .catch(function(err){ sub.disabled=false; sub.textContent='Anmelden'; showLogin(err.status===401?'Falsche E-Mail oder Passwort':err.status===429?'Zu viele Versuche':'Verbindungsfehler'); }); }; } // ── Passwort vergessen ──────────────────────────────────────────────────────── function showForgotPassword(){ var c=tpl('tpl-forgot-pw'); showSheet(c,false); var email=c.querySelector('.fp-email'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.fp-sub'); setTimeout(function(){email.focus();},50); c.querySelector('.fp-back').onclick=function(){showLogin();}; sub.onclick=function(){ var ev=email.value.trim(); if(!ev) return; sub.disabled=true; sub.textContent='…'; api('POST','reset-request',{email:ev}) .then(function(){ var conf=tpl('tpl-email-sent'); conf.querySelector('.es-ok').onclick=function(){showLogin();}; showSheet(conf,false); }) .catch(function(err){ sub.disabled=false; sub.textContent='Link senden'; errEl.textContent=err.message||'Fehler'; errEl.style.display=''; }); }; } // ── Passwort zurücksetzen ───────────────────────────────────────────────────── function showResetPassword(selector,token){ var c=tpl('tpl-reset-pw'); showSheet(c,false); var pass=c.querySelector('.rp-pass'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.rp-sub'); setTimeout(function(){pass.focus();},50); sub.onclick=function(){ var pv=pass.value; if(!pv) return; sub.disabled=true; sub.textContent='…'; api('POST','reset-password',{selector:selector,token:token,password:pv}) .then(function(){ var conf=tpl('tpl-pw-changed'); conf.querySelector('.pc-ok').onclick=function(){showLogin();}; showSheet(conf,false); }) .catch(function(err){ sub.disabled=false; sub.textContent='Passwort setzen'; errEl.textContent=err.message||'Fehler'; errEl.style.display=''; }); }; } // ── Passwort ändern ─────────────────────────────────────────────────────────── function showChangePassword(){ var c=tpl('tpl-change-pw'); showSheet(c,true); var oldP=c.querySelector('.cp-old'), newP=c.querySelector('.cp-new'), newP2=c.querySelector('.cp-new2'); var errEl=c.querySelector('.login-err'), sub=c.querySelector('.cp-sub'); setTimeout(function(){oldP.focus();},50); c.querySelector('.cp-can').onclick=closeOv; sub.onclick=function(){ var o=oldP.value, n=newP.value, n2=newP2.value; if(!o||!n||!n2) return; if(n!==n2){ errEl.textContent='Die neuen Passwörter stimmen nicht überein'; errEl.style.display=''; return; } sub.disabled=true; sub.textContent='…'; api('POST','change-password',{old_password:o,new_password:n}) .then(function(){ showToast('Passwort geändert'); closeOv(); }) .catch(function(err){ sub.disabled=false; sub.textContent='Ändern'; errEl.textContent=err.message||'Fehler'; errEl.style.display=''; }); }; } // ── Registrierung ───────────────────────────────────────────────────────────── function showRegister(token){ var c=tpl('tpl-register'); showSheet(c,false); var nameInp=c.querySelector('.rg-name'), email=c.querySelector('.rg-email'); var pass=c.querySelector('.rg-pass'), pass2=c.querySelector('.rg-pass2'); var errEl=c.querySelector('.login-err'), sub=c.querySelector('.rg-sub'); setTimeout(function(){nameInp.focus();},50); nameInp.onkeydown=function(e){if(e.key==='Enter')email.focus();}; email.onkeydown=function(e){if(e.key==='Enter')pass.focus();}; pass.onkeydown=function(e){if(e.key==='Enter')pass2.focus();}; pass2.onkeydown=function(e){if(e.key==='Enter')sub.click();}; function checkMatch(){ if(pass2.value&&pass.value!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; } else { errEl.style.display='none'; } } pass.oninput=checkMatch; pass2.oninput=checkMatch; sub.onclick=function(){ var nv=nameInp.value.trim(), ev=email.value.trim(), pv=pass.value; if(!nv||!ev||!pv){ errEl.textContent='Bitte alle Felder ausfüllen'; errEl.style.display=''; return; } if(pv!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; return; } sub.disabled=true; sub.textContent='…'; api('POST','register',{name:nv,email:ev,password:pv,token:token}) .then(function(r){ userName=r.name||''; return loadGoals(); }) .then(function(g){ goals=g; closeOv(); updateHeader(); render(); }) .catch(function(err){ sub.disabled=false; sub.textContent='Registrieren'; errEl.textContent=err.message||'Fehler'; errEl.style.display=''; }); }; } // ── Neues Ziel ──────────────────────────────────────────────────────────────── function openNew(){ var c=tpl('tpl-new-goal'); showSheet(c,true); var name=c.querySelector('.ng-name'), unit=c.querySelector('.ng-unit'); var daily=c.querySelector('.ng-daily'), days=c.querySelector('.ng-days'), sub=c.querySelector('.ng-sub'); setTimeout(function(){name.focus();},50); c.querySelector('.ng-can').onclick=closeOv; sub.onclick=function(){ var nv=(name.value||'').trim(), uv=(unit.value||'').trim()||'Stück'; var dv=parseInt(daily.value,10)||1, dyv=parseInt(days.value,10)||30; if(!nv){ name.focus(); return; } sub.disabled=true; api('POST','goals',{name:nv,unit:uv,daily:dv,days:dyv,start:TODAY.toISOString()}) .then(function(r){ goals.push({id:r.id,name:r.name,unit:r.unit,daily:r.daily,days:r.days,start:r.start,sets:r.sets||{}}); closeOv(); render(); }).catch(function(e){ sub.disabled=false; if(e.status===401){ closeOv(); showLogin(); } else showToast('Fehler beim Erstellen'); }); }; } // ── Daten-Menü ──────────────────────────────────────────────────────────────── function openData(){ var c=tpl('tpl-data-menu'); showSheet(c,true); c.querySelector('.dm-cls').onclick=closeOv; c.querySelector('.dm-name').onclick=function(){ var nc=tpl('tpl-change-name'); showSheet(nc,true); var inp=nc.querySelector('.cn-name'), errEl=nc.querySelector('.login-err'), sub=nc.querySelector('.cn-sub'); inp.value=userName; setTimeout(function(){inp.focus();inp.select();},50); nc.querySelector('.cn-can').onclick=closeOv; sub.onclick=function(){ var nv=inp.value.trim(); if(!nv){ errEl.textContent='Name darf nicht leer sein'; errEl.style.display=''; return; } sub.disabled=true; sub.textContent='…'; api('PATCH','me',{name:nv}) .then(function(r){ userName=r.name; closeOv(); render(); showToast('Name gespeichert'); }) .catch(function(){ sub.disabled=false; sub.textContent='Speichern'; showToast('Fehler beim Speichern'); }); }; }; c.querySelector('.dm-cpw').onclick=function(){ closeOv(); showChangePassword(); }; c.querySelector('.dm-lgout').onclick=function(){ api('POST','logout').then(function(){ goals=[]; closeOv(); render(); showLogin(); }); }; c.querySelector('.dm-inv').onclick=function(){ var ic=tpl('tpl-invite-form'); showSheet(ic,true); var invName=ic.querySelector('.inv-name'); setTimeout(function(){invName.focus();},50); ic.querySelector('.inv-cancel').onclick=closeOv; ic.querySelector('.inv-gen').onclick=function(){ var note=invName.value.trim(), btn=this; btn.disabled=true; btn.textContent='…'; api('POST','invite',{note:note}).then(function(res){ var lc=tpl('tpl-invite-link'); lc.querySelector('.stitle').textContent='Einladungslink'+(note?' für '+note:''); var urlInp=lc.querySelector('.il-url'); urlInp.value=res.url; showSheet(lc,true); lc.querySelector('.il-close').onclick=closeOv; lc.querySelector('.il-copy').onclick=function(){ navigator.clipboard.writeText(res.url).then(function(){ showToast('Link kopiert!'); closeOv(); }); }; setTimeout(function(){urlInp.select();},50); }).catch(function(){ btn.disabled=false; btn.textContent='Link generieren'; showToast('Fehler beim Generieren'); }); }; }; c.querySelector('.dm-invlist').onclick=function(){ api('GET','invites').then(function(list){ var statusLabel={'pending':'Ausstehend','used':'Angenommen','expired':'Abgelaufen'}; var statusColor={'pending':'var(--amber)','used':'var(--green)','expired':'var(--red)'}; var lc=tpl('tpl-invite-list'); var body=lc.querySelector('.dpanel-body'); if(!list.length){ var empty=document.createElement('div'); empty.className='nosets'; empty.style.padding='16px'; empty.textContent='Noch keine Einladungen verschickt'; body.appendChild(empty); } else { for(var i=0;i0?'var(--blue)':c.st===0?'var(--green)':c.dailyDelta<=0?'var(--green)':c.dailyDelta<=g.daily*.2?'var(--amber)':'var(--red)'; var bc,bt,bufStr=(c.buf>0?'+':'')+c.buf; if(c.ok&&c.surplus>0){bc='b-buf';bt=bufStr;} else if(c.ok){bc='b-done';bt=bufStr;} else if(c.dailyDelta<=0){bc='b-ok';bt=bufStr;} else if(c.dailyDelta<=g.daily*.2){bc='b-warn';bt=bufStr;} else{bc='b-danger';bt=bufStr;} var el; if(isCollapsed(g.id)){ el=tpl('tpl-card-collapsed'); if(c.ok) el.classList.add('done'); el.querySelector('.card-hdr').dataset.g=g.id; var bd=el.querySelector('.card-bd'); bd.insertBefore(buildNameWrap(g),bd.firstElementChild); var hc=heuteColor(c.tdone,g.daily); el.querySelector('.m-dr').textContent=c.dr; el.querySelector('.m-end').textContent=fs(c.end); var mH=el.querySelector('.m-heute'); mH.textContent=c.tdone+'/'+g.daily; mH.style.color=hc; el.querySelector('.m-total').textContent=c.done+'/'+c.tot; var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt; var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc; return el; } el=tpl('tpl-card-expanded'); if(c.ok) el.classList.add('done'); el.querySelector('.card-hdr').dataset.g=g.id; var bd=el.querySelector('.card-bd'); bd.insertBefore(buildNameWrap(g),bd.firstElementChild); el.querySelector('.m-dr').textContent=c.dr; el.querySelector('.m-end').textContent=fs(c.end); var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt; var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc; el.querySelector('.pr-done').textContent=c.done+' '+g.unit+' gemacht'; el.querySelector('.pr-pct').textContent=c.pct+'% von '+c.tot; el.querySelector('.sv-tdone').textContent=c.tdone; el.querySelector('.sv-daily').textContent=g.daily; el.querySelector('.sv-st').textContent=c.st; el.querySelector('.sv-noch').style.color=heuteColor(c.tdone,g.daily); el.querySelectorAll('.sunit').forEach(function(u){ u.textContent=g.unit; }); var sel=selDay[g.id]!=null?selDay[g.id]:t; var dotsWrap=el.querySelector('.dots-wrap'); for(var i=0;i0?' ry':''); if(ed){ dot.dataset.g=g.id; dot.dataset.d=i; } dot.textContent=dlbl(g,i); dotsWrap.appendChild(dot); } if(sel!=null) el.insertBefore(buildPanel(g,sel),el.querySelector('.card-foot')); el.querySelector('.btn-del').dataset.g=g.id; return el; } // ── Quick-Buchen ────────────────────────────────────────────────────────────── function buildQuickBook(){ var active=goals.filter(function(g){ var c=calc(g); return tOff(g)=g.days) units+=Math.floor(g.days/30); } var gold=Math.floor(units/25); units%=25; var silver=Math.floor(units/5); var bronze=units%5; return{gold:gold,silver:silver,bronze:bronze}; } function render(){ var m=document.getElementById('main'); var frag=document.createDocumentFragment(); if(!prefs.hd){ var hint=tpl('tpl-hint'); hint.querySelector('.hclose').onclick=function(){ prefs.hd=1; saveP(); hint.remove(); }; frag.appendChild(hint); } var aw=calcAwards(); if(aw.gold||aw.silver||aw.bronze){ var awards=document.createElement('div'); awards.className='awards'; var medals=[['🥇',aw.gold],['🥈',aw.silver],['🥉',aw.bronze]]; for(var mi=0;mi= 1000; document.querySelectorAll('.btn-sw-fill').forEach(function(b){ b.style.display = show ? '' : 'none'; }); } function fmt(ms){ return (ms / 1000).toFixed(2) + 's'; } function tick(){ swEl.textContent = fmt(Date.now() - start + elapsed); updateFillBtns(); raf = requestAnimationFrame(tick); } swEl.addEventListener('click', function(){ if(state === 0){ start = Date.now(); elapsed = 0; swEl.classList.add('running'); state = 1; tick(); } else if(state === 1){ cancelAnimationFrame(raf); elapsed += Date.now() - start; swEl.textContent = fmt(elapsed); swEl.classList.remove('running'); state = 2; updateFillBtns(); } else { cancelAnimationFrame(raf); elapsed = 0; swEl.textContent = '0.00s'; swEl.classList.remove('running'); state = 0; updateFillBtns(); } }); document.addEventListener('click', function(e){ if(!e.target.classList.contains('btn-sw-fill')) return; var inp = e.target.closest('.add-row, .qb-row').querySelector('.num-in'); if(inp) inp.value = Math.floor(getMs() / 1000); }); return { getMs: getMs }; })();