dudi/docs/superpowers/plans/2026-04-17-refactor-collapsible.md
Simon Kühn fd473f00af Initial commit: Dudi habit tracker
Symfony 8 SPA with Doctrine ORM, Symfony Security, vanilla JS frontend.
Migrated from plain PHP (delight-im/auth + raw SQL) to full Symfony stack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:40:57 +02:00

29 KiB
Raw Blame History

Zieltracker Refactor & Collapsible Cards Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Split index.html into three files, add collapsible cards with compact meta-line, sort by daily completion status, replace 4-block stats with a "Heute" group, and constrain desktop width to 480px.

Architecture: Pure HTML/CSS/JS — no build step. Logic stays in app.js, styles in style.css, HTML skeleton in index.html. All changes are in-place; localStorage data format is unchanged.

Tech Stack: Vanilla JS (ES5), CSS custom properties, localStorage


File Map

File Action Responsibility
index.html Modify HTML skeleton only — links to style.css + app.js
style.css Create All styles including new classes
app.js Create All logic including new collapse/sort/render

Task 1: Create style.css

Files:

  • Create: style.css

  • Step 1: Create style.css — extract the full <style> block from index.html (lines 12103) and add new classes at the bottom:

@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Mono:wght@400;500&display=swap');
:root{--bg:#f5f4f0;--bg2:#fff;--bg3:#f0eeea;--border:rgba(0,0,0,.07);--border2:rgba(0,0,0,.12);--text:#1a1a1a;--text2:#666;--text3:#aaa;--green:#16a34a;--green-bg:rgba(22,163,74,.08);--blue:#2563eb;--blue-bg:rgba(37,99,235,.08);--amber:#d97706;--amber-bg:rgba(217,119,6,.08);--red:#dc2626;--red-bg:rgba(220,38,38,.08);--r:14px;--rs:8px}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding-bottom:80px}
.main-wrap{max-width:480px;margin:0 auto}
.hdr{position:sticky;top:0;z-index:100;background:rgba(245,244,240,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:16px 20px 14px;display:flex;align-items:center;justify-content:space-between}
.hdr-title{font-size:18px;font-weight:600;letter-spacing:-.3px}
.hdr-sub{font-size:12px;color:var(--text3);margin-top:1px;font-family:'DM Mono',monospace}
.hdr-btns{display:flex;gap:8px;align-items:center}
.btn-add{width:38px;height:38px;border-radius:50%;background:var(--text);color:var(--bg);border:none;cursor:pointer;font-size:22px;display:flex;align-items:center;justify-content:center;font-weight:300;transition:transform .15s}
.btn-add:active{transform:scale(.92)}
.btn-menu{width:38px;height:38px;border-radius:50%;background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-size:20px;display:flex;align-items:center;justify-content:center;transition:transform .15s}
.btn-menu:active{transform:scale(.92)}
.main{padding:16px 16px 0}
.sec-lbl{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px;font-family:'DM Mono',monospace;margin-bottom:6px;margin-top:4px;padding:0 2px}
.empty{text-align:center;padding:60px 20px;color:var(--text3);font-size:14px;line-height:1.8}
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
.card.done{opacity:.7}
.card-hdr{padding:14px 16px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:pointer;user-select:none}
.name-wrap{display:flex;align-items:center;gap:6px}
.goal-name{font-size:16px;font-weight:600;letter-spacing:-.2px}
.btn-ren{background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px;padding:2px 4px;line-height:1;flex-shrink:0}
.btn-ren:active{color:var(--blue)}
.ren-input{font-family:'DM Sans',sans-serif;font-size:16px;font-weight:600;background:var(--bg3);border:1px solid var(--blue);border-radius:6px;color:var(--text);padding:2px 8px;width:100%;letter-spacing:-.2px}
.ren-input:focus{outline:none}
.goal-meta{font-size:11px;color:var(--text3);margin-top:3px;font-family:'DM Mono',monospace;line-height:1.5}
.chevron{font-size:13px;color:var(--text3);margin-left:4px;flex-shrink:0}
.badge{font-size:10px;font-weight:600;padding:3px 8px;border-radius:99px;white-space:nowrap;flex-shrink:0;letter-spacing:.3px;text-transform:uppercase}
.b-ok{background:var(--green-bg);color:var(--green)}.b-done{background:var(--green-bg);color:var(--green)}.b-warn{background:var(--amber-bg);color:var(--amber)}.b-danger{background:var(--red-bg);color:var(--red)}.b-buf{background:var(--blue-bg);color:var(--blue)}
.prog-wrap{padding:0 16px 12px}
.prog-track{height:4px;background:var(--bg3);border-radius:99px;overflow:hidden}
.prog-fill{height:100%;border-radius:99px;transition:width .4s ease}
.prog-row{display:flex;justify-content:space-between;font-size:11px;color:var(--text3);margin-top:5px;font-family:'DM Mono',monospace}
.heute-stats{padding:0 16px 14px}
.heute-group{background:var(--bg3);border-radius:var(--rs);padding:8px 8px 6px}
.heute-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;text-align:center;margin-bottom:6px;font-family:'DM Mono',monospace}
.heute-inner{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px}
.stat{background:var(--bg2);border-radius:6px;padding:7px 6px 5px;text-align:center}
.slbl{font-size:9px;color:var(--text3);margin-bottom:2px}
.sval{font-size:14px;font-weight:600;font-family:'DM Mono',monospace}
.sunit{font-size:8px;color:var(--text3)}
.dots-sec{padding:0 16px 12px}
.dots-lbl{font-size:11px;color:var(--text3);margin-bottom:6px}
.dots-wrap{display:flex;gap:3px;flex-wrap:wrap}
.dot{width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;font-family:'DM Mono',monospace;transition:opacity .15s}
.df{background:var(--bg3);color:var(--text3)}
.dm{background:rgba(220,38,38,.12);color:var(--red)}
.dp{background:rgba(217,119,6,.12);color:var(--amber)}
.dd{background:rgba(22,163,74,.12);color:var(--green)}
.db{background:rgba(37,99,235,.12);color:var(--blue)}
.de{cursor:pointer}.de:active{opacity:.6;transform:scale(.9)}.dl{opacity:.6;cursor:default}
.rt{box-shadow:0 0 0 2px var(--text2)}.ry{box-shadow:0 0 0 2px var(--text3)}.rs{box-shadow:0 0 0 2px var(--blue) !important}
.legend{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
.leg{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text3)}
.ldot{width:8px;height:8px;border-radius:2px}
.dpanel{margin:0 16px 14px;background:var(--bg3);border-radius:var(--rs);overflow:hidden;border:1px solid var(--border2)}
.dpanel-hdr{padding:10px 12px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
.dpanel-title{font-size:13px;font-weight:600}
.dpanel-sub{font-size:11px;color:var(--text2);font-family:'DM Mono',monospace}
.dpanel-body{padding:10px 12px}
.set-row{display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border);font-size:13px}
.set-row:last-of-type{border-bottom:none}
.stime{font-size:10px;color:var(--text3);font-family:'DM Mono',monospace;margin-right:4px}
.sdel{background:none;border:none;color:var(--text3);cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px}
.sdel:active{color:var(--red)}
.add-row{display:flex;gap:8px;align-items:center;margin-top:10px}
.num-in{font-family:'DM Mono',monospace;font-size:15px;padding:8px 10px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg2);color:var(--text);width:90px;text-align:center}
.num-in:focus{outline:none;border-color:var(--blue)}
.ulbl{font-size:12px;color:var(--text3)}
.btn-as{flex:1;padding:8px;border-radius:var(--rs);background:var(--blue-bg);color:var(--blue);border:1px solid rgba(37,99,235,.2);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600}
.btn-as:active{opacity:.7}
.nosets{font-size:12px;color:var(--text3);padding:4px 0 8px}
.card-foot{padding:8px 16px 12px;display:flex;justify-content:flex-end}
.btn-del{background:none;border:none;color:var(--text3);font-size:12px;cursor:pointer;padding:4px 0;font-family:'DM Sans',sans-serif}
.btn-del:active{color:var(--red)}
.ov{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);display:flex;align-items:flex-end;animation:fi .2s ease}
@keyframes fi{from{opacity:0}to{opacity:1}}
.sheet{width:100%;background:var(--bg2);border-radius:20px 20px 0 0;border:1px solid var(--border2);padding:20px 20px 40px;animation:su .25s ease;max-height:90dvh;overflow-y:auto}
@keyframes su{from{transform:translateY(100%)}to{transform:translateY(0)}}
.shandle{width:36px;height:4px;border-radius:2px;background:var(--border2);margin:0 auto 20px}
.stitle{font-size:17px;font-weight:600;margin-bottom:4px}
.ssub{font-size:12px;color:var(--text3);margin-bottom:16px}
.fgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
.ff{margin-bottom:10px}
.ff label{font-size:11px;color:var(--text3);display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
.fi{width:100%;font-family:'DM Sans',sans-serif;font-size:15px;padding:10px 12px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg3);color:var(--text)}
.fi:focus{outline:none;border-color:var(--blue)}
.factions{display:flex;gap:8px;margin-top:16px}
.btn-p{flex:1;padding:13px;border-radius:var(--rs);background:var(--text);color:var(--bg);border:none;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px;font-weight:600}
.btn-p:active{opacity:.8}
.btn-c{padding:13px 20px;border-radius:var(--rs);background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px}
.dbtn{width:100%;padding:13px 16px;border-radius:var(--rs);background:var(--bg3);border:1px solid var(--border);color:var(--text);font-family:'DM Sans',sans-serif;font-size:15px;text-align:left;cursor:pointer;margin-bottom:8px;display:flex;align-items:center;gap:12px}
.dbtn:active{background:var(--border2)}
.dico{font-size:18px;width:24px;text-align:center;flex-shrink:0}
.dlbl{flex:1}.dsub{font-size:11px;color:var(--text3);display:block;margin-top:1px}
.ddanger{color:var(--red)}
.ddiv{height:1px;background:var(--border);margin:12px 0}
.hint{margin:0 16px 12px;background:var(--blue-bg);border:1px solid rgba(37,99,235,.15);border-radius:var(--rs);padding:10px 12px;font-size:12px;color:var(--blue);line-height:1.5;display:flex;justify-content:space-between;align-items:center;gap:8px}
.hclose{background:none;border:none;color:var(--blue);cursor:pointer;font-size:16px;padding:0 2px}
  • Step 2: Verify file exists
