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

562 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```css
@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**
```bash
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:
```js
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**
```bash
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:
```html
<!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**
```bash
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"
```