2026-04-29 13:40:57 +00:00
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 < Math . min ( t , g . days ) ; i ++ ) past += dTot ( g , i ) ;
var tdone = dTot ( g , t ) , tot2 = past + tdone ;
var dl = dr + 1 ;
var remaining = Math . max ( 0 , tot - past ) ;
var pd = Math . ceil ( remaining / Math . max ( 1 , dl ) ) ;
var st = Math . max ( 0 , pd - tdone ) ;
var expectedPast = Math . min ( t , g . days ) * g . daily ;
var buf = ( past - expectedPast ) + Math . max ( 0 , tdone - g . daily ) ;
var deficit = Math . min ( 0 , buf ) ;
var surplus = Math . max ( 0 , buf ) ;
var dailyDelta = pd - g . daily ;
var pct = Math . min ( 100 , Math . round ( ( tot2 / tot ) * 100 ) ) ;
return { tot : tot , tOff : t , end : end , dr : dr , done : tot2 , tdone : tdone , pd : pd , st : st , buf : buf , deficit : deficit , surplus : surplus , dailyDelta : dailyDelta , net : tdone - pd , pct : pct , ok : tdone >= 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' ) ;
2026-04-30 09:08:23 +00:00
var daily = c . querySelector ( '.ng-daily' ) , weekly = c . querySelector ( '.ng-weekly' ) ;
var days = c . querySelector ( '.ng-days' ) , sub = c . querySelector ( '.ng-sub' ) ;
daily . addEventListener ( 'input' , function ( ) {
if ( daily . value ) weekly . value = Math . round ( parseFloat ( daily . value ) * 7 * 100 ) / 100 ;
} ) ;
weekly . addEventListener ( 'input' , function ( ) {
if ( weekly . value ) daily . value = Math . round ( parseFloat ( weekly . value ) / 7 * 100 ) / 100 ;
} ) ;
2026-04-29 13:40:57 +00:00
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' ;
2026-04-30 09:08:23 +00:00
var dv = parseFloat ( daily . value ) || 1 , dyv = parseInt ( days . value , 10 ) || 30 ;
2026-04-29 13:40:57 +00:00
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 ; i < list . length ; i ++ ) {
var inv = list [ i ] ;
var label = inv . note || new Date ( inv . created _at ) . toLocaleDateString ( 'de-DE' , { day : 'numeric' , month : 'short' , year : 'numeric' } ) ;
var detail = inv . used _by _email ? ( '→ ' + inv . used _by _email ) : ( inv . status === 'pending' ? 'läuft ab: ' + new Date ( inv . expires _at ) . toLocaleDateString ( 'de-DE' , { day : 'numeric' , month : 'short' } ) : '' ) ;
var row = tpl ( 'tpl-invite-row' ) ;
row . querySelector ( '.ir-label' ) . textContent = label ;
if ( detail ) row . querySelector ( '.ir-detail' ) . textContent = ' ' + detail ;
var st = row . querySelector ( '.ir-status' ) ;
st . textContent = statusLabel [ inv . status ] ; st . style . color = statusColor [ inv . status ] ;
if ( inv . url ) {
var cp = row . querySelector ( '.ir-copy' ) ; cp . style . display = '' ;
cp . onclick = function ( url ) { return function ( ) { navigator . clipboard . writeText ( url ) . then ( function ( ) { showToast ( 'Link kopiert!' ) ; } ) ; } ; } ( inv . url ) ;
}
body . appendChild ( row ) ;
}
}
showSheet ( lc , true ) ;
lc . querySelector ( '.il-close' ) . onclick = closeOv ;
} ) . catch ( function ( ) { showToast ( 'Fehler beim Laden' ) ; } ) ;
} ;
c . querySelector ( '.dm-exp' ) . onclick = function ( ) {
var blob = new Blob ( [ JSON . stringify ( { goals : goals , at : new Date ( ) . toISOString ( ) } , null , 2 ) ] , { type : 'application/json' } ) ;
var url = URL . createObjectURL ( blob ) , a = document . createElement ( 'a' ) ;
a . href = url ; a . download = 'dudi-backup.json' ; a . click ( ) ; URL . revokeObjectURL ( url ) ; closeOv ( ) ;
} ;
c . querySelector ( '.dm-imp' ) . onclick = function ( ) {
var inp = document . createElement ( 'input' ) ; inp . type = 'file' ; inp . accept = '.json' ;
inp . onchange = function ( e ) {
var f = e . target . files [ 0 ] ; if ( ! f ) return ;
var r = new FileReader ( ) ; r . onload = function ( ev ) {
try {
var p = JSON . parse ( ev . target . result ) ;
if ( ! p . goals || ! Array . isArray ( p . goals ) ) throw new Error ( 'Ungültiges Format' ) ;
if ( ! confirm ( p . goals . length + ' Ziel(e) importieren?' ) ) return ;
var promises = p . goals . map ( function ( g ) {
return api ( 'POST' , 'goals' , { name : g . name , unit : g . unit , daily : g . daily , days : g . days , start : g . start , sets : g . sets || { } } )
. 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 || { } } ) ; } ) ;
} ) ;
Promise . all ( promises ) . then ( function ( ) { closeOv ( ) ; render ( ) ; alert ( p . goals . length + ' Ziel(e) importiert.' ) ; } ) ;
} catch ( err ) { alert ( 'Fehler: ' + err . message ) ; }
} ; r . readAsText ( f ) ;
} ; inp . click ( ) ;
} ;
c . querySelector ( '.dm-clr' ) . onclick = function ( ) {
if ( ! confirm ( 'Alle Daten löschen?' ) ) return ;
var ids = goals . map ( function ( g ) { return g . id ; } ) ; goals = [ ] ; render ( ) ;
Promise . all ( ids . map ( function ( id ) { return api ( 'DELETE' , 'goals/' + id ) ; } ) )
. catch ( function ( ) { showToast ( 'Fehler beim Löschen' ) ; } ) ;
closeOv ( ) ;
} ;
}
// ── Card-Bausteine ────────────────────────────────────────────────────────────
function buildNameWrap ( g ) {
if ( renamingId === g . id ) {
var el = tpl ( 'tpl-name-edit' ) ;
var inp = el . querySelector ( '.ren-input' ) ;
inp . id = 'ri' + g . id ; inp . value = g . name ; inp . dataset . g = g . id ;
return el ;
}
var el = tpl ( 'tpl-name-view' ) ;
el . querySelector ( '.goal-name' ) . textContent = g . name ;
el . querySelector ( '.btn-ren' ) . dataset . g = g . id ;
return el ;
}
function buildPanel ( g , off ) {
var t = tOff ( g ) , sets = g . sets [ String ( off ) ] || [ ] , tot = dTot ( g , off ) ;
var lbl = off === t ? 'Heute' : 'Gestern' , k = g . id + '_' + off ;
var el = tpl ( 'tpl-panel' ) ;
el . querySelector ( '.dpanel-title' ) . textContent = lbl + ' — ' + fd ( o2d ( g , off ) ) ;
el . querySelector ( '.dpanel-sub' ) . textContent = tot + ' / ' + g . daily + ' ' + g . unit ;
var body = el . querySelector ( '.dpanel-body' ) ;
if ( sets . length ) {
for ( var i = 0 ; i < sets . length ; i ++ ) {
var s = sets [ i ] , row = tpl ( 'tpl-set-row' ) , span = row . querySelector ( 'span' ) ;
if ( s . time !== '—' ) {
var st = document . createElement ( 'span' ) ; st . className = 'stime' ; st . textContent = s . time + ' ·' ;
span . appendChild ( st ) ; span . appendChild ( document . createTextNode ( ' ' ) ) ;
}
var strong = document . createElement ( 'strong' ) ; strong . textContent = s . amount ;
span . appendChild ( strong ) ; span . appendChild ( document . createTextNode ( ' ' + g . unit ) ) ;
var btn = row . querySelector ( '.sdel' ) ;
btn . dataset . g = g . id ; btn . dataset . o = off ; btn . dataset . i = i ;
body . appendChild ( row ) ;
}
} else {
body . appendChild ( tpl ( 'tpl-nosets' ) ) ;
}
var addRow = tpl ( 'tpl-add-row' ) ;
var inp = addRow . querySelector ( '.num-in' ) ;
inp . placeholder = g . daily ; inp . value = addAmt [ k ] || '' ; inp . dataset . k = k ; inp . dataset . g = g . id ; inp . dataset . o = off ;
var abtn = addRow . querySelector ( '.btn-as' ) ;
abtn . dataset . g = g . id ; abtn . dataset . o = off ;
addRow . querySelector ( '.ulbl' ) . textContent = g . unit ;
body . appendChild ( addRow ) ;
return el ;
}
function buildCard ( g ) {
var c = calc ( g ) , t = c . tOff ;
var fc = c . surplus > 0 ? '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 ; i < g . days ; i ++ ) {
var it = i === t , iy = i === t - 1 , is = sel === i , ed = editable ( g , i ) ;
var dot = tpl ( 'tpl-dot' ) ;
dot . className = dcls ( g , i ) + ( is ? ' rs' : it ? ' rt' : iy && t > 0 ? ' 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 && ! c . ok ; } ) ;
if ( ! active . length ) return null ;
var frag = document . createDocumentFragment ( ) ;
var lbl = document . createElement ( 'div' ) ; lbl . className = 'sec-lbl' ; lbl . textContent = 'Quick-Buchen' ;
frag . appendChild ( lbl ) ;
var card = document . createElement ( 'div' ) ; card . className = 'card qb-card' ;
for ( var i = 0 ; i < active . length ; i ++ ) {
var g = active [ i ] , c = calc ( g ) , k = g . id + '_' + c . tOff ;
var row = tpl ( 'tpl-qb-row' ) ;
row . querySelector ( '.qb-name' ) . textContent = g . name ;
var stat = row . querySelector ( '.qb-stat' ) ; stat . textContent = c . tdone + '/' + g . daily ; stat . style . color = heuteColor ( c . tdone , g . daily ) ;
var inp = row . querySelector ( '.num-in' ) ;
inp . placeholder = g . daily ; inp . value = addAmt [ k ] || '' ; inp . dataset . k = k ; inp . dataset . g = g . id ; inp . dataset . o = c . tOff ;
var btn = row . querySelector ( '.btn-as' ) ; btn . dataset . g = g . id ; btn . dataset . o = c . tOff ;
card . appendChild ( row ) ;
}
frag . appendChild ( card ) ;
return frag ;
}
// ── Render ────────────────────────────────────────────────────────────────────
function calcAwards ( ) {
var units = 0 ;
for ( var i = 0 ; i < goals . length ; i ++ ) {
var g = goals [ i ] ;
if ( 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 < medals . length ; mi ++ ) {
for ( var ai = 0 ; ai < medals [ mi ] [ 1 ] ; ai ++ ) {
var sp = document . createElement ( 'span' ) ; sp . className = 'aw' ; sp . textContent = medals [ mi ] [ 0 ] ;
awards . appendChild ( sp ) ;
}
}
frag . appendChild ( awards ) ;
}
if ( ! goals . length ) {
frag . appendChild ( tpl ( 'tpl-empty' ) ) ;
m . innerHTML = '' ; m . appendChild ( frag ) ; wire ( ) ; return ;
}
if ( userName ) {
var gr = document . createElement ( 'div' ) ; gr . className = 'greeting' ; gr . textContent = 'Hallo ' + userName + '!' ;
frag . appendChild ( gr ) ;
}
var qb = buildQuickBook ( ) ; if ( qb ) frag . appendChild ( qb ) ;
var open = [ ] , done = [ ] ;
for ( var gi = 0 ; gi < goals . length ; gi ++ ) {
var g = goals [ gi ] , c = calc ( g ) ;
if ( c . ok ) done . push ( g ) ; else open . push ( g ) ;
}
if ( open . length ) {
var sl = document . createElement ( 'div' ) ; sl . className = 'sec-lbl' ; sl . textContent = 'Offen' ;
frag . appendChild ( sl ) ;
for ( var i = 0 ; i < open . length ; i ++ ) frag . appendChild ( buildCard ( open [ i ] ) ) ;
}
if ( done . length ) {
var sl2 = document . createElement ( 'div' ) ; sl2 . className = 'sec-lbl' ; sl2 . textContent = 'Heute erledigt' ;
frag . appendChild ( sl2 ) ;
for ( var j = 0 ; j < done . length ; j ++ ) frag . appendChild ( buildCard ( done [ j ] ) ) ;
}
m . innerHTML = '' ; m . appendChild ( frag ) ; wire ( ) ;
}
function wire ( ) {
document . querySelectorAll ( '.card-hdr[data-g]' ) . forEach ( function ( el ) {
el . onclick = function ( e ) {
if ( e . target . classList . contains ( 'btn-ren' ) || e . target . classList . contains ( 'ren-input' ) ) return ;
toggleCollapse ( this . dataset . g ) ;
} ;
} ) ;
document . querySelectorAll ( '.btn-ren' ) . forEach ( function ( b ) {
b . onclick = function ( e ) { e . stopPropagation ( ) ; startRen ( this . dataset . g ) ; } ;
} ) ;
document . querySelectorAll ( '.ren-input' ) . forEach ( function ( inp ) {
var gid = inp . dataset . g ;
inp . oninput = function ( ) { renameVal = this . value ; } ;
inp . onkeydown = function ( e ) { if ( e . key === 'Enter' ) commitRen ( gid ) ; if ( e . key === 'Escape' ) cancelRen ( ) ; } ;
inp . onblur = function ( ) { commitRen ( gid ) ; } ;
} ) ;
document . querySelectorAll ( '.de' ) . forEach ( function ( d ) {
d . onclick = function ( e ) { e . stopPropagation ( ) ; selD ( this . dataset . g , parseInt ( this . dataset . d , 10 ) ) ; } ;
} ) ;
document . querySelectorAll ( '.btn-as' ) . forEach ( function ( b ) {
b . onclick = function ( ) { addSet ( this . dataset . g , parseInt ( this . dataset . o , 10 ) ) ; } ;
} ) ;
document . querySelectorAll ( '.num-in' ) . forEach ( function ( inp ) {
var k = inp . dataset . k , g = inp . dataset . g , o = parseInt ( inp . dataset . o , 10 ) ;
inp . oninput = function ( ) { addAmt [ k ] = this . value ; } ;
inp . onkeydown = function ( e ) { if ( e . key === 'Enter' ) addSet ( g , o ) ; } ;
} ) ;
document . querySelectorAll ( '.sdel' ) . forEach ( function ( b ) {
b . onclick = function ( ) { remSet ( this . dataset . g , parseInt ( this . dataset . o , 10 ) , parseInt ( this . dataset . i , 10 ) ) ; } ;
} ) ;
document . querySelectorAll ( '.btn-del' ) . forEach ( function ( b ) {
b . onclick = function ( ) { delGoal ( this . dataset . g ) ; } ;
} ) ;
}
// ── Start ─────────────────────────────────────────────────────────────────────
function updateHeader ( ) {
document . getElementById ( 'tlbl' ) . textContent = TODAY . toLocaleDateString ( 'de-DE' , { weekday : 'long' , day : 'numeric' , month : 'long' } ) ;
}
document . getElementById ( 'btnNew' ) . onclick = openNew ;
document . getElementById ( 'btnData' ) . onclick = openData ;
updateHeader ( ) ;
var _qs = new URLSearchParams ( window . location . search ) ;
var inviteToken = _qs . get ( 'invite' ) ;
var resetSelector = _qs . get ( 'reset_selector' ) ;
var resetToken = _qs . get ( 'reset_token' ) ;
if ( inviteToken || resetSelector ) history . replaceState ( null , '' , location . pathname ) ;
if ( resetSelector && resetToken ) {
render ( ) ; showResetPassword ( resetSelector , resetToken ) ;
} else {
api ( 'GET' , 'me' )
. then ( function ( r ) { userName = r . name || '' ; updateHeader ( ) ; return loadGoals ( ) ; } )
. then ( function ( g ) { goals = g ; render ( ) ; } )
. catch ( function ( ) {
render ( ) ;
if ( inviteToken ) { showRegister ( inviteToken ) ; }
else { showLogin ( ) ; }
} ) ;
}
function scheduleMidnight ( ) {
var n = new Date ( ) ;
var ms = new Date ( n . getFullYear ( ) , n . getMonth ( ) , n . getDate ( ) + 1 , 0 , 0 , 5 ) . getTime ( ) - n . getTime ( ) ;
setTimeout ( function ( ) {
TODAY = new Date ( ) ; TODAY . setHours ( 0 , 0 , 0 , 0 ) ; selDay = { } ; collapsed = { } ;
updateHeader ( ) ; render ( ) ; scheduleMidnight ( ) ;
} , ms ) ;
}
scheduleMidnight ( ) ;
document . addEventListener ( 'visibilitychange' , function ( ) {
if ( document . visibilityState === 'visible' ) {
var n = new Date ( ) ; n . setHours ( 0 , 0 , 0 , 0 ) ;
if ( n . getTime ( ) !== TODAY . getTime ( ) ) { TODAY = n ; selDay = { } ; collapsed = { } ; render ( ) ; scheduleMidnight ( ) ; }
loadGoals ( ) . then ( function ( g ) { goals = g ; render ( ) ; } ) . catch ( function ( ) { } ) ;
}
} ) ;
2026-04-30 08:35:29 +00:00
2026-04-30 09:00:54 +00:00
var sw = ( function ( ) {
2026-04-30 08:35:29 +00:00
var swEl = document . getElementById ( 'sw' ) ;
var state = 0 ; // 0=stopped, 1=running, 2=paused
var start = 0 , elapsed = 0 , raf = null ;
2026-04-30 09:00:54 +00:00
function getMs ( ) { return state === 1 ? elapsed + ( Date . now ( ) - start ) : elapsed ; }
function updateFillBtns ( ) {
var show = getMs ( ) >= 1000 ;
document . querySelectorAll ( '.btn-sw-fill' ) . forEach ( function ( b ) { b . style . display = show ? '' : 'none' ; } ) ;
2026-04-30 08:35:29 +00:00
}
2026-04-30 09:00:54 +00:00
function fmt ( ms ) { return ( ms / 1000 ) . toFixed ( 2 ) + 's' ; }
2026-04-30 08:35:29 +00:00
function tick ( ) {
swEl . textContent = fmt ( Date . now ( ) - start + elapsed ) ;
2026-04-30 09:00:54 +00:00
updateFillBtns ( ) ;
2026-04-30 08:35:29 +00:00
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' ) ;
2026-04-30 09:00:54 +00:00
state = 2 ; updateFillBtns ( ) ;
2026-04-30 08:35:29 +00:00
} else {
cancelAnimationFrame ( raf ) ;
elapsed = 0 ; swEl . textContent = '0.00s' ;
swEl . classList . remove ( 'running' ) ;
2026-04-30 09:00:54 +00:00
state = 0 ; updateFillBtns ( ) ;
2026-04-30 08:35:29 +00:00
}
} ) ;
2026-04-30 09:00:54 +00:00
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 } ;
2026-04-30 08:35:29 +00:00
} ) ( ) ;