ls -la /srv/http/zieltracker/style.css

Expected: file exists, ~4KB


Task 2: Create app.js

Files:

  • Create: app.js

  • Step 1: Create app.js with the full logic — extracted from index.html plus all new functionality:

var TODAY = new Date(); TODAY.setHours(0,0,0,0);
var goals, prefs, selDay={}, addAmt={}, renamingId=null, renameVal='', collapsed={};

function load(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } }
function save(){ localStorage.setItem('zt_g',JSON.stringify(goals)); }
function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); }
goals = load('zt_g','[]');
prefs = load('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'); }

// Returns CSS color for heute: X/Y display
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)';
}

// collapsed defaults to true — only false when explicitly opened
function isCollapsed(id){ return collapsed[id]!==false; }
function toggleCollapse(id){ collapsed[id]=!isCollapsed(id); 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)+'%';
}

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]=''; save(); 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); save(); render();
}
function delGoal(id){
  if(!confirm('Ziel wirklich löschen?')) return;
  goals=goals.filter(function(g){return g.id!==id;}); save(); render();
}
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();
  renamingId=null; save(); render();
}
function cancelRen(){ renamingId=null; render(); }

function closeOv(){ var o=document.getElementById('ov'); o.style.display='none'; o.innerHTML=''; }
function showSheet(html){
  var o=document.getElementById('ov');
  o.style.cssText='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;animation:fi .2s ease';
  o.innerHTML='<div class="sheet">'+html+'</div>';
  o.onclick=function(e){if(e.target===o)closeOv();};
}

function openNew(){
  showSheet(
    '<div class="shandle"></div>'+
    '<div class="stitle">Neues Ziel</div>'+
    '<div class="ff"><label>Übung / Gewohnheit</label><input class="fi" id="fn" type="text" placeholder="Liegestütz, Plank …"/></div>'+
    '<div class="fgrid">'+
    '<div class="ff"><label>Einheit</label><input class="fi" id="fu" type="text" value="Stück"/></div>'+
    '<div class="ff"><label>Tagesziel</label><input class="fi" id="fd" type="number" min="1" value="50"/></div>'+
    '</div>'+
    '<div class="ff"><label>Dauer in Tagen</label><input class="fi" id="fdy" type="number" min="7" max="365" value="30"/></div>'+
    '<div class="factions"><button class="btn-p" id="fsub">Ziel starten</button><button class="btn-c" id="fcan">Abbrechen</button></div>'
  );
  document.getElementById('fn').focus();
  document.getElementById('fsub').onclick=function(){
    var name=(document.getElementById('fn').value||'').trim();
    var unit=(document.getElementById('fu').value||'').trim()||'Stück';
    var daily=parseInt(document.getElementById('fd').value,10)||1;
    var days=parseInt(document.getElementById('fdy').value,10)||30;
    if(!name){document.getElementById('fn').focus();return;}
    goals.push({id:Date.now(),name:name,unit:unit,daily:daily,days:days,start:TODAY.toISOString(),sets:{}});
    save(); closeOv(); render();
  };
  document.getElementById('fcan').onclick=closeOv;
}

function openData(){
  showSheet(
    '<div class="shandle"></div>'+
    '<div class="stitle">Daten verwalten</div>'+
    '<div class="ssub">Export, Import und Backup</div>'+
    '<button class="dbtn" id="dexp"><span class="dico">⬇</span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>'+
    '<button class="dbtn" id="dimp"><span class="dico">⬆</span><span class="dlbl">Importieren<span class="dsub">Backup laden oder zusammenführen</span></span></button>'+
    '<div class="ddiv"></div>'+
    '<button class="dbtn ddanger" id="dclr"><span class="dico">✕</span><span class="dlbl">Alle Daten löschen<span class="dsub">Kann nicht rückgängig gemacht werden</span></span></button>'+
    '<button class="btn-c" id="dcls" style="width:100%;margin-top:4px;text-align:center">Schließen</button>'
  );
  document.getElementById('dcls').onclick=closeOv;
  document.getElementById('dexp').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='zieltracker-backup.json'; a.click(); URL.revokeObjectURL(url); closeOv();
  };
  document.getElementById('dimp').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? Bestehende Daten bleiben erhalten.')) return;
          var ex={}; goals.forEach(function(g){ex[g.id]=1;});
          var add=0; p.goals.forEach(function(g){if(!ex[g.id]){goals.push(g);add++;}});
          save(); closeOv(); render(); alert(add+' Ziel(e) importiert.');
        }catch(err){alert('Fehler: '+err.message);}
      }; r.readAsText(f);
    }; inp.click();
  };
  document.getElementById('dclr').onclick=function(){
    if(!confirm('Alle Daten löschen?')) return;
    goals=[]; save(); closeOv(); render();
  };
}

function panel(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 rows='';
  for(var i=0;i<sets.length;i++){
    var s=sets[i];
    rows+='<div class="set-row"><span>'+(s.time!=='—'?'<span class="stime">'+s.time+' ·</span>':'')+' <strong>'+s.amount+'</strong> '+g.unit+'</span>'+
      '<button class="sdel" data-g="'+g.id+'" data-o="'+off+'" data-i="'+i+'">×</button></div>';
  }
  return '<div class="dpanel">'+
    '<div class="dpanel-hdr"><span class="dpanel-title">'+lbl+' — '+fd(o2d(g,off))+'</span><span class="dpanel-sub">'+tot+' / '+g.daily+' '+g.unit+'</span></div>'+
    '<div class="dpanel-body">'+
      (sets.length?rows:'<div class="nosets">Noch kein Eintrag</div>')+
      '<div class="add-row">'+
        '<input class="num-in" type="number" min="1" placeholder="'+g.daily+'" value="'+(addAmt[k]||'')+'" data-k="'+k+'" data-g="'+g.id+'" data-o="'+off+'"/>'+
        '<span class="ulbl">'+g.unit+'</span>'+
        '<button class="btn-as" data-g="'+g.id+'" data-o="'+off+'">+ Satz</button>'+
      '</div>'+
    '</div></div>';
}

function cardHtml(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;
  if(c.ok&&c.surplus>0){bc='b-buf';bt='+'+c.surplus+' Puffer';}
  else if(c.ok){bc='b-done';bt='Heute ✓';}
  else if(c.dailyDelta<=0){bc='b-ok';bt='Im Plan';}
  else if(c.dailyDelta<=g.daily*.2){bc='b-warn';bt='Leicht mehr';}
  else{bc='b-danger';bt='-'+Math.abs(c.deficit)+' Rückstand';}

  var nh=renamingId===g.id
    ?'<div class="name-wrap"><input class="ren-input" id="ri'+g.id+'" value="'+g.name.replace(/"/g,'&quot;')+'" data-g="'+g.id+'"/></div>'
    :'<div class="name-wrap"><div class="goal-name">'+g.name+'</div><button class="btn-ren" data-g="'+g.id+'">✎</button></div>';

  var doneCls=c.ok?' done':'';

  if(isCollapsed(g.id)){
    var hc=heuteColor(c.tdone,g.daily);
    var metaC='Noch '+c.dr+'T · endet '+fs(c.end)+' · heute: <span style="color:'+hc+'">'+c.tdone+'/'+g.daily+'</span> · total: '+c.done+'/'+c.tot;
    return '<div class="card'+doneCls+'">'+
      '<div class="card-hdr" data-g="'+g.id+'">'+
        '<div style="flex:1;min-width:0">'+nh+'<div class="goal-meta">'+metaC+'</div></div>'+
        '<span class="badge '+bc+'">'+bt+'</span>'+
        '<span class="chevron">▸</span>'+
      '</div>'+
      '<div style="padding:0 16px 12px"><div class="prog-track"><div class="prog-fill" style="width:'+c.pct+'%;background:'+fc+'"></div></div></div>'+
    '</div>';
  }

  // Expanded
  var sel=selDay[g.id];
  var dots='';
  for(var i=0;i<g.days;i++){
    var it=i===t, iy=i===t-1, is=sel===i, ed=editable(g,i);
    var cls=dcls(g,i)+(is?' rs':it?' rt':iy&&t>0?' ry':'');
    dots+='<div class="'+cls+'"'+(ed?' data-g="'+g.id+'" data-d="'+i+'"':'')+'>'+dlbl(g,i)+'</div>';
  }
  var nc=heuteColor(c.tdone,g.daily);
  return '<div class="card'+doneCls+'">'+
    '<div class="card-hdr" data-g="'+g.id+'">'+
      '<div style="flex:1;min-width:0">'+nh+'<div class="goal-meta">Noch '+c.dr+'T · endet '+fs(c.end)+'</div></div>'+
      '<span class="badge '+bc+'">'+bt+'</span>'+
      '<span class="chevron">▴</span>'+
    '</div>'+
    '<div class="prog-wrap"><div class="prog-track"><div class="prog-fill" style="width:'+c.pct+'%;background:'+fc+'"></div></div>'+
    '<div class="prog-row"><span>'+c.done+' '+g.unit+' gemacht</span><span>'+c.pct+'% von '+c.tot+'</span></div></div>'+
    '<div class="heute-stats"><div class="heute-group">'+
      '<div class="heute-lbl">Heute</div>'+
      '<div class="heute-inner">'+
        '<div class="stat"><div class="slbl">Gemacht</div><div class="sval">'+c.tdone+'<div class="sunit">'+g.unit+'</div></div></div>'+
        '<div class="stat"><div class="slbl">Tagesziel</div><div class="sval">'+g.daily+'<div class="sunit">'+g.unit+'</div></div></div>'+
        '<div class="stat"><div class="slbl">Noch</div><div class="sval" style="color:'+nc+'">'+c.st+'<div class="sunit">'+g.unit+'</div></div></div>'+
      '</div>'+
    '</div></div>'+
    '<div class="dots-sec"><div class="dots-lbl">Verlauf — heute &amp; gestern bearbeitbar</div>'+
    '<div class="dots-wrap">'+dots+'</div>'+
    '<div class="legend">'+
      '<span class="leg"><span class="ldot" style="background:rgba(37,99,235,.3)"></span>Puffer</span>'+
      '<span class="leg"><span class="ldot" style="background:rgba(22,163,74,.3)"></span>Erreicht</span>'+
      '<span class="leg"><span class="ldot" style="background:rgba(217,119,6,.3)"></span>Teilweise</span>'+
      '<span class="leg"><span class="ldot" style="background:rgba(220,38,38,.3)"></span>Verpasst</span>'+
    '</div></div>'+
    (sel!=null?panel(g,sel):'')+
    '<div class="card-foot"><button class="btn-del" data-g="'+g.id+'">Ziel löschen</button></div>'+
  '</div>';
}

function render(){
  var m=document.getElementById('main'), h='';
  if(!prefs.hd) h+='<div class="hint">Menü → "Zum Startbildschirm" für App-Icon<button class="hclose" id="hc">×</button></div>';
  if(!goals.length){
    h+='<div class="empty"><div style="font-size:40px;opacity:.4;margin-bottom:12px">🎯</div>Noch keine Ziele.<br>Tippe auf <strong>+</strong> um zu starten.</div>';
    m.innerHTML=h; wire(); return;
  }

  // Sort: open first, done today at bottom
  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){
    h+='<div class="sec-lbl">Offen</div>';
    for(var i=0;i<open.length;i++) h+=cardHtml(open[i]);
  }
  if(done.length){
    h+='<div class="sec-lbl">Heute erledigt</div>';
    for(var j=0;j<done.length;j++) h+=cardHtml(done[j]);
  }

  m.innerHTML=h; wire();
}

function wire(){
  var hc=document.getElementById('hc');
  if(hc) hc.onclick=function(){prefs.hd=1;saveP();this.closest('.hint').remove();};

  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(parseInt(this.dataset.g,10));
    };
  });

  document.querySelectorAll('.btn-ren').forEach(function(b){
    b.onclick=function(e){e.stopPropagation();startRen(parseInt(this.dataset.g,10));};
  });
  document.querySelectorAll('.ren-input').forEach(function(inp){
    var gid=parseInt(inp.dataset.g,10);
    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(parseInt(this.dataset.g,10),parseInt(this.dataset.d,10));};
  });
  document.querySelectorAll('.btn-as').forEach(function(b){
    b.onclick=function(){addSet(parseInt(this.dataset.g,10),parseInt(this.dataset.o,10));};
  });
  document.querySelectorAll('.num-in').forEach(function(inp){
    var k=inp.dataset.k, g=parseInt(inp.dataset.g,10), 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(parseInt(this.dataset.g,10),parseInt(this.dataset.o,10),parseInt(this.dataset.i,10));};
  });
  document.querySelectorAll('.btn-del').forEach(function(b){
    b.onclick=function(){delGoal(parseInt(this.dataset.g,10));};
  });
}

document.getElementById('btnNew').onclick=openNew;
document.getElementById('btnData').onclick=openData;
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
render();

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={};
    document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
    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();}
  }
});
  • Step 2: Verify file exists
ls -la /srv/http/zieltracker/app.js

Expected: file exists, ~8KB


Task 3: Rewrite index.html as clean skeleton

Files:

  • Modify: index.html

  • Step 1: Replace index.html with a clean skeleton that references the new files:

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<meta name="theme-color" content="#f5f4f0"/>
<meta name="mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<title>Zieltracker</title>
<link rel="stylesheet" href="style.css"/>
</head>
<body>
<div class="main-wrap">
  <div class="hdr">
    <div>
      <div class="hdr-title">Zieltracker</div>
      <div class="hdr-sub" id="tlbl"></div>
    </div>
    <div class="hdr-btns">
      <button class="btn-menu" id="btnData"></button>
      <button class="btn-add" id="btnNew">+</button>
    </div>
  </div>
  <div class="main" id="main"></div>
</div>
<div id="ov" style="display:none"></div>
<script src="app.js"></script>
</body>
</html>
  • Step 2: Open in browser and verify

Open http://localhost/zieltracker/ (or wherever it's served).

Check:

  • Page loads without console errors

  • Header and cards are centered on a wide screen (max ~480px content width)

  • Existing goals show up as collapsed with meta-line Noch XT · endet DD. Mon · heute: X/Y · total: X/Y

  • Clicking a card header expands it, showing Heute-Stats group + Dots + Entry panel

  • Clicking again collapses it

  • Goals with ok=true (today's quota met) appear under "Heute erledigt" with reduced opacity

  • Rename pencil still works (click pencil → edit inline)

  • Dot click still opens entry panel

  • + Satz still saves a set

  • Step 3: Commit

cd /srv/http/zieltracker
git add index.html style.css app.js
git commit -m "refactor: split into files, collapsible cards, heute stats, desktop max-width"